fix(web): 修复流式期间消息重渲染、WebSocket 无谓重连与新消息计数错误
- MessageBubble 用 React.memo 包裹,流式 delta 只追加到最后一条消息, 其余消息引用不变可被 memo 跳过,避免 N-1 条历史消息无谓重渲染 - useWebSocket 用 useRef 存储回调最新引用,connect 不再依赖回调引用, 避免调用方回调变化触发 disconnect+connect 重连 - MessageList 用 prevMessageCountRef 追踪消息条数变化,仅当真正新增 消息条数时才累加 newMessageCount;流式 delta 不增加条数不再误计数, 滑到底部按钮触发的清零路径与时序经审查覆盖各种边界场景
This commit is contained in:
parent
5eec77ae10
commit
4e74d66a7c
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, memo } from 'react';
|
||||
import {
|
||||
User,
|
||||
Bot,
|
||||
@ -349,7 +349,7 @@ function parseTaskResult(content: string): TaskToolResult | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
export const MessageBubble = memo(function MessageBubble({
|
||||
message,
|
||||
onNavigateToSubAgent,
|
||||
showThinking = true,
|
||||
@ -964,4 +964,4 @@ export function MessageBubble({
|
||||
{lightboxElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -28,6 +28,11 @@ export function MessageList({
|
||||
const viewKeyRef = useRef(viewKey);
|
||||
viewKeyRef.current = viewKey;
|
||||
|
||||
// 追踪上次的消息条数,用于计算真正新增的消息数(而非 messages 引用变化次数)。
|
||||
// 流式输出时每个 delta 都会产生新的 messages 数组引用,但消息条数不变,
|
||||
// 不应计入 newMessageCount。
|
||||
const prevMessageCountRef = useRef(messages.length);
|
||||
|
||||
// Per-view scroll position memory
|
||||
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
@ -86,11 +91,13 @@ export function MessageList({
|
||||
|
||||
if (messages.length === 0) {
|
||||
isAtBottomRef.current = true;
|
||||
prevMessageCountRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewChanged) {
|
||||
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
||||
prevMessageCountRef.current = messages.length;
|
||||
const key = viewKey ?? '';
|
||||
const savedPos = scrollPositionsRef.current.get(key);
|
||||
if (savedPos !== undefined && containerRef.current) {
|
||||
@ -108,11 +115,18 @@ export function MessageList({
|
||||
|
||||
// Same view, messages changed: normal auto-scroll logic
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const newCount = messages.length - prevMessageCountRef.current;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
|
||||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||||
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]);
|
||||
|
||||
|
||||
@ -39,6 +39,17 @@ export function useWebSocket({
|
||||
const isManualDisconnectRef = useRef(false);
|
||||
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(() => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
@ -73,13 +84,13 @@ export function useWebSocket({
|
||||
}
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
onConnect?.();
|
||||
onConnectRef.current?.();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WsOutbound;
|
||||
onMessage?.(message);
|
||||
onMessageRef.current?.(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
@ -87,13 +98,13 @@ export function useWebSocket({
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setStatus('error');
|
||||
onError?.(error);
|
||||
onErrorRef.current?.(error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected');
|
||||
clearPing();
|
||||
onDisconnect?.();
|
||||
onDisconnectRef.current?.();
|
||||
|
||||
// 指数退避自动重连:3s → 6s → 12s → 24s → 60s 封顶
|
||||
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
@ -109,7 +120,7 @@ export function useWebSocket({
|
||||
setStatus('error');
|
||||
console.error('WebSocket connection error:', error);
|
||||
}
|
||||
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts, clearPing]);
|
||||
}, [url, reconnectInterval, maxReconnectAttempts, clearPing]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
isManualDisconnectRef.current = true;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user