Files
Agent-System/web/src/hooks/useVirtualMessages.ts
T

132 lines
4.6 KiB
TypeScript

/**
* useVirtualMessages — 虚拟化消息列表 Hook (P3 虚拟滚动层)
*
* 封装 @tanstack/react-virtual 的 Dynamic 模式:
* - Dynamic 模式:渲染后由 measureElement 修正行高,适配气泡高度差异大的场景;
* - overscan=15:视口外预渲染 15 条,保证快速滚动时无白帧;
* - scrollToMessageId:按消息 ID 精准定位(anchorNavigator 快/慢路径使用);
* - isAtBottom:替代原 scrollTop 判断,决定新消息到达时是否自动跟随;
* - onNearTop:替代 MessageStream 内部的 scrollTop < 120 判断,触顶回调。
*/
import { useRef, useCallback, useEffect } from 'react';
import { useVirtualizer, ReactVirtualizer } from '@tanstack/react-virtual';
import { ChatMessage, ChatDayBucket } from '../types/workbench';
export interface VirtualScrollHandle {
/** 按消息 serverId 滚动到该消息,居中显示 */
scrollToMessageId: (messageId: number) => void;
/** 当前视口是否在底部附近(用于新消息自动跟随判断) */
isAtBottom: () => boolean;
/** 跳转到最底部(切换会话 / 首屏落底) */
scrollToBottom: () => void;
}
export interface UseVirtualMessagesOptions {
/** 平铺的消息列表(跨所有 dayBucket 合并后的有序列表) */
flatMessages: ChatMessage[];
/** 容器 ref(必须是 overflow:auto 的滚动容器) */
containerRef: React.RefObject<HTMLDivElement | null>;
/** 触顶回调(替代 scrollTop < 120 判断) */
onNearTop?: () => void;
/** 估计行高(默认 120px,由 measureElement 在渲染后修正) */
estimatedSize?: number;
}
export interface UseVirtualMessagesReturn {
virtualizer: ReactVirtualizer<HTMLDivElement, Element>;
handle: VirtualScrollHandle;
}
export function useVirtualMessages({
flatMessages,
containerRef,
onNearTop,
estimatedSize = 120,
}: UseVirtualMessagesOptions): UseVirtualMessagesReturn {
const onNearTopRef = useRef(onNearTop);
useEffect(() => {
onNearTopRef.current = onNearTop;
}, [onNearTop]);
const virtualizer = useVirtualizer<HTMLDivElement, Element>({
count: flatMessages.length,
getScrollElement: () => containerRef.current,
estimateSize: () => estimatedSize,
overscan: 15,
measureElement:
typeof window !== 'undefined'
? (el) => el.getBoundingClientRect().height
: undefined,
});
// 监听滚动,触发触顶回调
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleScroll = () => {
if (el.scrollTop < 120) {
onNearTopRef.current?.();
}
};
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [containerRef]);
const handle: VirtualScrollHandle = {
scrollToMessageId: useCallback(
(messageId: number) => {
const idx = flatMessages.findIndex(
(m) => (m.serverId ?? 0) === messageId
);
if (idx === -1) return;
const container = containerRef.current;
const viewportHeight = container?.clientHeight || 600;
const offsetInfo = virtualizer.getOffsetForIndex(idx, 'start');
if (offsetInfo) {
const [startOffset] = offsetInfo;
// 让目标消息起始位置位于中间栏垂直居中偏上位置,防止长文本头部被截断在视口外
const targetOffset = Math.max(0, startOffset - viewportHeight / 2);
virtualizer.scrollToOffset(targetOffset, { align: 'start', behavior: 'auto' });
} else {
virtualizer.scrollToIndex(idx, { align: 'start', behavior: 'auto' });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[flatMessages, virtualizer, containerRef]
),
isAtBottom: useCallback(() => {
const el = containerRef.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}, [containerRef]),
scrollToBottom: useCallback(() => {
if (flatMessages.length === 0) return;
virtualizer.scrollToIndex(flatMessages.length - 1, {
align: 'end',
behavior: 'auto',
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flatMessages.length, virtualizer]),
};
return { virtualizer, handle };
}
/**
* 工具函数:将 dayStack 展平为有序消息列表(保留 dayKey 引用)
*/
export function flattenDayStack(dayStack: ChatDayBucket[]): ChatMessage[] {
const result: ChatMessage[] = [];
for (const bucket of dayStack) {
for (const msg of bucket.messages) {
result.push(msg);
}
}
return result;
}