From 5651c4ae7f0121105057294cd0d0ca2aa94f1396 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 6 Jul 2026 18:07:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=B8=93=E5=AE=B6=20Tab=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=B8=93=E5=AE=B6=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ExpertsConfig/ExpertItem/ExpertListResponse 类型与 AppConfig.experts 字段 - TABS 数组加入专家 Tab (UserCheck 图标),TabId 含 'experts' - renderExperts 渲染启用开关、来源目录编辑器、已发现专家列表与添加按钮 - 专家编辑 modal 含 name(编辑时只读)/description(必填)/body(textarea) 三字段 - 导出 selectExpert 与 getSelectedExpert 模块级函数供 ExpertSelector 复用 - Tab 切换自动拉取列表,专家 toggle 即时生效不触发未保存警告 - initialTab prop 支持从对话框入口直达专家 Tab --- web/src/components/Settings/ConfigPage.tsx | 356 ++++++++++++++++++++- 1 file changed, 352 insertions(+), 4 deletions(-) diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 22d25b9..fb52427 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, type ReactNode } from 'react' import { Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, - CheckCircle, Plug, Radio, RefreshCw, + CheckCircle, Plug, Radio, RefreshCw, UserCheck, Pencil, } from 'lucide-react' // ── Types ────────────────────────────────────────────── @@ -59,6 +59,21 @@ interface SubagentListResponse { subagents: SubagentItem[] } +interface ExpertsConfig { enabled: boolean; sources: string[] } +interface ExpertItem { + name: string + description: string + source: string + path?: string + body?: string + disabled_in_scopes: string[] +} +interface ExpertListResponse { + experts_system_enabled: boolean + total: number + experts: ExpertItem[] +} + interface McpServerStatus { key: string name: string @@ -89,6 +104,7 @@ interface AppConfig { memory_maintenance: MemoryMaintenanceConfig image_context: ImageContextConfig subagents: SubagentsConfig + experts: ExpertsConfig client: ClientConfig channels: Record mcpServers: Record @@ -97,9 +113,10 @@ interface AppConfig { interface ConfigPageProps { onClose: () => void onSaveConnection?: (host: string, port: number) => void + initialTab?: TabId } -type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'mcp' | 'channels' +type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'experts' | 'mcp' | 'channels' const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ { id: 'providers', label: '服务商', icon: Cpu }, @@ -108,6 +125,7 @@ const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ { id: 'mcp', label: 'MCP 服务器', icon: Plug }, { id: 'skills', label: '技能', icon: Wrench }, { id: 'subagents', label: '子代理', icon: Bot }, + { id: 'experts', label: '专家', icon: UserCheck }, { id: 'channels', label: '渠道', icon: Radio }, { id: 'tools', label: '工具', icon: Settings }, { id: 'memory', label: '记忆维护', icon: Users }, @@ -315,10 +333,28 @@ function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: ) } +// ── Expert API helpers (reusable, also used by ExpertSelector) ─────── +export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { + const resp = await fetch(`/api/experts/selected?session_id=${encodeURIComponent(sessionId)}`) + if (!resp.ok) return { expert_name: null, expert: null } + return resp.json() +} + +export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { + const resp = await fetch('/api/experts/select', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), + }) + const data = await resp.json().catch(() => ({})) + if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } + return { success: true } +} + // ── Main Component ───────────────────────────────────── -export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { +export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { const [config, setConfig] = useState(null) - const [activeTab, setActiveTab] = useState('providers') + const [activeTab, setActiveTab] = useState(initialTab ?? 'providers') const [loading, setLoading] = useState(true) // Connection settings (localStorage-based) const [connHost, setConnHost] = useState(() => { @@ -340,6 +376,18 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { const [skillListLoading, setSkillListLoading] = useState(false) const [subagentList, setSubagentList] = useState(null) const [subagentListLoading, setSubagentListLoading] = useState(false) + const [expertList, setExpertList] = useState(null) + const [expertListLoading, setExpertListLoading] = useState(false) + const [editingExpert, setEditingExpert] = useState<{ + mode: 'create' | 'edit' + name?: string + scope: string + nameField: string + description: string + body: string + } | null>(null) + const [editingExpertError, setEditingExpertError] = useState('') + const [savingExpert, setSavingExpert] = useState(false) const fetchMcpStatus = useCallback(async () => { try { @@ -384,6 +432,49 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { return resp }, []) + const fetchExpertList = useCallback(async () => { + setExpertListLoading(true) + try { + const resp = await fetch('/api/experts') + if (resp.ok) setExpertList(await resp.json()) + } catch { /* ignore fetch errors */ } + finally { setExpertListLoading(false) } + }, []) + + const toggleExpert = useCallback(async (name: string, scope: string, enabled: boolean) => { + const resp = await fetch('/api/experts/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, scope, enabled }), + }) + return resp + }, []) + + const createExpert = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => { + const resp = await fetch('/api/experts/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return resp + }, []) + + const updateExpert = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => { + const resp = await fetch('/api/experts/update', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return resp + }, []) + + const deleteExpert = useCallback(async (name: string, scope: string) => { + const resp = await fetch(`/api/experts/delete?name=${encodeURIComponent(name)}&scope=${encodeURIComponent(scope)}`, { + method: 'DELETE', + }) + return resp + }, []) + const handleClose = useCallback(() => { if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return onClose() @@ -412,6 +503,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { if (activeTab === 'subagents') fetchSubagentList() }, [activeTab, fetchSubagentList]) + // 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() } @@ -920,6 +1016,257 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { ) } + const EXPERT_KNOWN_SOURCES: KnownSource[] = [ + { key: 'user', label: '用户专家', description: '~/.picobot/experts' }, + { key: 'project', label: '项目专家', description: '.picobot/experts' }, + ] + + const renderExperts = () => ( +
+ +
启用专家系统 update('experts', { ...config.experts, enabled: v })} />
+
+ + update('experts', { ...config.experts, sources: v })} + knownSources={EXPERT_KNOWN_SOURCES} + examplePaths={['D:\\my-experts', '/home/user/shared-experts']} + /> + + {renderDiscoveredExperts()} + + {renderExpertModal()} +
+ ) + + 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 toggleExpert(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 ?? '', + }) + } + + 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 || '删除专家失败') + setTimeout(() => setToast(''), 3000) + return + } + setToast('专家已删除') + setTimeout(() => setToast(''), 3000) + fetchExpertList() + } catch { + setToast('网络错误,删除专家失败') + setTimeout(() => setToast(''), 3000) + } + } + + return ( + + {expertListLoading && experts.length === 0 ? ( +
+ 加载中... +
+ ) : experts.length === 0 ? ( +

未发现任何专家

+ ) : ( +
+ {experts.map(expert => { + const isEnabled = expert.disabled_in_scopes.length === 0 + return ( +
+
+
+ {expert.name} + {expert.source} +
+

{expert.description}

+
+ + + handleToggle(expert.name, isEnabled)} /> +
+ ) + })} +
+ )} +
+ ) + } + + 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 { + const resp = isEdit + ? await updateExpert({ + name: editingExpert.nameField, + scope: 'project', + description: editingExpert.description, + body: editingExpert.body, + }) + : await createExpert({ + name: editingExpert.nameField, + description: editingExpert.description, + body: editingExpert.body, + 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: any) { + setEditingExpertError(e.message || '网络错误') + } finally { + setSavingExpert(false) + } + } + + return ( +
+
+
+ +

+ {isEdit ? '编辑专家' : '添加专家'} +

+
+
+ + setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)} + disabled={isEdit} + placeholder="如 translator" + className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')} + autoFocus={!isEdit} + /> + + + setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)} + placeholder="如 翻译专家" + className={inputCls} + /> + + +