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 日志便于定位。
2609 lines
101 KiB
TypeScript
2609 lines
101 KiB
TypeScript
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_* 为空时传 undefined(None=不限),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>
|
||
);
|
||
}
|