From 8f6d92949aa5dcdd9235945bb77faba2f70617b0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:19:27 +0800 Subject: [PATCH] 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 --- oxlint-suppressions.json | 10 +- .../app/annotation/__tests__/filter.spec.tsx | 49 +++-- web/app/components/app/annotation/filter.tsx | 9 +- .../app/log/__tests__/filter.spec.tsx | 33 ++-- .../app/log/__tests__/index.spec.tsx | 60 ++++-- .../app/log/__tests__/list.spec.tsx | 53 +++-- web/app/components/app/log/filter.tsx | 16 +- web/app/components/app/log/index.tsx | 33 ++-- web/app/components/app/log/list-utils.ts | 62 ++++-- web/app/components/app/log/list.tsx | 120 +++++++----- .../workflow-log/__tests__/filter.spec.tsx | 2 +- .../app/workflow-log/__tests__/index.spec.tsx | 99 +++++----- .../app/workflow-log/__tests__/list.spec.tsx | 23 ++- .../components/app/workflow-log/filter.tsx | 2 +- web/app/components/app/workflow-log/index.tsx | 20 +- web/app/components/app/workflow-log/list.tsx | 59 +++--- .../workflow/run/__tests__/status.spec.tsx | 43 +++-- web/app/components/workflow/run/status.tsx | 17 +- web/models/log.ts | 182 +----------------- web/service/use-log.ts | 120 ------------ 20 files changed, 434 insertions(+), 578 deletions(-) delete mode 100644 web/service/use-log.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 41243788e1f..0609dba2890 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/web/app/components/app/annotation/__tests__/filter.spec.tsx b/web/app/components/app/annotation/__tests__/filter.spec.tsx index efc9bb1eb88..9733c5ddd4f 100644 --- a/web/app/components/app/annotation/__tests__/filter.spec.tsx +++ b/web/app/components/app/annotation/__tests__/filter.spec.tsx @@ -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()), + 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({ isLoading: true }), ) @@ -82,7 +99,7 @@ describe('Filter', () => { it('should render nothing when data is undefined', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ 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({ 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({ 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({ 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({ 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({ 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({ data: { count: 5 }, isLoading: false, @@ -247,7 +264,7 @@ describe('Filter', () => { it('should handle undefined keyword in queryParams', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 5 }, isLoading: false, @@ -267,7 +284,7 @@ describe('Filter', () => { it('should handle zero count', () => { // Arrange - mockUseAnnotationsCount.mockReturnValue( + mockUseQuery.mockReturnValue( createMockQueryResult({ data: { count: 0 }, isLoading: false, diff --git a/web/app/components/app/annotation/filter.tsx b/web/app/components/app/annotation/filter.tsx index 7fd53304c52..f7b0c541dc0 100644 --- a/web/app/components/app/annotation/filter.tsx +++ b/web/app/components/app/annotation/filter.tsx @@ -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 = ({ 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 ( diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index fda389b312f..f47249346dc 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -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() @@ -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() @@ -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() @@ -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() diff --git a/web/app/components/app/log/__tests__/index.spec.tsx b/web/app/components/app/log/__tests__/index.spec.tsx index 99f9894307c..3e0d9749f22 100644 --- a/web/app/components/app/log/__tests__/index.spec.tsx +++ b/web/app/components/app/log/__tests__/index.spec.tsx @@ -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) => ({ ...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()), + 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), + }), }), }), ) diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index 58fa91eb8ef..c893f3f205d 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -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() + return { + ...actual, + useQuery: (options: Parameters[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 }) =>
{model}
, + default: ({ model }: { model: { name: string } }) => ( +
{model.name}
+ ), })) 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: { diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index d75e54078c5..49b6d6c9ef6 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -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 = ({ 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 = ({ 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 = ({ }, ]} onSelect={(value) => { - setQueryParams({ ...queryParams, sort_by: value as string }) + setQueryParams({ ...queryParams, sort_by: value as QueryParam['sort_by'] }) }} /> diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index 6584fe1acb2..a307f0eb1fd 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -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 + 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 = ({ 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 diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts index c776ca9345c..63a3c6bcfd1 100644 --- a/web/app/components/app/log/list-utils.ts +++ b/web/app/components/app/log/list-utils.ts @@ -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 - message_files?: Array<{ url?: string }> + inputs?: Record + message_files?: Array<{ url?: string | null }> query?: string } -type UserInputFormItem = Record - 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, -) => - 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 } +} diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 6265ceced0d..737308696b4 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -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(null) @@ -449,15 +454,17 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { )} {!isChatMode && (
- {formatTime( - detail.created_at, - t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, - )} + {detail.created_at + ? formatTime( + detail.created_at, + t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string, + ) + : defaultValue}
)}
- {!isAdvanced && } + {!isAdvanced && modelInfo && }
{!isChatMode ? ( -
-
-
- {t(($) => $['table.header.output'], { ns: 'appLog' })} + completionMessage && ( +
+
+
+ {t(($) => $['table.header.output'], { ns: 'appLog' })} +
+
-
{ + onFeedback(completionMessage.id, feedback).catch(noop) }} - >
+ isShowTextToSpeech + siteInfo={null} + />
- item.from_source === 'admin')} - onFeedback={(feedback) => { - onFeedback(detail.message.id, feedback).catch(noop) - }} - isShowTextToSpeech - siteInfo={null} - /> -
+ ) ) : threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
= ({ 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 = ({ 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) => { diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 0585e1bdb6e..130cf5a563d 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -214,7 +214,7 @@ describe('Filter', () => { ])('should display correct label for %s status', (statusValue, expectedLabel) => { render( , ) diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index fdebf47c65b..ec120c93763 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -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>() + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQuery: (options: Parameters[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() @@ -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 = {}): AppDetailWit ...overrides, }) -const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ +const createMockWorkflowRun = ( + overrides: Partial = {}, +): 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 = {}): Work }) const createMockWorkflowLog = ( - overrides: Partial = {}, -): WorkflowAppLogDetail => ({ + overrides: Partial = {}, +): 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 -} - -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({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -272,7 +285,7 @@ describe('Logs Container', () => { it('should render Filter component', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -293,7 +306,7 @@ describe('Logs Container', () => { it('should show loading spinner when data is undefined', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ 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({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -329,7 +342,7 @@ describe('Logs Container', () => { it('should render empty element when total is 0', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -350,7 +363,7 @@ describe('Logs Container', () => { it('should call useWorkflowLogs with correct appId and default params', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -373,7 +386,7 @@ describe('Logs Container', () => { it('should include date filters for non-allTime periods', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -390,7 +403,7 @@ describe('Logs Container', () => { it('should not include status param when status is all', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -412,7 +425,7 @@ describe('Logs Container', () => { // Arrange const user = userEvent.setup() mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -436,7 +449,7 @@ describe('Logs Container', () => { // Arrange const user = userEvent.setup() mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -459,7 +472,7 @@ describe('Logs Container', () => { const user = userEvent.setup() mockPlanState.value = 'sandbox' mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) @@ -482,7 +495,7 @@ describe('Logs Container', () => { // Arrange const user = userEvent.setup() mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ 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({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -529,7 +542,7 @@ describe('Logs Container', () => { ) mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ 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({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -564,7 +577,7 @@ describe('Logs Container', () => { it('should display log data in table', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse( [ createMockWorkflowLog({ @@ -607,7 +620,7 @@ describe('Logs Container', () => { it('should handle different app modes', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ data: createMockLogsResponse([createMockWorkflowLog()], 1), }), ) @@ -624,7 +637,7 @@ describe('Logs Container', () => { it('should handle error state from useWorkflowLogs', () => { // Arrange mockedUseWorkflowLogs.mockReturnValue( - createMockQueryResult({ + createMockQueryResult({ 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({ + createMockQueryResult({ data: createMockLogsResponse([], 0), }), ) diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index 3a672ae8e9e..12daedc89b1 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -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 = {}): AppDetailWit ...overrides, }) -const createMockWorkflowRun = (overrides: Partial = {}): WorkflowRunDetail => ({ +const createMockWorkflowRun = ( + overrides: Partial = {}, +): 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 = {}): Work }) const createMockWorkflowLog = ( - overrides: Partial = {}, -): WorkflowAppLogDetail => ({ + // read_at is not declared by the console response yet; the list still honours it. + overrides: Partial & { 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, diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx index 99d6ee7ab9c..28a626b99b0 100644 --- a/web/app/components/app/workflow-log/filter.tsx +++ b/web/app/components/app/workflow-log/filter.tsx @@ -54,7 +54,7 @@ const Filter: FC = ({ queryParams, setQueryParams }: IFilterProps) { - 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, }) diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 8b28ddf8073..168a3adc73c 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -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['status'] + export type QueryParam = { period: string - status?: string + status?: WorkflowLogStatus | 'all' keyword?: string } @@ -81,10 +86,11 @@ const Logs: FC = ({ 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( diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index cb190f8b711..5b66a2c863e 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -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 = ({ logs, appDetail, onRefresh }) => { const isMobile = media === MediaType.mobile const [showDrawer, setShowDrawer] = useState(false) - const [currentLog, setCurrentLog] = useState() + const [currentLog, setCurrentLog] = useState() const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') - const [localLogs, setLocalLogs] = useState(logs?.data || []) + const [localLogs, setLocalLogs] = useState(logs?.data || []) useEffect(() => { if (!logs?.data) { @@ -56,7 +56,7 @@ const WorkflowAppLogList: FC = ({ 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 = ({ logs, appDetail, onRefresh }) => { const isWorkflow = appDetail?.mode === AppModeEnum.WORKFLOW - const statusTdRender = (status: string) => { + const statusTdRender = (status?: string | null) => { if (status === 'succeeded') { return (
@@ -209,7 +209,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { - {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 = ({ logs, appDetail, onRefresh }) => { }} > - {!log.read_at && ( + {/* The console workflow-log response does not declare read_at yet. */} + {!('read_at' in log && log.read_at) && (
@@ -246,21 +247,21 @@ const WorkflowAppLogList: FC = ({ 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} - {statusTdRender(log.workflow_run.status)} + {statusTdRender(log.workflow_run?.status)} -
- {`${log.workflow_run.elapsed_time.toFixed(3)}s`} +
+ {`${(log.workflow_run?.elapsed_time ?? 0).toFixed(3)}s`}
- {log.workflow_run.total_tokens} + {log.workflow_run?.total_tokens}
= ({ logs, appDetail, onRefresh }) => { {isWorkflow && ( )} @@ -292,10 +295,10 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx index 3c1a8909704..c49571a2412 100644 --- a/web/app/components/workflow/run/__tests__/status.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -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()), + useQuery: (options: unknown) => mockUseQuery(options), +})) + +vi.mock('@/service/console', () => ({ + consoleQuery: { + workflow: { + byWorkflowRunId: { + pauseDetails: { + get: { + queryOptions: (options: unknown) => options, + }, + }, + }, + }, + }, })) const createPausedDetails = ( - overrides: Partial = {}, -): WorkflowPausedDetailsResponse => ({ + overrides: Partial = {}, +): 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: [ { diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index 64eb0793a1a..69704b98817 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -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 = ({ }) => { 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 = ({ 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) } }) diff --git a/web/models/log.ts b/web/models/log.ts index f94a11d7875..460bcb9460a 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -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 - } - message: Pick -} - -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 - 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 - 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 - 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[] -} diff --git a/web/service/use-log.ts b/web/service/use-log.ts deleted file mode 100644 index 65a201861a8..00000000000 --- a/web/service/use-log.ts +++ /dev/null @@ -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 -} - -export const useChatConversations = ({ appId, params }: ChatConversationsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'chat-conversations', appId, params], - queryFn: () => get(`/apps/${appId}/chat-conversations`, { params }), - enabled: !!appId, - }) -} - -// ============ Completion Conversations ============ - -type CompletionConversationsParams = { - appId: string - params?: Partial -} - -export const useCompletionConversations = ({ appId, params }: CompletionConversationsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'completion-conversations', appId, params], - queryFn: () => - get(`/apps/${appId}/completion-conversations`, { params }), - enabled: !!appId, - }) -} - -// ============ Chat Conversation Detail ============ - -export const useChatConversationDetail = (appId?: string, conversationId?: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'chat-conversation-detail', appId, conversationId], - queryFn: () => - get( - `/apps/${appId}/chat-conversations/${conversationId}`, - ), - enabled: !!appId && !!conversationId, - }) -} - -// ============ Completion Conversation Detail ============ - -export const useCompletionConversationDetail = (appId?: string, conversationId?: string) => { - return useQuery({ - queryKey: [NAME_SPACE, 'completion-conversation-detail', appId, conversationId], - queryFn: () => - get( - `/apps/${appId}/completion-conversations/${conversationId}`, - ), - enabled: !!appId && !!conversationId, - }) -} - -// ============ Workflow Logs ============ - -type WorkflowLogsParams = { - appId: string - params?: Record -} - -export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => { - return useQuery({ - queryKey: [NAME_SPACE, 'workflow-logs', appId, params], - queryFn: () => get(`/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({ - queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId], - queryFn: () => get(`/workflow/${workflowRunId}/pause-details`), - enabled: enabled && !!workflowRunId, - }) -}