feat(web): 专家/子代理 capability 字段改为勾选式编辑

- 专家编辑模态框:4 个 textarea 替换为 CheckboxList,技能/工具按来源分组展示

- 新增子代理编辑模态框:非 builtin 子代理显示编辑按钮,支持描述 + 4 个 capability 勾选字段

- 进入 experts/subagents 标签页时自动拉取技能与工具列表

- allowed_* 为空时传 undefined(后端 None=不限),避免空数组触发白名单空集语义
This commit is contained in:
oudecheng 2026-07-30 16:55:38 +08:00
parent 3ea0c19262
commit 9381ed5dd4

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { import {
Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
CheckCircle, RefreshCw, UserCheck, Pencil, CheckCircle, RefreshCw, UserCheck, Pencil, Bot,
} from 'lucide-react' } from 'lucide-react'
// ── Extracted modules ───────────────────────────────── // ── Extracted modules ─────────────────────────────────
@ -10,16 +10,19 @@ import type {
ProviderConfig, ModelConfig, AgentConfig, ProviderConfig, ModelConfig, AgentConfig,
McpServerConfig, McpStatusResponse, McpServerConfig, McpStatusResponse,
SkillListResponse, SkillListResponse,
SubagentListResponse, SubagentListResponse, SubagentItem,
ToolsListResponse,
ExpertItem, ExpertListResponse, ExpertItem, ExpertListResponse,
CapabilityPolicy,
KnownSource, KnownSource,
SchedulerConfig, ChannelConfig, SchedulerConfig, ChannelConfig,
} from './types' } from './types'
import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants' import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'
import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui' import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader, CheckboxList } from './ui'
import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config' import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'
import { listSkills, toggleSkill } from '../../api/skills' import { listSkills, toggleSkill } from '../../api/skills'
import { listSubagents, toggleSubagent } from '../../api/subagents' import { listTools } from '../../api/tools'
import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents'
import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts' import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts'
import { getMcpStatus } from '../../api/mcp' import { getMcpStatus } from '../../api/mcp'
export { getSelectedExpert, selectExpert } from '../../api/experts' export { getSelectedExpert, selectExpert } from '../../api/experts'
@ -47,6 +50,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null) const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
const [skillList, setSkillList] = useState<SkillListResponse | null>(null) const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
const [skillListLoading, setSkillListLoading] = useState(false) const [skillListLoading, setSkillListLoading] = useState(false)
const [toolList, setToolList] = useState<ToolsListResponse | null>(null)
const [toolListLoading, setToolListLoading] = useState(false)
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null) const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
const [subagentListLoading, setSubagentListLoading] = useState(false) const [subagentListLoading, setSubagentListLoading] = useState(false)
const [expertList, setExpertList] = useState<ExpertListResponse | null>(null) const [expertList, setExpertList] = useState<ExpertListResponse | null>(null)
@ -58,9 +63,23 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
nameField: string nameField: string
description: string description: string
body: string body: string
allowedSkills: string[]
deniedSkills: string[]
allowedTools: string[]
deniedTools: string[]
} | null>(null) } | null>(null)
const [editingExpertError, setEditingExpertError] = useState('') const [editingExpertError, setEditingExpertError] = useState('')
const [savingExpert, setSavingExpert] = useState(false) const [savingExpert, setSavingExpert] = useState(false)
const [editingSubagent, setEditingSubagent] = useState<{
name: string
description: string
allowedSkills: string[]
deniedSkills: string[]
allowedTools: string[]
deniedTools: string[]
} | null>(null)
const [editingSubagentError, setEditingSubagentError] = useState('')
const [savingSubagent, setSavingSubagent] = useState(false)
const fetchMcpStatus = useCallback(async () => { const fetchMcpStatus = useCallback(async () => {
const data = await getMcpStatus() const data = await getMcpStatus()
@ -74,6 +93,13 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
setSkillListLoading(false) setSkillListLoading(false)
}, []) }, [])
const fetchToolList = useCallback(async () => {
setToolListLoading(true)
const data = await listTools()
if (data) setToolList(data)
setToolListLoading(false)
}, [])
const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => { const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleSkill(name, scope, enabled) return toggleSkill(name, scope, enabled)
}, []) }, [])
@ -89,6 +115,10 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return toggleSubagent(name, scope, enabled) return toggleSubagent(name, scope, enabled)
}, []) }, [])
const updateSubagentCb = useCallback(async (payload: { name: string; description?: string; body?: string; capability?: CapabilityPolicy }) => {
return updateSubagent(payload)
}, [])
const fetchExpertList = useCallback(async () => { const fetchExpertList = useCallback(async () => {
setExpertListLoading(true) setExpertListLoading(true)
const data = await listExperts() const data = await listExperts()
@ -100,11 +130,11 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return toggleExpert(name, scope, enabled) return toggleExpert(name, scope, enabled)
}, []) }, [])
const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => { const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy }) => {
return createExpert(payload) return createExpert(payload)
}, []) }, [])
const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => { const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy }) => {
return updateExpert(payload) return updateExpert(payload)
}, []) }, [])
@ -141,6 +171,14 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
if (activeTab === 'subagents') fetchSubagentList() if (activeTab === 'subagents') fetchSubagentList()
}, [activeTab, fetchSubagentList]) }, [activeTab, fetchSubagentList])
// Fetch skills + tools when experts/subagents tab is selected (for capability CheckboxList)
useEffect(() => {
if (activeTab === 'experts' || activeTab === 'subagents') {
if (!skillList) fetchSkillList()
if (!toolList) fetchToolList()
}
}, [activeTab, fetchSkillList, fetchToolList, skillList, toolList])
// Fetch expert list when experts tab is selected // Fetch expert list when experts tab is selected
useEffect(() => { useEffect(() => {
if (activeTab === 'experts') fetchExpertList() if (activeTab === 'experts') fetchExpertList()
@ -568,6 +606,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
/> />
</SectionCard> </SectionCard>
{renderDiscoveredSubagents()} {renderDiscoveredSubagents()}
{renderSubagentModal()}
</div> </div>
) )
@ -636,6 +675,18 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
) )
} }
const handleEditSubagent = (subagent: SubagentItem) => {
setEditingSubagentError('')
setEditingSubagent({
name: subagent.name,
description: subagent.description,
allowedSkills: subagent.capability?.allowed_skills ?? [],
deniedSkills: subagent.capability?.denied_skills ?? [],
allowedTools: subagent.capability?.allowed_tools ?? [],
deniedTools: subagent.capability?.denied_tools ?? [],
})
}
return ( return (
<SectionCard title="已发现子代理" subtitle="即时生效"> <SectionCard title="已发现子代理" subtitle="即时生效">
{subagentListLoading && subagents.length === 0 ? ( {subagentListLoading && subagents.length === 0 ? (
@ -648,6 +699,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<div className="space-y-1"> <div className="space-y-1">
{subagents.map(subagent => { {subagents.map(subagent => {
const isEnabled = subagent.disabled_in_scopes.length === 0 const isEnabled = subagent.disabled_in_scopes.length === 0
const isBuiltin = subagent.source === 'builtin'
return ( return (
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors"> <div key={subagent.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-1 min-w-0">
@ -656,9 +708,18 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span> <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
</div> </div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p> <p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
{renderToolTags('允许', subagent.allowed_tools, 'allow')} {renderToolTags('允许', subagent.capability?.allowed_tools, 'allow')}
{renderToolTags('禁用', subagent.denied_tools, 'deny')} {renderToolTags('禁用', subagent.capability?.denied_tools, 'deny')}
</div> </div>
{!isBuiltin && (
<button
onClick={() => handleEditSubagent(subagent)}
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>
)}
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} /> <Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
</div> </div>
) )
@ -691,7 +752,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button <button
onClick={() => { onClick={() => {
setEditingExpertError('') setEditingExpertError('')
setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '' }) setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '', allowedSkills: [], deniedSkills: [], allowedTools: [], deniedTools: [] })
}} }}
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" 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"
> >
@ -750,6 +811,10 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
nameField: expert.name, nameField: expert.name,
description: expert.description, description: expert.description,
body: expert.body ?? '', body: expert.body ?? '',
allowedSkills: expert.capability?.allowed_skills ?? [],
deniedSkills: expert.capability?.denied_skills ?? [],
allowedTools: expert.capability?.allowed_tools ?? [],
deniedTools: expert.capability?.denied_tools ?? [],
}) })
} }
@ -817,6 +882,12 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
) )
} }
// 技能/工具勾选选项(专家与子代理编辑模态框共用)
const skillOptions = (skillList?.skills ?? []).map(s => ({ key: s.name, label: s.name, description: s.description, group: s.source }))
const toolOptions = (toolList?.tools ?? []).map(t => ({ key: t.name, label: t.name, description: t.description, group: t.source }))
const skillEmptyHint = skillListLoading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录'
const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具'
const renderExpertModal = () => { const renderExpertModal = () => {
if (!editingExpert) return null if (!editingExpert) return null
const isEdit = editingExpert.mode === 'edit' const isEdit = editingExpert.mode === 'edit'
@ -827,19 +898,24 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
setSavingExpert(true) setSavingExpert(true)
setEditingExpertError('') setEditingExpertError('')
try { try {
// allowed_skills/allowed_tools 为空时必须传 undefined后端 None=不限),
// 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。
// denied_skills/denied_tools 为 Vec<String>,空数组即"不禁",可直接传。
const capability: CapabilityPolicy = {
allowed_skills: editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined,
denied_skills: editingExpert.deniedSkills,
allowed_tools: editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined,
denied_tools: editingExpert.deniedTools,
}
const payload = {
name: editingExpert.nameField,
description: editingExpert.description,
body: editingExpert.body,
capability,
}
const resp = isEdit const resp = isEdit
? await updateExpertCb({ ? await updateExpertCb({ ...payload, scope: 'project' })
name: editingExpert.nameField, : await createExpertCb({ ...payload, scope: 'project' })
scope: 'project',
description: editingExpert.description,
body: editingExpert.body,
})
: await createExpertCb({
name: editingExpert.nameField,
description: editingExpert.description,
body: editingExpert.body,
scope: 'project',
})
const data = await resp.json().catch(() => ({})) const data = await resp.json().catch(() => ({}))
if (!resp.ok) { if (!resp.ok) {
setEditingExpertError(data.error || data.message || '保存失败') setEditingExpertError(data.error || data.message || '保存失败')
@ -859,7 +935,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return ( return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"> <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-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]"> <div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] overflow-y-auto">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" /> <UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]"> <h3 className="text-sm font-semibold text-[var(--text-primary)]">
@ -890,9 +966,51 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
value={editingExpert.body} value={editingExpert.body}
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)} onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
placeholder="你是一名专业翻译..." placeholder="你是一名专业翻译..."
className={inputCls + ' min-h-[360px] resize-y font-mono text-xs'} className={inputCls + ' min-h-[200px] resize-y font-mono text-xs'}
/> />
</Field> </Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-[var(--border-color)]">
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
<CheckboxList
options={skillOptions}
selected={editingExpert.allowedSkills}
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedSkills: v } : prev)}
extraSelected={editingExpert.allowedSkills}
emptyHint={skillEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={skillOptions}
selected={editingExpert.deniedSkills}
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedSkills: v } : prev)}
extraSelected={editingExpert.deniedSkills}
emptyHint={skillEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具">
<CheckboxList
options={toolOptions}
selected={editingExpert.allowedTools}
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedTools: v } : prev)}
extraSelected={editingExpert.allowedTools}
emptyHint={toolEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的工具" hint="在白名单之后应用">
<CheckboxList
options={toolOptions}
selected={editingExpert.deniedTools}
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedTools: v } : prev)}
extraSelected={editingExpert.deniedTools}
emptyHint={toolEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
</div>
{editingExpertError && ( {editingExpertError && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2"> <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{editingExpertError} {editingExpertError}
@ -920,6 +1038,135 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
) )
} }
const renderSubagentModal = () => {
if (!editingSubagent) return null
const canSave = editingSubagent.description.trim().length > 0
const handleSave = async () => {
if (!canSave) return
setSavingSubagent(true)
setEditingSubagentError('')
try {
// 与专家一致allowed_* 为空时传 undefinedNone=不限denied_* 空数组即"不禁"
const capability: CapabilityPolicy = {
allowed_skills: editingSubagent.allowedSkills.length > 0 ? editingSubagent.allowedSkills : undefined,
denied_skills: editingSubagent.deniedSkills,
allowed_tools: editingSubagent.allowedTools.length > 0 ? editingSubagent.allowedTools : undefined,
denied_tools: editingSubagent.deniedTools,
}
const resp = await updateSubagentCb({
name: editingSubagent.name,
description: editingSubagent.description,
capability,
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setEditingSubagentError(data.error || data.message || '保存失败')
setSavingSubagent(false)
return
}
setToast('子代理已更新')
setTimeout(() => setToast(''), 3000)
setEditingSubagent(null)
fetchSubagentList()
} catch (e: unknown) {
setEditingSubagentError(e instanceof Error ? e.message : '网络错误')
} finally {
setSavingSubagent(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-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] overflow-y-auto">
<div className="flex items-center gap-2 mb-4">
<Bot className="h-5 w-5 text-[var(--accent-cyan)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]"></h3>
</div>
<div className="space-y-3">
<Field label="名称">
<input
value={editingSubagent.name}
disabled
className={inputCls + ' opacity-60 cursor-not-allowed'}
/>
</Field>
<Field label="描述" hint="子代理的简短描述,用于主智能体选择">
<input
value={editingSubagent.description}
onChange={e => setEditingSubagent(prev => prev ? { ...prev, description: e.target.value } : prev)}
className={inputCls}
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-[var(--border-color)]">
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
<CheckboxList
options={skillOptions}
selected={editingSubagent.allowedSkills}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedSkills: v } : prev)}
extraSelected={editingSubagent.allowedSkills}
emptyHint={skillEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={skillOptions}
selected={editingSubagent.deniedSkills}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedSkills: v } : prev)}
extraSelected={editingSubagent.deniedSkills}
emptyHint={skillEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具">
<CheckboxList
options={toolOptions}
selected={editingSubagent.allowedTools}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedTools: v } : prev)}
extraSelected={editingSubagent.allowedTools}
emptyHint={toolEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的工具" hint="在白名单之后应用">
<CheckboxList
options={toolOptions}
selected={editingSubagent.deniedTools}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedTools: v } : prev)}
extraSelected={editingSubagent.deniedTools}
emptyHint={toolEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
</div>
{editingSubagentError && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{editingSubagentError}
</div>
)}
</div>
<div className="flex gap-3 justify-end mt-5">
<button
onClick={() => setEditingSubagent(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 || savingSubagent}
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"
>
{savingSubagent ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{savingSubagent ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)
}
const renderMcp = () => { const renderMcp = () => {
const entries = Object.entries(config.mcpServers) const entries = Object.entries(config.mcpServers)
const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key) const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)