/** * ConfirmationDialog — 工具执行确认对话框(v0.3.2 批量审批版本) * * 当 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:confirmationResponseBatch` IPC 通道批量发送结果。 */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox, Accordion, AccordionSummary, AccordionDetails, LinearProgress, List, ListItem, ListItemIcon, ListItemText, IconButton, Tooltip, Divider, } from '@mui/material'; import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; // v0.7.2 P4-15: 文案出层(字典含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; import '@renderer/lib/i18n-strings'; interface ConfirmationRequest { toolCallId: string; toolName: string; args: Record; riskLevel: string; reason: string; /** * 发起确认的会话 ID(v0.5.1) * 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。 * 用于会话 TERMINATED 时只清除该会话的请求(并发会话互不干扰)。 */ sessionId?: string; /** 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */ expiresAt?: number; } const RISK_COLORS: Record = { safe: 'success', low: 'info', medium: 'warning', high: 'error', critical: 'error', }; /** * 按工具名分组后的请求组 */ interface GroupedRequests { toolName: string; riskLevel: string; /** 共享同一 riskLevel 的多个请求(riskLevel 来自 toolDef,同工具必然相同) */ requests: ConfirmationRequest[]; } export function ConfirmationDialog(): React.JSX.Element | 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); // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 const [confirmAutoExecute, setConfirmAutoExecute] = useState(false); // v0.4.1: 本会话内记住"拒绝"的工具(带 TTL,可手动恢复询问) const [rememberedDenials, setRememberedDenials] = useState< Array<{ toolName: string; expiresInSeconds: number }> >([]); // v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步) // v0.5.0: 传当前会话 ID — 拒绝记忆按会话隔离,只展示本会话的记忆 const refreshRememberedDenials = useCallback(async () => { try { const sessionId = useAgentStore.getState().currentSessionId ?? undefined; const result = await window.metona?.tool?.getRememberedDenials(sessionId); setRememberedDenials(result?.success ? (result.data ?? []) : []); } catch { setRememberedDenials([]); } }, []); // v0.4.1: 重置某个工具的拒绝记忆(恢复询问) // v0.5.0: 传当前会话 ID,只重置本会话的记忆 const handleResetDenial = useCallback(async (toolName: string) => { try { const sessionId = useAgentStore.getState().currentSessionId ?? undefined; await window.metona?.tool?.resetRememberedDenial(toolName, sessionId); setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName)); } catch { // 重置失败保持现状,TTL 到期后仍会自动恢复 } }, []); // ===== 弹框打开时主动拉取已积压的 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])); } } // v0.4.1: 弹框打开时同步拉取被拒工具列表(展示恢复询问入口) void refreshRememberedDenials(); }, [refreshRememberedDenials], ); useEffect(() => { // 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API) const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => { const req = data as ConfirmationRequest; // 每次新请求到达时主动拉取完整 pending 列表,防止 state 覆盖丢失 refreshPending(req); setRemember(false); setAutoExecute(false); // 初始化倒计时(使用本次请求的过期时间作为初始值) if (req.expiresAt) { const remain = Math.max(0, req.expiresAt - Date.now()); setRemainingMs(remain); setInitialMs(remain); } }); return cleanup; }, [refreshPending]); // ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空该会话的前端 state ===== // 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步, // 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。 // 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。 // v0.5.1: 按会话过滤 — 后端 pending 已按 sessionId 隔离(v0.5.0), // 此前端点也只清除该会话的请求,并发会话等待中的确认不再被误清。 // selectedIds 中残留的已移除 id 无害(后端 resolveConfirmationsBatch 跳过不存在项, // 展示计数已按 requests 收敛)。 useEffect(() => { if (!window.metona?.agent?.onStateChange) return; const unsubscribe = window.metona.agent.onStateChange((state: unknown) => { const data = state as { state?: string; current?: string; sessionId?: string }; const stateValue = data.state ?? data.current ?? ''; // INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环) if (stateValue === 'INIT' || stateValue === 'TERMINATED') { const sid = data.sessionId; if (sid) { setRequests((prev) => prev.filter((r) => r.sessionId !== sid)); } else { // 无 sessionId 的兜底(理论上不出现):全清 setRequests([]); setSelectedIds(new Set()); } } }); return unsubscribe; }, []); // 倒计时:每 200ms 更新剩余时间(取所有请求中最早过期的) // F-6 修复: 归零时主动拉取 pending —— 后端超时已 resolve(false) 并删除条目, // 但此前不通知前端,弹框滞留在"已超时"且禁止关闭。主动拉取后列表为空, // 弹框自然消解;若后端 timer 尚未触发(前端轮询略早),拉回的 pending 保留原请求。 useEffect(() => { if (requests.length === 0) return; const interval = setInterval(() => { // 取所有请求中最早过期的剩余时间 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); void refreshPending(); } else { setRemainingMs(remaining); } }, 200); return () => clearInterval(interval); }, [requests, refreshPending]); // ===== v0.6.4 根治"超时锁死"===== 原缺陷:倒计时归零后若 refreshPending 拉回的 // 条目仍处于过期态(后端超时 timer 尚未触发),isExpired 使全部按钮 disabled 且 // ESC/backdrop 关闭被禁止 —— 弹窗进入完全锁死的静止态。 // 双保险修复: // ① 兜底自动拒绝 —— 过期 2.5 秒后若过期条目仍在(F-6 刷新没能消解它们), // 前端按后端一致的"超时=拒绝"语义补发批量响应并移除,弹窗必然收敛; // ② 恢复用户能动性 —— 见下方按钮区:拒绝全部始终可用、ESC/backdrop 可执行 // 拒绝全部(对已失效 id 的响应由后端安全跳过,不再有状态不一致风险)。 useEffect(() => { if (requests.length === 0 || remainingMs > 0) return; const expiredIds = requests .filter((r) => typeof r.expiresAt === 'number' && r.expiresAt <= Date.now()) .map((r) => r.toolCallId); if (expiredIds.length === 0) return; const timer = setTimeout(() => { window.metona?.tool?.sendConfirmationResponseBatch({ toolCallIds: expiredIds, approved: false, remember: false, }); const idSet = new Set(expiredIds); setRequests((prev) => prev.filter((r) => !idSet.has(r.toolCallId))); setSelectedIds(new Set()); }, 2500); return () => clearTimeout(timer); }, [requests, remainingMs]); // 按工具名分组(同工具多次调用折叠为一组) 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?.sendConfirmationResponseBatch({ toolCallIds: targetIds, approved, remember, autoExecute: approved && autoExecute, }); 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], ); 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]; // v0.5.1: 计数按当前 requests 收敛 — selectedIds 可能残留已被会话过滤移除的 id // (后端 resolveConfirmationsBatch 对不存在项安全跳过,仅影响展示计数) const selectedCount = requests.filter((r) => selectedIds.has(r.toolCallId)).length; const totalCount = requests.length; const allSelected = selectedCount === totalCount && totalCount > 0; // 综合风险等级:取所有请求中最高的 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 && earliestExpires != null; const totalMs = initialMs || remainingMs; const progressPercent = totalMs > 0 ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) : 100; // 格式化参数显示 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'; if (value === undefined) return 'undefined'; try { return JSON.stringify(value, null, 2); } catch { return String(value); } }; // 综合风险文案 const batchReason = totalCount > 1 ? t('confirm.batchReason', { count: totalCount, groups: grouped.length, risk: highestRisk, }) : (requests[0]?.reason ?? t('confirm.defaultReason')); return ( <> 0} onClose={(_, reason) => { // v0.6.4: 移除"超时后禁止关闭"拦截 —— 它配合全按钮 disabled 会把弹窗 // 锁死在静止态(见上方自动拒绝兜底注释)。对已失效 id 的批量响应由 // 后端 resolveConfirmationsBatch 安全跳过,拒绝语义幂等无副作用。 // 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭 // 防止 ESC 穿透到外层导致意外拒绝所有工具执行 if (confirmAutoExecute) return; // 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC) // reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick' if (reason === 'backdropClick' || reason === 'escapeKeyDown') { handleRespond(false, false); } }} maxWidth="md" fullWidth slotProps={{ paper: { sx: { bgcolor: 'background.paper' }, }, }} > {t('confirm.title')} {totalCount > 1 && ` ${t('confirm.parallelSuffix', { count: totalCount })}`} refreshPending()}> {batchReason} {/* v0.4.1: 本会话内被记住拒绝的工具 — 提供恢复询问入口(拒绝记忆 10 分钟后自动过期) */} {rememberedDenials.length > 0 && ( {t('confirm.rememberedDenials', { minutes: Math.ceil(rememberedDenials[0].expiresInSeconds / 60), })} {rememberedDenials.map((d) => ( handleResetDenial(d.toolName)} sx={{ cursor: 'pointer' }} /> ))} )} {earliestExpires && !isExpired && ( {totalCount > 1 ? t('confirm.earliestExpiry') : t('confirm.remaining')} {remainingSec}s )} {/* 选择控制条 */} {t('confirm.selectedCount', { selected: selectedCount, total: totalCount })} {/* 分组列表 */} {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 && ( {t('confirm.noArgs')} )} } /> ); })} ); })} setRemember(e.target.checked)} size="small" /> } label={ {t('confirm.rememberSession')} } /> { // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 if (e.target.checked) { setConfirmAutoExecute(true); } else { setAutoExecute(false); } }} size="small" /> } label={ {t('confirm.autoExecuteLabel')} {selectedCount > 0 && autoExecute && ( {t('confirm.autoExecuteApplies', { count: new Set( Array.from(selectedIds) .map((id) => requests.find((r) => r.toolCallId === id)?.toolName) .filter(Boolean) as string[], ).size, })} )} } /> {/* v0.6.4: 拒绝全部不再因过期被 disabled —— 过期场景下这是唯一有效的清理动作 */} {/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */} {/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown; onKeyDown stopPropagation 阻止 ESC 事件穿透到外层 Dialog */} { // 禁用 ESC 关闭(仅允许点击遮罩/按钮关闭),与原 disableEscapeKeyDown 行为一致 if (reason === 'escapeKeyDown') return; setConfirmAutoExecute(false); }} maxWidth="xs" fullWidth onKeyDown={(e) => e.stopPropagation()} > {t('confirm.autoExecConfirmTitle')} {t('confirm.autoExecConfirmBody')} {t('confirm.autoExecConfirmQuestion')} ); }