'use client' import type { FC } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react' import useSWR from 'swr' import { HandThumbDownIcon, HandThumbUpIcon, } 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' import { createContext, useContext } from 'use-context-selector' import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' import type { ChatItemInTree } from '../../base/chat/types' import Indicator from '../../header/indicator' import VarPanel from './var-panel' import type { FeedbackFunc, FeedbackType, IChatItem, SubmitAnnotationFunc } from '@/app/components/base/chat/chat/type' import type { Annotation, ChatConversationGeneralDetail, ChatConversationsResponse, ChatMessage, ChatMessagesRequest, CompletionConversationGeneralDetail, CompletionConversationsResponse, LogAnnotation } from '@/models/log' import type { App } from '@/types/app' import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' import Drawer from '@/app/components/base/drawer' import Chat from '@/app/components/base/chat/chat' import { ToastContext } from '@/app/components/base/toast' import { fetchChatConversationDetail, fetchChatMessages, fetchCompletionConversationDetail, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import ModelInfo from '@/app/components/app/log/model-info' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import TextGeneration from '@/app/components/app/text-generate/item' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import MessageLogModal from '@/app/components/base/message-log-modal' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' import useTimestamp from '@/hooks/use-timestamp' import Tooltip from '@/app/components/base/tooltip' import { CopyIcon } from '@/app/components/base/copy-icon' import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import cn from '@/utils/classnames' import { noop } from 'lodash-es' import PromptLogModal from '../../base/prompt-log-modal' dayjs.extend(utc) dayjs.extend(timezone) type IConversationList = { logs?: ChatConversationsResponse | CompletionConversationsResponse appDetail: App onRefresh: () => void } const defaultValue = 'N/A' type IDrawerContext = { onClose: () => void appDetail?: App } type StatusCount = { success: number failed: number partial_success: number } const DrawerContext = createContext({} as IDrawerContext) /** * Icon component with numbers */ const HandThumbIconWithCount: FC<{ count: number; iconType: 'up' | 'down' }> = ({ count, iconType }) => { const classname = iconType === 'up' ? 'text-primary-600 bg-primary-50' : 'text-red-600 bg-red-50' const Icon = iconType === 'up' ? HandThumbUpIcon : HandThumbDownIcon return
{count > 0 ? count : null}
} const statusTdRender = (statusCount: StatusCount) => { if (!statusCount) return null if (statusCount.partial_success + statusCount.failed === 0) { return (
Success
) } else if (statusCount.failed === 0) { return (
Partial Success
) } else { return (
{statusCount.failed} {`${statusCount.failed > 1 ? 'Failures' : 'Failure'}`}
) } } const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => { const newChatList: IChatItem[] = [] messages.forEach((item: ChatMessage) => { const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] newChatList.push({ id: `question-${item.id}`, content: item.inputs.query || item.inputs.default_input || item.query, // text generation: item.inputs.query; chat: item.query isAnswer: false, message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))), parentMessageId: item.parent_message_id || undefined, }) const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] newChatList.push({ id: item.id, content: item.answer, agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), feedback: item.feedbacks.find(item => item.from_source === 'user'), // user feedback adminFeedback: item.feedbacks.find(item => item.from_source === 'admin'), // admin feedback feedbackDisabled: false, isAnswer: true, message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), log: [ ...item.message, ...(item.message[item.message.length - 1]?.role !== 'assistant' ? [ { role: 'assistant', text: item.answer, files: item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], }, ] : []), ] as IChatItem['log'], workflow_run_id: item.workflow_run_id, conversationId, input: { inputs: item.inputs, query: item.query, }, more: { time: dayjs.unix(item.created_at).tz(timezone).format(format), tokens: item.answer_tokens + item.message_tokens, latency: item.provider_response_latency.toFixed(2), }, citation: item.metadata?.retriever_resources, annotation: (() => { if (item.annotation_hit_history) { return { id: item.annotation_hit_history.annotation_id, authorName: item.annotation_hit_history.annotation_create_account?.name || 'N/A', created_at: item.annotation_hit_history.created_at, } } if (item.annotation) { return { id: item.annotation.id, authorName: item.annotation.account.name, logAnnotation: item.annotation, created_at: 0, } } return undefined })(), parentMessageId: `question-${item.id}`, }) }) return newChatList } type IDetailPanel = { detail: any onFeedback: FeedbackFunc onSubmitAnnotation: SubmitAnnotationFunc } function DetailPanel({ detail, onFeedback }: IDetailPanel) { const { userProfile: { timezone } } = useAppContext() const { formatTime } = useTimestamp() const { onClose, appDetail } = useContext(DrawerContext) const { notify } = useContext(ToastContext) const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({ currentLogItem: state.currentLogItem, setCurrentLogItem: state.setCurrentLogItem, showMessageLogModal: state.showMessageLogModal, setShowMessageLogModal: state.setShowMessageLogModal, showPromptLogModal: state.showPromptLogModal, setShowPromptLogModal: state.setShowPromptLogModal, currentLogModalActiveTab: state.currentLogModalActiveTab, }))) const { t } = useTranslation() const [hasMore, setHasMore] = useState(true) const [varValues, setVarValues] = useState>({}) const [allChatItems, setAllChatItems] = useState([]) const [chatItemTree, setChatItemTree] = useState([]) const [threadChatItems, setThreadChatItems] = useState([]) const fetchData = useCallback(async () => { try { if (!hasMore) return const params: ChatMessagesRequest = { conversation_id: detail.id, limit: 10, } if (allChatItems[0]?.id) params.first_id = allChatItems[0]?.id.replace('question-', '') const messageRes = await fetchChatMessages({ url: `/apps/${appDetail?.id}/chat-messages`, params, }) if (messageRes.data.length > 0) { const varValues = messageRes.data.at(-1)!.inputs setVarValues(varValues) } setHasMore(messageRes.has_more) const newAllChatItems = [ ...getFormattedChatList(messageRes.data, detail.id, timezone!, t('appLog.dateTimeFormat') as string), ...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 (err) { console.error(err) } }, [allChatItems, detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction]) const switchSibling = useCallback((siblingMessageId: string) => { setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId)) }, [chatItemTree]) const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { setAllChatItems(allChatItems.map((item, i) => { if (i === index - 1) { return { ...item, content: query, } } if (i === index) { return { ...item, annotation: { ...item.annotation, logAnnotation: { ...item.annotation?.logAnnotation, content: answer, }, } as any, } } return item })) }, [allChatItems]) const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => { setAllChatItems(allChatItems.map((item, i) => { if (i === index - 1) { return { ...item, content: query, } } if (i === index) { const answerItem = { ...item, content: item.content, annotation: { id: annotationId, authorName, logAnnotation: { content: answer, account: { id: '', name: authorName, email: '', }, }, } as Annotation, } return answerItem } return item })) }, [allChatItems]) const handleAnnotationRemoved = useCallback(async (index: number): Promise => { const annotation = allChatItems[index]?.annotation try { if (annotation?.id) { const { delAnnotation } = await import('@/service/annotation') await delAnnotation(appDetail?.id || '', annotation.id) } setAllChatItems(allChatItems.map((item, i) => { if (i === index) { return { ...item, content: item.content, annotation: undefined, } } return item })) notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) return true } catch { notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) return false } }, [allChatItems, appDetail?.id, t]) const fetchInitiated = useRef(false) useEffect(() => { if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) { fetchInitiated.current = true fetchData() } }, [appDetail?.id, detail.id, appDetail?.mode, fetchData]) const isChatMode = appDetail?.mode !== 'completion' const isAdvanced = appDetail?.mode === 'advanced-chat' const varList = (detail.model_config as any).user_input_form?.map((item: any) => { const itemContent = item[Object.keys(item)[0]] return { label: itemContent.variable, value: varValues[itemContent.variable] || detail.message?.inputs?.[itemContent.variable], } }) || [] const message_files = (!isChatMode && detail.message.message_files && detail.message.message_files.length > 0) ? detail.message.message_files.map((item: any) => item.url) : [] const [width, setWidth] = useState(0) const ref = useRef(null) const adjustModalWidth = () => { if (ref.current) setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8) } useEffect(() => { const raf = requestAnimationFrame(adjustModalWidth) return () => cancelAnimationFrame(raf) }, []) return (
{/* Panel Header */}
{isChatMode ? t('appLog.detail.conversationId') : t('appLog.detail.time')}
{isChatMode && (
{detail.id}
)} {!isChatMode && (
{formatTime(detail.created_at, t('appLog.dateTimeFormat') as string)}
)}
{!isAdvanced && }
{/* Panel Body */}
{(varList.length > 0 || (!isChatMode && message_files.length > 0)) && ( )}
{!isChatMode ?
{t('appLog.table.header.output')}
item.from_source === 'admin')} onFeedback={feedback => onFeedback(detail.message.id, feedback)} isShowTextToSpeech siteInfo={null} />
: threadChatItems.length < 8 ?
:
{/* 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} >
}
{showMessageLogModal && ( { setCurrentLogItem() setShowMessageLogModal(false) }} defaultTab={currentLogModalActiveTab} /> )} {!isChatMode && showPromptLogModal && ( { setCurrentLogItem() setShowPromptLogModal(false) }} /> )} ) } /** * Text App Conversation Detail Component */ const CompletionConversationDetailComp: FC<{ appId?: string; conversationId?: string }> = ({ appId, conversationId }) => { // Text Generator App Session Details Including Message List const detailParams = ({ url: `/apps/${appId}/completion-conversations/${conversationId}` }) const { data: conversationDetail, mutate: conversationDetailMutate } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchCompletionConversationDetail) const { notify } = useContext(ToastContext) const { t } = useTranslation() const handleFeedback = async (mid: string, { rating }: FeedbackType): Promise => { try { await updateLogMessageFeedbacks({ url: `/apps/${appId}/feedbacks`, body: { message_id: mid, rating } }) conversationDetailMutate() notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) return true } catch { notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) return false } } const handleAnnotation = async (mid: string, value: string): Promise => { try { await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } }) conversationDetailMutate() notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) return true } catch { notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) return false } } if (!conversationDetail) return null return } /** * Chat App Conversation Detail Component */ const ChatConversationDetailComp: FC<{ appId?: string; conversationId?: string }> = ({ appId, conversationId }) => { const detailParams = { url: `/apps/${appId}/chat-conversations/${conversationId}` } const { data: conversationDetail } = useSWR(() => (appId && conversationId) ? detailParams : null, fetchChatConversationDetail) const { notify } = useContext(ToastContext) const { t } = useTranslation() const handleFeedback = async (mid: string, { rating }: FeedbackType): Promise => { try { await updateLogMessageFeedbacks({ url: `/apps/${appId}/feedbacks`, body: { message_id: mid, rating } }) notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) return true } catch { notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) return false } } const handleAnnotation = async (mid: string, value: string): Promise => { try { await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } }) notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') }) return true } catch { notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') }) return false } } if (!conversationDetail) return null return } /** * Conversation list component including basic information */ const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() const media = useBreakpoints() const isMobile = media === MediaType.mobile const [showDrawer, setShowDrawer] = useState(false) // Whether to display the chat details drawer const [currentConversation, setCurrentConversation] = useState() // Currently selected conversation const isChatMode = appDetail.mode !== 'completion' // Whether the app is a chat app const isChatflow = appDetail.mode === 'advanced-chat' // Whether the app is a chatflow app const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow(state => ({ setShowPromptLogModal: state.setShowPromptLogModal, setShowAgentLogModal: state.setShowAgentLogModal, setShowMessageLogModal: state.setShowMessageLogModal, }))) // Annotated data needs to be highlighted const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { return ( {`${t('appLog.detail.annotationTip', { user: annotation?.account?.name })} ${formatTime(annotation?.created_at || dayjs().unix(), 'MM-DD hh:mm A')}`} } popupClassName={(isHighlight && !isChatMode) ? '' : '!hidden'} >
{value || '-'}
) } const onCloseDrawer = () => { onRefresh() setShowDrawer(false) setCurrentConversation(undefined) setShowPromptLogModal(false) setShowAgentLogModal(false) setShowMessageLogModal(false) } if (!logs) return return (
{isChatflow && } {logs.data.map((log: any) => { const endUser = log.from_end_user_session_id || log.from_account_name const leftValue = get(log, isChatMode ? 'name' : 'message.inputs.query') || (!isChatMode ? (get(log, 'message.query') || get(log, 'message.inputs.default_input')) : '') || '' const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer') return { setShowDrawer(true) setCurrentConversation(log) }}> {isChatflow && } })}
{isChatMode ? t('appLog.table.header.summary') : t('appLog.table.header.input')} {t('appLog.table.header.endUser')}{t('appLog.table.header.status')}{isChatMode ? t('appLog.table.header.messageCount') : t('appLog.table.header.output')} {t('appLog.table.header.userRate')} {t('appLog.table.header.adminRate')} {t('appLog.table.header.updatedTime')} {t('appLog.table.header.time')}
{!log.read_at && (
)}
{renderTdValue(leftValue || t('appLog.table.empty.noChat'), !leftValue, isChatMode && log.annotated)} {renderTdValue(endUser || defaultValue, !endUser)} {statusTdRender(log.status_count)} {renderTdValue(rightValue === 0 ? 0 : (rightValue || t('appLog.table.empty.noOutput')), !rightValue, !isChatMode && !!log.annotation?.content, log.annotation)} {(!log.user_feedback_stats.like && !log.user_feedback_stats.dislike) ? renderTdValue(defaultValue, true) : <> {!!log.user_feedback_stats.like && } {!!log.user_feedback_stats.dislike && } } {(!log.admin_feedback_stats.like && !log.admin_feedback_stats.dislike) ? renderTdValue(defaultValue, true) : <> {!!log.admin_feedback_stats.like && } {!!log.admin_feedback_stats.dislike && } } {formatTime(log.updated_at, t('appLog.dateTimeFormat') as string)} {formatTime(log.created_at, t('appLog.dateTimeFormat') as string)}
{isChatMode ? : }
) } export default ConversationList