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:
parent
9d7c1f2e52
commit
5651c4ae7f
@ -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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user