PicoBot/web/src/components/Settings/ConfigPage.tsx
oudecheng 5651c4ae7f feat: 设置页新增专家 Tab,支持专家增删改查与启用切换
- 新增 ExpertsConfig/ExpertItem/ExpertListResponse 类型与 AppConfig.experts 字段

- TABS 数组加入专家 Tab (UserCheck 图标),TabId 含 'experts'

- renderExperts 渲染启用开关、来源目录编辑器、已发现专家列表与添加按钮

- 专家编辑 modal 含 name(编辑时只读)/description(必填)/body(textarea) 三字段

- 导出 selectExpert 与 getSelectedExpert 模块级函数供 ExpertSelector 复用

- Tab 切换自动拉取列表,专家 toggle 即时生效不触发未保存警告

- initialTab prop 支持从对话框入口直达专家 Tab
2026-07-06 18:07:54 +08:00

1569 lines
78 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, UserCheck, Pencil,
} 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 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<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
experts: ExpertsConfig
client: ClientConfig
channels: Record<string, any>
mcpServers: Record<string, McpServerConfig>
}
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 (
<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, subtitle, children }: { title: string; subtitle?: 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">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
</div>
</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>
)
}
// ── 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<AppConfig | null>(null)
const [activeTab, setActiveTab] = useState<TabId>(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<McpStatusResponse | null>(null)
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
const [skillListLoading, setSkillListLoading] = useState(false)
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
const [subagentListLoading, setSubagentListLoading] = useState(false)
const [expertList, setExpertList] = useState<ExpertListResponse | null>(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(<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>
{renderDiscoveredSkills()}
</div>
)
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 (
<SectionCard title="已发现技能" subtitle="即时生效">
{skillListLoading && skills.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : skills.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{skills.map(skill => {
const isEnabled = skill.disabled_in_scopes.length === 0
return (
<div key={skill.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{skill.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{skill.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{skill.description}</p>
</div>
<Toggle checked={isEnabled} onChange={() => handleToggle(skill.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
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>
{renderDiscoveredSubagents()}
</div>
)
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 (
<SectionCard title="已发现子代理" subtitle="即时生效">
{subagentListLoading && subagents.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : subagents.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{subagents.map(subagent => {
const isEnabled = subagent.disabled_in_scopes.length === 0
return (
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{subagent.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
</div>
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
const EXPERT_KNOWN_SOURCES: KnownSource[] = [
{ key: 'user', label: '用户专家', description: '~/.picobot/experts' },
{ key: 'project', label: '项目专家', description: '.picobot/experts' },
]
const renderExperts = () => (
<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.experts.enabled} onChange={v => update('experts', { ...config.experts, enabled: v })} /></div>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.experts.sources}
onChange={v => update('experts', { ...config.experts, sources: v })}
knownSources={EXPERT_KNOWN_SOURCES}
examplePaths={['D:\\my-experts', '/home/user/shared-experts']}
/>
</SectionCard>
{renderDiscoveredExperts()}
<button
onClick={() => {
setEditingExpertError('')
setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '' })
}}
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>
{renderExpertModal()}
</div>
)
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 (
<SectionCard title="已发现专家" subtitle="即时生效">
{expertListLoading && experts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : experts.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{experts.map(expert => {
const isEnabled = expert.disabled_in_scopes.length === 0
return (
<div key={expert.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{expert.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{expert.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{expert.description}</p>
</div>
<button
onClick={() => handleEdit(expert)}
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(expert.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Toggle checked={isEnabled} onChange={() => handleToggle(expert.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
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 (
<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 w-[90%] max-w-lg mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="flex items-center gap-2 mb-4">
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
{isEdit ? '编辑专家' : '添加专家'}
</h3>
</div>
<div className="space-y-3">
<Field label="名称">
<input
value={editingExpert.nameField}
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
disabled={isEdit}
placeholder="如 translator"
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
autoFocus={!isEdit}
/>
</Field>
<Field label="描述" hint="必填,简要说明专家身份">
<input
value={editingExpert.description}
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
placeholder="如 翻译专家"
className={inputCls}
/>
</Field>
<Field label="专家提示词正文" hint="markdown 格式,将作为系统提示词注入">
<textarea
value={editingExpert.body}
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
placeholder="你是一名专业翻译..."
className={inputCls + ' min-h-[200px] resize-y font-mono text-xs'}
/>
</Field>
{editingExpertError && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{editingExpertError}
</div>
)}
</div>
<div className="flex gap-3 justify-end mt-5">
<button
onClick={() => setEditingExpert(null)}
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={!canSave || savingExpert}
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 disabled:opacity-40 disabled:cursor-not-allowed"
>
{savingExpert ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{savingExpert ? '保存中...' : '保存'}
</button>
</div>
</div>
</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 'experts': return renderExperts()
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]">
<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>
)
}