2 Commits
Author SHA1 Message Date
thzxx 8973ea6d47 feat: 升级至 v0.3.11 — 流式输出性能优化 12 项,解决长内容卡死
针对 AI 流式输出长内容时应用卡死问题,实施 12 项性能优化:

渲染层优化(F1/F3/F7):
- F1: React.memo 包裹 MessageItem/AssistantMessage,跳过历史消息重渲染
- F3: 流式时用纯文本渲染,避免 ReactMarkdown 全量重解析 O(n²)
- F7: useMemo 缓存 ReactMarkdown 元素,非流式时复用实例

滚动与布局(F2/F4/F10/F12):
- F2: 滚动节流 rAF + 100ms throttle + trailing 兜底,避免高频布局
- F4: CSS content-visibility 准虚拟滚动,跳过不可见区域布局
- F10: CSS contain 隔离 markdown 布局计算
- F12: 滚动容器 transform: translateZ(0) GPU 加速

状态与 IPC 批处理(F5/F8/F9):
- F5: 渲染进程 text_delta rAF 批处理,合并多次 store 写入
- F8: 主进程 text_delta 32ms 节流合并,降低 IPC 频率
- F9: reasoning_delta traceSteps thought 走 rAF 批处理

代码清理(F11):
- F11: 移除 rehypeRaw,节省 raw HTML 解析开销 + 防 AI 输出注入

验证:tsc --noEmit 通过,flush 逻辑在 done/error/cleanup 三处正确执行。
2026-07-21 14:38:27 +08:00
thzxx cd681b949a feat: 升级至 v0.3.10 — LLM 配置批量保存 + workspace.path 失败感知 + provider 切换防御性修复
- 新增 config:setBatch IPC:批量写入 8 个 LLM 字段后统一 reloadAdapter,
  解决设置页串行 config:set 在中间态触发"LLM 配置不完整"错误的问题
- SettingsModal.handleSave 和 OnboardingWizard.handleNext 改用 setBatch
- workspace.path 写入独立文件失败时返回 success:false(config:set 和 setBatch 一致),
  避免用户误以为保存成功但下次启动仍使用旧路径
- setBatch 将 provider 切换清空 apiKey 的逻辑移到循环前执行,
  消除对 entries 中 llm.provider 必须出现在 llm.apiKey 之前的隐含顺序依赖
