Files
metona-ai-desktop/src/components/ConfirmationDialog.tsx
T
thzxx 64af91bde9 feat: 升级至 v0.3.9 — 工具批量审批 + run_command 频率限制优化 + Toast 规范
1. 工具批量审批:解决并行工具审批弹框覆盖问题,新增批量 IPC 通道和列表 UI,支持同工具多次调用分组展示;2. 审批弹框健壮性:超时 toast 防风暴、agent 状态同步清空、ErrorBoundary 防白屏;3. run_command maxFrequency 从 3 调整为 10;4. 开发规范新增 Toast 通知铁律(必须使用 MeToast)
2026-07-21 11:43:41 +08:00

581 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>;
riskLevel: string;
reason: string;
/** 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
expiresAt?: number;
}
const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
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<ConfirmationRequest[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [remember, setRemember] = useState(false);
const [autoExecute, setAutoExecute] = useState(false);
const [remainingMs, setRemainingMs] = 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(() => {
// 监听来自主进程的确认请求(通过 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 开始)或 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 更新剩余时间(取所有请求中最早过期的)
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<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 仅在批准时生效(拒绝时无需持久化)
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<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 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 (
<Dialog
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: {
sx: { bgcolor: 'background.paper' },
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
<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>
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{batchReason}
</Alert>
{earliestExpires && !isExpired && (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Timer
size={16}
color={isUrgent ? 'var(--mui-palette-error-main)' : 'var(--mui-palette-text-secondary)'}
style={isUrgent ? { animation: 'metona-pulse 1s ease-in-out infinite' } : undefined}
/>
<Typography
variant="caption"
sx={{
color: isUrgent ? 'error.main' : 'text.secondary',
fontWeight: isUrgent ? 700 : 500,
minWidth: 80,
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
'@keyframes metona-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.4 },
},
}}
>
{totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s
</Typography>
<LinearProgress
variant="determinate"
value={progressPercent}
color={isUrgent ? 'error' : 'primary'}
sx={{ flex: 1, height: 6, borderRadius: 3 }}
/>
</Box>
)}
{/* 选择控制条 */}
<Box sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" onClick={handleSelectAll} disabled={allSelected}>
全选
</Button>
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
全不选
</Button>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
已选 {selectedCount} / {totalCount}
</Typography>
</Box>
<Divider sx={{ mb: 1 }} />
{/* 分组列表 */}
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
{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 (
<Accordion
key={group.toolName}
defaultExpanded
sx={{ bgcolor: 'background.default', mb: 0.5 }}
>
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
<Checkbox
size="small"
checked={groupAllSelected}
indeterminate={!groupAllSelected && groupSomeSelected}
onChange={(e) => {
e.stopPropagation();
handleToggleGroupSelect(group);
}}
onClick={(e) => e.stopPropagation()}
/>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{group.toolName}
</Typography>
{isMulti && (
<Chip
label={`×${group.requests.length}`}
size="small"
color="primary"
variant="outlined"
sx={{ height: 20, fontSize: 11 }}
/>
)}
<Chip
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
control={
<Checkbox
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
在本次会话中记住此决定(同工具不再询问)
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={autoExecute}
onChange={(e) => {
setAutoExecute(e.target.checked);
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
if (e.target.checked) setRemember(true);
}}
size="small"
/>
}
label={
<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>
}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={() => handleRespond(false, false)}
color="error"
variant="outlined"
size="small"
disabled={isExpired}
>
拒绝全部 ({totalCount})
</Button>
<Button
onClick={() => handleRespond(true, false)}
color="success"
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={allSelected ? '已全部选中,请使用"批准选中"' : '批准全部请求'}
>
批准全部
</Button>
<Button
onClick={() => handleRespond(true, true)}
color="success"
variant="contained"
size="small"
autoFocus
disabled={isExpired || selectedCount === 0}
>
{isExpired
? '已超时'
: selectedCount === totalCount
? `确认执行 (${selectedCount})`
: `批准选中 (${selectedCount})`}
</Button>
</DialogActions>
</Dialog>
);
}