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:
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* MCPSettings — MCP 服务管理 Tab
|
||||
*
|
||||
* 从 SettingsModal.tsx 提取(v0.4.1 拆分)。
|
||||
* 功能:MCP Server 列表/连接/断开/移除(Dialog 二次确认)/添加。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
|
||||
export function MCPSettings() {
|
||||
const [servers, setServers] = useState<
|
||||
Array<{ name: string; status: string; toolCount: number; error?: string }>
|
||||
>([]);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCommand, setNewCommand] = useState('');
|
||||
const [newArgs, setNewArgs] = useState('');
|
||||
// v0.4.1: 新增传输方式选择(stdio / streamable-http)与 URL 字段
|
||||
const [newTransport, setNewTransport] = useState<'stdio' | 'streamable-http'>('stdio');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
// L-11 修复: 用 MUI Dialog 替换浏览器原生 confirm(),保持 UI 一致性
|
||||
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
||||
|
||||
// L-20 修复: loadServers 改为多行 async/await 写法,提升可读性
|
||||
const loadServers = useCallback(async () => {
|
||||
if (!window.metona?.mcp?.listServers) return;
|
||||
try {
|
||||
const list = await window.metona.mcp.listServers();
|
||||
setServers(list as MetonaMCPServerStatus[]);
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
}
|
||||
}, []);
|
||||
// 审计补充修复: useEffect 添加 cancelled 标志,防止卸载后 setState
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!window.metona?.mcp?.listServers) return;
|
||||
try {
|
||||
const list = await window.metona.mcp.listServers();
|
||||
if (!cancelled) setServers(list as MetonaMCPServerStatus[]);
|
||||
} catch (err) {
|
||||
if (!cancelled) console.error('[MCPSettings]', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const handleAdd = async () => {
|
||||
// v0.4.1: 按传输方式校验必填字段(stdio→命令,streamable-http→URL)
|
||||
if (!newName.trim()) return;
|
||||
if (newTransport === 'stdio' && !newCommand.trim()) return;
|
||||
if (newTransport === 'streamable-http' && !newUrl.trim()) return;
|
||||
try {
|
||||
const config =
|
||||
newTransport === 'stdio'
|
||||
? {
|
||||
name: newName.trim(),
|
||||
transport: 'stdio' as const,
|
||||
command: newCommand.trim(),
|
||||
args: newArgs.trim() ? newArgs.trim().split(/\s+/) : [],
|
||||
enabled: true,
|
||||
}
|
||||
: {
|
||||
name: newName.trim(),
|
||||
transport: 'streamable-http' as const,
|
||||
url: newUrl.trim(),
|
||||
enabled: true,
|
||||
};
|
||||
const r = await window.metona?.mcp?.addServer(config);
|
||||
if (r?.success) {
|
||||
setNewName('');
|
||||
setNewCommand('');
|
||||
setNewArgs('');
|
||||
setNewUrl('');
|
||||
setNewTransport('stdio');
|
||||
setShowAdd(false);
|
||||
loadServers();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '添加 MCP 服务失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`添加 MCP 服务失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
const statusColors: Record<string, string> = {
|
||||
connected: 'success.main',
|
||||
connecting: 'warning.main',
|
||||
disconnected: 'text.secondary',
|
||||
error: 'error.main',
|
||||
};
|
||||
|
||||
// L-11 修复: 确认移除 MCP 服务
|
||||
// 审计补充修复: 添加 try/catch,避免 removeServer reject 时 Dialog 卡死无法关闭
|
||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||
const handleConfirmRemove = async () => {
|
||||
if (!confirmRemove) return;
|
||||
try {
|
||||
setRemoveError(null);
|
||||
const r = await window.metona?.mcp?.removeServer(confirmRemove);
|
||||
if (r?.success) {
|
||||
setConfirmRemove(null);
|
||||
loadServers();
|
||||
} else {
|
||||
setRemoveError(r?.error ?? '移除失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setRemoveError((err as Error).message ?? '移除失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
MCP 服务
|
||||
</Typography>
|
||||
{servers.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>
|
||||
暂无 MCP 服务
|
||||
</Typography>
|
||||
) : (
|
||||
servers.map((s) => (
|
||||
<Stack
|
||||
key={s.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={{ alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{ width: 8, height: 8, borderRadius: '50', bgcolor: statusColors[s.status] }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{s.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: statusColors[s.status] }}>
|
||||
{s.status}
|
||||
</Typography>
|
||||
{s.toolCount > 0 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{s.toolCount} 工具
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const r = await window.metona?.mcp?.toggleServer(
|
||||
s.name,
|
||||
s.status !== 'connected',
|
||||
);
|
||||
if (r?.success) {
|
||||
loadServers();
|
||||
} else {
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(r?.error ?? '操作失败'))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MCPSettings]', err);
|
||||
import('@metona-team/metona-toast')
|
||||
.then((mod) => mod.default.error(`操作失败:${(err as Error).message}`))
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{s.status === 'connected' ? '断开' : '连接'}
|
||||
</Button>
|
||||
{/* L-11 修复: 点击移除打开 MUI Dialog 二次确认,而非原生 confirm() */}
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{ fontSize: 10, minWidth: 40 }}
|
||||
onClick={() => setConfirmRemove(s.name)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* L-11 修复: MUI Dialog 替代原生 confirm() */}
|
||||
<Dialog
|
||||
open={confirmRemove !== null}
|
||||
onClose={() => {
|
||||
setConfirmRemove(null);
|
||||
setRemoveError(null);
|
||||
}}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>确认移除</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
确定移除 MCP 服务 "{confirmRemove}"?此操作不可撤销。
|
||||
</Typography>
|
||||
{removeError && (
|
||||
<Alert severity="error" sx={{ mt: 1, fontSize: 12 }}>
|
||||
移除失败:{removeError}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setConfirmRemove(null);
|
||||
setRemoveError(null);
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirmRemove} color="error" variant="contained">
|
||||
移除
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
{showAdd ? (
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'secondary.main',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="服务名称"
|
||||
/>
|
||||
{/* v0.4.1: 传输方式选择(stdio 本地命令 / streamable-http 远程) */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant={newTransport === 'stdio' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setNewTransport('stdio')}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
本地 (stdio)
|
||||
</Button>
|
||||
<Button
|
||||
variant={newTransport === 'streamable-http' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
onClick={() => setNewTransport('streamable-http')}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
远程 (HTTP)
|
||||
</Button>
|
||||
</Stack>
|
||||
{newTransport === 'stdio' ? (
|
||||
<>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newCommand}
|
||||
onChange={(e) => setNewCommand(e.target.value)}
|
||||
placeholder="命令路径(如 npx / node / python)"
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
value={newArgs}
|
||||
onChange={(e) => setNewArgs(e.target.value)}
|
||||
placeholder="参数 (空格分隔)"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="Streamable HTTP URL(如 https://example.com/mcp)"
|
||||
/>
|
||||
)}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAdd}
|
||||
disabled={
|
||||
!newName.trim() || (newTransport === 'stdio' ? !newCommand.trim() : !newUrl.trim())
|
||||
}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setShowAdd(false)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button variant="outlined" fullWidth size="small" onClick={() => setShowAdd(true)}>
|
||||
+ 添加 MCP 服务
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user