feat(web): 子代理模态框改造并暴露 max_nesting_depth 配置项

对抗审查修复 4 个 P0 问题:
- 子代理 modal handleSave 缺失 allowed_subagents/denied_subagents 字段,导致保存时既有策略被后端 #[serde(default)] 静默清空(数据丢失)
- 子代理 modal 缺失子代理黑白名单 CheckboxList UI
- 子代理 modal 未改造为 ModalHeader/ModalFooter/SectionCard,无 X 关闭按钮与遮罩 onClick
- experts tab 未加载 subagentList,编辑专家时子代理勾选列表显示空

新增改动:
- ui.tsx 新增 ModalHeader/ModalFooter 通用组件
- ConfigPage renderTools 新增最大嵌套深度输入框(后端 TaskConfig.max_nesting_depth 早已存在,默认 2)
- 子代理 modal 子代理选项排除自身避免意外自递归
This commit is contained in:
oudecheng 2026-07-30 17:38:08 +08:00
parent c27efedb6c
commit 951e31aca4
3 changed files with 277 additions and 139 deletions

View File

@ -18,7 +18,7 @@ import type {
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, CheckboxList } from './ui' import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader, CheckboxList, ModalHeader, ModalFooter } 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 { listTools } from '../../api/tools' import { listTools } from '../../api/tools'
@ -67,6 +67,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
deniedSkills: string[] deniedSkills: string[]
allowedTools: string[] allowedTools: string[]
deniedTools: string[] deniedTools: string[]
allowedSubagents: string[]
deniedSubagents: string[]
} | null>(null) } | null>(null)
const [editingExpertError, setEditingExpertError] = useState('') const [editingExpertError, setEditingExpertError] = useState('')
const [savingExpert, setSavingExpert] = useState(false) const [savingExpert, setSavingExpert] = useState(false)
@ -77,6 +79,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
deniedSkills: string[] deniedSkills: string[]
allowedTools: string[] allowedTools: string[]
deniedTools: string[] deniedTools: string[]
allowedSubagents: string[]
deniedSubagents: string[]
} | null>(null) } | null>(null)
const [editingSubagentError, setEditingSubagentError] = useState('') const [editingSubagentError, setEditingSubagentError] = useState('')
const [savingSubagent, setSavingSubagent] = useState(false) const [savingSubagent, setSavingSubagent] = useState(false)
@ -179,6 +183,11 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
} }
}, [activeTab, fetchSkillList, fetchToolList, skillList, toolList]) }, [activeTab, fetchSkillList, fetchToolList, skillList, toolList])
// experts tab 编辑专家时也需要子代理勾选列表按需加载subagents tab 由下方独立 useEffect 刷新)
useEffect(() => {
if (activeTab === 'experts' && !subagentList) fetchSubagentList()
}, [activeTab, fetchSubagentList, subagentList])
// 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()
@ -565,6 +574,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"> Task </span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div> <div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"> Task </span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div>
<Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field> <Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field> <Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
<Field label="最大嵌套深度" hint="允许的子代理最大嵌套层数。1=仅子代理2=子代理+孙代理默认0=禁止嵌套。深度达上限时移除 task 工具以防无限递归"><input type="number" min={0} value={config.tools.task.max_nesting_depth} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_nesting_depth: Math.max(0, +e.target.value || 0) } })} className={inputCls} /></Field>
</SectionCard> </SectionCard>
<SectionCard title="允许的工具列表"> <SectionCard title="允许的工具列表">
<SourceEditor <SourceEditor
@ -684,6 +694,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
deniedSkills: subagent.capability?.denied_skills ?? [], deniedSkills: subagent.capability?.denied_skills ?? [],
allowedTools: subagent.capability?.allowed_tools ?? [], allowedTools: subagent.capability?.allowed_tools ?? [],
deniedTools: subagent.capability?.denied_tools ?? [], deniedTools: subagent.capability?.denied_tools ?? [],
allowedSubagents: subagent.capability?.allowed_subagents ?? [],
deniedSubagents: subagent.capability?.denied_subagents ?? [],
}) })
} }
@ -752,7 +764,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button <button
onClick={() => { onClick={() => {
setEditingExpertError('') setEditingExpertError('')
setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '', allowedSkills: [], deniedSkills: [], allowedTools: [], deniedTools: [] }) setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '', allowedSkills: [], deniedSkills: [], allowedTools: [], deniedTools: [], allowedSubagents: [], deniedSubagents: [] })
}} }}
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"
> >
@ -815,6 +827,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
deniedSkills: expert.capability?.denied_skills ?? [], deniedSkills: expert.capability?.denied_skills ?? [],
allowedTools: expert.capability?.allowed_tools ?? [], allowedTools: expert.capability?.allowed_tools ?? [],
deniedTools: expert.capability?.denied_tools ?? [], deniedTools: expert.capability?.denied_tools ?? [],
allowedSubagents: expert.capability?.allowed_subagents ?? [],
deniedSubagents: expert.capability?.denied_subagents ?? [],
}) })
} }
@ -885,8 +899,25 @@ 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 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 toolOptions = (toolList?.tools ?? []).map(t => ({ key: t.name, label: t.name, description: t.description, group: t.source }))
const subagentOptions = (subagentList?.subagents ?? []).map(s => ({ key: s.name, label: s.name, description: s.description, group: s.source }))
const skillEmptyHint = skillListLoading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录' const skillEmptyHint = skillListLoading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录'
const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具' const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具'
const subagentEmptyHint = subagentListLoading ? '加载中...' : '未发现任何子代理'
// 子模态框打开时ESC 键仅关闭子模态框(阻止冒泡到 ConfigPage 全局 ESC避免关闭整个配置页
useEffect(() => {
if (!editingExpert && !editingSubagent) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
e.preventDefault()
setEditingExpert(null)
setEditingSubagent(null)
}
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [editingExpert, editingSubagent])
const renderExpertModal = () => { const renderExpertModal = () => {
if (!editingExpert) return null if (!editingExpert) return null
@ -898,14 +929,16 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
setSavingExpert(true) setSavingExpert(true)
setEditingExpertError('') setEditingExpertError('')
try { try {
// allowed_skills/allowed_tools 为空时必须传 undefined后端 None=不限), // allowed_* 为空时必须传 undefined后端 None=不限),
// 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。 // 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。
// denied_skills/denied_tools 为 Vec<String>,空数组即"不禁",可直接传。 // denied_* 为 Vec<String>,空数组即"不禁",可直接传。
const capability: CapabilityPolicy = { const capability: CapabilityPolicy = {
allowed_skills: editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined, allowed_skills: editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined,
denied_skills: editingExpert.deniedSkills, denied_skills: editingExpert.deniedSkills,
allowed_tools: editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined, allowed_tools: editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined,
denied_tools: editingExpert.deniedTools, denied_tools: editingExpert.deniedTools,
allowed_subagents: editingExpert.allowedSubagents.length > 0 ? editingExpert.allowedSubagents : undefined,
denied_subagents: editingExpert.deniedSubagents,
} }
const payload = { const payload = {
name: editingExpert.nameField, name: editingExpert.nameField,
@ -934,90 +967,124 @@ 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" onClick={() => setEditingExpert(null)}>
<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
<div className="flex items-center gap-2 mb-4"> className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" /> onClick={e => e.stopPropagation()}
<h3 className="text-sm font-semibold text-[var(--text-primary)]"> >
{isEdit ? '编辑专家' : '添加专家'} <ModalHeader
</h3> icon={<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />}
</div> title={isEdit ? '编辑专家' : '添加专家'}
<div className="space-y-3"> onClose={() => setEditingExpert(null)}
<Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入"> />
<input <div className="flex-1 overflow-y-auto p-6 space-y-4">
value={editingExpert.nameField} <SectionCard title="基本信息">
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)} <Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入">
disabled={isEdit} <input
placeholder="如 translator" value={editingExpert.nameField}
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')} onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
autoFocus={!isEdit} disabled={isEdit}
/> placeholder="如 translator"
</Field> className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
<Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入"> autoFocus={!isEdit}
<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>
<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>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用"> <Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入">
<CheckboxList <input
options={skillOptions} value={editingExpert.description}
selected={editingExpert.deniedSkills} onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedSkills: v } : prev)} placeholder="如 翻译专家"
extraSelected={editingExpert.deniedSkills} className={inputCls}
emptyHint={skillEmptyHint}
groupBy={o => o.group ?? '其他'}
/> />
</Field> </Field>
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具"> <Field label="专家提示词正文" hint="markdown 格式。非空时仅注入正文;为空时自动用“名称+描述”生成兜底提示词">
<CheckboxList <textarea
options={toolOptions} value={editingExpert.body}
selected={editingExpert.allowedTools} onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedTools: v } : prev)} placeholder="你是一名专业翻译..."
extraSelected={editingExpert.allowedTools} className={inputCls + ' min-h-[160px] resize-y font-mono text-xs'}
emptyHint={toolEmptyHint}
groupBy={o => o.group ?? '其他'}
/> />
</Field> </Field>
<Field label="禁用的工具" hint="在白名单之后应用"> </SectionCard>
<CheckboxList <SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除">
options={toolOptions} <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
selected={editingExpert.deniedTools} <Field label="允许的技能(白名单)" hint="留空表示不限">
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedTools: v } : prev)} <CheckboxList
extraSelected={editingExpert.deniedTools} options={skillOptions}
emptyHint={toolEmptyHint} selected={editingExpert.allowedSkills}
groupBy={o => o.group ?? '其他'} onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedSkills: v } : prev)}
/> extraSelected={editingExpert.allowedSkills}
</Field> emptyHint={skillEmptyHint}
</div> 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>
</div>
</SectionCard>
<SectionCard title="工具能力" subtitle="含 mcp_*">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的工具(白名单)" hint="留空表示不限">
<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>
</SectionCard>
<SectionCard title="子代理能力" subtitle="限制可加载的子代理">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的子代理(白名单)" hint="留空表示不限。仅勾选的子代理可被加载">
<CheckboxList
options={subagentOptions}
selected={editingExpert.allowedSubagents}
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedSubagents: v } : prev)}
extraSelected={editingExpert.allowedSubagents}
emptyHint={subagentEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={subagentOptions}
selected={editingExpert.deniedSubagents}
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedSubagents: v } : prev)}
extraSelected={editingExpert.deniedSubagents}
emptyHint={subagentEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
{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}
</div> </div>
)} )}
</div> </div>
<div className="flex gap-3 justify-end mt-5"> <ModalFooter>
<button <button
onClick={() => setEditingExpert(null)} 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" 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"
@ -1032,7 +1099,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{savingExpert ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} {savingExpert ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{savingExpert ? '保存中...' : '保存'} {savingExpert ? '保存中...' : '保存'}
</button> </button>
</div> </ModalFooter>
</div> </div>
</div> </div>
) )
@ -1041,6 +1108,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const renderSubagentModal = () => { const renderSubagentModal = () => {
if (!editingSubagent) return null if (!editingSubagent) return null
const canSave = editingSubagent.description.trim().length > 0 const canSave = editingSubagent.description.trim().length > 0
// 排除自身避免子代理勾选自己造成意外自递归max_nesting_depth 仍兜底)
const subagentOptionsExcludingSelf = subagentOptions.filter(o => o.key !== editingSubagent.name)
const handleSave = async () => { const handleSave = async () => {
if (!canSave) return if (!canSave) return
@ -1048,11 +1117,15 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
setEditingSubagentError('') setEditingSubagentError('')
try { try {
// 与专家一致allowed_* 为空时传 undefinedNone=不限denied_* 空数组即"不禁" // 与专家一致allowed_* 为空时传 undefinedNone=不限denied_* 空数组即"不禁"
// 必须包含全部 6 个字段,否则后端 #[serde(default)] 会让缺失字段变为 None/vec![]
// 经 next_capability 完全覆盖原 capability导致既有策略被清空数据丢失
const capability: CapabilityPolicy = { const capability: CapabilityPolicy = {
allowed_skills: editingSubagent.allowedSkills.length > 0 ? editingSubagent.allowedSkills : undefined, allowed_skills: editingSubagent.allowedSkills.length > 0 ? editingSubagent.allowedSkills : undefined,
denied_skills: editingSubagent.deniedSkills, denied_skills: editingSubagent.deniedSkills,
allowed_tools: editingSubagent.allowedTools.length > 0 ? editingSubagent.allowedTools : undefined, allowed_tools: editingSubagent.allowedTools.length > 0 ? editingSubagent.allowedTools : undefined,
denied_tools: editingSubagent.deniedTools, denied_tools: editingSubagent.deniedTools,
allowed_subagents: editingSubagent.allowedSubagents.length > 0 ? editingSubagent.allowedSubagents : undefined,
denied_subagents: editingSubagent.deniedSubagents,
} }
const resp = await updateSubagentCb({ const resp = await updateSubagentCb({
name: editingSubagent.name, name: editingSubagent.name,
@ -1077,76 +1150,112 @@ 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" onClick={() => setEditingSubagent(null)}>
<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
<div className="flex items-center gap-2 mb-4"> className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
<Bot className="h-5 w-5 text-[var(--accent-cyan)]" /> onClick={e => e.stopPropagation()}
<h3 className="text-sm font-semibold text-[var(--text-primary)]"></h3> >
</div> <ModalHeader
<div className="space-y-3"> icon={<Bot className="h-5 w-5 text-[var(--accent-cyan)]" />}
<Field label="名称"> title="编辑子代理"
<input onClose={() => setEditingSubagent(null)}
value={editingSubagent.name} />
disabled <div className="flex-1 overflow-y-auto p-6 space-y-4">
className={inputCls + ' opacity-60 cursor-not-allowed'} <SectionCard title="基本信息">
/> <Field label="名称">
</Field> <input
<Field label="描述" hint="子代理的简短描述,用于主智能体选择"> value={editingSubagent.name}
<input disabled
value={editingSubagent.description} className={inputCls + ' opacity-60 cursor-not-allowed'}
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>
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用"> <Field label="描述" hint="子代理的简短描述,用于主智能体选择">
<CheckboxList <input
options={skillOptions} value={editingSubagent.description}
selected={editingSubagent.deniedSkills} onChange={e => setEditingSubagent(prev => prev ? { ...prev, description: e.target.value } : prev)}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedSkills: v } : prev)} className={inputCls}
extraSelected={editingSubagent.deniedSkills}
emptyHint={skillEmptyHint}
groupBy={o => o.group ?? '其他'}
/> />
</Field> </Field>
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具"> </SectionCard>
<CheckboxList <SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除">
options={toolOptions} <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
selected={editingSubagent.allowedTools} <Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedTools: v } : prev)} <CheckboxList
extraSelected={editingSubagent.allowedTools} options={skillOptions}
emptyHint={toolEmptyHint} selected={editingSubagent.allowedSkills}
groupBy={o => o.group ?? '其他'} onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedSkills: v } : prev)}
/> extraSelected={editingSubagent.allowedSkills}
</Field> emptyHint={skillEmptyHint}
<Field label="禁用的工具" hint="在白名单之后应用"> groupBy={o => o.group ?? '其他'}
<CheckboxList />
options={toolOptions} </Field>
selected={editingSubagent.deniedTools} <Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedTools: v } : prev)} <CheckboxList
extraSelected={editingSubagent.deniedTools} options={skillOptions}
emptyHint={toolEmptyHint} selected={editingSubagent.deniedSkills}
groupBy={o => o.group ?? '其他'} onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedSkills: v } : prev)}
/> extraSelected={editingSubagent.deniedSkills}
</Field> emptyHint={skillEmptyHint}
</div> groupBy={o => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
<SectionCard title="工具能力" subtitle="含 mcp_*">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<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>
</SectionCard>
<SectionCard title="子代理能力" subtitle="限制可加载的孙代理">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的子代理(白名单)" hint="留空表示不限。仅勾选的子代理可被加载为孙代理">
<CheckboxList
options={subagentOptionsExcludingSelf}
selected={editingSubagent.allowedSubagents}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedSubagents: v } : prev)}
extraSelected={editingSubagent.allowedSubagents}
emptyHint={subagentEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
<CheckboxList
options={subagentOptionsExcludingSelf}
selected={editingSubagent.deniedSubagents}
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedSubagents: v } : prev)}
extraSelected={editingSubagent.deniedSubagents}
emptyHint={subagentEmptyHint}
groupBy={o => o.group ?? '其他'}
/>
</Field>
</div>
</SectionCard>
{editingSubagentError && ( {editingSubagentError && (
<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">
{editingSubagentError} {editingSubagentError}
</div> </div>
)} )}
</div> </div>
<div className="flex gap-3 justify-end mt-5"> <ModalFooter>
<button <button
onClick={() => setEditingSubagent(null)} 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" 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"
@ -1161,7 +1270,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{savingSubagent ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} {savingSubagent ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{savingSubagent ? '保存中...' : '保存'} {savingSubagent ? '保存中...' : '保存'}
</button> </button>
</div> </ModalFooter>
</div> </div>
</div> </div>
) )

