feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
+434 -348
View File
@@ -18,11 +18,28 @@
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,
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';
@@ -64,36 +81,65 @@ export function ConfirmationDialog(): React.JSX.Element | null {
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: 拉取被拒工具列表(弹框打开/刷新时同步)
const refreshRememberedDenials = useCallback(async () => {
try {
const result = await window.metona?.tool?.getRememberedDenials();
setRememberedDenials(result?.success ? (result.data ?? []) : []);
} catch {
setRememberedDenials([]);
}
}, []);
// v0.4.1: 重置某个工具的拒绝记忆(恢复询问)
const handleResetDenial = useCallback(async (toolName: string) => {
try {
await window.metona?.tool?.resetRememberedDenial(toolName);
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());
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]));
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
@@ -207,44 +253,45 @@ export function ConfirmationDialog(): React.JSX.Element | null {
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);
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;
if (targetIds.length === 0) return;
// 通过批量 IPC 通道发送响应
// autoExecute 仅在批准时生效(拒绝时无需持久化)
window.metona?.tool?.sendConfirmationResponseBatch({
toolCallIds: targetIds,
approved,
remember,
autoExecute: approved && autoExecute,
});
// 通过批量 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);
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());
}
} else {
// "拒绝全部 / 批准全部":清空所有
setRequests([]);
setSelectedIds(new Set());
}
}, [requests, selectedIds, remember, autoExecute, refreshPending]);
},
[requests, selectedIds, remember, autoExecute, refreshPending],
);
if (requests.length === 0) return null;
@@ -269,9 +316,8 @@ export function ConfirmationDialog(): React.JSX.Element | null {
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 progressPercent =
totalMs > 0 ? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100)) : 100;
// 格式化参数显示
const formatArg = (key: string, value: unknown): string => {
@@ -288,303 +334,340 @@ export function ConfirmationDialog(): React.JSX.Element | null {
};
// 综合风险文案
const batchReason = totalCount > 1
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
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;
// 审查修复: 内层二次确认 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">
{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}
<Dialog
open={requests.length > 0}
onClose={(_, reason) => {
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
if (isExpired) return;
// 审查修复: 内层二次确认 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">
{totalCount > 1 && `${totalCount} 个并行请求)`}
</Typography>
</Box>
<Box sx={{ flex: 1 }} />
<Tooltip title="刷新 pending 列表">
<IconButton size="small" onClick={() => refreshPending()}>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</DialogTitle>
<Divider sx={{ mb: 1 }} />
<DialogContent>
<Alert severity={riskColor === 'error' ? 'error' : 'warning'} sx={{ mb: 2 }}>
{batchReason}
</Alert>
{/* 分组列表 */}
<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;
{/* 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 }}>
{Math.ceil(rememberedDenials[0].expiresInSeconds / 60)}{' '}
:
</Typography>
{rememberedDenials.map((d) => (
<Chip
key={d.toolName}
label={`${d.toolName} — 重新询问`}
size="small"
color="primary"
variant="outlined"
onClick={() => handleResetDenial(d.toolName)}
sx={{ cursor: 'pointer' }}
/>
))}
</Box>
</Alert>
)}
return (
<Accordion
key={group.toolName}
defaultExpanded
sx={{ bgcolor: 'background.default', mb: 0.5 }}
{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 },
},
}}
>
<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 && (
{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.requests.length}`}
label={group.riskLevel}
color={groupRiskColor}
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>
</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
}
/>
</ListItem>
);
})}
</AccordionDetails>
</Accordion>
);
})}
</List>
>
<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 }} />
<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={remember}
onChange={(e) => setRemember(e.target.checked)}
size="small"
/>
}
label={
<Typography variant="caption" color="text.secondary">
</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">
{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>
<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">
{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>
<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>
{/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */}
{/* 审查修复: MUI v9 移除了 disableEscapeKeyDown 顶层 prop,改为在 onClose 中按 reason 拦截 escapeKeyDown
@@ -603,14 +686,17 @@ export function ConfirmationDialog(): React.JSX.Element | null {
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'warning.main', fontWeight: 600 }}>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit"></Button>
<Button onClick={() => setConfirmAutoExecute(false)} color="inherit">
</Button>
<Button
onClick={() => {
setAutoExecute(true);