perf(web): virtualize chat message lists (#21790)

This commit is contained in:
chanx authored and GitHub committed 2026-11-02 11:19:42 +08:00
1 parent 306541333b
commit e77793cc6d
18 files changed
+415 -461

No files matched your search

@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import { MessageVirtualizer } from '@/hooks/use-virtual-messages';
import { VirtualMessageList } from './index';
function buildVirtualizer() {
return {
getTotalSize: () => 300,
getVirtualItems: () => [
{ index: 0, key: 'user_1', start: 0, size: 100, end: 100, lane: 0 },
{ index: 2, key: 'user_3', start: 200, size: 100, end: 300, lane: 0 },
],
measureElement: jest.fn(),
} as unknown as MessageVirtualizer;
}
describe('VirtualMessageList', () => {
it('renders only the virtual items the virtualizer returns', () => {
render(
<VirtualMessageList
items={['one', 'two', 'three']}
virtualizer={buildVirtualizer()}
renderItem={(item, index) => <span>{`${index}:${item}`}</span>}
/>,
);
expect(screen.getByText('0:one')).toBeInTheDocument();
expect(screen.getByText('2:three')).toBeInTheDocument();
expect(screen.queryByText('1:two')).not.toBeInTheDocument();
});
it('positions each item at the offset reported by the virtualizer', () => {
render(
<VirtualMessageList
items={['one', 'two', 'three']}
virtualizer={buildVirtualizer()}
renderItem={(item) => <span>{item}</span>}
/>,
);
expect(screen.getByText('one').parentElement).toHaveStyle({
transform: 'translateY(0px)',
});
expect(screen.getByText('three').parentElement).toHaveStyle({
transform: 'translateY(200px)',
});
});
});
@@ -0,0 +1,63 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MessageVirtualizer } from '@/hooks/use-virtual-messages';
import { ReactNode } from 'react';
interface VirtualMessageListProps<T> {
items: T[];
virtualizer: MessageVirtualizer;
renderItem: (item: T, index: number) => ReactNode;
className?: string;
}
export function VirtualMessageList<T>({
items,
virtualizer,
renderItem,
className,
}: VirtualMessageListProps<T>) {
return (
<div className={className}>
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
if (item === undefined) return null;
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderItem(item, virtualItem.index)}
</div>
);
})}
</div>
</div>
);
}
@@ -1,114 +0,0 @@
jest.mock('eventsource-parser/stream', () => ({}));
import { act, renderHook } from '@testing-library/react';
import { useScrollToBottom } from '../logic-hooks';
function createMockContainer({ atBottom = true } = {}) {
const scrollTop = atBottom ? 100 : 0;
const clientHeight = 100;
const scrollHeight = 200;
const listeners: Record<string, (...args: unknown[]) => void> = {};
return {
current: {
scrollTop,
clientHeight,
scrollHeight,
style: {},
scrollTo: jest.fn(),
addEventListener: jest.fn((event, cb) => {
listeners[event] = cb;
}),
removeEventListener: jest.fn(),
},
listeners,
} as any;
}
// Helper to flush all timers and microtasks
async function flushAll() {
jest.runAllTimers();
// Flush microtasks
await Promise.resolve();
// Sometimes, effects queue more timers, so run again
jest.runAllTimers();
await Promise.resolve();
}
describe('useScrollToBottom', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should set isAtBottom true when user is at bottom', () => {
const containerRef = createMockContainer({ atBottom: true });
const { result } = renderHook(() => useScrollToBottom([], containerRef));
expect(result.current.isAtBottom).toBe(true);
});
it('should set isAtBottom false when user is not at bottom', () => {
const containerRef = createMockContainer({ atBottom: false });
const { result } = renderHook(() => useScrollToBottom([], containerRef));
expect(result.current.isAtBottom).toBe(false);
});
it('should scroll to bottom when isAtBottom is true and messages change', async () => {
const containerRef = createMockContainer({ atBottom: true });
const { rerender } = renderHook(
({ messages }) => useScrollToBottom(messages, containerRef),
{ initialProps: { messages: [] as string[] } },
);
rerender({ messages: ['msg1'] });
await flushAll();
expect(containerRef.current.scrollTo).toHaveBeenCalled();
});
it('should NOT scroll to bottom when isAtBottom is false and messages change', async () => {
const containerRef = createMockContainer({ atBottom: false });
const { result, rerender } = renderHook(
({ messages }) => useScrollToBottom(messages, containerRef),
{ initialProps: { messages: [] as string[] } },
);
// Simulate user scrolls up before messages change
await act(async () => {
containerRef.current.scrollTop = 0;
containerRef.current.addEventListener.mock.calls[0][1]();
await flushAll();
// Advance fake timers by 10ms instead of real setTimeout
jest.advanceTimersByTime(10);
console.log('AFTER SCROLL: isAtBottom:', result.current.isAtBottom);
});
rerender({ messages: ['msg1'] });
await flushAll();
console.log('AFTER RERENDER: isAtBottom:', result.current.isAtBottom);
expect(containerRef.current.scrollTo).not.toHaveBeenCalled();
// Optionally, flush again after the assertion to see if it gets called late
await flushAll();
});
it('should indicate button should appear when user is not at bottom', () => {
const containerRef = createMockContainer({ atBottom: false });
const { result } = renderHook(() => useScrollToBottom([], containerRef));
// The button should appear in the UI when isAtBottom is false
expect(result.current.isAtBottom).toBe(false);
});
});
const originalRAF = global.requestAnimationFrame;
beforeAll(() => {
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
});
afterAll(() => {
global.requestAnimationFrame = originalRAF;
});
+5 -126
View File
@@ -51,6 +51,7 @@ import {
import { useTranslate } from './common-hooks';
import { useSetPaginationParams } from './route-hook';
import { useSaveSetting } from './use-user-setting-request';
import { useVirtualMessages } from './use-virtual-messages';
export function usePrevious<T>(value: T) {
const ref = useRef<T>();
@@ -445,129 +446,6 @@ export const useSpeechWithSse = (url: string = api.chatsTts) => {
//#region chat hooks
// Firefox reports a fractional `scrollTop`, while `scrollHeight` / `clientHeight`
// are rounded. `scrollToBottom` therefore lands a sub-pixel *below* the position
// a native clamp had produced, which reads as a decreasing `scrollTop`. Require a
// real gesture's worth of movement so that jitter is not mistaken for one.
const UserScrollUpThreshold = 2;
export const useScrollToBottom = (
messages?: unknown,
containerRef?: React.RefObject<HTMLDivElement>,
) => {
const ref = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true);
// `null` means "no baseline yet", so the very first measurement has to fall
// back to the distance check instead of guessing a scroll direction.
const lastScrollTopRef = useRef<number | null>(null);
useEffect(() => {
isAtBottomRef.current = isAtBottom;
}, [isAtBottom]);
// We pin the transcript to the bottom ourselves, so browser scroll anchoring is
// pure interference: when a streamed answer re-lays out (markdown turning a
// paragraph into a code block, a line re-wrapping), Firefox shifts `scrollTop`
// to hold its anchor node still. That shift is indistinguishable from a user
// scrolling up in the handler below, so it latched auto-follow off mid-answer.
// Chrome suppresses the adjustment while pinned to the bottom, which is why
// only Firefox drifted away from the bottom.
useEffect(() => {
if (!containerRef?.current) return;
containerRef.current.style.overflowAnchor = 'none';
}, [containerRef]);
const checkIfNearBottom = useCallback(() => {
if (!containerRef?.current) return true;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
// Content shorter than the viewport has nothing to scroll, so it is
// trivially "at the bottom". Returning false here would latch auto-follow
// off when a stream starts from a short transcript: growing content does
// not fire a `scroll` event, so no later check would ever re-arm the flag
// and the view would never track the incoming message.
if (scrollHeight <= clientHeight) return true;
return Math.abs(scrollTop + clientHeight - scrollHeight) < 60;
}, [containerRef]);
useEffect(() => {
if (!containerRef?.current) return;
const container = containerRef.current;
const handleScroll = () => {
const previousScrollTop = lastScrollTopRef.current;
const { scrollTop } = container;
lastScrollTopRef.current = scrollTop;
const nearBottom = checkIfNearBottom();
let atBottom: boolean;
if (nearBottom) {
atBottom = true;
} else if (
previousScrollTop === null ||
scrollTop < previousScrollTop - UserScrollUpThreshold
) {
// With scroll anchoring off, only a user gesture (wheel, drag, keys,
// touch) can shrink `scrollTop` by a meaningful amount, so this is the
// one reliable signal that they want to leave the bottom.
atBottom = false;
} else {
// We are far from the bottom yet `scrollTop` did not move: the gap comes
// from content that grew after `scrollToBottom` ran but before this
// event was dispatched. Disarming here would strand auto-follow forever,
// because growing content never fires another `scroll` event to re-arm
// it. Keep whatever the user last asked for.
atBottom = isAtBottomRef.current;
}
// Write the ref here rather than relying on the effect that mirrors
// `isAtBottom`: that effect only runs after the next render, and while the
// main thread is busy rendering a streaming answer an already scheduled
// auto-scroll would still see the stale `true`.
isAtBottomRef.current = atBottom;
setIsAtBottom(atBottom);
};
container.addEventListener('scroll', handleScroll);
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [containerRef, checkIfNearBottom]);
// Imperative scroll function
const scrollToBottom = useCallback(() => {
if (containerRef?.current) {
const container = containerRef.current;
// Overshoot and let the browser clamp. `scrollHeight - clientHeight` is a
// difference of two rounded values, so in Firefox — where the real maximum
// is fractional — it can land just short of the bottom.
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
});
}
}, [containerRef]);
// Streaming replaces `messages` many times a second. The previous
// rAF + setTimeout(100) chain always had several scrolls queued, and they read
// `isAtBottomRef` long after the user had scrolled up — yanking the view back
// down. One cancellable frame per change, gated on the latest position, keeps
// auto-follow without fighting the user.
useEffect(() => {
if (!messages) return;
if (!containerRef?.current) return;
if (!isAtBottomRef.current) return;
const frame = requestAnimationFrame(() => {
if (isAtBottomRef.current) {
scrollToBottom();
}
});
return () => cancelAnimationFrame(frame);
}, [messages, containerRef, scrollToBottom]);
return { scrollRef: ref, isAtBottom, scrollToBottom };
};
export const useHandleMessageInputChange = () => {
const [value, setValue] = useState('');
@@ -584,14 +462,15 @@ export const useHandleMessageInputChange = () => {
};
};
export const useSelectDerivedMessages = () => {
export const useSelectDerivedMessages = (resetKey?: string) => {
const [derivedMessages, setDerivedMessages] = useState<IMessage[]>([]);
const messageContainerRef = useRef<HTMLDivElement>(null);
const { scrollRef, scrollToBottom } = useScrollToBottom(
const { virtualizer, scrollToBottom } = useVirtualMessages(
derivedMessages,
messageContainerRef,
{ resetKey },
);
const addNewestQuestion = useCallback(
@@ -771,7 +650,7 @@ export const useSelectDerivedMessages = () => {
}, [setDerivedMessages]);
return {
scrollRef,
virtualizer,
messageContainerRef,
derivedMessages,
setDerivedMessages,
+79
View File
@@ -0,0 +1,79 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual';
import { RefObject, useCallback, useEffect } from 'react';
import { IMessage } from '@/interfaces/database/chat';
import { buildMessageUuidWithRole } from '@/utils/chat';
// Until a message is measured, the virtualizer positions it with this
// estimate. A chat turn is usually a short question and a long answer, so a
// mid-sized value keeps the initial jump to the bottom close for both.
const EstimatedMessageHeight = 160;
// Losing the bottom by more than this means the user scrolled up on purpose.
const FollowBottomThreshold = 60;
export type MessageVirtualizer = Virtualizer<HTMLDivElement, HTMLDivElement>;
interface UseVirtualMessagesOptions {
/** Changes when the displayed transcript is replaced, e.g. a conversation switch. */
resetKey?: string;
}
export const useVirtualMessages = (
messages: IMessage[] | undefined,
containerRef: RefObject<HTMLDivElement>,
{ resetKey }: UseVirtualMessagesOptions = {},
) => {
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: messages?.length ?? 0,
getScrollElement: () => containerRef.current,
getItemKey: (index) => {
const message = messages?.[index];
return message ? buildMessageUuidWithRole(message) : index;
},
estimateSize: () => EstimatedMessageHeight,
overscan: 4,
// A transcript is read from the bottom: keep the distance to the end
// stable when messages are appended or resized, and follow new output
// only while the user is still at the bottom.
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: FollowBottomThreshold,
});
const scrollToBottom = useCallback(() => {
virtualizer.scrollToEnd();
}, [virtualizer]);
// The transcript pins itself to the bottom, so browser scroll anchoring is
// pure interference while an answer re-wraps and re-measures.
useEffect(() => {
const container = containerRef.current;
if (container) {
container.style.overflowAnchor = 'none';
}
}, [containerRef]);
// Appends are handled by followOnAppend. A replaced transcript (mount or
// conversation switch) has no append to follow, so open it at the bottom.
useEffect(() => {
scrollToBottom();
}, [resetKey, scrollToBottom]);
return { virtualizer, scrollToBottom };
};
+47 -50
View File
@@ -8,12 +8,12 @@ import MarkdownContent from '@/components/next-markdown-content';
import MessageItem from '@/components/next-message-item';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { VirtualMessageList } from '@/components/virtual-message-list';
import {
useFetchAgent,
useUploadAgentFileWithProgress,
} from '@/hooks/use-agent-request';
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
import { buildMessageUuidWithRole } from '@/utils/chat';
import { memo, useCallback, useContext, useEffect, useRef } from 'react';
import { AgentChatContext } from '../context';
import DebugContent from '../debug-content';
@@ -24,7 +24,7 @@ function AgentChatBox() {
const { data: canvasInfo, refetch } = useFetchAgent();
const {
value,
scrollRef,
virtualizer,
messageContainerRef,
sendLoading,
derivedMessages,
@@ -100,56 +100,53 @@ function AgentChatBox() {
<>
<section className="flex flex-1 flex-col px-5 min-h-0 pb-4">
<div className="flex-1 overflow-auto min-h-0" ref={messageContainerRef}>
<div>
{!sendLoading && <div data-testid="agent-run-idle" />}
{/* <Spin spinning={sendLoading}> */}
{derivedMessages?.map((message, i) => {
return (
<MessageItem
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages.length - 1 === i
}
key={buildMessageUuidWithRole(message)}
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={canvasInfo.avatar}
item={message}
reference={findReferenceByMessageId(message.id)}
clickDocumentButton={clickDocumentButton}
index={i}
showLikeButton={false}
sendLoading={sendLoading && derivedMessages.length - 1 === i}
>
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 === i && (
<DebugContent
parameters={buildInputList(message)}
message={message}
ok={handleOk(message)}
isNext={false}
btnText={'Submit'}
></DebugContent>
)}
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 !== i && (
{!sendLoading && <div data-testid="agent-run-idle" />}
<VirtualMessageList
className="w-full"
items={derivedMessages}
virtualizer={virtualizer}
renderItem={(message, i) => (
<MessageItem
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages.length - 1 === i
}
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={canvasInfo.avatar}
item={message}
reference={findReferenceByMessageId(message.id)}
clickDocumentButton={clickDocumentButton}
index={i}
showLikeButton={false}
sendLoading={sendLoading && derivedMessages.length - 1 === i}
>
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 === i && (
<DebugContent
parameters={buildInputList(message)}
message={message}
ok={handleOk(message)}
isNext={false}
btnText={'Submit'}
></DebugContent>
)}
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 !== i && (
<div>
<MarkdownContent
content={message?.data?.tips}
loading={false}
></MarkdownContent>
<div>
<MarkdownContent
content={message?.data?.tips}
loading={false}
></MarkdownContent>
<div>
{buildInputList(message)?.map((item) => item.value)}
</div>
{buildInputList(message)?.map((item) => item.value)}
</div>
)}
</MessageItem>
);
})}
{/* </Spin> */}
</div>
<div ref={scrollRef} />
</div>
)}
</MessageItem>
)}
/>
</div>
{isTaskMode || (
<div ref={inputWrapperRef}>
@@ -47,7 +47,7 @@ jest.mock('@/hooks/logic-hooks', () => ({
useSelectDerivedMessages: () => ({
derivedMessages: [],
setDerivedMessages: jest.fn(),
scrollRef: { current: null },
virtualizer: {},
messageContainerRef: { current: null },
removeLatestMessage: jest.fn(),
removeMessageById: jest.fn(),
@@ -305,7 +305,7 @@ export const useSendAgentMessage = ({
const prologue = useGetBeginNodePrologue();
const {
derivedMessages,
scrollRef,
virtualizer,
messageContainerRef,
removeLatestMessage,
removeMessageById,
@@ -316,7 +316,7 @@ export const useSendAgentMessage = ({
scrollToBottom,
addPrologue,
setDerivedMessages,
} = useSelectDerivedMessages();
} = useSelectDerivedMessages(activeSessionId);
const { addEventList: addEventListFun } = useContext(AgentChatLogContext);
const {
appendUploadResponseList,
@@ -611,7 +611,7 @@ export const useSendAgentMessage = ({
value,
sendLoading: !done,
derivedMessages,
scrollRef,
virtualizer,
messageContainerRef,
handlePressEnter,
handleInputChange,
@@ -4,6 +4,7 @@ import MarkdownContent from '@/components/next-markdown-content';
import MessageItem from '@/components/next-message-item';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { VirtualMessageList } from '@/components/virtual-message-list';
import { MessageType } from '@/constants/chat';
import { useUploadAgentFileWithProgress } from '@/hooks/use-agent-request';
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
@@ -13,7 +14,7 @@ import DebugContent from '@/pages/agent/debug-content';
import { useAwaitComponentData } from '@/pages/agent/hooks/use-chat-logic';
import { BeginQuery } from '@/pages/agent/interface';
import { ParameterDialog } from '@/pages/agent/share/parameter-dialog';
import { buildMessageUuidWithRole, buildReferenceObject } from '@/utils/chat';
import { buildReferenceObject } from '@/utils/chat';
import { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useExploreUrlParams } from '../hooks/use-explore-url-params';
@@ -34,7 +35,7 @@ export function SessionChat({ session }: SessionChatProps) {
const {
value,
derivedMessages,
scrollRef,
virtualizer,
messageContainerRef,
sendLoading,
handleInputChange,
@@ -160,8 +161,11 @@ export function SessionChat({ session }: SessionChatProps) {
No messages in this session
</div>
) : (
<div className="w-full pr-5">
{derivedMessages.map((message, i) => {
<VirtualMessageList
className="w-full pr-5"
items={derivedMessages}
virtualizer={virtualizer}
renderItem={(message, i) => {
const inputList = buildInputList(message);
const hasUserFillUpInputs =
message.role === MessageType.Assistant &&
@@ -174,7 +178,6 @@ export function SessionChat({ session }: SessionChatProps) {
isStreamingActiveSession &&
derivedMessages.length - 1 === i
}
key={buildMessageUuidWithRole(message)}
item={message}
nickname={userInfo.nickname}
avatar={userInfo.avatar}
@@ -215,10 +218,9 @@ export function SessionChat({ session }: SessionChatProps) {
)}
</MessageItem>
);
})}
</div>
}}
/>
)}
<div ref={scrollRef} />
</div>
)}
<section className="p-4">
+53 -54
View File
@@ -5,6 +5,7 @@ import MessageItem from '@/components/next-message-item';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { useSyncThemeFromParams } from '@/components/theme-provider';
import { VirtualMessageList } from '@/components/virtual-message-list';
import { MessageType } from '@/constants/chat';
import { useUploadAgentFileWithProgress } from '@/hooks/use-agent-request';
import { cn } from '@/lib/utils';
@@ -12,7 +13,6 @@ import i18n, { changeLanguageAsync } from '@/locales/config';
import DebugContent from '@/pages/agent/debug-content';
import { useCacheChatLog } from '@/pages/agent/hooks/use-cache-chat-log';
import { useAwaitComponentData } from '@/pages/agent/hooks/use-chat-logic';
import { buildMessageUuidWithRole } from '@/utils/chat';
import { isEmpty } from 'lodash';
import React, { forwardRef, useCallback } from 'react';
import {
@@ -47,7 +47,7 @@ const ChatContainer = () => {
handleInputChange,
value,
sendLoading,
scrollRef,
virtualizer,
messageContainerRef,
derivedMessages,
hasError,
@@ -130,60 +130,59 @@ const ChatContainer = () => {
)}
ref={messageContainerRef}
>
<div>
{derivedMessages?.map((message, i) => {
return (
<MessageItem
visibleAvatar={visibleAvatar}
conversationId={conversationId}
currentEventListWithoutMessageById={
currentEventListWithoutMessageById
}
setCurrentMessageId={setCurrentMessageId}
key={buildMessageUuidWithRole(message)}
item={message}
nickname="You"
reference={findReferenceByMessageId(message.id)}
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages?.length - 1 === i
}
isShare={true}
avatarDialog={inputsData.avatar}
agentName={inputsData.title}
index={i}
clickDocumentButton={clickDocumentButton}
showLikeButton={false}
showLoudspeaker={false}
showLog={false}
sendLoading={sendLoading}
>
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 === i && (
<DebugContent
parameters={buildInputList(message)}
message={message}
ok={handleOk(message)}
isNext={false}
btnText={'Submit'}
></DebugContent>
)}
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 !== i && (
<div>
<div>{message?.data?.tips}</div>
<VirtualMessageList
className="w-full"
items={derivedMessages}
virtualizer={virtualizer}
renderItem={(message, i) => (
<MessageItem
visibleAvatar={visibleAvatar}
conversationId={conversationId}
currentEventListWithoutMessageById={
currentEventListWithoutMessageById
}
setCurrentMessageId={setCurrentMessageId}
item={message}
nickname="You"
reference={findReferenceByMessageId(message.id)}
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages?.length - 1 === i
}
isShare={true}
avatarDialog={inputsData.avatar}
agentName={inputsData.title}
index={i}
clickDocumentButton={clickDocumentButton}
showLikeButton={false}
showLoudspeaker={false}
showLog={false}
sendLoading={sendLoading}
>
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 === i && (
<DebugContent
parameters={buildInputList(message)}
message={message}
ok={handleOk(message)}
isNext={false}
btnText={'Submit'}
></DebugContent>
)}
{message.role === MessageType.Assistant &&
derivedMessages.length - 1 !== i && (
<div>
<div>{message?.data?.tips}</div>
<div>
{buildInputList(message)?.map((item) => item.value)}
</div>
<div>
{buildInputList(message)?.map((item) => item.value)}
</div>
)}
</MessageItem>
);
})}
</div>
<div ref={scrollRef} />
</div>
)}
</MessageItem>
)}
/>
</div>
{isTaskMode || (
<div className="flex w-full justify-center md:mb-8">
@@ -45,6 +45,9 @@ jest.mock('@/components/message-input/next', () => ({
MockNextMessageInput(props),
}));
jest.mock('@/components/message-item', () => () => null);
jest.mock('@/components/virtual-message-list', () => ({
VirtualMessageList: () => null,
}));
jest.mock('@/components/pdf-drawer', () => () => null);
jest.mock('@/components/pdf-drawer/hooks', () => ({
useClickDrawer: () => ({
@@ -61,7 +64,6 @@ jest.mock('@/hooks/logic-hooks', () => ({
value: 'Question',
setValue: jest.fn(),
}),
useScrollToBottom: () => ({ scrollRef: { current: null } }),
}));
jest.mock('@/hooks/use-chat-request', () => ({
useFetchChat: () => ({
@@ -15,11 +15,9 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { VirtualMessageList } from '@/components/virtual-message-list';
import { MessageType } from '@/constants/chat';
import {
useHandleMessageInputChange,
useScrollToBottom,
} from '@/hooks/logic-hooks';
import { useHandleMessageInputChange } from '@/hooks/logic-hooks';
import {
useFetchChat,
useGetChatSearchParams,
@@ -28,7 +26,6 @@ import {
import { useFindLlmByUuid } from '@/hooks/use-llm-request';
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
import { IClientConversation } from '@/interfaces/database/chat';
import { buildMessageUuidWithRole } from '@/utils/chat';
import {
deriveModelPreset,
removeUselessFieldsFromValues,
@@ -110,18 +107,19 @@ const ChatCard = forwardRef(function ChatCard(
const { id: dialogId } = useParams();
const { updateChat } = useUpdateChat();
const { derivedMessages, handlePressEnter, sendLoading } =
useSendSingleMessage({
controller,
value,
setValue,
files,
clearFiles,
});
const messageContainerRef = useRef<HTMLDivElement>(null);
const { scrollRef } = useScrollToBottom(derivedMessages, messageContainerRef);
const {
derivedMessages,
handlePressEnter,
sendLoading,
messageContainerRef,
virtualizer,
} = useSendSingleMessage({
controller,
value,
setValue,
files,
clearFiles,
});
const messageReferences = useMessageReferences(derivedMessages);
@@ -264,32 +262,31 @@ const ChatCard = forwardRef(function ChatCard(
</CardHeader>
<CardContent className="flex-1 min-h-0">
<div ref={messageContainerRef} className="h-full overflow-auto">
<div className="w-full">
{derivedMessages?.map((message, i) => {
return (
<MessageItem
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages.length - 1 === i
}
key={buildMessageUuidWithRole(message)}
item={message}
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={currentDialog.icon}
reference={messageReferences.get(message) ?? EmptyReference}
// clickDocumentButton={clickDocumentButton}
index={i}
isLast={i === derivedMessages.length - 1}
sendLoading={sendLoading}
clickDocumentButton={clickDocumentButton}
showLikeButton={false}
></MessageItem>
);
})}
</div>
<div ref={scrollRef} />
<VirtualMessageList
className="w-full"
items={derivedMessages ?? []}
virtualizer={virtualizer}
renderItem={(message, i) => (
<MessageItem
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages.length - 1 === i
}
item={message}
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={currentDialog.icon}
reference={messageReferences.get(message) ?? EmptyReference}
// clickDocumentButton={clickDocumentButton}
index={i}
isLast={i === derivedMessages.length - 1}
sendLoading={sendLoading}
clickDocumentButton={clickDocumentButton}
showLikeButton={false}
/>
)}
/>
</div>
</CardContent>
</Card>
@@ -10,6 +10,9 @@ jest.mock('@/components/message-input/next', () => ({
NextMessageInput: () => null,
}));
jest.mock('@/components/message-item', () => () => null);
jest.mock('@/components/virtual-message-list', () => ({
VirtualMessageList: () => null,
}));
jest.mock('@/components/pdf-drawer', () => () => null);
jest.mock('@/components/pdf-drawer/hooks', () => ({
useClickDrawer: () => ({
@@ -2,11 +2,11 @@ import { NextMessageInput } from '@/components/message-input/next';
import MessageItem from '@/components/message-item';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { VirtualMessageList } from '@/components/virtual-message-list';
import { MessageType } from '@/constants/chat';
import { useFetchChat, useGetChatSearchParams } from '@/hooks/use-chat-request';
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
import { IClientConversation } from '@/interfaces/database/chat';
import { buildMessageUuidWithRole } from '@/utils/chat';
import { useEffect } from 'react';
import {
useGetSendButtonDisabled,
@@ -25,7 +25,7 @@ interface IProps {
export function SingleChatBox({ conversation }: IProps) {
const {
value,
scrollRef,
virtualizer,
messageContainerRef,
sendLoading,
messages,
@@ -82,31 +82,32 @@ export function SingleChatBox({ conversation }: IProps) {
ref={messageContainerRef}
className="p-5 flex-1 overflow-auto min-h-0 scrollbar-auto"
>
<div className="w-full pr-5">
{messages?.map((message, i) => (
<VirtualMessageList
className="w-full pr-5"
items={messages ?? []}
virtualizer={virtualizer}
renderItem={(message, i) => (
<MessageItem
loading={
message.role === MessageType.Assistant &&
sendLoading &&
messages.length - 1 === i
(messages?.length ?? 0) - 1 === i
}
key={buildMessageUuidWithRole(message)}
item={message}
nickname={userInfo.nickname}
avatar={userInfo.avatar}
avatarDialog={currentDialog.icon}
item={message}
reference={messageReferences.get(message) ?? EmptyReference}
clickDocumentButton={clickDocumentButton}
index={i}
isLast={i === messages.length - 1}
isLast={(messages?.length ?? 0) - 1 === i}
removeMessageById={removeMessageById}
deleteDisabled={message.awaitingServerId}
regenerateMessage={regenerateMessage}
sendLoading={sendLoading}
/>
))}
</div>
<div ref={scrollRef} />
)}
/>
</div>
<div className="p-5 pt-0">
@@ -1,10 +1,8 @@
import { NextMessageInputOnPressEnterParameter } from '@/components/message-input/next';
import { MessageType } from '@/constants/chat';
import {
useHandleMessageInputChange,
useScrollToBottom,
} from '@/hooks/logic-hooks';
import { useHandleMessageInputChange } from '@/hooks/logic-hooks';
import { useFetchChat, useGetChatSearchParams } from '@/hooks/use-chat-request';
import { useVirtualMessages } from '@/hooks/use-virtual-messages';
import { buildMessageListWithUuid } from '@/utils/chat';
import { IMessage, Message } from '@/interfaces/database/chat';
import notification from '@/utils/notification';
@@ -40,7 +38,11 @@ export const useCurrentChatSession = () => {
const isStreaming = useIsChatStreaming(conversationId);
const messageContainerRef = useRef<HTMLDivElement>(null);
const { scrollRef } = useScrollToBottom(messages, messageContainerRef);
const { virtualizer, scrollToBottom } = useVirtualMessages(
messages,
messageContainerRef,
{ resetKey: conversationId },
);
const ensureSession = useChatStreamStore((state) => state.ensureSession);
const seedPrologue = useChatStreamStore((state) => state.seedPrologue);
@@ -58,8 +60,9 @@ export const useCurrentChatSession = () => {
return {
messages,
isStreaming,
scrollRef,
virtualizer,
messageContainerRef,
scrollToBottom,
};
};
@@ -73,8 +76,13 @@ export const useSendMessage = () => {
const { id: chatId } = useParams();
const { data: currentDialog } = useFetchChat();
const { messages, isStreaming, scrollRef, messageContainerRef } =
useCurrentChatSession();
const {
messages,
isStreaming,
virtualizer,
messageContainerRef,
scrollToBottom,
} = useCurrentChatSession();
const appendQuestion = useChatStreamStore((state) => state.appendQuestion);
const failStream = useChatStreamStore((state) => state.failStream);
@@ -240,15 +248,7 @@ export const useSendMessage = () => {
clearFiles();
// Auto scroll to bottom when sending new message
if (messageContainerRef.current) {
const el = messageContainerRef.current;
requestAnimationFrame(() => {
el.scrollTo({
top: el.scrollHeight,
});
});
}
requestAnimationFrame(scrollToBottom);
},
[
value,
@@ -260,7 +260,7 @@ export const useSendMessage = () => {
clearFiles,
setValue,
sendMessage,
messageContainerRef,
scrollToBottom,
],
);
@@ -275,7 +275,7 @@ export const useSendMessage = () => {
setValue,
regenerateMessage,
sendLoading: isStreaming,
scrollRef,
virtualizer,
messageContainerRef,
messages,
removeMessageById,
@@ -56,7 +56,7 @@ export const useSendSharedMessage = () => {
removeLatestMessage,
addNewestAnswer,
addNewestQuestion,
scrollRef,
virtualizer,
messageContainerRef,
removeAllMessages,
removeAllMessagesExceptFirst,
@@ -165,7 +165,7 @@ export const useSendSharedMessage = () => {
derivedMessages,
hasError,
stopOutputMessage,
scrollRef,
virtualizer,
messageContainerRef,
removeAllMessages,
removeAllMessagesExceptFirst,
@@ -36,7 +36,7 @@ export function useSendSingleMessage({
const { send, answer, done } = useSendMessageWithSse();
const {
scrollRef,
virtualizer,
messageContainerRef,
setDerivedMessages,
derivedMessages,
@@ -151,7 +151,7 @@ export function useSendSingleMessage({
);
return {
scrollRef,
virtualizer,
messageContainerRef,
setDerivedMessages,
derivedMessages,
+25 -27
View File
@@ -4,10 +4,10 @@ import MessageItem from '@/components/message-item';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { useSyncThemeFromParams } from '@/components/theme-provider';
import { VirtualMessageList } from '@/components/virtual-message-list';
import { MessageType } from '@/constants/chat';
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
import i18n, { changeLanguageAsync } from '@/locales/config';
import { buildMessageUuidWithRole } from '@/utils/chat';
import React, { forwardRef } from 'react';
import { useSendButtonDisabled } from '../hooks/use-button-disabled';
import {
@@ -36,7 +36,7 @@ const ChatContainer = () => {
derivedMessages,
hasError,
stopOutputMessage,
scrollRef,
virtualizer,
messageContainerRef,
removeAllMessagesExceptFirst,
} = useSendSharedMessage();
@@ -72,31 +72,29 @@ const ChatContainer = () => {
}
ref={messageContainerRef}
>
<div>
{derivedMessages?.map((message, i) => {
return (
<MessageItem
visibleAvatar={visibleAvatar}
key={buildMessageUuidWithRole(message)}
avatarDialog={avatarDialogSrc}
item={message}
nickname="You"
reference={messageReferences.get(message) ?? EmptyReference}
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages?.length - 1 === i
}
index={i}
isLast={i === derivedMessages.length - 1}
clickDocumentButton={clickDocumentButton}
showLikeButton={false}
showLoudspeaker={false}
></MessageItem>
);
})}
</div>
<div ref={scrollRef} />
<VirtualMessageList
className="w-full"
items={derivedMessages}
virtualizer={virtualizer}
renderItem={(message, i) => (
<MessageItem
visibleAvatar={visibleAvatar}
avatarDialog={avatarDialogSrc}
item={message}
nickname="You"
reference={messageReferences.get(message) ?? EmptyReference}
loading={
message.role === MessageType.Assistant &&
sendLoading &&
derivedMessages.length - 1 === i
}
index={i}
clickDocumentButton={clickDocumentButton}
showLikeButton={false}
showLoudspeaker={false}
/>
)}
/>
</div>
<div className="flex w-full justify-center md:mb-8">
<div className="w-full md:w-5/6">