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

973 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { setBotAccessPassword } from '../../utils/botAccess';
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: '',
access_password: '',
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,
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
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);
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));
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) {
commitMaxTokensDraft(maxTokensDraft);
commitCpuCoresDraft(cpuCoresDraft);
commitMemoryMbDraft(memoryMbDraft);
commitStorageGbDraft(storageGbDraft);
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,
access_password: form.access_password,
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),
cpu_cores: Number(form.cpu_cores),
memory_mb: Number(form.memory_mb),
storage_gb: Number(form.storage_gb),
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 (String(form.access_password || '').trim()) {
setBotAccessPassword(form.id, form.access_password);
}
if (autoStart) {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${form.id}/start`);
}
onCreated?.();
onGoDashboard?.();
setForm(initialForm);
setMaxTokensDraft(String(initialForm.max_tokens));
setCpuCoresDraft(String(initialForm.cpu_cores));
setMemoryMbDraft(String(initialForm.memory_mb));
setStorageGbDraft(String(initialForm.storage_gb));
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));
};
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));
};
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 }))} />
<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 }}>
{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>
</div>
<div className="stack card wizard-step2-card">
<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 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"
value={maxTokensDraft}
onChange={(e) => setMaxTokensDraft(e.target.value)}
onBlur={(e) => commitMaxTokensDraft(e.target.value)}
/>
<div className="field-label">{ui.tokenRange}</div>
</div>
<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>
<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>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowChannelModal(true)} tooltip={lc.openManager} aria-label={lc.openManager}>
<Settings2 size={14} />
</LucentIconButton>
</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>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setShowToolsConfigModal(true)}
tooltip={ui.openToolsManager}
aria-label={ui.openToolsManager}
>
<Settings2 size={14} />
</LucentIconButton>
</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>{ui.accessPassword}: {form.access_password ? (isZh ? '已设置' : 'Configured') : (isZh ? '未设置' : 'Not set')}</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>
<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>
<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>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeChannel(idx)}
tooltip={lc.remove}
aria-label={lc.remove}
>
<Trash2 size={14} />
</LucentIconButton>
</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>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" disabled={addableChannelTypes.length === 0} onClick={addChannel} tooltip={lc.addChannel} aria-label={lc.addChannel}>
<Plus size={14} />
</LucentIconButton>
</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}
/>
<LucentIconButton
className="btn btn-secondary btn-sm wizard-icon-btn"
onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
tooltip={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
aria-label={envVisibleByKey[key] ? ui.hideEnvValue : ui.showEnvValue}
>
{envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
onClick={() => removeEnvParam(key)}
tooltip={ui.removeEnvParam}
aria-label={ui.removeEnvParam}
>
<Trash2 size={14} />
</LucentIconButton>
</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}
/>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setEnvDraftVisible((v) => !v)}
tooltip={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
aria-label={envDraftVisible ? ui.hideEnvValue : ui.showEnvValue}
>
{envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
const key = String(envDraftKey || '').trim().toUpperCase();
if (!key) return;
upsertEnvParam(key, envDraftValue);
setEnvDraftKey('');
setEnvDraftValue('');
}}
tooltip={ui.addEnvParam}
aria-label={ui.addEnvParam}
>
<Plus size={14} />
</LucentIconButton>
</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>
);
}