- 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 的隔离缺陷
172 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|