P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
/**
|
||
* ToolsSettings — 工具管理 Tab
|
||
*
|
||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||
* 功能:工具启用/禁用开关、自动执行工具管理(乐观更新 + 失败回滚)。
|
||
*/
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box, TextField } from '@mui/material';
|
||
import { alpha } from '@mui/material/styles';
|
||
import { useConfig } from './useConfig';
|
||
|
||
export function ToolsSettings() {
|
||
const [tools, setTools] = useState<
|
||
Array<{
|
||
name: string;
|
||
description: string;
|
||
riskLevel: string;
|
||
requiresPermission: boolean;
|
||
enabled: boolean;
|
||
}>
|
||
>([]);
|
||
const [autoExecList, setAutoExecList] = useState<string[]>([]);
|
||
|
||
const loadTools = () => {
|
||
if (window.metona?.tools?.list) {
|
||
window.metona.tools
|
||
.list()
|
||
.then((l) =>
|
||
setTools(
|
||
(l as MetonaToolInfo[]).map((t) => ({
|
||
name: t.name,
|
||
description: t.description,
|
||
riskLevel: t.riskLevel,
|
||
requiresPermission: t.requiresPermission,
|
||
enabled: t.enabled,
|
||
})),
|
||
),
|
||
)
|
||
.catch((err) => {
|
||
console.error('[ToolsSettings]', err);
|
||
});
|
||
}
|
||
};
|
||
const loadAutoExec = () => {
|
||
if (window.metona?.tool?.getAutoExecuteList) {
|
||
window.metona.tool
|
||
.getAutoExecuteList()
|
||
.then((r) => {
|
||
if (r.success) setAutoExecList(r.data);
|
||
})
|
||
.catch((err) => {
|
||
console.error('[ToolsSettings]', err);
|
||
});
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
loadTools();
|
||
loadAutoExec();
|
||
}, []);
|
||
|
||
const handleToggle = async (name: string, enabled: boolean) => {
|
||
// v0.3.6 修复: 乐观更新失败时回滚 UI,避免开关显示与实际状态不一致
|
||
// 注意: 只回滚失败的单个工具(用 !enabled),不能用 setTools(prev) 整体回滚,
|
||
// 否则会覆盖 await 期间用户对其他工具的并发修改
|
||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled } : t)));
|
||
try {
|
||
const r = await window.metona?.tools?.toggle(name, enabled);
|
||
if (r && !r.success) {
|
||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
|
||
import('@metona-team/metona-toast')
|
||
.then((mod) => mod.default.error(r.error ?? '切换工具失败'))
|
||
.catch(() => {});
|
||
}
|
||
} catch (err) {
|
||
console.error('[ToolsSettings]', err);
|
||
setTools((p) => p.map((t) => (t.name === name ? { ...t, enabled: !enabled } : t)));
|
||
import('@metona-team/metona-toast')
|
||
.then((mod) => mod.default.error('切换工具失败'))
|
||
.catch(() => {});
|
||
}
|
||
};
|
||
|
||
// 设置/取消自动执行
|
||
const handleSetAutoExec = async (name: string, enabled: boolean) => {
|
||
if (!window.metona?.tool?.setAutoExecute) return;
|
||
try {
|
||
const r = await window.metona.tool.setAutoExecute(name, enabled);
|
||
if (r.success) {
|
||
setAutoExecList((p) => (enabled ? [...p, name] : p.filter((n) => n !== name)));
|
||
} else {
|
||
import('@metona-team/metona-toast')
|
||
.then((mod) => mod.default.error(r.error ?? '设置自动执行失败'))
|
||
.catch(() => {});
|
||
}
|
||
} catch (err) {
|
||
console.error('[ToolsSettings]', err);
|
||
import('@metona-team/metona-toast')
|
||
.then((mod) => mod.default.error('设置自动执行失败'))
|
||
.catch(() => {});
|
||
}
|
||
};
|
||
|
||
// v0.3.1: 添加 critical 键,防止 critical 级别工具 Chip 渲染为 undefined color
|
||
const riskColors: Record<string, 'success' | 'info' | 'warning' | 'error'> = {
|
||
safe: 'success',
|
||
low: 'info',
|
||
medium: 'warning',
|
||
high: 'error',
|
||
critical: 'error',
|
||
};
|
||
|
||
// 需要确认的工具(high/critical 或 requiresPermission)
|
||
const needsConfirmTools = tools.filter(
|
||
(t) => t.riskLevel === 'high' || t.riskLevel === 'critical' || t.requiresPermission,
|
||
);
|
||
// 需要确认但未设为自动执行的工具
|
||
const pendingConfirmTools = needsConfirmTools.filter((t) => !autoExecList.includes(t.name));
|
||
// 已设为自动执行的工具详情
|
||
const autoExecToolDetails = autoExecList
|
||
.map((name) => tools.find((t) => t.name === name))
|
||
.filter((t): t is NonNullable<typeof t> => t !== undefined);
|
||
|
||
return (
|
||
<Stack spacing={2}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||
工具管理
|
||
</Typography>
|
||
{tools.length === 0 ? (
|
||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||
加载中...
|
||
</Typography>
|
||
) : (
|
||
tools.map((t) => (
|
||
<Stack
|
||
key={t.name}
|
||
direction="row"
|
||
sx={{
|
||
py: 1,
|
||
px: 1.5,
|
||
borderRadius: 1.5,
|
||
bgcolor: 'secondary.main',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<Stack direction="row" spacing={1} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
|
||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||
{t.name}
|
||
</Typography>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||
>
|
||
{t.description.slice(0, 40)}
|
||
</Typography>
|
||
</Stack>
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||
<Chip
|
||
label={t.riskLevel.toUpperCase()}
|
||
size="small"
|
||
color={riskColors[t.riskLevel]}
|
||
variant="outlined"
|
||
sx={{ height: 18, fontSize: 9 }}
|
||
/>
|
||
<Checkbox
|
||
checked={t.enabled}
|
||
onChange={(e) => handleToggle(t.name, e.target.checked)}
|
||
size="small"
|
||
/>
|
||
</Stack>
|
||
</Stack>
|
||
))
|
||
)}
|
||
|
||
<Divider sx={{ my: 1 }} />
|
||
|
||
{/* ===== 自动执行工具管理 ===== */}
|
||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||
自动执行工具
|
||
</Typography>
|
||
<Chip
|
||
label={`${autoExecList.length} 个`}
|
||
size="small"
|
||
color={autoExecList.length > 0 ? 'success' : 'default'}
|
||
variant="outlined"
|
||
sx={{ height: 18, fontSize: 10 }}
|
||
/>
|
||
</Stack>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
|
||
已设为自动执行的工具将跳过用户确认步骤,直接执行。此设置跨会话持久化。
|
||
</Typography>
|
||
|
||
{/* 已自动执行的工具列表 */}
|
||
{autoExecToolDetails.length === 0 ? (
|
||
<Typography
|
||
variant="caption"
|
||
sx={{ textAlign: 'center', py: 2, color: 'text.disabled', fontStyle: 'italic' }}
|
||
>
|
||
暂无自动执行工具
|
||
</Typography>
|
||
) : (
|
||
autoExecToolDetails.map((t) => (
|
||
<Stack
|
||
key={t.name}
|
||
direction="row"
|
||
sx={(theme) => ({
|
||
py: 1,
|
||
px: 1.5,
|
||
borderRadius: 1.5,
|
||
// 用主题 success 色的 12% 透明度做底,文字和按钮保持不透明
|
||
bgcolor: alpha(theme.palette.success.main, 0.12),
|
||
// 左侧绿色状态条,强化"已自动执行"视觉
|
||
boxShadow: `inset 3px 0 0 ${theme.palette.success.main}`,
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
})}
|
||
>
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
width: 6,
|
||
height: 6,
|
||
borderRadius: '50%',
|
||
bgcolor: 'success.main',
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{
|
||
fontFamily: 'monospace',
|
||
fontSize: 12,
|
||
color: 'text.primary',
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{t.name}
|
||
</Typography>
|
||
<Chip label="自动" size="small" color="success" sx={{ height: 16, fontSize: 9 }} />
|
||
</Stack>
|
||
<Button
|
||
size="small"
|
||
color="error"
|
||
variant="contained"
|
||
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
|
||
onClick={() => handleSetAutoExec(t.name, false)}
|
||
>
|
||
取消自动
|
||
</Button>
|
||
</Stack>
|
||
))
|
||
)}
|
||
|
||
{/* 可设为自动执行的工具(需要确认但未设置) */}
|
||
{pendingConfirmTools.length > 0 && (
|
||
<>
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', mt: 1 }}>
|
||
可设为自动执行(当前需要确认)
|
||
</Typography>
|
||
{pendingConfirmTools.map((t) => (
|
||
<Stack
|
||
key={t.name}
|
||
direction="row"
|
||
sx={{
|
||
py: 1,
|
||
px: 1.5,
|
||
borderRadius: 1.5,
|
||
bgcolor: 'secondary.main',
|
||
border: '1px dashed',
|
||
borderColor: 'divider',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<Stack
|
||
direction="row"
|
||
spacing={1}
|
||
sx={{ alignItems: 'center', minWidth: 0, flex: 1 }}
|
||
>
|
||
<Typography
|
||
variant="body2"
|
||
sx={{ fontFamily: 'monospace', fontSize: 12, color: 'text.primary' }}
|
||
>
|
||
{t.name}
|
||
</Typography>
|
||
<Chip
|
||
label={t.riskLevel.toUpperCase()}
|
||
size="small"
|
||
color={riskColors[t.riskLevel]}
|
||
variant="outlined"
|
||
sx={{ height: 16, fontSize: 9 }}
|
||
/>
|
||
</Stack>
|
||
<Button
|
||
size="small"
|
||
color="success"
|
||
variant="contained"
|
||
sx={{ fontSize: 11, minWidth: 72, fontWeight: 600 }}
|
||
onClick={() => handleSetAutoExec(t.name, true)}
|
||
>
|
||
设为自动
|
||
</Button>
|
||
</Stack>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* ===== 网络代理(v0.6.4 P4-5)===== */}
|
||
<Divider sx={{ my: 1 }} />
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||
网络代理
|
||
</Typography>
|
||
<ProxyField />
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* v0.6.4 P4-5: network.proxyUrl 编辑项 —— useConfig 保存即触发主进程
|
||
* applySessionProxy(shared.ts 副作用联动),Chromium session 与主进程 fetch
|
||
* 双通道同时生效。格式示例:http://127.0.0.1:7890 或 socks5://user:pass@host:1080。
|
||
*/
|
||
function ProxyField(): React.JSX.Element {
|
||
const [proxyUrl, setProxyUrl] = useConfig('network.proxyUrl', '');
|
||
const [draft, setDraft] = useState<string>('');
|
||
|
||
// 首次载入后把配置值同步到草稿(此后用户自由编辑,blur/save 时提交)
|
||
const [synced, setSynced] = useState(false);
|
||
useEffect(() => {
|
||
if (!synced && proxyUrl !== null) {
|
||
setDraft(proxyUrl);
|
||
setSynced(true);
|
||
}
|
||
}, [proxyUrl, synced]);
|
||
|
||
const invalid =
|
||
!!draft.trim() && !/^(https?|socks[45]):\/\//i.test(draft.trim());
|
||
|
||
return (
|
||
<>
|
||
<TextField
|
||
size="small"
|
||
label="代理地址(可选)"
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onBlur={() => {
|
||
if (draft.trim() !== (proxyUrl ?? '') && !invalid) {
|
||
setProxyUrl(draft.trim());
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.currentTarget.blur();
|
||
}
|
||
}}
|
||
placeholder="http://127.0.0.1:7890 或 socks5://…(留空直连)"
|
||
error={invalid}
|
||
helperText={
|
||
invalid
|
||
? '需以 http://、https://、socks5:// 或 socks4:// 开头'
|
||
: '应用于 Chromium 会话与主进程全部网络请求;未填时回退系统环境变量 HTTPS_PROXY。'
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|