973 lines
39 KiB
TypeScript
973 lines
39 KiB
TypeScript
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': 'Kimi(Moonshot)接口,模型示例 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>
|
||
);
|
||
}
|