feat: 升级至 v0.3.9 — 工具批量审批 + run_command 频率限制优化 + Toast 规范
1. 工具批量审批:解决并行工具审批弹框覆盖问题,新增批量 IPC 通道和列表 UI,支持同工具多次调用分组展示;2. 审批弹框健壮性:超时 toast 防风暴、agent 状态同步清空、ErrorBoundary 防白屏;3. run_command maxFrequency 从 3 调整为 10;4. 开发规范新增 Toast 通知铁律(必须使用 MeToast)
This commit is contained in:
@@ -1,21 +1,30 @@
|
||||
/**
|
||||
* ConfirmationDialog — 工具执行确认对话框(v0.2.0 新增)
|
||||
* ConfirmationDialog — 工具执行确认对话框(v0.3.2 批量审批版本)
|
||||
*
|
||||
* 当 Agent 调用 requiresPermission=true 或 riskLevel>=HIGH 的工具时,
|
||||
* 通过此对话框请求用户确认。
|
||||
* 当 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:confirmationResponse` IPC 通道发送结果。
|
||||
* 用户确认/拒绝后通过 `tool:confirmationResponseBatch` IPC 通道批量发送结果。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||
Button, Typography, Box, Chip, Alert, FormControlLabel, Checkbox,
|
||||
Accordion, AccordionSummary, AccordionDetails,
|
||||
LinearProgress,
|
||||
LinearProgress, List, ListItem, ListItemIcon, ListItemText,
|
||||
IconButton, Tooltip, Divider,
|
||||
} from '@mui/material';
|
||||
import { ShieldAlert, ChevronDown, Timer } from 'lucide-react';
|
||||
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface ConfirmationRequest {
|
||||
toolCallId: string;
|
||||
@@ -35,39 +44,105 @@ const RISK_COLORS: Record<string, 'default' | 'success' | 'warning' | 'error' |
|
||||
critical: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* 按工具名分组后的请求组
|
||||
*/
|
||||
interface GroupedRequests {
|
||||
toolName: string;
|
||||
riskLevel: string;
|
||||
/** 共享同一 riskLevel 的多个请求(riskLevel 来自 toolDef,同工具必然相同) */
|
||||
requests: ConfirmationRequest[];
|
||||
}
|
||||
|
||||
export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
const [request, setRequest] = useState<ConfirmationRequest | null>(null);
|
||||
const [requests, setRequests] = useState<ConfirmationRequest[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [autoExecute, setAutoExecute] = useState(false);
|
||||
const [remainingMs, setRemainingMs] = useState<number>(0);
|
||||
// 记录首次接收时的剩余时间,作为进度条总量(固定不变)
|
||||
const [initialMs, setInitialMs] = useState<number>(0);
|
||||
|
||||
// ===== 弹框打开时主动拉取已积压的 pending 请求 =====
|
||||
// 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达,
|
||||
// 或在 React state 更新批次中被覆盖。主动拉取确保不丢请求。
|
||||
const refreshPending = useCallback(async (mergeNew?: ConfirmationRequest) => {
|
||||
try {
|
||||
const result = await window.metona?.tool?.getPendingConfirmations();
|
||||
const pendingList: ConfirmationRequest[] = result?.success ? result.data : [];
|
||||
// 合并新到的 IPC 请求(若 pending 快照已包含则去重)
|
||||
const merged = [...pendingList];
|
||||
if (mergeNew) {
|
||||
const exists = merged.some((r) => r.toolCallId === mergeNew.toolCallId);
|
||||
if (!exists) merged.push(mergeNew);
|
||||
}
|
||||
// 按 toolCallId 去重(防止 refresh 与 IPC 事件重复添加)
|
||||
const dedupedMap = new Map<string, ConfirmationRequest>();
|
||||
for (const r of merged) dedupedMap.set(r.toolCallId, r);
|
||||
const deduped = Array.from(dedupedMap.values());
|
||||
|
||||
setRequests(deduped);
|
||||
// 默认全选
|
||||
setSelectedIds(new Set(deduped.map((r) => r.toolCallId)));
|
||||
} catch {
|
||||
// 拉取失败时回退到只显示新到的请求
|
||||
if (mergeNew) {
|
||||
setRequests([mergeNew]);
|
||||
setSelectedIds(new Set([mergeNew.toolCallId]));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听来自主进程的确认请求(通过 preload 暴露的 metona.tool API)
|
||||
const cleanup = window.metona?.tool?.onConfirmationRequest((data: unknown) => {
|
||||
const req = data as ConfirmationRequest;
|
||||
setRequest(req);
|
||||
// 每次新请求到达时主动拉取完整 pending 列表,防止 state 覆盖丢失
|
||||
refreshPending(req);
|
||||
setRemember(false);
|
||||
setAutoExecute(false);
|
||||
// 初始化倒计时
|
||||
// 初始化倒计时(使用本次请求的过期时间作为初始值)
|
||||
if (req.expiresAt) {
|
||||
const remain = Math.max(0, req.expiresAt - Date.now());
|
||||
setRemainingMs(remain);
|
||||
setInitialMs(remain);
|
||||
} else {
|
||||
setRemainingMs(0);
|
||||
setInitialMs(0);
|
||||
}
|
||||
});
|
||||
return cleanup;
|
||||
}, [refreshPending]);
|
||||
|
||||
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空前端 state =====
|
||||
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
|
||||
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
|
||||
// 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。
|
||||
useEffect(() => {
|
||||
if (!window.metona?.agent?.onStateChange) return;
|
||||
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
|
||||
const data = state as { state?: string; current?: string };
|
||||
const stateValue = data.state ?? data.current ?? '';
|
||||
// INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环)
|
||||
if (stateValue === 'INIT' || stateValue === 'TERMINATED') {
|
||||
setRequests([]);
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// 倒计时:每 200ms 更新剩余时间
|
||||
// 倒计时:每 200ms 更新剩余时间(取所有请求中最早过期的)
|
||||
useEffect(() => {
|
||||
if (!request?.expiresAt) return;
|
||||
if (requests.length === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
const remaining = request.expiresAt! - Date.now();
|
||||
// 取所有请求中最早过期的剩余时间
|
||||
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);
|
||||
@@ -76,28 +151,121 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}
|
||||
}, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [request]);
|
||||
}, [requests]);
|
||||
|
||||
const handleRespond = useCallback((approved: boolean) => {
|
||||
if (!request) return;
|
||||
// 通过 preload 暴露的 metona.tool API 发送响应
|
||||
// 按工具名分组(同工具多次调用折叠为一组)
|
||||
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?.sendConfirmationResponse({
|
||||
toolCallId: request.toolCallId,
|
||||
window.metona?.tool?.sendConfirmationResponseBatch({
|
||||
toolCallIds: targetIds,
|
||||
approved,
|
||||
remember,
|
||||
autoExecute: approved && autoExecute,
|
||||
});
|
||||
setRequest(null);
|
||||
}, [request, remember, autoExecute]);
|
||||
|
||||
if (!request) return null;
|
||||
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]);
|
||||
|
||||
const riskColor = RISK_COLORS[request.riskLevel] ?? 'default';
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
// 取所有请求中最早过期的,用于倒计时显示
|
||||
const earliestExpires = requests
|
||||
.map((r) => r.expiresAt)
|
||||
.filter((v): v is number => typeof v === 'number')
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const totalCount = requests.length;
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
// 综合风险等级:取所有请求中最高的
|
||||
const highestRisk = requests.reduce<string>((highest, r) => {
|
||||
const order = ['safe', 'low', 'medium', 'high', 'critical'];
|
||||
return order.indexOf(r.riskLevel) > order.indexOf(highest) ? r.riskLevel : highest;
|
||||
}, 'safe');
|
||||
|
||||
const riskColor = RISK_COLORS[highestRisk] ?? 'default';
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
const isUrgent = remainingSec <= 10 && remainingSec > 0;
|
||||
const isExpired = remainingMs <= 0 && request.expiresAt != null;
|
||||
// 总时长使用首次接收时的剩余时间(固定值),保证进度条单调递减
|
||||
const isExpired = remainingMs <= 0 && earliestExpires != null;
|
||||
const totalMs = initialMs || remainingMs;
|
||||
const progressPercent = totalMs > 0
|
||||
? Math.max(0, Math.min(100, (remainingMs / totalMs) * 100))
|
||||
@@ -106,7 +274,6 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
// 格式化参数显示
|
||||
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';
|
||||
@@ -118,11 +285,25 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}
|
||||
};
|
||||
|
||||
// 综合风险文案
|
||||
const batchReason = totalCount > 1
|
||||
? `检测到 ${totalCount} 个并行工具调用请求确认(涉及 ${grouped.length} 个不同工具)。综合最高风险:${highestRisk}`
|
||||
: requests[0]?.reason ?? 'Agent 正在请求执行工具';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!request}
|
||||
onClose={() => handleRespond(false)}
|
||||
maxWidth="sm"
|
||||
open={requests.length > 0}
|
||||
onClose={(_, reason) => {
|
||||
// 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致)
|
||||
// 防止超时后用户误触关闭,导致后端 pending 状态不一致
|
||||
if (isExpired) return;
|
||||
// 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC)
|
||||
// reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick'
|
||||
if (reason === 'backdropClick' || reason === 'escapeKeyDown') {
|
||||
handleRespond(false, false);
|
||||
}
|
||||
}}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
@@ -132,15 +313,23 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ShieldAlert size={20} color="var(--mui-palette-warning-main)" />
|
||||
<Typography variant="h6" component="span">工具执行确认</Typography>
|
||||
<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 }}>
|
||||
{request.reason}
|
||||
{batchReason}
|
||||
</Alert>
|
||||
|
||||
{request.expiresAt && !isExpired && (
|
||||
{earliestExpires && !isExpired && (
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Timer
|
||||
size={16}
|
||||
@@ -152,7 +341,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
sx={{
|
||||
color: isUrgent ? 'error.main' : 'text.secondary',
|
||||
fontWeight: isUrgent ? 700 : 500,
|
||||
minWidth: 48,
|
||||
minWidth: 80,
|
||||
animation: isUrgent ? 'metona-pulse 1s ease-in-out infinite' : 'none',
|
||||
'@keyframes metona-pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
@@ -160,7 +349,7 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
},
|
||||
}}
|
||||
>
|
||||
剩余 {remainingSec}s
|
||||
{totalCount > 1 ? '最早过期 ' : '剩余 '}{remainingSec}s
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
@@ -171,64 +360,145 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mb: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={`工具: ${request.toolName}`}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={`风险: ${request.riskLevel}`}
|
||||
color={riskColor}
|
||||
size="small"
|
||||
/>
|
||||
{/* 选择控制条 */}
|
||||
<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>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Agent 正在请求执行以下工具,请确认参数:
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
<Accordion defaultExpanded sx={{ bgcolor: 'background.default' }}>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
参数详情 ({Object.keys(request.args).length} 个参数)
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{Object.entries(request.args).map(([key, value]) => (
|
||||
<Box key={key} component="div" sx={{ mb: 1 }}>
|
||||
<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>
|
||||
))}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{/* 分组列表 */}
|
||||
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
|
||||
{grouped.map((group) => {
|
||||
const groupIds = group.requests.map((r) => r.toolCallId);
|
||||
const groupAllSelected = groupIds.every((id) => selectedIds.has(id));
|
||||
const groupSomeSelected = groupIds.some((id) => selectedIds.has(id));
|
||||
const groupRiskColor = RISK_COLORS[group.riskLevel] ?? 'default';
|
||||
const isMulti = group.requests.length > 1;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={group.toolName}
|
||||
defaultExpanded
|
||||
sx={{ bgcolor: 'background.default', mb: 0.5 }}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ChevronDown size={16} />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', pr: 1 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={groupAllSelected}
|
||||
indeterminate={!groupAllSelected && groupSomeSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleGroupSelect(group);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{group.toolName}
|
||||
</Typography>
|
||||
{isMulti && (
|
||||
<Chip
|
||||
label={`×${group.requests.length}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
sx={{ height: 20, fontSize: 11 }}
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
label={group.riskLevel}
|
||||
color={groupRiskColor}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: 11 }}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ pt: 0 }}>
|
||||
{group.requests.map((req, idx) => {
|
||||
const isSelected = selectedIds.has(req.toolCallId);
|
||||
return (
|
||||
<ListItem
|
||||
key={req.toolCallId}
|
||||
sx={{
|
||||
py: 0.5,
|
||||
bgcolor: isSelected ? 'action.selected' : 'transparent',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
secondaryAction={
|
||||
isMulti ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
#{idx + 1}
|
||||
</Typography>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggleSelect(req.toolCallId)}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: 'text.primary',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{Object.entries(req.args).map(([key, value]) => (
|
||||
<Box key={key} component="div" sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ color: 'info.main', fontWeight: 700 }}
|
||||
>
|
||||
{key}:
|
||||
</Typography>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ color: 'text.primary', ml: 1 }}
|
||||
>
|
||||
{formatArg(key, value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{Object.keys(req.args).length === 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
(无参数)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -240,10 +510,9 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}
|
||||
label={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
在本次会话中记住此决定(同一工具不再询问)
|
||||
在本次会话中记住此决定(同工具不再询问)
|
||||
</Typography>
|
||||
}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
@@ -260,32 +529,50 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
||||
}
|
||||
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>
|
||||
}
|
||||
sx={{ mt: 0.5 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button
|
||||
onClick={() => handleRespond(false)}
|
||||
onClick={() => handleRespond(false, false)}
|
||||
color="error"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={isExpired}
|
||||
>
|
||||
拒绝执行
|
||||
拒绝全部 ({totalCount})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRespond(true)}
|
||||
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}
|
||||
disabled={isExpired || selectedCount === 0}
|
||||
>
|
||||
{isExpired ? '已超时' : '确认执行'}
|
||||
{isExpired
|
||||
? '已超时'
|
||||
: selectedCount === totalCount
|
||||
? `确认执行 (${selectedCount})`
|
||||
: `批准选中 (${selectedCount})`}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user