PicoBot/web/src/components/Settings/ConfigPage.tsx
oudecheng 6901659849 fix(mcp): 限制 server 名称字符并兜底 tool_name 清洗
OpenAI 要求 function name 匹配 ^[a-zA-Z0-9_-]+$,否则整个请求 400。MCP 工具名由 mcp_{server_key}_{tool_name} 拼成,两个输入源:server_key 用户可控(前端 addMcp/renameMcp 正则校验 + toast),tool_name 由 MCP server 上报(后端 sanitize_tool_name 替换非法字符为 _)。

前端:MCP 卡片头部改用 MapEntryHeader 支持点击重命名,状态指示灯移到上方独立行。顺手修了 MapEntryHeader 进入编辑时 val 未同步当前 name 的 bug。

后端:保留 server_key 和 tool_name 原值用于路由,仅清洗 LLM 可见的 full_name,并在发生清洗时打 warn 日志便于定位。
2026-08-04 11:17:41 +08:00

2609 lines
101 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 } from 'react';
import {
Settings,
Save,
X,
Plus,
Trash2,
AlertTriangle,
Loader2,
Wifi,
CheckCircle,
RefreshCw,
UserCheck,
Pencil,
Bot,
} from 'lucide-react';
// ── Extracted modules ─────────────────────────────────
import type {
AppConfig,
ConfigPageProps,
TabId,
ProviderConfig,
ModelConfig,
AgentConfig,
McpServerConfig,
McpStatusResponse,
SkillListResponse,
SubagentListResponse,
SubagentItem,
ToolsListResponse,
ExpertItem,
ExpertListResponse,
CapabilityPolicy,
KnownSource,
SchedulerConfig,
ChannelConfig,
ModelOptionsResponse,
} from './types';
import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants';
import {
Field,
Toggle,
TagEditor,
SectionCard,
SourceEditor,
MapEntryHeader,
CheckboxList,
ModalHeader,
ModalFooter,
} from './ui';
import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config';
import { listSkills, toggleSkill } from '../../api/skills';
import { listTools } from '../../api/tools';
import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents';
import {
listExperts,
toggleExpert,
createExpert,
updateExpert,
deleteExpert,
listModelOptions,
} from '../../api/experts';
import { getMcpStatus } from '../../api/mcp';
export { getSelectedExpert, selectExpert } from '../../api/experts';
// ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) {
const [config, setConfig] = useState<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 [toolList, setToolList] = useState<ToolsListResponse | null>(null);
const [toolListLoading, setToolListLoading] = 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 [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
const [editingExpert, setEditingExpert] = useState<{
mode: 'create' | 'edit';
name?: string;
scope: string;
nameField: string;
description: string;
body: string;
provider: string;
model: string;
allowedSkills: string[];
deniedSkills: string[];
allowedTools: string[];
deniedTools: string[];
allowedSubagents: string[];
deniedSubagents: string[];
} | null>(null);
const [editingExpertError, setEditingExpertError] = useState('');
const [savingExpert, setSavingExpert] = useState(false);
const [editingSubagent, setEditingSubagent] = useState<{
name: string;
description: string;
provider: string;
model: string;
allowedSkills: string[];
deniedSkills: string[];
allowedTools: string[];
deniedTools: string[];
allowedSubagents: string[];
deniedSubagents: string[];
} | null>(null);
const [editingSubagentError, setEditingSubagentError] = useState('');
const [savingSubagent, setSavingSubagent] = useState(false);
const fetchMcpStatus = useCallback(async () => {
const data = await getMcpStatus();
if (data) setMcpStatus(data);
}, []);
const fetchSkillList = useCallback(async () => {
setSkillListLoading(true);
const data = await listSkills();
if (data) setSkillList(data);
setSkillListLoading(false);
}, []);
const fetchToolList = useCallback(async () => {
setToolListLoading(true);
const data = await listTools();
if (data) setToolList(data);
setToolListLoading(false);
}, []);
const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleSkill(name, scope, enabled);
}, []);
const fetchSubagentList = useCallback(async () => {
setSubagentListLoading(true);
const data = await listSubagents();
if (data) setSubagentList(data);
setSubagentListLoading(false);
}, []);
const toggleSubagentCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleSubagent(name, scope, enabled);
}, []);
const updateSubagentCb = useCallback(
async (payload: {
name: string;
description?: string;
body?: string;
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}) => {
return updateSubagent(payload);
},
[],
);
const fetchExpertList = useCallback(async () => {
setExpertListLoading(true);
const data = await listExperts();
if (data) setExpertList(data);
setExpertListLoading(false);
}, []);
const toggleExpertCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleExpert(name, scope, enabled);
}, []);
const createExpertCb = useCallback(
async (payload: {
name: string;
description: string;
body: string;
scope: string;
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}) => {
return createExpert(payload);
},
[],
);
const updateExpertCb = useCallback(
async (payload: {
name: string;
scope: string;
description?: string;
body?: string;
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}) => {
return updateExpert(payload);
},
[],
);
const deleteExpertCb = useCallback(async (name: string, scope: string) => {
return deleteExpert(name, scope);
}, []);
const handleClose = useCallback(() => {
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return;
onClose();
}, [dirty, onClose]);
// Load config
useEffect(() => {
getAppConfig().then(([data, err]) => {
if (data) setConfig(data);
if (err) setError('加载配置失败: ' + err);
setLoading(false);
});
}, []);
// Fetch MCP status when MCP tab is selected
useEffect(() => {
if (activeTab === 'mcp') fetchMcpStatus();
}, [activeTab, fetchMcpStatus]);
// Fetch skill list when skills tab is selected
useEffect(() => {
if (activeTab === 'skills') fetchSkillList();
}, [activeTab, fetchSkillList]);
// Fetch subagent list when subagents tab is selected
useEffect(() => {
if (activeTab === 'subagents') fetchSubagentList();
}, [activeTab, fetchSubagentList]);
// Fetch skills + tools + model options when experts/subagents tab is selected (for capability CheckboxList & provider/model dropdowns)
useEffect(() => {
if (activeTab === 'experts' || activeTab === 'subagents') {
if (!skillList) fetchSkillList();
if (!toolList) fetchToolList();
if (!modelOptions)
listModelOptions().then((data) => {
if (data) setModelOptions(data);
});
}
}, [activeTab, fetchSkillList, fetchToolList, skillList, toolList, modelOptions]);
// experts tab 编辑专家时也需要子代理勾选列表按需加载subagents tab 由下方独立 useEffect 刷新)
useEffect(() => {
if (activeTab === 'experts' && !subagentList) fetchSubagentList();
}, [activeTab, fetchSubagentList, subagentList]);
// Fetch expert list when experts tab is selected
useEffect(() => {
if (activeTab === 'experts') fetchExpertList();
}, [activeTab, fetchExpertList]);
// ESC to close
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [handleClose]);
// 子模态框打开时ESC 键仅关闭子模态框(阻止冒泡到 ConfigPage 全局 ESC避免关闭整个配置页
// 必须放在所有条件 return 之前,否则 loading 首次渲染时不执行此 hook
// config 加载后重新渲染才执行 → hooks 数量不一致 → React 崩溃
useEffect(() => {
if (!editingExpert && !editingSubagent) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
setEditingExpert(null);
setEditingSubagent(null);
}
};
window.addEventListener('keydown', handler, true);
return () => window.removeEventListener('keydown', handler, true);
}, [editingExpert, editingSubagent]);
const update = useCallback(<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('');
const [ok, err] = await updateAppConfig(config);
if (!ok) {
setError(err || '保存失败');
} else {
// Config is now synced to both disk and in-memory state,
// so the local state is already correct. No need to re-fetch.
setDirty(false);
// Show restart confirmation dialog
setShowRestartDialog(true);
}
setSaving(false);
};
const handleRestart = async () => {
setShowRestartDialog(false);
setRestarting(true);
try {
const { status, data } = await restartGateway();
if (status === 409) {
setToast(data.message || '有任务运行中,请等待完成后再试');
setRestarting(false);
setTimeout(() => setToast(''), 5000);
return;
}
if (status < 200 || status >= 300) throw new Error(data.message || '重启失败');
setToast('服务正在重启,页面将自动重连...');
// Poll /health until gateway is back
const poll = async () => {
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
if (await checkHealth()) {
const [refreshed] = await getAppConfig();
if (refreshed) setConfig(refreshed);
setToast('服务已重启,配置已生效');
setRestarting(false);
setTimeout(() => setToast(''), 3000);
return;
}
}
setToast('重启超时,请手动刷新页面');
setRestarting(false);
setTimeout(() => setToast(''), 5000);
};
poll();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '重启失败');
setRestarting(false);
}
};
// ── Render sections ──────────────────────────────────
if (loading)
return (
<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 SchedulerConfig['misfire_policy'],
})
}
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 toggleSkillCb(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
// Rollback
setSkillList(prevSkillList);
setToast(data.error || '切换技能状态失败');
setTimeout(() => setToast(''), 3000);
return;
}
// Update with server response
setSkillList({
...prevSkillList,
skills: prevSkillList.skills.map((s) =>
s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s,
),
});
} catch {
// Rollback
setSkillList(prevSkillList);
setToast('网络错误,切换技能状态失败');
setTimeout(() => setToast(''), 3000);
}
};
return (
<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="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>
<Field
label="最大嵌套深度"
hint="允许的子代理最大嵌套层数。1=仅子代理2=子代理+孙代理默认0=禁止嵌套。深度达上限时移除 task 工具以防无限递归"
>
<input
type="number"
min={0}
value={config.tools.task.max_nesting_depth}
onChange={(e) =>
update('tools', {
...config.tools,
task: {
...config.tools.task,
max_nesting_depth: Math.max(0, +e.target.value || 0),
},
})
}
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()}
{renderSubagentModal()}
</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 toggleSubagentCb(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
setSubagentList(prevList);
setToast(data.error || '切换子代理状态失败');
setTimeout(() => setToast(''), 3000);
return;
}
setSubagentList({
...prevList,
subagents: prevList.subagents.map((s) =>
s.name === name ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } : s,
),
});
} catch {
setSubagentList(prevList);
setToast('网络错误,切换子代理状态失败');
setTimeout(() => setToast(''), 3000);
}
};
const toolLabel = (key: string): string => {
const known = TASK_KNOWN_TOOLS.find((t) => t.key === key);
return known ? known.label : key;
};
const renderToolTags = (label: string, tools: string[] | undefined, tone: 'allow' | 'deny') => {
if (!tools || tools.length === 0) return null;
const tagCls =
tone === 'allow'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400';
return (
<div className="flex items-center gap-1 flex-wrap mt-1">
<span className="text-[10px] text-[var(--text-muted)]">{label}:</span>
{tools.map((t) => (
<span key={t} className={`text-[10px] px-1.5 py-0.5 rounded ${tagCls}`}>
{toolLabel(t)}
</span>
))}
</div>
);
};
const handleEditSubagent = (subagent: SubagentItem) => {
setEditingSubagentError('');
setEditingSubagent({
name: subagent.name,
description: subagent.description,
provider: subagent.provider ?? '',
model: subagent.model ?? '',
allowedSkills: subagent.capability?.allowed_skills ?? [],
deniedSkills: subagent.capability?.denied_skills ?? [],
allowedTools: subagent.capability?.allowed_tools ?? [],
deniedTools: subagent.capability?.denied_tools ?? [],
allowedSubagents: subagent.capability?.allowed_subagents ?? [],
deniedSubagents: subagent.capability?.denied_subagents ?? [],
});
};
return (
<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;
const isBuiltin = subagent.source === 'builtin';
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>
{renderToolTags('允许', subagent.capability?.allowed_tools, 'allow')}
{renderToolTags('禁用', subagent.capability?.denied_tools, 'deny')}
</div>
{!isBuiltin && (
<button
onClick={() => handleEditSubagent(subagent)}
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>
)}
<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: '',
provider: '',
model: '',
allowedSkills: [],
deniedSkills: [],
allowedTools: [],
deniedTools: [],
allowedSubagents: [],
deniedSubagents: [],
});
}}
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 toggleExpertCb(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
setExpertList(prevList);
setToast(data.error || '切换专家状态失败');
setTimeout(() => setToast(''), 3000);
return;
}
setExpertList({
...prevList,
experts: prevList.experts.map((e) =>
e.name === name ? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] } : e,
),
});
} catch {
setExpertList(prevList);
setToast('网络错误,切换专家状态失败');
setTimeout(() => setToast(''), 3000);
}
};
const handleEdit = (expert: ExpertItem) => {
setEditingExpertError('');
setEditingExpert({
mode: 'edit',
name: expert.name,
scope: 'project',
nameField: expert.name,
description: expert.description,
body: expert.body ?? '',
provider: expert.provider ?? '',
model: expert.model ?? '',
allowedSkills: expert.capability?.allowed_skills ?? [],
deniedSkills: expert.capability?.denied_skills ?? [],
allowedTools: expert.capability?.allowed_tools ?? [],
deniedTools: expert.capability?.denied_tools ?? [],
allowedSubagents: expert.capability?.allowed_subagents ?? [],
deniedSubagents: expert.capability?.denied_subagents ?? [],
});
};
const handleDelete = async (name: string) => {
if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return;
try {
const resp = await deleteExpertCb(name, 'project');
const data = await resp.json();
if (!resp.ok || !data.success) {
setToast(data.error || '删除专家失败');
setTimeout(() => setToast(''), 3000);
return;
}
setToast('专家已删除');
setTimeout(() => setToast(''), 3000);
fetchExpertList();
} catch {
setToast('网络错误,删除专家失败');
setTimeout(() => setToast(''), 3000);
}
};
return (
<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 skillOptions = (skillList?.skills ?? []).map((s) => ({
key: s.name,
label: s.name,
description: s.description,
group: s.source,
}));
const toolOptions = (toolList?.tools ?? []).map((t) => ({
key: t.name,
label: t.name,
description: t.description,
group: t.source,
}));
const subagentOptions = (subagentList?.subagents ?? []).map((s) => ({
key: s.name,
label: s.name,
description: s.description,
group: s.source,
}));
const skillEmptyHint = skillListLoading
? '加载中...'
: '未发现任何技能,请先在技能页配置来源目录';
const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具';
const subagentEmptyHint = subagentListLoading ? '加载中...' : '未发现任何子代理';
const renderExpertModal = () => {
if (!editingExpert) return null;
const isEdit = editingExpert.mode === 'edit';
const canSave = editingExpert.nameField.trim() && editingExpert.description.trim();
const handleSave = async () => {
if (!canSave) return;
setSavingExpert(true);
setEditingExpertError('');
try {
// allowed_* 为空时必须传 undefined后端 None=不限),
// 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。
// denied_* 为 Vec<String>,空数组即"不禁",可直接传。
const capability: CapabilityPolicy = {
allowed_skills:
editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined,
denied_skills: editingExpert.deniedSkills,
allowed_tools:
editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined,
denied_tools: editingExpert.deniedTools,
allowed_subagents:
editingExpert.allowedSubagents.length > 0 ? editingExpert.allowedSubagents : undefined,
denied_subagents: editingExpert.deniedSubagents,
};
const payload = {
name: editingExpert.nameField,
description: editingExpert.description,
body: editingExpert.body,
capability,
provider: editingExpert.provider || undefined,
model: editingExpert.model || undefined,
};
const resp = isEdit
? await updateExpertCb({ ...payload, scope: 'project' })
: await createExpertCb({ ...payload, scope: 'project' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
setEditingExpertError(data.error || data.message || '保存失败');
setSavingExpert(false);
return;
}
setToast(isEdit ? '专家已更新' : '专家已创建');
setTimeout(() => setToast(''), 3000);
setEditingExpert(null);
fetchExpertList();
} catch (e: unknown) {
setEditingExpertError(e instanceof Error ? e.message : '网络错误');
} finally {
setSavingExpert(false);
}
};
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"
onClick={() => setEditingExpert(null)}
>
<div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<ModalHeader
icon={<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />}
title={isEdit ? '编辑专家' : '添加专家'}
onClose={() => setEditingExpert(null)}
/>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<SectionCard title="基本信息">
<Field
label="名称"
hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入"
>
<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-[160px] resize-y font-mono text-xs'}
/>
</Field>
</SectionCard>
<SectionCard title="模型配置" subtitle="留空继承默认">
<Field label="Provider" hint="留空继承默认配置">
<select
value={editingExpert.provider}
onChange={(e) =>
setEditingExpert((prev) =>
prev ? { ...prev, provider: e.target.value } : prev,
)
}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</Field>
<Field label="Model" hint="留空继承默认配置">
<select
value={editingExpert.model}
onChange={(e) =>
setEditingExpert((prev) => (prev ? { ...prev, model: e.target.value } : prev))
}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</SectionCard>
<SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的技能(白名单)" hint="留空表示不限">
<CheckboxList
options={skillOptions}
selected={editingExpert.allowedSkills}
onChange={(v) =>
setEditingExpert((prev) => (prev ? { ...prev, allowedSkills: v } : prev))
}
extraSelected={editingExpert.allowedSkills}
emptyHint={skillEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={skillOptions}
selected={editingExpert.deniedSkills}
onChange={(v) =>
setEditingExpert((prev) => (prev ? { ...prev, deniedSkills: v } : prev))
}
extraSelected={editingExpert.deniedSkills}
emptyHint={skillEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="工具能力" subtitle="含 mcp_*">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的工具(白名单)" hint="留空表示不限">
<CheckboxList
options={toolOptions}
selected={editingExpert.allowedTools}
onChange={(v) =>
setEditingExpert((prev) => (prev ? { ...prev, allowedTools: v } : prev))
}
extraSelected={editingExpert.allowedTools}
emptyHint={toolEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的工具" hint="在白名单之后应用">
<CheckboxList
options={toolOptions}
selected={editingExpert.deniedTools}
onChange={(v) =>
setEditingExpert((prev) => (prev ? { ...prev, deniedTools: v } : prev))
}
extraSelected={editingExpert.deniedTools}
emptyHint={toolEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="子代理能力" subtitle="限制可加载的子代理">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的子代理(白名单)" hint="留空表示不限。仅勾选的子代理可被加载">
<CheckboxList
options={subagentOptions}
selected={editingExpert.allowedSubagents}
onChange={(v) =>
setEditingExpert((prev) => (prev ? { ...prev, allowedSubagents: v } : prev))
}
extraSelected={editingExpert.allowedSubagents}
emptyHint={subagentEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={subagentOptions}
selected={editingExpert.deniedSubagents}
onChange={(v) =>
setEditingExpert((prev) => (prev ? { ...prev, deniedSubagents: v } : prev))
}
extraSelected={editingExpert.deniedSubagents}
emptyHint={subagentEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
{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>
<ModalFooter>
<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>
</ModalFooter>
</div>
</div>
);
};
const renderSubagentModal = () => {
if (!editingSubagent) return null;
const canSave = editingSubagent.description.trim().length > 0;
// 排除自身避免子代理勾选自己造成意外自递归max_nesting_depth 仍兜底)
const subagentOptionsExcludingSelf = subagentOptions.filter(
(o) => o.key !== editingSubagent.name,
);
const handleSave = async () => {
if (!canSave) return;
setSavingSubagent(true);
setEditingSubagentError('');
try {
// 与专家一致allowed_* 为空时传 undefinedNone=不限denied_* 空数组即"不禁"
// 必须包含全部 6 个字段,否则后端 #[serde(default)] 会让缺失字段变为 None/vec![]
// 经 next_capability 完全覆盖原 capability导致既有策略被清空数据丢失
const capability: CapabilityPolicy = {
allowed_skills:
editingSubagent.allowedSkills.length > 0 ? editingSubagent.allowedSkills : undefined,
denied_skills: editingSubagent.deniedSkills,
allowed_tools:
editingSubagent.allowedTools.length > 0 ? editingSubagent.allowedTools : undefined,
denied_tools: editingSubagent.deniedTools,
allowed_subagents:
editingSubagent.allowedSubagents.length > 0
? editingSubagent.allowedSubagents
: undefined,
denied_subagents: editingSubagent.deniedSubagents,
};
const resp = await updateSubagentCb({
name: editingSubagent.name,
description: editingSubagent.description,
capability,
provider: editingSubagent.provider || undefined,
model: editingSubagent.model || undefined,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
setEditingSubagentError(data.error || data.message || '保存失败');
setSavingSubagent(false);
return;
}
setToast('子代理已更新');
setTimeout(() => setToast(''), 3000);
setEditingSubagent(null);
fetchSubagentList();
} catch (e: unknown) {
setEditingSubagentError(e instanceof Error ? e.message : '网络错误');
} finally {
setSavingSubagent(false);
}
};
return (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"
onClick={() => setEditingSubagent(null)}
>
<div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<ModalHeader
icon={<Bot className="h-5 w-5 text-[var(--accent-cyan)]" />}
title="编辑子代理"
onClose={() => setEditingSubagent(null)}
/>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<SectionCard title="基本信息">
<Field label="名称">
<input
value={editingSubagent.name}
disabled
className={inputCls + ' opacity-60 cursor-not-allowed'}
/>
</Field>
<Field label="描述" hint="子代理的简短描述,用于主智能体选择">
<input
value={editingSubagent.description}
onChange={(e) =>
setEditingSubagent((prev) =>
prev ? { ...prev, description: e.target.value } : prev,
)
}
className={inputCls}
/>
</Field>
</SectionCard>
<SectionCard title="模型配置" subtitle="留空继承默认">
<Field label="Provider" hint="留空继承默认配置">
<select
value={editingSubagent.provider}
onChange={(e) =>
setEditingSubagent((prev) =>
prev ? { ...prev, provider: e.target.value } : prev,
)
}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</Field>
<Field label="Model" hint="留空继承默认配置">
<select
value={editingSubagent.model}
onChange={(e) =>
setEditingSubagent((prev) => (prev ? { ...prev, model: e.target.value } : prev))
}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</SectionCard>
<SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
<CheckboxList
options={skillOptions}
selected={editingSubagent.allowedSkills}
onChange={(v) =>
setEditingSubagent((prev) => (prev ? { ...prev, allowedSkills: v } : prev))
}
extraSelected={editingSubagent.allowedSkills}
emptyHint={skillEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={skillOptions}
selected={editingSubagent.deniedSkills}
onChange={(v) =>
setEditingSubagent((prev) => (prev ? { ...prev, deniedSkills: v } : prev))
}
extraSelected={editingSubagent.deniedSkills}
emptyHint={skillEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="工具能力" subtitle="含 mcp_*">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具">
<CheckboxList
options={toolOptions}
selected={editingSubagent.allowedTools}
onChange={(v) =>
setEditingSubagent((prev) => (prev ? { ...prev, allowedTools: v } : prev))
}
extraSelected={editingSubagent.allowedTools}
emptyHint={toolEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的工具" hint="在白名单之后应用">
<CheckboxList
options={toolOptions}
selected={editingSubagent.deniedTools}
onChange={(v) =>
setEditingSubagent((prev) => (prev ? { ...prev, deniedTools: v } : prev))
}
extraSelected={editingSubagent.deniedTools}
emptyHint={toolEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="子代理能力" subtitle="限制可加载的孙代理">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field
label="允许的子代理(白名单)"
hint="留空表示不限。仅勾选的子代理可被加载为孙代理"
>
<CheckboxList
options={subagentOptionsExcludingSelf}
selected={editingSubagent.allowedSubagents}
onChange={(v) =>
setEditingSubagent((prev) => (prev ? { ...prev, allowedSubagents: v } : prev))
}
extraSelected={editingSubagent.allowedSubagents}
emptyHint={subagentEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={subagentOptionsExcludingSelf}
selected={editingSubagent.deniedSubagents}
onChange={(v) =>
setEditingSubagent((prev) => (prev ? { ...prev, deniedSubagents: v } : prev))
}
extraSelected={editingSubagent.deniedSubagents}
emptyHint={subagentEmptyHint}
groupBy={(o) => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
{editingSubagentError && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{editingSubagentError}
</div>
)}
</div>
<ModalFooter>
<button
onClick={() => setEditingSubagent(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 || savingSubagent}
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"
>
{savingSubagent ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{savingSubagent ? '保存中...' : '保存'}
</button>
</ModalFooter>
</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) return;
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
setToast('名称只能包含字母、数字、下划线和连字符');
setTimeout(() => setToast(''), 3000);
return;
}
if (config.mcpServers[name]) {
setToast('该名称已存在');
setTimeout(() => setToast(''), 3000);
return;
}
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 renameMcp = (oldName: string, newName: string) => {
const trimmed = newName.trim();
if (trimmed === oldName || !trimmed) return;
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setToast('名称只能包含字母、数字、下划线和连字符');
setTimeout(() => setToast(''), 3000);
return;
}
if (config.mcpServers[trimmed]) {
setToast('该名称已存在');
setTimeout(() => setToast(''), 3000);
return;
}
const entries = Object.entries(config.mcpServers);
const newMap: Record<string, McpServerConfig> = {};
for (const [k, v] of entries) {
newMap[k === oldName ? trimmed : k] = v;
}
update('mcpServers', newMap);
};
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"
>
{st && (
<div className="flex items-center gap-2 px-4 py-2">
{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>
)}
</div>
)}
<MapEntryHeader
name={name}
onDelete={() => delMcp(name)}
onRename={(n) => renameMcp(name, n)}
/>
<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: Partial<ChannelConfig>) =>
update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } });
const getChannelType = (ch: ChannelConfig): string => {
if (ch.type) return ch.type;
if ('app_id' in ch || 'app_secret' in ch) return 'feishu';
if ('cred_path' in ch) 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>
);
}