fix(web): 修复流式期间消息重渲染、WebSocket 无谓重连与新消息计数错误

- MessageBubble 用 React.memo 包裹,流式 delta 只追加到最后一条消息,
  其余消息引用不变可被 memo 跳过,避免 N-1 条历史消息无谓重渲染
- useWebSocket 用 useRef 存储回调最新引用,connect 不再依赖回调引用,
  避免调用方回调变化触发 disconnect+connect 重连
- MessageList 用 prevMessageCountRef 追踪消息条数变化,仅当真正新增
  消息条数时才累加 newMessageCount;流式 delta 不增加条数不再误计数,
  滑到底部按钮触发的清零路径与时序经审查覆盖各种边界场景
This commit is contained in:
oudecheng 2026-08-05 08:10:44 +08:00
parent 5eec77ae10
commit 4e74d66a7c
3 changed files with 35 additions and 10 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, memo } from 'react';
import { import {
User, User,
Bot, Bot,
@ -349,7 +349,7 @@ function parseTaskResult(content: string): TaskToolResult | null {
} }
} }
export function MessageBubble({ export const MessageBubble = memo(function MessageBubble({
message, message,
onNavigateToSubAgent, onNavigateToSubAgent,
showThinking = true, showThinking = true,
@ -964,4 +964,4 @@ export function MessageBubble({
{lightboxElement} {lightboxElement}
</div> </div>
); );
} });

View File

@ -28,6 +28,11 @@ export function MessageList({
const viewKeyRef = useRef(viewKey); const viewKeyRef = useRef(viewKey);
viewKeyRef.current = viewKey; viewKeyRef.current = viewKey;
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
// 不应计入 newMessageCount。
const prevMessageCountRef = useRef(messages.length);
// Per-view scroll position memory // Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map()); const scrollPositionsRef = useRef<Map<string, number>>(new Map());
@ -86,11 +91,13 @@ export function MessageList({
if (messages.length === 0) { if (messages.length === 0) {
isAtBottomRef.current = true; isAtBottomRef.current = true;
prevMessageCountRef.current = 0;
return; return;
} }
if (viewChanged) { if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position // View switched (e.g. breadcrumb navigation): restore saved scroll position
prevMessageCountRef.current = messages.length;
const key = viewKey ?? ''; const key = viewKey ?? '';
const savedPos = scrollPositionsRef.current.get(key); const savedPos = scrollPositionsRef.current.get(key);
if (savedPos !== undefined && containerRef.current) { if (savedPos !== undefined && containerRef.current) {
@ -108,11 +115,18 @@ export function MessageList({
// Same view, messages changed: normal auto-scroll logic // Same view, messages changed: normal auto-scroll logic
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
const newCount = messages.length - prevMessageCountRef.current;
prevMessageCountRef.current = messages.length;
if (lastMessage.role === 'user' || isAtBottomRef.current) { if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' }); bottomRef.current?.scrollIntoView({ behavior: 'instant' });
} else { // 用户自己发消息或已在底部时,不需要计数
setNewMessageCount((prev) => prev + 1); if (newCount > 0) {
setNewMessageCount(0);
}
} else if (newCount > 0) {
// 只有真正新增了消息条数时才累加(流式 delta 不增加条数,不计数)
setNewMessageCount((prev) => prev + newCount);
} }
}, [messages, viewKey]); }, [messages, viewKey]);

View File

@ -39,6 +39,17 @@ export function useWebSocket({
const isManualDisconnectRef = useRef(false); const isManualDisconnectRef = useRef(false);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 用 ref 存储回调的最新引用,避免回调变化导致 connect 重建 → useEffect 重跑 → 无谓断连重连。
// 调用方即使未 useCallback 包裹回调,也不会触发重连。
const onMessageRef = useRef(onMessage);
const onConnectRef = useRef(onConnect);
const onDisconnectRef = useRef(onDisconnect);
const onErrorRef = useRef(onError);
onMessageRef.current = onMessage;
onConnectRef.current = onConnect;
onDisconnectRef.current = onDisconnect;
onErrorRef.current = onError;
const clearPing = useCallback(() => { const clearPing = useCallback(() => {
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current); clearInterval(pingIntervalRef.current);
@ -73,13 +84,13 @@ export function useWebSocket({
} }
} }
}, PING_INTERVAL_MS); }, PING_INTERVAL_MS);
onConnect?.(); onConnectRef.current?.();
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data) as WsOutbound; const message = JSON.parse(event.data) as WsOutbound;
onMessage?.(message); onMessageRef.current?.(message);
} catch (error) { } catch (error) {
console.error('Failed to parse message:', error); console.error('Failed to parse message:', error);
} }
@ -87,13 +98,13 @@ export function useWebSocket({
ws.onerror = (error) => { ws.onerror = (error) => {
setStatus('error'); setStatus('error');
onError?.(error); onErrorRef.current?.(error);
}; };
ws.onclose = () => { ws.onclose = () => {
setStatus('disconnected'); setStatus('disconnected');
clearPing(); clearPing();
onDisconnect?.(); onDisconnectRef.current?.();
// 指数退避自动重连3s → 6s → 12s → 24s → 60s 封顶 // 指数退避自动重连3s → 6s → 12s → 24s → 60s 封顶
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
@ -109,7 +120,7 @@ export function useWebSocket({
setStatus('error'); setStatus('error');
console.error('WebSocket connection error:', error); console.error('WebSocket connection error:', error);
} }
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts, clearPing]); }, [url, reconnectInterval, maxReconnectAttempts, clearPing]);
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
isManualDisconnectRef.current = true; isManualDisconnectRef.current = true;