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 {
|
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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user