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 零跳过
785 lines
31 KiB
TypeScript
785 lines
31 KiB
TypeScript
/**
|
||
* 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;
|
||
/**
|
||
* 发起确认的会话 ID(v0.5.1)
|
||
* 主会话为 sessionId;SubAgent 委派的工具确认为 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 开始)或 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<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>
|
||
</>
|
||
);
|
||
}
|