refactor(web): migrate log queries to the generated console contract

Replace the forwarding hooks in service/use-log.ts with direct
consoleQuery calls at each call site and type the log pages with the
generated contract instead of handwritten DTOs.

- Conversation list/detail, workflow logs, pause details and annotation
  count now use consoleQuery queryOptions; use-log.ts is removed.
- Untyped JSON fields (model_config.model, user_input_form, details) are
  narrowed at the boundary in list-utils instead of being read as any.
- Read the conversation introduction from the top-level field the API
  actually returns, and skip null backstage input URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stephen Zhou
2026-10-15 22:19:27 +08:00
co-authored by Claude Opus 5.5
parent c4a650480f
commit 8f6d92949a
20 changed files with 434 additions and 578 deletions
+1 -9
View File
@@ -474,7 +474,7 @@
"count": 2
},
"typescript/no-explicit-any": {
"count": 5
"count": 3
}
},
"web/app/components/app/log/var-panel.tsx": {
@@ -4255,14 +4255,6 @@
"count": 1
}
},
"web/service/use-log.ts": {
"@tanstack/query/prefer-query-options": {
"count": 7
},
"no-restricted-imports": {
"count": 1
}
},
"web/service/use-models.ts": {
"@tanstack/query/prefer-query-options": {
"count": 3
@@ -1,16 +1,33 @@
import type { AnnotationCountResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { UseQueryResult } from '@tanstack/react-query'
import type { Mock } from 'vite-plus/test'
import type { QueryParam } from '../filter'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import * as useLogModule from '@/service/use-log'
import Filter from '../filter'
vi.mock('@/service/use-log')
const mockUseQuery = vi.fn()
const mockUseAnnotationsCount = useLogModule.useAnnotationsCount as Mock
vi.mock('@tanstack/react-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/react-query')>()),
useQuery: (options: unknown) => mockUseQuery(options),
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
apps: {
byAppId: {
annotations: {
count: {
get: {
queryOptions: (options: unknown) => options,
},
},
},
},
},
},
}))
// ============================================================================
// Test Utilities
@@ -65,7 +82,7 @@ describe('Filter', () => {
describe('Rendering', () => {
it('should render nothing when data is loading', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({ isLoading: true }),
)
@@ -82,7 +99,7 @@ describe('Filter', () => {
it('should render nothing when data is undefined', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({ data: undefined, isLoading: false }),
)
@@ -99,7 +116,7 @@ describe('Filter', () => {
it('should render filter and children when data is available', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 20 },
isLoading: false,
@@ -123,9 +140,9 @@ describe('Filter', () => {
// Props Tests (REQUIRED)
// --------------------------------------------------------------------------
describe('Props', () => {
it('should call useAnnotationsCount with appId', () => {
it('should query the annotations count for appId', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 10 },
isLoading: false,
@@ -140,12 +157,12 @@ describe('Filter', () => {
)
// Assert
expect(mockUseAnnotationsCount).toHaveBeenCalledWith(appId)
expect(mockUseQuery).toHaveBeenCalledWith({ input: { params: { app_id: appId } } })
})
it('should display keyword value in input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 10 },
isLoading: false,
@@ -173,7 +190,7 @@ describe('Filter', () => {
describe('User Interactions', () => {
it('should call setQueryParams when typing in search input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 20 },
isLoading: false,
@@ -198,7 +215,7 @@ describe('Filter', () => {
it('should call setQueryParams with empty keyword when clearing input', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 20 },
isLoading: false,
@@ -227,7 +244,7 @@ describe('Filter', () => {
describe('Edge Cases', () => {
it('should handle empty keyword in queryParams', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 5 },
isLoading: false,
@@ -247,7 +264,7 @@ describe('Filter', () => {
it('should handle undefined keyword in queryParams', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 5 },
isLoading: false,
@@ -267,7 +284,7 @@ describe('Filter', () => {
it('should handle zero count', () => {
// Arrange
mockUseAnnotationsCount.mockReturnValue(
mockUseQuery.mockReturnValue(
createMockQueryResult<AnnotationCountResponse>({
data: { count: 0 },
isLoading: false,
+7 -2
View File
@@ -1,9 +1,10 @@
'use client'
import type { FC } from 'react'
import { skipToken, useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { useAnnotationsCount } from '@/service/use-log'
import { consoleQuery } from '@/service/console'
export type QueryParam = {
keyword?: string
@@ -17,7 +18,11 @@ type IFilterProps = {
}
const Filter: FC<IFilterProps> = ({ appId, queryParams, setQueryParams, children }) => {
const { data, isLoading } = useAnnotationsCount(appId)
const { data, isLoading } = useQuery(
consoleQuery.apps.byAppId.annotations.count.get.queryOptions({
input: appId ? { params: { app_id: appId } } : skipToken,
}),
)
const { t } = useTranslation(['common'])
if (isLoading || !data) return null
return (
@@ -27,19 +27,30 @@ const render = (ui: ReactElement) => {
queryFn: () => new Promise(() => {}),
})
} else seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } })
const annotationsCountQueryKey = consoleQuery.apps.byAppId.annotations.count.get.queryKey({
input: { params: { app_id: 'test-app-id' } },
})
if (mockAnnotationsCountLoading) {
void queryClient.query({
queryKey: annotationsCountQueryKey,
queryFn: () => new Promise(() => {}),
})
} else if (mockAnnotationsCountData) {
queryClient.setQueryData(annotationsCountQueryKey, mockAnnotationsCountData)
} else {
void queryClient
.query({
queryKey: annotationsCountQueryKey,
queryFn: () => Promise.reject(new Error('annotations count unavailable')),
})
.catch(() => {})
}
return renderWithConsoleQuery(ui, {
queryClient,
systemFeatures: { deployment_edition: scenario.deploymentEdition },
})
}
vi.mock('@/service/use-log', () => ({
useAnnotationsCount: () => ({
data: mockAnnotationsCountData,
isLoading: mockAnnotationsCountLoading,
}),
}))
vi.mock('@/app/components/base/chip', async () => {
const { useState } = await import('react')
@@ -350,7 +361,7 @@ describe('Filter', () => {
it('should display annotated status with count when annotation_status is annotated', () => {
const propsWithAnnotation = {
...defaultProps,
queryParams: { ...defaultQueryParams, annotation_status: 'annotated' },
queryParams: { ...defaultQueryParams, annotation_status: 'annotated' as const },
}
render(<Filter {...propsWithAnnotation} />)
@@ -363,7 +374,7 @@ describe('Filter', () => {
it('should display not_annotated status when annotation_status is not_annotated', () => {
const propsWithNotAnnotated = {
...defaultProps,
queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' },
queryParams: { ...defaultQueryParams, annotation_status: 'not_annotated' as const },
}
render(<Filter {...propsWithNotAnnotated} />)
@@ -394,7 +405,7 @@ describe('Filter', () => {
const propsWithSort = {
...defaultProps,
isChatMode: true,
queryParams: { ...defaultQueryParams, sort_by: 'created_at' },
queryParams: { ...defaultQueryParams, sort_by: 'created_at' as const },
}
render(<Filter {...propsWithSort} />)
@@ -408,7 +419,7 @@ describe('Filter', () => {
const propsWithDescSort = {
...defaultProps,
isChatMode: true,
queryParams: { ...defaultQueryParams, sort_by: '-created_at' },
queryParams: { ...defaultQueryParams, sort_by: '-created_at' as const },
}
render(<Filter {...propsWithDescSort} />)
@@ -35,13 +35,31 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/service/use-log', () => ({
useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args),
useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args),
useAnnotationsCount: () => ({
data: { count: 0 },
isLoading: false,
}),
vi.mock('@/service/console', () => {
const tagged = (operation: string) => ({
queryOptions: (options: Record<string, unknown>) => ({ ...options, operation }),
})
return {
consoleQuery: {
apps: {
byAppId: {
chatConversations: { get: tagged('chatConversations') },
completionConversations: { get: tagged('completionConversations') },
annotations: { count: { get: tagged('annotationsCount') } },
},
},
},
}
})
vi.mock('@tanstack/react-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/react-query')>()),
useQuery: ({ operation, ...options }: { operation?: string }) => {
if (operation === 'chatConversations') return mockUseChatConversations(options)
if (operation === 'completionConversations') return mockUseCompletionConversations(options)
if (operation === 'annotationsCount') return { data: { count: 0 }, isLoading: false }
return { data: undefined }
},
}))
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
@@ -102,7 +120,7 @@ describe('Logs', () => {
expect(mockUseChatConversations).toHaveBeenCalledWith(
expect.objectContaining({
appId: 'app-1',
input: expect.objectContaining({ params: { app_id: 'app-1' } }),
}),
)
expect(screen.getByRole('heading', { name: /(?:^|\.)title(?=$|:)/ })).toBeInTheDocument()
@@ -133,7 +151,7 @@ describe('Logs', () => {
expect(mockUseCompletionConversations).toHaveBeenCalledWith(
expect.objectContaining({
appId: 'app-2',
input: expect.objectContaining({ params: { app_id: 'app-2' } }),
}),
)
expect(screen.getByText('empty-logs')).toBeInTheDocument()
@@ -188,9 +206,11 @@ describe('Logs', () => {
).toBeInTheDocument()
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.objectContaining({
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
input: expect.objectContaining({
query: expect.objectContaining({
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
}),
}),
}),
)
@@ -213,9 +233,11 @@ describe('Logs', () => {
await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/))
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.not.objectContaining({
start: expect.anything(),
end: expect.anything(),
input: expect.objectContaining({
query: expect.not.objectContaining({
start: expect.anything(),
end: expect.anything(),
}),
}),
}),
)
@@ -234,9 +256,11 @@ describe('Logs', () => {
).toBeInTheDocument()
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.objectContaining({
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
end: expect.any(String),
input: expect.objectContaining({
query: expect.objectContaining({
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
end: expect.any(String),
}),
}),
}),
)
@@ -35,15 +35,20 @@ vi.mock('@/hooks/use-breakpoints', () => ({
},
}))
vi.mock('@/service/use-log', () => ({
useChatConversationDetail: () => ({
data: mockChatConversationDetail,
}),
useCompletionConversationDetail: () => ({
data: mockCompletionConversationDetail,
refetch: mockCompletionRefetch,
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: (options: Parameters<typeof actual.useQuery>[0]) => {
const [path] = options.queryKey as [string[]]
if (Array.isArray(path) && path.includes('byConversationId')) {
if (path.includes('chatConversations')) return { data: mockChatConversationDetail }
return { data: mockCompletionConversationDetail, refetch: mockCompletionRefetch }
}
return actual.useQuery(options)
},
}
})
vi.mock('@/service/log', () => ({
fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args),
@@ -60,7 +65,9 @@ vi.mock('@/app/components/base/loading-placeholder', () => ({
}))
vi.mock('@/app/components/app/log/model-info', () => ({
default: ({ model }: { model: string }) => <div data-testid="model-info">{model}</div>,
default: ({ model }: { model: { name: string } }) => (
<div data-testid="model-info">{model.name}</div>
),
}))
vi.mock('@/app/components/app/log/var-panel', () => ({
@@ -401,11 +408,9 @@ describe('ConversationList', () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
introduction: 'Hello there',
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
user_input_form: [],
},
message: {
@@ -443,11 +448,9 @@ describe('ConversationList', () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
introduction: 'Hello there',
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
user_input_form: [
{
query: {
@@ -525,11 +528,9 @@ describe('ConversationList', () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
introduction: 'Hello there',
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
user_input_form: [],
},
message: {
@@ -554,7 +555,7 @@ describe('ConversationList', () => {
id: 'conversation-1',
created_at: 1710000000,
model_config: {
model: 'gpt-4o-mini',
model: { provider: 'openai', name: 'gpt-4o-mini', mode: 'chat', completion_params: {} },
user_input_form: [
{
query: {
@@ -672,11 +673,9 @@ describe('ConversationList', () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
introduction: 'Hello there',
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
model: { provider: 'openai', name: 'gpt-4o', mode: 'chat', completion_params: {} },
user_input_form: [
{
query: {
+12 -4
View File
@@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { QueryParam } from './index'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { RiCalendarLine } from '@remixicon/react'
import { skipToken, useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import * as React from 'react'
@@ -10,7 +11,7 @@ import { useTranslation } from 'react-i18next'
import Chip from '@/app/components/base/chip'
import { SearchInput } from '@/app/components/base/search-input'
import Sort from '@/app/components/base/sort'
import { useAnnotationsCount } from '@/service/use-log'
import { consoleQuery } from '@/service/console'
import {
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
CLOUD_SANDBOX_TIME_PERIOD_KEYS,
@@ -50,7 +51,11 @@ const Filter: FC<IFilterProps> = ({
queryParams,
setQueryParams,
}: IFilterProps) => {
const { data, isLoading } = useAnnotationsCount(appId)
const { data, isLoading } = useQuery(
consoleQuery.apps.byAppId.annotations.count.get.queryOptions({
input: appId ? { params: { app_id: appId } } : skipToken,
}),
)
const { t } = useTranslation(['appLog', 'common'])
const planState = useCloudSandboxPlanStatus()
const isTimePeriodRestricted = isLogTimePeriodRestricted(planState)
@@ -86,7 +91,10 @@ const Filter: FC<IFilterProps> = ({
showLeftIcon={false}
value={queryParams.annotation_status || 'all'}
onSelect={(item) => {
setQueryParams({ ...queryParams, annotation_status: item.value as string })
setQueryParams({
...queryParams,
annotation_status: item.value as QueryParam['annotation_status'],
})
}}
onClear={() => setQueryParams({ ...queryParams, annotation_status: 'all' })}
items={[
@@ -123,7 +131,7 @@ const Filter: FC<IFilterProps> = ({
},
]}
onSelect={(value) => {
setQueryParams({ ...queryParams, sort_by: value as string })
setQueryParams({ ...queryParams, sort_by: value as QueryParam['sort_by'] })
}}
/>
</>
+20 -13
View File
@@ -1,8 +1,12 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type {
AppDetailWithSite,
GetAppsByAppIdChatConversationsData,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'es-toolkit/object'
@@ -13,7 +17,7 @@ import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { APP_PAGE_LIMIT } from '@/config'
import { useDocLink } from '@/context/i18n'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
import { consoleQuery } from '@/service/console'
import { AppModeEnum } from '@/types/app'
import PageTitle from '../log-annotation/page-title'
import {
@@ -30,11 +34,13 @@ type ILogsProps = {
appDetail: AppDetailWithSite
}
type ConversationsQuery = NonNullable<GetAppsByAppIdChatConversationsData['query']>
export type QueryParam = {
period: string
annotation_status?: string
annotation_status?: ConversationsQuery['annotation_status']
keyword?: string
sort_by?: string
sort_by?: ConversationsQuery['sort_by']
}
const defaultQueryParams: QueryParam = {
@@ -115,16 +121,17 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
}
// When the details are obtained, proceed to the next request
const { data: chatConversations, refetch: mutateChatList } = useChatConversations({
appId: isChatMode ? appDetail.id : '',
params: query,
})
const { data: chatConversations, refetch: mutateChatList } = useQuery(
consoleQuery.apps.byAppId.chatConversations.get.queryOptions({
input: isChatMode ? { params: { app_id: appDetail.id }, query } : skipToken,
}),
)
const { data: completionConversations, refetch: mutateCompletionList } =
useCompletionConversations({
appId: !isChatMode ? appDetail.id : '',
params: query,
})
const { data: completionConversations, refetch: mutateCompletionList } = useQuery(
consoleQuery.apps.byAppId.completionConversations.get.queryOptions({
input: !isChatMode ? { params: { app_id: appDetail.id }, query } : skipToken,
}),
)
const total = isChatMode ? chatConversations?.total : completionConversations?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
+46 -16
View File
@@ -1,11 +1,12 @@
import type { ChatItemInTree } from '../../base/chat/types'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type'
import type { Annotation, ChatMessage } 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 { isPlainObject } from 'es-toolkit/predicate'
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'
@@ -15,26 +16,27 @@ dayjs.extend(timezone)
type LogDetailMessage = {
answer?: string | number | null
inputs?: Record<string, string>
message_files?: Array<{ url?: string }>
inputs?: Record<string, unknown>
message_files?: Array<{ url?: string | null }>
query?: string
}
type UserInputFormItem = Record<string, { variable?: string }>
type ConversationLogDetail = {
from_account_name?: string
from_end_user_session_id?: string
message?: LogDetailMessage
from_account_name?: string | null
from_end_user_session_id?: string | null
message?: LogDetailMessage | null
message_count?: number
model_config?: {
user_input_form?: UserInputFormItem[]
}
// Untyped JSON in the console API response.
user_input_form?: unknown
} | null
name?: string
}
const getUserInputVariable = (item: UserInputFormItem) => {
const variable = Object.values(item)[0]?.variable
const getUserInputVariable = (item: unknown) => {
if (!isPlainObject(item)) return undefined
const field = Object.values(item)[0]
const variable = isPlainObject(field) ? field.variable : undefined
return typeof variable === 'string' ? variable : undefined
}
@@ -305,10 +307,12 @@ export const getConversationRowValues = ({
noOutputLabel: string
}) => {
const endUser = log.from_end_user_session_id || log.from_account_name
const leftValue =
const rawLeftValue: unknown =
get(log, isChatMode ? 'name' : 'message.inputs.query') ||
(!isChatMode ? get(log, 'message.query') || get(log, 'message.inputs.default_input') : '') ||
''
const leftValue =
typeof rawLeftValue === 'string' || typeof rawLeftValue === 'number' ? rawLeftValue : ''
const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer')
return {
@@ -323,8 +327,11 @@ export const getConversationRowValues = ({
export const getDetailVarList = (
detail: ConversationLogDetail,
varValues: Record<string, string>,
) =>
detail.model_config?.user_input_form?.flatMap((item) => {
) => {
const userInputForm = detail.model_config?.user_input_form
if (!Array.isArray(userInputForm)) return []
return userInputForm.flatMap((item) => {
const variable = getUserInputVariable(item)
if (!variable) return []
@@ -339,7 +346,8 @@ export const getDetailVarList = (
value,
},
]
}) || []
})
}
export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatMode: boolean) => {
const messageFiles = detail.message?.message_files
@@ -348,3 +356,25 @@ export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatM
return messageFiles.flatMap((item) => (item.url ? [item.url] : []))
}
// `model_config.model` is untyped JSON in the console API response.
export const getLogModelInfo = (model: unknown) => {
if (!isPlainObject(model)) return undefined
const { name, provider, completion_params } = model
if (typeof name !== 'string' || typeof provider !== 'string') return undefined
return {
name,
provider,
completion_params: isPlainObject(completion_params) ? completion_params : undefined,
}
}
export const getAdminFeedback = (
feedbacks: Array<{ content?: string | null; from_source: string; rating: string }>,
): FeedbackType | undefined => {
const feedback = feedbacks.find((item) => item.from_source === 'admin')
if (feedback?.rating !== 'like' && feedback?.rating !== 'dislike') return undefined
return { rating: feedback.rating, content: feedback.content }
}
+70 -50
View File
@@ -1,6 +1,14 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type {
AppDetailWithSite,
Conversation,
ConversationDetail,
ConversationMessageDetail,
ConversationPagination,
ConversationWithSummary,
ConversationWithSummaryPagination,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { ChatItemInTree } from '../../base/chat/types'
import type {
@@ -9,14 +17,7 @@ import type {
IChatItem,
SubmitAnnotationFunc,
} from '@/app/components/base/chat/chat/type'
import type {
ChatConversationGeneralDetail,
ChatConversationsResponse,
ChatMessagesRequest,
CompletionConversationGeneralDetail,
CompletionConversationsResponse,
LogAnnotation,
} from '@/models/log'
import type { ChatMessagesRequest, LogAnnotation } from '@/models/log'
import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import {
@@ -33,7 +34,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiCloseLine, RiEditFill } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { skipToken, useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
@@ -54,13 +55,13 @@ import { toast } from '@/app/notifications'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/console'
import {
fetchChatMessages,
updateLogMessageAnnotations,
updateLogMessageFeedbacks,
} from '@/service/log'
import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import PromptLogModal from '../../base/prompt-log-modal'
import {
@@ -68,10 +69,12 @@ import {
applyAnnotationEdited,
applyAnnotationRemoved,
buildChatThreadState,
getAdminFeedback,
getCompletionMessageFiles,
getConversationRowValues,
getDetailVarList,
getFormattedChatList,
getLogModelInfo,
getThreadChatItems,
isNearTopLoadMore,
mergePaginatedChatItems,
@@ -79,14 +82,14 @@ import {
} from './list-utils'
import VarPanel from './var-panel'
type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail
type ConversationListItem = ConversationWithSummary | Conversation
type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true }
dayjs.extend(utc)
dayjs.extend(timezone)
type IConversationList = {
logs?: ChatConversationsResponse | CompletionConversationsResponse
logs?: ConversationWithSummaryPagination | ConversationPagination
appDetail: AppDetailWithSite
onRefresh: () => void
}
@@ -157,7 +160,7 @@ const statusTdRender = (statusCount: StatusCount) => {
type IDetailPanel = {
appDetail: AppDetailWithSite
detail: any
detail: ConversationDetail | ConversationMessageDetail
onClose: () => void
onFeedback: FeedbackFunc
onSubmitAnnotation: SubmitAnnotationFunc
@@ -247,12 +250,12 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
const nextThreadState = buildChatThreadState({
allChatItems,
hasMore,
introduction: detail?.model_config?.configs?.introduction,
introduction: 'introduction' in detail ? (detail.introduction ?? undefined) : undefined,
})
setChatItemTree(nextThreadState.chatItemTree)
setThreadChatItems(nextThreadState.threadChatItems)
if (nextThreadState.oldestAnswerId) oldestAnswerIdRef.current = nextThreadState.oldestAnswerId
}, [allChatItems, hasMore, detail?.model_config?.configs?.introduction])
}, [allChatItems, hasMore, detail])
const switchSibling = useCallback(
(siblingMessageId: string) => {
@@ -412,6 +415,8 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
const varList = getDetailVarList(detail, varValues)
const message_files = getCompletionMessageFiles(detail, isChatMode)
const completionMessage = 'message' in detail ? detail.message : undefined
const modelInfo = getLogModelInfo(detail.model_config?.model)
const [width, setWidth] = useState(0)
const ref = useRef<HTMLDivElement>(null)
@@ -449,15 +454,17 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
)}
{!isChatMode && (
<div className="system-2xs-regular-uppercase text-text-secondary">
{formatTime(
detail.created_at,
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)}
{detail.created_at
? formatTime(
detail.created_at,
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)
: defaultValue}
</div>
)}
</div>
<div className="flex grow flex-wrap items-center justify-end gap-y-1">
{!isAdvanced && <ModelInfo model={detail.model_config.model} />}
{!isAdvanced && modelInfo && <ModelInfo model={modelInfo} />}
</div>
<IconButton
size="lg"
@@ -477,35 +484,37 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) {
</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-4.5 items-center space-x-3">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{t(($) => $['table.header.output'], { ns: 'appLog' })}
completionMessage && (
<div className="px-6 py-4">
<div className="flex h-4.5 items-center space-x-3">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{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>
</div>
<div
className="h-px grow"
style={{
background:
'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, rgb(243, 244, 246) 100%)',
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionMessage.answer}
messageId={completionMessage.id}
isError={false}
onRetry={noop}
supportFeedback
feedback={getAdminFeedback(completionMessage.feedbacks)}
onFeedback={(feedback) => {
onFeedback(completionMessage.id, feedback).catch(noop)
}}
></div>
isShowTextToSpeech
siteInfo={null}
/>
</div>
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={detail.message.answer}
messageId={detail.message.id}
isError={false}
onRetry={noop}
supportFeedback
feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')}
onFeedback={(feedback) => {
onFeedback(detail.message.id, feedback).catch(noop)
}}
isShowTextToSpeech
siteInfo={null}
/>
</div>
)
) : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
<div className="mb-4 pt-4">
<Chat
@@ -632,8 +641,13 @@ const CompletionConversationDetailComp: FC<ConversationDetailProps> = ({
onClose,
}) => {
// Text Generator App Session Details Including Message List
const { data: conversationDetail, refetch: conversationDetailMutate } =
useCompletionConversationDetail(appDetail.id, conversationId)
const { data: conversationDetail, refetch: conversationDetailMutate } = useQuery(
consoleQuery.apps.byAppId.completionConversations.byConversationId.get.queryOptions({
input: conversationId
? { params: { app_id: appDetail.id, conversation_id: conversationId } }
: skipToken,
}),
)
const { t } = useTranslation(['appLog', 'common'])
const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => {
@@ -687,7 +701,13 @@ const ChatConversationDetailComp: FC<ConversationDetailProps> = ({
conversationId,
onClose,
}) => {
const { data: conversationDetail } = useChatConversationDetail(appDetail.id, conversationId)
const { data: conversationDetail } = useQuery(
consoleQuery.apps.byAppId.chatConversations.byConversationId.get.queryOptions({
input: conversationId
? { params: { app_id: appDetail.id, conversation_id: conversationId } }
: skipToken,
}),
)
const { t } = useTranslation(['appLog', 'common'])
const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => {
@@ -214,7 +214,7 @@ describe('Filter', () => {
])('should display correct label for %s status', (statusValue, expectedLabel) => {
render(
<Filter
queryParams={createDefaultQueryParams({ status: statusValue })}
queryParams={createDefaultQueryParams({ status: statusValue as QueryParam['status'] })}
setQueryParams={defaultSetQueryParams}
/>,
)
@@ -1,4 +1,10 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type {
AppDetailWithSite,
GetAppsByAppIdWorkflowAppLogsData,
WorkflowAppLogPaginationResponse,
WorkflowAppLogPartialResponse,
WorkflowRunForLogResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { UseQueryResult } from '@tanstack/react-query'
/**
* Logs Container Component Tests
@@ -15,17 +21,14 @@ import type { UseQueryResult } from '@tanstack/react-query'
* - detail.spec.tsx
* - trigger-by-display.spec.tsx
*/
import type { MockedFunction } from 'vite-plus/test'
import type { CloudSandboxPlanState } from '../../log/cloud-sandbox-retention'
import type { ILogsProps } from '../index'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type { AppModeEnum } from '@/types/app'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
@@ -41,7 +44,26 @@ const mockPlanState = vi.hoisted(() => ({
value: 'unrestricted' as CloudSandboxPlanState,
}))
vi.mock('@/service/use-log')
type WorkflowLogsInput = {
params: { app_id: string }
query?: GetAppsByAppIdWorkflowAppLogsData['query']
}
const mockedUseWorkflowLogs =
vi.fn<(input: WorkflowLogsInput) => UseQueryResult<WorkflowAppLogPaginationResponse, Error>>()
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: (options: Parameters<typeof actual.useQuery>[0]) => {
const [path, meta] = options.queryKey as [string[], { input?: WorkflowLogsInput }?]
if (Array.isArray(path) && path.includes('workflowAppLogs') && meta?.input)
return mockedUseWorkflowLogs(meta.input)
return actual.useQuery(options)
},
}
})
vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../log/cloud-sandbox-retention')>()
@@ -106,10 +128,6 @@ vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<
typeof useLogModule.useWorkflowLogs
>
// ============================================================================
// Test Utilities
// ============================================================================
@@ -180,14 +198,14 @@ const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWit
...overrides,
})
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
const createMockWorkflowRun = (
overrides: Partial<WorkflowRunForLogResponse> = {},
): WorkflowRunForLogResponse => ({
id: 'run-1',
version: '1.0.0',
status: 'succeeded',
elapsed_time: 1.234,
total_tokens: 100,
total_price: 0.001,
currency: 'USD',
total_steps: 5,
finished_at: Date.now(),
triggered_from: WorkflowRunTriggeredFrom.APP_RUN,
@@ -195,8 +213,8 @@ const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): Work
})
const createMockWorkflowLog = (
overrides: Partial<WorkflowAppLogDetail> = {},
): WorkflowAppLogDetail => ({
overrides: Partial<WorkflowAppLogPartialResponse> = {},
): WorkflowAppLogPartialResponse => ({
id: 'log-1',
workflow_run: createMockWorkflowRun(),
created_from: 'web-app',
@@ -211,9 +229,9 @@ const createMockWorkflowLog = (
})
const createMockLogsResponse = (
data: WorkflowAppLogDetail[] = [],
data: WorkflowAppLogPartialResponse[] = [],
total = data.length,
): WorkflowLogsResponse => ({
): WorkflowAppLogPaginationResponse => ({
data,
has_more: data.length < total,
limit: APP_PAGE_LIMIT,
@@ -225,14 +243,9 @@ const createMockLogsResponse = (
// Type-safe Mock Helper
// ============================================================================
type WorkflowLogsParams = {
appId: string
params?: Record<string, string | number | boolean | undefined>
}
const getMockCallParams = (): WorkflowLogsParams | undefined => {
const lastCall = mockedUseWorkflowLogs.mock.calls.at(-1)
return lastCall?.[0]
const getMockCallParams = () => {
const input = mockedUseWorkflowLogs.mock.calls.at(-1)?.[0]
return input && { appId: input.params.app_id, params: input.query }
}
// ============================================================================
@@ -256,7 +269,7 @@ describe('Logs Container', () => {
it('should render title and subtitle', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -272,7 +285,7 @@ describe('Logs Container', () => {
it('should render Filter component', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -293,7 +306,7 @@ describe('Logs Container', () => {
it('should show loading spinner when data is undefined', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: undefined,
isLoading: true,
}),
@@ -309,7 +322,7 @@ describe('Logs Container', () => {
it('should not show loading spinner when data is available', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
@@ -329,7 +342,7 @@ describe('Logs Container', () => {
it('should render empty element when total is 0', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -350,7 +363,7 @@ describe('Logs Container', () => {
it('should call useWorkflowLogs with correct appId and default params', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -373,7 +386,7 @@ describe('Logs Container', () => {
it('should include date filters for non-allTime periods', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -390,7 +403,7 @@ describe('Logs Container', () => {
it('should not include status param when status is all', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -412,7 +425,7 @@ describe('Logs Container', () => {
// Arrange
const user = userEvent.setup()
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -436,7 +449,7 @@ describe('Logs Container', () => {
// Arrange
const user = userEvent.setup()
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -459,7 +472,7 @@ describe('Logs Container', () => {
const user = userEvent.setup()
mockPlanState.value = 'sandbox'
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -482,7 +495,7 @@ describe('Logs Container', () => {
// Arrange
const user = userEvent.setup()
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -510,7 +523,7 @@ describe('Logs Container', () => {
it('should not render pagination when total is less than limit', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
@@ -529,7 +542,7 @@ describe('Logs Container', () => {
)
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse(logs, APP_PAGE_LIMIT + 10),
}),
)
@@ -549,7 +562,7 @@ describe('Logs Container', () => {
it('should render List component when data is available', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
@@ -564,7 +577,7 @@ describe('Logs Container', () => {
it('should display log data in table', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse(
[
createMockWorkflowLog({
@@ -607,7 +620,7 @@ describe('Logs Container', () => {
it('should handle different app modes', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([createMockWorkflowLog()], 1),
}),
)
@@ -624,7 +637,7 @@ describe('Logs Container', () => {
it('should handle error state from useWorkflowLogs', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: undefined,
error: new Error('Failed to fetch'),
}),
@@ -640,7 +653,7 @@ describe('Logs Container', () => {
it('should handle app with different ID', () => {
// Arrange
mockedUseWorkflowLogs.mockReturnValue(
createMockQueryResult<WorkflowLogsResponse>({
createMockQueryResult<WorkflowAppLogPaginationResponse>({
data: createMockLogsResponse([], 0),
}),
)
@@ -1,5 +1,9 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunDetail } from '@/models/log'
import type {
AppDetailWithSite,
WorkflowAppLogPaginationResponse,
WorkflowAppLogPartialResponse,
WorkflowRunForLogResponse,
} from '@dify/contracts/api/console/apps/types.gen'
/**
* WorkflowAppLogList Component Tests
*
@@ -99,14 +103,14 @@ const createMockApp = (overrides: Partial<AppDetailWithSite> = {}): AppDetailWit
...overrides,
})
const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail => ({
const createMockWorkflowRun = (
overrides: Partial<WorkflowRunForLogResponse> = {},
): WorkflowRunForLogResponse => ({
id: 'run-1',
version: '1.0.0',
status: 'succeeded',
elapsed_time: 1.234,
total_tokens: 100,
total_price: 0.001,
currency: 'USD',
total_steps: 5,
finished_at: Date.now(),
triggered_from: WorkflowRunTriggeredFrom.APP_RUN,
@@ -114,8 +118,9 @@ const createMockWorkflowRun = (overrides: Partial<WorkflowRunDetail> = {}): Work
})
const createMockWorkflowLog = (
overrides: Partial<WorkflowAppLogDetail> = {},
): WorkflowAppLogDetail => ({
// read_at is not declared by the console response yet; the list still honours it.
overrides: Partial<WorkflowAppLogPartialResponse> & { read_at?: number } = {},
): WorkflowAppLogPartialResponse => ({
id: 'log-1',
workflow_run: createMockWorkflowRun(),
created_from: 'web-app',
@@ -130,9 +135,9 @@ const createMockWorkflowLog = (
})
const createMockLogsResponse = (
data: WorkflowAppLogDetail[] = [],
data: WorkflowAppLogPartialResponse[] = [],
total = data.length,
): WorkflowLogsResponse => ({
): WorkflowAppLogPaginationResponse => ({
data,
has_more: data.length < total,
limit: APP_PAGE_LIMIT,
@@ -54,7 +54,7 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
<Chip
value={queryParams.status || 'all'}
onSelect={(item) => {
setQueryParams({ ...queryParams, status: item.value as string })
setQueryParams({ ...queryParams, status: item.value as QueryParam['status'] })
trackEvent('workflow_log_filter_status_selected', {
workflow_log_filter_status: item.value as string,
})
+13 -7
View File
@@ -1,6 +1,9 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type {
AppDetailWithSite,
GetAppsByAppIdWorkflowAppLogsData,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useQuery } from '@tanstack/react-query'
@@ -16,7 +19,7 @@ import EmptyElement from '@/app/components/app/log/empty-element'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { APP_PAGE_LIMIT } from '@/config'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useWorkflowLogs } from '@/service/use-log'
import { consoleQuery } from '@/service/console'
import PageTitle from '../log-annotation/page-title'
import { ArchivedLogsNotice } from '../log/archived-logs-notice'
import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils'
@@ -36,9 +39,11 @@ export type ILogsProps = {
appDetail: AppDetailWithSite
}
type WorkflowLogStatus = NonNullable<GetAppsByAppIdWorkflowAppLogsData['query']>['status']
export type QueryParam = {
period: string
status?: string
status?: WorkflowLogStatus | 'all'
keyword?: string
}
@@ -81,10 +86,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
...omit(requestQueryParams, ['period', 'status']),
}
const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({
appId: appDetail.id,
params: query,
})
const { data: workflowLogs, refetch: mutate } = useQuery(
consoleQuery.apps.byAppId.workflowAppLogs.get.queryOptions({
input: { params: { app_id: appDetail.id }, query },
}),
)
const total = workflowLogs?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const showArchivedLogsNotice = shouldShowArchivedLogsNotice(
+31 -28
View File
@@ -1,12 +1,12 @@
'use client'
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type {
WorkflowAppLogDetail,
WorkflowLogsResponse,
WorkflowRunTriggeredFrom,
} from '@/models/log'
AppDetailWithSite,
WorkflowAppLogPaginationResponse,
WorkflowAppLogPartialResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FC } from 'react'
import type { WorkflowLogDetails, WorkflowRunTriggeredFrom } from '@/models/log'
import { ArrowDownIcon } from '@heroicons/react/24/outline'
import { cn } from '@langgenius/dify-ui/cn'
import {
@@ -30,7 +30,7 @@ import DetailPanel from './detail'
import TriggerByDisplay from './trigger-by-display'
type ILogs = {
logs?: WorkflowLogsResponse
logs?: WorkflowAppLogPaginationResponse
appDetail?: AppDetailWithSite
onRefresh: () => void
}
@@ -45,9 +45,9 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
const isMobile = media === MediaType.mobile
const [showDrawer, setShowDrawer] = useState<boolean>(false)
const [currentLog, setCurrentLog] = useState<WorkflowAppLogDetail | undefined>()
const [currentLog, setCurrentLog] = useState<WorkflowAppLogPartialResponse | undefined>()
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
const [localLogs, setLocalLogs] = useState<WorkflowAppLogDetail[]>(logs?.data || [])
const [localLogs, setLocalLogs] = useState<WorkflowAppLogPartialResponse[]>(logs?.data || [])
useEffect(() => {
if (!logs?.data) {
@@ -56,7 +56,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}
const sortedLogs = [...logs.data].sort((a, b) => {
const result = a.created_at - b.created_at
const result = (a.created_at ?? 0) - (b.created_at ?? 0)
return sortOrder === 'asc' ? result : -result
})
@@ -69,7 +69,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW
const statusTdRender = (status: string) => {
const statusTdRender = (status?: string | null) => {
if (status === 'succeeded') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
@@ -209,7 +209,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
</tr>
</thead>
<tbody className="system-sm-regular text-text-secondary">
{localLogs.map((log: WorkflowAppLogDetail) => {
{localLogs.map((log: WorkflowAppLogPartialResponse) => {
const endUser = log.created_by_end_user
? log.created_by_end_user.session_id
: log.created_by_account
@@ -230,7 +230,8 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
}}
>
<td className="h-4">
{!log.read_at && (
{/* The console workflow-log response does not declare read_at yet. */}
{!('read_at' in log && log.read_at) && (
<div className="flex items-center p-3 pr-0.5">
<span className="inline-block size-1.5 rounded-sm bg-util-colors-blue-blue-500"></span>
</div>
@@ -246,21 +247,21 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
setShowDrawer(true)
}}
>
{formatTime(
log.created_at,
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)}
{log.created_at
? formatTime(
log.created_at,
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)
: defaultValue}
</DrawerTrigger>
</td>
<td className="p-3 pr-2">{statusTdRender(log.workflow_run.status)}</td>
<td className="p-3 pr-2">{statusTdRender(log.workflow_run?.status)}</td>
<td className="p-3 pr-2">
<div
className={cn(log.workflow_run.elapsed_time === 0 && 'text-text-quaternary')}
>
{`${log.workflow_run.elapsed_time.toFixed(3)}s`}
<div className={cn(!log.workflow_run?.elapsed_time && 'text-text-quaternary')}>
{`${(log.workflow_run?.elapsed_time ?? 0).toFixed(3)}s`}
</div>
</td>
<td className="p-3 pr-2">{log.workflow_run.total_tokens}</td>
<td className="p-3 pr-2">{log.workflow_run?.total_tokens}</td>
<td className="p-3 pr-2">
<div
className={cn(
@@ -274,8 +275,10 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
{isWorkflow && (
<td className="p-3 pr-2">
<TriggerByDisplay
triggeredFrom={log.workflow_run.triggered_from as WorkflowRunTriggeredFrom}
triggerMetadata={log.details?.trigger_metadata}
triggeredFrom={log.workflow_run?.triggered_from as WorkflowRunTriggeredFrom}
triggerMetadata={
(log.details as WorkflowLogDetails | undefined)?.trigger_metadata
}
/>
</td>
)}
@@ -292,10 +295,10 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
<DetailPanel
appId={appDetail.id}
onClose={onCloseDrawer}
runID={currentLog?.workflow_run.id || ''}
runID={currentLog?.workflow_run?.id || ''}
canReplay={
currentLog?.workflow_run.triggered_from === 'app-run' ||
currentLog?.workflow_run.triggered_from === 'debugging'
currentLog?.workflow_run?.triggered_from === 'app-run' ||
currentLog?.workflow_run?.triggered_from === 'debugging'
}
/>
</DrawerContent>
@@ -1,11 +1,12 @@
import type { WorkflowPausedDetailsResponse } from '@/models/log'
import type { WorkflowPauseDetailsResponse } from '@dify/contracts/api/console/workflow/types.gen'
import { skipToken } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { cloneElement, isValidElement } from 'react'
import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n'
import Status from '../status'
const mockDocLink = createDocLinkMock()
const mockUseWorkflowPausedDetails = vi.fn()
const mockUseQuery = vi.fn()
vi.mock('react-i18next', async () => {
const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock')
@@ -57,14 +58,28 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
vi.mock('@/service/use-log', () => ({
useWorkflowPausedDetails: (params: { workflowRunId: string; enabled?: boolean }) =>
mockUseWorkflowPausedDetails(params),
vi.mock('@tanstack/react-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@tanstack/react-query')>()),
useQuery: (options: unknown) => mockUseQuery(options),
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
workflow: {
byWorkflowRunId: {
pauseDetails: {
get: {
queryOptions: (options: unknown) => options,
},
},
},
},
},
}))
const createPausedDetails = (
overrides: Partial<WorkflowPausedDetailsResponse> = {},
): WorkflowPausedDetailsResponse => ({
overrides: Partial<WorkflowPauseDetailsResponse> = {},
): WorkflowPauseDetailsResponse => ({
paused_at: '2026-03-18T00:00:00Z',
paused_nodes: [],
...overrides,
@@ -73,7 +88,7 @@ const createPausedDetails = (
describe('Status', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined })
mockUseQuery.mockReturnValue({ data: undefined })
})
it('renders the scheduled status before execution', () => {
@@ -87,10 +102,7 @@ describe('Status', () => {
expect(screen.getByText('appLog.status.running')).toBeInTheDocument()
expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2)
expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
workflowRunId: 'run-1',
enabled: false,
})
expect(mockUseQuery).toHaveBeenCalledWith({ input: skipToken })
})
it('renders the listening label when the run is waiting for input', () => {
@@ -170,14 +182,13 @@ describe('Status', () => {
expect(screen.getByText('appLog.status.paused')).toBeInTheDocument()
expect(screen.getByText('workflowHumanInput.nodes.humanInput.log.reason')).toBeInTheDocument()
expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3)
expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
workflowRunId: 'run-3',
enabled: true,
expect(mockUseQuery).toHaveBeenCalledWith({
input: { params: { workflow_run_id: 'run-3' } },
})
})
it('renders paused human-input reasons and backstage URLs', () => {
mockUseWorkflowPausedDetails.mockReturnValue({
mockUseQuery.mockReturnValue({
data: createPausedDetails({
paused_nodes: [
{
+11 -6
View File
@@ -2,11 +2,12 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import StatusContainer from '@/app/components/workflow/run/status-container'
import { useDocLink } from '@/context/i18n'
import { useWorkflowPausedDetails } from '@/service/use-log'
import { consoleQuery } from '@/service/console'
type ResultProps = {
status: string
@@ -31,10 +32,14 @@ const StatusPanel: FC<ResultProps> = ({
}) => {
const { t } = useTranslation(['runLog', 'workflow', 'appLog', 'workflowHumanInput'])
const docLink = useDocLink()
const { data: pausedDetails } = useWorkflowPausedDetails({
workflowRunId: workflowRunId || '',
enabled: status === 'paused',
})
const { data: pausedDetails } = useQuery(
consoleQuery.workflow.byWorkflowRunId.pauseDetails.get.queryOptions({
input:
status === 'paused' && workflowRunId
? { params: { workflow_run_id: workflowRunId } }
: skipToken,
}),
)
const pausedReasons = useMemo(() => {
const reasons: string[] = []
@@ -55,7 +60,7 @@ const StatusPanel: FC<ResultProps> = ({
const hasHumanInputNode = paused_nodes.some((node) => node.pause_type.type === 'human_input')
if (hasHumanInputNode) {
paused_nodes.forEach((node) => {
if (node.pause_type.type === 'human_input') {
if (node.pause_type.type === 'human_input' && node.pause_type.backstage_input_url) {
inputURLs.push(node.pause_type.backstage_input_url)
}
})
+1 -181
View File
@@ -3,35 +3,6 @@ import type { Metadata, ThoughtItem } from '@/app/components/base/chat/chat/type
import type { Edge, Node } from '@/app/components/workflow/types'
import type { VisionFile } from '@/types/app'
type CompletionParamsType = {
max_tokens: number
temperature: number
top_p: number
stop: string[]
presence_penalty: number
frequency_penalty: number
}
type LogModelConfig = {
name: string
provider: string
completion_params: CompletionParamsType
}
type ModelConfigDetail = {
introduction: string
prompt_template: string
prompt_variables: Array<{
key: string
name: string
description: string
type: string | number
default: string
options: string[]
}>
completion_params: CompletionParamsType
}
export type LogAnnotation = {
id: string
content: string
@@ -84,98 +55,6 @@ type MessageContent = {
parent_message_id: string | null
}
export type CompletionConversationGeneralDetail = {
id: string
status: 'normal' | 'finished' | 'paused'
from_source: 'api' | 'console'
from_end_user_id: string
from_end_user_session_id: string
from_account_id: string
read_at: Date
created_at: number
updated_at: number
annotation: Annotation
user_feedback_stats: {
like: number
dislike: number
}
admin_feedback_stats: {
like: number
dislike: number
}
model_config: {
provider: string
model_id: string
configs: Pick<ModelConfigDetail, 'prompt_template'>
}
message: Pick<MessageContent, 'inputs' | 'query' | 'answer' | 'message'>
}
export type CompletionConversationFullDetailResponse = {
id: string
status: 'normal' | 'finished'
from_source: 'api' | 'console'
from_end_user_id: string
from_account_id: string
// read_at: Date
created_at: number
model_config: {
provider: string
model_id: string
configs: ModelConfigDetail
}
message: MessageContent
}
export type CompletionConversationsResponse = {
data: Array<CompletionConversationGeneralDetail>
has_more: boolean
limit: number
total: number
page: number
}
export type CompletionConversationsRequest = {
keyword: string
start: string
end: string
annotation_status: string
page: number
limit: number // The default value is 20 and the range is 1-100
}
export type ChatConversationGeneralDetail = Omit<
CompletionConversationGeneralDetail,
'message' | 'annotation'
> & {
summary: string
message_count: number
annotated: boolean
}
export type ChatConversationsResponse = {
data: Array<ChatConversationGeneralDetail>
has_more: boolean
limit: number
total: number
page: number
}
export type ChatConversationsRequest = CompletionConversationsRequest & { message_count: number }
export type ChatConversationFullDetailResponse = Omit<
CompletionConversationGeneralDetail,
'message' | 'model_config'
> & {
message_count: number
model_config: {
provider: string
model_id: string
configs: ModelConfigDetail
model: LogModelConfig
}
}
export type ChatMessagesRequest = {
conversation_id: string
first_id?: string
@@ -230,30 +109,10 @@ export type TriggerMetadata = {
icon_dark?: string | null
}
type WorkflowLogDetails = {
export type WorkflowLogDetails = {
trigger_metadata?: TriggerMetadata
}
export type WorkflowRunDetail = {
id: string
version: string
status:
| 'scheduled'
| 'running'
| 'succeeded'
| 'failed'
| 'stopped'
| 'partial-succeeded'
| 'paused'
error?: string
triggered_from?: WorkflowRunTriggeredFrom
elapsed_time: number
total_tokens: number
total_price: number
currency: string
total_steps: number
finished_at: number
}
type AccountInfo = {
id: string
name: string
@@ -265,24 +124,6 @@ type EndUserInfo = {
is_anonymous: boolean
session_id: string
}
export type WorkflowAppLogDetail = {
id: string
workflow_run: WorkflowRunDetail
details?: WorkflowLogDetails
created_from: 'service-api' | 'web-app' | 'explore'
created_by_role: 'account' | 'end_user'
created_by_account?: AccountInfo
created_by_end_user?: EndUserInfo
created_at: number
read_at?: number
}
export type WorkflowLogsResponse = {
data: Array<WorkflowAppLogDetail>
has_more: boolean
limit: number
total: number
page: number
}
export type WorkflowRunDetailResponse = {
id: string
version: string
@@ -371,24 +212,3 @@ export type AgentLogDetailResponse = {
iterations: AgentIteration[]
files: AgentLogFile[]
}
type PauseType =
| {
type: 'human_input'
form_id: string
backstage_input_url: string
}
| {
type: 'breakpoint'
}
type PauseDetail = {
node_id: string
node_title: string
pause_type: PauseType
}
export type WorkflowPausedDetailsResponse = {
paused_at: string
paused_nodes: PauseDetail[]
}
-120
View File
@@ -1,120 +0,0 @@
import type {
ChatConversationFullDetailResponse,
ChatConversationsRequest,
ChatConversationsResponse,
CompletionConversationFullDetailResponse,
CompletionConversationsRequest,
CompletionConversationsResponse,
WorkflowLogsResponse,
WorkflowPausedDetailsResponse,
} from '@/models/log'
import { useQuery } from '@tanstack/react-query'
import { consoleClient } from '@/service/console'
import { get } from './base'
const NAME_SPACE = 'log'
// ============ Annotations Count ============
export const useAnnotationsCount = (appId: string) => {
return useQuery({
queryKey: [NAME_SPACE, 'annotations-count', appId],
queryFn: () =>
consoleClient.apps.byAppId.annotations.count.get({
params: {
app_id: appId,
},
}),
enabled: !!appId,
})
}
// ============ Chat Conversations ============
type ChatConversationsParams = {
appId: string
params?: Partial<ChatConversationsRequest>
}
export const useChatConversations = ({ appId, params }: ChatConversationsParams) => {
return useQuery<ChatConversationsResponse>({
queryKey: [NAME_SPACE, 'chat-conversations', appId, params],
queryFn: () => get<ChatConversationsResponse>(`/apps/${appId}/chat-conversations`, { params }),
enabled: !!appId,
})
}
// ============ Completion Conversations ============
type CompletionConversationsParams = {
appId: string
params?: Partial<CompletionConversationsRequest>
}
export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => {
return useQuery<CompletionConversationsResponse>({
queryKey: [NAME_SPACE, 'completion-conversations', appId, params],
queryFn: () =>
get<CompletionConversationsResponse>(`/apps/${appId}/completion-conversations`, { params }),
enabled: !!appId,
})
}
// ============ Chat Conversation Detail ============
export const useChatConversationDetail = (appId?: string, conversationId?: string) => {
return useQuery<ChatConversationFullDetailResponse>({
queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId],
queryFn: () =>
get<ChatConversationFullDetailResponse>(
`/apps/${appId}/chat-conversations/${conversationId}`,
),
enabled: !!appId && !!conversationId,
})
}
// ============ Completion Conversation Detail ============
export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => {
return useQuery<CompletionConversationFullDetailResponse>({
queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId],
queryFn: () =>
get<CompletionConversationFullDetailResponse>(
`/apps/${appId}/completion-conversations/${conversationId}`,
),
enabled: !!appId && !!conversationId,
})
}
// ============ Workflow Logs ============
type WorkflowLogsParams = {
appId: string
params?: Record<string, string | number | boolean | undefined>
}
export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => {
return useQuery<WorkflowLogsResponse>({
queryKey: [NAME_SPACE, 'workflow-logs', appId, params],
queryFn: () => get<WorkflowLogsResponse>(`/apps/${appId}/workflow-app-logs`, { params }),
enabled: !!appId,
})
}
// ============ Workflow Pause Details ============
type WorkflowPausedDetailsParams = {
workflowRunId: string
enabled?: boolean
}
export const useWorkflowPausedDetails = ({
workflowRunId,
enabled = true,
}: WorkflowPausedDetailsParams) => {
return useQuery<WorkflowPausedDetailsResponse>({
queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId],
queryFn: () => get<WorkflowPausedDetailsResponse>(`/workflow/${workflowRunId}/pause-details`),
enabled: enabled && !!workflowRunId,
})
}