From fdb5fe2adeca7edbc50ecf8e8d8891f8e00d8b4d Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Thu, 10 Sep 2026 17:44:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=80=E8=83=BD/=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0=E5=A2=9E=E5=90=AF=E5=81=9C?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E2=80=94=E2=80=94=E6=8C=89=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=B2=92=E5=BA=A6=E6=8E=A7=E5=88=B6=20Agent=20=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/ipc.ts | 8 ++ apps/desktop/src/main/pi/agent-service.ts | 66 +++++++++-- apps/desktop/src/preload/index.ts | 2 + .../renderer/src/components/SettingsModal.tsx | 111 ++++++++++++++---- apps/desktop/src/renderer/src/lib/api.ts | 1 + apps/desktop/src/shared/types.ts | 8 ++ 6 files changed, 163 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 443ffa2..e7cc87d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -205,6 +205,14 @@ export function registerIpc(): AgentService { service.setThemeMode(mode) }) + // 启用/禁用单个技能或命令(重建运行时使发现阶段过滤生效) + ipcMain.handle('settings:setResourceEnabled', (_event, kind: unknown, name: unknown, enabled: unknown) => { + if (kind !== 'skill' && kind !== 'command') throw new Error('invalid resource kind') + if (typeof name !== 'string' || !name.trim() || name.length > 200) throw new Error('invalid resource name') + if (typeof enabled !== 'boolean') throw new Error('invalid enabled flag') + return service.setResourceEnabled(kind, name, enabled) + }) + // ---- 工作区 ---- ipcMain.handle('workspaces:list', () => service.listWorkspaces().catch((err: unknown) => { diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index 90dd374..3ca333d 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -15,7 +15,9 @@ import { type AgentSessionEvent, type AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, - type SessionInfo + type PromptTemplate, + type SessionInfo, + type Skill } from '@earendil-works/pi-coding-agent' import { ApprovalService, createApprovalExtension } from './approval' import { getPluginHost } from '../plugins/plugin-host' @@ -56,6 +58,12 @@ const CUSTOM_TOOL_NAMES = new Set(browserToolNames) /** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */ const TOOLS_REQUIRING_APPROVAL: ReadonlySet = new Set(['bash', 'powershell', 'write', 'edit']) +/** 资源禁用清单(disabledSkills/disabledCommands)的白名单式解析:仅保留非空字符串并去重 */ +function parseNameList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value.filter((n): n is string => typeof n === 'string' && n.trim().length > 0).map((n) => n.trim()))] +} + /** Windows 优先 PowerShell:探测结果进程内缓存(getPowerShellConfig 会 spawnSync where 探测, * 且平台与安装情况不会在应用运行中变化)。探测失败保持 bash(Git Bash)不动。 */ let powershellProbe: boolean | null = null @@ -103,6 +111,10 @@ export class AgentService { private permissionMode: PermissionMode = 'confirm' /** 技能清单 token 估算缓存(随工作区失效) */ private skillsEstimate: { cwd: string; value: number } | null = null + /** pi 全量发现的技能/命令快照(skillsOverride/promptsOverride 在每次 loader reload 时写入)。 + * 被禁用的资源在 loader 里已过滤掉,设置页靠这份快照展示"已禁用"状态。 */ + private resourceSnapshotSkills: Skill[] = [] + private resourceSnapshotPrompts: PromptTemplate[] = [] readonly approvals = new ApprovalService() constructor() { @@ -141,7 +153,9 @@ export class AgentService { permissionMode: raw.permissionMode === 'full' ? 'full' : 'confirm', themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined, - themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system' + themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system', + disabledSkills: parseNameList(raw.disabledSkills), + disabledCommands: parseNameList(raw.disabledCommands) } } catch { this.settings = { permissionMode: this.permissionMode, themeMode: 'system' } @@ -297,7 +311,20 @@ export class AgentService { createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode), // 插件聚合 extension:全部已启用插件在同一真实 ExtensionAPI 上下文里激活 getPluginHost().createAggregatedExtension() - ] + ], + // 技能/命令启停:发现阶段按 AppSettings 过滤(被禁资源不进系统提示词与 / 命令面板)。 + // 全量结果先快照给设置页(被禁项在 loader 结果里不可见);闭包在每次 reload 重跑, + // 工作区切换/重建运行时后快照随之刷新。 + skillsOverride: (base) => { + this.resourceSnapshotSkills = base.skills + const disabled = new Set(this.settings.disabledSkills ?? []) + return { skills: base.skills.filter((s) => !disabled.has(s.name)), diagnostics: base.diagnostics } + }, + promptsOverride: (base) => { + this.resourceSnapshotPrompts = base.prompts + const disabled = new Set(this.settings.disabledCommands ?? []) + return { prompts: base.prompts.filter((p) => !disabled.has(p.name)), diagnostics: base.diagnostics } + } } }) const initialServices = await buildServices(cwd, agentDir) @@ -716,7 +743,8 @@ export class AgentService { } /** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。 - * 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */ + * 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 + * 数据源用 override 写入的全量快照:被禁资源在会话里已过滤,只有快照还看得到。 */ async listResourceDetails(): Promise { const agentDir = getAgentDir() const dirs = { @@ -727,17 +755,21 @@ export class AgentService { globalCommands: join(agentDir, 'prompts'), projectCommands: join(this.cwd, '.pi', 'prompts') } + const disabledSkills = new Set(this.settings.disabledSkills ?? []) + const disabledCommands = new Set(this.settings.disabledCommands ?? []) const skills: SkillDetail[] = [] try { const runtime = await this.getRuntime() - for (const s of runtime.session.resourceLoader.getSkills().skills) { + const all = this.resourceSnapshotSkills.length > 0 ? this.resourceSnapshotSkills : runtime.session.resourceLoader.getSkills().skills + for (const s of all) { skills.push({ name: s.name, description: s.description, filePath: s.filePath, // 与 resource loader 的默认归类一致:无来源信息时按用户级 scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user', - disableModelInvocation: Boolean(s.disableModelInvocation) + disableModelInvocation: Boolean(s.disableModelInvocation), + disabled: disabledSkills.has(s.name) }) } } catch (err) { @@ -746,13 +778,16 @@ export class AgentService { const commands: CommandDetail[] = [] try { const runtime = await this.getRuntime() + const all = + this.resourceSnapshotPrompts.length > 0 ? this.resourceSnapshotPrompts : runtime.session.promptTemplates commands.push( - ...runtime.session.promptTemplates.map((t) => ({ + ...all.map((t) => ({ name: t.name, description: t.description ?? '', argumentHint: t.argumentHint || undefined, filePath: t.filePath, - scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const) + scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const), + disabled: disabledCommands.has(t.name) })) ) } catch (err) { @@ -763,6 +798,21 @@ export class AgentService { return { skills, commands, dirs } } + /** 启用/禁用单个技能或命令:持久化到 AppSettings 后重建运行时(发现阶段过滤随之生效) */ + async setResourceEnabled(kind: 'skill' | 'command', name: string, enabled: boolean): Promise { + const trimmed = name.trim() + if (!trimmed) throw new Error('资源名不能为空') + const field = kind === 'skill' ? ('disabledSkills' as const) : ('disabledCommands' as const) + const next = new Set(this.settings[field] ?? []) + if (enabled) next.delete(trimmed) + else next.add(trimmed) + this.settings = { ...this.settings, [field]: [...next].sort((a, b) => a.localeCompare(b)) } + this.saveSettings() + await this.rebuildRuntime() + const session = this.runtime?.session + if (session) this.emit({ type: 'state', state: this.buildState(session) }) + } + // ---- 工作区文件(@ 引用)---- async listWorkspaceFiles(): Promise { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6a09ec3..cf98f4f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -96,6 +96,8 @@ const api = { getSystemPrompt: (): Promise => ipcRenderer.invoke('settings:getSystemPrompt'), setThemeColor: (id: string): Promise => ipcRenderer.invoke('settings:setTheme', id), setThemeMode: (mode: ThemeMode): Promise => ipcRenderer.invoke('settings:setThemeMode', mode), + setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean): Promise => + ipcRenderer.invoke('settings:setResourceEnabled', kind, name, enabled), resolveApproval: (id: string, approved: boolean): Promise => ipcRenderer.invoke('approval:resolve', id, approved), onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index 6a67bf2..660c3d5 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -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({ ) : (
{scoped.map((item) => ( - +
+ {item.desc &&
{item.desc}
} + + {onToggleItem && ( + + )} + ))} )} @@ -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} /> )} diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index 56f3405..53d4a74 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -92,6 +92,7 @@ declare global { getSystemPrompt: () => Promise setThemeColor: (id: string) => Promise setThemeMode: (mode: ThemeMode) => Promise + setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean) => Promise resolveApproval: (id: string, approved: boolean) => Promise onEvent: (callback: (event: AgentUiEvent) => void) => () => void browserGetState: () => Promise diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 935e242..1aa5e78 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -149,6 +149,10 @@ export interface AppSettings { themeColor?: string /** 深浅色模式(缺省 system 跟随系统;经 Electron nativeTheme 生效) */ themeMode?: ThemeMode + /** 已禁用的技能名清单(技能按 name 加载去重,name 即唯一标识;加载阶段整体过滤) */ + disabledSkills?: string[] + /** 已禁用的命令(prompt 模板)名清单 */ + disabledCommands?: string[] } /** 深浅色模式:light / dark / system(跟随系统) */ @@ -193,6 +197,8 @@ export interface SkillDetail { scope: ResourceScope /** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */ disableModelInvocation: boolean + /** true = 已被用户禁用(加载阶段过滤,不进会话) */ + disabled: boolean } /** 设置页命令条目(pi prompt template,含来源文件路径) */ @@ -203,6 +209,8 @@ export interface CommandDetail { argumentHint?: string filePath: string scope: ResourceScope + /** true = 已被用户禁用(加载阶段过滤,不进会话) */ + disabled: boolean } /** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */