dashboard-nanobot/frontend/src/modules/onboarding/BotWizardModule.tsx

962 lines
38 KiB
TypeScript
Raw Normal View History

2026-03-01 16:26:03 +00:00
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { Eye, EyeOff, Plus, Settings2, Trash2 } from 'lucide-react';
import { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore';
import { channelsZhCn } from '../../i18n/channels.zh-cn';
import { channelsEn } from '../../i18n/channels.en';
import { pickLocale } from '../../i18n';
import { wizardZhCn } from '../../i18n/wizard.zh-cn';
import { wizardEn } from '../../i18n/wizard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
const FALLBACK_SOUL_MD = '# Soul\n\n你是专业的企业数字员工表达清晰、可执行。';
const FALLBACK_AGENTS_MD = '# Agent Instructions\n\n- 优先完成任务目标\n- 操作前先说明意图\n- 输出必须可执行\n\n## 默认输出规范\n\n- 每次执行任务时,在 workspace 中创建新目录保存本次输出。\n- 输出内容默认采用 Markdown.md格式。';
const FALLBACK_USER_MD = '# User\n\n- 语言: 中文\n- 风格: 专业\n- 偏好: 简明且有步骤';
const FALLBACK_TOOLS_MD = '# Tools\n\n- 谨慎使用 shell\n- 修改文件后复核\n- 失败时说明原因并重试策略';
const FALLBACK_IDENTITY_MD = '# Identity\n\n- 角色: 企业数字员工\n- 领域: 运维与任务执行';
interface WizardChannelConfig {
channel_type: ChannelType;
is_active: boolean;
external_app_id: string;
app_secret: string;
internal_port: number;
extra_config: Record<string, unknown>;
}
interface NanobotImage {
tag: string;
status: string;
}
interface SystemDefaultsResponse {
templates?: {
soul_md?: string;
agents_md?: string;
user_md?: string;
tools_md?: string;
identity_md?: string;
};
}
const providerPresets: Record<string, { model: string; note: { 'zh-cn': string; en: string }; apiBase?: string }> = {
openrouter: {
model: 'openai/gpt-4o-mini',
note: {
'zh-cn': 'OpenRouter 网关,模型名示例 openai/gpt-4o-mini。',
en: 'OpenRouter gateway, model example: openai/gpt-4o-mini.',
},
apiBase: 'https://openrouter.ai/api/v1',
},
dashscope: {
model: 'qwen-plus',
note: {
'zh-cn': '阿里云 DashScope千问模型示例 qwen-plus / qwen-max。',
en: 'Alibaba DashScope (Qwen), model example: qwen-plus / qwen-max.',
},
apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
openai: {
model: 'gpt-4o-mini',
note: {
'zh-cn': 'OpenAI 原生模型。',
en: 'OpenAI native models.',
},
},
deepseek: {
model: 'deepseek-chat',
note: {
'zh-cn': 'DeepSeek 原生模型。',
en: 'DeepSeek native models.',
},
},
kimi: {
model: 'moonshot-v1-8k',
note: {
'zh-cn': 'KimiMoonshot接口模型示例 moonshot-v1-8k。',
en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k.',
},
apiBase: 'https://api.moonshot.cn/v1',
},
minimax: {
model: 'MiniMax-Text-01',
note: {
'zh-cn': 'MiniMax 接口,模型示例 MiniMax-Text-01。',
en: 'MiniMax endpoint, model example: MiniMax-Text-01.',
},
apiBase: 'https://api.minimax.chat/v1',
},
};
const initialForm = {
id: '',
name: '',
llm_provider: 'dashscope',
llm_model: providerPresets.dashscope.model,
api_key: '',
api_base: providerPresets.dashscope.apiBase ?? '',
image_tag: '',
temperature: 0.2,
top_p: 1.0,
max_tokens: 8192,
2026-03-03 06:09:11 +00:00
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
2026-03-01 16:26:03 +00:00
soul_md: FALLBACK_SOUL_MD,
agents_md: FALLBACK_AGENTS_MD,
user_md: FALLBACK_USER_MD,
tools_md: FALLBACK_TOOLS_MD,
identity_md: FALLBACK_IDENTITY_MD,
env_params: {} as Record<string, string>,
send_progress: false,
send_tool_hints: false,
channels: [] as WizardChannelConfig[],
};
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack'];
interface BotWizardModuleProps {
onCreated?: () => void;
onGoDashboard?: () => void;
}
export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModuleProps) {
const { locale } = useAppStore();
const { notify } = useLucentPrompt();
const [step, setStep] = useState(1);
const [images, setImages] = useState<NanobotImage[]>([]);
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [autoStart, setAutoStart] = useState(true);
const [isTestingProvider, setIsTestingProvider] = useState(false);
const [testResult, setTestResult] = useState('');
const [agentTab, setAgentTab] = useState<AgentTab>('AGENTS');
const [showChannelModal, setShowChannelModal] = useState(false);
const [showToolsConfigModal, setShowToolsConfigModal] = useState(false);
const [envDraftKey, setEnvDraftKey] = useState('');
const [envDraftValue, setEnvDraftValue] = useState('');
const [envDraftVisible, setEnvDraftVisible] = useState(false);
const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
const [form, setForm] = useState(initialForm);
const [defaultAgentsTemplate, setDefaultAgentsTemplate] = useState(FALLBACK_AGENTS_MD);
2026-03-03 06:09:11 +00:00
const [maxTokensDraft, setMaxTokensDraft] = useState(String(initialForm.max_tokens));
const [cpuCoresDraft, setCpuCoresDraft] = useState(String(initialForm.cpu_cores));
const [memoryMbDraft, setMemoryMbDraft] = useState(String(initialForm.memory_mb));
const [storageGbDraft, setStorageGbDraft] = useState(String(initialForm.storage_gb));
2026-03-01 16:26:03 +00:00
const readyImages = useMemo(() => images.filter((img) => img.status === 'READY'), [images]);
const isZh = locale === 'zh';
const ui = pickLocale(locale, { 'zh-cn': wizardZhCn, en: wizardEn });
const lc = isZh ? channelsZhCn : channelsEn;
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const activeChannelTypes = useMemo(() => new Set(form.channels.map((c) => c.channel_type)), [form.channels]);
const addableChannelTypes = useMemo(
() => optionalChannelTypes.filter((t) => !activeChannelTypes.has(t)),
[activeChannelTypes],
);
const envEntries = useMemo(
() =>
Object.entries(form.env_params || {})
.filter(([k]) => String(k || '').trim().length > 0)
.sort(([a], [b]) => a.localeCompare(b)),
[form.env_params],
);
useEffect(() => {
const loadSystemDefaults = async () => {
try {
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
const tpl = res.data?.templates || {};
const agentsTemplate = String(tpl.agents_md || '').trim() || FALLBACK_AGENTS_MD;
setDefaultAgentsTemplate(agentsTemplate);
setForm((prev) => {
return {
...prev,
soul_md: String(tpl.soul_md || '').trim() || prev.soul_md,
agents_md: agentsTemplate,
user_md: String(tpl.user_md || '').trim() || prev.user_md,
tools_md: String(tpl.tools_md || '').trim() || prev.tools_md,
identity_md: String(tpl.identity_md || '').trim() || prev.identity_md,
};
});
} catch {
// keep fallback templates
}
};
void loadSystemDefaults();
}, []);
const configuredChannelsLabel = useMemo(
() => (form.channels.length > 0 ? form.channels.map((c) => c.channel_type).join(', ') : '-'),
[form.channels],
);
const loadImages = async () => {
setIsLoadingImages(true);
try {
const res = await axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`);
setImages(res.data);
const ready = res.data.filter((img) => img.status === 'READY');
if (!form.image_tag && ready.length > 0) {
setForm((prev) => ({ ...prev, image_tag: ready[0].tag }));
}
return ready;
} finally {
setIsLoadingImages(false);
}
};
const next = async () => {
if (step === 1) {
const ready = await loadImages();
if (ready.length === 0) {
notify(ui.noReadyImage, { tone: 'warning' });
return;
}
}
if (step === 2) {
2026-03-03 06:09:11 +00:00
commitMaxTokensDraft(maxTokensDraft);
commitCpuCoresDraft(cpuCoresDraft);
commitMemoryMbDraft(memoryMbDraft);
commitStorageGbDraft(storageGbDraft);
2026-03-01 16:26:03 +00:00
if (!form.id || !form.name || !form.api_key || !form.image_tag || !form.llm_model) {
notify(ui.requiredBase, { tone: 'warning' });
return;
}
}
if (step < 4) {
setStep((s) => s + 1);
}
};
const testProvider = async () => {
if (!form.llm_provider || !form.api_key || !form.llm_model) {
notify(ui.providerRequired, { tone: 'warning' });
return;
}
setIsTestingProvider(true);
setTestResult('');
try {
const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, {
provider: form.llm_provider,
model: form.llm_model,
api_key: form.api_key,
api_base: form.api_base || undefined,
});
if (res.data?.ok) {
const preview = (res.data.models_preview || []).slice(0, 3).join(', ');
setTestResult(ui.connOk(preview));
} else {
setTestResult(ui.connFailed(res.data?.detail || 'unknown error'));
}
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || 'request failed';
setTestResult(ui.connFailed(msg));
} finally {
setIsTestingProvider(false);
}
};
const createBot = async () => {
setIsSubmitting(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, {
id: form.id,
name: form.name,
llm_provider: form.llm_provider,
llm_model: form.llm_model,
api_key: form.api_key,
api_base: form.api_base || undefined,
image_tag: form.image_tag,
system_prompt: form.soul_md,
temperature: clampTemperature(Number(form.temperature)),
top_p: Number(form.top_p),
max_tokens: Number(form.max_tokens),
2026-03-03 06:09:11 +00:00
cpu_cores: Number(form.cpu_cores),
memory_mb: Number(form.memory_mb),
storage_gb: Number(form.storage_gb),
2026-03-01 16:26:03 +00:00
soul_md: form.soul_md,
agents_md: form.agents_md,
user_md: form.user_md,
tools_md: form.tools_md,
identity_md: form.identity_md,
send_progress: Boolean(form.send_progress),
send_tool_hints: Boolean(form.send_tool_hints),
channels: form.channels.map((c) => ({
channel_type: c.channel_type,
is_active: c.is_active,
external_app_id: c.external_app_id,
app_secret: c.app_secret,
internal_port: c.internal_port,
extra_config: sanitizeChannelExtra(c.channel_type, c.extra_config),
})),
env_params: form.env_params,
});
if (autoStart) {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`);
}
onCreated?.();
onGoDashboard?.();
setForm(initialForm);
2026-03-03 06:09:11 +00:00
setMaxTokensDraft(String(initialForm.max_tokens));
setCpuCoresDraft(String(initialForm.cpu_cores));
setMemoryMbDraft(String(initialForm.memory_mb));
setStorageGbDraft(String(initialForm.storage_gb));
2026-03-01 16:26:03 +00:00
setStep(1);
setTestResult('');
notify(ui.created, { tone: 'success' });
} catch (error: any) {
const msg = error?.response?.data?.detail || ui.createFailed;
notify(msg, { tone: 'error' });
} finally {
setIsSubmitting(false);
}
};
const onProviderChange = (provider: string) => {
const preset = providerPresets[provider] ?? { model: '' };
setForm((p) => ({
...p,
llm_provider: provider,
llm_model: preset.model || p.llm_model,
api_base: preset.apiBase ?? '',
}));
setTestResult('');
};
const tabMap: Record<AgentTab, keyof typeof form> = {
AGENTS: 'agents_md',
SOUL: 'soul_md',
USER: 'user_md',
TOOLS: 'tools_md',
IDENTITY: 'identity_md',
};
const addChannel = () => {
if (!addableChannelTypes.includes(newChannelType)) return;
setForm((prev) => ({
...prev,
channels: [
...prev.channels,
{
channel_type: newChannelType,
is_active: true,
external_app_id: '',
app_secret: '',
internal_port: 8080,
extra_config: {},
},
],
}));
const rest = addableChannelTypes.filter((t) => t !== newChannelType);
if (rest.length > 0) setNewChannelType(rest[0]);
};
const upsertEnvParam = (key: string, value: string) => {
const normalized = String(key || '').trim().toUpperCase();
if (!normalized) return;
setForm((prev) => ({
...prev,
env_params: {
...(prev.env_params || {}),
[normalized]: String(value || ''),
},
}));
};
const removeEnvParam = (key: string) => {
const normalized = String(key || '').trim().toUpperCase();
if (!normalized) return;
setForm((prev) => {
const next = { ...(prev.env_params || {}) };
delete next[normalized];
return { ...prev, env_params: next };
});
};
const updateChannel = (index: number, patch: Partial<WizardChannelConfig>) => {
setForm((prev) => ({
...prev,
channels: prev.channels.map((c, i) => (i === index ? { ...c, ...patch } : c)),
}));
};
const removeChannel = (index: number) => {
setForm((prev) => ({
...prev,
channels: prev.channels.filter((_, i) => i !== index),
}));
};
const clampMaxTokens = (value: number) => {
if (Number.isNaN(value)) return 8192;
return Math.min(32768, Math.max(256, Math.round(value)));
};
const clampTemperature = (value: number) => {
if (Number.isNaN(value)) return 0.2;
return Math.min(1, Math.max(0, value));
};
2026-03-03 06:09:11 +00:00
const clampCpuCores = (value: number) => {
if (Number.isNaN(value)) return 1;
if (value === 0) return 0;
return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10));
};
const clampMemoryMb = (value: number) => {
if (Number.isNaN(value)) return 1024;
if (value === 0) return 0;
return Math.min(65536, Math.max(256, Math.round(value)));
};
const clampStorageGb = (value: number) => {
if (Number.isNaN(value)) return 10;
if (value === 0) return 0;
return Math.min(1024, Math.max(1, Math.round(value)));
};
const commitMaxTokensDraft = (raw: string) => {
const next = clampMaxTokens(Number(raw));
setForm((p) => ({ ...p, max_tokens: next }));
setMaxTokensDraft(String(next));
};
const commitCpuCoresDraft = (raw: string) => {
const next = clampCpuCores(Number(raw));
setForm((p) => ({ ...p, cpu_cores: next }));
setCpuCoresDraft(String(next));
};
const commitMemoryMbDraft = (raw: string) => {
const next = clampMemoryMb(Number(raw));
setForm((p) => ({ ...p, memory_mb: next }));
setMemoryMbDraft(String(next));
};
const commitStorageGbDraft = (raw: string) => {
const next = clampStorageGb(Number(raw));
setForm((p) => ({ ...p, storage_gb: next }));
setStorageGbDraft(String(next));
};
2026-03-01 16:26:03 +00:00
const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => {
setForm((prev) => {
if (key === 'sendProgress') return { ...prev, send_progress: value };
return { ...prev, send_tool_hints: value };
});
};
const sanitizeChannelExtra = (_channelType: string, extra: Record<string, unknown>) => {
const next = { ...(extra || {}) };
delete next.sendProgress;
delete next.sendToolHints;
return next;
};
const renderChannelFields = (channel: WizardChannelConfig, idx: number) => {
if (channel.channel_type === 'telegram') {
return (
<>
<input
className="input"
type="password"
placeholder={lc.telegramToken}
value={channel.app_secret}
onChange={(e) => updateChannel(idx, { app_secret: e.target.value })}
/>
<input
className="input"
placeholder={lc.proxy}
value={String((channel.extra_config || {}).proxy || '')}
onChange={(e) =>
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })
}
/>
<label className="field-label">
<input
type="checkbox"
checked={Boolean((channel.extra_config || {}).replyToMessage)}
onChange={(e) =>
updateChannel(idx, {
extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked },
})
}
style={{ marginRight: 6 }}
/>
{lc.replyToMessage}
</label>
</>
);
}
if (channel.channel_type === 'feishu') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
<input
className="input"
placeholder={lc.encryptKey}
value={String((channel.extra_config || {}).encryptKey || '')}
onChange={(e) =>
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })
}
/>
<input
className="input"
placeholder={lc.verificationToken}
value={String((channel.extra_config || {}).verificationToken || '')}
onChange={(e) =>
updateChannel(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })
}
/>
</>
);
}
if (channel.channel_type === 'dingtalk') {
return (
<>
<input className="input" placeholder={lc.clientId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
</>
);
}
if (channel.channel_type === 'slack') {
return (
<>
<input className="input" placeholder={lc.botToken} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
</>
);
}
if (channel.channel_type === 'qq') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id} onChange={(e) => updateChannel(idx, { external_app_id: e.target.value })} />
<input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret} onChange={(e) => updateChannel(idx, { app_secret: e.target.value })} />
</>
);
}
return null;
};
return (
<section className="panel stack wizard-shell" style={{ height: '100%' }}>
<div className="wizard-head">
<h2>{ui.title}</h2>
<p className="panel-desc">{ui.sub}</p>
</div>
<div className="wizard-steps wizard-steps-4 wizard-steps-enhanced">
<div className={`wizard-step ${step === 1 ? 'active' : ''}`}>{ui.s1}</div>
<div className={`wizard-step ${step === 2 ? 'active' : ''}`}>{ui.s2}</div>
<div className={`wizard-step ${step === 3 ? 'active' : ''}`}>{ui.s3}</div>
<div className={`wizard-step ${step === 4 ? 'active' : ''}`}>{ui.s4}</div>
</div>
{step === 1 && (
<div className="stack">
<button className="btn btn-secondary" onClick={() => void loadImages()}>{isLoadingImages ? ui.loading : ui.loadImages}</button>
<div className="list-scroll" style={{ maxHeight: '52vh' }}>
{readyImages.map((img) => (
<label key={img.tag} className="card selectable" style={{ display: 'block', cursor: 'pointer' }}>
<input
type="radio"
checked={form.image_tag === img.tag}
onChange={() => setForm((prev) => ({ ...prev, image_tag: img.tag }))}
style={{ marginRight: 8 }}
/>
<span className="mono">{img.tag}</span>
<span style={{ marginLeft: 10 }} className="badge badge-ok">READY</span>
</label>
))}
{readyImages.length === 0 && <div style={{ color: 'var(--muted)' }}>{ui.noReady}</div>}
</div>
</div>
)}
{step === 2 && (
<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="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.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
<div className="section-mini-title">{ui.modelAccess}</div>
<select className="select" value={form.llm_provider} onChange={(e) => onProviderChange(e.target.value)}>
<option value="openrouter">openrouter</option>
<option value="dashscope">dashscope (aliyun qwen)</option>
<option value="openai">openai</option>
<option value="deepseek">deepseek</option>
<option value="kimi">kimi (moonshot)</option>
<option value="minimax">minimax</option>
</select>
<input className="input" placeholder={ui.modelNamePlaceholder} value={form.llm_model} onChange={(e) => setForm((p) => ({ ...p, llm_model: e.target.value }))} />
<input className="input" type="password" placeholder="API Key" value={form.api_key} onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))} />
<input className="input" placeholder="API Base" value={form.api_base} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} />
<div className="card wizard-note-card" style={{ fontSize: 12, color: 'var(--muted)' }}>
{providerPresets[form.llm_provider]?.note[noteLocale]}
</div>
<button className="btn btn-secondary" onClick={() => void testProvider()} disabled={isTestingProvider}>
{isTestingProvider ? ui.testing : ui.test}
</button>
{testResult && <div className="card wizard-note-card">{testResult}</div>}
</div>
<div className="stack card wizard-step2-card">
<div className="section-mini-title">{ui.modelParams}</div>
<div className="slider-row">
<label className="field-label">Temperature: {form.temperature.toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={form.temperature} onChange={(e) => setForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
</div>
<div className="slider-row">
<label className="field-label">Top P: {form.top_p.toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={form.top_p} onChange={(e) => setForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
</div>
<div className="slider-row token-input-row">
<label className="field-label" htmlFor="wizard-max-tokens">Max Tokens</label>
<input
id="wizard-max-tokens"
className="input token-number-input"
type="number"
step="1"
min="256"
max="32768"
2026-03-03 06:09:11 +00:00
value={maxTokensDraft}
onChange={(e) => setMaxTokensDraft(e.target.value)}
onBlur={(e) => commitMaxTokensDraft(e.target.value)}
2026-03-01 16:26:03 +00:00
/>
<div className="field-label">{ui.tokenRange}</div>
</div>
2026-03-03 06:09:11 +00:00
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[4096, 8192, 16384, 32768].map((value) => (
<button
key={value}
className="btn btn-secondary btn-sm"
type="button"
onClick={() => {
setForm((p) => ({ ...p, max_tokens: value }));
setMaxTokensDraft(String(value));
}}
>
{value}
</button>
))}
</div>
<div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '资源配额' : 'Resource Limits'}
</div>
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input
className="input"
type="number"
min="0"
max="16"
step="0.1"
value={cpuCoresDraft}
onChange={(e) => setCpuCoresDraft(e.target.value)}
onBlur={(e) => commitCpuCoresDraft(e.target.value)}
/>
<label className="field-label">{isZh ? '内存 (MB)' : 'Memory (MB)'}</label>
<input
className="input"
type="number"
min="0"
max="65536"
step="128"
value={memoryMbDraft}
onChange={(e) => setMemoryMbDraft(e.target.value)}
onBlur={(e) => commitMemoryMbDraft(e.target.value)}
/>
<label className="field-label">{isZh ? '存储 (GB)' : 'Storage (GB)'}</label>
<input
className="input"
type="number"
min="0"
max="1024"
step="1"
value={storageGbDraft}
onChange={(e) => setStorageGbDraft(e.target.value)}
onBlur={(e) => commitStorageGbDraft(e.target.value)}
/>
<div className="field-label">{isZh ? '提示:填写 0 表示不限制。' : 'Tip: value 0 means unlimited.'}</div>
2026-03-01 16:26:03 +00:00
</div>
<div className="stack card wizard-step2-card">
<div className="section-mini-title">{lc.wizardSectionTitle}</div>
<div className="card wizard-note-card wizard-channel-summary">
<div className="field-label">{lc.wizardSectionDesc}</div>
<div className="mono">
{configuredChannelsLabel}
</div>
<button className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowChannelModal(true)} title={lc.openManager} aria-label={lc.openManager}>
<Settings2 size={14} />
</button>
</div>
<div className="section-mini-title" style={{ marginTop: 6 }}>{ui.toolsConfig}</div>
<div className="card wizard-note-card wizard-channel-summary">
<div className="field-label">{ui.toolsDesc}</div>
<div className="mono">
{envEntries.length > 0 ? envEntries.map(([k]) => k).join(', ') : ui.noEnvParams}
</div>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setShowToolsConfigModal(true)}
title={ui.openToolsManager}
aria-label={ui.openToolsManager}
>
<Settings2 size={14} />
</button>
</div>
</div>
</div>
)}
{step === 3 && (
<div className="wizard-agent-layout">
<div className="agent-tabs-vertical">
{(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => (
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => setAgentTab(tab)}>
{tab}.md
</button>
))}
</div>
<div className="stack" style={{ minWidth: 0 }}>
{agentTab === 'AGENTS' ? (
<div className="row-between">
<span className="field-label">
{isZh
? '建议:将“创建新目录并以 Markdown 输出”写入 AGENTS.md'
: 'Tip: Put "create output directory + markdown output" in AGENTS.md'}
</span>
<button
className="btn btn-secondary btn-sm"
onClick={() =>
setForm((p) => ({ ...p, agents_md: defaultAgentsTemplate }))
}
>
{isZh ? '插入默认规则' : 'Insert default rule'}
</button>
</div>
) : null}
<textarea
className="textarea md-area"
value={String(form[tabMap[agentTab]])}
onChange={(e) => setForm((p) => ({ ...p, [tabMap[agentTab]]: e.target.value }))}
/>
</div>
</div>
)}
{step === 4 && (
<div className="stack">
<div className="card summary-grid">
<div>{ui.image}: <span className="mono">{form.image_tag}</span></div>
<div>Bot ID: <span className="mono">{form.id}</span></div>
<div>{ui.name}: {form.name}</div>
<div>Provider: {form.llm_provider}</div>
<div>{ui.model}: {form.llm_model}</div>
<div>Temperature: {form.temperature.toFixed(2)}</div>
<div>Top P: {form.top_p.toFixed(2)}</div>
<div>Max Tokens: {form.max_tokens}</div>
2026-03-03 06:09:11 +00:00
<div>CPU: {Number(form.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : form.cpu_cores}</div>
<div>{isZh ? '内存' : 'Memory'}: {Number(form.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${form.memory_mb} MB`}</div>
<div>{isZh ? '存储' : 'Storage'}: {Number(form.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${form.storage_gb} GB`}</div>
2026-03-01 16:26:03 +00:00
<div>{ui.channels}: {configuredChannelsLabel}</div>
<div>{ui.tools}: {envEntries.map(([k]) => k).join(', ') || '-'}</div>
</div>
<label>
<input type="checkbox" checked={autoStart} onChange={(e) => setAutoStart(e.target.checked)} style={{ marginRight: 8 }} />
{ui.autoStart}
</label>
</div>
)}
{showChannelModal && (
<div className="modal-mask" onClick={() => setShowChannelModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{lc.wizardSectionTitle}</h3>
<div className="card">
<div className="section-mini-title">{lc.globalDeliveryTitle}</div>
<div className="field-label">{lc.globalDeliveryDesc}</div>
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(form.send_progress)}
onChange={(e) => updateGlobalDeliveryFlag('sendProgress', e.target.checked)}
style={{ marginRight: 6 }}
/>
{lc.sendProgress}
</label>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(form.send_tool_hints)}
onChange={(e) => updateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
style={{ marginRight: 6 }}
/>
{lc.sendToolHints}
</label>
</div>
</div>
<div className="wizard-channel-list">
{form.channels.map((channel, idx) => (
<div key={`${channel.channel_type}-${idx}`} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between">
<strong style={{ textTransform: 'uppercase' }}>{channel.channel_type}</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label className="field-label">
<input
type="checkbox"
checked={channel.is_active}
onChange={(e) => updateChannel(idx, { is_active: e.target.checked })}
style={{ marginRight: 6 }}
/>
{lc.enabled}
</label>
<button
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeChannel(idx)}
title={lc.remove}
>
<Trash2 size={14} />
</button>
</div>
</div>
{renderChannelFields(channel, idx)}
</div>
))}
</div>
<div className="row-between">
<select className="select" value={newChannelType} onChange={(e) => setNewChannelType(e.target.value as ChannelType)} disabled={addableChannelTypes.length === 0}>
{addableChannelTypes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<button className="btn btn-secondary btn-sm icon-btn" disabled={addableChannelTypes.length === 0} onClick={addChannel} title={lc.addChannel} aria-label={lc.addChannel}>
<Plus size={14} />
</button>
</div>
<div className="row-between">
<span className="field-label">{lc.wizardSectionDesc}</span>
<button className="btn btn-primary" onClick={() => setShowChannelModal(false)}>{lc.close}</button>
</div>
</div>
</div>
)}
{showToolsConfigModal && (
<div className="modal-mask" onClick={() => setShowToolsConfigModal(false)}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<h3>{ui.toolsSectionTitle}</h3>
<div className="field-label" style={{ marginBottom: 8 }}>{ui.envParamsDesc}</div>
<div className="wizard-channel-list">
{envEntries.length === 0 ? (
<div className="ops-empty-inline">{ui.noEnvParams}</div>
) : (
envEntries.map(([key, value]) => (
<div key={key} className="card wizard-channel-card wizard-channel-compact">
<div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
<input
className="input mono"
value={key}
readOnly
style={{ maxWidth: 280 }}
/>
<input
className="input"
type={envVisibleByKey[key] ? 'text' : 'password'}
value={value}
onChange={(e) => upsertEnvParam(key, e.target.value)}
placeholder={ui.envValue}
/>
<button
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
title={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
aria-label={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
title={ui.removeEnvParam}
>
<Trash2 size={14} />
</button>
</div>
</div>
))
)}
</div>
<div className="row-between">
<input
className="input mono"
value={envDraftKey}
onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
placeholder={ui.envKey}
/>
<input
className="input"
type={envDraftVisible ? 'text' : 'password'}
value={envDraftValue}
onChange={(e) => setEnvDraftValue(e.target.value)}
placeholder={ui.envValue}
/>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
title={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
aria-label={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
upsertEnvParam(key, envDraftValue);
2026-03-01 19:44:06 +00:00
setEnvDraftKey('');
2026-03-01 16:26:03 +00:00
setEnvDraftValue('');
}}
title={ui.addEnvParam}
aria-label={ui.addEnvParam}
>
<Plus size={14} />
</button>
</div>
<div className="row-between">
<span className="field-label">{ui.toolsDesc}</span>
<button className="btn btn-primary" onClick={() => setShowToolsConfigModal(false)}>{lc.close}</button>
</div>
</div>
</div>
)}
<div className="row-between">
<button className="btn btn-secondary" disabled={step === 1 || isSubmitting} onClick={() => setStep((s) => Math.max(1, s - 1))}>{ui.prev}</button>
{step < 4 ? (
<button className="btn btn-primary" onClick={() => void next()}>{ui.next}</button>
) : (
<button className="btn btn-primary" disabled={isSubmitting} onClick={() => void createBot()}>{isSubmitting ? ui.creating : ui.finish}</button>
)}
</div>
</section>
);
}