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
+71 -94
View File
@@ -1,8 +1,18 @@
/**
* MessageList — 消息列表容器
* MessageList — 消息列表容器v0.4.1: react-virtuoso 真虚拟滚动)
*
* v0.4.1 重构:content-visibility 准虚拟滚动升级为 react-virtuoso 真虚拟滚动。
* - 千条消息会话:屏幕外 DOM 节点不再挂载(此前仅跳过渲染,节点仍全量存在)
* - 新消息滚动:followOutput(数组追加时生效)
* - 流式跟随(复查补充): followOutput 只响应"数组长度变化",流式 delta 更新
* 最后一条消息内容时高度增长不会自动滚底 — 用 atBottomStateChange 跟踪底部
* 状态 + 流式期间 200ms 定时滚底兜底,仅当用户处于底部时才跟随
* (用户上翻查看历史时不打断)
* - 动态高度:Markdown/代码块/工具卡片高度变化由 Virtuoso 自动测量
*/
import { useEffect, useRef } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { Box, Typography } from '@mui/material';
import { useAgentStore } from '@renderer/stores/agent-store';
import { MessageItem } from './MessageItem';
@@ -11,114 +21,81 @@ import { StreamingIndicator } from './StreamingIndicator';
export function MessageList(): React.JSX.Element {
const messages = useAgentStore((s) => s.messages);
const isStreaming = useAgentStore((s) => s.isStreaming);
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentSessionId = useAgentStore((s) => s.currentSessionId);
// F2: 滚动节流优化
// 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发,
// 且 'smooth' 在长列表上触发主线程布局动画,加剧卡顿。
// 方案:
// - 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程)
// - 非流式时:立即 'smooth' 滚动(新消息发送/接收完成)
// - 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局
const lastScrollRef = useRef(0);
const trailingTimerRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
// 用户是否处于列表底部(离开底部=上翻查看历史,此时流式输出不强制拉底)
const atBottomRef = useRef(true);
// 流式期间的滚动跟随兜底(复查修复)
// followOutput 只在 data 数组追加时触发;流式 delta 增高最后一条消息时需主动跟随。
// 仅当用户在底部时执行 scrollToIndex,保证上翻浏览不被打断。
useEffect(() => {
const now = performance.now();
const elapsed = now - lastScrollRef.current;
// 调度一次 rAF 滚动(自动取消上一次挂起的 rAF)
const doScroll = (behavior: ScrollBehavior) => {
lastScrollRef.current = performance.now();
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
messagesEndRef.current?.scrollIntoView({ behavior });
});
};
// 非流式或首次进入:立即平滑滚动(新消息出现)
if (!isStreaming || lastScrollRef.current === 0) {
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
if (!isStreaming) return;
const timer = window.setInterval(() => {
if (atBottomRef.current) {
virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior: 'auto' });
}
doScroll('smooth');
return;
}
// 流式中:100ms 节流 + trailing 兜底
if (elapsed >= 100) {
// 已过节流窗口,立即执行
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
doScroll('auto');
} else if (trailingTimerRef.current === null) {
// 在节流窗口内,安排 trailing 滚动(保证最后一次 delta 后到底)
const remaining = 100 - elapsed;
trailingTimerRef.current = window.setTimeout(() => {
trailingTimerRef.current = null;
doScroll('auto');
}, remaining);
}
}, [messages, isStreaming]);
// 组件卸载时清理所有挂起的 timer 和 rAF
useEffect(() => {
return () => {
if (trailingTimerRef.current !== null) {
clearTimeout(trailingTimerRef.current);
trailingTimerRef.current = null;
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, []);
}, 200);
return () => window.clearInterval(timer);
}, [isStreaming]);
if (messages.length === 0 && !isStreaming) {
return (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ textAlign: 'center', animation: 'fadeIn 200ms ease-out' }}>
<Box component="img" src="./logo.png" alt="Metona" sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }} />
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>MetonaAI Desktop</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}> AI Agent </Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>Agent · Provider </Typography>
<Box
component="img"
src="./logo.png"
alt="Metona"
sx={{ width: 80, height: 80, mx: 'auto', mb: 2.5, borderRadius: 2 }}
/>
<Typography variant="h5" sx={{ color: 'text.primary', mb: 0.5, fontWeight: 700 }}>
MetonaAI Desktop
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
AI Agent
</Typography>
<Typography variant="body2" sx={{ mt: 2, display: 'block', opacity: 0.6 }}>
Agent · Provider
</Typography>
</Box>
</Box>
);
}
return (
// F12: GPU 加速 — transform: translateZ(0) 将滚动容器提升为合成层
// 滚动时由合成器线程处理,避免主线程重绘叠加卡顿
// 风险评估:已确认 chat 目录内无 position: fixed 元素;
// ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3, transform: 'translateZ(0)' }}>
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
{messages.map((msg, i) => (
// F4: content-visibility 准虚拟滚动
// 浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。
// 配合 F1 memo(跳过 re-render)+ F3 流式纯文本,历史消息开销降至最低。
// contain-intrinsic-size 提供估算高度,避免滚动时高度抖动。
// 注:如后续需真正虚拟滚动(移除 DOM 节点),可升级到 react-virtuoso。
<Box
key={msg.id}
sx={{
'content-visibility': 'auto',
'contain-intrinsic-size': 'auto 300px',
}}
>
<MessageItem message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
<Virtuoso
ref={virtuosoRef}
// key=sessionId: 切换会话时强制重新挂载,使 initialTopMostItemIndex 重新生效
// (定位到新会话的最后一条消息;同会话内 messages 变化不触发 remount
key={currentSessionId ?? 'no-session'}
style={{ flex: 1, minHeight: 0 }}
data={messages}
// 初始定位到最后一条(切换会话加载历史时直接到最新消息)
initialTopMostItemIndex={Math.max(0, messages.length - 1)}
// 新消息追加时跟随(流式中同步,非流式平滑)
followOutput={isStreaming ? 'auto' : 'smooth'}
// 跟踪底部状态:供流式跟随兜底定时器判断(上翻时暂停跟随)
atBottomStateChange={(atBottom) => {
atBottomRef.current = atBottom;
}}
itemContent={(index, msg) => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pt: index === 0 ? 3 : 1.5, pb: 1.5 }}>
<MessageItem
message={msg}
isLast={index === messages.length - 1}
isStreaming={isStreaming}
/>
</Box>
)}
components={{
Footer: () => (
<Box sx={{ maxWidth: 768, mx: 'auto', px: 2, pb: 3 }}>
<StreamingIndicator />
</Box>
))}
<StreamingIndicator />
<div ref={messagesEndRef} />
</Box>
</Box>
),
}}
/>
);
}