feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user