Commit 7d5b6a5c by Xianquan Committed by GitHub

Chat UI improvement (#7118)

* feat: add chat scroll to bottom button

* fix: refine chat history pagination and scrollbar

* refactor: remove chat scroll button delay

* fix: align chat action buttons

* fix: stop chat auto scroll after user scrolls up

* fix: truncate long chat placeholder app name

* fix: refresh chat history silently after title update

* fix: stabilize chat input placeholder text type
parent 725529dd
...@@ -215,16 +215,21 @@ export function useScrollPagination< ...@@ -215,16 +215,21 @@ export function useScrollPagination<
const loadData = useLockFn( const loadData = useLockFn(
async ({ async ({
init = false, init = false,
ScrollContainerRef ScrollContainerRef,
silent = false
}: { }: {
init?: boolean; init?: boolean;
ScrollContainerRef?: RefObject<HTMLDivElement>; ScrollContainerRef?: RefObject<HTMLDivElement>;
silent?: boolean;
} = {}) => { } = {}) => {
if (noMore && !init) return; if (noMore && !init) return;
// 静默刷新用于后台校准数据,保留旧列表并避免整块 loading 闪烁。
if (!silent) {
setTrue(); setTrue();
}
if (init) { if (init && !silent) {
setData([]); setData([]);
setTotal(0); setTotal(0);
} }
...@@ -275,8 +280,10 @@ export function useScrollPagination< ...@@ -275,8 +280,10 @@ export function useScrollPagination<
console.log(error); console.log(error);
} }
if (!silent) {
setFalse(); setFalse();
} }
}
); );
let ScrollRef = useRef<HTMLDivElement>(null); let ScrollRef = useRef<HTMLDivElement>(null);
...@@ -375,6 +382,7 @@ export function useScrollPagination< ...@@ -375,6 +382,7 @@ export function useScrollPagination<
total: Math.max(total, data.length), total: Math.max(total, data.length),
data, data,
setData, setData,
setTotal,
fetchData: loadData, fetchData: loadData,
refreshList refreshList
}; };
......
...@@ -30,6 +30,7 @@ import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workf ...@@ -30,6 +30,7 @@ import type { WorkflowInteractiveResponseType } from '@fastgpt/global/core/workf
import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants'; import { ChatGenerateStatusEnum } from '@fastgpt/global/core/chat/constants';
const InputGuideBox = dynamic(() => import('./InputGuideBox')); const InputGuideBox = dynamic(() => import('./InputGuideBox'));
const PLACEHOLDER_APP_NAME_TOKEN = '__APP_NAME__';
const fileTypeFilter = (file: File) => { const fileTypeFilter = (file: File) => {
return ( return (
...@@ -85,8 +86,27 @@ const ChatInput = ({ ...@@ -85,8 +86,27 @@ const ChatInput = ({
const autoTTSResponse = useContextSelector(ChatBoxContext, (v) => v.autoTTSResponse); const autoTTSResponse = useContextSelector(ChatBoxContext, (v) => v.autoTTSResponse);
const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType); const chatType = useContextSelector(ChatBoxContext, (v) => v.chatType);
const appName = useContextSelector(ChatItemContext, (v) => v.chatBoxData.app.name); const appName = useContextSelector(ChatItemContext, (v) => v.chatBoxData.app.name);
const placeholderAppName = const placeholderAppName = chatType === ChatTypeEnum.home ? 'FastGPT' : appName || 'FastGPT';
chatType === ChatTypeEnum.home ? 'FastGPT' : appName || 'FastGPT'; const appNamePlaceholderParts = useMemo(() => {
const placeholderText = String(
t('common:core.chat.Type a message to app', {
appName: PLACEHOLDER_APP_NAME_TOKEN
})
);
const tokenIndex = placeholderText.indexOf(PLACEHOLDER_APP_NAME_TOKEN);
if (tokenIndex < 0) {
return {
prefix: placeholderText,
suffix: ''
};
}
return {
prefix: placeholderText.slice(0, tokenIndex),
suffix: placeholderText.slice(tokenIndex + PLACEHOLDER_APP_NAME_TOKEN.length)
};
}, [t]);
const fileCtrl = useFieldArray({ const fileCtrl = useFieldArray({
control, control,
...@@ -164,7 +184,7 @@ const ChatInput = ({ ...@@ -164,7 +184,7 @@ const ChatInput = ({
() => ( () => (
<Flex direction={'column'} mt={fileList.length > 0 ? 1 : 0}> <Flex direction={'column'} mt={fileList.length > 0 ? 1 : 0}>
{/* Textarea */} {/* Textarea */}
<Flex w={'100%'}> <Flex w={'100%'} position={'relative'}>
{/* Prompt Container */} {/* Prompt Container */}
<Textarea <Textarea
ref={TextareaDom} ref={TextareaDom}
...@@ -177,12 +197,10 @@ const ChatInput = ({ ...@@ -177,12 +197,10 @@ const ChatInput = ({
_focusVisible={{ _focusVisible={{
border: 'none' border: 'none'
}} }}
placeholder={ placeholder={dialogTips || ''}
dialogTips ||
t('common:core.chat.Type a message to app', { appName: placeholderAppName })
}
resize={'none'} resize={'none'}
rows={1} rows={1}
bg={'transparent'}
height={textareaMinH} height={textareaMinH}
lineHeight={textareaMinH} lineHeight={textareaMinH}
maxHeight={[24, 32]} maxHeight={[24, 32]}
...@@ -270,6 +288,37 @@ const ChatInput = ({ ...@@ -270,6 +288,37 @@ const ChatInput = ({
onFocus={onFocus} onFocus={onFocus}
onBlur={offFocus} onBlur={offFocus}
/> />
{!dialogTips && !inputValue && (
<Flex
pointerEvents={'none'}
position={'absolute'}
left={0}
right={0}
top={0}
h={textareaMinH}
alignItems={'center'}
color={'myGray.400'}
fontSize={'16px'}
lineHeight={textareaMinH}
letterSpacing={'0.5px'}
minW={0}
overflow={'hidden'}
whiteSpace={'nowrap'}
>
<Box flexShrink={0}>{appNamePlaceholderParts.prefix}</Box>
<Box
flex={'0 1 auto'}
minW={0}
maxW={'100%'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{placeholderAppName}
</Box>
<Box flexShrink={0}>{appNamePlaceholderParts.suffix}</Box>
</Flex>
)}
</Flex> </Flex>
</Flex> </Flex>
), ),
...@@ -277,6 +326,9 @@ const ChatInput = ({ ...@@ -277,6 +326,9 @@ const ChatInput = ({
fileList.length, fileList.length,
TextareaDom, TextareaDom,
dialogTips, dialogTips,
appNamePlaceholderParts.prefix,
appNamePlaceholderParts.suffix,
placeholderAppName,
isPc, isPc,
t, t,
inputValue, inputValue,
......
import React from 'react';
import { Box } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
type ScrollToBottomButtonProps = {
isVisible: boolean;
onClick: () => void;
};
/**
* 渲染聊天输入框上方的回到底部按钮。
*
* 组件只关心展示和点击事件,不读取滚动容器,也不处理 ChatBox 业务状态;是否展示由
* `useChatScroll` 根据滚动位置决定,便于后续调整位置或替换图标时不影响滚动逻辑。
*/
const ScrollToBottomButton = ({ isVisible, onClick }: ScrollToBottomButtonProps) => {
if (!isVisible) return null;
return (
<Box
as="button"
type="button"
aria-label="Scroll to bottom"
display="flex"
alignItems="center"
justifyContent="center"
boxSize="32px"
minW="32px"
minH="32px"
p="8px"
boxSizing="border-box"
position="absolute"
top="-48px"
left="50%"
transform="translateX(-50%)"
zIndex={1}
borderRadius="full"
border="1px solid"
borderColor="myGray.250"
boxShadow="0 1px 2px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08)"
bg="white"
color="myGray.600"
cursor="pointer"
_hover={{ bg: 'myGray.25' }}
_active={{ bg: 'myGray.50' }}
onClick={onClick}
>
<MyIcon
name="common/arrowLeft"
w="16px"
h="16px"
color="myGray.600"
transform="rotate(-90deg)"
transformOrigin="center"
/>
</Box>
);
};
export default React.memo(ScrollToBottomButton);
import { useRef } from 'react'; import { useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
import { useMemoizedFn, useThrottleFn } from 'ahooks'; import { useMemoizedFn, useThrottleFn } from 'ahooks';
import { shouldFollowGeneratingScroll } from '../utils/scrollUtils'; import {
isChatScrollAtBottom,
shouldFollowGeneratingScroll,
shouldShowChatScrollToBottomButton
} from '../utils/scrollUtils';
/** /**
* 管理 ChatBox 的滚动容器和生成中跟随底部逻辑。 * 管理 ChatBox 的滚动容器和生成中跟随底部逻辑。
...@@ -13,15 +17,79 @@ import { shouldFollowGeneratingScroll } from '../utils/scrollUtils'; ...@@ -13,15 +17,79 @@ import { shouldFollowGeneratingScroll } from '../utils/scrollUtils';
* - `ScrollContainerRef` 绑定到聊天记录滚动容器。 * - `ScrollContainerRef` 绑定到聊天记录滚动容器。
* - `scrollToBottom` 用于明确要求滚到底部,支持 smooth/auto 和延迟。 * - `scrollToBottom` 用于明确要求滚到底部,支持 smooth/auto 和延迟。
* - `generatingScroll` 用于流式生成中“条件跟随底部”,避免打断用户查看历史。 * - `generatingScroll` 用于流式生成中“条件跟随底部”,避免打断用户查看历史。
* - `isScrollAtBottom` 只描述当前滚动位置。
* - `isScrollToBottomButtonVisible` 由安全距离阈值控制,避免贴近底部时按钮闪烁。
* *
* 关键边界: * 关键边界:
* - `scrollToBottom` 保留 DOM 未挂载时的延迟重试,因为 ChatBox 有动态加载记录、 * - `scrollToBottom` 保留 DOM 未挂载时的延迟重试,因为 ChatBox 有动态加载记录、
* home/chat 分支切换和恢复生成占位消息,调用时机可能早于滚动容器真实出现。 * home/chat 分支切换和恢复生成占位消息,调用时机可能早于滚动容器真实出现。
* - `generatingScroll` 复用 `shouldFollowGeneratingScroll`,只有用户接近底部或调用方 * - 用户主动向上滚动后,当前轮生成不再自动吸附底部;滚回底部或显式点击回到底部后恢复。
* 传入 `force` 时才跟随;这能避免用户向上查看历史时被流式 token 拉回底部 * - `generatingScroll` 复用 `shouldFollowGeneratingScroll`,只有仍允许跟随且用户接近底部时才跟随
*/ */
export const useChatScroll = () => { export const useChatScroll = () => {
const ScrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const lastScrollTopRef = useRef(0);
const shouldFollowGeneratingRef = useRef(true);
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
const ScrollContainerRef = useMemo(
() =>
({
get current() {
return scrollContainerRef.current;
},
set current(container: HTMLDivElement | null) {
if (scrollContainerRef.current === container) return;
scrollContainerRef.current = container;
setScrollContainer(container);
}
}) as MutableRefObject<HTMLDivElement | null>,
[]
);
const [isScrollAtBottom, setIsScrollAtBottom] = useState(true);
const [isScrollToBottomButtonVisible, setIsScrollToBottomButtonVisible] = useState(false);
/**
* 同步用户是否已经滚动到底部。
*
* 这个状态只服务 UI 提示,不参与生成中的自动贴底逻辑,避免额外状态改变影响流式
* token 的滚动节奏。没有可滚动内容时视为已经在底部,因此不会展示回到底部按钮。
*/
const syncScrollAtBottom = useMemoizedFn(
({ fromScroll = false }: { fromScroll?: boolean } = {}) => {
const container = scrollContainerRef.current;
if (!container) {
shouldFollowGeneratingRef.current = true;
lastScrollTopRef.current = 0;
setIsScrollAtBottom(true);
setIsScrollToBottomButtonVisible(false);
return;
}
const scrollState = {
scrollTop: container.scrollTop,
clientHeight: container.clientHeight,
scrollHeight: container.scrollHeight
};
const nextIsScrollAtBottom = isChatScrollAtBottom(scrollState);
if (
fromScroll &&
container.scrollTop < lastScrollTopRef.current - 1 &&
!nextIsScrollAtBottom
) {
shouldFollowGeneratingRef.current = false;
}
if (nextIsScrollAtBottom) {
shouldFollowGeneratingRef.current = true;
}
lastScrollTopRef.current = container.scrollTop;
setIsScrollAtBottom(nextIsScrollAtBottom);
setIsScrollToBottomButtonVisible(
!nextIsScrollAtBottom && shouldShowChatScrollToBottomButton(scrollState)
);
}
);
/** /**
* 滚动到底部。 * 滚动到底部。
...@@ -31,15 +99,17 @@ export const useChatScroll = () => { ...@@ -31,15 +99,17 @@ export const useChatScroll = () => {
*/ */
const scrollToBottom = useMemoizedFn((behavior: 'smooth' | 'auto' = 'smooth', delay = 0) => { const scrollToBottom = useMemoizedFn((behavior: 'smooth' | 'auto' = 'smooth', delay = 0) => {
const runScroll = () => { const runScroll = () => {
if (!ScrollContainerRef.current) { if (!scrollContainerRef.current) {
setTimeout(runScroll, 500); setTimeout(runScroll, 500);
return; return;
} }
ScrollContainerRef.current.scrollTo({ shouldFollowGeneratingRef.current = true;
top: ScrollContainerRef.current.scrollHeight, scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior behavior
}); });
syncScrollAtBottom();
}; };
setTimeout(() => { setTimeout(() => {
...@@ -49,13 +119,15 @@ export const useChatScroll = () => { ...@@ -49,13 +119,15 @@ export const useChatScroll = () => {
const { run: generatingScroll } = useThrottleFn( const { run: generatingScroll } = useThrottleFn(
(force?: boolean) => { (force?: boolean) => {
if (!ScrollContainerRef.current) return; if (!scrollContainerRef.current) return;
if (!shouldFollowGeneratingRef.current) return;
// 流式响应会高频触发,先判断用户是否仍在底部附近,再决定是否滚动。 // 流式响应会高频触发,先判断用户是否仍在底部附近,再决定是否滚动。
// `force` 用于发送新消息、恢复占位等明确需要贴底的场景 // `force` 只绕过距离阈值,不绕过用户主动向上滚动后的暂停跟随状态
const isBottom = shouldFollowGeneratingScroll({ const isBottom = shouldFollowGeneratingScroll({
scrollTop: ScrollContainerRef.current.scrollTop, scrollTop: scrollContainerRef.current.scrollTop,
clientHeight: ScrollContainerRef.current.clientHeight, clientHeight: scrollContainerRef.current.clientHeight,
scrollHeight: ScrollContainerRef.current.scrollHeight, scrollHeight: scrollContainerRef.current.scrollHeight,
force force
}); });
...@@ -68,9 +140,53 @@ export const useChatScroll = () => { ...@@ -68,9 +140,53 @@ export const useChatScroll = () => {
} }
); );
useEffect(() => {
const container = scrollContainer;
if (!container) {
syncScrollAtBottom();
return;
}
syncScrollAtBottom();
const handleScroll = () => syncScrollAtBottom({ fromScroll: true });
const handleResize = () => syncScrollAtBottom();
container.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
const resizeObserver =
typeof ResizeObserver === 'undefined'
? undefined
: new ResizeObserver(() => {
syncScrollAtBottom();
});
resizeObserver?.observe(container);
const mutationObserver =
typeof MutationObserver === 'undefined'
? undefined
: new MutationObserver(() => {
syncScrollAtBottom();
});
mutationObserver?.observe(container, {
childList: true,
subtree: true,
characterData: true
});
return () => {
container.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [scrollContainer, syncScrollAtBottom]);
return { return {
ScrollContainerRef, ScrollContainerRef,
scrollToBottom, scrollToBottom,
generatingScroll generatingScroll,
isScrollAtBottom,
isScrollToBottomButtonVisible
}; };
}; };
...@@ -22,7 +22,7 @@ import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch ...@@ -22,7 +22,7 @@ import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/ch
* 关键边界: * 关键边界:
* - 主聊天请求已经 abort 时不再生成问题引导,否则用户点击停止后仍可能看到新推荐问题。 * - 主聊天请求已经 abort 时不再生成问题引导,否则用户点击停止后仍可能看到新推荐问题。
* - 每次请求前都会创建新的 AbortController,并写回 ref,让后续 stop/leave 能中断当前请求。 * - 每次请求前都会创建新的 AbortController,并写回 ref,让后续 stop/leave 能中断当前请求。
* - 结果写入后延迟滚动到底部,给推荐问题组件一次渲染高度的时间 * - 结果写入后延迟触发生成滚动,复用用户主动上滚后的暂停吸附语义
*/ */
export const useQuestionGuide = ({ export const useQuestionGuide = ({
appId, appId,
...@@ -32,7 +32,7 @@ export const useQuestionGuide = ({ ...@@ -32,7 +32,7 @@ export const useQuestionGuide = ({
chatControllerRef, chatControllerRef,
questionGuideControllerRef, questionGuideControllerRef,
setQuestionGuide, setQuestionGuide,
scrollToBottom generatingScroll
}: { }: {
appId: string; appId: string;
chatId: string; chatId: string;
...@@ -41,7 +41,7 @@ export const useQuestionGuide = ({ ...@@ -41,7 +41,7 @@ export const useQuestionGuide = ({
chatControllerRef: MutableRefObject<AbortController>; chatControllerRef: MutableRefObject<AbortController>;
questionGuideControllerRef: MutableRefObject<AbortController>; questionGuideControllerRef: MutableRefObject<AbortController>;
setQuestionGuide: (guides: string[]) => void; setQuestionGuide: (guides: string[]) => void;
scrollToBottom: (behavior?: 'smooth' | 'auto', delay?: number) => void; generatingScroll: (force?: boolean) => void;
}) => { }) => {
return useCallback(async () => { return useCallback(async () => {
// 保留拆分前语义:只用主聊天请求的 abort 状态阻止回答结束后的问题引导。 // 保留拆分前语义:只用主聊天请求的 abort 状态阻止回答结束后的问题引导。
...@@ -66,9 +66,9 @@ export const useQuestionGuide = ({ ...@@ -66,9 +66,9 @@ export const useQuestionGuide = ({
); );
if (Array.isArray(result)) { if (Array.isArray(result)) {
setQuestionGuide(result); setQuestionGuide(result);
// 推荐问题渲染会增加底部高度,延迟滚动可以避免在 DOM 高度更新前滚动失败 // 推荐问题渲染会增加底部高度,延迟到 DOM 更新后再按当前吸附状态决定是否跟随
setTimeout(() => { setTimeout(() => {
scrollToBottom(); generatingScroll();
}, 100); }, 100);
} }
} catch {} } catch {}
...@@ -80,6 +80,6 @@ export const useQuestionGuide = ({ ...@@ -80,6 +80,6 @@ export const useQuestionGuide = ({
chatId, chatId,
outLinkAuthData, outLinkAuthData,
setQuestionGuide, setQuestionGuide,
scrollToBottom generatingScroll
]); ]);
}; };
...@@ -8,7 +8,7 @@ import React, { ...@@ -8,7 +8,7 @@ import React, {
useLayoutEffect useLayoutEffect
} from 'react'; } from 'react';
import Script from 'next/script'; import Script from 'next/script';
import { Box, Flex } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus'; import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { postMarkChatRead } from '@/web/core/chat/history/api'; import { postMarkChatRead } from '@/web/core/chat/history/api';
...@@ -58,6 +58,7 @@ import ChatBoxModals from './components/ChatBoxModals'; ...@@ -58,6 +58,7 @@ import ChatBoxModals from './components/ChatBoxModals';
import type { ChatRecordsListProps } from './components/ChatRecordsList'; import type { ChatRecordsListProps } from './components/ChatRecordsList';
import AppChatMain from './components/AppChatMain'; import AppChatMain from './components/AppChatMain';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
import ScrollToBottomButton from './components/ScrollToBottomButton';
const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm')); const ChatHomeVariablesForm = dynamic(() => import('./components/home/ChatHomeVariablesForm'));
const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout')); const DesktopHomeLayout = dynamic(() => import('./components/home/DesktopHomeLayout'));
...@@ -118,7 +119,8 @@ const ChatBox = ({ ...@@ -118,7 +119,8 @@ const ChatBox = ({
const [questionGuides, setQuestionGuide] = useState<string[]>([]); const [questionGuides, setQuestionGuide] = useState<string[]>([]);
const [expandedDeletedGroups, setExpandedDeletedGroups] = useState<Set<string>>(new Set()); const [expandedDeletedGroups, setExpandedDeletedGroups] = useState<Set<string>>(new Set());
const { ScrollContainerRef, scrollToBottom, generatingScroll } = useChatScroll(); const { ScrollContainerRef, scrollToBottom, generatingScroll, isScrollToBottomButtonVisible } =
useChatScroll();
const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData); const chatBoxData = useContextSelector(ChatItemContext, (v) => v.chatBoxData);
const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData); const setChatBoxData = useContextSelector(ChatItemContext, (v) => v.setChatBoxData);
...@@ -266,7 +268,7 @@ const ChatBox = ({ ...@@ -266,7 +268,7 @@ const ChatBox = ({
chatControllerRef: chatController, chatControllerRef: chatController,
questionGuideControllerRef: questionGuideController, questionGuideControllerRef: questionGuideController,
setQuestionGuide, setQuestionGuide,
scrollToBottom generatingScroll
}); });
const { abortRequest, generatingMessage, sendPrompt } = useChatGenerate({ const { abortRequest, generatingMessage, sendPrompt } = useChatGenerate({
...@@ -380,6 +382,12 @@ const ChatBox = ({ ...@@ -380,6 +382,12 @@ const ChatBox = ({
const canRenderChatInput = onStartChat && chatStarted && active && canSendQuery; const canRenderChatInput = onStartChat && chatStarted && active && canSendQuery;
const canSendPrompt = canRenderChatInput && !isRoundPending; const canSendPrompt = canRenderChatInput && !isRoundPending;
const canRenderScrollToBottomButton =
(chatType === ChatTypeEnum.chat ||
chatType === ChatTypeEnum.home ||
chatType === ChatTypeEnum.test ||
chatType === ChatTypeEnum.share) &&
isScrollToBottomButtonVisible;
// Add listener // Add listener
useEffect(() => { useEffect(() => {
...@@ -583,6 +591,11 @@ const ChatBox = ({ ...@@ -583,6 +591,11 @@ const ChatBox = ({
{canRenderChatInput && ( {canRenderChatInput && (
<Box {...ChatInputWrapperStyle}> <Box {...ChatInputWrapperStyle}>
{showWorkorder && <WorkorderEntrance />} {showWorkorder && <WorkorderEntrance />}
<Box position="relative">
<ScrollToBottomButton
isVisible={canRenderScrollToBottomButton}
onClick={() => scrollToBottom('smooth')}
/>
<ChatInput <ChatInput
onSendMessage={sendPrompt} onSendMessage={sendPrompt}
...@@ -596,6 +609,7 @@ const ChatBox = ({ ...@@ -596,6 +609,7 @@ const ChatBox = ({
chatForm={chatForm} chatForm={chatForm}
/> />
</Box> </Box>
</Box>
)} )}
</> </>
)} )}
......
export const CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD = 150; export const CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD = 150;
export const CHAT_SCROLL_BOTTOM_VISIBILITY_THRESHOLD = 4;
export const CHAT_SCROLL_TO_BOTTOM_BUTTON_DISTANCE_THRESHOLD = 48;
export const getChatScrollTargetKey = ({ appId, chatId }: { appId?: string; chatId?: string }) => { export const getChatScrollTargetKey = ({ appId, chatId }: { appId?: string; chatId?: string }) => {
if (!appId || !chatId) return; if (!appId || !chatId) return;
...@@ -31,3 +33,47 @@ export const shouldFollowGeneratingScroll = ({ ...@@ -31,3 +33,47 @@ export const shouldFollowGeneratingScroll = ({
force?: boolean; force?: boolean;
threshold?: number; threshold?: number;
}) => force || scrollTop + clientHeight + threshold >= scrollHeight; }) => force || scrollTop + clientHeight + threshold >= scrollHeight;
export const getChatScrollBottomDistance = ({
scrollTop,
clientHeight,
scrollHeight
}: {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
}) => Math.max(scrollHeight - scrollTop - clientHeight, 0);
export const isChatScrollAtBottom = ({
scrollTop,
clientHeight,
scrollHeight,
threshold = CHAT_SCROLL_BOTTOM_VISIBILITY_THRESHOLD
}: {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
threshold?: number;
}) =>
getChatScrollBottomDistance({
scrollTop,
clientHeight,
scrollHeight
}) <= threshold;
export const shouldShowChatScrollToBottomButton = ({
scrollTop,
clientHeight,
scrollHeight,
threshold = CHAT_SCROLL_TO_BOTTOM_BUTTON_DISTANCE_THRESHOLD
}: {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
threshold?: number;
}) =>
getChatScrollBottomDistance({
scrollTop,
clientHeight,
scrollHeight
}) > threshold;
import React, { useEffect, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import HelperBotContextProvider, { type HelperBotProps } from './context'; import HelperBotContextProvider, { type HelperBotProps } from './context';
import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/helperBot/type'; import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/helperBot/type';
...@@ -20,7 +20,7 @@ import type { ChatBoxInputFormType } from '../ChatContainer/ChatBox/type'; ...@@ -20,7 +20,7 @@ import type { ChatBoxInputFormType } from '../ChatContainer/ChatBox/type';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useMemoizedFn, useThrottleFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { import {
HelperBotTypeEnum, HelperBotTypeEnum,
type HelperBotChatItemSiteType type HelperBotChatItemSiteType
...@@ -31,12 +31,15 @@ import { streamFetch } from '@/web/common/api/fetch'; ...@@ -31,12 +31,15 @@ import { streamFetch } from '@/web/common/api/fetch';
import type { generatingMessageProps } from '../ChatContainer/type'; import type { generatingMessageProps } from '../ChatContainer/type';
import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { SseResponseEventEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import { getErrText } from '@fastgpt/global/common/error/utils'; import { getErrText } from '@fastgpt/global/common/error/utils';
import { useChatScroll } from '../ChatContainer/ChatBox/hooks/useChatScroll';
import ScrollToBottomButton from '../ChatContainer/ChatBox/components/ScrollToBottomButton';
const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotProps) => { const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const ScrollContainerRef = useRef<HTMLDivElement>(null); const { ScrollContainerRef, scrollToBottom, generatingScroll, isScrollToBottomButtonVisible } =
useChatScroll();
// Messages 管理 // Messages 管理
const [chatId, setChatId] = useState<string>(getNanoid(12)); const [chatId, setChatId] = useState<string>(getNanoid(12));
const [isChatting, setIsChatting] = useState(false); const [isChatting, setIsChatting] = useState(false);
...@@ -58,20 +61,6 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -58,20 +61,6 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
}; };
}, []); }, []);
const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth', delay = 0) => {
setTimeout(() => {
if (!ScrollContainerRef.current) {
setTimeout(() => {
scrollToBottom(behavior);
}, 500);
} else {
ScrollContainerRef.current.scrollTo({
top: ScrollContainerRef.current.scrollHeight,
behavior
});
}
}, delay);
}, []);
const { const {
data: chatRecords, data: chatRecords,
setData: setChatRecords, setData: setChatRecords,
...@@ -126,21 +115,6 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -126,21 +115,6 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
const TextareaDom = useRef<HTMLTextAreaElement>(null); const TextareaDom = useRef<HTMLTextAreaElement>(null);
// Message request // Message request
const { run: generatingScroll } = useThrottleFn(
(force?: boolean) => {
if (!ScrollContainerRef.current) return;
const isBottom =
ScrollContainerRef.current.scrollTop + ScrollContainerRef.current.clientHeight + 150 >=
ScrollContainerRef.current.scrollHeight;
if (isBottom || force) {
scrollToBottom('auto');
}
},
{
wait: 100
}
);
const generatingMessage = useMemoizedFn( const generatingMessage = useMemoizedFn(
({ event, text = '', reasoningText, collectionForm, formData }: generatingMessageProps) => { ({ event, text = '', reasoningText, collectionForm, formData }: generatingMessageProps) => {
setChatRecords((state) => setChatRecords((state) =>
...@@ -409,6 +383,12 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -409,6 +383,12 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
))} ))}
</ScrollData> </ScrollData>
<Box {...ChatInputWrapperStyle}> <Box {...ChatInputWrapperStyle}>
<Box position="relative">
<ScrollToBottomButton
isVisible={isScrollToBottomButtonVisible}
onClick={() => scrollToBottom('smooth')}
/>
<ChatInput <ChatInput
TextareaDom={TextareaDom} TextareaDom={TextareaDom}
chatId={chatId} chatId={chatId}
...@@ -418,6 +398,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro ...@@ -418,6 +398,7 @@ const ChatBox = ({ type, metadata, onApply, ChatBoxRef, ...props }: HelperBotPro
onStop={() => {}} onStop={() => {}}
/> />
</Box> </Box>
</Box>
</MyBox> </MyBox>
); );
}; };
......
...@@ -15,7 +15,6 @@ import { useChatStore } from '@/web/core/chat/context/useChatStore'; ...@@ -15,7 +15,6 @@ import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { cardStyles } from '../../constants'; import { cardStyles } from '../../constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList'; import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants'; import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import type { Form2WorkflowFnType } from '../FormComponent/type'; import type { Form2WorkflowFnType } from '../FormComponent/type';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
...@@ -29,6 +28,7 @@ import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/Sandbo ...@@ -29,6 +28,7 @@ import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/Sandbo
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useUserStore } from '@/web/support/user/useUserStore'; import { useUserStore } from '@/web/support/user/useUserStore';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import ChatVariableButton from '@/pageComponents/chat/ChatWindow/ChatVariableButton';
type Props = { type Props = {
appForm: AppFormEditFormType; appForm: AppFormEditFormType;
...@@ -56,8 +56,6 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -56,8 +56,6 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
const { appDetail } = useContextSelector(AppContext, (v) => v); const { appDetail } = useContextSelector(AppContext, (v) => v);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData); const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData); const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
// agentForm2AppWorkflow dependent allDatasets
const isVariableVisible = useContextSelector(ChatItemContext, (v) => v.isVariableVisible);
const [workflowData, setWorkflowData] = useSafeState({ const [workflowData, setWorkflowData] = useSafeState({
nodes: appDetail.modules || [], nodes: appDetail.modules || [],
...@@ -141,7 +139,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -141,7 +139,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
{...cardStyles} {...cardStyles}
boxShadow={'3'} boxShadow={'3'}
> >
<Flex px={[2, 5]} pb={2}> <Flex px={[2, 5]} pb={2} alignItems={'center'}>
<FillRowTabs<'helper' | 'chat_debug'> <FillRowTabs<'helper' | 'chat_debug'>
py={1} py={1}
list={[ list={[
...@@ -160,12 +158,10 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -160,12 +158,10 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
}} }}
/> />
{!isVariableVisible && activeTab === 'chat_debug' && (
<VariablePopover chatType={ChatTypeEnum.test} />
)}
<Box flex={1} /> <Box flex={1} />
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} /> <Flex alignItems={'center'} gap={2}>
<SandboxEntryIcon size={'smSquare'} onOpen={onOpenSandboxModal} />
{activeTab === 'chat_debug' && <ChatVariableButton chatType={ChatTypeEnum.test} />}
<MyTooltip label={t('common:core.chat.Restart')}> <MyTooltip label={t('common:core.chat.Restart')}>
<IconButton <IconButton
className="chat" className="chat"
...@@ -181,6 +177,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props ...@@ -181,6 +177,7 @@ const ChatTest = ({ appForm, setAppForm, setRenderEdit, form2WorkflowFn }: Props
/> />
</MyTooltip> </MyTooltip>
</Flex> </Flex>
</Flex>
<Box flex={1}> <Box flex={1}>
{activeTab === 'helper' && ( {activeTab === 'helper' && (
<HelperBot <HelperBot
......
...@@ -15,10 +15,10 @@ import { useChatStore } from '@/web/core/chat/context/useChatStore'; ...@@ -15,10 +15,10 @@ import { useChatStore } from '@/web/core/chat/context/useChatStore';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import { cardStyles } from '../../constants'; import { cardStyles } from '../../constants';
import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList'; import ChatQuoteList from '@/pageComponents/chat/ChatQuoteList';
import VariablePopover from '@/components/core/chat/ChatContainer/components/VariablePopover';
import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants'; import { ChatTypeEnum } from '@/components/core/chat/ChatContainer/ChatBox/constants';
import type { Form2WorkflowFnType } from '../FormComponent/type'; import type { Form2WorkflowFnType } from '../FormComponent/type';
import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook'; import { useSandboxEditor, useSandboxStatus } from '@/pageComponents/chat/SandboxEditor/hook';
import ChatVariableButton from '@/pageComponents/chat/ChatWindow/ChatVariableButton';
type Props = { type Props = {
appForm: AppFormEditFormType; appForm: AppFormEditFormType;
...@@ -32,8 +32,6 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => { ...@@ -32,8 +32,6 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
const { appDetail } = useContextSelector(AppContext, (v) => v); const { appDetail } = useContextSelector(AppContext, (v) => v);
const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData); const datasetCiteData = useContextSelector(ChatItemContext, (v) => v.datasetCiteData);
const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData); const setCiteModalData = useContextSelector(ChatItemContext, (v) => v.setCiteModalData);
// agentForm2AppWorkflow dependent allDatasets
const isVariableVisible = useContextSelector(ChatItemContext, (v) => v.isVariableVisible);
const [workflowData, setWorkflowData] = useSafeState({ const [workflowData, setWorkflowData] = useSafeState({
nodes: appDetail.modules || [], nodes: appDetail.modules || [],
...@@ -79,14 +77,15 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => { ...@@ -79,14 +77,15 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
{...cardStyles} {...cardStyles}
boxShadow={'3'} boxShadow={'3'}
> >
<Flex px={[2, 5]} pb={2}> <Flex px={[2, 5]} pb={2} alignItems={'center'}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} color={'myGray.900'} mr={3}> <Box fontSize={['md', 'lg']} fontWeight={'bold'} color={'myGray.900'} mr={3}>
{t('app:chat_debug')} {t('app:chat_debug')}
</Box> </Box>
{!isVariableVisible && <VariablePopover chatType={ChatTypeEnum.test} />}
<Box flex={1} /> <Box flex={1} />
<SandboxEntryIcon size={'smSquare'} mr={2} onOpen={onOpenSandboxModal} /> <Flex alignItems={'center'} gap={2}>
<SandboxEntryIcon size={'smSquare'} onOpen={onOpenSandboxModal} />
<ChatVariableButton chatType={ChatTypeEnum.test} />
<MyTooltip label={t('common:core.chat.Restart')}> <MyTooltip label={t('common:core.chat.Restart')}>
<IconButton <IconButton
className="chat" className="chat"
...@@ -103,6 +102,7 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => { ...@@ -103,6 +102,7 @@ const ChatTest = ({ appForm, setRenderEdit, form2WorkflowFn }: Props) => {
/> />
</MyTooltip> </MyTooltip>
</Flex> </Flex>
</Flex>
<Box flex={1}> <Box flex={1}>
<ChatContainer /> <ChatContainer />
</Box> </Box>
......
...@@ -48,8 +48,8 @@ const RunPreviewHeader = ({ ...@@ -48,8 +48,8 @@ const RunPreviewHeader = ({
</MyTooltip> </MyTooltip>
<Flex position="absolute" right="24px" alignItems="center" gap={2}> <Flex position="absolute" right="24px" alignItems="center" gap={2}>
<ChatVariableButton chatType={ChatTypeEnum.test} />
<SandboxEntryIcon onOpen={onOpenSandboxModal} /> <SandboxEntryIcon onOpen={onOpenSandboxModal} />
<ChatVariableButton chatType={ChatTypeEnum.test} />
<MyTooltip label={restartLabel}> <MyTooltip label={restartLabel}>
<IconButton <IconButton
className="chat" className="chat"
......
...@@ -31,9 +31,9 @@ const ChatWindowHeader = ({ ...@@ -31,9 +31,9 @@ const ChatWindowHeader = ({
> >
{title} {title}
<Flex position="absolute" right={5} alignItems="center" gap={2}> <Flex position="absolute" right={5} alignItems="center" gap={2}>
{rightActions}
<ChatVariableButton chatType={chatType} /> <ChatVariableButton chatType={chatType} />
{hasHistory && <MarkdownExportButton history={history} />} {hasHistory && <MarkdownExportButton history={history} />}
{rightActions}
</Flex> </Flex>
</Flex> </Flex>
); );
......
...@@ -105,15 +105,24 @@ const ToolMenu = ({ ...@@ -105,15 +105,24 @@ const ToolMenu = ({
menuList={[ menuList={[
{ {
children: [ children: [
...(!isPc && sandboxExists
? [
{ {
icon: 'core/chat/chatLight', icon: 'core/chat/monitor' as const,
label: t('common:core.chat.New Chat'), label: t('app:use_agent_sandbox'),
onClick: () => { onClick: () => onOpenSandboxModal()
clearChatRecords();
onChangeChatId();
setSandboxExists(false);
} }
}, ]
: []),
...(!isPc && chatType && hasVariables
? [
{
icon: 'core/chat/var' as const,
label: t('common:core.module.Variable'),
onClick: () => setIsVariableDrawerOpen(true)
}
]
: []),
// { // {
// icon: 'core/app/appApiLight', // icon: 'core/app/appApiLight',
// label: `HTML ${t('common:Export')}`, // label: `HTML ${t('common:Export')}`,
...@@ -128,24 +137,15 @@ const ToolMenu = ({ ...@@ -128,24 +137,15 @@ const ToolMenu = ({
} }
] ]
: []), : []),
...(!isPc && chatType && hasVariables
? [
{ {
icon: 'core/chat/var' as const, icon: 'core/chat/chatLight',
label: t('common:core.module.Variable'), label: t('common:core.chat.New Chat'),
onClick: () => setIsVariableDrawerOpen(true) onClick: () => {
clearChatRecords();
onChangeChatId();
setSandboxExists(false);
} }
]
: []),
...(!isPc && sandboxExists
? [
{
icon: 'core/chat/monitor' as const,
label: t('app:use_agent_sandbox'),
onClick: () => onOpenSandboxModal()
} }
]
: [])
// { // {
// icon: 'core/chat/export/pdf', // icon: 'core/chat/export/pdf',
// label: `PDF ${t('common:Export')}`, // label: `PDF ${t('common:Export')}`,
......
...@@ -112,7 +112,11 @@ const ChatSliderList = () => { ...@@ -112,7 +112,11 @@ const ChatSliderList = () => {
<ScrollData <ScrollData
flex={'1 0 0'} flex={'1 0 0'}
h={0} h={0}
p={0} pt={0}
pl={0}
pr={'16px'}
pb={'16px'}
mr={'-16px'}
overflowY={'auto'} overflowY={'auto'}
overflowX={'hidden'} overflowX={'hidden'}
sx={{ sx={{
......
...@@ -33,7 +33,7 @@ type ChatContextType = { ...@@ -33,7 +33,7 @@ type ChatContextType = {
setHistories: React.Dispatch<React.SetStateAction<ChatHistoryItemType[]>>; setHistories: React.Dispatch<React.SetStateAction<ChatHistoryItemType[]>>;
forbidLoadChat: React.MutableRefObject<boolean>; forbidLoadChat: React.MutableRefObject<boolean>;
onChangeChatId: (chatId?: string, forbid?: boolean) => void; onChangeChatId: (chatId?: string, forbid?: boolean) => void;
loadHistories: () => void; loadHistories: (options?: { init?: boolean; silent?: boolean }) => void;
ScrollData: ({ ScrollData: ({
children, children,
EmptyChildren, EmptyChildren,
...@@ -116,6 +116,7 @@ const ChatContextProvider = ({ ...@@ -116,6 +116,7 @@ const ChatContextProvider = ({
ScrollData, ScrollData,
isLoading: isPaginationLoading, isLoading: isPaginationLoading,
setData: setHistories, setData: setHistories,
setTotal: setHistoriesTotal,
fetchData: loadHistories, fetchData: loadHistories,
data: histories data: histories
} = useScrollPagination(getChatHistories, { } = useScrollPagination(getChatHistories, {
...@@ -124,6 +125,11 @@ const ChatContextProvider = ({ ...@@ -124,6 +125,11 @@ const ChatContextProvider = ({
refreshDeps: [params], refreshDeps: [params],
showErrorToast: false showErrorToast: false
}); });
const historiesRef = useRef(histories);
useEffect(() => {
historiesRef.current = histories;
}, [histories]);
const onChangeChatId = useCallback( const onChangeChatId = useCallback(
(changeChatId = getNanoid(24), forbid = false) => { (changeChatId = getNanoid(24), forbid = false) => {
...@@ -204,7 +210,12 @@ const ChatContextProvider = ({ ...@@ -204,7 +210,12 @@ const ChatContextProvider = ({
{ {
onSuccess(data, params) { onSuccess(data, params) {
const chatId = params[0]; const chatId = params[0];
const hasDeletedHistory = historiesRef.current.some((i) => i.chatId === chatId);
setHistories((old) => old.filter((i) => i.chatId !== chatId)); setHistories((old) => old.filter((i) => i.chatId !== chatId));
if (hasDeletedHistory) {
setHistoriesTotal((total) => Math.max(total - 1, 0));
}
}, },
refreshDeps: [outLinkAuthData, historyAppId] refreshDeps: [outLinkAuthData, historyAppId]
} }
...@@ -220,6 +231,7 @@ const ChatContextProvider = ({ ...@@ -220,6 +231,7 @@ const ChatContextProvider = ({
refreshDeps: [outLinkAuthData, historyAppId], refreshDeps: [outLinkAuthData, historyAppId],
onSuccess() { onSuccess() {
setHistories([]); setHistories([]);
setHistoriesTotal(0);
}, },
onFinally() { onFinally() {
onChangeChatId(); onChangeChatId();
...@@ -241,20 +253,15 @@ const ChatContextProvider = ({ ...@@ -241,20 +253,15 @@ const ChatContextProvider = ({
fallbackTitle: '新对话' fallbackTitle: '新对话'
}) })
); );
loadHistories({ init: true }); loadHistories({ init: true, silent: true });
}, },
[historyAppId, loadHistories, setHistories] [historyAppId, loadHistories, setHistories]
); );
const historyChatIdsKey = useMemo(() => histories.map((h) => h.chatId).join(','), [histories]); const historyChatIdsKey = useMemo(() => histories.map((h) => h.chatId).join(','), [histories]);
const historiesRef = useRef(histories);
const prevHistoryAppIdRef = useRef<string | null>(null); const prevHistoryAppIdRef = useRef<string | null>(null);
const pendingAppChatRestoreRef = useRef(false); const pendingAppChatRestoreRef = useRef(false);
useEffect(() => {
historiesRef.current = histories;
}, [histories]);
/** 切换应用后,若当前 chatId 无效则恢复该应用上次会话或最近一条历史 */ /** 切换应用后,若当前 chatId 无效则恢复该应用上次会话或最近一条历史 */
useEffect(() => { useEffect(() => {
if (prevHistoryAppIdRef.current === null) { if (prevHistoryAppIdRef.current === null) {
......
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD, CHAT_GENERATING_SCROLL_BOTTOM_THRESHOLD,
CHAT_SCROLL_TO_BOTTOM_BUTTON_DISTANCE_THRESHOLD,
getChatScrollBottomDistance,
getChatScrollTargetKey, getChatScrollTargetKey,
isChatScrollAtBottom,
shouldFollowGeneratingScroll, shouldFollowGeneratingScroll,
shouldForceScrollAfterRecordsLoaded shouldForceScrollAfterRecordsLoaded,
shouldShowChatScrollToBottomButton
} from '@/components/core/chat/ChatContainer/ChatBox/utils/scrollUtils'; } from '@/components/core/chat/ChatContainer/ChatBox/utils/scrollUtils';
describe('ChatBox scrollUtils', () => { describe('ChatBox scrollUtils', () => {
...@@ -89,4 +93,52 @@ describe('ChatBox scrollUtils', () => { ...@@ -89,4 +93,52 @@ describe('ChatBox scrollUtils', () => {
}) })
).toBe(true); ).toBe(true);
}); });
it('should identify whether chat scroll is already at bottom', () => {
expect(
isChatScrollAtBottom({
scrollTop: 496,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(true);
expect(
isChatScrollAtBottom({
scrollTop: 495,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(false);
expect(
isChatScrollAtBottom({
scrollTop: 0,
clientHeight: 500,
scrollHeight: 500
})
).toBe(true);
});
it('should show scroll-to-bottom button only after leaving bottom enough', () => {
expect(
getChatScrollBottomDistance({
scrollTop: 400,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(100);
expect(
shouldShowChatScrollToBottomButton({
scrollTop: 1000 - 500 - CHAT_SCROLL_TO_BOTTOM_BUTTON_DISTANCE_THRESHOLD,
clientHeight: 500,
scrollHeight: 1000
})
).toBe(false);
expect(
shouldShowChatScrollToBottomButton({
scrollTop: 1000 - 500 - CHAT_SCROLL_TO_BOTTOM_BUTTON_DISTANCE_THRESHOLD - 1,
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