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
+311
View File
@@ -0,0 +1,311 @@
/**
* ToolsSettings — 工具管理 Tab
*
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
* 功能:工具启用/禁用开关、自动执行工具管理(乐观更新 + 失败回滚)。
*/
import { useState, useEffect } from 'react';
import { Button, Stack, Typography, Checkbox, Divider, Chip, Box } from '@mui/material';
import { alpha } from '@mui/material/styles';
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>
))}
</>
)}
</Stack>
);
}