From d99ca80f488579e4e67b03c5a51a5ddcb4314e9d Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 31 Mar 2026 10:22:06 +0800 Subject: [PATCH] feat(log): implement list detail panel and conversation drawer with associated tests - Added component for displaying detailed log information. - Introduced hook to manage conversation state and interactions. - Created unit tests for the new components and hooks to ensure functionality and reliability. - Updated utility functions for better handling of conversation logs and chat items. --- .../app/log/list-detail-panel.spec.tsx | 468 ++++++++ .../components/app/log/list-detail-panel.tsx | 272 +++++ web/app/components/app/log/list-utils.ts | 293 +++++ web/app/components/app/log/list.spec.tsx | 805 +++++++++---- web/app/components/app/log/list.tsx | 1054 ++--------------- .../app/log/use-conversation-drawer.spec.tsx | 197 +++ .../app/log/use-conversation-drawer.ts | 116 ++ .../app/log/use-detail-panel-state.spec.tsx | 512 ++++++++ .../app/log/use-detail-panel-state.ts | 294 +++++ 9 files changed, 2879 insertions(+), 1132 deletions(-) create mode 100644 web/app/components/app/log/list-detail-panel.spec.tsx create mode 100644 web/app/components/app/log/list-detail-panel.tsx create mode 100644 web/app/components/app/log/list-utils.ts create mode 100644 web/app/components/app/log/use-conversation-drawer.spec.tsx create mode 100644 web/app/components/app/log/use-conversation-drawer.ts create mode 100644 web/app/components/app/log/use-detail-panel-state.spec.tsx create mode 100644 web/app/components/app/log/use-detail-panel-state.ts diff --git a/web/app/components/app/log/list-detail-panel.spec.tsx b/web/app/components/app/log/list-detail-panel.spec.tsx new file mode 100644 index 0000000000..dbbc5db869 --- /dev/null +++ b/web/app/components/app/log/list-detail-panel.spec.tsx @@ -0,0 +1,468 @@ +import type { + ChatConversationFullDetailResponse, + ChatMessagesResponse, + CompletionConversationFullDetailResponse, + MessageContent, +} from '@/models/log' +import type { App, AppIconType, AppModeEnum } from '@/types/app' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TransferMethod } from '@/types/app' +import { ChatConversationDetailComp, CompletionConversationDetailComp } from './list-detail-panel' + +const mockFetchChatMessages = vi.fn() +const mockUpdateLogMessageFeedbacks = vi.fn() +const mockRefetchCompletionDetail = vi.fn() +const mockDelAnnotation = vi.fn() +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +const mockSetCurrentLogItem = vi.fn() +const mockSetShowMessageLogModal = vi.fn() +const mockSetShowPromptLogModal = vi.fn() + +let mockChatDetail: ChatConversationFullDetailResponse | undefined +let mockCompletionDetail: CompletionConversationFullDetailResponse | undefined +let mockStoreState: { + currentLogItem?: Record + currentLogModalActiveTab?: string + setCurrentLogItem: typeof mockSetCurrentLogItem + setShowMessageLogModal: typeof mockSetShowMessageLogModal + setShowPromptLogModal: typeof mockSetShowPromptLogModal + showMessageLogModal: boolean + showPromptLogModal: boolean +} + +vi.mock('@/service/log', () => ({ + fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args), + updateLogMessageFeedbacks: (...args: unknown[]) => mockUpdateLogMessageFeedbacks(...args), +})) + +vi.mock('@/service/use-log', () => ({ + useChatConversationDetail: () => ({ data: mockChatDetail }), + useCompletionConversationDetail: () => ({ data: mockCompletionDetail, refetch: mockRefetchCompletionDetail }), +})) + +vi.mock('@/service/annotation', () => ({ + delAnnotation: (...args: unknown[]) => mockDelAnnotation(...args), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + timezone: 'UTC', + }, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/app/log/model-info', () => ({ + default: () =>
model-info
, +})) + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/copy-icon', () => ({ + default: () =>
copy-icon
, +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/base/message-log-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../../base/prompt-log-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/context', () => ({ + WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('./var-panel', () => ({ + default: ({ message_files, varList }: { message_files: string[], varList: Array<{ label: string, value: string }> }) => ( +
{`${varList.length}-${message_files.length}`}
+ ), +})) + +vi.mock('@/app/components/base/chat/chat', () => ({ + default: ({ + chatList, + onAnnotationAdded, + onAnnotationEdited, + onAnnotationRemoved, + onFeedback, + switchSibling, + }: { + chatList: Array<{ id: string }> + onAnnotationAdded: (annotationId: string, authorName: string, query: string, answer: string, index: number) => void + onAnnotationEdited: (query: string, answer: string, index: number) => void + onAnnotationRemoved: (index: number) => Promise + onFeedback: (messageId: string, payload: { rating: 'like' | 'dislike', content?: string }) => Promise + switchSibling: (messageId: string) => void + }) => ( +
+ {chatList.length} + + + + + +
+ ), +})) + +vi.mock('@/app/components/app/text-generate/item', () => ({ + default: ({ content, onFeedback }: { content: string, onFeedback: (payload: { rating: 'like' | 'dislike', content?: string }) => Promise }) => ( +
+ {content} + +
+ ), +})) + +const createMockApp = (overrides: Partial = {}) => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: 'chat' as AppModeEnum, + runtime_type: 'classic' as const, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) satisfies App + +const createMessage = (): MessageContent => ({ + id: 'message-1', + conversation_id: 'conversation-1', + query: 'hello', + inputs: { customer: 'Alice' }, + message: [{ role: 'user', text: 'hello' }], + message_tokens: 10, + answer_tokens: 12, + answer: 'world', + provider_response_latency: 1.23, + created_at: 100, + annotation: { + id: 'annotation-1', + content: 'annotated answer', + account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 123, + }, + annotation_hit_history: { + annotation_id: 'annotation-hit-1', + annotation_create_account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 123, + }, + feedbacks: [{ rating: 'like', content: null, from_source: 'admin' }], + message_files: [], + metadata: { + retriever_resources: [], + annotation_reply: { + id: 'annotation-reply-1', + account: { + id: 'account-1', + name: 'Admin', + }, + }, + }, + agent_thoughts: [], + workflow_run_id: 'workflow-1', + parent_message_id: null, +}) + +describe('list detail panel', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockChatDetail = { + id: 'chat-conversation-1', + status: 'normal', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_end_user_session_id: 'session-1', + from_account_id: 'account-1', + read_at: new Date(), + created_at: 100, + updated_at: 200, + annotation: { + id: 'annotation-1', + authorName: 'Admin', + created_at: 123, + }, + user_feedback_stats: { like: 1, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + message_count: 2, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + introduction: 'hello', + prompt_template: 'Prompt', + prompt_variables: [], + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + model: { + name: 'gpt-4', + provider: 'openai', + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + }, + } + + mockCompletionDetail = { + id: 'completion-conversation-1', + status: 'finished', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_account_id: 'account-1', + created_at: 100, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + introduction: '', + prompt_template: 'Prompt', + prompt_variables: [], + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + }, + message: { + ...createMessage(), + message_files: [{ + id: 'file-1', + type: 'image', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/file.png', + upload_file_id: 'upload-1', + belongs_to: 'assistant', + }], + }, + } + + mockStoreState = { + currentLogItem: { id: 'log-item-1' }, + currentLogModalActiveTab: 'trace', + setCurrentLogItem: mockSetCurrentLogItem, + setShowMessageLogModal: mockSetShowMessageLogModal, + setShowPromptLogModal: mockSetShowPromptLogModal, + showMessageLogModal: false, + showPromptLogModal: false, + } + + mockFetchChatMessages.mockResolvedValue({ + data: [createMessage()], + has_more: false, + limit: 10, + } satisfies ChatMessagesResponse) + mockUpdateLogMessageFeedbacks.mockResolvedValue({ result: 'success' }) + mockDelAnnotation.mockResolvedValue(undefined) + }) + + it('should fetch chat messages and handle feedback and annotation removal', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect(mockFetchChatMessages).toHaveBeenCalledWith({ + url: '/apps/test-app-id/chat-messages', + params: { + conversation_id: 'chat-conversation-1', + limit: 10, + }, + }) + }) + + expect(screen.getByTestId('chat-component')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('chat-count')).toHaveTextContent('3') + }) + + await user.click(screen.getByText('chat-feedback')) + await user.click(screen.getByText('remove-annotation')) + await user.click(screen.getByText('switch-sibling')) + + await waitFor(() => { + expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({ + url: '/apps/test-app-id/feedbacks', + body: { message_id: 'message-1', rating: 'like', content: 'great' }, + }) + expect(mockDelAnnotation).toHaveBeenCalledWith('test-app-id', 'annotation-hit-1') + }) + + expect(mockToastSuccess).toHaveBeenCalled() + }) + + it('should render completion output, refetch on feedback, and close prompt/message modals', async () => { + const user = userEvent.setup() + mockStoreState = { + ...mockStoreState, + showMessageLogModal: true, + showPromptLogModal: true, + } + + render( + , + ) + + expect(screen.getByTestId('text-generation')).toBeInTheDocument() + expect(screen.getByText('world')).toBeInTheDocument() + expect(screen.getByTestId('var-panel')).toHaveTextContent('0-1') + expect(screen.getByTestId('message-log-modal')).toBeInTheDocument() + expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument() + + await user.click(screen.getByText('completion-feedback')) + + await waitFor(() => { + expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({ + url: '/apps/test-app-id/feedbacks', + body: { message_id: 'message-1', rating: 'like', content: 'great' }, + }) + expect(mockRefetchCompletionDetail).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByText('close-message-log')) + await user.click(screen.getByText('close-prompt-log')) + + expect(mockSetCurrentLogItem).toHaveBeenCalled() + expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) + expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) + }) + + it('should show an error toast when feedback updates fail', async () => { + const user = userEvent.setup() + mockUpdateLogMessageFeedbacks.mockRejectedValueOnce(new Error('update failed')) + + render( + , + ) + + await user.click(screen.getByText('completion-feedback')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled() + }) + }) + + it('should render nothing when conversation detail is unavailable', () => { + mockChatDetail = undefined + mockCompletionDetail = undefined + + const { container, rerender } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/app/log/list-detail-panel.tsx b/web/app/components/app/log/list-detail-panel.tsx new file mode 100644 index 0000000000..6dd28c416b --- /dev/null +++ b/web/app/components/app/log/list-detail-panel.tsx @@ -0,0 +1,272 @@ +'use client' + +import type { DetailPanelProps } from './use-detail-panel-state' +import type { FeedbackType } from '@/app/components/base/chat/chat/type' +import type { CompletionConversationFullDetailResponse } from '@/models/log' +import type { App } from '@/types/app' +import { RiCloseLine } from '@remixicon/react' +import { noop } from 'es-toolkit/function' +import * as React from 'react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import ModelInfo from '@/app/components/app/log/model-info' +import TextGeneration from '@/app/components/app/text-generate/item' +import ActionButton from '@/app/components/base/action-button' +import Chat from '@/app/components/base/chat/chat' +import CopyIcon from '@/app/components/base/copy-icon' +import MessageLogModal from '@/app/components/base/message-log-modal' +import { toast } from '@/app/components/base/ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' +import { WorkflowContextProvider } from '@/app/components/workflow/context' +import { updateLogMessageFeedbacks } from '@/service/log' +import { AppSourceType } from '@/service/share' +import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' +import PromptLogModal from '../../base/prompt-log-modal' +import { MIN_ITEMS_FOR_SCROLL_LOADING } from './list-utils' +import { useDetailPanelState } from './use-detail-panel-state' +import VarPanel from './var-panel' + +function ListDetailPanel({ appDetail, detail, onClose, onFeedback }: DetailPanelProps) { + const { t } = useTranslation() + const { + containerRef, + currentLogItem, + currentLogModalActiveTab, + formatTime, + handleAnnotationAdded, + handleAnnotationEdited, + handleAnnotationRemoved, + handleScroll, + hasMore, + isAdvanced, + isChatMode, + messageDateTimeFormat, + messageFiles, + setCurrentLogItem, + setShowMessageLogModal, + setShowPromptLogModal, + showMessageLogModal, + showPromptLogModal, + switchSibling, + threadChatItems, + varList, + width, + } = useDetailPanelState({ appDetail, detail }) + const completionDetail = isChatMode ? undefined : detail as CompletionConversationFullDetailResponse + + return ( +
+
+
+
+ {isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })} +
+ {isChatMode && ( +
+ + {detail.id}
} /> + {detail.id} + + +
+ )} + {!isChatMode && ( +
+ {formatTime(detail.created_at, messageDateTimeFormat)} +
+ )} +
+
+ {!isAdvanced && 'model' in detail.model_config && } +
+ + + +
+
+
+ {(varList.length > 0 || (!isChatMode && messageFiles.length > 0)) && ( + + )} +
+
+
+ {!isChatMode + ? ( +
+
+
{t('table.header.output', { ns: 'appLog' })}
+
+
+ item.from_source === 'admin')} + onFeedback={feedback => onFeedback(completionDetail?.message.id ?? '', feedback)} + isShowTextToSpeech + siteInfo={null} + /> +
+ ) + : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING + ? ( +
+ +
+ ) + : ( +
+
+ +
+ {hasMore && ( +
+
+ {t('detail.loading', { ns: 'appLog' })} + ... +
+
+ )} +
+ )} +
+ {showMessageLogModal && ( + + { + setCurrentLogItem() + setShowMessageLogModal(false) + }} + defaultTab={currentLogModalActiveTab} + /> + + )} + {!isChatMode && showPromptLogModal && ( + { + setCurrentLogItem() + setShowPromptLogModal(false) + }} + /> + )} +
+ ) +} + +function useConversationMutationHandlers(appId: string | undefined, onAfterSuccess?: () => void) { + const { t } = useTranslation() + + const handleFeedback = useCallback(async (messageId: string, { rating, content }: FeedbackType): Promise => { + try { + await updateLogMessageFeedbacks({ + url: `/apps/${appId}/feedbacks`, + body: { message_id: messageId, rating, content: content ?? undefined }, + }) + onAfterSuccess?.() + toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) + return true + } + catch { + toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) + return false + } + }, [appId, onAfterSuccess, t]) + + return { + handleFeedback, + } +} + +export const CompletionConversationDetailComp = ({ appDetail, conversationId, onClose }: { appDetail?: App, conversationId?: string, onClose: () => void }) => { + const { data: conversationDetail, refetch } = useCompletionConversationDetail(appDetail?.id, conversationId) + const { handleFeedback } = useConversationMutationHandlers(appDetail?.id, refetch) + + if (!conversationDetail) + return null + + return ( + + ) +} + +export const ChatConversationDetailComp = ({ appDetail, conversationId, onClose }: { appDetail?: App, conversationId?: string, onClose: () => void }) => { + const { data: conversationDetail } = useChatConversationDetail(appDetail?.id, conversationId) + const { handleFeedback } = useConversationMutationHandlers(appDetail?.id) + + if (!conversationDetail) + return null + + return ( + + ) +} diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts new file mode 100644 index 0000000000..55ea2a3103 --- /dev/null +++ b/web/app/components/app/log/list-utils.ts @@ -0,0 +1,293 @@ +import type { ChatItemInTree } from '../../base/chat/types' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import type { + Annotation, + ChatConversationFullDetailResponse, + ChatConversationGeneralDetail, + ChatConversationsResponse, + ChatMessage, + CompletionConversationFullDetailResponse, + CompletionConversationGeneralDetail, + CompletionConversationsResponse, + LogAnnotation, +} from '@/models/log' +import type { FileResponse } from '@/types/workflow' +import dayjs from 'dayjs' +import timezone from 'dayjs/plugin/timezone' +import utc from 'dayjs/plugin/utc' +import { get } from 'es-toolkit/compat' +import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' +import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' +import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' +import { AppModeEnum } from '@/types/app' + +dayjs.extend(utc) +dayjs.extend(timezone) + +export type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail +export type ConversationSelection = ConversationListItem | { id: string, isPlaceholder?: true } +export type ConversationLogs = ChatConversationsResponse | CompletionConversationsResponse +export type ConversationDetail = ChatConversationFullDetailResponse | CompletionConversationFullDetailResponse + +export type StatusCount = { + paused: number + success: number + failed: number + partial_success: number +} + +type UserInputField = Record + +export const DEFAULT_EMPTY_VALUE = 'N/A' +export const MIN_ITEMS_FOR_SCROLL_LOADING = 8 +export const SCROLL_DEBOUNCE_MS = 200 +export const MAX_RETRY_COUNT = 3 + +export const mergeUniqueChatItems = (prevItems: IChatItem[], newItems: IChatItem[]) => { + const existingIds = new Set(prevItems.map(item => item.id)) + const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) + + return { + mergedItems: [...uniqueNewItems, ...prevItems], + uniqueNewItems, + } +} + +export const getNextRetryCount = (uniqueNewItemsLength: number, prevItemsLength: number, currentRetryCount: number, maxRetryCount = MAX_RETRY_COUNT) => { + if (uniqueNewItemsLength > 0) + return 0 + + if (currentRetryCount < maxRetryCount && prevItemsLength > 1) + return currentRetryCount + 1 + + return 0 +} + +export const shouldThrottleLoad = (now: number, lastLoadTime: number, debounceMs = SCROLL_DEBOUNCE_MS) => { + return now - lastLoadTime < debounceMs +} + +export const isReverseScrollNearTop = (scrollTop: number, scrollHeight: number, clientHeight: number, threshold = 40) => { + return Math.abs(scrollTop) > scrollHeight - clientHeight - threshold +} + +export const buildConversationUrl = (pathname: string, searchParams: URLSearchParams | { toString: () => string }, conversationId?: string) => { + const params = new URLSearchParams(searchParams.toString()) + if (conversationId) + params.set('conversation_id', conversationId) + else + params.delete('conversation_id') + + const queryString = params.toString() + return queryString ? `${pathname}?${queryString}` : pathname +} + +export const resolveConversationSelection = (logs: ConversationLogs | undefined, conversationIdInUrl: string, pendingConversation: ConversationSelection | undefined) => { + const matchedConversation = logs?.data?.find((item: ConversationListItem) => item.id === conversationIdInUrl) + return matchedConversation ?? pendingConversation ?? { id: conversationIdInUrl, isPlaceholder: true } +} + +export const getFormattedChatList = (messages: ChatMessage[], conversationId: string, userTimezone: string, format: string) => { + const newChatList: IChatItem[] = [] + + messages.forEach((item) => { + const questionFiles = item.message_files?.filter(file => file.belongs_to === 'user') ?? [] + newChatList.push({ + id: `question-${item.id}`, + content: item.inputs.query || item.inputs.default_input || item.query, + isAnswer: false, + message_files: getProcessedFilesFromResponse(questionFiles.map(file => ({ ...file, related_id: file.id })) as FileResponse[]), + parentMessageId: item.parent_message_id || undefined, + }) + + const answerFiles = item.message_files?.filter(file => file.belongs_to === 'assistant') ?? [] + const existingLog = item.message ?? [] + const normalizedLog = existingLog.at(-1)?.role === 'assistant' + ? existingLog + : [ + ...existingLog, + { + role: 'assistant' as const, + text: item.answer, + files: answerFiles, + }, + ] + + 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(feedback => feedback.from_source === 'user'), + adminFeedback: item.feedbacks?.find(feedback => feedback.from_source === 'admin'), + feedbackDisabled: false, + isAnswer: true, + message_files: getProcessedFilesFromResponse(answerFiles.map(file => ({ ...file, related_id: file.id })) as FileResponse[]), + log: normalizedLog 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(userTimezone).format(format), + tokens: item.answer_tokens + item.message_tokens, + latency: (item.provider_response_latency ?? 0).toFixed(2), + }, + citation: item.metadata?.retriever_resources, + annotation: item.annotation_hit_history + ? { + id: item.annotation_hit_history.annotation_id, + authorName: item.annotation_hit_history.annotation_create_account?.name || DEFAULT_EMPTY_VALUE, + created_at: item.annotation_hit_history.created_at, + } + : item.annotation + ? { + id: item.annotation.id, + authorName: item.annotation.account.name, + logAnnotation: item.annotation, + created_at: 0, + } + : undefined, + parentMessageId: `question-${item.id}`, + }) + }) + + return newChatList +} + +export const buildChatState = (allChatItems: IChatItem[], hasMore: boolean, introduction?: string | null) => { + if (allChatItems.length === 0) { + return { + chatItemTree: [] as ChatItemInTree[], + threadChatItems: [] as IChatItem[], + oldestAnswerId: undefined as string | undefined, + } + } + + let chatItemTree = buildChatItemTree(allChatItems) + if (!hasMore && introduction) { + chatItemTree = [{ + id: 'introduction', + isAnswer: true, + isOpeningStatement: true, + content: introduction, + feedbackDisabled: true, + children: chatItemTree, + }] + } + + const lastMessageId = allChatItems.at(-1)?.id + const threadChatItems = getThreadMessages(chatItemTree, lastMessageId) + const oldestAnswerId = allChatItems.find(item => item.isAnswer)?.id + + return { + chatItemTree, + threadChatItems, + oldestAnswerId, + } +} + +export const applyEditedAnnotation = (allChatItems: IChatItem[], query: string, answer: string, index: number) => { + return allChatItems.map((item, currentIndex) => { + if (currentIndex === index - 1) + return { ...item, content: query } + + if (currentIndex === index) { + return { + ...item, + annotation: { + ...item.annotation, + logAnnotation: { + ...item.annotation?.logAnnotation, + content: answer, + }, + } as Annotation, + } + } + + return item + }) +} + +export const applyAddedAnnotation = (allChatItems: IChatItem[], annotationId: string, authorName: string, query: string, answer: string, index: number) => { + return allChatItems.map((item, currentIndex) => { + if (currentIndex === index - 1) + return { ...item, content: query } + + if (currentIndex === index) { + return { + ...item, + content: item.content, + annotation: { + id: annotationId, + authorName, + logAnnotation: { + content: answer, + account: { + id: '', + name: authorName, + email: '', + }, + }, + } as Annotation, + } + } + + return item + }) +} + +export const removeAnnotationFromChatItems = (allChatItems: IChatItem[], index: number) => { + return allChatItems.map((item, currentIndex) => { + if (currentIndex !== index) + return item + + return { + ...item, + content: item.content, + annotation: undefined, + } + }) +} + +export const buildDetailVarList = (detail: ConversationDetail, varValues: Record) => { + const userInputForm = ((detail.model_config as { user_input_form?: UserInputField[] })?.user_input_form) ?? [] + const detailInputs = 'message' in detail ? detail.message.inputs : undefined + + return userInputForm.map((item) => { + const itemContent = item[Object.keys(item)[0]] + return { + label: itemContent.variable, + value: varValues[itemContent.variable] || detailInputs?.[itemContent.variable], + } + }) +} + +export const getDetailMessageFiles = (appMode: AppModeEnum, detail: ConversationDetail) => { + if (appMode !== AppModeEnum.COMPLETION || !('message' in detail) || !detail.message.message_files?.length) + return [] + + return detail.message.message_files.map(item => item.url) +} + +export const getConversationRowValues = (log: ConversationListItem, isChatMode: boolean) => { + const endUser = log.from_end_user_session_id || log.from_account_id + 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 { + endUser, + leftValue, + rightValue, + } +} + +export const getAnnotationTooltipText = (annotation: LogAnnotation | undefined, formattedTime: string, text: string) => { + if (!annotation) + return '' + + return `${text} ${formattedTime}` +} diff --git a/web/app/components/app/log/list.spec.tsx b/web/app/components/app/log/list.spec.tsx index cb92ecc437..65ce80f95e 100644 --- a/web/app/components/app/log/list.spec.tsx +++ b/web/app/components/app/log/list.spec.tsx @@ -1,228 +1,619 @@ -/** - * Tests for race condition prevention logic in chat message loading. - * These tests verify the core algorithms used in fetchData and loadMoreMessages - * to prevent race conditions, infinite loops, and stale state issues. - * See GitHub issue #30259 for context. - */ +import type { StatusCount } from './list-utils' +import type { + Annotation, + ChatConversationGeneralDetail, + ChatConversationsResponse, + CompletionConversationGeneralDetail, + CompletionConversationsResponse, +} from '@/models/log' +import type { App, AppIconType, AppModeEnum } from '@/types/app' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TransferMethod } from '@/types/app' +import List from './list' +import { + applyAddedAnnotation, + applyEditedAnnotation, + buildChatState, + buildConversationUrl, + buildDetailVarList, + getAnnotationTooltipText, + getConversationRowValues, + getDetailMessageFiles, + getFormattedChatList, + getNextRetryCount, + isReverseScrollNearTop, + mergeUniqueChatItems, + removeAnnotationFromChatItems, + resolveConversationSelection, + shouldThrottleLoad, -// Test the race condition prevention logic in isolation -describe('Chat Message Loading Race Condition Prevention', () => { +} from './list-utils' + +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockSetShowPromptLogModal = vi.fn() +const mockSetShowAgentLogModal = vi.fn() +const mockSetShowMessageLogModal = vi.fn() +let mockSearchParams = new URLSearchParams() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + }), + usePathname: () => '/apps/test-app/logs', + useSearchParams: () => mockSearchParams, +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { + setShowPromptLogModal: typeof mockSetShowPromptLogModal + setShowAgentLogModal: typeof mockSetShowAgentLogModal + setShowMessageLogModal: typeof mockSetShowMessageLogModal + }) => unknown) => selector({ + setShowPromptLogModal: mockSetShowPromptLogModal, + setShowAgentLogModal: mockSetShowAgentLogModal, + setShowMessageLogModal: mockSetShowMessageLogModal, + }), +})) + +vi.mock('@/app/components/base/drawer', () => ({ + default: ({ children, isOpen }: { children: React.ReactNode, isOpen: boolean }) => ( + isOpen ?
{children}
: null + ), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('./list-detail-panel', () => ({ + ChatConversationDetailComp: ({ conversationId, onClose }: { conversationId?: string, onClose: () => void }) => ( +
+ {conversationId} + +
+ ), + CompletionConversationDetailComp: ({ conversationId, onClose }: { conversationId?: string, onClose: () => void }) => ( +
+ {conversationId} + +
+ ), +})) + +const createMockApp = (overrides: Partial = {}) => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: 'chat' as AppModeEnum, + runtime_type: 'classic' as const, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) satisfies App + +const createAnnotation = (overrides: Partial = {}): Annotation => ({ + id: 'annotation-1', + authorName: 'Admin', + logAnnotation: { + id: 'log-annotation-1', + content: 'Saved answer', + account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 123, + }, + created_at: 123, + ...overrides, +}) + +const createChatLog = (overrides: Partial = {}): ChatConversationGeneralDetail => ({ + id: 'chat-conversation-1', + status: 'normal', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_end_user_session_id: 'session-1', + from_account_id: 'account-1', + read_at: new Date(), + created_at: 100, + updated_at: 200, + user_feedback_stats: { like: 1, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + prompt_template: 'Prompt', + }, + }, + summary: 'Chat summary', + message_count: 2, + annotated: false, + ...overrides, +}) + +const createCompletionLog = (overrides: Partial = {}): CompletionConversationGeneralDetail => ({ + id: 'completion-conversation-1', + status: 'finished', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_end_user_session_id: 'session-1', + from_account_id: 'account-1', + read_at: new Date(), + created_at: 100, + updated_at: 200, + annotation: createAnnotation(), + user_feedback_stats: { like: 0, dislike: 0 }, + admin_feedback_stats: { like: 1, dislike: 0 }, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + prompt_template: 'Prompt', + }, + }, + message: { + inputs: { query: 'completion input' }, + query: 'completion query', + answer: 'completion answer', + message: [], + }, + ...overrides, +}) + +describe('list utils', () => { beforeEach(() => { vi.clearAllMocks() - vi.useFakeTimers() }) - afterEach(() => { - vi.useRealTimers() + it('should merge only unique chat items', () => { + const existingItems = [{ id: 'msg-1' }, { id: 'msg-2' }] as never[] + const newItems = [{ id: 'msg-2' }, { id: 'msg-3' }] as never[] + + const result = mergeUniqueChatItems(existingItems, newItems) + + expect(result.uniqueNewItems).toHaveLength(1) + expect(result.uniqueNewItems[0].id).toBe('msg-3') + expect(result.mergedItems.map(item => item.id)).toEqual(['msg-3', 'msg-1', 'msg-2']) }) - describe('Request Deduplication', () => { - it('should deduplicate messages with same IDs when merging responses', async () => { - // Simulate the deduplication logic used in setAllChatItems - const existingItems = [ - { id: 'msg-1', isAnswer: false }, - { id: 'msg-2', isAnswer: true }, - ] - const newItems = [ - { id: 'msg-2', isAnswer: true }, // duplicate - { id: 'msg-3', isAnswer: false }, // new - ] - - const existingIds = new Set(existingItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) - const mergedItems = [...uniqueNewItems, ...existingItems] - - expect(uniqueNewItems).toHaveLength(1) - expect(uniqueNewItems[0].id).toBe('msg-3') - expect(mergedItems).toHaveLength(3) - }) + it('should calculate retry counts for empty result pages', () => { + expect(getNextRetryCount(0, 5, 0)).toBe(1) + expect(getNextRetryCount(0, 5, 3)).toBe(0) + expect(getNextRetryCount(2, 5, 2)).toBe(0) }) - describe('Retry Counter Logic', () => { - const MAX_RETRY_COUNT = 3 - - it('should increment retry counter when no unique items found', () => { - const state = { retryCount: 0 } - const prevItemsLength = 5 - - // Simulate the retry logic from loadMoreMessages - const uniqueNewItemsLength = 0 - - if (uniqueNewItemsLength === 0) { - if (state.retryCount < MAX_RETRY_COUNT && prevItemsLength > 1) { - state.retryCount++ - } - else { - state.retryCount = 0 - } - } - - expect(state.retryCount).toBe(1) - }) - - it('should reset retry counter after MAX_RETRY_COUNT attempts', () => { - const state = { retryCount: MAX_RETRY_COUNT } - const prevItemsLength = 5 - const uniqueNewItemsLength = 0 - - if (uniqueNewItemsLength === 0) { - if (state.retryCount < MAX_RETRY_COUNT && prevItemsLength > 1) { - state.retryCount++ - } - else { - state.retryCount = 0 - } - } - - expect(state.retryCount).toBe(0) - }) - - it('should reset retry counter when unique items are found', () => { - const state = { retryCount: 2 } - - // Simulate finding unique items (length > 0) - const processRetry = (uniqueCount: number) => { - if (uniqueCount === 0) { - state.retryCount++ - } - else { - state.retryCount = 0 - } - } - - processRetry(3) // Found 3 unique items - - expect(state.retryCount).toBe(0) - }) + it('should throttle scroll-triggered loads inside the debounce window', () => { + expect(shouldThrottleLoad(1100, 1000)).toBe(true) + expect(shouldThrottleLoad(1300, 1000)).toBe(false) }) - describe('Throttling Logic', () => { - const SCROLL_DEBOUNCE_MS = 200 - - it('should throttle requests within debounce window', () => { - const state = { lastLoadTime: 0 } - const results: boolean[] = [] - - const tryRequest = (now: number): boolean => { - if (now - state.lastLoadTime >= SCROLL_DEBOUNCE_MS) { - state.lastLoadTime = now - return true - } - return false - } - - // First request - should pass - results.push(tryRequest(1000)) - // Second request within debounce - should be blocked - results.push(tryRequest(1100)) - // Third request after debounce - should pass - results.push(tryRequest(1300)) - - expect(results).toEqual([true, false, true]) - }) + it('should detect reverse-scroll near-top state', () => { + expect(isReverseScrollNearTop(-900, 1000, 100)).toBe(true) + expect(isReverseScrollNearTop(-100, 1000, 100)).toBe(false) }) - describe('AbortController Cancellation', () => { - it('should abort previous request when new request starts', () => { - const state: { controller: AbortController | null } = { controller: null } - const abortedSignals: boolean[] = [] + it('should build and clear conversation urls', () => { + const params = new URLSearchParams('page=2') - // First request - const controller1 = new AbortController() - state.controller = controller1 - - // Second request - should abort first - if (state.controller) { - state.controller.abort() - abortedSignals.push(state.controller.signal.aborted) - } - const controller2 = new AbortController() - state.controller = controller2 - - expect(abortedSignals).toEqual([true]) - expect(controller1.signal.aborted).toBe(true) - expect(controller2.signal.aborted).toBe(false) - }) + expect(buildConversationUrl('/apps/test/logs', params, 'conversation-1')).toBe('/apps/test/logs?page=2&conversation_id=conversation-1') + expect(buildConversationUrl('/apps/test/logs', new URLSearchParams('conversation_id=conversation-1'))).toBe('/apps/test/logs') }) - describe('Stale Response Detection', () => { - it('should ignore responses from outdated requests', () => { - const state = { requestId: 0 } - const processedResponses: number[] = [] - - // Simulate concurrent requests - each gets its own captured ID - const request1Id = ++state.requestId - const request2Id = ++state.requestId - - // Request 2 completes first (current requestId is 2) - if (request2Id === state.requestId) { - processedResponses.push(request2Id) - } - - // Request 1 completes later (stale - requestId is still 2) - if (request1Id === state.requestId) { - processedResponses.push(request1Id) - } - - expect(processedResponses).toEqual([2]) - expect(processedResponses).not.toContain(1) - }) - }) - - describe('Pagination Anchor Management', () => { - it('should track oldest answer ID for pagination', () => { - let oldestAnswerIdRef: string | undefined - - const chatItems = [ - { id: 'question-1', isAnswer: false }, - { id: 'answer-1', isAnswer: true }, - { id: 'question-2', isAnswer: false }, - { id: 'answer-2', isAnswer: true }, - ] - - // Update pagination anchor with oldest answer ID - const answerItems = chatItems.filter(item => item.isAnswer) - const oldestAnswer = answerItems[0] - if (oldestAnswer?.id) { - oldestAnswerIdRef = oldestAnswer.id - } - - expect(oldestAnswerIdRef).toBe('answer-1') - }) - - it('should use pagination anchor in subsequent requests', () => { - const oldestAnswerIdRef = 'answer-123' - const params: { conversation_id: string, limit: number, first_id?: string } = { - conversation_id: 'conv-1', - limit: 10, - } - - if (oldestAnswerIdRef) { - params.first_id = oldestAnswerIdRef - } - - expect(params.first_id).toBe('answer-123') - }) - }) -}) - -describe('Functional State Update Pattern', () => { - it('should use functional update to avoid stale closures', () => { - // Simulate the functional update pattern used in setAllChatItems - let state = [{ id: '1' }, { id: '2' }] - - const newItems = [{ id: '3' }, { id: '2' }] // id '2' is duplicate - - // Functional update pattern - const updater = (prevItems: { id: string }[]) => { - const existingIds = new Set(prevItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) - return [...uniqueNewItems, ...prevItems] + it('should resolve the active conversation from logs, cache, or placeholder', () => { + const logs: ChatConversationsResponse = { + data: [createChatLog()], + has_more: false, + limit: 20, + total: 1, + page: 1, } - state = updater(state) + expect(resolveConversationSelection(logs, 'chat-conversation-1', undefined)).toMatchObject({ id: 'chat-conversation-1' }) + expect(resolveConversationSelection(undefined, 'cached-id', { id: 'cached-id', isPlaceholder: true })).toMatchObject({ id: 'cached-id' }) + expect(resolveConversationSelection(undefined, 'placeholder-id', undefined)).toMatchObject({ id: 'placeholder-id', isPlaceholder: true }) + }) - expect(state).toHaveLength(3) - expect(state.map(i => i.id)).toEqual(['3', '1', '2']) + it('should format chat messages into question/answer items', () => { + const formatted = getFormattedChatList([{ + id: 'message-1', + conversation_id: 'conversation-1', + query: 'What is Dify?', + inputs: { query: 'What is Dify?' }, + message: [{ role: 'user', text: 'What is Dify?' }], + message_tokens: 10, + answer_tokens: 20, + answer: 'An AI app platform', + provider_response_latency: 1.2, + created_at: 123, + annotation: createAnnotation().logAnnotation!, + annotation_hit_history: { + annotation_id: 'history-1', + annotation_create_account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 120, + }, + feedbacks: [{ rating: 'like', content: null, from_source: 'admin' }], + message_files: [], + metadata: { + retriever_resources: [], + annotation_reply: { + id: 'annotation-reply-1', + account: { + id: 'account-1', + name: 'Admin', + }, + }, + }, + agent_thoughts: [], + workflow_run_id: 'workflow-1', + parent_message_id: null, + }], 'conversation-1', 'UTC', 'YYYY-MM-DD') + + expect(formatted).toHaveLength(2) + expect(formatted[0].id).toBe('question-message-1') + expect(formatted[1].id).toBe('message-1') + expect(formatted[1].annotation?.id).toBe('history-1') + }) + + it('should preserve assistant logs and fallback annotations when formatting chat messages', () => { + const formatted = getFormattedChatList([{ + id: 'message-2', + conversation_id: 'conversation-1', + query: 'What is new?', + inputs: { default_input: 'fallback input' }, + message: [{ role: 'assistant', text: 'Already normalized' }], + message_tokens: 10, + answer_tokens: 20, + answer: 'Already normalized', + provider_response_latency: 1.2, + created_at: 123, + annotation: createAnnotation().logAnnotation!, + annotation_hit_history: undefined as never, + feedbacks: [], + message_files: [], + metadata: { + retriever_resources: [], + annotation_reply: { + id: 'annotation-reply-1', + account: { + id: 'account-1', + name: 'Admin', + }, + }, + }, + agent_thoughts: [], + workflow_run_id: 'workflow-1', + parent_message_id: null, + }], 'conversation-1', 'UTC', 'YYYY-MM-DD') + + expect(formatted[0].content).toBe('fallback input') + expect(formatted[1].log).toHaveLength(1) + expect(formatted[1].annotation?.authorName).toBe('Admin') + }) + + it('should apply annotation add and edit updates to chat items', () => { + const items = [ + { id: 'question-1', content: 'Old question' }, + { id: 'answer-1', content: 'Old answer' }, + ] as never[] + + const added = applyAddedAnnotation(items, 'annotation-1', 'Admin', 'New question', 'New answer', 1) + const edited = applyEditedAnnotation(added, 'Edited question', 'Edited answer', 1) + + expect(added[0].content).toBe('New question') + expect(added[1].annotation?.id).toBe('annotation-1') + expect(edited[0].content).toBe('Edited question') + expect(edited[1].annotation?.logAnnotation?.content).toBe('Edited answer') + }) + + it('should derive detail vars, files, row values, and tooltip text from typed data', () => { + const completionDetail = { + id: 'detail-1', + status: 'finished', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_account_id: 'account-1', + created_at: 100, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + introduction: '', + prompt_template: 'Prompt', + prompt_variables: [], + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + user_input_form: [{ customer: { variable: 'customer' } }], + }, + message: { + id: 'message-1', + conversation_id: 'detail-1', + query: 'hello', + inputs: { customer: 'Alice' }, + message: [], + message_tokens: 0, + answer_tokens: 0, + answer: 'world', + provider_response_latency: 0, + created_at: 100, + annotation: createAnnotation().logAnnotation!, + annotation_hit_history: { + annotation_id: 'annotation-hit', + annotation_create_account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 100, + }, + feedbacks: [], + message_files: [{ + id: 'file-1', + type: 'image', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/file.png', + upload_file_id: 'upload-1', + belongs_to: 'assistant', + }], + metadata: { retriever_resources: [] }, + agent_thoughts: [], + workflow_run_id: 'workflow-1', + parent_message_id: null, + }, + } as never + + const vars = buildDetailVarList(completionDetail, {}) + const files = getDetailMessageFiles('completion' as AppModeEnum, completionDetail) + const rowValues = getConversationRowValues(createCompletionLog({ + from_end_user_session_id: '', + message: { + ...createCompletionLog().message, + inputs: { default_input: 'fallback query' }, + query: '', + answer: 'completion answer', + }, + }), false) + const tooltipText = getAnnotationTooltipText(createAnnotation().logAnnotation, '03-30 05:00 PM', 'Saved by Admin') + + expect(vars).toEqual([{ label: 'customer', value: 'Alice' }]) + expect(files).toEqual(['https://example.com/file.png']) + expect(getDetailMessageFiles('chat' as AppModeEnum, completionDetail)).toEqual([]) + expect(rowValues.endUser).toBe('account-1') + expect(rowValues.leftValue).toBe('fallback query') + expect(rowValues.rightValue).toBe('completion answer') + expect(tooltipText).toBe('Saved by Admin 03-30 05:00 PM') + expect(getAnnotationTooltipText(undefined, '03-30 05:00 PM', 'Saved')).toBe('') + }) + + it('should build chat state and remove annotations without mutating other items', () => { + const items = [ + { id: 'question-1', content: 'Question', isAnswer: false }, + { id: 'answer-1', content: 'Answer', isAnswer: true, parentMessageId: 'question-1', annotation: createAnnotation() }, + ] as never[] + + expect(buildChatState([], false)).toEqual({ + chatItemTree: [], + threadChatItems: [], + oldestAnswerId: undefined, + }) + + const state = buildChatState(items, false, 'Opening statement') + const removed = removeAnnotationFromChatItems(items, 1) + + expect(state.oldestAnswerId).toBe('answer-1') + expect(state.threadChatItems[0].id).toBe('introduction') + expect(removed[0].annotation).toBeUndefined() + expect(removed[1].annotation).toBeUndefined() + }) +}) + +describe('List component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSearchParams = new URLSearchParams() + }) + + it('should render a loading state when logs are undefined', () => { + render() + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should push conversation id and open the chat drawer when a row is clicked', async () => { + const user = userEvent.setup() + const logs: ChatConversationsResponse = { + data: [Object.assign(createChatLog(), { name: 'Chat summary' })], + has_more: false, + limit: 20, + total: 1, + page: 1, + } + + render() + + await user.click(screen.getByText('Chat summary')) + + expect(mockPush).toHaveBeenCalledWith('/apps/test-app/logs?conversation_id=chat-conversation-1', { scroll: false }) + }) + + it('should open from the url and clear the query param when the drawer closes', async () => { + const user = userEvent.setup() + mockSearchParams = new URLSearchParams('conversation_id=chat-conversation-1') + const logs: ChatConversationsResponse = { + data: [createChatLog()], + has_more: false, + limit: 20, + total: 1, + page: 1, + } + const onRefresh = vi.fn() + + render() + + await waitFor(() => { + expect(screen.getByTestId('drawer')).toBeInTheDocument() + }) + + await user.click(screen.getByText('close-drawer')) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false) + expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false) + expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false) + expect(mockReplace).toHaveBeenCalledWith('/apps/test-app/logs', { scroll: false }) + }) + + it('should render advanced chat status counts and completion annotations', () => { + const advancedLogs: ChatConversationsResponse = { + data: [ + Object.assign(createChatLog({ + id: 'advanced-conversation-1', + summary: 'Advanced summary', + annotated: true, + }), { + status_count: { + paused: 0, + success: 1, + failed: 0, + partial_success: 0, + } satisfies StatusCount, + }), + ], + has_more: false, + limit: 20, + total: 1, + page: 1, + } + const completionLogs: CompletionConversationsResponse = { + data: [createCompletionLog()], + has_more: false, + limit: 20, + total: 1, + page: 1, + } + + const { rerender } = render( + , + ) + + expect(screen.getByText('Success')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText(/appLog.detail.annotationTip/)).toBeInTheDocument() + }) + + it('should render pending, partial-success, and failure statuses for advanced chat rows', () => { + const advancedLogs: ChatConversationsResponse = { + data: [ + Object.assign(createChatLog({ id: 'pending-log', summary: 'Pending summary' }), { + status_count: { + paused: 1, + success: 0, + failed: 0, + partial_success: 0, + } satisfies StatusCount, + }), + Object.assign(createChatLog({ id: 'partial-log', summary: 'Partial summary' }), { + status_count: { + paused: 0, + success: 0, + failed: 0, + partial_success: 2, + } satisfies StatusCount, + }), + Object.assign(createChatLog({ id: 'failure-log', summary: 'Failure summary' }), { + status_count: { + paused: 0, + success: 0, + failed: 2, + partial_success: 0, + } satisfies StatusCount, + }), + ], + has_more: false, + limit: 20, + total: 3, + page: 1, + } + + render( + , + ) + + expect(screen.getByText('Pending')).toBeInTheDocument() + expect(screen.getByText('Partial Success')).toBeInTheDocument() + expect(screen.getByText('2 Failures')).toBeInTheDocument() }) }) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 4a22a0c85f..c0c594c720 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -1,99 +1,41 @@ 'use client' + import type { FC } from 'react' -import type { ChatItemInTree } from '../../base/chat/types' -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 { ConversationListItem, ConversationLogs, StatusCount } from './list-utils' import type { App } from '@/types/app' -import { - HandThumbDownIcon, - HandThumbUpIcon, -} from '@heroicons/react/24/outline' -import { RiCloseLine, RiEditFill } from '@remixicon/react' -import dayjs from 'dayjs' -import timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' -import { get } from 'es-toolkit/compat' -import { noop } from 'es-toolkit/function' +import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' +import { RiEditFill } from '@remixicon/react' import * as React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { createContext, useContext } from 'use-context-selector' -import { useShallow } from 'zustand/react/shallow' -import ModelInfo from '@/app/components/app/log/model-info' -import { useStore as useAppStore } from '@/app/components/app/store' -import TextGeneration from '@/app/components/app/text-generate/item' -import ActionButton from '@/app/components/base/action-button' -import Chat from '@/app/components/base/chat/chat' -import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' -import CopyIcon from '@/app/components/base/copy-icon' import Drawer from '@/app/components/base/drawer' -import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import Loading from '@/app/components/base/loading' -import MessageLogModal from '@/app/components/base/message-log-modal' -import Tooltip from '@/app/components/base/tooltip' -import { toast } from '@/app/components/base/ui/toast' -import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' -import { WorkflowContextProvider } from '@/app/components/workflow/context' -import { useAppContext } from '@/context/app-context' -import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip' +import Indicator from '@/app/components/header/indicator' import useTimestamp from '@/hooks/use-timestamp' -import { usePathname, useRouter, useSearchParams } from '@/next/navigation' -import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' -import { AppSourceType } from '@/service/share' -import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' -import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' -import PromptLogModal from '../../base/prompt-log-modal' -import Indicator from '../../header/indicator' -import VarPanel from './var-panel' - -type AppStoreState = ReturnType -type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail -type ConversationSelection = ConversationListItem | { id: string, isPlaceholder?: true } - -dayjs.extend(utc) -dayjs.extend(timezone) +import { ChatConversationDetailComp, CompletionConversationDetailComp } from './list-detail-panel' +import { DEFAULT_EMPTY_VALUE, getConversationRowValues } from './list-utils' +import { useConversationDrawer } from './use-conversation-drawer' type IConversationList = { - logs?: ChatConversationsResponse | CompletionConversationsResponse + logs?: ConversationLogs appDetail: App onRefresh: () => void } -const defaultValue = 'N/A' - -type IDrawerContext = { - onClose: () => void - appDetail?: App -} - -type StatusCount = { - paused: number - 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 - +const StatusCountIndicator = ({ statusCount }: { statusCount: StatusCount }) => { if (statusCount.paused > 0) { return (
@@ -102,7 +44,8 @@ const statusTdRender = (statusCount: StatusCount) => {
) } - else if (statusCount.partial_success + statusCount.failed === 0) { + + if (statusCount.partial_success + statusCount.failed === 0) { return (
@@ -110,7 +53,8 @@ const statusTdRender = (statusCount: StatusCount) => {
) } - else if (statusCount.failed === 0) { + + if (statusCount.failed === 0) { return (
@@ -118,848 +62,98 @@ const statusTdRender = (statusCount: StatusCount) => {
) } - else { - return ( -
- - - {statusCount.failed} - {' '} - {`${statusCount.failed > 1 ? 'Failures' : 'Failure'}`} - -
- ) - } -} - -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({ - 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 ?? 0).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 - } - catch (error) { - console.error('getFormattedChatList processing failed:', error) - throw error - } -} - -type IDetailPanel = { - detail: any - onFeedback: FeedbackFunc - onSubmitAnnotation: SubmitAnnotationFunc -} - -function DetailPanel({ detail, onFeedback }: IDetailPanel) { - const MIN_ITEMS_FOR_SCROLL_LOADING = 8 - const SCROLL_DEBOUNCE_MS = 200 - const { userProfile: { timezone } } = useAppContext() - const { formatTime } = useTimestamp() - const { onClose, appDetail } = useContext(DrawerContext) - const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({ - 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 isLoadingRef = useRef(false) - const abortControllerRef = useRef(null) - const requestIdRef = useRef(0) - const lastLoadTimeRef = useRef(0) - const retryCountRef = useRef(0) - const oldestAnswerIdRef = useRef(undefined) - const MAX_RETRY_COUNT = 3 - - const [allChatItems, setAllChatItems] = useState([]) - const [chatItemTree, setChatItemTree] = useState([]) - const [threadChatItems, setThreadChatItems] = useState([]) - - const fetchData = useCallback(async () => { - if (isLoadingRef.current || !hasMore) - return - - // Cancel any in-flight request - if (abortControllerRef.current) { - abortControllerRef.current.abort() - } - - const controller = new AbortController() - abortControllerRef.current = controller - const currentRequestId = ++requestIdRef.current - - try { - isLoadingRef.current = true - - const params: ChatMessagesRequest = { - conversation_id: detail.id, - limit: 10, - } - // Use ref for pagination anchor to avoid stale closure issues - if (oldestAnswerIdRef.current) - params.first_id = oldestAnswerIdRef.current - - const messageRes = await fetchChatMessages({ - url: `/apps/${appDetail?.id}/chat-messages`, - params, - }) - - // Ignore stale responses - if (currentRequestId !== requestIdRef.current || controller.signal.aborted) - 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('dateTimeFormat', { ns: 'appLog' }) as string) - - // Use functional update to avoid stale state issues - setAllChatItems((prevItems: IChatItem[]) => { - const existingIds = new Set(prevItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) - return [...uniqueNewItems, ...prevItems] - }) - } - catch (err: unknown) { - if (err instanceof Error && err.name === 'AbortError') - return - console.error('fetchData execution failed:', err) - } - finally { - isLoadingRef.current = false - if (abortControllerRef.current === controller) - abortControllerRef.current = null - } - }, [detail.id, hasMore, timezone, t, appDetail]) - - // Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems - useEffect(() => { - if (allChatItems.length === 0) - return - - let tree = buildChatItemTree(allChatItems) - if (!hasMore && 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) - - const lastMessageId = allChatItems.length > 0 ? allChatItems[allChatItems.length - 1].id : undefined - setThreadChatItems(getThreadMessages(tree, lastMessageId)) - - // Update pagination anchor ref with the oldest answer ID - const answerItems = allChatItems.filter(item => item.isAnswer) - const oldestAnswer = answerItems[0] - if (oldestAnswer?.id) - oldestAnswerIdRef.current = oldestAnswer.id - }, [allChatItems, hasMore, detail?.model_config?.configs?.introduction]) - - const switchSibling = useCallback((siblingMessageId: string) => { - const newThreadChatItems = getThreadMessages(chatItemTree, siblingMessageId) - setThreadChatItems(newThreadChatItems) - }, [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 - })) - - toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - return true - } - catch { - toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) - return false - } - }, [allChatItems, appDetail?.id, t]) - - const fetchInitiated = useRef(false) - - // Only load initial messages, don't auto-load more - useEffect(() => { - if (appDetail?.id && detail.id && appDetail?.mode !== AppModeEnum.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 - - // Throttle using ref to persist across re-renders - const now = Date.now() - if (now - lastLoadTimeRef.current < SCROLL_DEBOUNCE_MS) - return - lastLoadTimeRef.current = now - - setIsLoading(true) - - try { - const params: ChatMessagesRequest = { - conversation_id: detail.id, - limit: 10, - } - - // Use ref for pagination anchor to avoid stale closure issues - if (oldestAnswerIdRef.current) { - params.first_id = oldestAnswerIdRef.current - } - - const messageRes = await fetchChatMessages({ - url: `/apps/${appDetail.id}/chat-messages`, - params, - }) - - if (!messageRes.data || messageRes.data.length === 0) { - setHasMore(false) - retryCountRef.current = 0 - 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('dateTimeFormat', { ns: 'appLog' }) as string, - ) - - // Use functional update to get latest state and avoid stale closures - setAllChatItems((prevItems: IChatItem[]) => { - const existingIds = new Set(prevItems.map(item => item.id)) - const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id)) - - // If no unique items and we haven't exceeded retry limit, signal retry needed - if (uniqueNewItems.length === 0) { - if (retryCountRef.current < MAX_RETRY_COUNT && prevItems.length > 1) { - retryCountRef.current++ - return prevItems - } - else { - retryCountRef.current = 0 - return prevItems - } - } - - retryCountRef.current = 0 - return [...uniqueNewItems, ...prevItems] - }) - } - catch (error) { - console.error(error) - setHasMore(false) - retryCountRef.current = 0 - } - finally { - setIsLoading(false) - } - }, [detail.id, hasMore, isLoading, timezone, t, appDetail]) - - const handleScroll = useCallback(() => { - const scrollableDiv = document.getElementById('scrollableDiv') - if (!scrollableDiv) - return - const clientHeight = scrollableDiv.clientHeight - const scrollHeight = scrollableDiv.scrollHeight - const currentScrollTop = scrollableDiv.scrollTop - // currentScrollTop is negative due to column-reverse flex direction - const isNearTop = Math.abs(currentScrollTop) > scrollHeight - clientHeight - 40 - - if (isNearTop && hasMore && !isLoading) { - loadMoreMessages() - } - }, [hasMore, isLoading, loadMoreMessages]) - - const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION - const isAdvanced = appDetail?.mode === AppModeEnum.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('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}
- {isChatMode && ( -
- -
{detail.id}
-
- -
- )} - {!isChatMode && ( -
{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}
- )} -
-
- {!isAdvanced && } -
- - - -
- {/* Panel Body */} -
-
- {(varList.length > 0 || (!isChatMode && message_files.length > 0)) && ( - - )} -
-
-
- {!isChatMode - ? ( -
-
-
{t('table.header.output', { ns: 'appLog' })}
-
-
-
- item.from_source === 'admin')} - onFeedback={feedback => onFeedback(detail.message.id, feedback)} - isShowTextToSpeech - siteInfo={null} - /> -
- ) - : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? ( -
- -
- ) : ( -
- {/* Put the scroll bar always on the bottom */} -
- -
- {hasMore && ( -
-
- {t('detail.loading', { ns: 'appLog' })} - ... -
-
- )} -
- )} -
- {showMessageLogModal && ( - - { - setCurrentLogItem() - setShowMessageLogModal(false) - }} - defaultTab={currentLogModalActiveTab} - /> - - )} - {!isChatMode && showPromptLogModal && ( - { - setCurrentLogItem() - setShowPromptLogModal(false) - }} - /> - )} +
+ + + {statusCount.failed} + {' '} + {statusCount.failed > 1 ? 'Failures' : 'Failure'} +
) } -/** - * Text App Conversation Detail Component - */ -const CompletionConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { - // Text Generator App Session Details Including Message List - const { data: conversationDetail, refetch: conversationDetailMutate } = useCompletionConversationDetail(appId, conversationId) - const { t } = useTranslation() +const TableCellValue = ({ + annotationTooltip, + isEmptyStyle, + isHighlight = false, + value, +}: { + annotationTooltip?: string + isEmptyStyle: boolean + isHighlight?: boolean + value: string | number | null +}) => { + const displayValue = value === 0 ? 0 : value || '-' + const content = ( +
+ {displayValue} +
+ ) - const handleFeedback = async (mid: string, { rating, content }: FeedbackType): Promise => { - try { - await updateLogMessageFeedbacks({ - url: `/apps/${appId}/feedbacks`, - body: { message_id: mid, rating, content: content ?? undefined }, - }) - conversationDetailMutate() - toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - return true - } - catch { - toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) - return false - } - } - - const handleAnnotation = async (mid: string, value: string): Promise => { - try { - await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } }) - conversationDetailMutate() - toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - return true - } - catch { - toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) - return false - } - } - - if (!conversationDetail) - return null + if (!annotationTooltip) + return content return ( - + + + + + + {annotationTooltip} + + + ) } -/** - * Chat App Conversation Detail Component - */ -const ChatConversationDetailComp: FC<{ appId?: string, conversationId?: string }> = ({ appId, conversationId }) => { - const { data: conversationDetail } = useChatConversationDetail(appId, conversationId) - const { t } = useTranslation() - - const handleFeedback = async (mid: string, { rating, content }: FeedbackType): Promise => { - try { - await updateLogMessageFeedbacks({ - url: `/apps/${appId}/feedbacks`, - body: { message_id: mid, rating, content: content ?? undefined }, - }) - toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - return true - } - catch { - toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) - return false - } - } - - const handleAnnotation = async (mid: string, value: string): Promise => { - try { - await updateLogMessageAnnotations({ url: `/apps/${appId}/annotations`, body: { message_id: mid, content: value } }) - toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - return true - } - catch { - toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) - return false - } - } - - if (!conversationDetail) - return null +const FeedbackCounts = ({ + defaultLabel, + dislike, + like, +}: { + defaultLabel: string + dislike: number + like: number +}) => { + if (!like && !dislike) + return return ( - + <> + {!!like && } + {!!dislike && } + ) } -/** - * Conversation list component including basic information - */ const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined - - 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 closingConversationIdRef = useRef(null) - const pendingConversationIdRef = useRef(null) - const pendingConversationCacheRef = useRef(undefined) - const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION // Whether the app is a chat app - const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT // Whether the app is a chatflow app - const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({ - setShowPromptLogModal: state.setShowPromptLogModal, - setShowAgentLogModal: state.setShowAgentLogModal, - setShowMessageLogModal: state.setShowMessageLogModal, - }))) - - const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id - - const buildUrlWithConversation = useCallback((conversationId?: string) => { - const params = new URLSearchParams(searchParams.toString()) - if (conversationId) - params.set('conversation_id', conversationId) - else - params.delete('conversation_id') - - const queryString = params.toString() - return queryString ? `${pathname}?${queryString}` : pathname - }, [pathname, searchParams]) - - const handleRowClick = useCallback((log: ConversationListItem) => { - if (conversationIdInUrl === log.id) { - if (!showDrawer) - setShowDrawer(true) - - if (!currentConversation || currentConversation.id !== log.id) - setCurrentConversation(log) - return - } - - pendingConversationIdRef.current = log.id - pendingConversationCacheRef.current = log - if (!showDrawer) - setShowDrawer(true) - - if (currentConversation?.id !== log.id) - setCurrentConversation(undefined) - - router.push(buildUrlWithConversation(log.id), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) - - const currentConversationId = currentConversation?.id - - useEffect(() => { - if (!conversationIdInUrl) { - if (pendingConversationIdRef.current) - return - - if (showDrawer || currentConversationId) { - setShowDrawer(false) - setCurrentConversation(undefined) - } - closingConversationIdRef.current = null - pendingConversationCacheRef.current = undefined - return - } - - if (closingConversationIdRef.current === conversationIdInUrl) - return - - if (pendingConversationIdRef.current === conversationIdInUrl) - pendingConversationIdRef.current = null - - const matchedConversation = logs?.data?.find((item: ConversationListItem) => item.id === conversationIdInUrl) - const nextConversation: ConversationSelection = matchedConversation - ?? pendingConversationCacheRef.current - ?? { id: conversationIdInUrl, isPlaceholder: true } - - if (!showDrawer) - setShowDrawer(true) - - if (!currentConversation || currentConversation.id !== conversationIdInUrl || (!('created_at' in currentConversation) && matchedConversation)) - setCurrentConversation(nextConversation) - - if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) - pendingConversationCacheRef.current = undefined - }, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) - - const onCloseDrawer = useCallback(() => { - onRefresh() - setShowDrawer(false) - setCurrentConversation(undefined) - setShowPromptLogModal(false) - setShowAgentLogModal(false) - setShowMessageLogModal(false) - pendingConversationIdRef.current = null - pendingConversationCacheRef.current = undefined - closingConversationIdRef.current = conversationIdInUrl ?? null - - if (conversationIdInUrl) - router.replace(buildUrlWithConversation(), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) - - // Annotated data needs to be highlighted - const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { - return ( - - - {`${t('detail.annotationTip', { ns: 'appLog', user: annotation?.account?.name })} ${formatTime(annotation?.created_at || dayjs().unix(), 'MM-DD hh:mm A')}`} - - )} - popupClassName={(isHighlight && !isChatMode) ? '' : '!hidden'} - > -
- {value || '-'} -
-
- ) - } + const { + activeConversationId, + currentConversation, + handleRowClick, + isChatMode, + isChatflow, + isMobile, + onCloseDrawer, + showDrawer, + } = useConversationDrawer({ appDetail, logs, onRefresh }) if (!logs) return return (
- +
@@ -974,14 +168,21 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) - {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') + {logs.data.map((log: ConversationListItem) => { + const { endUser, leftValue, rightValue } = getConversationRowValues(log, isChatMode) + const completionAnnotation = 'annotation' in log ? log.annotation : undefined + const statusCount = 'status_count' in (log as Record) ? (log as { status_count?: StatusCount }).status_count : undefined + const annotationTooltip = !isChatMode && completionAnnotation?.logAnnotation?.content + ? `${t('detail.annotationTip', { ns: 'appLog', user: completionAnnotation.authorName })} ${formatTime(completionAnnotation.created_at || 0, 'MM-DD hh:mm A')}` + : undefined + return ( handleRowClick(log)} > + - {isChatflow && ( )} + + - - ) })} @@ -1037,15 +247,9 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) footer={null} panelClassName="mt-16 mx-2 sm:mr-2 mb-4 !p-0 !max-w-[640px] rounded-xl bg-components-panel-bg" > - - {isChatMode - ? - : } - + {isChatMode + ? + : } ) diff --git a/web/app/components/app/log/use-conversation-drawer.spec.tsx b/web/app/components/app/log/use-conversation-drawer.spec.tsx new file mode 100644 index 0000000000..5dcdafe37f --- /dev/null +++ b/web/app/components/app/log/use-conversation-drawer.spec.tsx @@ -0,0 +1,197 @@ +import type { ChatConversationGeneralDetail, ChatConversationsResponse } from '@/models/log' +import type { App, AppIconType, AppModeEnum } from '@/types/app' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useConversationDrawer } from './use-conversation-drawer' + +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockSetShowPromptLogModal = vi.fn() +const mockSetShowAgentLogModal = vi.fn() +const mockSetShowMessageLogModal = vi.fn() + +let mockSearchParams = new URLSearchParams() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + }), + usePathname: () => '/apps/test-app/logs', + useSearchParams: () => mockSearchParams, +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'pc', + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { + setShowPromptLogModal: typeof mockSetShowPromptLogModal + setShowAgentLogModal: typeof mockSetShowAgentLogModal + setShowMessageLogModal: typeof mockSetShowMessageLogModal + }) => unknown) => selector({ + setShowPromptLogModal: mockSetShowPromptLogModal, + setShowAgentLogModal: mockSetShowAgentLogModal, + setShowMessageLogModal: mockSetShowMessageLogModal, + }), +})) + +const createMockApp = (overrides: Partial = {}) => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: 'chat' as AppModeEnum, + runtime_type: 'classic' as const, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) satisfies App + +const createChatLog = (overrides: Partial = {}): ChatConversationGeneralDetail => ({ + id: 'chat-conversation-1', + status: 'normal', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_end_user_session_id: 'session-1', + from_account_id: 'account-1', + read_at: new Date(), + created_at: 100, + updated_at: 200, + user_feedback_stats: { like: 1, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + prompt_template: 'Prompt', + }, + }, + summary: 'Chat summary', + message_count: 2, + annotated: false, + ...overrides, +}) + +const createLogs = (log: ChatConversationGeneralDetail): ChatConversationsResponse => ({ + data: [log], + has_more: false, + limit: 20, + total: 1, + page: 1, +}) + +describe('useConversationDrawer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSearchParams = new URLSearchParams() + }) + + it('should reopen the active conversation without pushing a new url', async () => { + mockSearchParams = new URLSearchParams('conversation_id=chat-conversation-1') + const log = createChatLog() + + const { result } = renderHook(() => useConversationDrawer({ + appDetail: createMockApp(), + logs: createLogs(log), + onRefresh: vi.fn(), + })) + + await waitFor(() => { + expect(result.current.showDrawer).toBe(true) + expect(result.current.currentConversation?.id).toBe(log.id) + }) + + act(() => { + result.current.onCloseDrawer() + }) + + expect(result.current.showDrawer).toBe(false) + + act(() => { + result.current.handleRowClick(log) + }) + + expect(result.current.showDrawer).toBe(true) + expect(result.current.currentConversation?.id).toBe(log.id) + expect(mockPush).not.toHaveBeenCalled() + }) + + it('should clear drawer state when the conversation id disappears from the url', async () => { + mockSearchParams = new URLSearchParams('conversation_id=chat-conversation-1') + const log = createChatLog() + + const { result, rerender } = renderHook(() => useConversationDrawer({ + appDetail: createMockApp(), + logs: createLogs(log), + onRefresh: vi.fn(), + })) + + await waitFor(() => { + expect(result.current.showDrawer).toBe(true) + expect(result.current.currentConversation?.id).toBe(log.id) + }) + + mockSearchParams = new URLSearchParams() + rerender() + + await waitFor(() => { + expect(result.current.showDrawer).toBe(false) + expect(result.current.currentConversation).toBeUndefined() + }) + }) + + it('should keep a pending conversation active until the url catches up', async () => { + const onRefresh = vi.fn() + const log = createChatLog() + + const { result, rerender } = renderHook(() => useConversationDrawer({ + appDetail: createMockApp(), + logs: undefined, + onRefresh, + })) + + act(() => { + result.current.handleRowClick(log) + }) + + expect(result.current.showDrawer).toBe(true) + expect(result.current.activeConversationId).toBe(log.id) + expect(result.current.currentConversation?.id).toBe(log.id) + expect(mockPush).toHaveBeenCalledWith('/apps/test-app/logs?conversation_id=chat-conversation-1', { scroll: false }) + + mockSearchParams = new URLSearchParams('conversation_id=chat-conversation-1') + rerender() + + await waitFor(() => { + expect(result.current.currentConversation?.id).toBe(log.id) + }) + + expect(result.current.activeConversationId).toBe(log.id) + expect(onRefresh).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/log/use-conversation-drawer.ts b/web/app/components/app/log/use-conversation-drawer.ts new file mode 100644 index 0000000000..d87aeeca8b --- /dev/null +++ b/web/app/components/app/log/use-conversation-drawer.ts @@ -0,0 +1,116 @@ +import type { ConversationListItem, ConversationLogs, ConversationSelection } from './list-utils' +import type { App } from '@/types/app' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useStore as useAppStore } from '@/app/components/app/store' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' +import { AppModeEnum } from '@/types/app' +import { buildConversationUrl, resolveConversationSelection } from './list-utils' + +type AppStoreState = ReturnType + +type UseConversationDrawerParams = { + appDetail: App + logs?: ConversationLogs + onRefresh: () => void +} + +export const useConversationDrawer = ({ appDetail, logs, onRefresh }: UseConversationDrawerParams) => { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined + + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + + const [closingConversationId, setClosingConversationId] = useState(null) + const [pendingConversationId, setPendingConversationId] = useState() + const pendingConversationCacheRef = useRef(undefined) + + const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION + const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT + + const { + setShowAgentLogModal, + setShowMessageLogModal, + setShowPromptLogModal, + } = useAppStore(useShallow((state: AppStoreState) => ({ + setShowPromptLogModal: state.setShowPromptLogModal, + setShowAgentLogModal: state.setShowAgentLogModal, + setShowMessageLogModal: state.setShowMessageLogModal, + }))) + + const activeConversationId = conversationIdInUrl ?? pendingConversationId + const showDrawer = !!activeConversationId && closingConversationId !== activeConversationId + const currentConversation = useMemo(() => { + if (!showDrawer || !activeConversationId) + return undefined + + if (conversationIdInUrl) + return resolveConversationSelection(logs, conversationIdInUrl, pendingConversationCacheRef.current) + + return pendingConversationCacheRef.current + }, [activeConversationId, conversationIdInUrl, logs, showDrawer]) + + const handleRowClick = useCallback((log: ConversationListItem) => { + if (conversationIdInUrl === log.id) { + setClosingConversationId(null) + + return + } + + setPendingConversationId(log.id) + pendingConversationCacheRef.current = log + setClosingConversationId(null) + + router.push(buildConversationUrl(pathname, searchParams, log.id), { scroll: false }) + }, [conversationIdInUrl, pathname, router, searchParams]) + + useEffect(() => { + if (!conversationIdInUrl) { + if (!pendingConversationId) { + queueMicrotask(() => { + setClosingConversationId(null) + }) + pendingConversationCacheRef.current = undefined + } + return + } + + if (pendingConversationId === conversationIdInUrl) { + queueMicrotask(() => { + setPendingConversationId(undefined) + }) + } + + const nextConversation = resolveConversationSelection(logs, conversationIdInUrl, pendingConversationCacheRef.current) + if (pendingConversationCacheRef.current?.id === conversationIdInUrl || ('created_at' in nextConversation)) + pendingConversationCacheRef.current = undefined + }, [conversationIdInUrl, logs, pendingConversationId]) + + const onCloseDrawer = useCallback(() => { + onRefresh() + setClosingConversationId(activeConversationId ?? null) + setShowPromptLogModal(false) + setShowAgentLogModal(false) + setShowMessageLogModal(false) + setPendingConversationId(undefined) + pendingConversationCacheRef.current = undefined + + if (conversationIdInUrl) + router.replace(buildConversationUrl(pathname, searchParams), { scroll: false }) + }, [activeConversationId, conversationIdInUrl, onRefresh, pathname, router, searchParams, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) + + return { + activeConversationId, + currentConversation, + handleRowClick, + isChatMode, + isChatflow, + isMobile, + onCloseDrawer, + showDrawer, + } +} diff --git a/web/app/components/app/log/use-detail-panel-state.spec.tsx b/web/app/components/app/log/use-detail-panel-state.spec.tsx new file mode 100644 index 0000000000..aa8020f2e2 --- /dev/null +++ b/web/app/components/app/log/use-detail-panel-state.spec.tsx @@ -0,0 +1,512 @@ +import type { ChatConversationFullDetailResponse, ChatMessagesResponse, CompletionConversationFullDetailResponse, MessageContent } from '@/models/log' +import type { App, AppIconType, AppModeEnum } from '@/types/app' +import { act, renderHook, waitFor } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import { useDetailPanelState } from './use-detail-panel-state' + +const mockFetchChatMessages = vi.fn() +const mockDelAnnotation = vi.fn() +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +const mockSetCurrentLogItem = vi.fn() +const mockSetShowMessageLogModal = vi.fn() +const mockSetShowPromptLogModal = vi.fn() + +let mockStoreState: { + currentLogItem?: Record + currentLogModalActiveTab?: string + setCurrentLogItem: typeof mockSetCurrentLogItem + setShowMessageLogModal: typeof mockSetShowMessageLogModal + setShowPromptLogModal: typeof mockSetShowPromptLogModal + showMessageLogModal: boolean + showPromptLogModal: boolean +} + +vi.mock('@/service/log', () => ({ + fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args), +})) + +vi.mock('@/service/annotation', () => ({ + delAnnotation: (...args: unknown[]) => mockDelAnnotation(...args), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { + timezone: 'UTC', + }, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + __esModule: true, + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +const createMockApp = (overrides: Partial = {}) => ({ + id: 'test-app-id', + name: 'Test App', + description: 'Test app description', + author_name: 'Test Author', + icon_type: 'emoji' as AppIconType, + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: 'chat' as AppModeEnum, + runtime_type: 'classic' as const, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) satisfies App + +const createMessage = (overrides: Partial = {}): MessageContent => ({ + id: 'message-1', + conversation_id: 'conversation-1', + query: 'hello', + inputs: { customer: 'Alice' }, + message: [{ role: 'user', text: 'hello' }], + message_tokens: 10, + answer_tokens: 12, + answer: 'world', + provider_response_latency: 1.23, + created_at: 100, + annotation: { + id: 'annotation-1', + content: 'annotated answer', + account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 123, + }, + annotation_hit_history: { + annotation_id: 'annotation-hit-1', + annotation_create_account: { + id: 'account-1', + name: 'Admin', + email: 'admin@example.com', + }, + created_at: 123, + }, + feedbacks: [{ rating: 'like', content: null, from_source: 'admin' }], + message_files: [], + metadata: { + retriever_resources: [], + annotation_reply: { + id: 'annotation-reply-1', + account: { + id: 'account-1', + name: 'Admin', + }, + }, + }, + agent_thoughts: [], + workflow_run_id: 'workflow-1', + parent_message_id: null, + ...overrides, +}) + +const createChatDetail = (): ChatConversationFullDetailResponse => ({ + id: 'chat-conversation-1', + status: 'normal', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_end_user_session_id: 'session-1', + from_account_id: 'account-1', + read_at: new Date(), + created_at: 100, + updated_at: 200, + annotation: { + id: 'annotation-1', + authorName: 'Admin', + created_at: 123, + }, + user_feedback_stats: { like: 1, dislike: 0 }, + admin_feedback_stats: { like: 0, dislike: 1 }, + message_count: 2, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + introduction: 'hello', + prompt_template: 'Prompt', + prompt_variables: [], + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + model: { + name: 'gpt-4', + provider: 'openai', + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + }, +}) + +const createCompletionDetail = (): CompletionConversationFullDetailResponse => ({ + id: 'completion-conversation-1', + status: 'finished', + from_source: 'console', + from_end_user_id: 'end-user-1', + from_account_id: 'account-1', + created_at: 100, + model_config: { + provider: 'openai', + model_id: 'gpt-4', + configs: { + introduction: '', + prompt_template: 'Prompt', + prompt_variables: [], + completion_params: { + max_tokens: 10, + temperature: 0.1, + top_p: 0.9, + stop: [], + presence_penalty: 0, + frequency_penalty: 0, + }, + }, + }, + message: { + ...createMessage(), + message_files: [{ + id: 'file-1', + type: 'image', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/file.png', + upload_file_id: 'upload-1', + belongs_to: 'assistant', + }], + }, +}) + +describe('useDetailPanelState', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + + mockStoreState = { + currentLogItem: { id: 'log-item-1' }, + currentLogModalActiveTab: 'trace', + setCurrentLogItem: mockSetCurrentLogItem, + setShowMessageLogModal: mockSetShowMessageLogModal, + setShowPromptLogModal: mockSetShowPromptLogModal, + showMessageLogModal: false, + showPromptLogModal: false, + } + }) + + it('should fetch initial chat data and derive thread state', async () => { + mockFetchChatMessages.mockResolvedValue({ + data: [createMessage()], + has_more: false, + limit: 10, + } satisfies ChatMessagesResponse) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + await waitFor(() => { + expect(mockFetchChatMessages).toHaveBeenCalled() + expect(result.current.threadChatItems).toHaveLength(3) + }) + + expect(result.current.isChatMode).toBe(true) + expect(result.current.isAdvanced).toBe(false) + expect(result.current.messageFiles).toEqual([]) + }) + + it('should update annotations in memory and remove them successfully', async () => { + mockFetchChatMessages.mockResolvedValue({ + data: [createMessage()], + has_more: false, + limit: 10, + } satisfies ChatMessagesResponse) + mockDelAnnotation.mockResolvedValue(undefined) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + await waitFor(() => { + expect(result.current.threadChatItems).toHaveLength(3) + }) + + act(() => { + result.current.handleAnnotationAdded('annotation-2', 'Reviewer', 'updated question', 'updated answer', 1) + result.current.handleAnnotationEdited('edited question', 'edited answer', 1) + }) + + await act(async () => { + await result.current.handleAnnotationRemoved(1) + }) + + expect(mockDelAnnotation).toHaveBeenCalledWith('test-app-id', 'annotation-2') + expect(mockToastSuccess).toHaveBeenCalled() + }) + + it('should report annotation removal failures', async () => { + mockFetchChatMessages.mockResolvedValue({ + data: [createMessage()], + has_more: false, + limit: 10, + } satisfies ChatMessagesResponse) + mockDelAnnotation.mockRejectedValue(new Error('delete failed')) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + await waitFor(() => { + expect(result.current.threadChatItems).toHaveLength(3) + }) + + await act(async () => { + await result.current.handleAnnotationRemoved(1) + }) + + expect(mockToastError).toHaveBeenCalled() + }) + + it('should stop loading more when scroll container is missing', () => { + mockFetchChatMessages.mockResolvedValue({ + data: [createMessage()], + has_more: false, + limit: 10, + } satisfies ChatMessagesResponse) + const getElementByIdSpy = vi.spyOn(document, 'getElementById').mockReturnValue(null) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + return waitFor(() => { + expect(mockFetchChatMessages).toHaveBeenCalled() + }).then(() => { + act(() => { + result.current.handleScroll() + }) + + expect(getElementByIdSpy).toHaveBeenCalledWith('scrollableDiv') + }) + }) + + it('should load more messages on near-top scroll and stop when the next page is empty', async () => { + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValue(1000) + mockFetchChatMessages + .mockResolvedValueOnce({ + data: [createMessage()], + has_more: true, + limit: 10, + } satisfies ChatMessagesResponse) + .mockResolvedValueOnce({ + data: [], + has_more: false, + limit: 10, + } satisfies ChatMessagesResponse) + + const fakeScrollableDiv = { + scrollTop: -900, + scrollHeight: 1000, + clientHeight: 100, + } as HTMLElement + vi.spyOn(document, 'getElementById').mockReturnValue(fakeScrollableDiv) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + await waitFor(() => { + expect(result.current.threadChatItems).toHaveLength(2) + }) + + act(() => { + result.current.handleScroll() + }) + + await waitFor(() => { + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2) + }) + }) + + it('should keep width in sync and ignore duplicate load-more pages', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000) + const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame') + const rafCallbacks: FrameRequestCallback[] = [] + requestAnimationFrameSpy.mockImplementation((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return 1 + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}) + Object.defineProperty(document.body, 'clientWidth', { + configurable: true, + value: 1024, + }) + + mockFetchChatMessages + .mockResolvedValueOnce({ + data: [createMessage()], + has_more: true, + limit: 10, + } satisfies ChatMessagesResponse) + .mockResolvedValueOnce({ + data: [createMessage()], + has_more: true, + limit: 10, + } satisfies ChatMessagesResponse) + + const fakeScrollableDiv = { + scrollTop: -900, + scrollHeight: 1000, + clientHeight: 100, + } as HTMLElement + vi.spyOn(document, 'getElementById').mockReturnValue(fakeScrollableDiv) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + await waitFor(() => { + expect(result.current.threadChatItems).toHaveLength(2) + }) + + act(() => { + Object.defineProperty(result.current.containerRef, 'current', { + configurable: true, + value: { clientWidth: 200 }, + }) + rafCallbacks[0]?.(0) + }) + + expect(result.current.width).toBe(800) + + act(() => { + result.current.handleScroll() + }) + + await waitFor(() => { + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2) + }) + + expect(mockFetchChatMessages).toHaveBeenLastCalledWith({ + url: '/apps/test-app-id/chat-messages', + params: { + conversation_id: 'chat-conversation-1', + limit: 10, + first_id: 'message-1', + }, + }) + expect(result.current.threadChatItems).toHaveLength(2) + nowSpy.mockRestore() + }) + + it('should stop future loads after a load-more error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const nowSpy = vi.spyOn(Date, 'now') + nowSpy.mockReturnValue(1000) + + mockFetchChatMessages + .mockResolvedValueOnce({ + data: [createMessage()], + has_more: true, + limit: 10, + } satisfies ChatMessagesResponse) + .mockRejectedValueOnce(new Error('load-more failed')) + + const fakeScrollableDiv = { + scrollTop: -900, + scrollHeight: 1000, + clientHeight: 100, + } as HTMLElement + vi.spyOn(document, 'getElementById').mockReturnValue(fakeScrollableDiv) + + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'chat' as AppModeEnum }), + detail: createChatDetail(), + })) + + await waitFor(() => { + expect(result.current.threadChatItems).toHaveLength(2) + }) + + act(() => { + result.current.handleScroll() + }) + + await waitFor(() => { + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2) + }) + + nowSpy.mockReturnValue(1300) + act(() => { + result.current.handleScroll() + }) + + expect(mockFetchChatMessages).toHaveBeenCalledTimes(2) + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + it('should skip the initial chat fetch for completion mode', async () => { + const { result } = renderHook(() => useDetailPanelState({ + appDetail: createMockApp({ mode: 'completion' as AppModeEnum }), + detail: createCompletionDetail(), + })) + + await act(async () => { + await Promise.resolve() + }) + + expect(mockFetchChatMessages).not.toHaveBeenCalled() + expect(result.current.isChatMode).toBe(false) + expect(result.current.messageFiles).toEqual(['https://example.com/file.png']) + }) +}) diff --git a/web/app/components/app/log/use-detail-panel-state.ts b/web/app/components/app/log/use-detail-panel-state.ts new file mode 100644 index 0000000000..0459ff2ef2 --- /dev/null +++ b/web/app/components/app/log/use-detail-panel-state.ts @@ -0,0 +1,294 @@ +import type { ConversationDetail } from './list-utils' +import type { FeedbackFunc, IChatItem, SubmitAnnotationFunc } from '@/app/components/base/chat/chat/type' +import type { + App, +} from '@/types/app' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' +import { useStore as useAppStore } from '@/app/components/app/store' +import { getThreadMessages } from '@/app/components/base/chat/utils' +import { toast } from '@/app/components/base/ui/toast' +import { useAppContext } from '@/context/app-context' +import useTimestamp from '@/hooks/use-timestamp' +import { fetchChatMessages } from '@/service/log' +import { AppModeEnum } from '@/types/app' +import { + applyAddedAnnotation, + applyEditedAnnotation, + buildChatState, + buildDetailVarList, + getDetailMessageFiles, + getFormattedChatList, + getNextRetryCount, + isReverseScrollNearTop, + MAX_RETRY_COUNT, + mergeUniqueChatItems, + removeAnnotationFromChatItems, + shouldThrottleLoad, +} from './list-utils' + +type AppStoreState = ReturnType + +type UseDetailPanelStateParams = { + appDetail?: App + detail: ConversationDetail +} + +export const useDetailPanelState = ({ appDetail, detail }: UseDetailPanelStateParams) => { + const { userProfile: { timezone } } = useAppContext() + const { formatTime } = useTimestamp() + const { t } = useTranslation() + const { + currentLogItem, + currentLogModalActiveTab, + setCurrentLogItem, + setShowMessageLogModal, + setShowPromptLogModal, + showMessageLogModal, + showPromptLogModal, + } = useAppStore(useShallow((state: AppStoreState) => ({ + currentLogItem: state.currentLogItem, + setCurrentLogItem: state.setCurrentLogItem, + showMessageLogModal: state.showMessageLogModal, + setShowMessageLogModal: state.setShowMessageLogModal, + showPromptLogModal: state.showPromptLogModal, + setShowPromptLogModal: state.setShowPromptLogModal, + currentLogModalActiveTab: state.currentLogModalActiveTab, + }))) + + const [hasMore, setHasMore] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const [selectedSiblingMessageId, setSelectedSiblingMessageId] = useState() + const [varValues, setVarValues] = useState>({}) + const [width, setWidth] = useState(0) + const [allChatItems, setAllChatItems] = useState([]) + const containerRef = useRef(null) + const isLoadingRef = useRef(false) + const abortControllerRef = useRef(null) + const requestIdRef = useRef(0) + const lastLoadTimeRef = useRef(0) + const retryCountRef = useRef(0) + const oldestAnswerIdRef = useRef(undefined) + const fetchInitiatedRef = useRef(false) + + const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION + const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT + const messageDateTimeFormat = t('dateTimeFormat', { ns: 'appLog' }) as string + + const fetchData = useCallback(async () => { + if (isLoadingRef.current || !hasMore) + return + + if (abortControllerRef.current) + abortControllerRef.current.abort() + + const controller = new AbortController() + abortControllerRef.current = controller + const currentRequestId = ++requestIdRef.current + + try { + isLoadingRef.current = true + + const params: { conversation_id: string, limit: number, first_id?: string } = { + conversation_id: detail.id, + limit: 10, + } + + if (oldestAnswerIdRef.current) + params.first_id = oldestAnswerIdRef.current + + const messageRes = await fetchChatMessages({ + url: `/apps/${appDetail?.id}/chat-messages`, + params, + }) + + if (currentRequestId !== requestIdRef.current || controller.signal.aborted) + return + + if (messageRes.data.length > 0) + setVarValues(messageRes.data.at(-1)?.inputs ?? {}) + + setHasMore(messageRes.has_more) + + const newItems = getFormattedChatList(messageRes.data, detail.id, timezone || 'UTC', messageDateTimeFormat) + setAllChatItems(prevItems => mergeUniqueChatItems(prevItems, newItems).mergedItems) + } + catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') + return + + console.error('fetchData execution failed:', error) + } + finally { + isLoadingRef.current = false + if (abortControllerRef.current === controller) + abortControllerRef.current = null + } + }, [appDetail?.id, detail.id, hasMore, messageDateTimeFormat, timezone]) + + const { chatItemTree, oldestAnswerId, threadChatItems: defaultThreadChatItems } = useMemo(() => buildChatState( + allChatItems, + hasMore, + detail?.model_config?.configs?.introduction, + ), [allChatItems, detail?.model_config?.configs?.introduction, hasMore]) + + useEffect(() => { + if (oldestAnswerId) + oldestAnswerIdRef.current = oldestAnswerId + }, [oldestAnswerId]) + + const threadChatItems = useMemo(() => { + if (!selectedSiblingMessageId) + return defaultThreadChatItems + + return getThreadMessages(chatItemTree, selectedSiblingMessageId) + }, [chatItemTree, defaultThreadChatItems, selectedSiblingMessageId]) + + const switchSibling = useCallback((siblingMessageId: string) => { + setSelectedSiblingMessageId(siblingMessageId) + }, []) + + const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { + setAllChatItems(prevItems => applyEditedAnnotation(prevItems, query, answer, index)) + }, []) + + const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => { + setAllChatItems(prevItems => applyAddedAnnotation(prevItems, annotationId, authorName, query, answer, index)) + }, []) + + 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(prevItems => removeAnnotationFromChatItems(prevItems, index)) + toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) + return true + } + catch { + toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) + return false + } + }, [allChatItems, appDetail?.id, t]) + + useEffect(() => { + if (!appDetail?.id || !detail.id || appDetail.mode === AppModeEnum.COMPLETION || fetchInitiatedRef.current) + return + + fetchInitiatedRef.current = true + fetchData() + }, [appDetail?.id, appDetail?.mode, detail.id, fetchData]) + + const loadMoreMessages = useCallback(async () => { + if (isLoading || !hasMore || !appDetail?.id || !detail.id) + return + + const now = Date.now() + if (shouldThrottleLoad(now, lastLoadTimeRef.current)) + return + + lastLoadTimeRef.current = now + setIsLoading(true) + + try { + const params: { conversation_id: string, limit: number, first_id?: string } = { + conversation_id: detail.id, + limit: 10, + } + + if (oldestAnswerIdRef.current) + params.first_id = oldestAnswerIdRef.current + + const messageRes = await fetchChatMessages({ + url: `/apps/${appDetail.id}/chat-messages`, + params, + }) + + if (!messageRes.data?.length) { + setHasMore(false) + retryCountRef.current = 0 + return + } + + setVarValues(messageRes.data.at(-1)?.inputs ?? {}) + setHasMore(messageRes.has_more) + + const newItems = getFormattedChatList(messageRes.data, detail.id, timezone || 'UTC', messageDateTimeFormat) + setAllChatItems((prevItems) => { + const { mergedItems, uniqueNewItems } = mergeUniqueChatItems(prevItems, newItems) + retryCountRef.current = getNextRetryCount(uniqueNewItems.length, prevItems.length, retryCountRef.current, MAX_RETRY_COUNT) + return uniqueNewItems.length === 0 ? prevItems : mergedItems + }) + } + catch (error) { + console.error(error) + setHasMore(false) + retryCountRef.current = 0 + } + finally { + setIsLoading(false) + } + }, [appDetail?.id, detail.id, hasMore, isLoading, messageDateTimeFormat, timezone]) + + const handleScroll = useCallback(() => { + const scrollableDiv = document.getElementById('scrollableDiv') + if (!scrollableDiv) + return + + if (isReverseScrollNearTop(scrollableDiv.scrollTop, scrollableDiv.scrollHeight, scrollableDiv.clientHeight) && hasMore && !isLoading) + loadMoreMessages() + }, [hasMore, isLoading, loadMoreMessages]) + + const varList = useMemo(() => buildDetailVarList(detail, varValues), [detail, varValues]) + const messageFiles = useMemo(() => getDetailMessageFiles(appDetail?.mode ?? AppModeEnum.CHAT, detail), [appDetail?.mode, detail]) + + useEffect(() => { + const adjustModalWidth = () => { + if (!containerRef.current) + return + + setWidth(document.body.clientWidth - (containerRef.current.clientWidth + 16) - 8) + } + + const raf = requestAnimationFrame(adjustModalWidth) + return () => cancelAnimationFrame(raf) + }, []) + + return { + containerRef, + currentLogItem, + currentLogModalActiveTab, + formatTime, + handleAnnotationAdded, + handleAnnotationEdited, + handleAnnotationRemoved, + handleScroll, + hasMore, + isAdvanced, + isChatMode, + messageDateTimeFormat, + messageFiles, + setCurrentLogItem, + setShowMessageLogModal, + setShowPromptLogModal, + showMessageLogModal, + showPromptLogModal, + switchSibling, + threadChatItems, + varList, + width, + } +} + +export type DetailPanelProps = { + detail: ConversationDetail + appDetail?: App + onClose: () => void + onFeedback: FeedbackFunc + onSubmitAnnotation?: SubmitAnnotationFunc +}
@@ -992,39 +193,48 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) )} - {renderTdValue(leftValue || t('table.empty.noChat', { ns: 'appLog' }), !leftValue, isChatMode && log.annotated)} + + + {renderTdValue(endUser || defaultValue, !endUser)} - {statusTdRender(log.status_count)} + {statusCount && } - {renderTdValue(rightValue === 0 ? 0 : (rightValue || t('table.empty.noOutput', { ns: 'appLog' })), !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('dateTimeFormat', { ns: 'appLog' }) as string)} + + {formatTime(log.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)} {formatTime(log.updated_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}{formatTime(log.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}