import { useState, useEffect, useCallback } from 'react'; import { Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, CheckCircle, RefreshCw, UserCheck, Pencil, Bot, } from 'lucide-react'; // ── Extracted modules ───────────────────────────────── import type { AppConfig, ConfigPageProps, TabId, ProviderConfig, ModelConfig, AgentConfig, McpServerConfig, McpStatusResponse, SkillListResponse, SubagentListResponse, SubagentItem, ToolsListResponse, ExpertItem, ExpertListResponse, CapabilityPolicy, KnownSource, SchedulerConfig, ChannelConfig, ModelOptionsResponse, } from './types'; import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'; import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader, CheckboxList, ModalHeader, ModalFooter, } from './ui'; import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'; import { listSkills, toggleSkill } from '../../api/skills'; import { listTools } from '../../api/tools'; import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents'; import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert, listModelOptions, } from '../../api/experts'; import { getMcpStatus } from '../../api/mcp'; export { getSelectedExpert, selectExpert } from '../../api/experts'; // ── Main Component ───────────────────────────────────── export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { const [config, setConfig] = useState(null); const [activeTab, setActiveTab] = useState(initialTab ?? 'providers'); const [loading, setLoading] = useState(true); // Connection settings (localStorage-based) const [connHost, setConnHost] = useState(() => { try { return localStorage.getItem('picobot-gateway-host') || '127.0.0.1'; } catch { return '127.0.0.1'; } }); const [connPort, setConnPort] = useState(() => { try { const p = parseInt(localStorage.getItem('picobot-gateway-port') || '19876', 10); return isNaN(p) ? 19876 : p; } catch { return 19876; } }); const [connError, setConnError] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [toast, setToast] = useState(''); const [dirty, setDirty] = useState(false); const [showRestartDialog, setShowRestartDialog] = useState(false); const [restarting, setRestarting] = useState(false); const [mcpStatus, setMcpStatus] = useState(null); const [skillList, setSkillList] = useState(null); const [skillListLoading, setSkillListLoading] = useState(false); const [toolList, setToolList] = useState(null); const [toolListLoading, setToolListLoading] = useState(false); const [subagentList, setSubagentList] = useState(null); const [subagentListLoading, setSubagentListLoading] = useState(false); const [expertList, setExpertList] = useState(null); const [expertListLoading, setExpertListLoading] = useState(false); const [modelOptions, setModelOptions] = useState(null); const [editingExpert, setEditingExpert] = useState<{ mode: 'create' | 'edit'; name?: string; scope: string; nameField: string; description: string; body: string; provider: string; model: string; allowedSkills: string[]; deniedSkills: string[]; allowedTools: string[]; deniedTools: string[]; allowedSubagents: string[]; deniedSubagents: string[]; } | null>(null); const [editingExpertError, setEditingExpertError] = useState(''); const [savingExpert, setSavingExpert] = useState(false); const [editingSubagent, setEditingSubagent] = useState<{ name: string; description: string; provider: string; model: string; allowedSkills: string[]; deniedSkills: string[]; allowedTools: string[]; deniedTools: string[]; allowedSubagents: string[]; deniedSubagents: string[]; } | null>(null); const [editingSubagentError, setEditingSubagentError] = useState(''); const [savingSubagent, setSavingSubagent] = useState(false); const fetchMcpStatus = useCallback(async () => { const data = await getMcpStatus(); if (data) setMcpStatus(data); }, []); const fetchSkillList = useCallback(async () => { setSkillListLoading(true); const data = await listSkills(); if (data) setSkillList(data); setSkillListLoading(false); }, []); const fetchToolList = useCallback(async () => { setToolListLoading(true); const data = await listTools(); if (data) setToolList(data); setToolListLoading(false); }, []); const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => { return toggleSkill(name, scope, enabled); }, []); const fetchSubagentList = useCallback(async () => { setSubagentListLoading(true); const data = await listSubagents(); if (data) setSubagentList(data); setSubagentListLoading(false); }, []); const toggleSubagentCb = useCallback(async (name: string, scope: string, enabled: boolean) => { return toggleSubagent(name, scope, enabled); }, []); const updateSubagentCb = useCallback( async (payload: { name: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string; }) => { return updateSubagent(payload); }, [], ); const fetchExpertList = useCallback(async () => { setExpertListLoading(true); const data = await listExperts(); if (data) setExpertList(data); setExpertListLoading(false); }, []); const toggleExpertCb = useCallback(async (name: string, scope: string, enabled: boolean) => { return toggleExpert(name, scope, enabled); }, []); const createExpertCb = useCallback( async (payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy; provider?: string; model?: string; }) => { return createExpert(payload); }, [], ); const updateExpertCb = useCallback( async (payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string; }) => { return updateExpert(payload); }, [], ); const deleteExpertCb = useCallback(async (name: string, scope: string) => { return deleteExpert(name, scope); }, []); const handleClose = useCallback(() => { if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return; onClose(); }, [dirty, onClose]); // Load config useEffect(() => { getAppConfig().then(([data, err]) => { if (data) setConfig(data); if (err) setError('加载配置失败: ' + err); setLoading(false); }); }, []); // Fetch MCP status when MCP tab is selected useEffect(() => { if (activeTab === 'mcp') fetchMcpStatus(); }, [activeTab, fetchMcpStatus]); // Fetch skill list when skills tab is selected useEffect(() => { if (activeTab === 'skills') fetchSkillList(); }, [activeTab, fetchSkillList]); // Fetch subagent list when subagents tab is selected useEffect(() => { if (activeTab === 'subagents') fetchSubagentList(); }, [activeTab, fetchSubagentList]); // Fetch skills + tools + model options when experts/subagents tab is selected (for capability CheckboxList & provider/model dropdowns) useEffect(() => { if (activeTab === 'experts' || activeTab === 'subagents') { if (!skillList) fetchSkillList(); if (!toolList) fetchToolList(); if (!modelOptions) listModelOptions().then((data) => { if (data) setModelOptions(data); }); } }, [activeTab, fetchSkillList, fetchToolList, skillList, toolList, modelOptions]); // experts tab 编辑专家时也需要子代理勾选列表,按需加载(subagents tab 由下方独立 useEffect 刷新) useEffect(() => { if (activeTab === 'experts' && !subagentList) fetchSubagentList(); }, [activeTab, fetchSubagentList, subagentList]); // Fetch expert list when experts tab is selected useEffect(() => { if (activeTab === 'experts') fetchExpertList(); }, [activeTab, fetchExpertList]); // ESC to close useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose(); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [handleClose]); // 子模态框打开时,ESC 键仅关闭子模态框(阻止冒泡到 ConfigPage 全局 ESC,避免关闭整个配置页) // 必须放在所有条件 return 之前,否则 loading 首次渲染时不执行此 hook, // config 加载后重新渲染才执行 → hooks 数量不一致 → React 崩溃 useEffect(() => { if (!editingExpert && !editingSubagent) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); setEditingExpert(null); setEditingSubagent(null); } }; window.addEventListener('keydown', handler, true); return () => window.removeEventListener('keydown', handler, true); }, [editingExpert, editingSubagent]); const update = useCallback((key: K, value: AppConfig[K]) => { setConfig((prev) => (prev ? { ...prev, [key]: value } : prev)); setDirty(true); }, []); const handleSave = async () => { if (!config) return; setSaving(true); setError(''); const [ok, err] = await updateAppConfig(config); if (!ok) { setError(err || '保存失败'); } else { // Config is now synced to both disk and in-memory state, // so the local state is already correct. No need to re-fetch. setDirty(false); // Show restart confirmation dialog setShowRestartDialog(true); } setSaving(false); }; const handleRestart = async () => { setShowRestartDialog(false); setRestarting(true); try { const { status, data } = await restartGateway(); if (status === 409) { setToast(data.message || '有任务运行中,请等待完成后再试'); setRestarting(false); setTimeout(() => setToast(''), 5000); return; } if (status < 200 || status >= 300) throw new Error(data.message || '重启失败'); setToast('服务正在重启,页面将自动重连...'); // Poll /health until gateway is back const poll = async () => { for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 1000)); if (await checkHealth()) { const [refreshed] = await getAppConfig(); if (refreshed) setConfig(refreshed); setToast('服务已重启,配置已生效'); setRestarting(false); setTimeout(() => setToast(''), 3000); return; } } setToast('重启超时,请手动刷新页面'); setRestarting(false); setTimeout(() => setToast(''), 5000); }; poll(); } catch (e: unknown) { setError(e instanceof Error ? e.message : '重启失败'); setRestarting(false); } }; // ── Render sections ────────────────────────────────── if (loading) return (
); if (!config) return (

{error || '加载失败'}

); const handleSaveConnection = () => { const host = connHost.trim(); if (!host) { setConnError('主机地址不能为空'); return; } if (connPort < 1 || connPort > 65535) { setConnError('端口号必须在 1-65535 之间'); return; } setConnError(''); localStorage.setItem('picobot-gateway-host', host); localStorage.setItem('picobot-gateway-port', String(connPort)); onSaveConnection?.(host, connPort); setToast('连接设置已保存,正在重连...'); setTimeout(() => setToast(''), 3000); }; const renderConnection = () => (
{ setConnHost(e.target.value); setConnError(''); }} className={inputCls} placeholder="127.0.0.1" /> { setConnPort(+e.target.value); setConnError(''); }} min={1} max={65535} className={inputCls} placeholder="19876" /> {connError && (
{connError}
)}
ws://{connHost.trim() || '...'}:{connPort || '...'}/ws
); const renderGateway = () => (
update('gateway', { ...config.gateway, host: e.target.value })} className={inputCls} /> update('gateway', { ...config.gateway, port: +e.target.value })} className={inputCls} />
显示工具结果 update('gateway', { ...config.gateway, show_tool_results: v })} />
update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value }) } className={inputCls} /> update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value }) } className={inputCls} /> { const v = e.target.value; update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined }); }} className={inputCls} placeholder="24" />
); const renderProviders = () => { const entries = Object.entries(config.providers); const addProvider = () => { const name = prompt('Provider 名称:')?.trim(); if (name && !config.providers[name]) { update('providers', { ...config.providers, [name]: { type: 'openai', base_url: '', api_key: '', extra_headers: {}, llm_timeout_secs: 120, memory_maintenance_timeout_secs: 600, }, }); } }; const delProvider = (name: string) => { if (confirm(`删除 Provider "${name}"?`)) { const { [name]: _, ...rest } = config.providers; update('providers', rest); } }; const renameProvider = (oldName: string, newName: string) => { if (newName === oldName || !newName) return; const entries = Object.entries(config.providers); const newMap: Record = {}; for (const [k, v] of entries) { newMap[k === oldName ? newName : k] = v; } update('providers', newMap); }; const updProvider = (name: string, patch: Partial) => { update('providers', { ...config.providers, [name]: { ...config.providers[name], ...patch } }); }; return (
{entries.map(([name, p]) => (
delProvider(name)} onRename={(n) => renameProvider(name, n)} />
updProvider(name, { base_url: e.target.value })} className={inputCls} /> updProvider(name, { api_key: e.target.value })} className={inputCls} /> updProvider(name, { llm_timeout_secs: +e.target.value })} className={inputCls} /> updProvider(name, { memory_maintenance_timeout_secs: +e.target.value }) } className={inputCls} />
))}
); }; const renderModels = () => { const entries = Object.entries(config.models); const addModel = () => { const name = prompt('Model 名称:')?.trim(); if (name && !config.models[name]) update('models', { ...config.models, [name]: { model_id: name } }); }; const delModel = (name: string) => { if (confirm(`删除 Model "${name}"?`)) { const { [name]: _, ...rest } = config.models; update('models', rest); } }; const updModel = (name: string, patch: Partial) => update('models', { ...config.models, [name]: { ...config.models[name], ...patch } }); return (
{entries.map(([name, m]) => (
delModel(name)} />
updModel(name, { model_id: e.target.value })} className={inputCls} /> updModel(name, { temperature: e.target.value ? +e.target.value : undefined }) } className={inputCls} placeholder="0.7" /> updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined }) } className={inputCls} placeholder="4096" /> updModel(name, { context_window_tokens: e.target.value ? +e.target.value : undefined, }) } className={inputCls} placeholder="128000" />
))}
); }; const renderAgents = () => { const entries = Object.entries(config.agents); const providerNames = Object.keys(config.providers); const modelNames = Object.keys(config.models); const addAgent = () => { const name = prompt('Agent 名称:')?.trim(); if (name && !config.agents[name]) update('agents', { ...config.agents, [name]: { provider: providerNames[0] || '', model: modelNames[0] || '', max_tool_iterations: 100, tool_result_max_chars: 100000, context_tool_result_trim_chars: 2000, }, }); }; const delAgent = (name: string) => { if (confirm(`删除 Agent "${name}"?`)) { const { [name]: _, ...rest } = config.agents; update('agents', rest); } }; const updAgent = (name: string, patch: Partial) => update('agents', { ...config.agents, [name]: { ...config.agents[name], ...patch } }); return (
{entries.map(([name, a]) => (
delAgent(name)} />
updAgent(name, { max_tool_iterations: +e.target.value })} className={inputCls} /> updAgent(name, { tool_result_max_chars: +e.target.value })} className={inputCls} /> updAgent(name, { context_tool_result_trim_chars: +e.target.value }) } className={inputCls} />
))}
); }; const renderTime = () => ( ); const renderScheduler = () => (
启用调度器 update('scheduler', { ...config.scheduler, enabled: v })} />
update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value }) } className={inputCls} /> update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value }) } className={inputCls} />
); const SKILL_KNOWN_SOURCES: KnownSource[] = [ { key: 'user', label: '用户技能', description: '~/.picobot/skills' }, { key: 'user_agent', label: '用户 Agent 技能', description: '~/.agents/skills' }, { key: 'user_openclaw', label: '用户 OpenClaw 技能', description: '~/.openclaw/skills' }, { key: 'project', label: '项目技能', description: '.picobot/skills' }, { key: 'project_agent', label: '项目 Agent 技能', description: '.agents/skills' }, { key: 'project_openclaw', label: '项目 OpenClaw 技能', description: '.openclaw/skills' }, ]; const SUBAGENT_KNOWN_SOURCES: KnownSource[] = [ { key: 'user', label: '用户子代理', description: '~/.picobot/subagents' }, { key: 'project', label: '项目子代理', description: '.picobot/subagents' }, ]; const renderSkills = () => (
启用技能 update('skills', { ...config.skills, enabled: v })} />
update('skills', { ...config.skills, max_index_chars: +e.target.value }) } className={inputCls} /> update('skills', { ...config.skills, max_listed_skills: +e.target.value }) } className={inputCls} />
update('skills', { ...config.skills, sources: v })} knownSources={SKILL_KNOWN_SOURCES} examplePaths={['D:\\my-skills', '/home/user/shared-skills']} /> {renderDiscoveredSkills()}
); const renderDiscoveredSkills = () => { if (!skillList || !skillList.skills_system_enabled) return null; const skills = skillList.skills; const handleToggle = async (name: string, currentlyEnabled: boolean) => { // Optimistic update const prevSkillList = skillList; setSkillList({ ...skillList, skills: skills.map((s) => s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s, ), }); try { const resp = await toggleSkillCb(name, 'project', !currentlyEnabled); const data = await resp.json(); if (!resp.ok || !data.success) { // Rollback setSkillList(prevSkillList); setToast(data.error || '切换技能状态失败'); setTimeout(() => setToast(''), 3000); return; } // Update with server response setSkillList({ ...prevSkillList, skills: prevSkillList.skills.map((s) => s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s, ), }); } catch { // Rollback setSkillList(prevSkillList); setToast('网络错误,切换技能状态失败'); setTimeout(() => setToast(''), 3000); } }; return ( {skillListLoading && skills.length === 0 ? (
加载中...
) : skills.length === 0 ? (

未发现任何技能,请检查来源目录配置

) : (
{skills.map((skill) => { const isEnabled = skill.disabled_in_scopes.length === 0; return (
{skill.name} {skill.source}

{skill.description}

handleToggle(skill.name, isEnabled)} />
); })}
)}
); }; const TASK_KNOWN_TOOLS: KnownSource[] = [ { key: 'read', label: 'Read', description: '读取文件' }, { key: 'edit', label: 'Edit', description: '编辑文件' }, { key: 'write', label: 'Write', description: '写入文件' }, { key: 'bash', label: 'Bash', description: '执行 Shell 命令' }, { key: 'http_request', label: 'HTTP Request', description: '发送 HTTP 请求' }, { key: 'web_fetch', label: 'Web Fetch', description: '抓取网页内容' }, { key: 'memory_search', label: 'Memory Search', description: '搜索记忆' }, { key: 'get_time', label: 'Get Time', description: '获取当前时间' }, { key: 'calculator', label: 'Calculator', description: '计算器' }, { key: 'skill_activate', label: 'Skill Activate', description: '激活技能' }, { key: 'skill_list', label: 'Skill List', description: '列出技能' }, { key: 'send_session_message', label: 'Send Session Message', description: '发送会话消息' }, ]; const renderTools = () => (
update('tools', { ...config.tools, disabled: v })} />
启用 Task 工具 update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } }) } />
update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value }, }) } className={inputCls} /> update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value }, }) } className={inputCls} /> update('tools', { ...config.tools, task: { ...config.tools.task, max_nesting_depth: Math.max(0, +e.target.value || 0), }, }) } className={inputCls} />
update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } }) } knownSources={TASK_KNOWN_TOOLS} showCustom={false} />
); const renderMemory = () => ( update('memory_maintenance', { ...config.memory_maintenance, max_merge_ratio: +e.target.value, }) } className={inputCls} /> update('memory_maintenance', { ...config.memory_maintenance, min_memories_to_keep: +e.target.value, }) } className={inputCls} /> update('memory_maintenance', { ...config.memory_maintenance, max_merge_per_group: +e.target.value, }) } className={inputCls} /> ); const renderImage = () => ( update('image_context', { ...config.image_context, max_images_in_context: +e.target.value, }) } className={inputCls} /> update('image_context', { ...config.image_context, max_image_age_rounds: +e.target.value, }) } className={inputCls} /> ); const renderSubagents = () => (
启用子代理发现 update('subagents', { ...config.subagents, enabled: v })} />
update('subagents', { ...config.subagents, sources: v })} knownSources={SUBAGENT_KNOWN_SOURCES} examplePaths={['D:\\my-subagents', '/home/user/shared-agents']} /> {renderDiscoveredSubagents()} {renderSubagentModal()}
); const renderDiscoveredSubagents = () => { if (!subagentList || !subagentList.subagents_system_enabled) return null; const subagents = subagentList.subagents; const handleToggle = async (name: string, currentlyEnabled: boolean) => { const prevList = subagentList; setSubagentList({ ...subagentList, subagents: subagents.map((s) => s.name === name ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : s, ), }); try { const resp = await toggleSubagentCb(name, 'project', !currentlyEnabled); const data = await resp.json(); if (!resp.ok || !data.success) { setSubagentList(prevList); setToast(data.error || '切换子代理状态失败'); setTimeout(() => setToast(''), 3000); return; } setSubagentList({ ...prevList, subagents: prevList.subagents.map((s) => s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s, ), }); } catch { setSubagentList(prevList); setToast('网络错误,切换子代理状态失败'); setTimeout(() => setToast(''), 3000); } }; const toolLabel = (key: string): string => { const known = TASK_KNOWN_TOOLS.find((t) => t.key === key); return known ? known.label : key; }; const renderToolTags = (label: string, tools: string[] | undefined, tone: 'allow' | 'deny') => { if (!tools || tools.length === 0) return null; const tagCls = tone === 'allow' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-rose-500/10 text-rose-600 dark:text-rose-400'; return (
{label}: {tools.map((t) => ( {toolLabel(t)} ))}
); }; const handleEditSubagent = (subagent: SubagentItem) => { setEditingSubagentError(''); setEditingSubagent({ name: subagent.name, description: subagent.description, provider: subagent.provider ?? '', model: subagent.model ?? '', allowedSkills: subagent.capability?.allowed_skills ?? [], deniedSkills: subagent.capability?.denied_skills ?? [], allowedTools: subagent.capability?.allowed_tools ?? [], deniedTools: subagent.capability?.denied_tools ?? [], allowedSubagents: subagent.capability?.allowed_subagents ?? [], deniedSubagents: subagent.capability?.denied_subagents ?? [], }); }; return ( {subagentListLoading && subagents.length === 0 ? (
加载中...
) : subagents.length === 0 ? (

未发现任何子代理

) : (
{subagents.map((subagent) => { const isEnabled = subagent.disabled_in_scopes.length === 0; const isBuiltin = subagent.source === 'builtin'; return (
{subagent.name} {subagent.source}

{subagent.description}

{renderToolTags('允许', subagent.capability?.allowed_tools, 'allow')} {renderToolTags('禁用', subagent.capability?.denied_tools, 'deny')}
{!isBuiltin && ( )} handleToggle(subagent.name, isEnabled)} />
); })}
)}
); }; const EXPERT_KNOWN_SOURCES: KnownSource[] = [ { key: 'user', label: '用户专家', description: '~/.picobot/experts' }, { key: 'project', label: '项目专家', description: '.picobot/experts' }, ]; const renderExperts = () => (
启用专家系统 update('experts', { ...config.experts, enabled: v })} />
update('experts', { ...config.experts, sources: v })} knownSources={EXPERT_KNOWN_SOURCES} examplePaths={['D:\\my-experts', '/home/user/shared-experts']} /> {renderDiscoveredExperts()} {renderExpertModal()}
); const renderDiscoveredExperts = () => { if (!expertList || !expertList.experts_system_enabled) return null; const experts = expertList.experts; const handleToggle = async (name: string, currentlyEnabled: boolean) => { const prevList = expertList; setExpertList({ ...expertList, experts: experts.map((e) => e.name === name ? { ...e, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : e, ), }); try { const resp = await toggleExpertCb(name, 'project', !currentlyEnabled); const data = await resp.json(); if (!resp.ok || !data.success) { setExpertList(prevList); setToast(data.error || '切换专家状态失败'); setTimeout(() => setToast(''), 3000); return; } setExpertList({ ...prevList, experts: prevList.experts.map((e) => e.name === name ? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] } : e, ), }); } catch { setExpertList(prevList); setToast('网络错误,切换专家状态失败'); setTimeout(() => setToast(''), 3000); } }; const handleEdit = (expert: ExpertItem) => { setEditingExpertError(''); setEditingExpert({ mode: 'edit', name: expert.name, scope: 'project', nameField: expert.name, description: expert.description, body: expert.body ?? '', provider: expert.provider ?? '', model: expert.model ?? '', allowedSkills: expert.capability?.allowed_skills ?? [], deniedSkills: expert.capability?.denied_skills ?? [], allowedTools: expert.capability?.allowed_tools ?? [], deniedTools: expert.capability?.denied_tools ?? [], allowedSubagents: expert.capability?.allowed_subagents ?? [], deniedSubagents: expert.capability?.denied_subagents ?? [], }); }; const handleDelete = async (name: string) => { if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return; try { const resp = await deleteExpertCb(name, 'project'); const data = await resp.json(); if (!resp.ok || !data.success) { setToast(data.error || '删除专家失败'); setTimeout(() => setToast(''), 3000); return; } setToast('专家已删除'); setTimeout(() => setToast(''), 3000); fetchExpertList(); } catch { setToast('网络错误,删除专家失败'); setTimeout(() => setToast(''), 3000); } }; return ( {expertListLoading && experts.length === 0 ? (
加载中...
) : experts.length === 0 ? (

未发现任何专家

) : (
{experts.map((expert) => { const isEnabled = expert.disabled_in_scopes.length === 0; return (
{expert.name} {expert.source}

{expert.description}

handleToggle(expert.name, isEnabled)} />
); })}
)}
); }; // 技能/工具勾选选项(专家与子代理编辑模态框共用) const skillOptions = (skillList?.skills ?? []).map((s) => ({ key: s.name, label: s.name, description: s.description, group: s.source, })); const toolOptions = (toolList?.tools ?? []).map((t) => ({ key: t.name, label: t.name, description: t.description, group: t.source, })); const subagentOptions = (subagentList?.subagents ?? []).map((s) => ({ key: s.name, label: s.name, description: s.description, group: s.source, })); const skillEmptyHint = skillListLoading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录'; const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具'; const subagentEmptyHint = subagentListLoading ? '加载中...' : '未发现任何子代理'; const renderExpertModal = () => { if (!editingExpert) return null; const isEdit = editingExpert.mode === 'edit'; const canSave = editingExpert.nameField.trim() && editingExpert.description.trim(); const handleSave = async () => { if (!canSave) return; setSavingExpert(true); setEditingExpertError(''); try { // allowed_* 为空时必须传 undefined(后端 None=不限), // 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。 // denied_* 为 Vec,空数组即"不禁",可直接传。 const capability: CapabilityPolicy = { allowed_skills: editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined, denied_skills: editingExpert.deniedSkills, allowed_tools: editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined, denied_tools: editingExpert.deniedTools, allowed_subagents: editingExpert.allowedSubagents.length > 0 ? editingExpert.allowedSubagents : undefined, denied_subagents: editingExpert.deniedSubagents, }; const payload = { name: editingExpert.nameField, description: editingExpert.description, body: editingExpert.body, capability, provider: editingExpert.provider || undefined, model: editingExpert.model || undefined, }; const resp = isEdit ? await updateExpertCb({ ...payload, scope: 'project' }) : await createExpertCb({ ...payload, scope: 'project' }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { setEditingExpertError(data.error || data.message || '保存失败'); setSavingExpert(false); return; } setToast(isEdit ? '专家已更新' : '专家已创建'); setTimeout(() => setToast(''), 3000); setEditingExpert(null); fetchExpertList(); } catch (e: unknown) { setEditingExpertError(e instanceof Error ? e.message : '网络错误'); } finally { setSavingExpert(false); } }; return (
setEditingExpert(null)} >
e.stopPropagation()} > } title={isEdit ? '编辑专家' : '添加专家'} onClose={() => setEditingExpert(null)} />
setEditingExpert((prev) => prev ? { ...prev, nameField: e.target.value } : prev, ) } disabled={isEdit} placeholder="如 translator" className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')} autoFocus={!isEdit} /> setEditingExpert((prev) => prev ? { ...prev, description: e.target.value } : prev, ) } placeholder="如 翻译专家" className={inputCls} />