PicoBot/web/src/components/Settings/ConfigPage.tsx
oudecheng 76abdbd1de feat: MCP stdio 命令解析与错误诊断增强
- 新增 resolve_command_path:Windows 上搜索 .exe/.cmd/.bat 后缀
- 启动失败时给出友好错误信息(含 PATH 和建议)
- 捕获子进程 stderr 并在连接失败时回显
- ConfigPage 新增 cwd 工作目录字段
- 添加单元测试覆盖路径解析逻辑
2026-07-03 19:31:52 +08:00

993 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
} from 'lucide-react'
// ── Types ──────────────────────────────────────────────
interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record<string, string>; 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<string, string>
cwd?: string
base_url?: string
headers?: Record<string, string>
description?: string
}
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<string, ProviderConfig>
models: Record<string, ModelConfig>
agents: Record<string, AgentConfig>
time: TimeConfig
gateway: GatewayConfig
scheduler: SchedulerConfig
skills: SkillsConfig
tools: ToolsConfig
memory_maintenance: MemoryMaintenanceConfig
image_context: ImageContextConfig
subagents: SubagentsConfig
client: ClientConfig
channels: Record<string, any>
mcpServers: Record<string, McpServerConfig>
}
interface ConfigPageProps {
onClose: () => void
onSaveConnection?: (host: string, port: number) => void
}
type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'mcp' | 'channels'
const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'connection', label: '连接', icon: Wifi },
{ id: 'gateway', label: '网关', icon: Server },
{ id: 'providers', label: '服务商', icon: Cpu },
{ id: 'models', label: '模型', icon: Brain },
{ id: 'agents', label: '代理', icon: Bot },
{ id: 'time', label: '时间', icon: Clock },
{ id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'skills', label: '技能', icon: Wrench },
{ id: 'tools', label: '工具', icon: Settings },
{ id: 'memory', label: '记忆维护', icon: Users },
{ id: 'image', label: '图片上下文', icon: Image },
{ id: 'subagents', label: '子代理', icon: Bot },
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
{ id: 'channels', label: '渠道', icon: Radio },
]
// ── Shared UI primitives ───────────────────────────────
function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
return (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
)
}
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 (
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
)
}
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 (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
{t}
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
<div className="flex gap-2">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
)
}
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 (
<div className="space-y-4">
{/* Known sources as toggles */}
<div className="space-y-2">
{knownSources.map(src => (
<div key={src.key} className="flex items-center justify-between py-1.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
</div>
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
</div>
))}
</div>
{/* Custom paths (only shown when showCustom is true) */}
{showCustom && (
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider"></div>
{customPaths.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{customPaths.map((p, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
{p}
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
value={customInput}
onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills"
className={inputCls + ' !text-xs font-mono'}
/>
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{examplePaths && (
<p className="text-xs text-[var(--text-muted)]">
: {examplePaths.join('、')}
</p>
)}
</div>
)}
</div>
)
}
function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(name)
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? (
<input value={val} onChange={e => 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 />
) : (
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
)}
<div className="flex-1" />
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
)
}
// ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [config, setConfig] = useState<AppConfig | null>(null)
const [activeTab, setActiveTab] = useState<TabId>('gateway')
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<McpStatusResponse | null>(null)
const fetchMcpStatus = useCallback(async () => {
try {
const resp = await fetch('/api/mcp/status')
if (resp.ok) setMcpStatus(await resp.json())
} catch { /* ignore fetch errors */ }
}, [])
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])
// 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(<K extends keyof AppConfig>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<Loader2 className="h-8 w-8 text-[var(--accent-cyan)] animate-spin" />
</div>
)
if (!config) return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="text-red-400 text-center space-y-3">
<AlertTriangle className="h-10 w-10 mx-auto" />
<p>{error || '加载失败'}</p>
<button onClick={onClose} className="px-4 py-2 rounded-lg bg-[var(--bg-tertiary)] text-sm"></button>
</div>
</div>
)
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 = () => (
<div className="space-y-5">
<SectionCard title="WebSocket 连接">
<Field label="主机地址"><input value={connHost} onChange={e => { setConnHost(e.target.value); setConnError('') }} className={inputCls} placeholder="127.0.0.1" /></Field>
<Field label="端口号"><input type="number" value={connPort} onChange={e => { setConnPort(+e.target.value); setConnError('') }} min={1} max={65535} className={inputCls} placeholder="19876" /></Field>
{connError && <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{connError}</div>}
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">ws://{connHost.trim() || '...'}:{connPort || '...'}/ws</div>
</SectionCard>
<button onClick={handleSaveConnection} className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 transition-all">
<Wifi className="h-4 w-4" />
</button>
</div>
)
const renderGateway = () => (
<div className="space-y-5">
<SectionCard title="连接">
<Field label="主机地址"><input value={config.gateway.host} onChange={e => update('gateway', { ...config.gateway, host: e.target.value })} className={inputCls} /></Field>
<Field label="端口"><input type="number" value={config.gateway.port} onChange={e => update('gateway', { ...config.gateway, port: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
<SectionCard title="行为">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.gateway.show_tool_results} onChange={v => update('gateway', { ...config.gateway, show_tool_results: v })} /></div>
<Field label="Agent Prompt 重新注入间隔" hint="每多少轮对话重新注入系统提示"><input type="number" value={config.gateway.agent_prompt_reinject_every} onChange={e => update('gateway', { ...config.gateway, agent_prompt_reinject_every: +e.target.value })} className={inputCls} /></Field>
<Field label="最大并发请求数"><input type="number" value={config.gateway.max_concurrent_requests} onChange={e => update('gateway', { ...config.gateway, max_concurrent_requests: +e.target.value })} className={inputCls} /></Field>
<Field label="Session TTL (小时)" hint="留空表示不过期"><input type="number" value={config.gateway.session_ttl_hours ?? ''} onChange={e => { const v = e.target.value; update('gateway', { ...config.gateway, session_ttl_hours: v ? +v : undefined }) }} className={inputCls} placeholder="24" /></Field>
</SectionCard>
</div>
)
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<string, ProviderConfig> = {}
for (const [k, v] of entries) { newMap[k === oldName ? newName : k] = v }
update('providers', newMap)
}
const updProvider = (name: string, patch: Partial<ProviderConfig>) => {
update('providers', { ...config.providers, [name]: { ...config.providers[name], ...patch } })
}
return (
<div className="space-y-4">
{entries.map(([name, p]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delProvider(name)} onRename={n => renameProvider(name, n)} />
<div className="p-4 space-y-3">
<Field label="类型"><select value={p.type} onChange={e => updProvider(name, { type: e.target.value })} className={selectCls}><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></Field>
<Field label="Base URL"><input value={p.base_url} onChange={e => updProvider(name, { base_url: e.target.value })} className={inputCls} /></Field>
<Field label="API Key"><input type="password" value={p.api_key} onChange={e => updProvider(name, { api_key: e.target.value })} className={inputCls} /></Field>
<Field label="LLM 超时 (秒)"><input type="number" value={p.llm_timeout_secs} onChange={e => updProvider(name, { llm_timeout_secs: +e.target.value })} className={inputCls} /></Field>
<Field label="记忆维护超时 (秒)"><input type="number" value={p.memory_maintenance_timeout_secs} onChange={e => updProvider(name, { memory_maintenance_timeout_secs: +e.target.value })} className={inputCls} /></Field>
</div>
</div>
))}
<button onClick={addProvider} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" />
</button>
</div>
)
}
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<ModelConfig>) => update('models', { ...config.models, [name]: { ...config.models[name], ...patch } })
return (
<div className="space-y-4">
{entries.map(([name, m]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delModel(name)} />
<div className="p-4 space-y-3">
<Field label="Model ID"><input value={m.model_id} onChange={e => updModel(name, { model_id: e.target.value })} className={inputCls} /></Field>
<Field label="Temperature" hint="控制回复随机性0 表示确定性输出,值越大越随机。留空使用模型默认值"><input type="number" step="0.1" value={m.temperature ?? ''} onChange={e => updModel(name, { temperature: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="0.7" /></Field>
<Field label="Max Tokens" hint="模型单次回复最大生成 token 数,超出会被截断。留空使用模型默认值(如 4096/8192"><input type="number" value={m.max_tokens ?? ''} onChange={e => updModel(name, { max_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="4096" /></Field>
<Field label="Context Window Tokens" hint="模型上下文窗口大小,用于内部历史消息压缩/裁剪计算。留空默认 128000"><input type="number" value={m.context_window_tokens ?? ''} onChange={e => updModel(name, { context_window_tokens: e.target.value ? +e.target.value : undefined })} className={inputCls} placeholder="128000" /></Field>
</div>
</div>
))}
<button onClick={addModel} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" />
</button>
</div>
)
}
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<AgentConfig>) => update('agents', { ...config.agents, [name]: { ...config.agents[name], ...patch } })
return (
<div className="space-y-4">
{entries.map(([name, a]) => (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delAgent(name)} />
<div className="p-4 space-y-3">
<Field label="Provider"><select value={a.provider} onChange={e => updAgent(name, { provider: e.target.value })} className={selectCls}>{providerNames.map(p => <option key={p} value={p}>{p}</option>)}</select></Field>
<Field label="Model"><select value={a.model} onChange={e => updAgent(name, { model: e.target.value })} className={selectCls}>{modelNames.map(m => <option key={m} value={m}>{m}</option>)}</select></Field>
<Field label="最大工具迭代次数"><input type="number" value={a.max_tool_iterations} onChange={e => updAgent(name, { max_tool_iterations: +e.target.value })} className={inputCls} /></Field>
<Field label="工具结果最大字符数"><input type="number" value={a.tool_result_max_chars} onChange={e => updAgent(name, { tool_result_max_chars: +e.target.value })} className={inputCls} /></Field>
<Field label="上下文工具结果裁剪字符数"><input type="number" value={a.context_tool_result_trim_chars} onChange={e => updAgent(name, { context_tool_result_trim_chars: +e.target.value })} className={inputCls} /></Field>
</div>
</div>
))}
<button onClick={addAgent} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" />
</button>
</div>
)
}
const renderTime = () => (
<SectionCard title="时区设置">
<Field label="时区" hint="IANA 格式">
<select
value={config.time.timezone}
onChange={e => update('time', { timezone: e.target.value })}
className={inputCls}
>
{TIMEZONE_OPTIONS.map(tz => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</Field>
</SectionCard>
)
const renderScheduler = () => (
<div className="space-y-5">
<SectionCard title="调度器">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.scheduler.enabled} onChange={v => update('scheduler', { ...config.scheduler, enabled: v })} /></div>
<Field label="Tick 分辨率 (ms)"><input type="number" value={config.scheduler.tick_resolution_ms} onChange={e => update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value })} className={inputCls} /></Field>
<Field label="工作队列容量"><input type="number" value={config.scheduler.worker_queue_capacity} onChange={e => update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value })} className={inputCls} /></Field>
<Field label="Misfire 策略"><select value={config.scheduler.misfire_policy} onChange={e => update('scheduler', { ...config.scheduler, misfire_policy: e.target.value as any })} className={selectCls}><option value="skip"> (Skip)</option><option value="catch_up"> (Catch Up)</option></select></Field>
</SectionCard>
</div>
)
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 = () => (
<div className="space-y-5">
<SectionCard title="技能系统">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.skills.enabled} onChange={v => update('skills', { ...config.skills, enabled: v })} /></div>
<Field label="最大索引字符数"><input type="number" value={config.skills.max_index_chars} onChange={e => update('skills', { ...config.skills, max_index_chars: +e.target.value })} className={inputCls} /></Field>
<Field label="最大展示技能数"><input type="number" value={config.skills.max_listed_skills} onChange={e => update('skills', { ...config.skills, max_listed_skills: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.skills.sources}
onChange={v => update('skills', { ...config.skills, sources: v })}
knownSources={SKILL_KNOWN_SOURCES}
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
/>
</SectionCard>
</div>
)
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 = () => (
<div className="space-y-5">
<SectionCard title="禁用工具列表">
<TagEditor tags={config.tools.disabled} onChange={v => update('tools', { ...config.tools, disabled: v })} />
</SectionCard>
<SectionCard title="Task 子代理">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"> Task </span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div>
<Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
<Field label="探索模式最大执行时间 (秒)"><input type="number" value={config.tools.task.explore_max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, explore_max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
</SectionCard>
<SectionCard title="允许的工具列表">
<SourceEditor
sources={config.tools.task.allowed_tools}
onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, allowed_tools: v } })}
knownSources={TASK_KNOWN_TOOLS}
showCustom={false}
/>
</SectionCard>
</div>
)
const renderMemory = () => (
<SectionCard title="记忆维护">
<Field label="最大合并比例" hint="0.0 - 1.0,单次最多合并/删除的记忆比例"><input type="number" step="0.05" min="0" max="1" value={config.memory_maintenance.max_merge_ratio} onChange={e => update('memory_maintenance', { ...config.memory_maintenance, max_merge_ratio: +e.target.value })} className={inputCls} /></Field>
<Field label="最小保留记忆数"><input type="number" value={config.memory_maintenance.min_memories_to_keep} onChange={e => update('memory_maintenance', { ...config.memory_maintenance, min_memories_to_keep: +e.target.value })} className={inputCls} /></Field>
<Field label="单组最大合并数"><input type="number" value={config.memory_maintenance.max_merge_per_group} onChange={e => update('memory_maintenance', { ...config.memory_maintenance, max_merge_per_group: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
)
const renderImage = () => (
<SectionCard title="图片上下文">
<Field label="上下文中最大图片数" hint="发送给模型的图片数量上限"><input type="number" value={config.image_context.max_images_in_context} onChange={e => update('image_context', { ...config.image_context, max_images_in_context: +e.target.value })} className={inputCls} /></Field>
<Field label="图片最大存活轮次" hint="超过此轮次后不再提交给模型"><input type="number" value={config.image_context.max_image_age_rounds} onChange={e => update('image_context', { ...config.image_context, max_image_age_rounds: +e.target.value })} className={inputCls} /></Field>
</SectionCard>
)
const renderSubagents = () => (
<div className="space-y-5">
<SectionCard title="子代理">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.subagents.enabled} onChange={v => update('subagents', { ...config.subagents, enabled: v })} /></div>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.subagents.sources}
onChange={v => update('subagents', { ...config.subagents, sources: v })}
knownSources={SUBAGENT_KNOWN_SOURCES}
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
/>
</SectionCard>
</div>
)
const renderMcp = () => {
const entries = Object.entries(config.mcpServers)
const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)
const addMcp = () => {
const name = prompt('MCP 服务器名称:')?.trim()
if (name && !config.mcpServers[name]) {
update('mcpServers', { ...config.mcpServers, [name]: { type: 'stdio', is_active: true, command: '', args: [] } })
}
}
const delMcp = (name: string) => { if (confirm(`删除 MCP 服务器 "${name}"?`)) { const { [name]: _, ...rest } = config.mcpServers; update('mcpServers', rest) } }
const updMcp = (name: string, patch: Partial<McpServerConfig>) => update('mcpServers', { ...config.mcpServers, [name]: { ...config.mcpServers[name], ...patch } })
return (
<div className="space-y-4">
{/* MCP Status Summary */}
{mcpStatus && mcpStatus.enabled && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-[var(--bg-tertiary)] text-xs">
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${mcpStatus.connected_servers > 0 ? 'bg-green-400' : 'bg-gray-400'}`} />
<span className="text-[var(--text-secondary)]">{mcpStatus.connected_servers}/{mcpStatus.total_servers} </span>
</div>
{mcpStatus.failed_servers > 0 && (
<span className="text-red-400">{mcpStatus.failed_servers} </span>
)}
<span className="text-[var(--text-muted)]">{mcpStatus.total_tools} </span>
<button onClick={fetchMcpStatus} className="ml-auto px-2 py-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新状态">
<RefreshCw className="h-3 w-3" />
</button>
</div>
)}
{entries.map(([name, s]) => {
const st = statusFor(name)
return (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="flex items-center gap-2 p-3 border-b border-[var(--border-color)]">
{st ? (
st.connected
? <span className="inline-flex items-center gap-1 text-xs text-green-400"><span className="w-2 h-2 rounded-full bg-green-400" /> {st.tool_count} </span>
: st.error
? <span className="inline-flex items-center gap-1 text-xs text-red-400" title={st.error}><span className="w-2 h-2 rounded-full bg-red-400" /> </span>
: <span className="inline-flex items-center gap-1 text-xs text-gray-400"><span className="w-2 h-2 rounded-full bg-gray-400" /> </span>
) : null}
<span className="flex-1 text-sm font-medium text-[var(--text-primary)]">{name}</span>
<button onClick={() => delMcp(name)} className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
<div className="p-4 space-y-3">
<Field label="传输类型">
<select value={s.type} onChange={e => updMcp(name, { type: e.target.value as McpServerConfig['type'] })} className={selectCls}>
<option value="stdio">stdio ()</option>
<option value="streamableHttp">streamableHttp (HTTP)</option>
</select>
</Field>
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={s.is_active} onChange={v => updMcp(name, { is_active: v })} /></div>
<Field label="描述"><input value={s.description ?? ''} onChange={e => updMcp(name, { description: e.target.value || undefined })} className={inputCls} placeholder="可选描述" /></Field>
{s.type === 'stdio' && (
<>
<Field label="命令" hint="如 npx, node, cargo, uv"><input value={s.command ?? ''} onChange={e => updMcp(name, { command: e.target.value })} className={inputCls} placeholder="npx" /></Field>
<Field label="参数" hint="空格分隔"><input value={(s.args ?? []).join(' ')} onChange={e => updMcp(name, { args: e.target.value ? e.target.value.split(/\s+/) : [] })} className={inputCls} placeholder="-y @modelcontextprotocol/server-filesystem /tmp" /></Field>
<Field label="工作目录 (cwd)" hint="可选。子进程运行目录,常用于 uv/python 项目解析 pyproject.toml 或 venv"><input value={s.cwd ?? ''} onChange={e => updMcp(name, { cwd: e.target.value || undefined })} className={inputCls} placeholder="E:\code_project\my-mcp-server" /></Field>
<Field label="环境变量" hint="KEY=VALUE每行一个">
<textarea
value={Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
onChange={e => {
const lines = e.target.value.split('\n').filter(l => l.includes('='))
const env: Record<string, string> = {}
lines.forEach(l => { const [k, ...rest] = l.split('='); if (k) env[k.trim()] = rest.join('=').trim() })
updMcp(name, { env: Object.keys(env).length > 0 ? env : undefined })
}}
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
placeholder="API_KEY=xxx"
/>
</Field>
</>
)}
{(s.type === 'streamableHttp' || s.type === 'http') && (
<>
<Field label="Base URL"><input value={s.base_url ?? ''} onChange={e => updMcp(name, { base_url: e.target.value })} className={inputCls} placeholder="http://localhost:3000/mcp" /></Field>
<Field label="请求头" hint="KEY=VALUE每行一个支持 ${ENV_VAR}">
<textarea
value={Object.entries(s.headers ?? {}).map(([k, v]) => `${k}=${v}`).join('\n')}
onChange={e => {
const lines = e.target.value.split('\n').filter(l => l.includes('='))
const headers: Record<string, string> = {}
lines.forEach(l => { const [k, ...rest] = l.split('='); if (k) headers[k.trim()] = rest.join('=').trim() })
updMcp(name, { headers: Object.keys(headers).length > 0 ? headers : undefined })
}}
className={inputCls + ' min-h-[60px] resize-y font-mono text-xs'}
placeholder="Authorization=Bearer ${TOKEN}"
/>
</Field>
</>
)}
</div>
</div>
)
})}
<button onClick={addMcp} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" /> MCP
</button>
</div>
)
}
const renderChannels = () => {
const entries = Object.entries(config.channels)
const addChannel = () => {
const name = prompt('渠道名称:')?.trim()
if (name && !config.channels[name]) {
update('channels', { ...config.channels, [name]: { type: 'feishu', enabled: false, app_id: '', app_secret: '' } })
}
}
const delChannel = (name: string) => { if (confirm(`删除渠道 "${name}"?`)) { const { [name]: _, ...rest } = config.channels; update('channels', rest) } }
const updChannel = (name: string, patch: Record<string, any>) => update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } })
const getChannelType = (ch: any): string => {
if (ch.type) return ch.type
if (ch.app_id !== undefined || ch.app_secret !== undefined) return 'feishu'
if (ch.cred_path !== undefined) return 'wechat'
return 'feishu'
}
return (
<div className="space-y-4">
{entries.map(([name, ch]) => {
const chType = getChannelType(ch)
return (
<div key={name} className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<MapEntryHeader name={name} onDelete={() => delChannel(name)} />
<div className="p-4 space-y-3">
<Field label="渠道类型">
<select value={chType} onChange={e => updChannel(name, { type: e.target.value })} className={selectCls}>
<option value="feishu"> (Feishu)</option>
<option value="wechat"> (WeChat)</option>
</select>
</Field>
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={!!ch.enabled} onChange={v => updChannel(name, { enabled: v })} /></div>
{chType === 'feishu' && (
<>
<Field label="App ID"><input value={ch.app_id ?? ''} onChange={e => updChannel(name, { app_id: e.target.value })} className={inputCls} /></Field>
<Field label="App Secret"><input type="password" value={ch.app_secret ?? ''} onChange={e => updChannel(name, { app_secret: e.target.value })} className={inputCls} /></Field>
<Field label="绑定 Agent" hint="留空使用 default">
<select value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={selectCls}>
<option value="">default</option>
{Object.keys(config.agents).map(a => <option key={a} value={a}>{a}</option>)}
</select>
</Field>
<Field label="最大消息字符数"><input type="number" value={ch.max_message_chars ?? 20000} onChange={e => updChannel(name, { max_message_chars: +e.target.value })} className={inputCls} /></Field>
<Field label="回复上下文最大字符数"><input type="number" value={ch.reply_context_max_chars ?? 20000} onChange={e => updChannel(name, { reply_context_max_chars: +e.target.value })} className={inputCls} /></Field>
</>
)}
{chType === 'wechat' && (
<>
<Field label="凭证文件路径"><input value={ch.cred_path ?? ''} onChange={e => updChannel(name, { cred_path: e.target.value })} className={inputCls} placeholder="~/.picobot/wechat/credentials.json" /></Field>
<Field label="Base URL"><input value={ch.base_url ?? 'https://ilinkai.weixin.qq.com'} onChange={e => updChannel(name, { base_url: e.target.value })} className={inputCls} /></Field>
<Field label="绑定 Agent" hint="留空使用 default">
<select value={ch.agent ?? ''} onChange={e => updChannel(name, { agent: e.target.value })} className={selectCls}>
<option value="">default</option>
{Object.keys(config.agents).map(a => <option key={a} value={a}>{a}</option>)}
</select>
</Field>
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={!!ch.force_login} onChange={v => updChannel(name, { force_login: v })} /></div>
</>
)}
</div>
</div>
)
})}
<button onClick={addChannel} className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center">
<Plus className="h-4 w-4" />
</button>
</div>
)
}
const renderContent = () => {
switch (activeTab) {
case 'connection': return renderConnection()
case 'gateway': return renderGateway()
case 'providers': return renderProviders()
case 'models': return renderModels()
case 'agents': return renderAgents()
case 'time': return renderTime()
case 'scheduler': return renderScheduler()
case 'skills': return renderSkills()
case 'tools': return renderTools()
case 'memory': return renderMemory()
case 'image': return renderImage()
case 'subagents': return renderSubagents()
case 'mcp': return renderMcp()
case 'channels': return renderChannels()
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={handleClose}>
<div
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="shrink-0 flex items-center gap-3 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md">
<Settings className="h-5 w-5 text-[var(--accent-cyan)]" />
<span className="text-lg font-semibold text-[var(--text-primary)]"></span>
{dirty && <span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full"></span>}
<div className="flex-1" />
<button onClick={handleClose} className="p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors" title="关闭 (Esc)">
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="flex flex-1 min-h-0">
{/* Left sidebar tabs */}
<div className="shrink-0 w-48 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/40 overflow-y-auto py-2">
{TABS.map(tab => {
const Icon = tab.icon
const active = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-all relative ${
active
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/5'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
>
{active && <span className="absolute left-0 top-1 bottom-1 w-[2px] rounded-r bg-[var(--accent-cyan)]" />}
<Icon className="h-4 w-4 shrink-0" />
<span>{tab.label}</span>
</button>
)
})}
</div>
{/* Right content */}
<div className="flex-1 overflow-y-auto p-6">
{renderContent()}
</div>
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md flex items-center gap-3">
{error && <span className="text-sm text-red-400 truncate max-w-xs">{error}</span>}
<div className="flex-1" />
<button onClick={handleClose} className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors">
</button>
<button
onClick={handleSave}
disabled={saving || !dirty}
className="flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? '保存中...' : '保存配置'}
</button>
</div>
{/* Toast */}
{toast && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]">
{restarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
{toast}
</div>
)}
{/* Restart confirmation dialog */}
{showRestartDialog && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 max-w-sm mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[var(--accent-cyan)]/10">
<RefreshCw className="h-5 w-5 text-[var(--accent-cyan)]" />
</div>
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)]"></h3>
<p className="text-xs text-[var(--text-muted)]">使</p>
</div>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRestartDialog(false)}
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleRestart}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}