feat: 升级至 v0.3.9 — 工具批量审批 + run_command 频率限制优化 + Toast 规范

1. 工具批量审批:解决并行工具审批弹框覆盖问题,新增批量 IPC 通道和列表 UI,支持同工具多次调用分组展示;2. 审批弹框健壮性:超时 toast 防风暴、agent 状态同步清空、ErrorBoundary 防白屏;3. run_command maxFrequency 从 3 调整为 10;4. 开发规范新增 Toast 通知铁律(必须使用 MeToast)
This commit is contained in:
2026-07-21 11:43:41 +08:00
parent 76bd72b702
commit 64af91bde9
11 changed files with 608 additions and 177 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
> 生产级通用 AI Agent 智能体桌面应用 > 生产级通用 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) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![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/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/)
+112 -7
View File
@@ -22,6 +22,11 @@ export interface ConfirmationRequest {
args: Record<string, unknown>; args: Record<string, unknown>;
riskLevel: string; riskLevel: string;
reason: string; reason: string;
/**
* 过期时间戳(ms),由 waitForConfirmation 注入,用于前端倒计时 UI。
* 注意:beforeExecute 构造 request 时不带此字段,仅在 waitForConfirmation 中追加。
*/
expiresAt?: number;
} }
export class ConfirmationHook implements PreToolHook { export class ConfirmationHook implements PreToolHook {
@@ -41,8 +46,17 @@ export class ConfirmationHook implements PreToolHook {
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */ /** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
private autoExecuteTools = new Set<string>(); private autoExecuteTools = new Set<string>();
/** 等待确认的 Promise 解析器 */ /** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => void; timer: NodeJS.Timeout; toolName: string; expiresAt: number }>(); private pendingConfirmations = new Map<string, {
resolve: (v: boolean) => void;
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>;
riskLevel?: string;
reason?: string;
}>();
/** 确认超时时间(可从配置读取,默认 120 秒) */ /** 确认超时时间(可从配置读取,默认 120 秒) */
private confirmationTimeoutMs = 120_000; 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<string>();
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<HookResult> { async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
const def = this.toolDefs.get(toolCall.name); const def = this.toolDefs.get(toolCall.name);
if (!def) { if (!def) {
@@ -227,7 +313,13 @@ export class ConfirmationHook implements PreToolHook {
/** /**
* 等待用户确认(带超时) * 等待用户确认(带超时)
* 发送 expiresAt 到前端,供倒计时 UI 使用;超时时发送 toast 通知 * 发送 expiresAt 到前端,供倒计时 UI 使用;超时时发送 toast 通知
*
* v0.3.2 修复:超时 toast 防风暴
* 并行工具全部超时时会触发 N 个 toast,用 lastToastAt 节流(3 秒内只发 1 条),
* 且消息改为汇总形式"工具确认超时,N 个工具未执行"。
*/ */
private lastTimeoutToastAt = 0;
private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> { private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const expiresAt = Date.now() + this.confirmationTimeoutMs; const expiresAt = Date.now() + this.confirmationTimeoutMs;
@@ -235,12 +327,21 @@ export class ConfirmationHook implements PreToolHook {
// 设置超时 // 设置超时
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.pendingConfirmations.delete(request.toolCallId); this.pendingConfirmations.delete(request.toolCallId);
// 超时发送 toast 通知用户 // 超时发送 toast 通知用户3 秒节流,防止并行工具风暴)
if (this.mainWindow && !this.mainWindow.isDestroyed()) { if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('toast:show', { const now = Date.now();
type: 'warning', if (now - this.lastTimeoutToastAt > 3000) {
message: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`, 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); // 超时视为拒绝 resolve(false); // 超时视为拒绝
}, this.confirmationTimeoutMs); }, this.confirmationTimeoutMs);
@@ -250,6 +351,10 @@ export class ConfirmationHook implements PreToolHook {
timer, timer,
toolName: request.toolName, toolName: request.toolName,
expiresAt, expiresAt,
// v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回
args: request.args,
riskLevel: request.riskLevel,
reason: request.reason,
}); });
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时) // 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
+1 -1
View File
@@ -39,7 +39,7 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ }, { 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: '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: '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 }, { toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具) // web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION // 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
+48
View File
@@ -1175,6 +1175,54 @@ export function registerAllIPCHandlers(
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`); 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: 持久化自动执行设置 ===== // ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => { ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染 // M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
+16
View File
@@ -95,6 +95,22 @@ const metonaAPI = {
}, },
sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
ipcRenderer.send('tool:confirmationResponse', response), 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<string, unknown>;
riskLevel: string;
reason: string;
expiresAt?: number;
}>;
}>,
// v0.2.0: 持久化自动执行设置 // v0.2.0: 持久化自动执行设置
setAutoExecute: (toolName: string, enabled: boolean) => setAutoExecute: (toolName: string, enabled: boolean) =>
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled), ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
+2 -65
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.8", "version": "0.3.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.8", "version": "0.3.9",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -2333,9 +2333,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2350,9 +2347,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2367,9 +2361,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2384,9 +2375,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2401,9 +2389,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2418,9 +2403,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2435,9 +2417,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2452,9 +2431,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2469,9 +2445,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2486,9 +2459,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2503,9 +2473,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2520,9 +2487,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2537,9 +2501,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2789,9 +2750,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2809,9 +2767,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2829,9 +2784,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2849,9 +2801,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7420,9 +7369,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7444,9 +7390,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7468,9 +7411,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -7492,9 +7432,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.8", "version": "0.3.9",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
+4 -1
View File
@@ -20,6 +20,7 @@ import { CommandPalette } from '@renderer/components/CommandPalette';
import { ToastContainer } from '@renderer/components/ToastContainer'; import { ToastContainer } from '@renderer/components/ToastContainer';
import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog'; import { ConfirmationDialog } from '@renderer/components/ConfirmationDialog';
import { ContextMenuDialogHost } from '@renderer/components/ContextMenu'; import { ContextMenuDialogHost } from '@renderer/components/ContextMenu';
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useAgentStream } from '@renderer/hooks/useAgentStream'; import { useAgentStream } from '@renderer/hooks/useAgentStream';
@@ -81,7 +82,9 @@ export default function App(): React.JSX.Element {
<OnboardingWizard /> <OnboardingWizard />
<CommandPalette /> <CommandPalette />
<ToastContainer /> <ToastContainer />
<ConfirmationDialog /> <ErrorBoundary fallbackTitle="工具确认弹框渲染失败">
<ConfirmationDialog />
</ErrorBoundary>
<ContextMenuDialogHost /> <ContextMenuDialogHost />
</div> </div>
</ThemeProvider> </ThemeProvider>
+388 -101
View File
@@ -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`,显示工具详情, * 监听 IPC 事件 `tool:confirmationRequest`,显示工具详情,
* 用户确认/拒绝后通过 `tool:confirmationResponse` IPC 通道发送结果。 * 用户确认/拒绝后通过 `tool:confirmationResponseBatch` IPC 通道批量发送结果。
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Dialog, DialogTitle, DialogContent, DialogActions, Dialog, DialogTitle, DialogContent, DialogActions,
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox, Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
Accordion, AccordionSummary, AccordionDetails, Accordion, AccordionSummary, AccordionDetails,
LinearProgress, LinearProgress, List, ListItem, ListItemIcon, ListItemText,
IconButton, Tooltip, Divider,
} from '@mui/material'; } from '@mui/material';
import { ShieldAlert, ChevronDown, Timer } from 'lucide-react'; import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
interface ConfirmationRequest { interface ConfirmationRequest {
toolCallId: string; toolCallId: string;
@@ -35,39 +44,105 @@ const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' |
critical: 'error', critical: 'error',
}; };
/**
* 按工具名分组后的请求组
*/
interface GroupedRequests {
toolName: string;
riskLevel: string;
/** 共享同一 riskLevel 的多个请求(riskLevel 来自 toolDef,同工具必然相同) */
requests: ConfirmationRequest[];
}
export function ConfirmationDialog(): React.JSX.Element | null { export function ConfirmationDialog(): React.JSX.Element | null {
const [request, setRequest] = useState<ConfirmationRequest | null>(null); const [requests, setRequests] = useState<ConfirmationRequest[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [autoExecute, setAutoExecute] = useState(false); const [autoExecute, setAutoExecute] = useState(false);
const [remainingMs, setRemainingMs] = useState<number>(0); const [remainingMs, setRemainingMs] = useState<number>(0);
// 记录首次接收时的剩余时间,作为进度条总量(固定不变) // 记录首次接收时的剩余时间,作为进度条总量(固定不变)
const [initialMs, setInitialMs] = useState<number>(0); const [initialMs, setInitialMs] = useState<number>(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<string, ConfirmationRequest>();
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(() => { useEffect(() => {
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API // 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API
const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => { const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => {
const req = data as ConfirmationRequest; const req = data as ConfirmationRequest;
setRequest(req); // 每次新请求到达时主动拉取完整 pending 列表,防止 state 覆盖丢失
refreshPending(req);
setRemember(false); setRemember(false);
setAutoExecute(false); setAutoExecute(false);
// 初始化倒计时 // 初始化倒计时(使用本次请求的过期时间作为初始值)
if (req.expiresAt) { if (req.expiresAt) {
const remain = Math.max(0, req.expiresAt - Date.now()); const remain = Math.max(0, req.expiresAt - Date.now());
setRemainingMs(remain); setRemainingMs(remain);
setInitialMs(remain); setInitialMs(remain);
} else {
setRemainingMs(0);
setInitialMs(0);
} }
}); });
return cleanup; return cleanup;
}, [refreshPending]);
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATEDrun 结束/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(() => { useEffect(() => {
if (!request?.expiresAt) return; if (requests.length === 0) return;
const interval = setInterval(() => { 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) { if (remaining <= 0) {
setRemainingMs(0); setRemainingMs(0);
clearInterval(interval); clearInterval(interval);
@@ -76,28 +151,121 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
}, 200); }, 200);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [request]); }, [requests]);
const handleRespond = useCallback((approved: boolean) => { // 按工具名分组(同工具多次调用折叠为一组)
if (!request) return; const grouped: GroupedRequests[] = useMemo(() => {
// 通过 preload 暴露的 metona.tool API 发送响应 const map = new Map<string, GroupedRequests>();
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 仅在批准时生效(拒绝时无需持久化) // autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponse({ window.metona?.tool?.sendConfirmationResponseBatch({
toolCallId: request.toolCallId, toolCallIds: targetIds,
approved, approved,
remember, remember,
autoExecute: approved && autoExecute, 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<string>((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 remainingSec = Math.ceil(remainingMs / 1000);
const isUrgent = remainingSec <= 10 && remainingSec > 0; const isUrgent = remainingSec <= 10 && remainingSec > 0;
const isExpired = remainingMs <= 0 && request.expiresAt != null; const isExpired = remainingMs <= 0 && earliestExpires != null;
// 总时长使用首次接收时的剩余时间(固定值),保证进度条单调递减
const totalMs = initialMs || remainingMs; const totalMs = initialMs || remainingMs;
const progressPercent = totalMs > 0 const progressPercent = totalMs > 0
? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) ? 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 => { const formatArg = (key: string, value: unknown): string => {
if (typeof value === 'string') { if (typeof value === 'string') {
// 长字符串截断
return value.length > 200 ? value.slice(0, 200) + '...' : value; return value.length > 200 ? value.slice(0, 200) + '...' : value;
} }
if (value === null) return 'null'; 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 ( return (
<Dialog <Dialog
open={!!request} open={requests.length > 0}
onClose={() => handleRespond(false)} onClose={(_, reason) => {
maxWidth="sm" // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return;
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
handleRespond(false, false);
}
}}
maxWidth="md"
fullWidth fullWidth
slotProps={{ slotProps={{
paper: { paper: {
@@ -132,15 +313,23 @@ export function ConfirmationDialog(): React.JSX.Element | null {
> >
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" /> <ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
<Typography variant="h6" component="span"></Typography> <Typography variant="h6" component="span">
{totalCount > 1 && `${totalCount} 个并行请求)`}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip title="刷新 pending 列表">
<IconButton size="small" onClick={() => refreshPending()}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}> <Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{request.reason} {batchReason}
</Alert> </Alert>
{request.expiresAt && !isExpired && ( {earliestExpires && !isExpired && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Timer <Timer
size={16} size={16}
@@ -152,7 +341,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
sx={{ sx={{
color: isUrgent ? 'error.main' : 'text.secondary', color: isUrgent ? 'error.main' : 'text.secondary',
fontWeight: isUrgent ? 700 : 500, fontWeight: isUrgent ? 700 : 500,
minWidth: 48, minWidth: 80,
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none', animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
'@keyframes metona-pulse': { '@keyframes metona-pulse': {
'0%, 100%': { opacity: 1 }, '0%, 100%': { opacity: 1 },
@@ -160,7 +349,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
}, },
}} }}
> >
{remainingSec}s {totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s
</Typography> </Typography>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
@@ -171,64 +360,145 @@ export function ConfirmationDialog(): React.JSX.Element | null {
</Box> </Box>
)} )}
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}> {/* 选择控制条 */}
<Chip <Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
label={`工具: ${request.toolName}`} <Button size="small" onClick={handleSelectAll} disabled={allSelected}>
color="primary"
size="small" </Button>
/> <Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
<Chip
label={`风险: ${request.riskLevel}`} </Button>
color={riskColor} <Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
size="small" {selectedCount} / {totalCount}
/> </Typography>
</Box> </Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> <Divider sx={{ mb: 1 }} />
Agent
</Typography>
<Accordion defaultExpanded sx={{ bgcolor: 'background.default' }}> {/* 分组列表 */}
<AccordionSummary expandIcon={<ChevronDown size={16} />}> <List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}> {grouped.map((group) => {
({Object.keys(request.args).length} ) const groupIds = group.requests.map((r) => r.toolCallId);
</Typography> const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
</AccordionSummary> const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
<AccordionDetails> const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
<Box const isMulti = group.requests.length > 1;
component="pre"
sx={{ return (
fontFamily: 'monospace', <Accordion
fontSize: 11, key={group.toolName}
color: 'text.primary', defaultExpanded
whiteSpace: 'pre-wrap', sx={{ bgcolor: 'background.default', mb: 0.5 }}
wordBreak: 'break-word', >
margin: 0, <AccordionSummary expandIcon={<ChevronDown size={16} />}>
maxHeight: 300, <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
overflow: 'auto', <Checkbox
}} size="small"
> checked={groupAllSelected}
{Object.entries(request.args).map(([key, value]) => ( indeterminate={!groupAllSelected && groupSomeSelected}
<Box key={key} component="div" sx={{ mb: 1 }}> onChange={(e) => {
<Typography e.stopPropagation();
component="span" handleToggleGroupSelect(group);
variant="caption" }}
sx={{ color: 'info.main', fontWeight: 700 }} onClick={(e) => e.stopPropagation()}
> />
{key}: <Typography variant="body2" sx={{ fontWeight: 600 }}>
</Typography> {group.toolName}
<Typography </Typography>
component="span" {isMulti && (
variant="caption" <Chip
sx={{ color: 'text.primary', ml: 1 }} label={`×${group.requests.length}`}
> size="small"
{formatArg(key, value)} color="primary"
</Typography> variant="outlined"
</Box> sx={{ height: 20, fontSize: 11 }}
))} />
</Box> )}
</AccordionDetails> <Chip
</Accordion> label={group.riskLevel}
color={groupRiskColor}
size="small"
sx={{ height: 20, fontSize: 11 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
{group.requests.map((req, idx) => {
const isSelected = selectedIds.has(req.toolCallId);
return (
<ListItem
key={req.toolCallId}
sx={{
py: 0.5,
bgcolor: isSelected ? 'action.selected' : 'transparent',
borderRadius: 1,
}}
secondaryAction={
isMulti ? (
<Typography variant="caption" color="text.secondary">
#{idx + 1}
</Typography>
) : undefined
}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox
size="small"
checked={isSelected}
onChange={() => handleToggleSelect(req.toolCallId)}
/>
</ListItemIcon>
<ListItemText
primary={
<Box
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: 11,
color: 'text.primary',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
maxHeight: 200,
overflow: 'auto',
}}
>
{Object.entries(req.args).map(([key, value]) => (
<Box key={key} component="div" sx={{ mb: 0.5 }}>
<Typography
component="span"
variant="caption"
sx={{ color: 'info.main', fontWeight: 700 }}
>
{key}:
</Typography>
<Typography
component="span"
variant="caption"
sx={{ color: 'text.primary', ml: 1 }}
>
{formatArg(key, value)}
</Typography>
</Box>
))}
{Object.keys(req.args).length === 0 && (
<Typography variant="caption" color="text.secondary">
</Typography>
)}
</Box>
}
/>
</ListItem>
);
})}
</AccordionDetails>
</Accordion>
);
})}
</List>
<Divider sx={{ my: 1 }} />
<FormControlLabel <FormControlLabel
control={ control={
@@ -240,10 +510,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
label={ label={
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
</Typography> </Typography>
} }
sx={{ mt: 1 }}
/> />
<FormControlLabel <FormControlLabel
@@ -260,32 +529,50 @@ export function ConfirmationDialog(): React.JSX.Element | null {
} }
label={ label={
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{selectedCount > 0 && autoExecute && (
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
· {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size}
</Box>
)}
</Typography> </Typography>
} }
sx={{ mt: 0.5 }}
/> />
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button <Button
onClick={() => handleRespond(false)} onClick={() => handleRespond(false, false)}
color="error" color="error"
variant="outlined" variant="outlined"
size="small" size="small"
disabled={isExpired} disabled={isExpired}
> >
({totalCount})
</Button> </Button>
<Button <Button
onClick={() => handleRespond(true)} onClick={() => handleRespond(true, false)}
color="success"
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
>
</Button>
<Button
onClick={() => handleRespond(true, true)}
color="success" color="success"
variant="contained" variant="contained"
size="small" size="small"
autoFocus autoFocus
disabled={isExpired} disabled={isExpired || selectedCount === 0}
> >
{isExpired ? '已超时' : '确认执行'} {isExpired
? '已超时'
: selectedCount === totalCount
? `确认执行 (${selectedCount})`
: `批准选中 (${selectedCount})`}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
+17
View File
@@ -335,6 +335,8 @@ interface MetonaConfirmationRequest {
args: Record<string, unknown>; args: Record<string, unknown>;
riskLevel: string; riskLevel: string;
reason: string; reason: string;
/** v0.3.2: 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
expiresAt?: number;
} }
interface MetonaToolAPI { interface MetonaToolAPI {
@@ -345,6 +347,21 @@ interface MetonaToolAPI {
remember: boolean; remember: boolean;
autoExecute?: boolean; autoExecute?: boolean;
}) => void; }) => 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 }>; setAutoExecute: (toolName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
/** 获取已设置为自动执行的工具列表 */ /** 获取已设置为自动执行的工具列表 */
+18
View File
@@ -86,6 +86,24 @@
| 头像 | Avatar | 自写头像圆形容器 | | 头像 | Avatar | 自写头像圆形容器 |
| 列表 | List、ListItemButton | 自写列表项 | | 列表 | 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 实现,且必须注释说明原因。 **例外**:仅当 MUI 确认不存在对应组件时,才可使用原生 HTML 元素 + Tailwind CSS 实现,且必须注释说明原因。
#### 4. 允许自写的场景 #### 4. 允许自写的场景