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.
This commit is contained in:
CodingOnStar
2026-03-31 10:22:06 +08:00
parent 11895d07c1
commit d99ca80f48
9 changed files with 2879 additions and 1132 deletions

View File

@ -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<string, unknown>
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: () => <div data-testid="model-info">model-info</div>,
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/base/copy-icon', () => ({
default: () => <div data-testid="copy-icon">copy-icon</div>,
}))
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 }) => (
<div data-testid="message-log-modal">
<button type="button" onClick={onCancel}>close-message-log</button>
</div>
),
}))
vi.mock('../../base/prompt-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="prompt-log-modal">
<button type="button" onClick={onCancel}>close-prompt-log</button>
</div>
),
}))
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 }> }) => (
<div data-testid="var-panel">{`${varList.length}-${message_files.length}`}</div>
),
}))
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<boolean>
onFeedback: (messageId: string, payload: { rating: 'like' | 'dislike', content?: string }) => Promise<boolean>
switchSibling: (messageId: string) => void
}) => (
<div data-testid="chat-component">
<span data-testid="chat-count">{chatList.length}</span>
<button type="button" onClick={() => onFeedback('message-1', { rating: 'like', content: 'great' })}>chat-feedback</button>
<button type="button" onClick={() => onAnnotationAdded('annotation-2', 'Reviewer', 'updated question', 'updated answer', 1)}>add-annotation</button>
<button type="button" onClick={() => onAnnotationEdited('edited question', 'edited answer', 1)}>edit-annotation</button>
<button type="button" onClick={() => void onAnnotationRemoved(1)}>remove-annotation</button>
<button type="button" onClick={() => switchSibling('message-1')}>switch-sibling</button>
</div>
),
}))
vi.mock('@/app/components/app/text-generate/item', () => ({
default: ({ content, onFeedback }: { content: string, onFeedback: (payload: { rating: 'like' | 'dislike', content?: string }) => Promise<boolean> }) => (
<div data-testid="text-generation">
<span>{content}</span>
<button type="button" onClick={() => onFeedback({ rating: 'like', content: 'great' })}>completion-feedback</button>
</div>
),
}))
const createMockApp = (overrides: Partial<App> = {}) => ({
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(
<ChatConversationDetailComp
appDetail={createMockApp({ mode: 'chat' as AppModeEnum })}
conversationId="chat-conversation-1"
onClose={vi.fn()}
/>,
)
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(
<CompletionConversationDetailComp
appDetail={createMockApp({ mode: 'completion' as AppModeEnum })}
conversationId="completion-conversation-1"
onClose={vi.fn()}
/>,
)
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(
<CompletionConversationDetailComp
appDetail={createMockApp({ mode: 'completion' as AppModeEnum })}
conversationId="completion-conversation-1"
onClose={vi.fn()}
/>,
)
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(
<ChatConversationDetailComp
appDetail={createMockApp({ mode: 'chat' as AppModeEnum })}
conversationId="chat-conversation-1"
onClose={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
rerender(
<CompletionConversationDetailComp
appDetail={createMockApp({ mode: 'completion' as AppModeEnum })}
conversationId="completion-conversation-1"
onClose={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -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 (
<div ref={containerRef} className="flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border">
<div className="flex shrink-0 items-center gap-2 rounded-t-xl bg-components-panel-bg pb-2 pl-4 pr-3 pt-3">
<div className="shrink-0">
<div className="mb-0.5 text-text-primary system-xs-semibold-uppercase">
{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}
</div>
{isChatMode && (
<div className="flex items-center text-text-secondary system-2xs-regular-uppercase">
<Tooltip>
<TooltipTrigger render={<div className="truncate">{detail.id}</div>} />
<TooltipContent>{detail.id}</TooltipContent>
</Tooltip>
<CopyIcon content={detail.id} />
</div>
)}
{!isChatMode && (
<div className="text-text-secondary system-2xs-regular-uppercase">
{formatTime(detail.created_at, messageDateTimeFormat)}
</div>
)}
</div>
<div className="flex grow flex-wrap items-center justify-end gap-y-1">
{!isAdvanced && 'model' in detail.model_config && <ModelInfo model={detail.model_config.model} />}
</div>
<ActionButton size="l" onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</ActionButton>
</div>
<div className="shrink-0 px-1 pt-1">
<div className="rounded-t-xl bg-background-section-burn p-3 pb-2">
{(varList.length > 0 || (!isChatMode && messageFiles.length > 0)) && (
<VarPanel varList={varList} message_files={messageFiles} />
)}
</div>
</div>
<div className="mx-1 mb-1 grow overflow-auto rounded-b-xl bg-background-section-burn">
{!isChatMode
? (
<div className="px-6 py-4">
<div className="flex h-[18px] items-center space-x-3">
<div className="text-text-tertiary system-xs-semibold-uppercase">{t('table.header.output', { ns: 'appLog' })}</div>
<div
className="h-px grow"
style={{ background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)' }}
/>
</div>
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionDetail?.message.answer ?? ''}
messageId={completionDetail?.message.id ?? ''}
isError={false}
onRetry={noop}
supportFeedback
feedback={completionDetail?.message.feedbacks.find(item => item.from_source === 'admin')}
onFeedback={feedback => onFeedback(completionDetail?.message.id ?? '', feedback)}
isShowTextToSpeech
siteInfo={null}
/>
</div>
)
: threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING
? (
<div className="mb-4 pt-4">
<Chat
config={{
appId: appDetail?.id,
text_to_speech: { enabled: true },
questionEditEnable: false,
supportAnnotation: true,
annotation_reply: { enabled: true },
supportFeedback: true,
} as never}
chatList={threadChatItems}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationEdited={handleAnnotationEdited}
onAnnotationRemoved={handleAnnotationRemoved}
onFeedback={onFeedback}
noChatInput
showPromptLog
hideProcessDetail
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
</div>
)
: (
<div
id="scrollableDiv"
className="py-4"
style={{
display: 'flex',
flexDirection: 'column-reverse',
height: '100%',
overflow: 'auto',
}}
onScroll={handleScroll}
>
<div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}>
<Chat
config={{
appId: appDetail?.id,
text_to_speech: { enabled: true },
questionEditEnable: false,
supportAnnotation: true,
annotation_reply: { enabled: true },
supportFeedback: true,
} as never}
chatList={threadChatItems}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationEdited={handleAnnotationEdited}
onAnnotationRemoved={handleAnnotationRemoved}
onFeedback={onFeedback}
noChatInput
showPromptLog
hideProcessDetail
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
</div>
{hasMore && (
<div className="py-3 text-center">
<div className="text-text-tertiary system-xs-regular">
{t('detail.loading', { ns: 'appLog' })}
...
</div>
</div>
)}
</div>
)}
</div>
{showMessageLogModal && (
<WorkflowContextProvider>
<MessageLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
defaultTab={currentLogModalActiveTab}
/>
</WorkflowContextProvider>
)}
{!isChatMode && showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
</div>
)
}
function useConversationMutationHandlers(appId: string | undefined, onAfterSuccess?: () => void) {
const { t } = useTranslation()
const handleFeedback = useCallback(async (messageId: string, { rating, content }: FeedbackType): Promise<boolean> => {
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 (
<ListDetailPanel
detail={conversationDetail}
appDetail={appDetail}
onClose={onClose}
onFeedback={handleFeedback}
/>
)
}
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 (
<ListDetailPanel
detail={conversationDetail}
appDetail={appDetail}
onClose={onClose}
onFeedback={handleFeedback}
/>
)
}

View File

@ -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<string, {
variable: string
}>
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<string, string>) => {
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}`
}

View File

@ -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 ? <div data-testid="drawer">{children}</div> : null
),
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
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 }) => (
<div data-testid="chat-detail">
<span>{conversationId}</span>
<button type="button" onClick={onClose}>close-drawer</button>
</div>
),
CompletionConversationDetailComp: ({ conversationId, onClose }: { conversationId?: string, onClose: () => void }) => (
<div data-testid="completion-detail">
<span>{conversationId}</span>
<button type="button" onClick={onClose}>close-drawer</button>
</div>
),
}))
const createMockApp = (overrides: Partial<App> = {}) => ({
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> = {}): 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> = {}): 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> = {}): 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(<List logs={undefined} appDetail={createMockApp()} onRefresh={vi.fn()} />)
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(<List logs={logs} appDetail={createMockApp()} onRefresh={vi.fn()} />)
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(<List logs={logs} appDetail={createMockApp()} onRefresh={onRefresh} />)
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(
<List
logs={advancedLogs}
appDetail={createMockApp({ mode: 'advanced-chat' as AppModeEnum })}
onRefresh={vi.fn()}
/>,
)
expect(screen.getByText('Success')).toBeInTheDocument()
rerender(
<List
logs={completionLogs}
appDetail={createMockApp({ mode: 'completion' as AppModeEnum })}
onRefresh={vi.fn()}
/>,
)
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(
<List
logs={advancedLogs}
appDetail={createMockApp({ mode: 'advanced-chat' as AppModeEnum })}
onRefresh={vi.fn()}
/>,
)
expect(screen.getByText('Pending')).toBeInTheDocument()
expect(screen.getByText('Partial Success')).toBeInTheDocument()
expect(screen.getByText('2 Failures')).toBeInTheDocument()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -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<App> = {}) => ({
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> = {}): 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()
})
})

View File

@ -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<typeof useAppStore.getState>
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<string | null>(null)
const [pendingConversationId, setPendingConversationId] = useState<string>()
const pendingConversationCacheRef = useRef<ConversationSelection | undefined>(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,
}
}

View File

@ -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<string, unknown>
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<App> = {}) => ({
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> = {}): 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'])
})
})

View File

@ -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<typeof useAppStore.getState>
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<string>()
const [varValues, setVarValues] = useState<Record<string, string>>({})
const [width, setWidth] = useState(0)
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const isLoadingRef = useRef(false)
const abortControllerRef = useRef<AbortController | null>(null)
const requestIdRef = useRef(0)
const lastLoadTimeRef = useRef(0)
const retryCountRef = useRef(0)
const oldestAnswerIdRef = useRef<string | undefined>(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<boolean> => {
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
}