import { useState, useEffect, useCallback, type ReactNode } from 'react' import { Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, CheckCircle, Plug, Radio, RefreshCw, UserCheck, Pencil, } from 'lucide-react' // ── Types ────────────────────────────────────────────── interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record; llm_timeout_secs: number; memory_maintenance_timeout_secs: number } interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number } interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number } interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number } interface TimeConfig { timezone: string } interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: any[] } interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } interface ToolsConfig { disabled: string[]; task: TaskConfig } interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } interface SubagentsConfig { enabled: boolean; sources: string[] } interface ClientConfig { gateway_url: string } interface McpServerConfig { name?: string type: 'stdio' | 'streamableHttp' | 'http' is_active: boolean command?: string args?: string[] env?: Record cwd?: string base_url?: string headers?: Record description?: string } interface SkillItem { name: string description: string source: string path: string disabled_in_scopes: string[] } interface SkillListResponse { skills_system_enabled: boolean total: number skills: SkillItem[] } interface SubagentItem { name: string description: string source: string disabled_in_scopes: string[] } interface SubagentListResponse { subagents_system_enabled: boolean total: number subagents: SubagentItem[] } interface ExpertsConfig { enabled: boolean; sources: string[] } interface ExpertItem { name: string description: string source: string path?: string body?: string disabled_in_scopes: string[] } interface ExpertListResponse { experts_system_enabled: boolean total: number experts: ExpertItem[] } interface McpServerStatus { key: string name: string transport_type: string is_active: boolean connected: boolean tool_count: number error?: string } interface McpStatusResponse { enabled: boolean total_servers: number connected_servers: number failed_servers: number total_tools: number servers: McpServerStatus[] } interface AppConfig { providers: Record models: Record agents: Record time: TimeConfig gateway: GatewayConfig scheduler: SchedulerConfig skills: SkillsConfig tools: ToolsConfig memory_maintenance: MemoryMaintenanceConfig image_context: ImageContextConfig subagents: SubagentsConfig experts: ExpertsConfig client: ClientConfig channels: Record mcpServers: Record } interface ConfigPageProps { onClose: () => void onSaveConnection?: (host: string, port: number) => void initialTab?: TabId } type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'experts' | 'mcp' | 'channels' const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ { id: 'providers', label: '服务商', icon: Cpu }, { id: 'models', label: '模型', icon: Brain }, { id: 'agents', label: '代理', icon: Bot }, { id: 'mcp', label: 'MCP 服务器', icon: Plug }, { id: 'skills', label: '技能', icon: Wrench }, { id: 'subagents', label: '子代理', icon: Bot }, { id: 'experts', label: '专家', icon: UserCheck }, { id: 'channels', label: '渠道', icon: Radio }, { id: 'tools', label: '工具', icon: Settings }, { id: 'memory', label: '记忆维护', icon: Users }, { id: 'scheduler', label: '调度器', icon: Calendar }, { id: 'image', label: '图片上下文', icon: Image }, { id: 'time', label: '时间', icon: Clock }, { id: 'connection', label: '连接', icon: Wifi }, { id: 'gateway', label: '网关', icon: Server }, ] // ── Shared UI primitives ─────────────────────────────── function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { return (
{children} {hint &&

{hint}

}
) } const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors" const selectCls = inputCls const TIMEZONE_OPTIONS: { value: string; label: string }[] = [ { value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' }, { value: 'Asia/Tokyo', label: 'Asia/Tokyo (日本标准时间, UTC+9)' }, { value: 'Asia/Seoul', label: 'Asia/Seoul (韩国标准时间, UTC+9)' }, { value: 'Asia/Singapore', label: 'Asia/Singapore (新加坡时间, UTC+8)' }, { value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (香港时间, UTC+8)' }, { value: 'Asia/Taipei', label: 'Asia/Taipei (台北时间, UTC+8)' }, { value: 'Asia/Bangkok', label: 'Asia/Bangkok (曼谷时间, UTC+7)' }, { value: 'Asia/Kolkata', label: 'Asia/Kolkata (印度标准时间, UTC+5:30)' }, { value: 'Asia/Dubai', label: 'Asia/Dubai (海湾标准时间, UTC+4)' }, { value: 'Europe/London', label: 'Europe/London (格林威治时间, UTC+0)' }, { value: 'Europe/Paris', label: 'Europe/Paris (中欧时间, UTC+1)' }, { value: 'Europe/Berlin', label: 'Europe/Berlin (中欧时间, UTC+1)' }, { value: 'Europe/Moscow', label: 'Europe/Moscow (莫斯科时间, UTC+3)' }, { value: 'America/New_York', label: 'America/New_York (美东时间, UTC-5)' }, { value: 'America/Chicago', label: 'America/Chicago (美中时间, UTC-6)' }, { value: 'America/Denver', label: 'America/Denver (美山地时间, UTC-7)' }, { value: 'America/Los_Angeles', label: 'America/Los_Angeles (美太平洋时间, UTC-8)' }, { value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' }, { value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' }, { value: 'UTC', label: 'UTC (协调世界时)' }, ] function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) { const [input, setInput] = useState('') const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } } return (
{tags.map((t, i) => ( {t} ))}
setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
) } function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) { return (

{title}

{subtitle && {subtitle}}
{children}
) } interface KnownSource { key: string label: string description: string } function SourceEditor({ sources, onChange, knownSources, examplePaths, showCustom = true, }: { sources: string[] onChange: (s: string[]) => void knownSources: KnownSource[] examplePaths?: string[] showCustom?: boolean }) { const [customInput, setCustomInput] = useState('') const knownKeys = new Set(knownSources.map(k => k.key)) const customPaths = sources.filter(s => !knownKeys.has(s)) const toggleKnown = (key: string) => { if (sources.includes(key)) { onChange(sources.filter(s => s !== key)) } else { onChange([...sources, key]) } } const addCustom = () => { const v = customInput.trim() if (v && !sources.includes(v)) { onChange([...sources, v]) setCustomInput('') } } const removeCustom = (path: string) => { onChange(sources.filter(s => s !== path)) } return (
{/* Known sources as toggles */}
{knownSources.map(src => (
{src.label}
{src.description}
toggleKnown(src.key)} />
))}
{/* Custom paths (only shown when showCustom is true) */} {showCustom && (
自定义路径
{customPaths.length > 0 && (
{customPaths.map((p, i) => ( {p} ))}
)}
setCustomInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())} placeholder="输入绝对路径,如 D:\my-skills" className={inputCls + ' !text-xs font-mono'} />
{examplePaths && (

示例: {examplePaths.join('、')}

)}
)}
) } function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) { const [editing, setEditing] = useState(false) const [val, setVal] = useState(name) return (
{editing ? ( setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus /> ) : ( onRename && setEditing(true)}>{name} )}
) } // ── Expert API helpers (reusable, also used by ExpertSelector) ─────── export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { const resp = await fetch(`/api/experts/selected?session_id=${encodeURIComponent(sessionId)}`) if (!resp.ok) return { expert_name: null, expert: null } return resp.json() } export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { const resp = await fetch('/api/experts/select', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), }) const data = await resp.json().catch(() => ({})) if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } return { success: true } } // ── 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 [subagentList, setSubagentList] = useState(null) const [subagentListLoading, setSubagentListLoading] = useState(false) const [expertList, setExpertList] = useState(null) const [expertListLoading, setExpertListLoading] = useState(false) const [editingExpert, setEditingExpert] = useState<{ mode: 'create' | 'edit' name?: string scope: string nameField: string description: string body: string } | null>(null) const [editingExpertError, setEditingExpertError] = useState('') const [savingExpert, setSavingExpert] = useState(false) const fetchMcpStatus = useCallback(async () => { try { const resp = await fetch('/api/mcp/status') if (resp.ok) setMcpStatus(await resp.json()) } catch { /* ignore fetch errors */ } }, []) const fetchSkillList = useCallback(async () => { setSkillListLoading(true) try { const resp = await fetch('/api/skills') if (resp.ok) setSkillList(await resp.json()) } catch { /* ignore fetch errors */ } finally { setSkillListLoading(false) } }, []) const toggleSkill = useCallback(async (name: string, scope: string, enabled: boolean) => { const resp = await fetch('/api/skills/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, scope, enabled }), }) return resp }, []) const fetchSubagentList = useCallback(async () => { setSubagentListLoading(true) try { const resp = await fetch('/api/subagents') if (resp.ok) setSubagentList(await resp.json()) } catch { /* ignore fetch errors */ } finally { setSubagentListLoading(false) } }, []) const toggleSubagent = useCallback(async (name: string, scope: string, enabled: boolean) => { const resp = await fetch('/api/subagents/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, scope, enabled }), }) return resp }, []) const fetchExpertList = useCallback(async () => { setExpertListLoading(true) try { const resp = await fetch('/api/experts') if (resp.ok) setExpertList(await resp.json()) } catch { /* ignore fetch errors */ } finally { setExpertListLoading(false) } }, []) const toggleExpert = useCallback(async (name: string, scope: string, enabled: boolean) => { const resp = await fetch('/api/experts/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, scope, enabled }), }) return resp }, []) const createExpert = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => { const resp = await fetch('/api/experts/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) return resp }, []) const updateExpert = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => { const resp = await fetch('/api/experts/update', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) return resp }, []) const deleteExpert = useCallback(async (name: string, scope: string) => { const resp = await fetch(`/api/experts/delete?name=${encodeURIComponent(name)}&scope=${encodeURIComponent(scope)}`, { method: 'DELETE', }) return resp }, []) const handleClose = useCallback(() => { if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return onClose() }, [dirty, onClose]) // Load config useEffect(() => { fetch('/api/config').then(r => r.json()).then(data => { setConfig(data) setLoading(false) }).catch(e => { setError('加载配置失败: ' + e.message); 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 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]) 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('') try { const resp = await fetch('/api/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config }), }) const data = await resp.json() if (!resp.ok) throw new Error(data.message || data.error || '保存失败') // 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) } catch (e: any) { setError(e.message || '保存失败') } finally { setSaving(false) } } const handleRestart = async () => { setShowRestartDialog(false) setRestarting(true) try { const resp = await fetch('/api/restart', { method: 'POST' }) const data = await resp.json() if (resp.status === 409) { setToast(data.message || '有任务运行中,请等待完成后再试') setRestarting(false) setTimeout(() => setToast(''), 5000) return } if (!resp.ok) 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)) try { const r = await fetch('/health') if (r.ok) { const refreshed = await fetch('/api/config').then(r => r.json()) setConfig(refreshed) setToast('服务已重启,配置已生效') setRestarting(false) setTimeout(() => setToast(''), 3000) return } } catch { /* gateway not ready yet */ } } setToast('重启超时,请手动刷新页面') setRestarting(false) setTimeout(() => setToast(''), 5000) } poll() } catch (e: any) { setError(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 toggleSkill(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, explore_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, 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()}
) 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 toggleSubagent(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) } } return ( {subagentListLoading && subagents.length === 0 ? (
加载中...
) : subagents.length === 0 ? (

未发现任何子代理

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

{subagent.description}

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 toggleExpert(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 ?? '', }) } const handleDelete = async (name: string) => { if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return try { const resp = await deleteExpert(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 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 { const resp = isEdit ? await updateExpert({ name: editingExpert.nameField, scope: 'project', description: editingExpert.description, body: editingExpert.body, }) : await createExpert({ name: editingExpert.nameField, description: editingExpert.description, body: editingExpert.body, 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: any) { setEditingExpertError(e.message || '网络错误') } finally { setSavingExpert(false) } } return (

{isEdit ? '编辑专家' : '添加专家'}

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} />