mirror of
https://github.com/langgenius/dify.git
synced 2026-03-31 10:58:48 +08:00
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:
468
web/app/components/app/log/list-detail-panel.spec.tsx
Normal file
468
web/app/components/app/log/list-detail-panel.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
272
web/app/components/app/log/list-detail-panel.tsx
Normal file
272
web/app/components/app/log/list-detail-panel.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
293
web/app/components/app/log/list-utils.ts
Normal file
293
web/app/components/app/log/list-utils.ts
Normal 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}`
|
||||
}
|
||||
@ -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
197
web/app/components/app/log/use-conversation-drawer.spec.tsx
Normal file
197
web/app/components/app/log/use-conversation-drawer.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
116
web/app/components/app/log/use-conversation-drawer.ts
Normal file
116
web/app/components/app/log/use-conversation-drawer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
512
web/app/components/app/log/use-detail-panel-state.spec.tsx
Normal file
512
web/app/components/app/log/use-detail-panel-state.spec.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
294
web/app/components/app/log/use-detail-panel-state.ts
Normal file
294
web/app/components/app/log/use-detail-panel-state.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user