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:
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user