main
mula.liu 2026-03-10 17:18:17 +08:00
parent 415d0078fb
commit 78b27d9c36
5 changed files with 114 additions and 30 deletions

View File

@ -55,6 +55,7 @@ os.makedirs(DATA_ROOT, exist_ok=True)
docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT) docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT)
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT) config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
class ChannelConfigRequest(BaseModel): class ChannelConfigRequest(BaseModel):
@ -1567,6 +1568,14 @@ async def test_provider(payload: dict):
@app.post("/api/bots") @app.post("/api/bots")
def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)): def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)):
normalized_bot_id = str(payload.id or "").strip()
if not normalized_bot_id:
raise HTTPException(status_code=400, detail="Bot ID is required")
if not BOT_ID_PATTERN.fullmatch(normalized_bot_id):
raise HTTPException(status_code=400, detail="Bot ID can only contain letters, numbers, and underscores")
if session.get(BotInstance, normalized_bot_id):
raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}")
image_row = session.get(NanobotImage, payload.image_tag) image_row = session.get(NanobotImage, payload.image_tag)
if not image_row: if not image_row:
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {payload.image_tag}") raise HTTPException(status_code=400, detail=f"Image not registered in DB: {payload.image_tag}")
@ -1576,22 +1585,22 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}") raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}")
bot = BotInstance( bot = BotInstance(
id=payload.id, id=normalized_bot_id,
name=payload.name, name=payload.name,
access_password=str(payload.access_password or ""), access_password=str(payload.access_password or ""),
image_tag=payload.image_tag, image_tag=payload.image_tag,
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, payload.id), workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
) )
session.add(bot) session.add(bot)
session.commit() session.commit()
session.refresh(bot) session.refresh(bot)
resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb) resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
_write_env_store(payload.id, _normalize_env_params(payload.env_params)) _write_env_store(normalized_bot_id, _normalize_env_params(payload.env_params))
_sync_workspace_channels( _sync_workspace_channels(
session, session,
payload.id, normalized_bot_id,
channels_override=_normalize_initial_channels(payload.id, payload.channels), channels_override=_normalize_initial_channels(normalized_bot_id, payload.channels),
global_delivery_override={ global_delivery_override={
"sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False,
"sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False,
@ -1618,7 +1627,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
}, },
) )
session.refresh(bot) session.refresh(bot)
_invalidate_bot_detail_cache(payload.id) _invalidate_bot_detail_cache(normalized_bot_id)
return _serialize_bot(bot) return _serialize_bot(bot)

View File

