Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8973ea6d47 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
|
|||||||
@@ -163,10 +163,73 @@ export function registerAllIPCHandlers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 监听 Agent Loop 事件
|
// 监听 Agent Loop 事件
|
||||||
const onStreamEvent = (event: MetonaStreamEvent) => {
|
// F8: 主进程 text_delta 节流合并
|
||||||
if (!mainWindow.isDestroyed()) {
|
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
|
||||||
mainWindow.webContents.send('agent:streamEvent', event);
|
// 每次 IPC 调用都有跨进程开销,叠加渲染进程处理成本。
|
||||||
|
// 方案:在主进程聚合 text_delta,32ms 间隔合并转发(IPC 频率降至 ~30 次/秒)。
|
||||||
|
// 非 text_delta 事件立即转发(先 flush 缓冲区保证顺序)。
|
||||||
|
// done/error 时立即 flush,避免最后一段 delta 丢失。
|
||||||
|
// 与 F5 渲染进程 rAF 批处理叠加,总延迟约 48ms(人眼不敏感)。
|
||||||
|
let textDeltaBuffer = '';
|
||||||
|
let lastTextEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null = null;
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flushTextDeltaBuffer = () => {
|
||||||
|
flushTimer = null;
|
||||||
|
if (!textDeltaBuffer || !lastTextEventMeta || mainWindow.isDestroyed()) {
|
||||||
|
textDeltaBuffer = '';
|
||||||
|
lastTextEventMeta = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 构建合并的 text_delta 事件,保留最后一个 delta 的元数据
|
||||||
|
const mergedEvent: MetonaStreamEvent = {
|
||||||
|
...lastTextEventMeta,
|
||||||
|
type: MetonaStreamEventType.TEXT_DELTA,
|
||||||
|
delta: textDeltaBuffer,
|
||||||
|
seq: lastTextEventMeta.seq, // 使用最后一个 delta 的 seq
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
mainWindow.webContents.send('agent:streamEvent', mergedEvent);
|
||||||
|
textDeltaBuffer = '';
|
||||||
|
lastTextEventMeta = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStreamEvent = (event: MetonaStreamEvent) => {
|
||||||
|
if (mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
|
||||||
|
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
|
||||||
|
if (textDeltaBuffer === '') {
|
||||||
|
lastTextEventMeta = {
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
iteration: event.iteration,
|
||||||
|
seq: event.seq,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
runId: event.runId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 更新 seq 为最新 delta 的 seq(保证前端 seq 连续性判断)
|
||||||
|
lastTextEventMeta = {
|
||||||
|
...lastTextEventMeta!,
|
||||||
|
seq: event.seq,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
runId: event.runId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
textDeltaBuffer += event.delta;
|
||||||
|
if (flushTimer === null) {
|
||||||
|
flushTimer = setTimeout(flushTextDeltaBuffer, 32);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 text_delta 事件:先 flush 缓冲区,再立即转发(保证事件顺序)
|
||||||
|
if (flushTimer !== null) {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTextDeltaBuffer();
|
||||||
|
}
|
||||||
|
mainWindow.webContents.send('agent:streamEvent', event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => {
|
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => {
|
||||||
@@ -413,6 +476,11 @@ export function registerAllIPCHandlers(
|
|||||||
|
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
} finally {
|
} finally {
|
||||||
|
// F8: 注销前 flush 残留的 text_delta 缓冲区,避免丢失最后一段内容
|
||||||
|
if (flushTimer !== null) {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTextDeltaBuffer();
|
||||||
|
}
|
||||||
agentLoop.off('streamEvent', onStreamEvent);
|
agentLoop.off('streamEvent', onStreamEvent);
|
||||||
agentLoop.off('stateChange', onStateChange);
|
agentLoop.off('stateChange', onStateChange);
|
||||||
agentLoop.off('compressed', onCompressed);
|
agentLoop.off('compressed', onCompressed);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.10",
|
"version": "0.3.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.10",
|
"version": "0.3.11",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.10",
|
"version": "0.3.11",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -5,15 +5,20 @@
|
|||||||
* 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content
|
* 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content
|
||||||
* 2. 🔧 工具调用 — ToolCallCard 展示调用和结果
|
* 2. 🔧 工具调用 — ToolCallCard 展示调用和结果
|
||||||
* 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出
|
* 3. ✏️ 回复阶段 — Markdown 渲染,打字光标指示流式输出
|
||||||
|
*
|
||||||
|
* F1 性能优化: 使用 React.memo 包裹,避免历史消息在流式 delta 时重渲染。
|
||||||
|
* 配合 agentStatus 选择器优化:历史消息(isStreaming=false)不订阅真实 agentStatus,
|
||||||
|
* 避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, memo, useMemo } from 'react';
|
||||||
import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material';
|
import { Box, Typography, Avatar, Stack, IconButton, Tooltip } from '@mui/material';
|
||||||
import { Bot, Copy, Check } from 'lucide-react';
|
import { Bot, Copy, Check } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeHighlight from 'rehype-highlight';
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import rehypeRaw from 'rehype-raw';
|
// F11: 移除 rehypeRaw — 避免解析 raw HTML 的开销 + 安全考虑(防止 AI 输出 HTML 注入)
|
||||||
|
// 如需恢复内联 HTML 渲染,可重新引入 rehype-raw
|
||||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||||
import { useAgentStore } from '@renderer/stores/agent-store';
|
import { useAgentStore } from '@renderer/stores/agent-store';
|
||||||
import { ThoughtBlock } from './ThoughtBlock';
|
import { ThoughtBlock } from './ThoughtBlock';
|
||||||
@@ -27,11 +32,14 @@ interface AssistantMessageProps {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
|
function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
|
||||||
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
|
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
|
||||||
const content = message.content;
|
const content = message.content;
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const agentStatus = useAgentStore((s) => s.agentStatus);
|
// F1: 历史消息(isStreaming=false)固定返回 'idle',避免 agentStatus 变化触发重渲染
|
||||||
|
// 逻辑等价:原 isThinking = agentStatus === 'thinking' && isStreaming
|
||||||
|
// 新 isThinking = agentStatus === 'thinking'(agentStatus 在非流式时恒为 'idle')
|
||||||
|
const agentStatus = useAgentStore((s) => (isStreaming ? s.agentStatus : 'idle'));
|
||||||
|
|
||||||
const contextMenuItems = createContextMenuItems('message', { content });
|
const contextMenuItems = createContextMenuItems('message', { content });
|
||||||
|
|
||||||
@@ -40,6 +48,29 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
|
|||||||
const hasContent = !!content;
|
const hasContent = !!content;
|
||||||
const isThinking = agentStatus === 'thinking' && isStreaming;
|
const isThinking = agentStatus === 'thinking' && isStreaming;
|
||||||
|
|
||||||
|
// F7: useMemo 缓存 ReactMarkdown 元素,避免非流式时因 isStreaming/isLast 变化重新创建元素
|
||||||
|
// 流式时此元素不被渲染(用纯文本),useMemo 仍会更新但仅创建轻量 React 元素对象
|
||||||
|
// 非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例
|
||||||
|
const markdownElement = useMemo(() => (
|
||||||
|
<Box className="prose-metona">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeHighlight]}
|
||||||
|
components={{
|
||||||
|
code({ className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className ?? '');
|
||||||
|
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
|
||||||
|
const codeStr = extractTextContent(children).replace(/\n$/, '');
|
||||||
|
if (!match) return <code className={className} {...props}>{children}</code>;
|
||||||
|
return <CodeBlock language={match[1]} code={codeStr} />;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
), [content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
@@ -109,23 +140,32 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasContent ? (
|
{hasContent ? (
|
||||||
<Box className="prose-metona">
|
isStreaming ? (
|
||||||
<ReactMarkdown
|
// F3: 流式时用纯文本渲染,避免 ReactMarkdown 对长内容重新解析导致卡顿。
|
||||||
remarkPlugins={[remarkGfm]}
|
// 根因:每个 delta 触发 ReactMarkdown 全量重新解析 + rehypeHighlight 高亮,
|
||||||
rehypePlugins={[rehypeHighlight, rehypeRaw]}
|
// 内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死。
|
||||||
components={{
|
// 流结束后自动切换到 ReactMarkdown 一次性渲染(一次性 O(n),可接受)。
|
||||||
code({ className, children, ...props }) {
|
<Box
|
||||||
const match = /language-(\w+)/.exec(className ?? '');
|
component="pre"
|
||||||
// rehype-highlight 会把代码替换为 <span> 高亮元素,需提取纯文本
|
className="prose-metona prose-streaming"
|
||||||
const codeStr = extractTextContent(children).replace(/\n$/, '');
|
sx={{
|
||||||
if (!match) return <code className={className} {...props}>{children}</code>;
|
margin: 0,
|
||||||
return <CodeBlock language={match[1]} code={codeStr} />;
|
padding: 0,
|
||||||
},
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
color: 'text.primary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</Box>
|
||||||
</Box>
|
) : (
|
||||||
|
// F7: 使用 useMemo 缓存的 ReactMarkdown 元素
|
||||||
|
markdownElement
|
||||||
|
)
|
||||||
) : isStreaming ? (
|
) : isStreaming ? (
|
||||||
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
|
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
|
||||||
{isThinking ? '正在思考...' : '正在生成回复...'}
|
{isThinking ? '正在思考...' : '正在生成回复...'}
|
||||||
@@ -160,6 +200,13 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F1: memo 包裹 AssistantMessage。
|
||||||
|
* - 流式 delta 时仅最后一条 message 引用变化,历史消息跳过 re-render
|
||||||
|
* - agentStatus 订阅已优化(非流式时返回 'idle'),历史消息不受 agentStatus 变化影响
|
||||||
|
*/
|
||||||
|
export const AssistantMessage = memo(AssistantMessageImpl);
|
||||||
|
|
||||||
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
|
function CodeBlock({ language, code }: { language: string; code: string }): React.JSX.Element {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
* MessageItem — 单条消息路由组件
|
* MessageItem — 单条消息路由组件
|
||||||
*
|
*
|
||||||
* 根据消息 role 路由到对应的子组件渲染。
|
* 根据消息 role 路由到对应的子组件渲染。
|
||||||
|
*
|
||||||
|
* F1 性能优化: 使用 React.memo 包裹,避免流式 delta 触发历史消息重渲染。
|
||||||
|
* 浅比较策略:message 引用变化(store 仅替换最后一条)或 isStreaming 变化时才重渲染。
|
||||||
|
* 历史消息的 message 引用在 delta 流中保持不变,因此可跳过 re-render。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
import { Box, Typography, Stack } from '@mui/material';
|
import { Box, Typography, Stack } from '@mui/material';
|
||||||
import { Terminal } from 'lucide-react';
|
import { Terminal } from 'lucide-react';
|
||||||
import type { ChatMessage } from '@renderer/stores/agent-store';
|
import type { ChatMessage } from '@renderer/stores/agent-store';
|
||||||
@@ -18,7 +23,7 @@ interface MessageItemProps {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
|
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
|
||||||
switch (message.role) {
|
switch (message.role) {
|
||||||
case 'user':
|
case 'user':
|
||||||
return <UserMessage message={message} />;
|
return <UserMessage message={message} />;
|
||||||
@@ -41,6 +46,14 @@ export function MessageItem({ message, isLast, isStreaming }: MessageItemProps):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F1: memo 默认浅比较 props。
|
||||||
|
* - message 是对象引用,store 更新最后一条时只替换最后一条,历史消息引用不变 → 跳过 re-render
|
||||||
|
* - isStreaming 是 boolean,流式过程中值不变(持续 true),流结束才变化一次 → 历史消息仅重渲染一次
|
||||||
|
* - isLast 是 boolean,仅在最后一条切换时变化
|
||||||
|
*/
|
||||||
|
export const MessageItem = memo(MessageItemImpl);
|
||||||
|
|
||||||
/** ToolMessage — 独立的工具结果消息(role=tool) */
|
/** ToolMessage — 独立的工具结果消息(role=tool) */
|
||||||
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,13 +13,72 @@ export function MessageList(): React.JSX.Element {
|
|||||||
const isStreaming = useAgentStore((s) => s.isStreaming);
|
const isStreaming = useAgentStore((s) => s.isStreaming);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 流式输出时高频触发,使用 throttle 避免滚动卡顿
|
// 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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const now = performance.now();
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
const elapsed = now - lastScrollRef.current;
|
||||||
}, 80);
|
|
||||||
return () => clearTimeout(timer);
|
// 调度一次 rAF 滚动(自动取消上一次挂起的 rAF)
|
||||||
}, [messages]);
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (messages.length === 0 && !isStreaming) {
|
if (messages.length === 0 && !isStreaming) {
|
||||||
return (
|
return (
|
||||||
@@ -35,10 +94,27 @@ export function MessageList(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 3 }}>
|
// 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 }}>
|
<Box sx={{ maxWidth: 768, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<MessageItem key={msg.id} message={msg} isLast={i === messages.length - 1} isStreaming={isStreaming} />
|
// 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} />
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
<StreamingIndicator />
|
<StreamingIndicator />
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
|||||||
+108
-20
@@ -25,6 +25,66 @@ export function useAgentStream(): void {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.metona?.agent?.onStreamEvent) return;
|
if (!window.metona?.agent?.onStreamEvent) return;
|
||||||
|
|
||||||
|
// F5: text_delta rAF 批处理
|
||||||
|
// 问题:每个 text_delta 直接调用 updateLastAssistantMessage + updateLastTraceStep,
|
||||||
|
// 频率 30-50 次/秒,每次触发 store 更新 + React re-render。
|
||||||
|
// 方案:累积 delta 到缓冲区,用 rAF 每帧 commit 一次,合并多次 store 写入。
|
||||||
|
// done/error 时立即 flush,避免最后一段 delta 丢失。
|
||||||
|
// 新迭代卡片创建逻辑(needsNewCard)立即处理,不缓冲。
|
||||||
|
let textDeltaBuffer = '';
|
||||||
|
let textBufferSessionId: string | undefined;
|
||||||
|
let textRafId: number | null = null;
|
||||||
|
|
||||||
|
const flushTextDelta = () => {
|
||||||
|
textRafId = null;
|
||||||
|
if (!textDeltaBuffer) return;
|
||||||
|
const delta = textDeltaBuffer;
|
||||||
|
const bufferedSessionId = textBufferSessionId;
|
||||||
|
textDeltaBuffer = '';
|
||||||
|
textBufferSessionId = undefined;
|
||||||
|
|
||||||
|
const store = useAgentStore.getState();
|
||||||
|
// 会话切换保护:如果缓冲时的会话与当前会话不一致,丢弃(避免跨会话污染)
|
||||||
|
if (bufferedSessionId && store.currentSessionId !== bufferedSessionId) return;
|
||||||
|
|
||||||
|
store.updateLastAssistantMessage(delta);
|
||||||
|
|
||||||
|
// 无 reasoning 模式下,将文本内容也记入 Trace thought
|
||||||
|
const messages = store.messages;
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
const steps = store.traceSteps;
|
||||||
|
const step = steps[steps.length - 1];
|
||||||
|
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
|
||||||
|
store.updateLastTraceStep({
|
||||||
|
thought: (step.thought ?? '') + delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// F9: reasoning_delta 的 traceSteps thought 更新走 rAF 批处理
|
||||||
|
// 问题:reasoning_delta 每个 delta 调用 updateLastTraceStep,频率 10-30 次/秒,
|
||||||
|
// 触发 TraceViewer 等订阅者 re-render(即使不可见也有 selector 调用开销)。
|
||||||
|
// 方案:累积 reasoning delta 到缓冲区,rAF 每帧 commit 一次。
|
||||||
|
// message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示思考内容)。
|
||||||
|
// 新迭代卡片创建时的 thought 更新保持即时(新卡片需立即显示)。
|
||||||
|
let traceThoughtBuffer = '';
|
||||||
|
let traceRafId: number | null = null;
|
||||||
|
|
||||||
|
const flushTraceThought = () => {
|
||||||
|
traceRafId = null;
|
||||||
|
if (!traceThoughtBuffer) return;
|
||||||
|
const delta = traceThoughtBuffer;
|
||||||
|
traceThoughtBuffer = '';
|
||||||
|
const store = useAgentStore.getState();
|
||||||
|
const steps = store.traceSteps;
|
||||||
|
const step = steps[steps.length - 1];
|
||||||
|
if (step) {
|
||||||
|
store.updateLastTraceStep({
|
||||||
|
thought: (step.thought ?? '') + delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
|
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
|
||||||
const data = event as {
|
const data = event as {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -121,13 +181,11 @@ export function useAgentStream(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步更新当前 Trace 步骤的 thought 字段
|
// F9: traceSteps thought 更新走 rAF 批处理(减少 TraceViewer re-render 频率)
|
||||||
const traceSteps = getStore().traceSteps;
|
// message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示)
|
||||||
const curStep = traceSteps[traceSteps.length - 1];
|
traceThoughtBuffer += data.delta;
|
||||||
if (curStep) {
|
if (traceRafId === null) {
|
||||||
getStore().updateLastTraceStep({
|
traceRafId = requestAnimationFrame(flushTraceThought);
|
||||||
thought: (curStep.thought ?? '') + data.delta,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -142,6 +200,11 @@ export function useAgentStream(): void {
|
|||||||
const needsNewCard = !last || last.role !== 'assistant' ||
|
const needsNewCard = !last || last.role !== 'assistant' ||
|
||||||
(last.iteration != null && last.iteration !== data.iteration);
|
(last.iteration != null && last.iteration !== data.iteration);
|
||||||
if (needsNewCard) {
|
if (needsNewCard) {
|
||||||
|
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
|
||||||
|
if (textRafId !== null) {
|
||||||
|
cancelAnimationFrame(textRafId);
|
||||||
|
flushTextDelta();
|
||||||
|
}
|
||||||
getStore().addMessage({
|
getStore().addMessage({
|
||||||
id: genMsgId('assistant'),
|
id: genMsgId('assistant'),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -155,19 +218,14 @@ export function useAgentStream(): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置
|
// F5: 累积 delta 到缓冲区,用 rAF 每帧 commit 一次
|
||||||
getStore().updateLastAssistantMessage(data.delta);
|
// 首次缓冲时记录 sessionId(用于会话切换保护)
|
||||||
|
if (textDeltaBuffer === '') {
|
||||||
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
|
textBufferSessionId = data.sessionId;
|
||||||
// 当 assistant 消息无 reasoningContent 时,持续累积 text delta 到 thought
|
}
|
||||||
const messages = getStore().messages;
|
textDeltaBuffer += data.delta;
|
||||||
const lastMsg = messages[messages.length - 1];
|
if (textRafId === null) {
|
||||||
const steps = getStore().traceSteps;
|
textRafId = requestAnimationFrame(flushTextDelta);
|
||||||
const step = steps[steps.length - 1];
|
|
||||||
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
|
|
||||||
getStore().updateLastTraceStep({
|
|
||||||
thought: (step.thought ?? '') + data.delta,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -305,6 +363,16 @@ export function useAgentStream(): void {
|
|||||||
|
|
||||||
// 流结束
|
// 流结束
|
||||||
case 'done':
|
case 'done':
|
||||||
|
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
|
||||||
|
if (textRafId !== null) {
|
||||||
|
cancelAnimationFrame(textRafId);
|
||||||
|
flushTextDelta();
|
||||||
|
}
|
||||||
|
// F9: flush traceThought 缓冲区,避免最后一段 reasoning delta 丢失
|
||||||
|
if (traceRafId !== null) {
|
||||||
|
cancelAnimationFrame(traceRafId);
|
||||||
|
flushTraceThought();
|
||||||
|
}
|
||||||
getStore().setStreaming(false);
|
getStore().setStreaming(false);
|
||||||
getStore().setCurrentRunId(null);
|
getStore().setCurrentRunId(null);
|
||||||
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
|
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
|
||||||
@@ -318,6 +386,16 @@ export function useAgentStream(): void {
|
|||||||
|
|
||||||
// 错误
|
// 错误
|
||||||
case 'error':
|
case 'error':
|
||||||
|
// F5: 错误前立即 flush 缓冲区,保留已接收的内容
|
||||||
|
if (textRafId !== null) {
|
||||||
|
cancelAnimationFrame(textRafId);
|
||||||
|
flushTextDelta();
|
||||||
|
}
|
||||||
|
// F9: flush traceThought 缓冲区,保留已接收的 reasoning 内容
|
||||||
|
if (traceRafId !== null) {
|
||||||
|
cancelAnimationFrame(traceRafId);
|
||||||
|
flushTraceThought();
|
||||||
|
}
|
||||||
getStore().setStreaming(false);
|
getStore().setStreaming(false);
|
||||||
getStore().setCurrentRunId(null);
|
getStore().setCurrentRunId(null);
|
||||||
getStore().setAgentStatus('error');
|
getStore().setAgentStatus('error');
|
||||||
@@ -335,6 +413,16 @@ export function useAgentStream(): void {
|
|||||||
cleanupRef.current = unsubscribe;
|
cleanupRef.current = unsubscribe;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
// F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容)
|
||||||
|
if (textRafId !== null) {
|
||||||
|
cancelAnimationFrame(textRafId);
|
||||||
|
flushTextDelta();
|
||||||
|
}
|
||||||
|
// F9: 清理 traceThought 的 rAF
|
||||||
|
if (traceRafId !== null) {
|
||||||
|
cancelAnimationFrame(traceRafId);
|
||||||
|
flushTraceThought();
|
||||||
|
}
|
||||||
cleanupRef.current?.();
|
cleanupRef.current?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -187,6 +187,10 @@ code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', mo
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
/* F10: CSS containment 隔离布局计算
|
||||||
|
contain: layout style 隔离 markdown 渲染区域的布局和样式计算,
|
||||||
|
避免 reflow 扩散到外部容器。不含 paint 以避免裁剪代码块横向滚动溢出。 */
|
||||||
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-metona h1,
|
.prose-metona h1,
|
||||||
|
|||||||
Reference in New Issue
Block a user