diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 724ef78e758966..ad9516621074fd 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -34,6 +34,8 @@ const ChatWrapper = () => { currentChatInstanceRef, appData, themeBuilder, + setChatListSize, + hasMore, } = useChatWithHistoryContext() const appConfig = useMemo(() => { const config = appParams || {} @@ -69,7 +71,7 @@ const ChatWrapper = () => { useEffect(() => { if (currentChatInstanceRef.current) currentChatInstanceRef.current.handleStop = handleStop - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const doSend: OnSend = useCallback((message, files, last_answer) => { @@ -163,6 +165,11 @@ const ChatWrapper = () => { /> : null + const onScrollToTop = useCallback(() => { + if (hasMore) + setChatListSize(size => size + 1) + }, [setChatListSize, hasMore]) + return (
{ answerIcon={answerIcon} hideProcessDetail themeBuilder={themeBuilder} + onScrollToTop={onScrollToTop} + hasMoreMessages={hasMore} />
) diff --git a/web/app/components/base/chat/chat-with-history/context.tsx b/web/app/components/base/chat/chat-with-history/context.tsx index 060c178993fafc..04bdb57eaa11cb 100644 --- a/web/app/components/base/chat/chat-with-history/context.tsx +++ b/web/app/components/base/chat/chat-with-history/context.tsx @@ -49,6 +49,8 @@ export type ChatWithHistoryContextValue = { handleFeedback: (messageId: string, feedback: Feedback) => void currentChatInstanceRef: RefObject<{ handleStop: () => void }> themeBuilder?: ThemeBuilder + hasMore: boolean + setChatListSize: (size: number | ((_size: number) => number)) => void } export const ChatWithHistoryContext = createContext({ @@ -59,21 +61,23 @@ export const ChatWithHistoryContext = createContext showConfigPanelBeforeChat: false, newConversationInputs: {}, newConversationInputsRef: { current: {} }, - handleNewConversationInputsChange: () => {}, + handleNewConversationInputsChange: () => { }, inputsForms: [], - handleNewConversation: () => {}, - handleStartChat: () => {}, - handleChangeConversation: () => {}, - handlePinConversation: () => {}, - handleUnpinConversation: () => {}, - handleDeleteConversation: () => {}, + handleNewConversation: () => { }, + handleStartChat: () => { }, + handleChangeConversation: () => { }, + handlePinConversation: () => { }, + handleUnpinConversation: () => { }, + handleDeleteConversation: () => { }, conversationRenaming: false, - handleRenameConversation: () => {}, - handleNewConversationCompleted: () => {}, + handleRenameConversation: () => { }, + handleNewConversationCompleted: () => { }, chatShouldReloadKey: '', isMobile: false, isInstalledApp: false, - handleFeedback: () => {}, - currentChatInstanceRef: { current: { handleStop: () => {} } }, + handleFeedback: () => { }, + currentChatInstanceRef: { current: { handleStop: () => { } } }, + hasMore: false, + setChatListSize: (_: number | ((_size: number) => number)) => { }, }) export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index a67cc3cd885387..b3ee7c9b3679e4 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -7,6 +7,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' import { useLocalStorageState } from 'ahooks' import produce from 'immer' import type { @@ -16,6 +17,7 @@ import type { } from '../types' import { CONVERSATION_ID_INFO } from '../constants' import { getPrevChatList } from '../utils' +import type { IChatItem } from '../chat/type' import { delConversation, fetchAppInfo, @@ -40,6 +42,8 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' +type IChatWithHistory = { data: Array; limit: number; has_more: boolean } + export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) @@ -107,15 +111,50 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId)) const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100)) const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100)) - const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId)) + const { + data: appChatListData, + isLoading: appChatListDataLoading, + setSize: setChatListSize, + mutate: mutateAppChatListData, + } = useSWRInfinite( + (pageIndex: number, previousPageData) => { + if (pageIndex === 0) { + return [fetchChatList, { + conversationId: chatShouldReloadKey, + installedAppId: appId, + isInstalledApp, + }] + } + + if (previousPageData && previousPageData.has_more) { + return [fetchChatList, { + conversationId: chatShouldReloadKey, + installedAppId: appId, + isInstalledApp, + firstId: previousPageData.data.at(-1)?.id, + }] + } - const appPrevChatList = useMemo( - () => (currentConversationId && appChatListData?.data.length) - ? getPrevChatList(appChatListData.data) - : [], - [appChatListData, currentConversationId], + return null + }, + ([_, params]: [unknown, { conversationId: string; installedAppId: string; isInstalledApp: boolean; firstId: string }]) => { + if (params) + return fetchChatList(params.conversationId, params.isInstalledApp, params.installedAppId, params.firstId) + return null + }, + { revalidateFirstPage: false }, ) + const hasMore = useMemo(() => { + return !!(appChatListData && appChatListData[appChatListData?.length - 1]?.has_more) + }, [appChatListData]) + + const appPrevChatList = useMemo(() => { + const items = appChatListData?.flatMap((value: any) => value?.data || []) || [] + const res = getPrevChatList(items) + return res + }, [appChatListData]) + const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false) const pinnedConversationList = useMemo(() => { @@ -169,6 +208,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { } }) }, [appParams]) + useEffect(() => { const conversationInputs: Record = {} @@ -264,8 +304,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setShowNewConversationItemInList(true) } }, [setShowConfigPanelBeforeChat, setShowNewConversationItemInList, checkInputsRequired]) + const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: () => { } }) - const handleChangeConversation = useCallback((conversationId: string) => { + const handleChangeConversation = useCallback((conversationId: string, reloadCurrent = true) => { currentChatInstanceRef.current.handleStop() setNewConversationId('') handleConversationIdInfoChange(conversationId) @@ -274,21 +315,28 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setShowConfigPanelBeforeChat(true) else setShowConfigPanelBeforeChat(false) - }, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired]) - const handleNewConversation = useCallback(() => { + + if (reloadCurrent) + mutateAppChatListData() + }, [handleConversationIdInfoChange, setShowConfigPanelBeforeChat, checkInputsRequired, mutateAppChatListData]) + + const handleNewConversation = useCallback((reloadCurrent = true) => { currentChatInstanceRef.current.handleStop() setNewConversationId('') if (showNewConversationItemInList) { - handleChangeConversation('') + handleChangeConversation('', reloadCurrent) } else if (currentConversationId) { + if (reloadCurrent) + mutateAppChatListData() + handleConversationIdInfoChange('') setShowConfigPanelBeforeChat(true) setShowNewConversationItemInList(true) handleNewConversationInputsChange({}) } - }, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange]) + }, [handleChangeConversation, currentConversationId, handleConversationIdInfoChange, setShowConfigPanelBeforeChat, setShowNewConversationItemInList, showNewConversationItemInList, handleNewConversationInputsChange, mutateAppChatListData]) const handleUpdateConversationList = useCallback(() => { mutateAppConversationData() mutateAppPinnedConversationData() @@ -327,7 +375,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { } if (conversationId === currentConversationId) - handleNewConversation() + handleNewConversation(false) handleUpdateConversationList() }, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting]) @@ -427,5 +475,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { chatShouldReloadKey, handleFeedback, currentChatInstanceRef, + hasMore, + setChatListSize, } } diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index 16524406d47d70..be94ec9b8bbc34 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -142,6 +142,8 @@ const ChatWithHistoryWrap: FC = ({ appId, handleFeedback, currentChatInstanceRef, + hasMore, + setChatListSize, } = useChatWithHistory(installedAppInfo) return ( @@ -178,6 +180,8 @@ const ChatWithHistoryWrap: FC = ({ handleFeedback, currentChatInstanceRef, themeBuilder, + hasMore, + setChatListSize, }}> diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index fa923ca0094978..3adb8b0639f1ff 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -75,6 +75,14 @@ export const useChat = ( setChatList(newChatList) chatListRef.current = newChatList }, []) + + useEffect(() => { + if (prevChatList && prevChatList.length > 0) { + setChatList(prevChatList) + chatListRef.current = prevChatList + } + }, [prevChatList]) + const handleResponding = useCallback((isResponding: boolean) => { setIsResponding(isResponding) isRespondingRef.current = isResponding @@ -249,7 +257,7 @@ export const useChat = ( else ttsUrl = `/apps/${params.appId}/text-to-audio` } - const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => {}) + const player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', (_: any): any => { }) ssePost( url, { diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index e6de01252dbd09..b3b1689fec4ccb 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -10,7 +10,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { debounce } from 'lodash-es' +import { debounce, throttle } from 'lodash-es' import { useShallow } from 'zustand/react/shallow' import type { ChatConfig, @@ -70,6 +70,8 @@ export type ChatProps = { showFileUpload?: boolean onFeatureBarClick?: (state: boolean) => void noSpacing?: boolean + onScrollToTop?: () => void + hasMoreMessages?: boolean } const Chat: FC = ({ @@ -106,6 +108,8 @@ const Chat: FC = ({ showFileUpload, onFeatureBarClick, noSpacing, + onScrollToTop, + hasMoreMessages, }) => { const { t } = useTranslation() const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({ @@ -122,12 +126,40 @@ const Chat: FC = ({ const chatFooterRef = useRef(null) const chatFooterInnerRef = useRef(null) const userScrolledRef = useRef(false) + const lastScrollTopRef = useRef(0) + const haveMoreMessagesRef = useRef(hasMoreMessages) + + useEffect(() => { + haveMoreMessagesRef.current = hasMoreMessages + }, [hasMoreMessages]) const handleScrollToBottom = useCallback(() => { if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current) chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight }, [chatList.length]) + const handleScrollToTop = useCallback(() => { + const handleScroll = throttle(async () => { + if (haveMoreMessagesRef.current && chatContainerRef.current) { + const currentScrollTop = chatContainerRef.current.scrollTop + const isScrollingUp = currentScrollTop < lastScrollTopRef.current + if (isScrollingUp && currentScrollTop < 50) { + if (chatContainerRef.current) + chatContainerRef.current.scrollTop = 100 + + chatContainerRef.current.style.overflowY = 'hidden' + onScrollToTop && onScrollToTop() + chatContainerRef.current.style.overflowY = 'auto' + } + lastScrollTopRef.current = currentScrollTop + } + }, 1000) + chatContainerRef.current?.addEventListener('scroll', handleScroll) + return () => { + chatContainerRef.current?.removeEventListener('scroll', handleScroll) + } + }, [onScrollToTop]) + const handleWindowResize = useCallback(() => { if (chatContainerRef.current) setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8) @@ -142,13 +174,15 @@ const Chat: FC = ({ useEffect(() => { handleScrollToBottom() handleWindowResize() - }, [handleScrollToBottom, handleWindowResize]) + handleScrollToTop() + }, [handleScrollToBottom, handleWindowResize, handleScrollToTop]) useEffect(() => { if (chatContainerRef.current) { requestAnimationFrame(() => { handleScrollToBottom() handleWindowResize() + handleScrollToTop() }) } }) @@ -216,6 +250,14 @@ const Chat: FC = ({ ref={chatContainerInnerRef} className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)} > + { + !hasMoreMessages && chatList.length > 20 + &&
+
+ {t('share.chat.loadedAllMessages')} +
+
+ } { chatList.map((item, index) => { if (item.isAnswer) { diff --git a/web/i18n/en-US/share-app.ts b/web/i18n/en-US/share-app.ts index 5d47fd31cf85eb..f643647168b480 100644 --- a/web/i18n/en-US/share-app.ts +++ b/web/i18n/en-US/share-app.ts @@ -30,6 +30,7 @@ const translation = { }, tryToSolve: 'Try to solve', temporarySystemIssue: 'Sorry, temporary system issue.', + loadedAllMessages: 'Loaded all messages', }, generation: { tabs: { diff --git a/web/i18n/zh-Hans/share-app.ts b/web/i18n/zh-Hans/share-app.ts index 968381bb3714cb..de8792cd97afa9 100644 --- a/web/i18n/zh-Hans/share-app.ts +++ b/web/i18n/zh-Hans/share-app.ts @@ -26,6 +26,7 @@ const translation = { }, tryToSolve: '尝试解决', temporarySystemIssue: '抱歉,临时系统问题。', + loadedAllMessages: '已加载所有消息', }, generation: { tabs: { diff --git a/web/service/share.ts b/web/service/share.ts index 0e46e30d01fec7..c61777165ac905 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -130,8 +130,14 @@ export const generationConversationName = async (isInstalledApp: boolean, instal return getAction('post', isInstalledApp)(getUrl(`conversations/${id}/name`, isInstalledApp, installedAppId), { body: { auto_generate: true } }) as Promise } -export const fetchChatList = async (conversationId: string, isInstalledApp: boolean, installedAppId = '') => { - return getAction('get', isInstalledApp)(getUrl('messages', isInstalledApp, installedAppId), { params: { conversation_id: conversationId, limit: 20, last_id: '' } }) as any +export const fetchChatList = async (conversationId: string, isInstalledApp: boolean, installedAppId = '', firstId = '') => { + return getAction('get', isInstalledApp)(getUrl('messages', isInstalledApp, installedAppId), { + params: { + conversation_id: conversationId, + limit: 20, + first_id: firstId, + }, + }) as any } // Abandoned API interface