Files
metona-ai-desktop/src/components/ConfirmationDialog.tsx
T
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

785 lines
31 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';
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<string, unknown>;
riskLevel: string;
reason: string;
/**
* 发起确认的会话 IDv0.5.1
* 主会话为 sessionIdSubAgent 委派的工具确认为 taskId。
* 用于会话 TERMINATED 时只清除该会话的请求(并发会话互不干扰)。
*/
sessionId?: 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);
// #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<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]));
}
}
// 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 开始)或 TERMINATEDrun 结束/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<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];
// 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<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
? t('confirm.batchReason', {
count: totalCount,
groups: grouped.length,
risk: highestRisk,
})
: (requests[0]?.reason ?? t('confirm.defaultReason'));
return (
<>
<Dialog
open={requests.length > 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' },
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
<Typography variant="h6" component="span">
{t('confirm.title')}
{totalCount > 1 && ` ${t('confirm.parallelSuffix', { count: totalCount })}`}
</Typography>
<Box sx={{ flex: 1 }} />
<Tooltip title={t('confirm.refreshList')}>
<IconButton size="small" onClick={() => refreshPending()}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</DialogTitle>
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{batchReason}
</Alert>
{/* v0.4.1: 本会话内被记住拒绝的工具 — 提供恢复询问入口(拒绝记忆 10 分钟后自动过期) */}
{rememberedDenials.length > 0 && (
<Alert severity="info" sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t('confirm.rememberedDenials', {
minutes: Math.ceil(rememberedDenials[0].expiresInSeconds / 60),
})}
</Typography>
{rememberedDenials.map((d) => (
<Chip
key={d.toolName}
label={`${d.toolName}${t('confirm.askAgain')}`}
size="small"
color="primary"
variant="outlined"
onClick={() => handleResetDenial(d.toolName)}
sx={{ cursor: 'pointer' }}
/>
))}
</Box>
</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 ? t('confirm.earliestExpiry') : t('confirm.remaining')}
{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}>
{t('confirm.selectAll')}
</Button>
<Button size="small" onClick={handleDeselectAll} disabled={selectedCount === 0}>
{t('confirm.deselectAll')}
</Button>
<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
{t('confirm.selectedCount', { selected: selectedCount, total: 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">
{t('confirm.noArgs')}
</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">
{t('confirm.rememberSession')}
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={autoExecute}
onChange={(e) => {
// #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击
if (e.target.checked) {
setConfirmAutoExecute(true);
} else {
setAutoExecute(false);
}
}}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
{t('confirm.autoExecuteLabel')}
{selectedCount > 0 && autoExecute && (
<Box component="span" sx={{ color: 'warning.main', ml: 1 }}>
{t('confirm.autoExecuteApplies', {
count: 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 }}>
{/* v0.6.4: 拒绝全部不再因过期被 disabled —— 过期场景下这是唯一有效的清理动作 */}
<Button
onClick={() => handleRespond(false, false)}
color="error"
variant="outlined"
size="small"
title={isExpired ? t('confirm.denyAllExpiredTitle') : t('confirm.denyAllTitle')}
>
{t('confirm.denyAll', { count: totalCount })}
</Button>
<Button
onClick={() => handleRespond(true, false)}
color="success"
variant="outlined"
size="small"
disabled={isExpired || allSelected}
title={
isExpired
? t('confirm.approveAllExpiredTitle')
: allSelected
? t('confirm.approveAllDisabledTitle')
: t('confirm.approveAllTitle')
}
>
{t('confirm.approveAll')}
</Button>
<Button
onClick={() => handleRespond(true, true)}
color="success"
variant="contained"
size="small"
autoFocus
disabled={isExpired || selectedCount === 0}
title={isExpired ? t('confirm.expiredHint') : undefined}
>
{isExpired
? t('confirm.expired')
: selectedCount === totalCount
? t('confirm.confirmExecution', { count: selectedCount })
: t('confirm.approveSelected', { count: selectedCount })}
</Button>
</DialogActions>
</Dialog>
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
{/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown
onKeyDown stopPropagation 阻止 ESC 事件穿透到外层 Dialog */}
<Dialog
open={confirmAutoExecute}
onClose={(_, reason) => {
// 禁用 ESC 关闭(仅允许点击遮罩/按钮关闭),与原 disableEscapeKeyDown 行为一致
if (reason === 'escapeKeyDown') return;
setConfirmAutoExecute(false);
}}
maxWidth="xs"
fullWidth
onKeyDown={(e) => e.stopPropagation()}
>
<DialogTitle sx={{ fontSize: 14 }}>{t('confirm.autoExecConfirmTitle')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('confirm.autoExecConfirmBody')}
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
{t('confirm.autoExecConfirmQuestion')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">
{t('common.cancel')}
</Button>
<Button
onClick={() => {
setAutoExecute(true);
// 勾选永久自动执行时,自动勾选会话内记忆(保持一致)
setRemember(true);
setConfirmAutoExecute(false);
}}
color="warning"
variant="contained"
>
{t('confirm.autoExecConfirmYes')}
</Button>
</DialogActions>
</Dialog>
</>
);
}