View File

@ -7,7 +7,7 @@ export interface GatewayConfig { host: string; port: number; show_tool_results:
export interface TimeConfig { timezone: string } export interface TimeConfig { timezone: string }
export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] } export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] }
export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number }
export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[]; max_nesting_depth: number }
export interface ToolsConfig { disabled: string[]; task: TaskConfig } export interface ToolsConfig { disabled: string[]; task: TaskConfig }
export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number }
export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number }
@ -57,6 +57,8 @@ export interface CapabilityPolicy {
denied_skills: string[] denied_skills: string[]
allowed_tools?: string[] allowed_tools?: string[]
denied_tools: string[] denied_tools: string[]
allowed_subagents?: string[]
denied_subagents: string[]
} }
export interface SubagentItem { export interface SubagentItem {

View File

@ -63,6 +63,33 @@ export function SectionCard({ title, subtitle, children }: { title: string; subt
) )
} }
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title: string; onClose: () => void }) {
return (
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
{icon}
<span className="text-base font-semibold text-[var(--text-primary)]">{title}</span>
<button
onClick={onClose}
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
aria-label="关闭"
title="关闭 (Esc)"
>
<X className="h-5 w-5" />
</button>
</div>
)
}
/** 模态框底部按钮区,与项目模态框惯例一致 */
export function ModalFooter({ children }: { children: ReactNode }) {
return (
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
{children}
</div>
)
}
export function SourceEditor({ export function SourceEditor({
sources, sources,
onChange, onChange,