/** * 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'; interface ConfirmationRequest { toolCallId: string; toolName: string; args: Record; riskLevel: string; reason: 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); // ===== 弹框打开时主动拉取已积压的 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; // 每次新请求到达时主动拉取完整 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。 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 更新剩余时间(取所有请求中最早过期的) 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); } else { setRemainingMs(remaining); } }, 200); return () => clearInterval(interval); }, [requests]); // 按工具名分组(同工具多次调用折叠为一组) 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]; 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 && 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 ? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}` : requests[0]?.reason ?? 'Agent 正在请求执行工具'; return ( 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: { sx: { bgcolor: 'background.paper' }, }, }} > 工具执行确认 {totalCount > 1 && `(${totalCount} 个并行请求)`} refreshPending()}> {batchReason} {earliestExpires && !isExpired && ( {totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s )} {/* 选择控制条 */} 已选 {selectedCount} / {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 && ( (无参数) )} } /> ); })} ); })} setRemember(e.target.checked)} size="small" /> } label={ 在本次会话中记住此决定(同工具不再询问) } /> { setAutoExecute(e.target.checked); // 勾选永久自动执行时,自动勾选会话内记忆(保持一致) if (e.target.checked) setRemember(true); }} size="small" /> } label={ 永久自动执行选中工具(跨会话不再询问,可在设置中关闭) {selectedCount > 0 && autoExecute && ( · 将应用到 {new Set(Array.from(selectedIds).map((id) => requests.find((r) => r.toolCallId === id)?.toolName).filter(Boolean) as string[]).size} 个工具 )} } /> ); }