feat: 技能/命令设置页新增启停开关——按资源粒度控制 Agent 能力
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -96,6 +96,8 @@ const api = {
|
||||
getSystemPrompt: (): Promise<string> => ipcRenderer.invoke('settings:getSystemPrompt'),
|
||||
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
|
||||
setThemeMode: (mode: ThemeMode): Promise<void> => ipcRenderer.invoke('settings:setThemeMode', mode),
|
||||
setResourceEnabled: (kind: 'skill' | 'command', name: string, enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('settings:setResourceEnabled', kind, name, enabled),
|
||||
resolveApproval: (id: string, approved: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('approval:resolve', id, approved),
|
||||
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
||||
|
||||
@@ -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,12 +185,15 @@ function ResourcePanel({
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{scoped.map((item) => (
|
||||
<button
|
||||
<div
|
||||
key={item.key}
|
||||
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"
|
||||
>
|
||||
<button
|
||||
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="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>
|
||||
@@ -200,6 +213,26 @@ function ResourcePanel({
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
|
||||
|
||||
Reference in New Issue
Block a user