v0.4.0: 工具与审批(业务工具骨架 + Approval Card)
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@earendil-works/pi-ai": "0.85.0",
|
||||||
"@earendil-works/pi-coding-agent": "0.85.0",
|
"@earendil-works/pi-coding-agent": "0.85.0",
|
||||||
"@earendil-works/pi-server": "0.85.0",
|
"@earendil-works/pi-server": "0.85.0",
|
||||||
"gsap": "3.15.0",
|
"gsap": "3.15.0",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -34,6 +34,11 @@ async function createWindow(): Promise<void> {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 窗口关闭时未决审批一律拒绝,避免 agent 永久挂起
|
||||||
|
win.on('closed', () => {
|
||||||
|
agentService.approvals.settleAll(false)
|
||||||
|
})
|
||||||
|
|
||||||
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
// 外部链接交给系统默认浏览器,不在应用内开新窗口
|
||||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
void shell.openExternal(url)
|
void shell.openExternal(url)
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ export function registerIpc(): AgentService {
|
|||||||
return pass(() => service.setSystemPrompt(text))
|
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', () => ({
|
ipcMain.handle('app:versions', () => ({
|
||||||
app: process.env.npm_package_version ?? '0.0.0',
|
app: process.env.npm_package_version ?? '0.0.0',
|
||||||
electron: process.versions.electron,
|
electron: process.versions.electron,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
type SessionInfo
|
type SessionInfo
|
||||||
} from '@earendil-works/pi-coding-agent'
|
} from '@earendil-works/pi-coding-agent'
|
||||||
|
import { ApprovalService, createApprovalExtension } from './approval'
|
||||||
|
import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
|
||||||
import type {
|
import type {
|
||||||
AgentUiEvent,
|
AgentUiEvent,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -41,6 +43,7 @@ export class AgentService {
|
|||||||
private attached: AgentSession | null = null
|
private attached: AgentSession | null = null
|
||||||
private unsubscribeAttached: (() => void) | null = null
|
private unsubscribeAttached: (() => void) | null = null
|
||||||
private settings: AppSettings = { systemPrompt: '' }
|
private settings: AppSettings = { systemPrompt: '' }
|
||||||
|
readonly approvals = new ApprovalService()
|
||||||
|
|
||||||
private get cwd(): string {
|
private get cwd(): string {
|
||||||
return app.getPath('home')
|
return app.getPath('home')
|
||||||
@@ -74,6 +77,11 @@ export class AgentService {
|
|||||||
return () => this.listeners.delete(fn)
|
return () => this.listeners.delete(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 供 IPC 层主动广播事件(如审批结果同步到所有窗口) */
|
||||||
|
broadcastEvent(event: AgentUiEvent): void {
|
||||||
|
this.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
private emit(event: AgentUiEvent): void {
|
private emit(event: AgentUiEvent): void {
|
||||||
for (const fn of this.listeners) {
|
for (const fn of this.listeners) {
|
||||||
try {
|
try {
|
||||||
@@ -110,13 +118,23 @@ export class AgentService {
|
|||||||
const cwd = this.cwd
|
const cwd = this.cwd
|
||||||
const agentDir = getAgentDir()
|
const agentDir = getAgentDir()
|
||||||
this.settings = this.loadSettings()
|
this.settings = this.loadSettings()
|
||||||
const initialServices = await createAgentSessionServices({
|
// 审批拦截:TOOLS_REQUIRING_APPROVAL 名单内的工具调用需经渲染层 Approval Card 确认
|
||||||
cwd,
|
this.approvals.onRequest((request) => {
|
||||||
agentDir,
|
this.emit({ type: 'approval_request', request })
|
||||||
resourceLoaderOptions: this.settings.systemPrompt.trim()
|
|
||||||
? { systemPrompt: this.settings.systemPrompt }
|
|
||||||
: undefined
|
|
||||||
})
|
})
|
||||||
|
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 变化时重建服务,否则复用
|
// 每次切换/新建时由 runtime 调用;cwd 变化时重建服务,否则复用
|
||||||
const factory: CreateAgentSessionRuntimeFactory = async ({
|
const factory: CreateAgentSessionRuntimeFactory = async ({
|
||||||
@@ -128,8 +146,13 @@ export class AgentService {
|
|||||||
const services =
|
const services =
|
||||||
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
|
nextCwd === initialServices.cwd && nextAgentDir === initialServices.agentDir
|
||||||
? initialServices
|
? initialServices
|
||||||
: await createAgentSessionServices({ cwd: nextCwd, agentDir: nextAgentDir })
|
: await buildServices(nextCwd, nextAgentDir)
|
||||||
const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })
|
const result = await createAgentSessionFromServices({
|
||||||
|
services,
|
||||||
|
sessionManager,
|
||||||
|
sessionStartEvent,
|
||||||
|
customTools: demoTools
|
||||||
|
})
|
||||||
return { ...result, services, diagnostics: services.diagnostics }
|
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]
|
||||||
@@ -21,6 +21,8 @@ const api = {
|
|||||||
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
|
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
|
||||||
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
|
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
|
||||||
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
|
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
|
||||||
|
resolveApproval: (id: string, approved: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('approval:resolve', id, approved),
|
||||||
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
|
||||||
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
||||||
ipcRenderer.on('agent:event', listener)
|
ipcRenderer.on('agent:event', listener)
|
||||||
|
|||||||
@@ -5,9 +5,21 @@ import { Sidebar } from './components/Sidebar'
|
|||||||
import { ChatView } from './components/ChatView'
|
import { ChatView } from './components/ChatView'
|
||||||
import { Composer } from './components/Composer'
|
import { Composer } from './components/Composer'
|
||||||
import { SettingsModal } from './components/SettingsModal'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
|
import { ApprovalStack } from './components/ApprovalCard'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
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<string | null>(null)
|
const prefillRef = useRef<string | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
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
|
const queuedCount = state.queue.followUp.length + state.queue.steering.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
<div className="relative flex h-full w-full bg-zinc-950 text-zinc-100 antialiased">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
sessionState={state.sessionState}
|
sessionState={state.sessionState}
|
||||||
stats={state.stats}
|
stats={state.stats}
|
||||||
@@ -30,7 +42,7 @@ export default function App(): JSX.Element {
|
|||||||
onDeleteSession={deleteSession}
|
onDeleteSession={deleteSession}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||||
<ChatView
|
<ChatView
|
||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
tools={state.tools}
|
tools={state.tools}
|
||||||
@@ -49,6 +61,7 @@ export default function App(): JSX.Element {
|
|||||||
onStop={stop}
|
onStop={stop}
|
||||||
prefillRef={prefillRef}
|
prefillRef={prefillRef}
|
||||||
/>
|
/>
|
||||||
|
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
|
||||||
</main>
|
</main>
|
||||||
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="pointer-events-auto w-[440px] max-w-[90vw] rounded-2xl border border-amber-900/60 bg-zinc-900 p-4 shadow-2xl shadow-black/50"
|
||||||
|
>
|
||||||
|
<div className="mb-2.5 flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-950/70 ring-1 ring-amber-900/60">
|
||||||
|
<ShieldAlert size={14} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-medium text-zinc-100">敏感操作,需要你的确认</div>
|
||||||
|
<div className="font-mono text-[11px] text-amber-300/80">{request.toolName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{argsText && argsText !== 'null' && (
|
||||||
|
<pre className="mb-3 max-h-32 overflow-auto rounded-lg bg-zinc-950/70 p-2.5 font-mono text-[11px] leading-relaxed text-zinc-400">
|
||||||
|
{argsText.length > 600 ? `${argsText.slice(0, 600)}…` : argsText}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onResolve(false)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-[12.5px] text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onResolve(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
允许执行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalStack({
|
||||||
|
requests,
|
||||||
|
onResolve
|
||||||
|
}: {
|
||||||
|
requests: ApprovalRequest[]
|
||||||
|
onResolve: (id: string, approved: boolean) => void
|
||||||
|
}): JSX.Element | null {
|
||||||
|
if (requests.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-30 flex flex-col items-center gap-2">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<ApprovalRow key={request.id} request={request} onResolve={(approved) => onResolve(request.id, approved)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
|
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 {
|
export interface ToolState {
|
||||||
toolName: string
|
toolName: string
|
||||||
@@ -9,6 +9,13 @@ export interface ToolState {
|
|||||||
status: 'running' | 'done' | 'error'
|
status: 'running' | 'done' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 已结算的审批(保留短暂时间用于展示结果反馈) */
|
||||||
|
export interface SettledApproval {
|
||||||
|
id: string
|
||||||
|
approved: boolean
|
||||||
|
timedOut: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiMessage {
|
export interface UiMessage {
|
||||||
key: string
|
key: string
|
||||||
role: string
|
role: string
|
||||||
@@ -31,6 +38,8 @@ interface AgentUiState {
|
|||||||
stats: SessionStatsInfo | null
|
stats: SessionStatsInfo | null
|
||||||
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
retry: { attempt: number; maxAttempts: number; errorMessage: string } | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
approvals: ApprovalRequest[]
|
||||||
|
settledApprovals: SettledApproval[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
@@ -48,7 +57,9 @@ const initialState: AgentUiState = {
|
|||||||
sessionState: null,
|
sessionState: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
retry: null,
|
retry: null,
|
||||||
ready: false
|
ready: false,
|
||||||
|
approvals: [],
|
||||||
|
settledApprovals: []
|
||||||
}
|
}
|
||||||
|
|
||||||
let keyCounter = 0
|
let keyCounter = 0
|
||||||
@@ -240,6 +251,16 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
sessionState: { ...state.sessionState, sessionName: event.name }
|
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':
|
case 'stats':
|
||||||
return { ...state, stats: event.stats }
|
return { ...state, stats: event.stats }
|
||||||
default:
|
default:
|
||||||
@@ -258,6 +279,7 @@ export interface UseAgent {
|
|||||||
openSession: (path: string) => void
|
openSession: (path: string) => void
|
||||||
renameSession: (name: string) => void
|
renameSession: (name: string) => void
|
||||||
deleteSession: (path: string) => void
|
deleteSession: (path: string) => void
|
||||||
|
resolveApproval: (id: string, approved: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgent(): UseAgent {
|
export function useAgent(): UseAgent {
|
||||||
@@ -341,6 +363,13 @@ export function useAgent(): UseAgent {
|
|||||||
[dispatchError]
|
[dispatchError]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resolveApproval = useCallback(
|
||||||
|
(id: string, approved: boolean): void => {
|
||||||
|
window.api.resolveApproval(id, approved).catch(dispatchError)
|
||||||
|
},
|
||||||
|
[dispatchError]
|
||||||
|
)
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -352,8 +381,21 @@ export function useAgent(): UseAgent {
|
|||||||
newSession,
|
newSession,
|
||||||
openSession,
|
openSession,
|
||||||
renameSession,
|
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
|
||||||
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ declare global {
|
|||||||
logoutProvider: (providerId: string) => Promise<void>
|
logoutProvider: (providerId: string) => Promise<void>
|
||||||
getSettings: () => Promise<AppSettings>
|
getSettings: () => Promise<AppSettings>
|
||||||
setSystemPrompt: (text: string) => Promise<void>
|
setSystemPrompt: (text: string) => Promise<void>
|
||||||
|
resolveApproval: (id: string, approved: boolean) => Promise<void>
|
||||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ export interface AppSettings {
|
|||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 审批请求(主进程 → 渲染层 Approval Card) */
|
||||||
|
export interface ApprovalRequest {
|
||||||
|
id: string
|
||||||
|
toolName: string
|
||||||
|
args: unknown
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转发到渲染进程的 agent 事件。
|
* 转发到渲染进程的 agent 事件。
|
||||||
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
* 已归一化为可结构化克隆的普通 JSON(message 原样透传,渲染层防御性读取)。
|
||||||
@@ -72,6 +79,8 @@ export type AgentUiEvent =
|
|||||||
| { type: 'session_error'; message: string }
|
| { type: 'session_error'; message: string }
|
||||||
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
|
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
|
||||||
| { type: 'state'; state: SessionState }
|
| { type: 'state'; state: SessionState }
|
||||||
|
| { type: 'approval_request'; request: ApprovalRequest }
|
||||||
|
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
||||||
| { type: 'agent_start' }
|
| { type: 'agent_start' }
|
||||||
| { type: 'agent_end' }
|
| { type: 'agent_end' }
|
||||||
| { type: 'agent_settled' }
|
| { type: 'agent_settled' }
|
||||||
|
|||||||
@@ -48,6 +48,21 @@
|
|||||||
4. **系统提示词重建成本**——保存提示词会整体重建 runtime(重新加载扩展/skills),秒级耗时且丢当前流式状态;保存前应检查是否有进行中的回复。当前实现未拦截,记录待修。
|
4. **系统提示词重建成本**——保存提示词会整体重建 runtime(重新加载扩展/skills),秒级耗时且丢当前流式状态;保存前应检查是否有进行中的回复。当前实现未拦截,记录待修。
|
||||||
5. **运行时重建后模型选择**——重建走 continueRecent,会话内保存的模型跟随会话;但全局默认模型(settings 持久化)与 UI 状态的同步依赖 `state` 事件,尚未覆盖"重建后 model fallback"提示(modelFallbackMessage 丢弃未展示)。排 v0.5。
|
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 — 骨架 + 聊天链路
|
## v0.1.0 — 骨架 + 聊天链路
|
||||||
|
|
||||||
**交付**:
|
**交付**:
|
||||||
|
|||||||
Generated
+4
-1
@@ -10,6 +10,9 @@ importers:
|
|||||||
|
|
||||||
apps/desktop:
|
apps/desktop:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@earendil-works/pi-ai':
|
||||||
|
specifier: 0.85.0
|
||||||
|
version: 0.85.0(ws@8.21.3)
|
||||||
'@earendil-works/pi-coding-agent':
|
'@earendil-works/pi-coding-agent':
|
||||||
specifier: 0.85.0
|
specifier: 0.85.0
|
||||||
version: 0.85.0(ws@8.21.3)
|
version: 0.85.0(ws@8.21.3)
|
||||||
@@ -1795,7 +1798,7 @@ snapshots:
|
|||||||
'@aws-sdk/types': 3.974.5
|
'@aws-sdk/types': 3.974.5
|
||||||
'@smithy/core': 3.33.3
|
'@smithy/core': 3.33.3
|
||||||
'@smithy/fetch-http-handler': 5.8.0
|
'@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
|
'@smithy/types': 4.18.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user