diff --git a/README.md b/README.md index 52f28c2..258b753 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 生产级通用 AI Agent 智能体桌面应用 -[![Version](https://img.shields.io/badge/version-0.3.8-blue)](./package.json) +[![Version](https://img.shields.io/badge/version-0.3.9-blue)](./package.json) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index 851441f..57462d0 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -22,6 +22,11 @@ export interface ConfirmationRequest { args: Record; riskLevel: string; reason: string; + /** + * 过期时间戳(ms),由 waitForConfirmation 注入,用于前端倒计时 UI。 + * 注意:beforeExecute 构造 request 时不带此字段,仅在 waitForConfirmation 中追加。 + */ + expiresAt?: number; } export class ConfirmationHook implements PreToolHook { @@ -41,8 +46,17 @@ export class ConfirmationHook implements PreToolHook { /** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */ private autoExecuteTools = new Set(); - /** 等待确认的 Promise 解析器 */ - private pendingConfirmations = new Map void; timer: NodeJS.Timeout; toolName: string; expiresAt: number }>(); + /** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */ + private pendingConfirmations = new Map void; + timer: NodeJS.Timeout; + toolName: string; + expiresAt: number; + /** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */ + args?: Record; + riskLevel?: string; + reason?: string; + }>(); /** 确认超时时间(可从配置读取,默认 120 秒) */ private confirmationTimeoutMs = 120_000; @@ -171,6 +185,78 @@ export class ConfirmationHook implements PreToolHook { } } + /** + * 批量处理用户确认响应(由 IPC tool:confirmationResponseBatch 调用) + * + * 设计要点: + * 1. 单次批量 resolve 即可处理 N 个 pending(避免逐个 IPC 往返) + * 2. remember/autoExecute 按 toolName 去重写入,避免 Map 重复赋值 + * 3. 批量拒绝时若 remember=true 也会写入"拒绝"记忆(与单条语义一致) + * + * @param toolCallIds 待处理的 toolCallId 列表 + * @param approved 批准/拒绝 + * @param remember 会话内记住决定(按 toolName 去重) + * @param autoExecute 永久自动执行(仅 approved=true 时生效,按 toolName 去重) + * @returns 实际处理成功的 toolCallId 数组(未找到的会被跳过) + */ + resolveConfirmationsBatch( + toolCallIds: string[], + approved: boolean, + remember: boolean, + autoExecute: boolean = false, + ): string[] { + const resolved: string[] = []; + // 同一 toolName 在批量中可能多次出现,仅写入一次决策记忆 + const processedToolNames = new Set(); + + for (const id of toolCallIds) { + const pending = this.pendingConfirmations.get(id); + if (!pending) continue; // 已超时或不存在,跳过 + + clearTimeout(pending.timer); + pending.resolve(approved); + this.pendingConfirmations.delete(id); + resolved.push(id); + + // 按工具名去重写入决策(同一工具的多次调用共享一次决策) + if (!processedToolNames.has(pending.toolName)) { + processedToolNames.add(pending.toolName); + if (autoExecute && approved) { + this.setAutoExecute(pending.toolName, true); + } + if (remember) { + this.rememberedDecisions.set(pending.toolName, approved); + } + } + } + return resolved; + } + + /** + * 获取当前所有等待中的确认请求(供前端批量审批 UI 拉取已积压的请求) + * + * 场景:并行工具触发的多个 IPC 事件可能在前端 state 中互相覆盖, + * 前端可在弹框打开时主动调用此接口,确保拿到完整的 pending 列表。 + * + * @returns pending 确认请求的快照(含 toolCallId/toolName/args/riskLevel/reason/expiresAt) + */ + getPendingConfirmations(): ConfirmationRequest[] { + const result: ConfirmationRequest[] = []; + for (const [id, pending] of this.pendingConfirmations) { + // 重建 ConfirmationRequest(前端需要 args 用于展示参数详情) + // pending 已在 waitForConfirmation 中缓存完整请求信息 + result.push({ + toolCallId: id, + toolName: pending.toolName, + args: pending.args ?? {}, + riskLevel: pending.riskLevel ?? 'medium', + reason: pending.reason ?? `Tool "${pending.toolName}" requires confirmation`, + expiresAt: pending.expiresAt, + }); + } + return result; + } + async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise { const def = this.toolDefs.get(toolCall.name); if (!def) { @@ -227,7 +313,13 @@ export class ConfirmationHook implements PreToolHook { /** * 等待用户确认(带超时) * 发送 expiresAt 到前端,供倒计时 UI 使用;超时时发送 toast 通知 + * + * v0.3.2 修复:超时 toast 防风暴 + * 并行工具全部超时时会触发 N 个 toast,用 lastToastAt 节流(3 秒内只发 1 条), + * 且消息改为汇总形式"工具确认超时,N 个工具未执行"。 */ + private lastTimeoutToastAt = 0; + private waitForConfirmation(request: ConfirmationRequest): Promise { return new Promise((resolve) => { const expiresAt = Date.now() + this.confirmationTimeoutMs; @@ -235,12 +327,21 @@ export class ConfirmationHook implements PreToolHook { // 设置超时 const timer = setTimeout(() => { this.pendingConfirmations.delete(request.toolCallId); - // 超时发送 toast 通知用户 + // 超时发送 toast 通知用户(3 秒节流,防止并行工具风暴) if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('toast:show', { - type: 'warning', - message: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`, - }); + const now = Date.now(); + if (now - this.lastTimeoutToastAt > 3000) { + this.lastTimeoutToastAt = now; + // 统计当前还有多少 pending(含本次刚超时的) + const pendingCount = this.pendingConfirmations.size + 1; + const message = pendingCount > 1 + ? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行` + : `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`; + this.mainWindow.webContents.send('toast:show', { + type: 'warning', + message, + }); + } } resolve(false); // 超时视为拒绝 }, this.confirmationTimeoutMs); @@ -250,6 +351,10 @@ export class ConfirmationHook implements PreToolHook { timer, toolName: request.toolName, expiresAt, + // v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回 + args: request.args, + riskLevel: request.riskLevel, + reason: request.reason, }); // 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时) diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index eb31cce..1bdf70c 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -39,7 +39,7 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ { toolName: 'memory_search', requiredLevel: PermissionLevel.READ }, { toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 }, { toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE }, - { toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 }, + { toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 10 }, { toolName: 'web_fetch', requiredLevel: PermissionLevel.READ }, // web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具) // 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 6d56703..65859ce 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1175,6 +1175,54 @@ export function registerAllIPCHandlers( log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`); }); + // ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批)===== + // 设计原因:多个工具并行触发时,逐条 IPC 响应会产生 N 次往返, + // 且前端 state 覆盖会导致部分请求丢失。批量响应一次解决。 + ipcMain.on('tool:confirmationResponseBatch', (_event, data: unknown) => { + if (!data || typeof data !== 'object') { + log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid data'); + return; + } + const req = data as { + toolCallIds?: unknown; + approved?: unknown; + remember?: unknown; + autoExecute?: unknown; + }; + // 严格校验 toolCallIds 数组 + if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) { + log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array'); + return; + } + // 每个元素必须是字符串 + for (const id of req.toolCallIds) { + if (typeof id !== 'string' || !id) { + log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid toolCallId in array'); + return; + } + } + if (typeof req.approved !== 'boolean') { + log.warn('[IPC] tool:confirmationResponseBatch rejected: invalid approved'); + return; + } + const remember = typeof req.remember === 'boolean' ? req.remember : false; + const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false; + const resolved = confirmationHook.resolveConfirmationsBatch( + req.toolCallIds as string[], + req.approved, + remember, + autoExecute, + ); + log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`); + }); + + // ===== v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用)===== + // 用途:解决并行工具触发的多个 IPC 事件在前端 state 中互相覆盖导致丢失的问题。 + // 前端在弹框打开时主动拉取,确保拿到完整的 pending 列表。 + ipcMain.handle('tool:getPendingConfirmations', async () => { + return { success: true, data: confirmationHook.getPendingConfirmations() }; + }); + // ===== v0.2.0: 持久化自动执行设置 ===== ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => { // M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染 diff --git a/electron/preload.ts b/electron/preload.ts index 4dce9a9..d26db5b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -95,6 +95,22 @@ const metonaAPI = { }, sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => ipcRenderer.send('tool:confirmationResponse', response), + // v0.3.2: 批量确认响应(并行工具一次性审批) + sendConfirmationResponseBatch: (response: { toolCallIds: string[]; approved: boolean; remember: boolean; autoExecute?: boolean }) => + ipcRenderer.send('tool:confirmationResponseBatch', response), + // v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失) + getPendingConfirmations: () => + ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{ + success: boolean; + data: Array<{ + toolCallId: string; + toolName: string; + args: Record; + riskLevel: string; + reason: string; + expiresAt?: number; + }>; + }>, // v0.2.0: 持久化自动执行设置 setAutoExecute: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled), diff --git a/package-lock.json b/package-lock.json index d37a43f..91af3b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.8", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.8", + "version": "0.3.9", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -2333,9 +2333,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2350,9 +2347,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2367,9 +2361,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2384,9 +2375,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2401,9 +2389,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2418,9 +2403,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2435,9 +2417,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2452,9 +2431,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2469,9 +2445,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2486,9 +2459,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2503,9 +2473,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2520,9 +2487,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2537,9 +2501,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2789,9 +2750,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2809,9 +2767,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2829,9 +2784,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2849,9 +2801,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7420,9 +7369,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7444,9 +7390,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7468,9 +7411,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7492,9 +7432,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index e7ddf02..925da77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.8", + "version": "0.3.9", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/App.tsx b/src/App.tsx index 8e4608a..70d9e89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { CommandPalette } from '@renderer/components/CommandPalette'; import { ToastContainer } from '@renderer/components/ToastContainer'; import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog'; import { ContextMenuDialogHost } from '@renderer/components/ContextMenu'; +import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary'; import { useTheme } from '@renderer/hooks/useTheme'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useAgentStream } from '@renderer/hooks/useAgentStream'; @@ -81,7 +82,9 @@ export default function App(): React.JSX.Element { - + + + diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 09dcb67..edec41d 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -1,21 +1,30 @@ /** - * ConfirmationDialog — 工具执行确认对话框(v0.2.0 新增) + * ConfirmationDialog — 工具执行确认对话框(v0.3.2 批量审批版本) * - * 当 Agent 调用 requiresPermission=true 或 riskLevel>=HIGH 的工具时, - * 通过此对话框请求用户确认。 + * 当 Agent 并行调用多个 requiresPermission=true 或 riskLevel>=HIGH 的工具时, + * 通过此对话框批量请求用户确认。 + * + * v0.3.2 关键改进: + * 1. state 从单值改为数组,支持同时显示多个 pending 请求 + * 2. 弹框打开时主动调用 getPendingConfirmations 拉取已积压请求, + * 解决并行 IPC 事件在前端 state 中互相覆盖丢失的问题 + * 3. 同工具多次调用按 toolName 分组展示(如 read_file (×5)) + * 4. 支持批量勾选 / 批量批准 / 批量拒绝 + * 5. remember/autoExecute 按 toolName 去重应用到所有选中项 * * 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情, - * 用户确认/拒绝后通过 `tool:confirmationResponse` IPC 通道发送结果。 + * 用户确认/拒绝后通过 `tool:confirmationResponseBatch` IPC 通道批量发送结果。 */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox, Accordion, AccordionSummary, AccordionDetails, - LinearProgress, + LinearProgress, List, ListItem, ListItemIcon, ListItemText, + IconButton, Tooltip, Divider, } from '@mui/material'; -import { ShieldAlert, ChevronDown, Timer } from 'lucide-react'; +import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react'; interface ConfirmationRequest { toolCallId: string; @@ -35,39 +44,105 @@ const RISK_COLORS: Record(null); + const [requests, setRequests] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); const [remember, setRemember] = useState(false); const [autoExecute, setAutoExecute] = useState(false); const [remainingMs, setRemainingMs] = useState(0); // 记录首次接收时的剩余时间,作为进度条总量(固定不变) const [initialMs, setInitialMs] = useState(0); + // ===== 弹框打开时主动拉取已积压的 pending 请求 ===== + // 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达, + // 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。 + const refreshPending = useCallback(async (mergeNew?: ConfirmationRequest) => { + try { + const result = await window.metona?.tool?.getPendingConfirmations(); + const pendingList: ConfirmationRequest[] = result?.success ? result.data : []; + // 合并新到的 IPC 请求(若 pending 快照已包含则去重) + const merged = [...pendingList]; + if (mergeNew) { + const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId); + if (!exists) merged.push(mergeNew); + } + // 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加) + const dedupedMap = new Map(); + for (const r of merged) dedupedMap.set(r.toolCallId, r); + const deduped = Array.from(dedupedMap.values()); + + setRequests(deduped); + // 默认全选 + setSelectedIds(new Set(deduped.map((r) => r.toolCallId))); + } catch { + // 拉取失败时回退到只显示新到的请求 + if (mergeNew) { + setRequests([mergeNew]); + setSelectedIds(new Set([mergeNew.toolCallId])); + } + } + }, []); + useEffect(() => { // 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API) const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => { const req = data as ConfirmationRequest; - setRequest(req); + // 每次新请求到达时主动拉取完整 pending 列表,防止 state 覆盖丢失 + refreshPending(req); setRemember(false); setAutoExecute(false); - // 初始化倒计时 + // 初始化倒计时(使用本次请求的过期时间作为初始值) if (req.expiresAt) { const remain = Math.max(0, req.expiresAt - Date.now()); setRemainingMs(remain); setInitialMs(remain); - } else { - setRemainingMs(0); - setInitialMs(0); } }); return cleanup; + }, [refreshPending]); + + // ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空前端 state ===== + // 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步, + // 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。 + // 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。 + useEffect(() => { + if (!window.metona?.agent?.onStateChange) return; + const unsubscribe = window.metona.agent.onStateChange((state: unknown) => { + const data = state as { state?: string; current?: string }; + const stateValue = data.state ?? data.current ?? ''; + // INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环) + if (stateValue === 'INIT' || stateValue === 'TERMINATED') { + setRequests([]); + setSelectedIds(new Set()); + } + }); + return unsubscribe; }, []); - // 倒计时:每 200ms 更新剩余时间 + // 倒计时:每 200ms 更新剩余时间(取所有请求中最早过期的) useEffect(() => { - if (!request?.expiresAt) return; + if (requests.length === 0) return; const interval = setInterval(() => { - const remaining = request.expiresAt! - Date.now(); + // 取所有请求中最早过期的剩余时间 + const earliestExpires = requests + .map((r) => r.expiresAt) + .filter((v): v is number => typeof v === 'number') + .sort((a, b) => a - b)[0]; + if (!earliestExpires) { + setRemainingMs(0); + return; + } + const remaining = earliestExpires - Date.now(); if (remaining <= 0) { setRemainingMs(0); clearInterval(interval); @@ -76,28 +151,121 @@ export function ConfirmationDialog(): React.JSX.Element | null { } }, 200); return () => clearInterval(interval); - }, [request]); + }, [requests]); - const handleRespond = useCallback((approved: boolean) => { - if (!request) return; - // 通过 preload 暴露的 metona.tool API 发送响应 + // 按工具名分组(同工具多次调用折叠为一组) + const grouped: GroupedRequests[] = useMemo(() => { + const map = new Map(); + for (const r of requests) { + const existing = map.get(r.toolName); + if (existing) { + existing.requests.push(r); + } else { + map.set(r.toolName, { + toolName: r.toolName, + riskLevel: r.riskLevel, + requests: [r], + }); + } + } + return Array.from(map.values()); + }, [requests]); + + const handleToggleSelect = useCallback((toolCallId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(toolCallId)) { + next.delete(toolCallId); + } else { + next.add(toolCallId); + } + return next; + }); + }, []); + + const handleToggleGroupSelect = useCallback((group: GroupedRequests) => { + const groupIds = group.requests.map((r) => r.toolCallId); + setSelectedIds((prev) => { + const next = new Set(prev); + const allSelected = groupIds.every((id) => next.has(id)); + if (allSelected) { + groupIds.forEach((id) => next.delete(id)); + } else { + groupIds.forEach((id) => next.add(id)); + } + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + setSelectedIds(new Set(requests.map((r) => r.toolCallId))); + }, [requests]); + + const handleDeselectAll = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + const handleRespond = useCallback((approved: boolean, onlySelected: boolean = true) => { + // 决定要处理的 toolCallId 列表 + // onlySelected=true: 仅处理勾选项(用于"批准选中") + // onlySelected=false: 处理全部请求(用于"拒绝全部" / "批准全部") + const targetIds = onlySelected + ? Array.from(selectedIds) + : requests.map((r) => r.toolCallId); + + if (targetIds.length === 0) return; + + // 通过批量 IPC 通道发送响应 // autoExecute 仅在批准时生效(拒绝时无需持久化) - window.metona?.tool?.sendConfirmationResponse({ - toolCallId: request.toolCallId, + window.metona?.tool?.sendConfirmationResponseBatch({ + toolCallIds: targetIds, approved, remember, autoExecute: approved && autoExecute, }); - setRequest(null); - }, [request, remember, autoExecute]); - if (!request) return null; + if (onlySelected) { + // "批准选中":只移除已处理的,保留未选中的 pending + // 防止未选中的 pending 被清空后丢失(用户看不到,会超时失败) + const targetSet = new Set(targetIds); + const remaining = requests.filter((r) => !targetSet.has(r.toolCallId)); + setRequests(remaining); + // 更新选中项:清空已处理的,保留未选中的(但实际上未选中的本来就不在 selectedIds 中) + setSelectedIds(new Set()); + // 主动刷新后端 pending 列表,拉取可能新到达的请求 + // 用 setTimeout 避免与 setRequests 同批次,确保后端已处理完批量响应 + if (remaining.length === 0) { + setTimeout(() => refreshPending(), 50); + } + } else { + // "拒绝全部 / 批准全部":清空所有 + setRequests([]); + setSelectedIds(new Set()); + } + }, [requests, selectedIds, remember, autoExecute, refreshPending]); - const riskColor = RISK_COLORS[request.riskLevel] ?? 'default'; + if (requests.length === 0) return null; + + // 取所有请求中最早过期的,用于倒计时显示 + const earliestExpires = requests + .map((r) => r.expiresAt) + .filter((v): v is number => typeof v === 'number') + .sort((a, b) => a - b)[0]; + + const selectedCount = selectedIds.size; + const totalCount = requests.length; + const allSelected = selectedCount === totalCount; + + // 综合风险等级:取所有请求中最高的 + const highestRisk = requests.reduce((highest, r) => { + const order = ['safe', 'low', 'medium', 'high', 'critical']; + return order.indexOf(r.riskLevel) > order.indexOf(highest) ? r.riskLevel : highest; + }, 'safe'); + + const riskColor = RISK_COLORS[highestRisk] ?? 'default'; const remainingSec = Math.ceil(remainingMs / 1000); const isUrgent = remainingSec <= 10 && remainingSec > 0; - const isExpired = remainingMs <= 0 && request.expiresAt != null; - // 总时长使用首次接收时的剩余时间(固定值),保证进度条单调递减 + const isExpired = remainingMs <= 0 && earliestExpires != null; const totalMs = initialMs || remainingMs; const progressPercent = totalMs > 0 ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) @@ -106,7 +274,6 @@ export function ConfirmationDialog(): React.JSX.Element | null { // 格式化参数显示 const formatArg = (key: string, value: unknown): string => { if (typeof value === 'string') { - // 长字符串截断 return value.length > 200 ? value.slice(0, 200) + '...' : value; } if (value === null) return 'null'; @@ -118,11 +285,25 @@ export function ConfirmationDialog(): React.JSX.Element | null { } }; + // 综合风险文案 + const batchReason = totalCount > 1 + ? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}` + : requests[0]?.reason ?? 'Agent 正在请求执行工具'; + return ( handleRespond(false)} - maxWidth="sm" + open={requests.length > 0} + onClose={(_, reason) => { + // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致) + // 防止超时后用户误触关闭,导致后端 pending 状态不一致 + if (isExpired) return; + // 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC) + // reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick' + if (reason === 'backdropClick' || reason === 'escapeKeyDown') { + handleRespond(false, false); + } + }} + maxWidth="md" fullWidth slotProps={{ paper: { @@ -132,15 +313,23 @@ export function ConfirmationDialog(): React.JSX.Element | null { > - 工具执行确认 + + 工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`} + + + + refreshPending()}> + + + - {request.reason} + {batchReason} - {request.expiresAt && !isExpired && ( + {earliestExpires && !isExpired && ( - 剩余 {remainingSec}s + {totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s )} - - - + {/* 选择控制条 */} + + + + + 已选 {selectedCount} / {totalCount} + - - Agent 正在请求执行以下工具,请确认参数: - + - - }> - - 参数详情 ({Object.keys(request.args).length} 个参数) - - - - - {Object.entries(request.args).map(([key, value]) => ( - - - {key}: - - - {formatArg(key, value)} - - - ))} - - - + {/* 分组列表 */} + + {grouped.map((group) => { + const groupIds = group.requests.map((r) => r.toolCallId); + const groupAllSelected = groupIds.every((id) => selectedIds.has(id)); + const groupSomeSelected = groupIds.some((id) => selectedIds.has(id)); + const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default'; + const isMulti = group.requests.length > 1; + + return ( + + }> + + { + e.stopPropagation(); + handleToggleGroupSelect(group); + }} + onClick={(e) => e.stopPropagation()} + /> + + {group.toolName} + + {isMulti && ( + + )} + + + + + {group.requests.map((req, idx) => { + const isSelected = selectedIds.has(req.toolCallId); + return ( + + #{idx + 1} + + ) : undefined + } + > + + handleToggleSelect(req.toolCallId)} + /> + + + {Object.entries(req.args).map(([key, value]) => ( + + + {key}: + + + {formatArg(key, value)} + + + ))} + {Object.keys(req.args).length === 0 && ( + + (无参数) + + )} + + } + /> + + ); + })} + + + ); + })} + + + - 在本次会话中记住此决定(同一工具不再询问) + 在本次会话中记住此决定(同工具不再询问) } - sx={{ mt: 1 }} /> - 永久自动执行此工具(跨会话不再询问,可在设置中关闭) + 永久自动执行选中工具(跨会话不再询问,可在设置中关闭) + {selectedCount > 0 && autoExecute && ( + + · 将应用到 {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size} 个工具 + + )} } - sx={{ mt: 0.5 }} /> + diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 0096334..efc7d88 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -335,6 +335,8 @@ interface MetonaConfirmationRequest { args: Record; riskLevel: string; reason: string; + /** v0.3.2: 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */ + expiresAt?: number; } interface MetonaToolAPI { @@ -345,6 +347,21 @@ interface MetonaToolAPI { remember: boolean; autoExecute?: boolean; }) => void; + /** v0.3.2: 批量确认响应(并行工具一次性审批,支持同工具多次调用) */ + sendConfirmationResponseBatch: (response: { + toolCallIds: string[]; + approved: boolean; + remember: boolean; + autoExecute?: boolean; + }) => void; + /** + * v0.3.2: 拉取当前所有 pending 确认 + * 前端弹框打开时主动调用,防止并行 IPC 事件在前端 state 中互相覆盖导致丢失 + */ + getPendingConfirmations: () => Promise<{ + success: boolean; + data: MetonaConfirmationRequest[]; + }>; /** 设置/取消工具的持久化自动执行(跨会话不再询问) */ setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>; /** 获取已设置为自动执行的工具列表 */ diff --git a/standard/开发规范.md b/standard/开发规范.md index 75896f0..6d17b4a 100644 --- a/standard/开发规范.md +++ b/standard/开发规范.md @@ -86,6 +86,24 @@ | 头像 | Avatar | 自写头像圆形容器 | | 列表 | List、ListItemButton | 自写列表项 | +#### 3.2 Toast 通知铁律 + +**所有 Toast 通知必须使用项目封装的 `MeToast` 组件**,禁止自写 Toast 组件、禁止直接使用第三方 Toast 库(如 notistack、react-hot-toast、react-toastify 等)。 + +| 场景 | 规范 | +|------|------| +| 前端主动显示 Toast | 使用 `MeToast` 组件(通过 `showToast()` 或对应 hook 调用) | +| 后端通过 IPC 触发 Toast | 主进程发送 `toast:show` IPC 事件,参数 `{ type: 'success' \| 'info' \| 'warning' \| 'error', message: string }`,前端 MeToast 监听并统一渲染 | +| 类型枚举 | `success` / `info` / `warning` / `error` 四种,不得自定义 | +| 并行事件防风暴 | 同一来源的并行事件(如多工具超时)需在后端节流,不得依赖前端去重 | + +**原因**: +1. 统一视觉风格与动画效果 +2. 集中管理 Toast 队列、堆叠顺序、自动消失时间 +3. 避免 MUI Snackbar 与第三方库混用导致重复弹出或层级冲突 + +**例外**:无。任何 Toast 需求都必须走 MeToast 通道。 + **例外**:仅当 MUI 确认不存在对应组件时,才可使用原生 HTML 元素 + Tailwind CSS 实现,且必须注释说明原因。 #### 4. 允许自写的场景