2026-07-21 13:14:42 +08:00
13 changed files with 556 additions and 96 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
> 生产级通用 AI Agent 智能体桌面应用
[![Version](https://img.shields.io/badge/version-0.3.9-blue)](./package.json)
[![Version](https://img.shields.io/badge/version-0.3.11-blue)](./package.json)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/)
[![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/)
+234 -3
View File
@@ -163,10 +163,73 @@ export function registerAllIPCHandlers(
}
// 监听 Agent Loop 事件
const onStreamEvent = (event: MetonaStreamEvent) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('agent:streamEvent', event);
// F8: 主进程 text_delta 节流合并
// 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,
// 每次 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 }) => {
@@ -413,6 +476,11 @@ export function registerAllIPCHandlers(
return { success: false, error: (error as Error).message };
} finally {
// F8: 注销前 flush 残留的 text_delta 缓冲区,避免丢失最后一段内容
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTextDeltaBuffer();
}
agentLoop.off('streamEvent', onStreamEvent);
agentLoop.off('stateChange', onStateChange);
agentLoop.off('compressed', onCompressed);
@@ -756,6 +824,169 @@ export function registerAllIPCHandlers(
log.info(`[CONFIG] Workspace path saved (restart required): ${value}`);
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径
// 用户看到"配置已保存"却实际未生效,会误以为配置系统故障
return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` };
}
}
return { success: true };
});
// ===== v0.3.9: 批量保存配置(解决串行保存中间态触发 reloadAdapter 失败问题)=====
// 设计原因:前端设置页一次保存 8 个字段,串行 config:set 会在中间态(如 provider
// 已改但 apiKey 还没保存)触发 reloadAdapter,导致返回"LLM 配置不完整"错误。
// 批量保存:先写入所有字段,最后统一触发一次 reloadAdapter 和 Engine/Orchestrator 同步。
ipcMain.handle('config:setBatch', async (_event, entries: unknown) => {
// 参数校验:必须是 {key, value}[] 非空数组
if (!Array.isArray(entries) || entries.length === 0) {
return { success: false, error: 'Invalid entries: must be non-empty array of {key, value}' };
}
// 逐条校验每个元素的结构和类型
for (const entry of entries) {
if (!entry || typeof entry !== 'object') {
return { success: false, error: 'Invalid entry: must be {key, value} object' };
}
const e = entry as { key?: unknown; value?: unknown };
if (typeof e.key !== 'string' || !e.key.trim()) {
return { success: false, error: 'Invalid config key in batch' };
}
const v = e.value;
if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') {
return { success: false, error: `Invalid config value for key "${e.key}": must be string, number, boolean, or null` };
}
}
// 敏感字段脱敏工具(与 config:set 保持一致)
const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password'];
const maskSensitive = (key: string, value: unknown): unknown => {
const isSensitive = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p));
if (isSensitive && typeof value === 'string' && value.length > 0) {
return value.length > 4 ? '***' + value.slice(-4) : '***';
}
return value;
};
// LLM 相关字段集合(用于判断是否需要 reloadAdapter
const LLM_KEYS = ['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx',
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow'];
// 第一步:逐条写入 configService + 审计日志
// 不在此处触发 reloadAdapter,避免中间态失败
let needsReloadAdapter = false;
const engineUpdates: { key: string; value: unknown }[] = [];
let workspacePathValue: string | null = null;
let logLevelValue: 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null = null;
// v0.3.10 防御性修复: Provider 切换时清空 API key 的逻辑必须在循环前执行,
// 不能依赖 entries 中 llm.provider 出现在 llm.apiKey 之前。
// 旧实现:若前端误把 llm.apiKey 放在 llm.provider 之前,会先 set apiKey
// 随后处理 llm.provider 时清空 apiKey,导致用户填写的 apiKey 丢失。
// 新实现:先扫描 entries 找出 llm.provider 的值,与当前值比较,若变化则清空 apiKey,
// 然后循环按 entries 顺序 set(包括 llm.apiKey),最终值正确。
const providerEntry = (entries as Array<{ key: string; value: unknown }>)
.find((e) => e.key === 'llm.provider');
if (providerEntry) {
const oldProvider = configService.get<string>('llm.provider') ?? '';
const newProvider = (providerEntry.value as string) ?? '';
if (oldProvider && newProvider && oldProvider !== newProvider) {
configService.set('llm.apiKey', '');
log.info(`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared (batch mode)`);
}
}
for (const entry of entries) {
const { key, value } = entry as { key: string; value: unknown };
configService.set(key, value);
needsReloadAdapter = needsReloadAdapter || LLM_KEYS.includes(key);
// 收集 Engine/Orchestrator 相关更新(稍后统一应用)
if (key === 'agent.maxIterations' || key === 'agent.totalTimeoutMs' ||
key === 'agent.enableThinking' || key === 'agent.thinkingEffort' ||
key === 'ollama.numCtx' || key === 'deepseek.contextWindow' ||
key === 'agnes.contextWindow' || key === 'mimo.contextWindow' ||
key === 'agent.toolExecutionTimeoutMs' || key === 'agent.confirmationTimeoutMs') {
engineUpdates.push({ key, value });
}
if (key === 'workspace.path' && typeof value === 'string') {
workspacePathValue = value;
}
if (key === 'logging.level' && typeof value === 'string') {
logLevelValue = value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
}
// 审计日志(脱敏)
auditService.log({
sessionId: '',
eventType: 'config_change',
actor: 'user',
target: key,
details: { value: maskSensitive(key, value) },
outcome: 'success',
});
}
// 第二步:统一触发一次 reloadAdapter(仅在 LLM 配置变更时)
if (needsReloadAdapter) {
const reloadSuccess = reloadAdapter();
if (!reloadSuccess) {
log.warn('[CONFIG] Adapter reload failed after batch config save');
return { success: false, error: 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写' };
} else {
log.info('[CONFIG] LLM config batch changed, adapter reloaded');
}
}
// 第三步:统一应用 Engine/Orchestrator 更新(取每个 key 的最后值)
// 注意:reloadAdapter 内部已重建 adapter 并同步 contextWindow,此处仅处理非 LLM 字段
// 的 Engine 配置(maxIterations/totalTimeoutMs/enableThinking 等)
for (const { key, value } of engineUpdates) {
if (key === 'agent.maxIterations') {
agentLoop.updateConfig({ maxIterations: value as number });
} else if (key === 'agent.totalTimeoutMs') {
agentLoop.updateConfig({ totalTimeoutMs: value as number });
} else if (key === 'agent.enableThinking') {
agentLoop.updateConfig({ thinkingEnabled: value as boolean });
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
} else if (key === 'agent.thinkingEffort') {
agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
} else if (key === 'ollama.numCtx') {
agentLoop.updateConfig({ contextLength: (value as number) || undefined });
orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined });
} else if (key === 'deepseek.contextWindow' || key === 'agnes.contextWindow' || key === 'mimo.contextWindow') {
const ctxWindow = (value as number) || undefined;
agentLoop.updateConfig({ contextWindow: ctxWindow });
orchestrator.updateDefaultConfig({ contextWindow: ctxWindow });
} else if (key === 'agent.confirmationTimeoutMs') {
confirmationHook.setConfirmationTimeout(value as number);
} else if (key === 'agent.toolExecutionTimeoutMs') {
agentLoop.updateConfig({ toolExecutionTimeoutMs: value as number });
}
}
if (engineUpdates.length > 0) {
log.info(`[CONFIG] Engine/Orchestrator updated with ${engineUpdates.length} config changes (batch mode)`);
}
// 第四步:日志级别即时应用
if (logLevelValue) {
log.transports.file.level = logLevelValue;
log.transports.console.level = logLevelValue;
log.info(`[CONFIG] Log level updated to ${logLevelValue}`);
}
// 第五步:工作空间路径写入独立文件(下次启动生效)
if (workspacePathValue) {
try {
const { writeWorkspacePathToFile } = await import('../main');
writeWorkspacePathToFile(workspacePathValue);
log.info(`[CONFIG] Workspace path saved (restart required): ${workspacePathValue}`);
} catch (err) {
log.error('[CONFIG] Failed to save workspace path:', err);
// v0.3.10: 工作空间路径写入失败必须告知用户,否则下次启动仍使用旧路径
return { success: false, error: `工作空间路径保存失败:${(err as Error).message}` };
}
}
+3
View File
@@ -121,6 +121,9 @@ const metonaAPI = {
config: {
get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: unknown) => ipcRenderer.invoke('config:set', key, value),
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) =>
ipcRenderer.invoke('config:setBatch', entries),
},
// ===== 应用工具 =====
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ai-desktop",
"version": "0.3.9",
"version": "0.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.3.9",
"version": "0.3.11",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.3.9",
"version": "0.3.11",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
+64 -17
View File
@@ -5,15 +5,20 @@
* 1. 💭 思考阶段 — ThoughtBlock 自动展开,流式追加 reasoning content
* 2. 🔧 工具调用 — ToolCallCard 展示调用和结果
* 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 { Bot, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
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 { useAgentStore } from '@renderer/stores/agent-store';
import { ThoughtBlock } from './ThoughtBlock';
@@ -27,11 +32,14 @@ interface AssistantMessageProps {
isStreaming?: boolean;
}
export function AssistantMessage({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
function AssistantMessageImpl({ message, isStreaming }: AssistantMessageProps): React.JSX.Element {
// 直接使用 message.content — updateLastAssistantMessage 已实时更新此字段
const content = message.content;
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 });
@@ -40,6 +48,29 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
const hasContent = !!content;
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 (
<Stack
direction="row"
@@ -109,23 +140,32 @@ export function AssistantMessage({ message, isStreaming }: AssistantMessageProps
)}
{hasContent ? (
<Box className="prose-metona">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
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} />;
},
isStreaming ? (
// F3: 流式时用纯文本渲染,避免 ReactMarkdown 对长内容重新解析导致卡顿。
// 根因:每个 delta 触发 ReactMarkdown 全量重新解析 + rehypeHighlight 高亮,
// 内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死。
// 流结束后自动切换到 ReactMarkdown 一次性渲染(一次性 O(n),可接受)。
<Box
component="pre"
className="prose-metona prose-streaming"
sx={{
margin: 0,
padding: 0,
fontFamily: 'inherit',
fontSize: 14,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
color: 'text.primary',
}}
>
{content}
</ReactMarkdown>
</Box>
) : (
// F7: 使用 useMemo 缓存的 ReactMarkdown 元素
markdownElement
)
) : isStreaming ? (
<Typography variant="body2" sx={{ color: 'text.disabled', fontStyle: 'italic', py: 1 }}>
{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 {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
+14 -1
View File
@@ -2,8 +2,13 @@
* MessageItem — 单条消息路由组件
*
* 根据消息 role 路由到对应的子组件渲染。
*
* F1 性能优化: 使用 React.memo 包裹,避免流式 delta 触发历史消息重渲染。
* 浅比较策略:message 引用变化(store 仅替换最后一条)或 isStreaming 变化时才重渲染。
* 历史消息的 message 引用在 delta 流中保持不变,因此可跳过 re-render。
*/
import { memo } from 'react';
import { Box, Typography, Stack } from '@mui/material';
import { Terminal } from 'lucide-react';
import type { ChatMessage } from '@renderer/stores/agent-store';
@@ -18,7 +23,7 @@ interface MessageItemProps {
isStreaming?: boolean;
}
export function MessageItem({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element {
switch (message.role) {
case 'user':
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) */
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
return (
+84 -8
View File
@@ -13,13 +13,72 @@ export function MessageList(): React.JSX.Element {
const isStreaming = useAgentStore((s) => s.isStreaming);
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(() => {
const timer = setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 80);
return () => clearTimeout(timer);
}, [messages]);
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;
}
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) {
return (
@@ -35,10 +94,27 @@ export function MessageList(): React.JSX.Element {
}
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 }}>
{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 />
<div ref={messagesEndRef} />
+19 -17
View File
@@ -26,23 +26,25 @@ export function OnboardingWizard(): React.JSX.Element | null {
const handleNext = async () => {
if (step < STEPS.length - 1) { setStep(step + 1); return; }
try {
if (window.metona?.config) {
const configSets: Promise<unknown>[] = [];
if (provider.trim()) configSets.push(window.metona.config.set('llm.provider', provider.trim()));
if (baseURL.trim()) configSets.push(window.metona.config.set('llm.baseURL', baseURL.trim()));
if (model.trim()) configSets.push(window.metona.config.set('llm.model', model.trim()));
if (apiKey.trim()) configSets.push(window.metona.config.set('llm.apiKey', apiKey.trim()));
if (workspacePath.trim()) configSets.push(window.metona.config.set('workspace.path', workspacePath.trim()));
configSets.push(window.metona.config.set('onboarding.completed', true));
// M-26 修复: 改用 Promise.allSettled,单个配置写入失败不阻止 onboarding 完成
// 但 onboarding.completed 必须成功,否则用户重启后仍会看到引导
const results = await Promise.allSettled(configSets);
const failedCount = results.filter((r) => r.status === 'rejected').length;
if (failedCount > 0) {
console.error('[OnboardingWizard]', `${failedCount} config(s) failed to save`);
// 用户主动操作失败必须有反馈,否则按钮看起来无响应
import('metona-toast').then((mod) => mod.default.error(`部分配置保存失败(${failedCount} 项),请重试`)).catch(() => {});
// 不调用 setOnboardingCompleted(true),让用户重试
if (window.metona?.config?.setBatch) {
// v0.3.9: 改用批量保存,避免并行 config.set 中间态触发 reloadAdapter 失败
// 旧实现问题:Promise.allSettled 并行 6 个 config.set
// - llm.provider 写入会清空 llm.apiKey(与 llm.apiKey 写入竞态)
// - reloadAdapter 被调用 4 次,中间态必然失败
// - 代码只检查 status === 'rejected',完全忽略 { success: false } 的情况
// - 实际配置失败但前端显示成功并关闭向导
const entries: Array<{ key: string; value: unknown }> = [];
if (provider.trim()) entries.push({ key: 'llm.provider', value: provider.trim() });
if (baseURL.trim()) entries.push({ key: 'llm.baseURL', value: baseURL.trim() });
if (model.trim()) entries.push({ key: 'llm.model', value: model.trim() });
if (apiKey.trim()) entries.push({ key: 'llm.apiKey', value: apiKey.trim() });
if (workspacePath.trim()) entries.push({ key: 'workspace.path', value: workspacePath.trim() });
entries.push({ key: 'onboarding.completed', value: true });
const r = await window.metona.config.setBatch(entries);
if (r && !r.success) {
console.error('[OnboardingWizard]', 'Batch config save failed:', r.error);
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败,请重试')).catch(() => {});
return;
}
useAgentStore.getState().setProvider(provider.trim() || 'deepseek', model.trim() || '');
+19 -25
View File
@@ -440,35 +440,29 @@ function LLMSettings() {
}
setSaving(true);
try {
const setConfig = window.metona?.config?.set;
if (!setConfig) {
const setBatch = window.metona?.config?.setBatch;
if (!setBatch) {
import('metona-toast').then((mod) => mod.default.error('配置 API 不可用')).catch(() => {});
return;
}
// 串行保存:避免并发 IPC 调用(reloadAdapter 内部 lastConfigSig 比较会跳过中间态的重载)
const fields: Array<[string, unknown]> = [
['llm.provider', provider],
['llm.model', model],
['llm.apiKey', apiKey],
['llm.baseURL', baseURL],
['ollama.numCtx', numCtx],
['deepseek.contextWindow', dsCtxWindow],
['agnes.contextWindow', agnesCtxWindow],
['mimo.contextWindow', mimoCtxWindow],
// v0.3.9: 批量保存,避免串行保存中间态触发 reloadAdapter 失败
// 旧实现:串行 config:set 8 次,provider 切换后第 1 步会清空 apiKey,
// 此时 reloadAdapter 读到空 apiKey 返回 false,前端 toast 报"配置不全"
// 但所有字段实际已写入,第二次点保存才显示"已保存"。
// 新实现:一次性传所有字段,后端先写入全部,最后统一 reloadAdapter 一次。
const entries: Array<{ key: string; value: unknown }> = [
{ key: 'llm.provider', value: provider },
{ key: 'llm.model', value: model },
{ key: 'llm.apiKey', value: apiKey },
{ key: 'llm.baseURL', value: baseURL },
{ key: 'ollama.numCtx', value: numCtx },
{ key: 'deepseek.contextWindow', value: dsCtxWindow },
{ key: 'agnes.contextWindow', value: agnesCtxWindow },
{ key: 'mimo.contextWindow', value: mimoCtxWindow },
];
let firstError: string | null = null;
for (const [key, value] of fields) {
try {
const r = await setConfig(key, value);
if (r && !r.success && !firstError) {
firstError = r.error ?? '配置保存失败';
}
} catch (err) {
if (!firstError) firstError = (err as Error).message;
}
}
if (firstError) {
import('metona-toast').then((mod) => mod.default.error(firstError!)).catch(() => {});
const r = await setBatch(entries);
if (r && !r.success) {
import('metona-toast').then((mod) => mod.default.error(r.error ?? '配置保存失败')).catch(() => {});
} else {
import('metona-toast').then((mod) => mod.default.success('配置已保存')).catch(() => {});
}
+108 -20
View File
@@ -25,6 +25,66 @@ export function useAgentStream(): void {
useEffect(() => {
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 data = event as {
type?: string;
@@ -121,13 +181,11 @@ export function useAgentStream(): void {
});
}
// 同步更新当前 Trace 步骤的 thought 字段
const traceSteps = getStore().traceSteps;
const curStep = traceSteps[traceSteps.length - 1];
if (curStep) {
getStore().updateLastTraceStep({
thought: (curStep.thought ?? '') + data.delta,
});
// F9: traceSteps thought 更新走 rAF 批处理(减少 TraceViewer re-render 频率)
// message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示)
traceThoughtBuffer += data.delta;
if (traceRafId === null) {
traceRafId = requestAnimationFrame(flushTraceThought);
}
}
break;
@@ -142,6 +200,11 @@ export function useAgentStream(): void {
const needsNewCard = !last || last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
getStore().addMessage({
id: genMsgId('assistant'),
role: 'assistant',
@@ -155,19 +218,14 @@ export function useAgentStream(): void {
break;
}
}
// isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置
getStore().updateLastAssistantMessage(data.delta);
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
// 当 assistant 消息无 reasoningContent 时,持续累积 text delta 到 thought
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
const steps = getStore().traceSteps;
const step = steps[steps.length - 1];
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
getStore().updateLastTraceStep({
thought: (step.thought ?? '') + data.delta,
});
// F5: 累积 delta 到缓冲区,用 rAF 每帧 commit 一次
// 首次缓冲时记录 sessionId(用于会话切换保护)
if (textDeltaBuffer === '') {
textBufferSessionId = data.sessionId;
}
textDeltaBuffer += data.delta;
if (textRafId === null) {
textRafId = requestAnimationFrame(flushTextDelta);
}
}
break;
@@ -305,6 +363,16 @@ export function useAgentStream(): void {
// 流结束
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().setCurrentRunId(null);
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
@@ -318,6 +386,16 @@ export function useAgentStream(): void {
// 错误
case 'error':
// F5: 错误前立即 flush 缓冲区,保留已接收的内容
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
// F9: flush traceThought 缓冲区,保留已接收的 reasoning 内容
if (traceRafId !== null) {
cancelAnimationFrame(traceRafId);
flushTraceThought();
}
getStore().setStreaming(false);
getStore().setCurrentRunId(null);
getStore().setAgentStatus('error');
@@ -335,6 +413,16 @@ export function useAgentStream(): void {
cleanupRef.current = unsubscribe;
return () => {
// F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容)
if (textRafId !== null) {
cancelAnimationFrame(textRafId);
flushTextDelta();
}
// F9: 清理 traceThought 的 rAF
if (traceRafId !== null) {
cancelAnimationFrame(traceRafId);
flushTraceThought();
}
cleanupRef.current?.();
};
}, []);
+4
View File
@@ -187,6 +187,10 @@ code, pre, .font-mono { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', mo
color: inherit;
line-height: 1.7;
font-size: 14px;
/* F10: CSS containment 隔离布局计算
contain: layout style 隔离 markdown 渲染区域的布局和样式计算,
避免 reflow 扩散到外部容器。不含 paint 以避免裁剪代码块横向滚动溢出。 */
contain: layout style;
}
.prose-metona h1,
+2
View File
@@ -162,6 +162,8 @@ interface MetonaMemoryAPI {
interface MetonaConfigAPI {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<{ success: boolean }>;
// v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败
setBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean }>;
}
// ===== App API =====