@ -21,9 +21,13 @@ export const wizardEn = {
loadImages: 'Load images', loadImages: 'Load images',
noReady: 'No READY image.', noReady: 'No READY image.',
baseInfo: 'Base Info', baseInfo: 'Base Info',
accessPassword: 'Access Password',
accessPasswordPlaceholder: 'Access password (optional)',
botIdPlaceholder: 'Bot ID', botIdPlaceholder: 'Bot ID',
botIdHint: 'Only letters, numbers, and underscores are allowed.',
botIdInvalid: 'Bot ID can only contain letters, numbers, and underscores.',
botIdChecking: 'Checking whether the Bot ID is available...',
botIdAvailable: 'Bot ID is available.',
botIdExists: 'This Bot ID already exists. Please choose another one.',
botIdRequired: 'Please enter a Bot ID.',
botName: 'Bot Name', botName: 'Bot Name',
modelAccess: 'Model Access', modelAccess: 'Model Access',
modelNamePlaceholder: 'Model name', modelNamePlaceholder: 'Model name',

View File

@ -21,9 +21,13 @@ export const wizardZhCn = {
loadImages: '加载镜像列表', loadImages: '加载镜像列表',
noReady: '暂无 READY 镜像。', noReady: '暂无 READY 镜像。',
baseInfo: '基础信息', baseInfo: '基础信息',
accessPassword: '访问密码',
accessPasswordPlaceholder: '访问密码(可选)',
botIdPlaceholder: 'Bot ID如 analyst_bot_01', botIdPlaceholder: 'Bot ID如 analyst_bot_01',
botIdHint: '只能输入英文字母、数字和下划线。',
botIdInvalid: 'Bot ID 只能包含英文字母、数字和下划线。',
botIdChecking: '正在检查 Bot ID 是否可用...',
botIdAvailable: 'Bot ID 可用。',
botIdExists: '该 Bot ID 已存在,请更换。',
botIdRequired: '请填写 Bot ID。',
botName: 'Bot 名称', botName: 'Bot 名称',
modelAccess: '模型接入', modelAccess: '模型接入',
modelNamePlaceholder: '模型名(如 qwen-plus', modelNamePlaceholder: '模型名(如 qwen-plus',

View File

@ -321,14 +321,14 @@
} }
.ops-bot-actions .ops-bot-action-stop { .ops-bot-actions .ops-bot-action-stop {
background: #0b1220; background: color-mix(in oklab, #f5af48 30%, var(--panel-soft) 70%);
border-color: color-mix(in oklab, #0b1220 72%, var(--line) 28%); border-color: color-mix(in oklab, #f5af48 58%, var(--line) 42%);
color: #fff; color: #5e3b00;
} }
.ops-bot-actions .ops-bot-action-stop:hover { .ops-bot-actions .ops-bot-action-stop:hover {
background: color-mix(in oklab, #0b1220 84%, #1f2937 16%); background: color-mix(in oklab, #f5af48 38%, var(--panel-soft) 62%);
border-color: color-mix(in oklab, #0b1220 82%, white 18%); border-color: color-mix(in oklab, #f5af48 70%, var(--line) 30%);
} }
.ops-bot-actions .ops-bot-action-delete { .ops-bot-actions .ops-bot-action-delete {
@ -1088,9 +1088,10 @@
width: 34px; width: 34px;
min-width: 34px; min-width: 34px;
padding: 0; padding: 0;
background: color-mix(in oklab, #d14b4b 20%, var(--panel) 80%); background: #0b1220;
color: color-mix(in oklab, var(--text) 86%, white 14%); color: #fff;
border: 1px solid color-mix(in oklab, #d14b4b 50%, var(--line) 50%); border: 1px solid color-mix(in oklab, #0b1220 72%, var(--line) 28%);
box-shadow: 0 8px 18px rgba(9, 15, 28, 0.22);
} }
.ops-composer-submit-btn:hover:not(:disabled) { .ops-composer-submit-btn:hover:not(:disabled) {

View File

@ -10,7 +10,6 @@ import { wizardZhCn } from '../../i18n/wizard.zh-cn';
import { wizardEn } from '../../i18n/wizard.en'; import { wizardEn } from '../../i18n/wizard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider'; import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton'; import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { setBotAccessPassword } from '../../utils/botAccess';
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
@ -97,7 +96,6 @@ const providerPresets: Record<string, { model: string; note: { 'zh-cn': string;
const initialForm = { const initialForm = {
id: '', id: '',
name: '', name: '',
access_password: '',
llm_provider: 'dashscope', llm_provider: 'dashscope',
llm_model: providerPresets.dashscope.model, llm_model: providerPresets.dashscope.model,
api_key: '', api_key: '',
@ -153,6 +151,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const [cpuCoresDraft, setCpuCoresDraft] = useState(String(initialForm.cpu_cores)); const [cpuCoresDraft, setCpuCoresDraft] = useState(String(initialForm.cpu_cores));
const [memoryMbDraft, setMemoryMbDraft] = useState(String(initialForm.memory_mb)); const [memoryMbDraft, setMemoryMbDraft] = useState(String(initialForm.memory_mb));
const [storageGbDraft, setStorageGbDraft] = useState(String(initialForm.storage_gb)); const [storageGbDraft, setStorageGbDraft] = useState(String(initialForm.storage_gb));
const [botIdStatus, setBotIdStatus] = useState<'idle' | 'checking' | 'available' | 'exists' | 'invalid'>('idle');
const [botIdStatusText, setBotIdStatusText] = useState('');
const readyImages = useMemo(() => images.filter((img) => img.status === 'READY'), [images]); const readyImages = useMemo(() => images.filter((img) => img.status === 'READY'), [images]);
const isZh = locale === 'zh'; const isZh = locale === 'zh';
@ -195,6 +195,40 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
}; };
void loadSystemDefaults(); void loadSystemDefaults();
}, []); }, []);
useEffect(() => {
const raw = String(form.id || '').trim();
if (!raw) {
setBotIdStatus('idle');
setBotIdStatusText('');
return;
}
if (!/^[A-Za-z0-9_]+$/.test(raw)) {
setBotIdStatus('invalid');
setBotIdStatusText(ui.botIdInvalid);
return;
}
setBotIdStatus('checking');
setBotIdStatusText(ui.botIdChecking);
const timer = window.setTimeout(async () => {
try {
await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(raw)}`);
setBotIdStatus('exists');
setBotIdStatusText(ui.botIdExists);
} catch (error: any) {
if (error?.response?.status === 404) {
setBotIdStatus('available');
setBotIdStatusText(ui.botIdAvailable);
return;
}
setBotIdStatus('idle');
setBotIdStatusText('');
}
}, 300);
return () => window.clearTimeout(timer);
}, [form.id, ui.botIdAvailable, ui.botIdChecking, ui.botIdExists, ui.botIdInvalid]);
const configuredChannelsLabel = useMemo( const configuredChannelsLabel = useMemo(
() => (form.channels.length > 0 ? form.channels.map((c) => c.channel_type).join(', ') : '-'), () => (form.channels.length > 0 ? form.channels.map((c) => c.channel_type).join(', ') : '-'),
[form.channels], [form.channels],
@ -229,7 +263,23 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
commitCpuCoresDraft(cpuCoresDraft); commitCpuCoresDraft(cpuCoresDraft);
commitMemoryMbDraft(memoryMbDraft); commitMemoryMbDraft(memoryMbDraft);
commitStorageGbDraft(storageGbDraft); commitStorageGbDraft(storageGbDraft);
if (!form.id || !form.name || !form.api_key || !form.image_tag || !form.llm_model) { if (!form.id) {
notify(ui.botIdRequired, { tone: 'warning' });
return;
}
if (botIdStatus === 'invalid') {
notify(ui.botIdInvalid, { tone: 'warning' });
return;
}
if (botIdStatus === 'exists') {
notify(ui.botIdExists, { tone: 'warning' });
return;
}
if (botIdStatus === 'checking') {
notify(ui.botIdChecking, { tone: 'warning' });
return;
}
if (!form.name || !form.api_key || !form.image_tag || !form.llm_model) {
notify(ui.requiredBase, { tone: 'warning' }); notify(ui.requiredBase, { tone: 'warning' });
return; return;
} }
@ -276,7 +326,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, { await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, {
id: form.id, id: form.id,
name: form.name, name: form.name,
access_password: form.access_password,
llm_provider: form.llm_provider, llm_provider: form.llm_provider,
llm_model: form.llm_model, llm_model: form.llm_model,
api_key: form.api_key, api_key: form.api_key,
@ -307,10 +356,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
env_params: form.env_params, env_params: form.env_params,
}); });
if (String(form.access_password || '').trim()) {
setBotAccessPassword(form.id, form.access_password);
}
if (autoStart) { if (autoStart) {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`); await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`);
} }
@ -323,6 +368,8 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
setStorageGbDraft(String(initialForm.storage_gb)); setStorageGbDraft(String(initialForm.storage_gb));
setStep(1); setStep(1);
setTestResult(''); setTestResult('');
setBotIdStatus('idle');
setBotIdStatusText('');
notify(ui.created, { tone: 'success' }); notify(ui.created, { tone: 'success' });
} catch (error: any) { } catch (error: any) {
const msg = error?.response?.data?.detail || ui.createFailed; const msg = error?.response?.data?.detail || ui.createFailed;
@ -594,9 +641,29 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<div className="grid-2 wizard-step2-grid wizard-step2-grid-3" style={{ gridTemplateColumns: '1fr 1fr 1fr' }}> <div className="grid-2 wizard-step2-grid wizard-step2-grid-3" style={{ gridTemplateColumns: '1fr 1fr 1fr' }}>
<div className="stack card wizard-step2-card"> <div className="stack card wizard-step2-card">
<div className="section-mini-title">{ui.baseInfo}</div> <div className="section-mini-title">{ui.baseInfo}</div>
<input className="input" placeholder={ui.botIdPlaceholder} value={form.id} onChange={(e) => setForm((p) => ({ ...p, id: e.target.value }))} /> <input
className="input"
placeholder={ui.botIdPlaceholder}
value={form.id}
onChange={(e) => {
const normalized = e.target.value.replace(/[^A-Za-z0-9_]/g, '');
setForm((p) => ({ ...p, id: normalized }));
}}
/>
<div
className="field-label"
style={{
color:
botIdStatus === 'invalid' || botIdStatus === 'exists'
? 'var(--err)'
: botIdStatus === 'available'
? 'var(--ok)'
: 'var(--muted)',
}}
>
{botIdStatusText || ui.botIdHint}
</div>
<input className="input" placeholder={ui.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} /> <input className="input" placeholder={ui.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
<input className="input" type="password" placeholder={ui.accessPasswordPlaceholder} value={form.access_password} onChange={(e) => setForm((p) => ({ ...p, access_password: e.target.value }))} />
<div className="section-mini-title" style={{ marginTop: 10 }}> <div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '资源配额' : 'Resource Limits'} {isZh ? '资源配额' : 'Resource Limits'}
@ -772,7 +839,6 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<div>{ui.image}: <span className="mono">{form.image_tag}</span></div> <div>{ui.image}: <span className="mono">{form.image_tag}</span></div>
<div>Bot ID: <span className="mono">{form.id}</span></div> <div>Bot ID: <span className="mono">{form.id}</span></div>
<div>{ui.name}: {form.name}</div> <div>{ui.name}: {form.name}</div>
<div>{ui.accessPassword}: {form.access_password ? (isZh ? '已设置' : 'Configured') : (isZh ? '未设置' : 'Not set')}</div>
<div>Provider: {form.llm_provider}</div> <div>Provider: {form.llm_provider}</div>
<div>{ui.model}: {form.llm_model}</div> <div>{ui.model}: {form.llm_model}</div>
<div>Temperature: {form.temperature.toFixed(2)}</div> <div>Temperature: {form.temperature.toFixed(2)}</div>