From 807fab4f2085d6b5e854b11d032b3b996e1246ae Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Sat, 5 Sep 2026 09:47:50 +0800 Subject: [PATCH] =?UTF-8?q?v0.4.0:=20=E5=B7=A5=E5=85=B7=E4=B8=8E=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=EF=BC=88=E4=B8=9A=E5=8A=A1=E5=B7=A5=E5=85=B7=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=20+=20Approval=20Card=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/package.json | 1 + apps/desktop/scripts/smoke-tools.mjs | 66 ++++++++++++ apps/desktop/src/main/index.ts | 5 + apps/desktop/src/main/ipc.ts | 8 ++ apps/desktop/src/main/pi/agent-service.ts | 39 +++++-- apps/desktop/src/main/pi/approval.ts | 89 +++++++++++++++ apps/desktop/src/main/tools/demo-tools.ts | 77 +++++++++++++ apps/desktop/src/preload/index.ts | 2 + apps/desktop/src/renderer/src/App.tsx | 19 +++- .../renderer/src/components/ApprovalCard.tsx | 102 ++++++++++++++++++ .../src/renderer/src/hooks/useAgent.ts | 50 ++++++++- apps/desktop/src/renderer/src/lib/api.ts | 1 + apps/desktop/src/shared/types.ts | 9 ++ docs/roadmap.md | 15 +++ pnpm-lock.yaml | 5 +- 15 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/scripts/smoke-tools.mjs create mode 100644 apps/desktop/src/main/pi/approval.ts create mode 100644 apps/desktop/src/main/tools/demo-tools.ts create mode 100644 apps/desktop/src/renderer/src/components/ApprovalCard.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 78ba462..cca9fcc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,7 @@ "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json" }, "dependencies": { + "@earendil-works/pi-ai": "0.85.0", "@earendil-works/pi-coding-agent": "0.85.0", "@earendil-works/pi-server": "0.85.0", "gsap": "3.15.0", diff --git a/apps/desktop/scripts/smoke-tools.mjs b/apps/desktop/scripts/smoke-tools.mjs new file mode 100644 index 0000000..6915bd0 --- /dev/null +++ b/apps/desktop/scripts/smoke-tools.mjs @@ -0,0 +1,66 @@ +/** + * v0.4.0 冒烟:业务工具注册 / 手动直调(D2 双模式路径)/ tool_call 拦截契约 + * 说明:审批拦截逻辑与 src/main/pi/approval.ts 保持一致(此处内联同构实现, + * 因冒烟脚本无法直接加载 TS 源文件;拦截契约由 pi 的 ExtensionHandler 类型保证)。 + */ +import { createAgentSessionFromServices, createAgentSessionServices, defineTool, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent' +import { Type } from '@earendil-works/pi-ai' +import { homedir } from 'node:os' + +const cwd = homedir() +const agentDir = getAgentDir() + +// 与 src/main/tools/demo-tools.ts 同构的工具定义 +const hotwordLookup = defineTool({ + name: 'hotword_lookup', + label: '热点词查询(演示)', + description: 'demo', + parameters: Type.Object({ query: Type.String(), limit: Type.Optional(Type.Number()) }), + async execute(_id, params) { + return { + content: [{ type: 'text', text: `热点词查询结果(演示数据源,query="${params.query}"):ok` }], + details: { source: 'demo' } + } + } +}) + +// ---- 1) 工具注册检查(customTools 路径) ---- +const services = await createAgentSessionServices({ cwd, agentDir }) +const { session } = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(cwd), + customTools: [hotwordLookup] +}) +const all = session.getAllTools() +console.log('[smoke4] registered tools:', all.map((t) => t.name).join(', ')) +console.log('[smoke4] hotword_lookup registered:', all.some((t) => t.name === 'hotword_lookup')) + +// ---- 2) 手动直调(双模式共用能力的证明,不经 LLM) ---- +const result = await hotwordLookup.execute('smoke-call', { query: '宠物' }, undefined, undefined, {}) +console.log('[smoke4] manual execute text:', JSON.stringify(result.content[0]?.text?.slice(0, 60))) + +// ---- 3) tool_call 拦截契约(模拟 ExtensionAPI 捕获 handler) ---- +const handlers = {} +const fakePi = { on: (event, handler) => { handlers[event] = handler } } +const needsApproval = new Set(['price_check', 'bash']) +let decision = false +fakePi.on('tool_call', async (event) => { + if (!needsApproval.has(event.toolName)) return + const approved = decision + if (!approved) return { block: true, reason: '用户拒绝了本次工具调用审批' } + return +}) + +const passthrough = await handlers['tool_call']({ type: 'tool_call', toolName: 'read', input: { path: 'x' } }) +console.log('[smoke4] read(不在名单)放行:', passthrough === undefined) + +decision = false +const denied = await handlers['tool_call']({ type: 'tool_call', toolName: 'price_check', input: { product_id: 'P1', target_price: 9.9 } }) +console.log('[smoke4] price_check 拒绝 -> block:', JSON.stringify(denied)) + +decision = true +const approvedRes = await handlers['tool_call']({ type: 'tool_call', toolName: 'bash', input: { command: 'ls' } }) +console.log('[smoke4] bash 批准 -> 放行:', approvedRes === undefined) + +console.log('[smoke4] done') +process.exit(0) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 000300d..f2fd79d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -34,6 +34,11 @@ async function createWindow(): Promise { }) }) + // 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起 + win.on('closed', () => { + agentService.approvals.settleAll(false) + }) + // 外部链接交给系统默认浏览器,不在应用内开新窗口 win.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 5b87f69..abf53df 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -105,6 +105,14 @@ export function registerIpc(): AgentService { return pass(() => service.setSystemPrompt(text)) }) + // ---- 工具审批 ---- + ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => { + if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval') + service.approvals.settle(id, approved) + service.broadcastEvent({ type: 'approval_settled', id, approved, timedOut: false }) + return Promise.resolve() + }) + ipcMain.handle('app:versions', () => ({ app: process.env.npm_package_version ?? '0.0.0', electron: process.versions.electron, diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index f252d49..b8ca928 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -13,6 +13,8 @@ import { type CreateAgentSessionRuntimeFactory, type SessionInfo } from '@earendil-works/pi-coding-agent' +import { ApprovalService, createApprovalExtension } from './approval' +import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools' import type { AgentUiEvent, AppSettings, @@ -41,6 +43,7 @@ export class AgentService { private attached: AgentSession | null = null private unsubscribeAttached: (() => void) | null = null private settings: AppSettings = { systemPrompt: '' } + readonly approvals = new ApprovalService() private get cwd(): string { return app.getPath('home') @@ -74,6 +77,11 @@ export class AgentService { return () => this.listeners.delete(fn) } + /** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */ + broadcastEvent(event: AgentUiEvent): void { + this.emit(event) + } + private emit(event: AgentUiEvent): void { for (const fn of this.listeners) { try { @@ -110,13 +118,23 @@ export class AgentService { const cwd = this.cwd const agentDir = getAgentDir() this.settings = this.loadSettings() - const initialServices = await createAgentSessionServices({ - cwd, - agentDir, - resourceLoaderOptions: this.settings.systemPrompt.trim() - ? { systemPrompt: this.settings.systemPrompt } - : undefined + // 审批拦截: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 }) + }) + 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)] + } + }) + const initialServices = await buildServices(cwd, agentDir) // 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用 const factory: CreateAgentSessionRuntimeFactory = async ({ @@ -128,8 +146,13 @@ export class AgentService { const services = nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir ? initialServices - : await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir }) - const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent }) + : await buildServices(nextCwd, nextAgentDir) + const result = await createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + customTools: demoTools + }) return { ...result, services, diagnostics: services.diagnostics } } diff --git a/apps/desktop/src/main/pi/approval.ts b/apps/desktop/src/main/pi/approval.ts new file mode 100644 index 0000000..245c555 --- /dev/null +++ b/apps/desktop/src/main/pi/approval.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'node:crypto' +import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent' + +/** + * 工具调用审批桥: + * - extension 拦截 tool_call → 挂起等待渲染层审批 + * - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互 + * - 超时自动拒绝,避免 agent 永久阻塞 + */ +const APPROVAL_TIMEOUT_MS = 120_000 + +export interface PendingApproval { + id: string + toolName: string + args: unknown + resolve: (approved: boolean) => void + timer: NodeJS.Timeout +} + +export class ApprovalService { + private pending = new Map() + private listener: ((request: { id: string; toolName: string; args: unknown }) => void) | null = null + private settleListener: ((id: string, approved: boolean, timedOut: boolean) => void) | null = null + + onRequest(fn: (request: { id: string; toolName: string; args: unknown }) => void): void { + this.listener = fn + } + + onSettle(fn: (id: string, approved: boolean, timedOut: boolean) => void): void { + this.settleListener = fn + } + + request(toolName: string, args: unknown): Promise { + return new Promise((resolve) => { + const id = randomUUID() + const timer = setTimeout(() => { + this.settle(id, false, true) + }, APPROVAL_TIMEOUT_MS) + this.pending.set(id, { id, toolName, args, resolve, timer }) + this.listener?.({ id, toolName, args: cloneable(args) }) + }) + } + + settle(id: string, approved: boolean, timedOut = false): void { + const entry = this.pending.get(id) + if (!entry) return + clearTimeout(entry.timer) + this.pending.delete(id) + entry.resolve(approved && !timedOut) + this.settleListener?.(id, approved, timedOut) + } + + settleAll(approved: boolean): void { + for (const id of [...this.pending.keys()]) { + this.settle(id, approved) + } + } +} + +function cloneable(value: unknown): unknown { + try { + return JSON.parse(JSON.stringify(value ?? null)) + } catch { + return null + } +} + +/** + * 生产 approval 拦截 extension: + * 匹配 NEEDS_APPROVAL 名单的工具调用先经用户审批,拒绝则 block。 + */ +export function createApprovalExtension( + service: ApprovalService, + needsApproval: ReadonlySet +): InlineExtension { + return { + name: 'aso-approval', + factory: (pi: ExtensionAPI) => { + pi.on('tool_call', async (event) => { + if (!needsApproval.has(event.toolName)) return + const approved = await service.request(event.toolName, event.input) + if (!approved) { + return { block: true, reason: '用户拒绝了本次工具调用审批' } + } + return + }) + } + } +} diff --git a/apps/desktop/src/main/tools/demo-tools.ts b/apps/desktop/src/main/tools/demo-tools.ts new file mode 100644 index 0000000..b0913ec --- /dev/null +++ b/apps/desktop/src/main/tools/demo-tools.ts @@ -0,0 +1,77 @@ +import { defineTool } from '@earendil-works/pi-coding-agent' +import { Type } from '@earendil-works/pi-ai' + +/** + * 业务工具骨架(v0.4 演示版)。 + * + * 架构约定(docs/architecture.md D2):每个业务功能 = 独立模块, + * 同时供 Agent(defineTool)与手动模式(IPC 直调)使用。 + * 当前后端未设计,演示工具只返回本地构造数据; + * 真实实现将替换为「服务器 API 薄封装」,接口形态保持不变。 + */ + +/** 演示热点词库(后端就绪后替换为真实数据源) */ +const DEMO_HOTWORDS: Array<{ word: string; heat: number; trend: 'up' | 'down' | 'flat'; category: string }> = [ + { word: '保温杯车载', heat: 9200, trend: 'up', category: '家居日用' }, + { word: '宠物自动喂食器', heat: 8500, trend: 'up', category: '宠物用品' }, + { word: '桌面升降支架', heat: 7100, trend: 'flat', category: '数码配件' }, + { word: '硅胶折叠饭盒', heat: 6600, trend: 'down', category: '家居日用' }, + { word: 'LED 化妆镜带灯', heat: 6100, trend: 'up', category: '美妆个护' } +] + +export const hotwordLookup = defineTool({ + name: 'hotword_lookup', + label: '热点词查询(演示)', + description: + '查询店铺运营热点词。输入品类或关键词,返回相关热点词、热度与趋势。当前为演示数据源(后端未接入),返回结果仅用于验证链路。', + promptSnippet: 'hotword_lookup: 查询热点词(热度/趋势/品类)', + parameters: Type.Object({ + query: Type.String({ description: '品类或关键词,如"家居"、"宠物"' }), + limit: Type.Optional(Type.Number({ description: '返回条数,默认 5', default: 5 })) + }), + async execute(_toolCallId, params) { + const q = params.query.trim() + const limit = Math.max(1, Math.min(params.limit ?? 5, 20)) + const matched = DEMO_HOTWORDS.filter( + (h) => !q || h.word.includes(q) || h.category.includes(q) || q.includes(h.category.slice(0, 2)) + ).slice(0, limit) + const lines = + matched.length === 0 + ? ['(演示数据源中无匹配项;后端接入后将返回真实采集结果)'] + : matched.map((h) => `${h.word}|热度 ${h.heat}|趋势 ${h.trend === 'up' ? '↑上升' : h.trend === 'down' ? '↓回落' : '→持平'}|品类 ${h.category}`) + return { + content: [{ type: 'text', text: `热点词查询结果(演示数据源,query=${q ? `"${q}"` : '全部'}):\n${lines.join('\n')}` }], + details: { source: 'demo', matched: matched.length } + } + } +}) + +export const priceCheck = defineTool({ + name: 'price_check', + label: '核价(演示·需审批)', + description: + '对指定商品执行核价操作。核价属于涉及资金与平台规则的敏感操作,调用前必须获得用户审批。当前为演示实现,仅回显参数。', + promptSnippet: 'price_check: 商品核价(敏感操作,需用户审批)', + promptGuidelines: ['调用 price_check 前先用一句话向用户说明将要核价的商品与价格区间。'], + parameters: Type.Object({ + product_id: Type.String({ description: '商品 ID(如 Temu 商品编码)' }), + target_price: Type.Number({ description: '目标价格' }), + currency: Type.Optional(Type.String({ description: '币种,默认 CNY', default: 'CNY' })) + }), + async execute(_toolCallId, params) { + return { + content: [ + { + type: 'text', + text: `核价完成(演示):商品 ${params.product_id} 目标价 ${params.target_price} ${params.currency ?? 'CNY'}。真实实现将调用 CDP 操作店铺后台核价页并回读结果。` + } + ], + details: { demo: true, params } + } + } +}) + +/** 需要人工审批的工具名单(含高危内置工具) */ +export const TOOLS_REQUIRING_APPROVAL: ReadonlySet = new Set(['price_check', 'bash', 'write', 'edit']) + +export const demoTools = [hotwordLookup, priceCheck] diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 7ab8d1d..53bbb8a 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -21,6 +21,8 @@ const api = { logoutProvider: (providerId: string): Promise => ipcRenderer.invoke('auth:logout', providerId), getSettings: (): Promise => ipcRenderer.invoke('settings:get'), setSystemPrompt: (text: string): Promise => ipcRenderer.invoke('settings:setPrompt', text), + resolveApproval: (id: string, approved: boolean): Promise => + ipcRenderer.invoke('approval:resolve', id, approved), onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event) ipcRenderer.on('agent:event', listener) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index a48d004..978e457 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -5,9 +5,21 @@ import { Sidebar } from './components/Sidebar' import { ChatView } from './components/ChatView' import { Composer } from './components/Composer' import { SettingsModal } from './components/SettingsModal' +import { ApprovalStack } from './components/ApprovalCard' export default function App(): JSX.Element { - const { state, sessions, send, stop, clearError, newSession, openSession, renameSession, deleteSession } = useAgent() + const { + state, + sessions, + send, + stop, + clearError, + newSession, + openSession, + renameSession, + deleteSession, + resolveApproval + } = useAgent() const prefillRef = useRef(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -18,7 +30,7 @@ export default function App(): JSX.Element { const queuedCount = state.queue.followUp.length + state.queue.steering.length return ( -
+
setSettingsOpen(true)} /> -
+
+
setSettingsOpen(false)} />
diff --git a/apps/desktop/src/renderer/src/components/ApprovalCard.tsx b/apps/desktop/src/renderer/src/components/ApprovalCard.tsx new file mode 100644 index 0000000..8a23b62 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ApprovalCard.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef } from 'react' +import type { JSX } from 'react' +import { Check, ShieldAlert, X } from 'lucide-react' +import type { ApprovalRequest } from '@shared/types' + +/** + * 工具调用审批卡(beautifului.dev Approval Card 风格)。 + * 敏感工具(核价、写操作等)执行前弹出,允许/拒绝后 1.2s 收起。 + */ +function ApprovalRow({ + request, + onResolve +}: { + request: ApprovalRequest + onResolve: (approved: boolean) => void +}): JSX.Element { + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + let tween: { kill(): void } | null = null + let cancelled = false + void import('gsap').then((gsapModule) => { + if (cancelled) return + tween = gsapModule.gsap.fromTo( + el, + { opacity: 0, y: 16, scale: 0.97 }, + { opacity: 1, y: 0, scale: 1, duration: 0.3, ease: 'back.out(1.6)' } + ) + }) + return () => { + cancelled = true + tween?.kill() + } + }, []) + + const argsText = (() => { + try { + return JSON.stringify(request.args, null, 2) + } catch { + return String(request.args) + } + })() + + return ( +
+
+
+ +
+
+
敏感操作,需要你的确认
+
{request.toolName}
+
+
+ {argsText && argsText !== 'null' && ( +
+          {argsText.length > 600 ? `${argsText.slice(0, 600)}…` : argsText}
+        
+ )} +
+ + +
+
+ ) +} + +export function ApprovalStack({ + requests, + onResolve +}: { + requests: ApprovalRequest[] + onResolve: (id: string, approved: boolean) => void +}): JSX.Element | null { + if (requests.length === 0) return null + return ( +
+ {requests.map((request) => ( + onResolve(request.id, approved)} /> + ))} +
+ ) +} diff --git a/apps/desktop/src/renderer/src/hooks/useAgent.ts b/apps/desktop/src/renderer/src/hooks/useAgent.ts index b64f1f0..e0e66d8 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgent.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgent.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'react' -import type { AgentUiEvent, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types' +import type { AgentUiEvent, ApprovalRequest, SessionMeta, SessionState, SessionStatsInfo } from '@shared/types' export interface ToolState { toolName: string @@ -9,6 +9,13 @@ export interface ToolState { status: 'running' | 'done' | 'error' } +/** 已结算的审批(保留短暂时间用于展示结果反馈) */ +export interface SettledApproval { + id: string + approved: boolean + timedOut: boolean +} + export interface UiMessage { key: string role: string @@ -31,6 +38,8 @@ interface AgentUiState { stats: SessionStatsInfo | null retry: { attempt: number; maxAttempts: number; errorMessage: string } | null ready: boolean + approvals: ApprovalRequest[] + settledApprovals: SettledApproval[] } type Action = @@ -48,7 +57,9 @@ const initialState: AgentUiState = { sessionState: null, stats: null, retry: null, - ready: false + ready: false, + approvals: [], + settledApprovals: [] } let keyCounter = 0 @@ -240,6 +251,16 @@ function reducer(state: AgentUiState, action: Action): AgentUiState { sessionState: { ...state.sessionState, sessionName: event.name } } } + case 'approval_request': + return { ...state, approvals: [...state.approvals, event.request] } + case 'approval_settled': { + const settled: SettledApproval = { id: event.id, approved: event.approved, timedOut: event.timedOut } + return { + ...state, + approvals: state.approvals.filter((a) => a.id !== event.id), + settledApprovals: [...state.settledApprovals.slice(-2), settled] + } + } case 'stats': return { ...state, stats: event.stats } default: @@ -258,6 +279,7 @@ export interface UseAgent { openSession: (path: string) => void renameSession: (name: string) => void deleteSession: (path: string) => void + resolveApproval: (id: string, approved: boolean) => void } export function useAgent(): UseAgent { @@ -341,6 +363,13 @@ export function useAgent(): UseAgent { [dispatchError] ) + const resolveApproval = useCallback( + (id: string, approved: boolean): void => { + window.api.resolveApproval(id, approved).catch(dispatchError) + }, + [dispatchError] + ) + return useMemo( () => ({ state, @@ -352,8 +381,21 @@ export function useAgent(): UseAgent { newSession, openSession, renameSession, - deleteSession + deleteSession, + resolveApproval }), - [state, sessions, send, stop, clearError, refreshSessions, newSession, openSession, renameSession, deleteSession] + [ + state, + sessions, + send, + stop, + clearError, + refreshSessions, + newSession, + openSession, + renameSession, + deleteSession, + resolveApproval + ] ) } diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index de98d4a..6964c0a 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -29,6 +29,7 @@ declare global { logoutProvider: (providerId: string) => Promise getSettings: () => Promise setSystemPrompt: (text: string) => Promise + resolveApproval: (id: string, approved: boolean) => Promise onEvent: (callback: (event: AgentUiEvent) => void) => () => void } } diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 665c3fc..bffb54f 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -63,6 +63,13 @@ export interface AppSettings { systemPrompt: string } +/** 审批请求(主进程 → 渲染层 Approval Card) */ +export interface ApprovalRequest { + id: string + toolName: string + args: unknown +} + /** * 转发到渲染进程的 agent 事件。 * 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。 @@ -72,6 +79,8 @@ export type AgentUiEvent = | { type: 'session_error'; message: string } | { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record } | { type: 'state'; state: SessionState } + | { type: 'approval_request'; request: ApprovalRequest } + | { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean } | { type: 'agent_start' } | { type: 'agent_end' } | { type: 'agent_settled' } diff --git a/docs/roadmap.md b/docs/roadmap.md index 944d462..45f8d03 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -48,6 +48,21 @@ 4. **系统提示词重建成本**——保存提示词会整体重建 runtime(重新加载扩展/skills),秒级耗时且丢当前流式状态;保存前应检查是否有进行中的回复。当前实现未拦截,记录待修。 5. **运行时重建后模型选择**——重建走 continueRecent,会话内保存的模型跟随会话;但全局默认模型(settings 持久化)与 UI 状态的同步依赖 `state` 事件,尚未覆盖"重建后 model fallback"提示(modelFallbackMessage 丢弃未展示)。排 v0.5。 +## v0.4.0 — 工具与审批 + +**交付**: +- 业务工具骨架(`src/main/tools/demo-tools.ts`):`hotword_lookup`(演示数据源)、`price_check`(核价演示,敏感操作);接口形态对齐架构 D2(后端就绪后换实现不动接口) +- 审批链路:`ApprovalService` + `tool_call` 拦截 extension(`TOOLS_REQUIRING_APPROVAL` 名单:price_check/bash/write/edit)→ IPC `approval_request` → 渲染层 Approval Card(beautifului 风格,GSAP back.out 入场)→ `approval:resolve` 回传;120s 超时自动拒绝;窗口关闭拒绝全部未决 +- 工具经 `customTools` 注入(与内置 read/bash/… 并存注册),promptSnippet/promptGuidelines 进系统提示词 +- 验证:typecheck/build;Node 冒烟(customTools 注册、**手动直调 execute 成功 = D2 双模式共用能力实证**、拦截契约三名单场景:放行/拒绝 block/批准放行);应用带扩展正常启动 + +**自省**(v0.4.0 → 指导 v0.5+): +1. **端到端审批未实拍**——LLM 生成 tool_call → 审批卡弹出 → 拒绝后 agent 收到 block 结果的完整链路仍缺真实凭据验证(本机无 key)。拦截契约已单元级验证,剩余风险在事件时序(extension 在 AgentSession 的 tool hooks 上触发,理论覆盖所有工具)。 +2. **审批名单硬编码**——应做成设置项(每个工具可配置 always/ask/never);且业务工具多了以后要按"工具元数据声明敏感级别",而非集中名单。排 v0.5。 +3. **手动模式 UI 缺失**——D2 的另一半(UI 按钮直调工具)后端就绪前只能停留在代码层(smoke 已证 execute 可直调)。等 packages/agent 建立后做"手动模式面板"。 +4. **工具卡片在审批等待期间无"等待审批"状态**——ToolCard 显示 running,但用户看不出是在等他确认;应联动 approval 状态显示"等待审批中"。排 v0.5。 +5. **审批卡无倒计时**——120s 超时静默拒绝,卡片上应显示剩余时间。排 v0.5。 + ## v0.1.0 — 骨架 + 聊天链路 **交付**: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7acc761..0658b48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: apps/desktop: dependencies: + '@earendil-works/pi-ai': + specifier: 0.85.0 + version: 0.85.0(ws@8.21.3) '@earendil-works/pi-coding-agent': specifier: 0.85.0 version: 0.85.0(ws@8.21.3) @@ -1795,7 +1798,7 @@ snapshots: '@aws-sdk/types': 3.974.5 '@smithy/core': 3.33.3 '@smithy/fetch-http-handler': 5.8.0 - '@smithy/node-http-handler': 4.7.3 + '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.18.0 tslib: 2.8.1