feat: 技能/命令设置页新增启停开关——按资源粒度控制 Agent 能力

This commit is contained in:
Rick Ye
2026-09-10 17:44:12 +08:00
parent e8cca122f8
commit fdb5fe2ade
6 changed files with 163 additions and 33 deletions
@@ -97,12 +97,16 @@ function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): R
interface ResourceItem {
key: string
/** 资源名(技能 name / 命令名,不含 / 前缀),启停切换的标识 */
name: string
title: string
scope: 'user' | 'project'
badges?: Array<{ text: string; tone: 'accent' | 'warn' }>
desc: string
filePath: string
hint?: string
/** true = 已禁用(不进会话;仅提供 onToggleItem 时有意义) */
disabled?: boolean
}
/** 技能 / 命令面板:全局 / 项目两个 tab,每次进入页面重新扫描;点击条目用默认编辑器打开来源文件 */
@@ -113,7 +117,9 @@ function ResourcePanel({
emptyTitle,
hints,
onOpen,
onOpenDir
onOpenDir,
onToggleItem,
toggleDisabled
}: {
loading: boolean
items: ResourceItem[]
@@ -124,6 +130,10 @@ function ResourcePanel({
hints: { user: string[]; project: string[] }
onOpen: (path: string) => void
onOpenDir: (path: string) => void
/** 提供时在行尾渲染启停开关;next = 目标启用态 */
onToggleItem?: (item: ResourceItem, next: boolean) => void
/** 切换请求处理中,禁用全部开关 */
toggleDisabled?: boolean
}): ReactNode {
const [scope, setScope] = useState<'user' | 'project'>('user')
const scoped = items.filter((i) => i.scope === scope)
@@ -175,31 +185,54 @@ function ResourcePanel({
) : (
<div className="space-y-1.5">
{scoped.map((item) => (
<button
<div
key={item.key}
type="button"
title={`${item.filePath}(点击打开)`}
onClick={() => onOpen(item.filePath)}
className="block w-full rounded-card border border-line px-3 py-2.5 text-left transition-colors hover:border-line-strong hover:bg-inset"
className="flex items-center gap-2 rounded-card border border-line py-2 pl-3 pr-2.5 transition-colors hover:border-line-strong hover:bg-inset"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12.5px] font-medium text-ink">{item.title}</span>
{(item.badges ?? []).map((b) => (
<span
key={b.text}
className={`rounded-control px-1.5 py-0.5 text-[10px] ${
b.tone === 'accent' ? 'bg-accent-tint text-accent-ink' : 'bg-orange-tint text-orange'
}`}
>
{b.text}
<button
type="button"
title={`${item.filePath}(点击打开)`}
onClick={() => onOpen(item.filePath)}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="font-mono text-[12.5px] font-medium text-ink">{item.title}</span>
{(item.badges ?? []).map((b) => (
<span
key={b.text}
className={`rounded-control px-1.5 py-0.5 text-[10px] ${
b.tone === 'accent' ? 'bg-accent-tint text-accent-ink' : 'bg-orange-tint text-orange'
}`}
>
{b.text}
</span>
))}
<span className="ml-auto min-w-0 truncate font-mono text-[10.5px] text-ink-3">
{item.hint || item.filePath}
</span>
))}
<span className="ml-auto min-w-0 truncate font-mono text-[10.5px] text-ink-3">
{item.hint || item.filePath}
</span>
</div>
{item.desc && <div className="mt-1 line-clamp-2 text-[12px] leading-5 text-ink-3">{item.desc}</div>}
</button>
</div>
{item.desc && <div className="mt-1 line-clamp-2 text-[12px] leading-5 text-ink-3">{item.desc}</div>}
</button>
{onToggleItem && (
<button
type="button"
role="switch"
aria-checked={!item.disabled}
aria-label={`${item.disabled ? '启用' : '停用'} ${item.title}`}
disabled={toggleDisabled}
onClick={() => onToggleItem(item, Boolean(item.disabled))}
className={`relative h-6 w-10 shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
item.disabled ? 'bg-line-strong' : 'bg-accent'
}`}
>
<span
className={`absolute top-0.5 size-5 rounded-full bg-white shadow transition-[left] ${
item.disabled ? 'left-0.5' : 'left-[18px]'
}`}
/>
</button>
)}
</div>
))}
</div>
)}
@@ -745,6 +778,26 @@ export function SettingsModal({
.finally(() => setTogglingId(null))
}
/** 启用/禁用技能或命令:主进程持久化并重建运行时(system prompt 技能清单与 / 命令面板随之更新) */
const toggleResource = (kind: 'skill' | 'command', name: string, next: boolean): void => {
if (streaming) {
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再切换(切换会重建运行时)' })
return
}
if (busy) return
setBusy(true)
setNotice(null)
const label = kind === 'skill' ? '技能' : '命令'
window.api
.setResourceEnabled(kind, name, next)
.then(() => {
setNotice({ kind: 'ok', text: `已${next ? '启用' : '停用'}${label}「${name}」,运行时已重建` })
refreshResources()
})
.catch((err: unknown) => setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) }))
.finally(() => setBusy(false))
}
const refreshResources = (): void => {
setResourcesLoading(true)
window.api
@@ -978,11 +1031,13 @@ export function SettingsModal({
loading={resourcesLoading}
items={(resources?.skills ?? []).map((s) => ({
key: s.filePath,
name: s.name,
title: s.name,
scope: s.scope,
badges: s.disableModelInvocation ? [{ text: '仅手动调用', tone: 'warn' as const }] : [],
desc: s.description,
filePath: s.filePath
filePath: s.filePath,
disabled: s.disabled
}))}
dirs={{
user: [resources?.dirs.globalSkills ?? '', resources?.dirs.globalAgentsSkills ?? ''],
@@ -999,6 +1054,8 @@ export function SettingsModal({
setNotice({ kind: 'err', text: `打开目录失败: ${err instanceof Error ? err.message : String(err)}` })
)
}}
onToggleItem={(item, next) => toggleResource('skill', item.name, next)}
toggleDisabled={busy}
/>
)}
@@ -1007,11 +1064,13 @@ export function SettingsModal({
loading={resourcesLoading}
items={(resources?.commands ?? []).map((c) => ({
key: c.filePath,
name: c.name,
title: `/${c.name}`,
scope: c.scope,
badges: c.argumentHint ? [{ text: c.argumentHint, tone: 'accent' as const }] : [],
desc: c.description,
filePath: c.filePath
filePath: c.filePath,
disabled: c.disabled
}))}
dirs={{
user: [resources?.dirs.globalCommands ?? ''],
@@ -1025,6 +1084,8 @@ export function SettingsModal({
setNotice({ kind: 'err', text: `打开目录失败: ${err instanceof Error ? err.message : String(err)}` })
)
}}
onToggleItem={(item, next) => toggleResource('command', item.name, next)}
toggleDisabled={busy}
/>
)}
+1
View File
@@ -92,6 +92,7 @@ declare global {
getSystemPrompt: () => Promise<string>
setThemeColor: (id: string) => Promise<void>
setThemeMode: (mode: ThemeMode) => Promise<void>
setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean) => Promise<void>
resolveApproval: (id: string, approved: boolean) => Promise<void>
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
browserGetState: () => Promise<BrowserUiState>