feat: 设置页新增专家 Tab,支持专家增删改查与启用切换

- 新增 ExpertsConfig/ExpertItem/ExpertListResponse 类型与 AppConfig.experts 字段

- TABS 数组加入专家 Tab (UserCheck 图标),TabId 含 'experts'

- renderExperts 渲染启用开关、来源目录编辑器、已发现专家列表与添加按钮

- 专家编辑 modal 含 name(编辑时只读)/description(必填)/body(textarea) 三字段

- 导出 selectExpert 与 getSelectedExpert 模块级函数供 ExpertSelector 复用

- Tab 切换自动拉取列表,专家 toggle 即时生效不触发未保存警告

- initialTab prop 支持从对话框入口直达专家 Tab
This commit is contained in:
oudecheng 2026-07-06 18:07:54 +08:00
parent 9d7c1f2e52
commit 5651c4ae7f

View File

@ -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<string, any>
mcpServers: Record<string, McpServerConfig>
@ -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<AppConfig | null>(null)
const [activeTab, setActiveTab] = useState<TabId>('providers')
const [activeTab, setActiveTab] = useState<TabId>(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<SubagentListResponse | null>(null)
const [subagentListLoading, setSubagentListLoading] = useState(false)
const [expertList, setExpertList] = useState<ExpertListResponse | null>(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 = () => (
<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: '' })
}}
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 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 (
<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 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 (
<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 w-[90%] max-w-lg mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="flex items-center gap-2 mb-4">
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
{isEdit ? '编辑专家' : '添加专家'}
</h3>
</div>
<div className="space-y-3">
<Field label="名称">
<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-[200px] resize-y font-mono text-xs'}
/>
</Field>
{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>
<div className="flex gap-3 justify-end mt-5">
<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>
</div>
</div>
</div>
)
}
const renderMcp = () => {
const entries = Object.entries(config.mcpServers)
const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)
@ -1105,6 +1452,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
case 'memory': return renderMemory()
case 'image': return renderImage()
case 'subagents': return renderSubagents()
case 'experts': return renderExperts()
case 'mcp': return renderMcp()
case 'channels': return renderChannels()
}