|
|
|
@@ -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<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 && ( |