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
+58 -8
View File
@@ -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<string>(browserToolNames)
/** 需要人工审批的工具名单(高危内置工具;业务演示工具已随 demo-tools.ts 移除) */
const TOOLS_REQUIRING_APPROVAL: ReadonlySet<string> = 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<ResourceDetails> {
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<void> {
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<WorkspaceFile[]> {