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"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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 }) => {
|
||||
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]
|
||||
@@ -21,6 +21,8 @@ const api = {
|
||||
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
|
||||
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
|
||||
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) => {
|
||||
const listener = (_e: Electron.IpcRendererEvent, event: AgentUiEvent): void => callback(event)
|
||||
ipcRenderer.on('agent:event', listener)
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<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
|
||||
sessionState={state.sessionState}
|
||||
stats={state.stats}
|
||||
@@ -30,7 +42,7 @@ export default function App(): JSX.Element {
|
||||
onDeleteSession={deleteSession}
|
||||
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
|
||||
messages={state.messages}
|
||||
tools={state.tools}
|
||||
@@ -49,6 +61,7 @@ export default function App(): JSX.Element {
|
||||
onStop={stop}
|
||||
prefillRef={prefillRef}
|
||||
/>
|
||||
<ApprovalStack requests={state.approvals} onResolve={resolveApproval} />
|
||||
</main>
|
||||
<SettingsModal open={settingsOpen} sessionState={state.sessionState} onClose={() => setSettingsOpen(false)} />
|
||||
</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 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
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ declare global {
|
||||
logoutProvider: (providerId: string) => Promise<void>
|
||||
getSettings: () => Promise<AppSettings>
|
||||
setSystemPrompt: (text: string) => Promise<void>
|
||||
resolveApproval: (id: string, approved: boolean) => Promise<void>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
| { 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' }
|
||||
|
||||
Reference in New Issue
Block a user