Commit 6ea0fa96 by Xianquan Committed by GitHub

fix: scroll chat resume to bottom (#6928)

parent e98a77e1
...@@ -73,6 +73,11 @@ import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; ...@@ -73,6 +73,11 @@ import { TeamErrEnum } from '@fastgpt/global/common/error/code/team';
import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
import {
getChatScrollTargetKey,
shouldFollowGeneratingScroll,
shouldForceScrollAfterRecordsLoaded
} from './scrollUtils';
const FeedbackModal = dynamic(() => import('./components/FeedbackModal')); const FeedbackModal = dynamic(() => import('./components/FeedbackModal'));
const SelectMarkCollection = dynamic(() => import('./components/SelectMarkCollection')); const SelectMarkCollection = dynamic(() => import('./components/SelectMarkCollection'));
...@@ -156,6 +161,7 @@ const ChatBox = ({ ...@@ -156,6 +161,7 @@ const ChatBox = ({
const pluginController = useRef(new AbortController()); const pluginController = useRef(new AbortController());
const resumeController = useRef<AbortController>(); const resumeController = useRef<AbortController>();
const resumedChatTargetRef = useRef<string>(); const resumedChatTargetRef = useRef<string>();
const lastRecordsLoadedScrollTargetRef = useRef<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [feedbackId, setFeedbackId] = useState<string>(); const [feedbackId, setFeedbackId] = useState<string>();
...@@ -185,6 +191,10 @@ const ChatBox = ({ ...@@ -185,6 +191,10 @@ const ChatBox = ({
activeAppIdRef.current = appId; activeAppIdRef.current = appId;
const activeChatIdRef = useRef<string | undefined>(chatId); const activeChatIdRef = useRef<string | undefined>(chatId);
activeChatIdRef.current = chatId; activeChatIdRef.current = chatId;
const chatScrollTargetKey = useMemo(
() => getChatScrollTargetKey({ appId, chatId }),
[appId, chatId]
);
const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData); const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData);
const welcomeText = useContextSelector(ChatBoxContext, (v) => v.welcomeText); const welcomeText = useContextSelector(ChatBoxContext, (v) => v.welcomeText);
const variableList = useContextSelector(ChatBoxContext, (v) => v.variableList); const variableList = useContextSelector(ChatBoxContext, (v) => v.variableList);
...@@ -325,11 +335,14 @@ const ChatBox = ({ ...@@ -325,11 +335,14 @@ const ChatBox = ({
const { run: generatingScroll } = useThrottleFn( const { run: generatingScroll } = useThrottleFn(
(force?: boolean) => { (force?: boolean) => {
if (!ScrollContainerRef.current) return; if (!ScrollContainerRef.current) return;
const isBottom = const isBottom = shouldFollowGeneratingScroll({
ScrollContainerRef.current.scrollTop + ScrollContainerRef.current.clientHeight + 150 >= scrollTop: ScrollContainerRef.current.scrollTop,
ScrollContainerRef.current.scrollHeight; clientHeight: ScrollContainerRef.current.clientHeight,
scrollHeight: ScrollContainerRef.current.scrollHeight,
force
});
if (isBottom || force) { if (isBottom) {
scrollToBottom('auto'); scrollToBottom('auto');
} }
}, },
...@@ -1274,6 +1287,21 @@ const ChatBox = ({ ...@@ -1274,6 +1287,21 @@ const ChatBox = ({
useEffect(() => { useEffect(() => {
if ( if (
!shouldForceScrollAfterRecordsLoaded({
isChatRecordsLoaded,
targetKey: chatScrollTargetKey,
lastScrolledTargetKey: lastRecordsLoadedScrollTargetRef.current
})
) {
return;
}
lastRecordsLoadedScrollTargetRef.current = chatScrollTargetKey;
scrollToBottom('auto');
}, [chatScrollTargetKey, isChatRecordsLoaded, scrollToBottom]);
useEffect(() => {
if (
!enableAutoResume || !enableAutoResume ||
!isReady || !isReady ||
!isChatRecordsLoaded || !isChatRecordsLoaded ||
...@@ -1296,6 +1324,7 @@ const ChatBox = ({ ...@@ -1296,6 +1324,7 @@ const ChatBox = ({
const controller = new AbortController(); const controller = new AbortController();
resumeController.current = controller; resumeController.current = controller;
scrollToBottom('auto'); scrollToBottom('auto');
scrollToBottom('auto', 100);
let resumeFinalStatus = ChatGenerateStatusEnum.done; let resumeFinalStatus = ChatGenerateStatusEnum.done;
let hasPreparedResumeAiRecord = false; let hasPreparedResumeAiRecord = false;
...@@ -1337,6 +1366,8 @@ const ChatBox = ({ ...@@ -1337,6 +1366,8 @@ const ChatBox = ({
status: ChatStatusEnum.finish status: ChatStatusEnum.finish
})) }))
); );
scrollToBottom('auto');
scrollToBottom('auto', 100);
return; return;
} }
...@@ -1380,6 +1411,7 @@ const ChatBox = ({ ...@@ -1380,6 +1411,7 @@ const ChatBox = ({
return next; return next;
}); });
scrollToBottom('auto');
} catch (error) { } catch (error) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return; if (!isActiveResumeTarget({ appId: resumeForAppId, chatId: resumeForChatId })) return;
...@@ -1417,6 +1449,7 @@ const ChatBox = ({ ...@@ -1417,6 +1449,7 @@ const ChatBox = ({
return next; return next;
}); });
scrollToBottom('auto');
if (isStreamError) { if (isStreamError) {
toast({ toast({
...@@ -1452,6 +1485,7 @@ const ChatBox = ({ ...@@ -1452,6 +1485,7 @@ const ChatBox = ({
); );
if (finishedInActiveChat) { if (finishedInActiveChat) {
scrollToBottom('auto', 100);
void postMarkChatRead({ void postMarkChatRead({
appId: resumeForAppId, appId: resumeForAppId,
chatId: resumeForChatId, chatId: resumeForChatId,
......
export const CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD = 150;
export const getChatScrollTargetKey = ({ appId, chatId }: { appId?: string; chatId?: string }) => {
if (!appId || !chatId) return;
return `${appId}:${chatId}`;
};
export const shouldForceScrollAfterRecordsLoaded = ({
isChatRecordsLoaded,
targetKey,
lastScrolledTargetKey
}: {
isChatRecordsLoaded: boolean;
targetKey?: string;
lastScrolledTargetKey?: string;
}) => {
if (!isChatRecordsLoaded || !targetKey) return false;
return targetKey !== lastScrolledTargetKey;
};
export const shouldFollowGeneratingScroll = ({
scrollTop,
clientHeight,
scrollHeight,
force = false,
threshold = CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD
}: {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
force?: boolean;
threshold?: number;
}) => force || scrollTop + clientHeight + threshold >= scrollHeight;
import { describe, expect, it } from 'vitest';
import {
CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD,
getChatScrollTargetKey,
shouldFollowGeneratingScroll,
shouldForceScrollAfterRecordsLoaded
} from '@/components/core/chat/ChatContainer/ChatBox/scrollUtils';
describe('ChatBox scrollUtils', () => {
it('should build stable scroll target keys only when appId and chatId exist', () => {
expect(getChatScrollTargetKey({ appId: 'app-1', chatId: 'chat-1' })).toBe('app-1:chat-1');
expect(getChatScrollTargetKey({ appId: 'app-1' })).toBeUndefined();
expect(getChatScrollTargetKey({ chatId: 'chat-1' })).toBeUndefined();
});
it('should force scroll after a different chat records loaded', () => {
expect(
shouldForceScrollAfterRecordsLoaded({
isChatRecordsLoaded: false,
targetKey: 'app-1:chat-1',
lastScrolledTargetKey: undefined
})
).toBe(false);
expect(
shouldForceScrollAfterRecordsLoaded({
isChatRecordsLoaded: true,
targetKey: undefined,
lastScrolledTargetKey: undefined
})
).toBe(false);
expect(
shouldForceScrollAfterRecordsLoaded({
isChatRecordsLoaded: true,
targetKey: 'app-1:chat-1',
lastScrolledTargetKey: undefined
})
).toBe(true);
expect(
shouldForceScrollAfterRecordsLoaded({
isChatRecordsLoaded: true,
targetKey: 'app-1:chat-2',
lastScrolledTargetKey: 'app-1:chat-1'
})
).toBe(true);
expect(
shouldForceScrollAfterRecordsLoaded({
isChatRecordsLoaded: true,
targetKey: 'app-1:chat-1',
lastScrolledTargetKey: 'app-1:chat-1'
})
).toBe(false);
});
it('should keep generating follow-scroll limited to near-bottom unless forced', () => {
expect(
shouldFollowGeneratingScroll({
scrollTop: 0,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(false);
expect(
shouldFollowGeneratingScroll({
scrollTop: 350,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(true);
expect(
shouldFollowGeneratingScroll({
scrollTop: 0,
clientHeight: 500,
scrollHeight: 1000,
force: true
})
).toBe(true);
expect(
shouldFollowGeneratingScroll({
scrollTop: 1000 - 500 - CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD - 1,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(false);
expect(
shouldFollowGeneratingScroll({
scrollTop: 1000 - 500 - CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(true);
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment