v0.4.0: 工具与审批(业务工具骨架 + Approval Card)
This commit is contained in:
@@ -34,6 +34,11 @@ async function createWindow(): Promise<void> {
|
||||
})
|
||||
})
|
||||
|
||||
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起
|
||||
win.on('closed', () => {
|
||||
agentService.approvals.settleAll(false)
|
||||
})
|
||||
|
||||
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, PendingApproval>()
|
||||
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<boolean> {
|
||||
return new Promise<boolean>((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<string>
|
||||
): 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> = new Set(['price_check', 'bash', 'write', 'edit'])
|
||||
|
||||
export const demoTools = [hotwordLookup, priceCheck]
|
||||
Reference in New Issue
Block a user