v0.6.0: beautifului 整合 + 会话/工作区/队列/权限/命令/附件全链路

- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
This commit is contained in:
Rick Ye
2026-09-06 09:57:14 +08:00
parent 3c2e9e96c5
commit a5951849c8
96 changed files with 11828 additions and 4649 deletions
+50 -6
View File
@@ -1,14 +1,35 @@
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { registerIpc } from './ipc'
process.env.APP_ROOT = join(__dirname, '..')
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
/** 应用图标(build/icon.ico,由 scripts/make-icons.cjs 从 build/icon.svg 生成) */
const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
const agentService = registerIpc()
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
function registerWindowControls(): void {
ipcMain.handle('win:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.handle('win:toggleMaximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle('win:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
}
async function createWindow(): Promise<void> {
const win = new BrowserWindow({
width: 1280,
@@ -16,8 +37,12 @@ async function createWindow(): Promise<void> {
minWidth: 960,
minHeight: 640,
show: false,
backgroundColor: '#09090b',
title: '店铺运营 Agent',
backgroundColor: '#ffffff',
title: 'Inkreach Agent',
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
// 无原生标题栏/菜单栏,标题栏与窗口控制按钮全部由渲染层 TitleBar 自绘
titleBarStyle: 'hidden',
webPreferences: {
preload: join(__dirname, '../preload/index.cjs'),
sandbox: false,
@@ -26,6 +51,18 @@ async function createWindow(): Promise<void> {
}
})
// 最大化状态同步给渲染层(切换「最大化/还原」字形)
const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized())
win.on('maximize', sendWinState)
win.on('unmaximize', sendWinState)
// 默认应用菜单已移除(File/Edit/View 不再显示),F12 补回 DevTools 开关
win.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
win.webContents.toggleDevTools()
}
})
win.on('ready-to-show', () => {
win.show()
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
@@ -46,8 +83,8 @@ async function createWindow(): Promise<void> {
})
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
win.webContents.on('console-message', (details) => {
console.log(`[renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`)
})
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
@@ -79,6 +116,11 @@ if (!app.requestSingleInstanceLock()) {
})
void app.whenReady().then(() => {
// Windows 任务栏归组的 AppUserModelID
if (process.platform === 'win32') {
app.setAppUserModelId('com.inkreach.agent')
}
Menu.setApplicationMenu(null)
void createWindow()
app.on('activate', () => {
@@ -89,4 +131,6 @@ if (!app.requestSingleInstanceLock()) {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
registerWindowControls()
}
+78 -4
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, ipcMain } from 'electron'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
/**
@@ -16,11 +17,20 @@ export function registerIpc(): AgentService {
}
})
ipcMain.handle('agent:send', (_event, text: unknown) => {
ipcMain.handle('agent:send', (_event, text: unknown, images: unknown) => {
if (typeof text !== 'string' || !text.trim()) {
throw new Error('empty prompt')
}
return service.send(text)
const sendImages = Array.isArray(images)
? images.filter(
(img): img is { mimeType: string; data: string } =>
typeof img === 'object' &&
img !== null &&
typeof (img as { mimeType?: unknown }).mimeType === 'string' &&
typeof (img as { data?: unknown }).data === 'string'
)
: undefined
return service.send(text, sendImages)
})
ipcMain.handle('agent:steer', (_event, text: unknown) => {
@@ -105,6 +115,39 @@ export function registerIpc(): AgentService {
return pass(() => service.setSystemPrompt(text))
})
// ---- 工作区 ----
ipcMain.handle('workspaces:list', () =>
service.listWorkspaces().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.handle('workspaces:create', (_event, name: unknown) => {
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
return service.createWorkspace(name).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
})
ipcMain.handle('workspaces:open', (_event, path: unknown) => {
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
return service.openWorkspace(path).catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
})
// 调起系统目录选择器;取消返回 null
ipcMain.handle('workspaces:browse', async () => {
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
const result = await dialog.showOpenDialog(win, {
title: '选择工作区目录',
defaultPath: service.workspacesRoot,
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths[0]
})
// ---- 工具审批 ----
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
@@ -117,8 +160,39 @@ export function registerIpc(): AgentService {
app: process.env.npm_package_version ?? '0.0.0',
electron: process.versions.electron,
node: process.versions.node,
chrome: process.versions.chrome
chrome: process.versions.chrome,
user: userInfo().username
}))
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
ipcMain.handle('chat:resources', () =>
service.listChatResources().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
const norm = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
return service.setQueue(norm(steering), norm(followUp))
})
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
const valid: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
if (typeof mode !== 'string' || !valid.has(mode)) throw new Error('invalid permission mode')
service.setPermissionMode(mode as never)
})
ipcMain.handle('files:list', () =>
service.listWorkspaceFiles().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
ipcMain.handle('attachments:pick', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
return service.pickAttachments(win ?? undefined)
})
return service
}
+303 -24
View File
@@ -1,11 +1,12 @@
import { app, shell } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { app, dialog, shell } from 'electron'
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import {
createAgentSessionRuntime,
createAgentSessionServices,
createAgentSessionFromServices,
getAgentDir,
loadSkills,
SessionManager,
type AgentSession,
type AgentSessionEvent,
@@ -18,11 +19,17 @@ import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
import type {
AgentUiEvent,
AppSettings,
AttachmentInfo,
ChatResources,
ModelCatalog,
ModelInfo,
PermissionMode,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo
SessionStatsInfo,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
@@ -43,10 +50,24 @@ export class AgentService {
private attached: AgentSession | null = null
private unsubscribeAttached: (() => void) | null = null
private settings: AppSettings = { systemPrompt: '' }
/** 当前工作区目录(agent cwd),会话按目录隔离 */
private workspaceDir = ''
private approvalsRegistered = false
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
private permissionMode: PermissionMode = 'confirm'
readonly approvals = new ApprovalService()
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
get workspacesRoot(): string {
return join(app.getPath('documents'), 'Inkreach')
}
private get defaultWorkspaceDir(): string {
return join(this.workspacesRoot, 'Default')
}
private get cwd(): string {
return app.getPath('home')
return this.workspaceDir || this.defaultWorkspaceDir
}
private get settingsFile(): string {
@@ -54,14 +75,103 @@ export class AgentService {
}
private loadSettings(): AppSettings {
const VALID_MODES: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
return {
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : ''
this.settings = {
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
customWorkspaces: Array.isArray(raw.customWorkspaces)
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
: undefined,
permissionMode:
typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
? (raw.permissionMode as PermissionMode)
: 'confirm'
}
} catch {
return { systemPrompt: '' }
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
}
this.permissionMode = this.settings.permissionMode ?? 'confirm'
this.initWorkspaces()
return this.settings
}
/** 确保根目录与默认工作区存在,并解析当前工作区(设置值失效时回退 Default) */
private initWorkspaces(): void {
try {
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
} catch (err) {
console.error('[agent-service] init workspaces failed:', err)
}
const saved = this.settings.workspace
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
}
// ---- 工作区 ----
async listWorkspaces(): Promise<WorkspaceMeta[]> {
try {
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
} catch {
// 目录已存在或创建失败时仍尝试枚举
}
const entries: WorkspaceMeta[] = []
const seen = new Set<string>([this.workspacesRoot.toLowerCase()])
try {
for (const d of readdirSync(this.workspacesRoot, { withFileTypes: true })) {
if (!d.isDirectory()) continue
const path = join(this.workspacesRoot, d.name)
entries.push({ name: d.name, path })
seen.add(path.toLowerCase())
}
} catch (err) {
console.error('[agent-service] list workspaces failed:', err)
}
// 目录选择器添加的根目录之外的工作区
for (const p of this.settings.customWorkspaces ?? []) {
if (!existsSync(p)) continue
const normalized = join(p).replace(/[/\\]+$/, '')
if (seen.has(normalized.toLowerCase())) continue
seen.add(normalized.toLowerCase())
entries.push({ name: dirname(normalized) === normalized ? normalized : normalized.split(/[\\/]/).pop() || normalized, path: normalized })
}
if (!entries.some((w) => w.name === 'Default')) {
entries.unshift({ name: 'Default', path: this.defaultWorkspaceDir })
}
return entries.sort((a, b) => (a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name)))
}
async createWorkspace(name: string): Promise<WorkspaceMeta> {
const trimmed = name.trim()
if (!trimmed || /[\\/:*?"<>|]/.test(trimmed)) {
throw new Error('工作区名称非法(不能包含 \\/:*?"<>|)')
}
const path = join(this.workspacesRoot, trimmed)
if (existsSync(path)) {
throw new Error(`工作区已存在: ${trimmed}`)
}
mkdirSync(path, { recursive: true })
return { name: trimmed, path }
}
/** 切换工作区:持久化设置后重建运行时(cwd 变化 → 会话列表随目录隔离)。
* 允许任意已存在目录;根目录之外的目录记入自定义工作区以便下次列出。 */
async openWorkspace(path: string): Promise<void> {
const resolved = join(path).replace(/[/\\]+$/, '')
if (!existsSync(resolved)) {
throw new Error(`工作区目录不存在: ${path}`)
}
if (samePath(resolved, this.cwd)) return
if (dirname(resolved).toLowerCase() !== this.workspacesRoot.toLowerCase()) {
const custom = new Set(this.settings.customWorkspaces ?? [])
custom.add(resolved)
this.settings.customWorkspaces = [...custom]
}
this.settings.workspace = resolved
this.saveSettings()
this.workspaceDir = resolved
await this.rebuildRuntime()
}
private saveSettings(): void {
@@ -118,20 +228,25 @@ export class AgentService {
const cwd = this.cwd
const agentDir = getAgentDir()
this.settings = this.loadSettings()
// 审批拦截:TOOLS_REQUIRING_APPROVAL 名单内的工具调用需经渲染层 Approval Card 确认
this.approvals.onRequest((request) => {
this.emit({ type: 'approval_request', request })
})
this.approvals.onSettle((id, approved, timedOut) => {
this.emit({ type: 'approval_settled', id, approved, timedOut })
})
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
if (!this.approvalsRegistered) {
this.approvalsRegistered = true
this.approvals.onRequest((request) => {
this.emit({ type: 'approval_request', request })
})
this.approvals.onSettle((id, approved, timedOut) => {
this.emit({ type: 'approval_settled', id, approved, timedOut })
})
}
const buildServices = (targetCwd: string, targetAgentDir: string) =>
createAgentSessionServices({
cwd: targetCwd,
agentDir: targetAgentDir,
resourceLoaderOptions: {
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
extensionFactories: [createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL)]
extensionFactories: [
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode)
]
}
})
const initialServices = await buildServices(cwd, agentDir)
@@ -164,6 +279,8 @@ export class AgentService {
this.emitSwitched(session)
})
this.attach(runtime.session)
// 此前 runtime 从未挂到实例上(rebuildRuntime 的 dispose 永远拿不到旧实例),补上
this.runtime = runtime
return runtime
}
@@ -306,7 +423,9 @@ export class AgentService {
model: modelInfo,
streaming: session.isStreaming,
activeTools: session.getActiveToolNames(),
messageCount: session.messages.length
messageCount: session.messages.length,
workspace: this.cwd,
permissionMode: this.permissionMode
}
}
@@ -331,13 +450,125 @@ export class AgentService {
return this.buildState(runtime.session)
}
async send(text: string): Promise<void> {
async send(text: string, images?: SendImage[]): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (session.isStreaming) {
await session.prompt(text, { streamingBehavior: 'followUp' })
} else {
await session.prompt(text)
const imageContent = images?.length
? images.map((img) => ({ type: 'image' as const, data: img.data, mimeType: img.mimeType }))
: undefined
const options = {
...(session.isStreaming ? { streamingBehavior: 'followUp' as const } : {}),
...(imageContent ? { images: imageContent } : {})
}
await session.prompt(text, Object.keys(options).length > 0 ? options : undefined)
}
// ---- Chatbox 资源(/ 命令、/skill: 技能)----
/** 重建排队消息(编辑/删除/排序/置顶都走这里:clear 后按新顺序重推)。
* 空闲时队列会被 pi 立即消费,无需(也不能)重推。 */
async setQueue(steering: string[], followUp: string[]): Promise<void> {
const runtime = await this.getRuntime()
const session = runtime.session
if (!session.isStreaming) return
session.clearQueue()
for (const text of steering) void session.steer(text)
for (const text of followUp) void session.followUp(text)
}
async listChatResources(): Promise<ChatResources> {
const runtime = await this.getRuntime()
let skills: ChatResources['skills'] = []
try {
const loaded = loadSkills({
cwd: this.cwd,
agentDir: getAgentDir(),
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
} catch (err) {
console.error('[agent-service] load skills failed:', err)
}
const commands = runtime.session.promptTemplates.map((t) => ({
name: t.name,
description: t.description ?? '',
kind: 'prompt' as const
}))
return { commands, skills }
}
// ---- 工作区文件(@ 引用)----
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
const root = this.cwd
const out: WorkspaceFile[] = []
const SKIP = new Set(['.git', 'node_modules', 'dist', 'out', 'build', '.pi', '.next', 'coverage'])
const walk = (dir: string, depth: number): void => {
if (depth > 4 || out.length >= 400) return
let entries: import('node:fs').Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (out.length >= 400) return
if (entry.name.startsWith('.') && entry.name !== '.env') continue
const full = join(dir, entry.name)
const rel = full.slice(root.length + 1).replace(/\\/g, '/')
if (entry.isDirectory()) {
if (SKIP.has(entry.name)) continue
out.push({ path: rel, name: entry.name, isDir: true })
walk(full, depth + 1)
} else if (entry.isFile()) {
out.push({ path: rel, name: entry.name, isDir: false })
}
}
}
walk(root, 0)
return out
}
// ---- 附件(+ 选择文件)----
async pickAttachments(parent?: Electron.BrowserWindow): Promise<AttachmentInfo[]> {
const options = {
title: '添加附件',
properties: ['openFile', 'multiSelections'] as Array<'openFile' | 'multiSelections'>,
filters: [
{ name: '常用文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'md', 'txt', 'csv', 'json', 'xlsx', 'docx'] },
{ name: '所有文件', extensions: ['*'] }
]
}
const result = parent ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) return []
return result.filePaths.map((path) => this.prepareAttachment(path))
}
prepareAttachment(path: string): AttachmentInfo {
const name = path.split(/[\\/]/).pop() ?? path
const ext = name.toLowerCase().split('.').pop() ?? ''
const IMAGE_MIME: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp'
}
const mimeType = IMAGE_MIME[ext] ?? ''
if (!mimeType) {
return { name, path, mimeType: '', data: '' }
}
try {
const stat = statSync(path)
if (stat.size > 8 * 1024 * 1024) {
throw new Error(`图片过大(>8MB): ${name}`)
}
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
} catch (err) {
console.error('[agent-service] read attachment failed:', err)
return { name, path, mimeType: '', data: '' }
}
}
@@ -356,9 +587,25 @@ export class AgentService {
async listSessions(): Promise<SessionMeta[]> {
const infos = await SessionManager.list(this.cwd)
return infos
const metas = infos
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
.map((info) => toSessionMeta(info))
// 新会话在首条消息写入前没有 .jsonl 文件,SessionManager.list 扫不到;
// 把当前活跃会话兜底合并进列表,避免侧栏“新建后不出现”
const session = this.runtime?.session
if (session && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) {
const now = new Date().toISOString()
metas.unshift({
id: session.sessionId,
path: session.sessionFile ?? '',
name: session.sessionName,
firstMessage: firstUserText(session.messages),
created: now,
modified: now,
messageCount: session.messages.length
})
}
return metas
}
async newSession(): Promise<void> {
@@ -466,11 +713,22 @@ export class AgentService {
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
async setSystemPrompt(text: string): Promise<void> {
this.settings = { systemPrompt: text }
this.settings = { ...this.settings, systemPrompt: text }
this.saveSettings()
await this.rebuildRuntime()
}
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
setPermissionMode(mode: PermissionMode): void {
this.permissionMode = mode
this.settings = { ...this.settings, permissionMode: mode }
this.saveSettings()
const session = this.runtime?.session ?? this.attached
if (session) {
this.emit({ type: 'state', state: this.buildState(session) })
}
}
private async rebuildRuntime(): Promise<void> {
const old = this.runtime
this.runtime = null
@@ -518,3 +776,24 @@ function readRole(message: unknown): string {
}
return 'unknown'
}
/** 提取第一条用户消息的纯文本(新会话未落盘时用作列表标题) */
function firstUserText(messages: unknown[]): string {
for (const message of messages) {
if (readRole(message) !== 'user') continue
const content = (message as { content?: unknown }).content
if (typeof content === 'string' && content.trim()) return content
if (Array.isArray(content)) {
const text = content
.map((block) =>
block && typeof block === 'object' && typeof (block as { text?: unknown }).text === 'string'
? (block as { text: string }).text
: ''
)
.join('')
.trim()
if (text) return text
}
}
return ''
}
+19 -2
View File
@@ -1,14 +1,19 @@
import { randomUUID } from 'node:crypto'
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
import type { PermissionMode } from '@shared/types'
/**
* 工具调用审批桥:
* - extension 拦截 tool_call → 挂起等待渲染层审批
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
* - 超时自动拒绝,避免 agent 永久阻塞
* - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
*/
const APPROVAL_TIMEOUT_MS = 120_000
/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
const EDIT_TOOLS = new Set(['write', 'edit'])
export interface PendingApproval {
id: string
toolName: string
@@ -67,17 +72,29 @@ function cloneable(value: unknown): unknown {
/**
* 生产 approval 拦截 extension:
* 匹配 NEEDS_APPROVAL 名单的工具调用先经用户审批,拒绝则 block。
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
* confirm:先经用户审批,拒绝则 block;full:全部放行;
* autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
*/
export function createApprovalExtension(
service: ApprovalService,
needsApproval: ReadonlySet<string>
needsApproval: ReadonlySet<string>,
getMode: () => PermissionMode
): InlineExtension {
return {
name: 'aso-approval',
factory: (pi: ExtensionAPI) => {
pi.on('tool_call', async (event) => {
if (!needsApproval.has(event.toolName)) return
const mode = getMode()
if (mode === 'full') return
if (mode === 'autoEdit' && EDIT_TOOLS.has(event.toolName)) return
if (mode === 'plan' && (EDIT_TOOLS.has(event.toolName) || event.toolName === 'bash')) {
return {
block: true,
reason: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
}
}
const approved = await service.request(event.toolName, event.input)
if (!approved) {
return { block: true, reason: '用户拒绝了本次工具调用审批' }