diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index b83e9e6a2..2ea5bfb76 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -8,7 +8,6 @@ import { } from '@heroicons/react/24/outline' import { RiCloseLine, RiEditFill } from '@remixicon/react' import { get } from 'lodash-es' -import InfiniteScroll from 'react-infinite-scroll-component' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import timezone from 'dayjs/plugin/timezone' @@ -111,7 +110,8 @@ const statusTdRender = (statusCount: StatusCount) => { const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => { const newChatList: IChatItem[] = [] - messages.forEach((item: ChatMessage) => { + try { + messages.forEach((item: ChatMessage) => { const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] newChatList.push({ id: `question-${item.id}`, @@ -178,7 +178,13 @@ const getFormattedChatList = (messages: ChatMessage[], conversationId: string, t parentMessageId: `question-${item.id}`, }) }) - return newChatList + + return newChatList + } + catch (error) { + console.error('getFormattedChatList processing failed:', error) + throw error + } } type IDetailPanel = { @@ -188,6 +194,9 @@ type IDetailPanel = { } function DetailPanel({ detail, onFeedback }: IDetailPanel) { + const MIN_ITEMS_FOR_SCROLL_LOADING = 8 + const SCROLL_THRESHOLD_PX = 50 + const SCROLL_DEBOUNCE_MS = 200 const { userProfile: { timezone } } = useAppContext() const { formatTime } = useTimestamp() const { onClose, appDetail } = useContext(DrawerContext) @@ -204,13 +213,19 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const { t } = useTranslation() const [hasMore, setHasMore] = useState(true) const [varValues, setVarValues] = useState>({}) + const isLoadingRef = useRef(false) const [allChatItems, setAllChatItems] = useState([]) const [chatItemTree, setChatItemTree] = useState([]) const [threadChatItems, setThreadChatItems] = useState([]) const fetchData = useCallback(async () => { + if (isLoadingRef.current) + return + try { + isLoadingRef.current = true + if (!hasMore) return @@ -218,8 +233,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { conversation_id: detail.id, limit: 10, } - if (allChatItems[0]?.id) - params.first_id = allChatItems[0]?.id.replace('question-', '') + // Use the oldest answer item ID for pagination + const answerItems = allChatItems.filter(item => item.isAnswer) + const oldestAnswerItem = answerItems[answerItems.length - 1] + if (oldestAnswerItem?.id) + params.first_id = oldestAnswerItem.id const messageRes = await fetchChatMessages({ url: `/apps/${appDetail?.id}/chat-messages`, params, @@ -249,15 +267,20 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { } setChatItemTree(tree) - setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id)) + const lastMessageId = newAllChatItems.length > 0 ? newAllChatItems[newAllChatItems.length - 1].id : undefined + setThreadChatItems(getThreadMessages(tree, lastMessageId)) } catch (err) { - console.error(err) + console.error('fetchData execution failed:', err) + } + finally { + isLoadingRef.current = false } }, [allChatItems, detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) const switchSibling = useCallback((siblingMessageId: string) => { - setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId)) + const newThreadChatItems = getThreadMessages(chatItemTree, siblingMessageId) + setThreadChatItems(newThreadChatItems) }, [chatItemTree]) const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { @@ -344,13 +367,217 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const fetchInitiated = useRef(false) + // Only load initial messages, don't auto-load more useEffect(() => { if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) { + // Mark as initialized, but don't auto-load more messages fetchInitiated.current = true + // Still call fetchData to get initial messages fetchData() } }, [appDetail?.id, detail.id, appDetail?.mode, fetchData]) + const [isLoading, setIsLoading] = useState(false) + + const loadMoreMessages = useCallback(async () => { + if (isLoading || !hasMore || !appDetail?.id || !detail.id) + return + + setIsLoading(true) + + try { + const params: ChatMessagesRequest = { + conversation_id: detail.id, + limit: 10, + } + + // Use the earliest response item as the first_id + const answerItems = allChatItems.filter(item => item.isAnswer) + const oldestAnswerItem = answerItems[answerItems.length - 1] + if (oldestAnswerItem?.id) { + params.first_id = oldestAnswerItem.id + } + else if (allChatItems.length > 0 && allChatItems[0]?.id) { + const firstId = allChatItems[0].id.replace('question-', '').replace('answer-', '') + params.first_id = firstId + } + + const messageRes = await fetchChatMessages({ + url: `/apps/${appDetail.id}/chat-messages`, + params, + }) + + if (!messageRes.data || messageRes.data.length === 0) { + setHasMore(false) + return + } + + if (messageRes.data.length > 0) { + const varValues = messageRes.data.at(-1)!.inputs + setVarValues(varValues) + } + + setHasMore(messageRes.has_more) + + const newItems = getFormattedChatList( + messageRes.data, + detail.id, + timezone!, + t('appLog.dateTimeFormat') as string, + ) + + // Check for duplicate messages + const existingIds = new Set(allChatItems.map(item => item.id)) + const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + + if (uniqueNewItems.length === 0) { + if (allChatItems.length > 1) { + const nextId = allChatItems[1].id.replace('question-', '').replace('answer-', '') + + const retryParams = { + ...params, + first_id: nextId, + } + + const retryRes = await fetchChatMessages({ + url: `/apps/${appDetail.id}/chat-messages`, + params: retryParams, + }) + + if (retryRes.data && retryRes.data.length > 0) { + const retryItems = getFormattedChatList( + retryRes.data, + detail.id, + timezone!, + t('appLog.dateTimeFormat') as string, + ) + + const retryUniqueItems = retryItems.filter(item => !existingIds.has(item.id)) + if (retryUniqueItems.length > 0) { + const newAllChatItems = [ + ...retryUniqueItems, + ...allChatItems, + ] + + setAllChatItems(newAllChatItems) + + let tree = buildChatItemTree(newAllChatItems) + if (retryRes.has_more === false && detail?.model_config?.configs?.introduction) { + tree = [{ + id: 'introduction', + isAnswer: true, + isOpeningStatement: true, + content: detail?.model_config?.configs?.introduction ?? 'hello', + feedbackDisabled: true, + children: tree, + }] + } + setChatItemTree(tree) + setHasMore(retryRes.has_more) + setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id)) + return + } + } + } + } + + const newAllChatItems = [ + ...uniqueNewItems, + ...allChatItems, + ] + + setAllChatItems(newAllChatItems) + + let tree = buildChatItemTree(newAllChatItems) + if (messageRes.has_more === false && detail?.model_config?.configs?.introduction) { + tree = [{ + id: 'introduction', + isAnswer: true, + isOpeningStatement: true, + content: detail?.model_config?.configs?.introduction ?? 'hello', + feedbackDisabled: true, + children: tree, + }] + } + setChatItemTree(tree) + + setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id)) + } + catch (error) { + console.error(error) + setHasMore(false) + } + finally { + setIsLoading(false) + } + }, [allChatItems, detail.id, hasMore, isLoading, timezone, t, appDetail]) + + useEffect(() => { + const scrollableDiv = document.getElementById('scrollableDiv') + const outerDiv = scrollableDiv?.parentElement + const chatContainer = document.querySelector('.mx-1.mb-1.grow.overflow-auto') as HTMLElement + + let scrollContainer: HTMLElement | null = null + + if (outerDiv && outerDiv.scrollHeight > outerDiv.clientHeight) { + scrollContainer = outerDiv + } + else if (scrollableDiv && scrollableDiv.scrollHeight > scrollableDiv.clientHeight) { + scrollContainer = scrollableDiv + } + else if (chatContainer && chatContainer.scrollHeight > chatContainer.clientHeight) { + scrollContainer = chatContainer + } + else { + const possibleContainers = document.querySelectorAll('.overflow-auto, .overflow-y-auto') + for (let i = 0; i < possibleContainers.length; i++) { + const container = possibleContainers[i] as HTMLElement + if (container.scrollHeight > container.clientHeight) { + scrollContainer = container + break + } + } + } + + if (!scrollContainer) + return + + let lastLoadTime = 0 + const throttleDelay = 200 + + const handleScroll = () => { + const currentScrollTop = scrollContainer!.scrollTop + const scrollHeight = scrollContainer!.scrollHeight + const clientHeight = scrollContainer!.clientHeight + + const distanceFromTop = currentScrollTop + const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight + + const now = Date.now() + + const isNearTop = distanceFromTop < 30 + // eslint-disable-next-line sonarjs/no-unused-vars + const _distanceFromBottom = distanceFromBottom < 30 + if (isNearTop && hasMore && !isLoading && (now - lastLoadTime > throttleDelay)) { + lastLoadTime = now + loadMoreMessages() + } + } + + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }) + + const handleWheel = (e: WheelEvent) => { + if (e.deltaY < 0) + handleScroll() + } + scrollContainer.addEventListener('wheel', handleWheel, { passive: true }) + + return () => { + scrollContainer!.removeEventListener('scroll', handleScroll) + scrollContainer!.removeEventListener('wheel', handleWheel) + } + }, [hasMore, isLoading, loadMoreMessages]) + const isChatMode = appDetail?.mode !== 'completion' const isAdvanced = appDetail?.mode === 'advanced-chat' @@ -378,6 +605,36 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { return () => cancelAnimationFrame(raf) }, []) + // Add scroll listener to ensure loading is triggered + useEffect(() => { + if (threadChatItems.length >= MIN_ITEMS_FOR_SCROLL_LOADING && hasMore) { + const scrollableDiv = document.getElementById('scrollableDiv') + + if (scrollableDiv) { + let loadingTimeout: NodeJS.Timeout | null = null + + const handleScroll = () => { + const { scrollTop } = scrollableDiv + + // Trigger loading when scrolling near the top + if (scrollTop < SCROLL_THRESHOLD_PX && !isLoadingRef.current) { + if (loadingTimeout) + clearTimeout(loadingTimeout) + + loadingTimeout = setTimeout(fetchData, SCROLL_DEBOUNCE_MS) // 200ms debounce + } + } + + scrollableDiv.addEventListener('scroll', handleScroll) + return () => { + scrollableDiv.removeEventListener('scroll', handleScroll) + if (loadingTimeout) + clearTimeout(loadingTimeout) + } + } + } + }, [threadChatItems.length, hasMore, fetchData]) + return (
{/* Panel Header */} @@ -439,8 +696,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { siteInfo={null} />
- : threadChatItems.length < 8 - ?
+ : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? ( +
- :
{/* Put the scroll bar always on the bottom */} - {t('appLog.detail.loading')}...
} - // endMessage={
Nothing more to show
} - // below props only if you need pull down functionality - refreshFunction={fetchData} - pullDownToRefresh - pullDownToRefreshThreshold={50} - // pullDownToRefreshContent={ - //
Pull down to refresh
- // } - // releaseToRefreshContent={ - //
Release to refresh
- // } - // To put endMessage and loader to the top. - style={{ display: 'flex', flexDirection: 'column-reverse' }} - inverse={true} - > +
+ {/* Loading state indicator - only shown when loading */} + {hasMore && isLoading && ( +
+
+ {t('appLog.detail.loading')}... +
+
+ )} + - +
+ ) } {showMessageLogModal && (