fix: update first_id logic to use the oldest answer item in chat messages (#23992)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
@@ -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,6 +110,7 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
|
||||
const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => {
|
||||
const newChatList: IChatItem[] = []
|
||||
try {
|
||||
messages.forEach((item: ChatMessage) => {
|
||||
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
||||
newChatList.push({
|
||||
@@ -178,7 +178,13 @@ const getFormattedChatList = (messages: ChatMessage[], conversationId: string, t
|
||||
parentMessageId: `question-${item.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
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<Record<string, string>>({})
|
||||
const isLoadingRef = useRef(false)
|
||||
|
||||
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
|
||||
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
|
||||
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
|
||||
|
||||
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 (
|
||||
<div ref={ref} className='flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border'>
|
||||
{/* Panel Header */}
|
||||
@@ -439,8 +696,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
siteInfo={null}
|
||||
/>
|
||||
</div>
|
||||
: threadChatItems.length < 8
|
||||
? <div className="mb-4 pt-4">
|
||||
: threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
|
||||
<div className="mb-4 pt-4">
|
||||
<Chat
|
||||
config={{
|
||||
appId: appDetail?.id,
|
||||
@@ -466,35 +723,27 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
</div>
|
||||
: <div
|
||||
) : (
|
||||
<div
|
||||
className="py-4"
|
||||
id="scrollableDiv"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{/* Put the scroll bar always on the bottom */}
|
||||
<InfiniteScroll
|
||||
scrollableTarget="scrollableDiv"
|
||||
dataLength={threadChatItems.length}
|
||||
next={fetchData}
|
||||
hasMore={hasMore}
|
||||
loader={<div className='system-xs-regular text-center text-text-tertiary'>{t('appLog.detail.loading')}...</div>}
|
||||
// endMessage={<div className='text-center'>Nothing more to show</div>}
|
||||
// below props only if you need pull down functionality
|
||||
refreshFunction={fetchData}
|
||||
pullDownToRefresh
|
||||
pullDownToRefreshThreshold={50}
|
||||
// pullDownToRefreshContent={
|
||||
// <div className='text-center'>Pull down to refresh</div>
|
||||
// }
|
||||
// releaseToRefreshContent={
|
||||
// <div className='text-center'>Release to refresh</div>
|
||||
// }
|
||||
// To put endMessage and loader to the top.
|
||||
style={{ display: 'flex', flexDirection: 'column-reverse' }}
|
||||
inverse={true}
|
||||
>
|
||||
<div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}>
|
||||
{/* Loading state indicator - only shown when loading */}
|
||||
{hasMore && isLoading && (
|
||||
<div className="sticky left-0 right-0 top-0 z-10 bg-primary-50/40 py-3 text-center">
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('appLog.detail.loading')}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Chat
|
||||
config={{
|
||||
appId: appDetail?.id,
|
||||
@@ -519,8 +768,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
chatContainerInnerClassName='px-3'
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{showMessageLogModal && (
|
||||
|
Reference in New Issue
Block a user