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:
@@ -22,6 +22,11 @@ export interface ConfirmationRequest {
|
||||
args: Record<string, unknown>;
|
||||
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<string>();
|
||||
|
||||
/** 等待确认的 Promise 解析器 */
|
||||
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => void; timer: NodeJS.Timeout; toolName: string; expiresAt: number }>();
|
||||
/** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
|
||||
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 秒) */
|
||||
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> {
|
||||
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<boolean> {
|
||||
return new Promise<boolean>((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,
|
||||
});
|
||||
|
||||
// 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 污染
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
riskLevel: string;
|
||||
reason: string;
|
||||
expiresAt?: number;
|
||||
}>;
|
||||
}>,
|
||||
// v0.2.0: 持久化自动执行设置
|
||||
setAutoExecute: (toolName: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
|
||||
|
||||
Reference in New Issue
Block a user