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:
parent
9755564a05
commit
6b51530e21
1 changed files with 283 additions and 33 deletions
|
|
@ -8,7 +8,6 @@ import {
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { RiCloseLine, RiEditFill } from '@remixicon/react'
|
import { RiCloseLine, RiEditFill } from '@remixicon/react'
|
||||||
import { get } from 'lodash-es'
|
import { get } from 'lodash-es'
|
||||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import utc from 'dayjs/plugin/utc'
|
import utc from 'dayjs/plugin/utc'
|
||||||
import timezone from 'dayjs/plugin/timezone'
|
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 getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => {
|
||||||
const newChatList: IChatItem[] = []
|
const newChatList: IChatItem[] = []
|
||||||
messages.forEach((item: ChatMessage) => {
|
try {
|
||||||
|
messages.forEach((item: ChatMessage) => {
|
||||||
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
||||||
newChatList.push({
|
newChatList.push({
|
||||||
id: `question-${item.id}`,
|
id: `question-${item.id}`,
|
||||||
|
|
@ -178,7 +178,13 @@ const getFormattedChatList = (messages: ChatMessage[], conversationId: string, t
|
||||||
parentMessageId: `question-${item.id}`,
|
parentMessageId: `question-${item.id}`,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return newChatList
|
|
||||||
|
return newChatList
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('getFormattedChatList processing failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type IDetailPanel = {
|
type IDetailPanel = {
|
||||||
|
|
@ -188,6 +194,9 @@ type IDetailPanel = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailPanel({ detail, onFeedback }: 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 { userProfile: { timezone } } = useAppContext()
|
||||||
const { formatTime } = useTimestamp()
|
const { formatTime } = useTimestamp()
|
||||||
const { onClose, appDetail } = useContext(DrawerContext)
|
const { onClose, appDetail } = useContext(DrawerContext)
|
||||||
|
|
@ -204,13 +213,19 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [varValues, setVarValues] = useState<Record<string, string>>({})
|
const [varValues, setVarValues] = useState<Record<string, string>>({})
|
||||||
|
const isLoadingRef = useRef(false)
|
||||||
|
|
||||||
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
|
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
|
||||||
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
|
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
|
||||||
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
|
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
|
if (isLoadingRef.current)
|
||||||
|
return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
isLoadingRef.current = true
|
||||||
|
|
||||||
if (!hasMore)
|
if (!hasMore)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -218,8 +233,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
conversation_id: detail.id,
|
conversation_id: detail.id,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
}
|
}
|
||||||
if (allChatItems[0]?.id)
|
// Use the oldest answer item ID for pagination
|
||||||
params.first_id = allChatItems[0]?.id.replace('question-', '')
|
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({
|
const messageRes = await fetchChatMessages({
|
||||||
url: `/apps/${appDetail?.id}/chat-messages`,
|
url: `/apps/${appDetail?.id}/chat-messages`,
|
||||||
params,
|
params,
|
||||||
|
|
@ -249,15 +267,20 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
}
|
}
|
||||||
setChatItemTree(tree)
|
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) {
|
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])
|
}, [allChatItems, detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||||
|
|
||||||
const switchSibling = useCallback((siblingMessageId: string) => {
|
const switchSibling = useCallback((siblingMessageId: string) => {
|
||||||
setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId))
|
const newThreadChatItems = getThreadMessages(chatItemTree, siblingMessageId)
|
||||||
|
setThreadChatItems(newThreadChatItems)
|
||||||
}, [chatItemTree])
|
}, [chatItemTree])
|
||||||
|
|
||||||
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
|
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
|
||||||
|
|
@ -344,13 +367,217 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
|
|
||||||
const fetchInitiated = useRef(false)
|
const fetchInitiated = useRef(false)
|
||||||
|
|
||||||
|
// Only load initial messages, don't auto-load more
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) {
|
if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) {
|
||||||
|
// Mark as initialized, but don't auto-load more messages
|
||||||
fetchInitiated.current = true
|
fetchInitiated.current = true
|
||||||
|
// Still call fetchData to get initial messages
|
||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
}, [appDetail?.id, detail.id, appDetail?.mode, 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 isChatMode = appDetail?.mode !== 'completion'
|
||||||
const isAdvanced = appDetail?.mode === 'advanced-chat'
|
const isAdvanced = appDetail?.mode === 'advanced-chat'
|
||||||
|
|
||||||
|
|
@ -378,6 +605,36 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
return () => cancelAnimationFrame(raf)
|
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 (
|
return (
|
||||||
<div ref={ref} className='flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border'>
|
<div ref={ref} className='flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border'>
|
||||||
{/* Panel Header */}
|
{/* Panel Header */}
|
||||||
|
|
@ -439,8 +696,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
siteInfo={null}
|
siteInfo={null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
: threadChatItems.length < 8
|
: threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
|
||||||
? <div className="mb-4 pt-4">
|
<div className="mb-4 pt-4">
|
||||||
<Chat
|
<Chat
|
||||||
config={{
|
config={{
|
||||||
appId: appDetail?.id,
|
appId: appDetail?.id,
|
||||||
|
|
@ -466,35 +723,27 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
switchSibling={switchSibling}
|
switchSibling={switchSibling}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
: <div
|
) : (
|
||||||
|
<div
|
||||||
className="py-4"
|
className="py-4"
|
||||||
id="scrollableDiv"
|
id="scrollableDiv"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column-reverse',
|
flexDirection: 'column-reverse',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
}}>
|
}}>
|
||||||
{/* Put the scroll bar always on the bottom */}
|
{/* Put the scroll bar always on the bottom */}
|
||||||
<InfiniteScroll
|
<div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}>
|
||||||
scrollableTarget="scrollableDiv"
|
{/* Loading state indicator - only shown when loading */}
|
||||||
dataLength={threadChatItems.length}
|
{hasMore && isLoading && (
|
||||||
next={fetchData}
|
<div className="sticky left-0 right-0 top-0 z-10 bg-primary-50/40 py-3 text-center">
|
||||||
hasMore={hasMore}
|
<div className='system-xs-regular text-text-tertiary'>
|
||||||
loader={<div className='system-xs-regular text-center text-text-tertiary'>{t('appLog.detail.loading')}...</div>}
|
{t('appLog.detail.loading')}...
|
||||||
// endMessage={<div className='text-center'>Nothing more to show</div>}
|
</div>
|
||||||
// below props only if you need pull down functionality
|
</div>
|
||||||
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}
|
|
||||||
>
|
|
||||||
<Chat
|
<Chat
|
||||||
config={{
|
config={{
|
||||||
appId: appDetail?.id,
|
appId: appDetail?.id,
|
||||||
|
|
@ -519,8 +768,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||||
chatContainerInnerClassName='px-3'
|
chatContainerInnerClassName='px-3'
|
||||||
switchSibling={switchSibling}
|
switchSibling={switchSibling}
|
||||||
/>
|
/>
|
||||||
</InfiniteScroll>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{showMessageLogModal && (
|
{showMessageLogModal && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue