oudecheng c2cc072b2e feat(settings): 拆分设置页为懒加载标签页并支持子代理创建/删除
- ConfigPage 拆分为 16 个懒加载 tab + 2 个 modal,首屏体积大幅下降
- 抽取 CapabilityTabs 共享组件,统一专家/子代理能力配置 UI
- 后端新增 POST /api/subagents/create、DELETE /api/subagents/delete
- SubagentRuntime 新增 create_subagent/delete_subagent,含路径校验与 builtin 保护
- SubagentModal 支持 create/edit 双模式、body 编辑、自身排除防自递归
- SubagentsTab 增加搜索、provider/model 标签、删除二次确认
- 修复 reload() 使用进程 cwd 而非 self.cwd 的隔离缺陷
2026-08-04 21:06:17 +08:00

172 lines
6.6 KiB
TypeScript

// ExpertsTab - 专家系统配置(来源目录 + 已发现专家 + 编辑/创建模态框)
import { useState, useEffect, useCallback } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react';
import { Toggle, SectionCard, SourceEditor } from '../ui';
import { AddButton } from '../shared';
import { ExpertModal, expertToDraft, emptyExpertDraft, type ExpertDraft } from '../modals/ExpertModal';
import {
listExperts,
toggleExpert,
deleteExpert,
} from '../../../api/experts';
import type { TabProps, ToastProps } from '../shared';
import type { KnownSource, ExpertListResponse } from '../types';
type Props = TabProps & ToastProps;
const EXPERT_KNOWN_SOURCES: KnownSource[] = [
{ key: 'user', label: '用户专家', description: '~/.picobot/experts' },
{ key: 'project', label: '项目专家', description: '.picobot/experts' },
];
export function ExpertsTab({ config, update, setToast }: Props) {
const [expertList, setExpertList] = useState<ExpertListResponse | null>(null);
const [expertListLoading, setExpertListLoading] = useState(false);
const [editingExpert, setEditingExpert] = useState<ExpertDraft | null>(null);
const fetchExpertList = useCallback(async () => {
setExpertListLoading(true);
const data = await listExperts();
if (data) setExpertList(data);
setExpertListLoading(false);
}, []);
useEffect(() => {
fetchExpertList();
}, [fetchExpertList]);
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
if (!expertList) return;
const prevList = expertList;
setExpertList({
...expertList,
experts: expertList.experts.map((e) =>
e.name === name ? { ...e, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } : e,
),
});
try {
const resp = await toggleExpert(name, 'project', !currentlyEnabled);
const data = await resp.json();
if (!resp.ok || !data.success) {
setExpertList(prevList);
setToast(data.error || '切换专家状态失败');
return;
}
setExpertList({
...prevList,
experts: prevList.experts.map((e) =>
e.name === name ? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] } : e,
),
});
} catch {
setExpertList(prevList);
setToast('网络错误,切换专家状态失败');
}
};
const handleDelete = async (name: string) => {
if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return;
try {
const resp = await deleteExpert(name, 'project');
const data = await resp.json();
if (!resp.ok || !data.success) {
setToast(data.error || '删除专家失败');
return;
}
setToast('专家已删除');
fetchExpertList();
} catch {
setToast('网络错误,删除专家失败');
}
};
return (
<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>
{expertList && expertList.experts_system_enabled && (
<SectionCard title="已发现专家" subtitle="即时生效">
{expertListLoading && expertList.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>
) : expertList.experts.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{expertList.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={() => setEditingExpert(expertToDraft(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>
)}
<AddButton label="添加专家" onClick={() => setEditingExpert(emptyExpertDraft())} />
{editingExpert && (
<ExpertModal
draft={editingExpert}
onClose={() => setEditingExpert(null)}
onSaved={() => {
setEditingExpert(null);
fetchExpertList();
}}
setToast={setToast}
/>
)}
</div>
);
}