- 新增 resolve_command_path:Windows 上搜索 .exe/.cmd/.bat 后缀 - 启动失败时给出友好错误信息(含 PATH 和建议) - 捕获子进程 stderr 并在连接失败时回显 - ConfigPage 新增 cwd 工作目录字段 - 添加单元测试覆盖路径解析逻辑
993 lines
56 KiB
TypeScript
993 lines
56 KiB
TypeScript
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>
|
||
)
|
||
}
|