From be521675479eb296049dd1fd05856d7a06163e01 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:38:23 +0800 Subject: [PATCH] refactor(web): migrate app log messages and agent logs to the console contract Chat messages, feedback, log annotations and agent log detail now call consoleClient directly. The log chat formatter and agent log modal read the generated response types; untyped JSON fields are narrowed where they enter the chat UI types. The agent log response schema now matches what the service returns: `tool_label` is a name or i18n labels, `tool_icon` a URL or emoji icon. The never-sent `meta.error` is no longer read, and a missing annotation account no longer crashes the log detail. Co-Authored-By: Claude Opus 5.5 --- api/controllers/console/app/agent.py | 4 +- oxlint-suppressions.json | 10 -- .../generated/api/console/apps/types.gen.ts | 13 +- .../generated/api/console/apps/zod.gen.ts | 4 +- .../app/log/__tests__/list-utils.spec.ts | 50 +++++- .../app/log/__tests__/list.spec.tsx | 36 ++-- web/app/components/app/log/list-utils.ts | 167 ++++++++++++------ web/app/components/app/log/list.tsx | 45 +++-- .../agent-log-modal/__tests__/detail.spec.tsx | 52 ++++-- .../agent-log-modal/__tests__/index.spec.tsx | 33 ++-- .../__tests__/iteration.spec.tsx | 4 + .../__tests__/tracing.spec.tsx | 11 +- .../base/agent-log-modal/detail.tsx | 37 ++-- .../base/agent-log-modal/index.stories.tsx | 2 +- .../base/agent-log-modal/tool-call.tsx | 15 +- web/models/log.ts | 124 +------------ web/service/log.ts | 55 +----- 17 files changed, 319 insertions(+), 343 deletions(-) diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 619d7da6371..3d213ab1fff 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -48,11 +48,11 @@ class AgentToolCallResponse(ResponseModel): error: str | None = None time_cost: float | int tool_name: str - tool_label: str + tool_label: str | dict[str, str] = Field(description="Tool name or its i18n labels keyed by locale") tool_input: dict[str, Any] tool_output: dict[str, Any] tool_parameters: dict[str, Any] - tool_icon: Any = Field(default=None) + tool_icon: str | dict[str, str] | None = Field(default=None, description="Icon URL or emoji icon") class AgentIterationLogResponse(ResponseModel): diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 85a29439506..0fe03d0b3d3 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -441,11 +441,6 @@ "count": 5 } }, - "web/app/components/app/log/__tests__/list-utils.spec.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "web/app/components/app/log/__tests__/list.spec.tsx": { "typescript/no-explicit-any": { "count": 9 @@ -3991,11 +3986,6 @@ "count": 3 } }, - "web/models/log.ts": { - "typescript/no-explicit-any": { - "count": 6 - } - }, "web/models/pipeline.ts": { "typescript/no-explicit-any": { "count": 6 diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 23d0600605b..b4a4a7b0947 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2762,11 +2762,20 @@ export type AgentToolCallResponse = { error?: string | null status: string time_cost: number | number - tool_icon?: unknown + tool_icon?: + | string + | { + [key: string]: string + } + | null tool_input: { [key: string]: unknown } - tool_label: string + tool_label: + | string + | { + [key: string]: string + } tool_name: string tool_output: { [key: string]: unknown diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 6fb39ce919d..56ad63267c0 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2846,9 +2846,9 @@ export const zAgentToolCallResponse = z.object({ error: z.string().nullish(), status: z.string(), time_cost: z.union([z.number(), z.int()]), - tool_icon: z.unknown().optional(), + tool_icon: z.union([z.string(), z.record(z.string(), z.string())]).nullish(), tool_input: z.record(z.string(), z.unknown()), - tool_label: z.string(), + tool_label: z.union([z.string(), z.record(z.string(), z.string())]), tool_name: z.string(), tool_output: z.record(z.string(), z.unknown()), tool_parameters: z.record(z.string(), z.unknown()), diff --git a/web/app/components/app/log/__tests__/list-utils.spec.ts b/web/app/components/app/log/__tests__/list-utils.spec.ts index 837b05db001..e27d04c2e47 100644 --- a/web/app/components/app/log/__tests__/list-utils.spec.ts +++ b/web/app/components/app/log/__tests__/list-utils.spec.ts @@ -1,3 +1,4 @@ +import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { IChatItem } from '@/app/components/base/chat/chat/type' import { applyAnnotationAdded, @@ -22,11 +23,30 @@ const createChatItems = (): IChatItem[] => { id: 'answer-2', content: 'reply', isAnswer: true, parentMessageId: 'question-2' }, ] as IChatItem[] +const createMessage = (overrides: Partial): MessageDetailResponse => ({ + id: 'message', + conversation_id: 'conversation', + inputs: {}, + query: '', + answer: '', + answer_tokens: 0, + message_tokens: 0, + message: [], + message_files: [], + agent_thoughts: [], + feedbacks: [], + metadata: {}, + from_source: 'console', + provider_response_latency: 0, + status: 'normal', + ...overrides, +}) + describe('log list utils', () => { it('should format chat messages into paired question and answer items', () => { const items = getFormattedChatList( [ - { + createMessage({ id: 'message-1', inputs: { query: 'hello' }, query: 'hello', @@ -36,11 +56,23 @@ describe('log list utils', () => { message_tokens: 2, message: [{ role: 'user', text: 'hello' }], message_files: [ - { belongs_to: 'assistant', id: 'file-1' }, - { belongs_to: 'user', id: 'file-2' }, + { + belongs_to: 'assistant', + id: 'file-1', + filename: '', + transfer_method: 'local_file', + type: 'image', + }, + { + belongs_to: 'user', + id: 'file-2', + filename: '', + transfer_method: 'local_file', + type: 'image', + }, ], - }, - ] as any, + }), + ], 'conversation-1', 'UTC', 'YYYY-MM-DD', @@ -67,7 +99,7 @@ describe('log list utils', () => { it('should preserve feedback and annotation hit history when formatting chat items', () => { const items = getFormattedChatList( [ - { + createMessage({ id: 'message-2', inputs: { default_input: 'fallback prompt' }, query: '', @@ -82,11 +114,11 @@ describe('log list utils', () => { ], annotation_hit_history: { annotation_id: 'annotation-1', - annotation_create_account: {}, + annotation_create_account: null, created_at: 123, }, - }, - ] as any, + }), + ], 'conversation-2', 'UTC', 'YYYY-MM-DD', diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index 403f7c70b5a..092979258ec 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,3 +1,4 @@ +import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { ReactNode } from 'react' import type { IChatItem } from '@/app/components/base/chat/chat/type' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -50,18 +51,15 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { } }) -vi.mock('@/service/log', () => ({ - fetchChatMessages: (...args: unknown[]) => mockFetchChatMessages(...args), - updateLogMessageFeedbacks: (...args: unknown[]) => mockUpdateLogMessageFeedbacks(...args), - updateLogMessageAnnotations: (...args: unknown[]) => mockUpdateLogMessageAnnotations(...args), -})) - vi.mock('@/service/console', async (importOriginal) => ({ ...(await importOriginal()), consoleClient: { apps: { byAppId: { + chatMessages: { get: (...args: unknown[]) => mockFetchChatMessages(...args) }, + feedbacks: { post: (...args: unknown[]) => mockUpdateLogMessageFeedbacks(...args) }, annotations: { + post: (...args: unknown[]) => mockUpdateLogMessageAnnotations(...args), byAnnotationId: { delete: (...args: unknown[]) => mockDelAnnotation(...args) }, }, }, @@ -302,8 +300,17 @@ const createCompletionLogs = () => ({ ], }) -const createChatMessage = (id: string, overrides: Record = {}) => ({ +const createChatMessage = ( + id: string, + overrides: Partial = {}, +): MessageDetailResponse => ({ id, + conversation_id: 'conversation-1', + agent_thoughts: [], + metadata: {}, + from_source: 'console', + provider_response_latency: 0, + status: 'normal', answer: `Assistant reply ${id}`, query: `Question ${id}`, created_at: 1710000000, @@ -315,7 +322,7 @@ const createChatMessage = (id: string, overrides: Record = {}) message_files: [], answer_tokens: 10, message_tokens: 5, - parent_message_id: undefined, + parent_message_id: null, ...overrides, }) @@ -498,11 +505,8 @@ describe('ConversationList', () => { await waitFor(() => { expect(mockFetchChatMessages).toHaveBeenCalledWith({ - url: '/apps/app-1/chat-messages', - params: { - conversation_id: 'conversation-1', - limit: 10, - }, + params: { app_id: 'app-1' }, + query: { conversation_id: 'conversation-1', limit: 10 }, }) }) @@ -517,7 +521,7 @@ describe('ConversationList', () => { await waitFor(() => { expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({ - url: '/apps/app-1/feedbacks', + params: { app_id: 'app-1' }, body: { message_id: 'message-1', rating: 'like', @@ -602,7 +606,7 @@ describe('ConversationList', () => { await waitFor(() => { expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({ - url: '/apps/app-1/feedbacks', + params: { app_id: 'app-1' }, body: { message_id: 'message-1', rating: 'like', @@ -706,7 +710,7 @@ describe('ConversationList', () => { annotation: { id: 'annotation-1', content: 'Annotated answer', - account: { name: 'Admin' }, + account: { id: 'account-1', name: 'Admin', email: 'admin@example.com' }, }, }), createChatMessage('message-2', { parent_message_id: 'message-1' }), diff --git a/web/app/components/app/log/list-utils.ts b/web/app/components/app/log/list-utils.ts index 63a3c6bcfd1..131bb39f172 100644 --- a/web/app/components/app/log/list-utils.ts +++ b/web/app/components/app/log/list-utils.ts @@ -1,6 +1,12 @@ +import type { MessageDetailResponse } from '@dify/contracts/api/console/apps/types.gen' import type { ChatItemInTree } from '../../base/chat/types' -import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/type' -import type { Annotation, ChatMessage } from '@/models/log' +import type { + CitationItem, + FeedbackType, + IChatItem, + ThoughtItem, +} from '@/app/components/base/chat/chat/type' +import type { Annotation } from '@/models/log' import type { FileResponse } from '@/types/workflow' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' @@ -10,6 +16,7 @@ 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' +import { TransferMethod } from '@/types/app' dayjs.extend(utc) dayjs.extend(timezone) @@ -41,94 +48,150 @@ const getUserInputVariable = (item: unknown) => { return typeof variable === 'string' ? variable : undefined } -const toFileResponse = (file: NonNullable[number]): FileResponse => ({ +type LogMessage = MessageDetailResponse + +const isTransferMethod = (value: string): value is TransferMethod => + (Object.values(TransferMethod) as string[]).includes(value) + +const toFileResponse = (file: LogMessage['message_files'][number]): FileResponse => ({ related_id: file.id ?? file.upload_file_id, extension: '', filename: '', size: 0, mime_type: '', - transfer_method: file.transfer_method, + transfer_method: isTransferMethod(file.transfer_method) + ? file.transfer_method + : TransferMethod.local_file, type: file.type, - url: file.url, - upload_file_id: file.upload_file_id, - remote_url: file.url, + url: file.url ?? '', + upload_file_id: file.upload_file_id ?? '', + remote_url: file.url ?? '', }) +const toText = (value: unknown) => (typeof value === 'string' && value ? value : undefined) + +// `inputs` values are untyped JSON in the console API response. +export const getStringInputs = (inputs: LogMessage['inputs']) => + Object.fromEntries( + Object.entries(inputs).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) + +const toFeedback = (feedback?: LogMessage['feedbacks'][number]): FeedbackType | undefined => + feedback?.rating === 'like' || feedback?.rating === 'dislike' + ? { rating: feedback.rating, content: feedback.content } + : undefined + +const toThoughtItems = (message: LogMessage): ThoughtItem[] => + message.agent_thoughts.map((thought) => ({ + id: thought.id, + tool: thought.tool ?? '', + thought: thought.thought ?? '', + answer: thought.answer ?? undefined, + tool_input: thought.tool_input ?? '', + // `tool_labels` is untyped JSON in the console API response. + tool_labels: isPlainObject(thought.tool_labels) + ? (thought.tool_labels as ThoughtItem['tool_labels']) + : undefined, + message_id: thought.message_id, + conversation_id: message.conversation_id, + observation: thought.observation ?? '', + position: thought.position, + files: thought.files, + })) + +// `message` and `metadata` are untyped JSON in the console API response. +const getPromptLog = (message: LogMessage) => + (Array.isArray(message.message) ? message.message : []) as NonNullable + +const getCitations = (message: LogMessage) => + isPlainObject(message.metadata) && Array.isArray(message.metadata.retriever_resources) + ? (message.metadata.retriever_resources as CitationItem[]) + : undefined + +const getAnnotation = (message: LogMessage): Annotation | undefined => { + if (message.annotation_hit_history) { + return { + id: message.annotation_hit_history.annotation_id, + authorName: message.annotation_hit_history.annotation_create_account?.name || 'N/A', + created_at: message.annotation_hit_history.created_at ?? undefined, + } + } + + if (message.annotation) { + const { account } = message.annotation + return { + id: message.annotation.id, + authorName: account?.name || 'N/A', + logAnnotation: { + id: message.annotation.id, + content: message.annotation.content, + account: { id: account?.id ?? '', name: account?.name ?? '', email: account?.email ?? '' }, + created_at: message.annotation.created_at ?? 0, + }, + created_at: 0, + } + } + + return undefined +} + export const getFormattedChatList = ( - messages: ChatMessage[], + messages: LogMessage[], conversationId: string, timezone: string, format: string, ) => { const newChatList: IChatItem[] = [] - messages.forEach((item: ChatMessage) => { - const questionFiles = item.message_files?.filter((file) => file.belongs_to === 'user') || [] + messages.forEach((item) => { + const files = getProcessedFilesFromResponse(item.message_files.map(toFileResponse)) + const questionFiles = getProcessedFilesFromResponse( + item.message_files.filter((file) => file.belongs_to === 'user').map(toFileResponse), + ) newChatList.push({ id: `question-${item.id}`, - content: item.inputs.query || item.inputs.default_input || item.query, + content: toText(item.inputs.query) || toText(item.inputs.default_input) || item.query, isAnswer: false, - message_files: getProcessedFilesFromResponse(questionFiles.map(toFileResponse)), + message_files: questionFiles, parentMessageId: item.parent_message_id || undefined, }) - const answerFiles = item.message_files?.filter((file) => file.belongs_to === 'assistant') || [] + const answerFiles = getProcessedFilesFromResponse( + item.message_files.filter((file) => file.belongs_to === 'assistant').map(toFileResponse), + ) + const promptLog = getPromptLog(item) newChatList.push({ id: item.id, content: item.answer, - agent_thoughts: addFileInfos( - item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, - item.message_files, + agent_thoughts: addFileInfos(sortAgentSorts(toThoughtItems(item)), files), + feedback: toFeedback(item.feedbacks.find((feedback) => feedback.from_source === 'user')), + adminFeedback: toFeedback( + item.feedbacks.find((feedback) => feedback.from_source === 'admin'), ), - feedback: item.feedbacks?.find((feedback) => feedback.from_source === 'user'), - adminFeedback: item.feedbacks?.find((feedback) => feedback.from_source === 'admin'), feedbackDisabled: false, isAnswer: true, - message_files: getProcessedFilesFromResponse(answerFiles.map(toFileResponse)), + message_files: answerFiles, log: [ - ...(item.message ?? []), - ...(item.message?.[item.message.length - 1]?.role !== 'assistant' - ? [ - { - role: 'assistant', - text: item.answer, - files: item.message_files?.filter((file) => file.belongs_to === 'assistant') || [], - }, - ] + ...promptLog, + ...(promptLog.at(-1)?.role !== 'assistant' + ? [{ role: 'assistant', text: item.answer, files: answerFiles }] : []), - ] as IChatItem['log'], - workflow_run_id: item.workflow_run_id, + ], + workflow_run_id: item.workflow_run_id ?? undefined, conversationId, input: { inputs: item.inputs, query: item.query, }, more: { - time: dayjs.unix(item.created_at).tz(timezone).format(format), + time: item.created_at ? dayjs.unix(item.created_at).tz(timezone).format(format) : '', tokens: item.answer_tokens + item.message_tokens, latency: (item.provider_response_latency ?? 0).toFixed(2), }, - citation: item.metadata?.retriever_resources, - annotation: (() => { - if (item.annotation_hit_history) { - return { - id: item.annotation_hit_history.annotation_id, - authorName: item.annotation_hit_history.annotation_create_account?.name || 'N/A', - created_at: item.annotation_hit_history.created_at, - } - } - - if (item.annotation) { - return { - id: item.annotation.id, - authorName: item.annotation.account.name, - logAnnotation: item.annotation, - created_at: 0, - } - } - - return undefined - })(), + citation: getCitations(item), + annotation: getAnnotation(item), parentMessageId: `question-${item.id}`, }) }) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index e84736da3b1..7f999174cb2 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -8,6 +8,7 @@ import type { ConversationPagination, ConversationWithSummary, ConversationWithSummaryPagination, + GetAppsByAppIdChatMessagesData, } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' import type { ChatItemInTree } from '../../base/chat/types' @@ -17,7 +18,7 @@ import type { IChatItem, SubmitAnnotationFunc, } from '@/app/components/base/chat/chat/type' -import type { ChatMessagesRequest, LogAnnotation } from '@/models/log' +import type { LogAnnotation } from '@/models/log' import { HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline' import { cn } from '@langgenius/dify-ui/cn' import { @@ -56,11 +57,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import { consoleClient, consoleQuery } from '@/service/console' -import { - fetchChatMessages, - updateLogMessageAnnotations, - updateLogMessageFeedbacks, -} from '@/service/log' import { AppSourceType } from '@/service/share' import { AppModeEnum } from '@/types/app' import PromptLogModal from '../../base/prompt-log-modal' @@ -75,6 +71,7 @@ import { getDetailVarList, getFormattedChatList, getLogModelInfo, + getStringInputs, getThreadChatItems, isNearTopLoadMore, mergePaginatedChatItems, @@ -205,22 +202,22 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { try { isLoadingRef.current = true - const params: ChatMessagesRequest = { + const params: GetAppsByAppIdChatMessagesData['query'] = { conversation_id: detail.id, limit: 10, } // Use ref for pagination anchor to avoid stale closure issues if (oldestAnswerIdRef.current) params.first_id = oldestAnswerIdRef.current - const messageRes = await fetchChatMessages({ - url: `/apps/${appDetail.id}/chat-messages`, - params, + const messageRes = await consoleClient.apps.byAppId.chatMessages.get({ + params: { app_id: appDetail.id }, + query: params, }) // Ignore stale responses if (currentRequestId !== requestIdRef.current || controller.signal.aborted) return if (messageRes.data.length > 0) { - const varValues = messageRes.data.at(-1)!.inputs + const varValues = getStringInputs(messageRes.data.at(-1)!.inputs) setVarValues(varValues) } setHasMore(messageRes.has_more) @@ -331,7 +328,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { setIsLoading(true) try { - const params: ChatMessagesRequest = { + const params: GetAppsByAppIdChatMessagesData['query'] = { conversation_id: detail.id, limit: 10, } @@ -341,9 +338,9 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { params.first_id = oldestAnswerIdRef.current } - const messageRes = await fetchChatMessages({ - url: `/apps/${appDetail.id}/chat-messages`, - params, + const messageRes = await consoleClient.apps.byAppId.chatMessages.get({ + params: { app_id: appDetail.id }, + query: params, }) if (!messageRes.data || messageRes.data.length === 0) { @@ -353,7 +350,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { } if (messageRes.data.length > 0) { - const varValues = messageRes.data.at(-1)!.inputs + const varValues = getStringInputs(messageRes.data.at(-1)!.inputs) setVarValues(varValues) } @@ -652,8 +649,8 @@ const CompletionConversationDetailComp: FC = ({ const { t } = useTranslation(['appLog', 'common']) const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => { - await updateLogMessageFeedbacks({ - url: `/apps/${appDetail.id}/feedbacks`, + await consoleClient.apps.byAppId.feedbacks.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, rating, content: content ?? undefined }, }) conversationDetailMutate() @@ -661,8 +658,8 @@ const CompletionConversationDetailComp: FC = ({ const handleAnnotation = async (mid: string, value: string): Promise => { try { - await updateLogMessageAnnotations({ - url: `/apps/${appDetail.id}/annotations`, + await consoleClient.apps.byAppId.annotations.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, content: value }, }) conversationDetailMutate() @@ -712,16 +709,16 @@ const ChatConversationDetailComp: FC = ({ const { t } = useTranslation(['appLog', 'common']) const handleFeedback = async (mid: string, { rating, content }: FeedbackType) => { - await updateLogMessageFeedbacks({ - url: `/apps/${appDetail.id}/feedbacks`, + await consoleClient.apps.byAppId.feedbacks.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, rating, content: content ?? undefined }, }) } const handleAnnotation = async (mid: string, value: string): Promise => { try { - await updateLogMessageAnnotations({ - url: `/apps/${appDetail.id}/annotations`, + await consoleClient.apps.byAppId.annotations.post({ + params: { app_id: appDetail.id }, body: { message_id: mid, content: value }, }) toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) diff --git a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx index eb677b3a458..ce8b091e4ee 100644 --- a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx @@ -3,7 +3,6 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { AgentLogDetailResponse } from '@/models/log' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { createHttpError } from '@/service/http-error' -import { fetchAgentLogDetail } from '@/service/log' import AgentLogDetail from '../detail' const { mockToast } = vi.hoisted(() => { @@ -19,10 +18,14 @@ const { mockToast } = vi.hoisted(() => { return { mockToast } }) -vi.mock('@/service/log', () => ({ +const { fetchAgentLogDetail } = vi.hoisted(() => ({ fetchAgentLogDetail: vi.fn(), })) +vi.mock('@/service/console', () => ({ + consoleClient: { apps: { byAppId: { agent: { logs: { get: fetchAgentLogDetail } } } } }, +})) + vi.mock('@/app/notifications', () => ({ toast: mockToast, })) @@ -100,6 +103,10 @@ const createMockResponse = ( tool_name: 'tool1', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 1' }, }, ], @@ -136,7 +143,7 @@ describe('AgentLogDetail', () => { describe('Rendering', () => { it('should show loading indicator while fetching data', async () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) renderComponent() @@ -144,7 +151,7 @@ describe('AgentLogDetail', () => { }) it('should display result panel after data loads', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -153,23 +160,20 @@ describe('AgentLogDetail', () => { }) it('should call fetchAgentLogDetail with correct params', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() expect(fetchAgentLogDetail).toHaveBeenCalledWith({ - appID: 'app-id', - params: { - conversation_id: 'conv-id', - message_id: 'msg-id', - }, + params: { app_id: 'app-id' }, + query: { conversation_id: 'conv-id', message_id: 'msg-id' }, }) }) }) describe('Props', () => { it('should default to DETAIL tab when activeTab is not provided', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -178,7 +182,7 @@ describe('AgentLogDetail', () => { }) it('should show TRACING tab when activeTab is TRACING', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData({ activeTab: 'TRACING' }) @@ -189,7 +193,7 @@ describe('AgentLogDetail', () => { describe('User Interactions', () => { it('should switch to TRACING tab when clicked', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -205,7 +209,7 @@ describe('AgentLogDetail', () => { }) it('should switch back to DETAIL tab after switching to TRACING', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) + fetchAgentLogDetail.mockResolvedValue(createMockResponse()) await renderAndWaitForData() @@ -226,7 +230,7 @@ describe('AgentLogDetail', () => { describe('Edge Cases', () => { it('should notify on API error', async () => { - vi.mocked(fetchAgentLogDetail).mockRejectedValue( + fetchAgentLogDetail.mockRejectedValue( createHttpError(new Response(null, { status: 500 }), { message: 'API Error' }), ) @@ -238,7 +242,7 @@ describe('AgentLogDetail', () => { }) it('should stop loading after API error', async () => { - vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('Network failure')) + fetchAgentLogDetail.mockRejectedValue(new Error('Network failure')) renderComponent() @@ -248,7 +252,7 @@ describe('AgentLogDetail', () => { }) it('should handle response with empty iterations', async () => { - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse({ iterations: [] })) + fetchAgentLogDetail.mockResolvedValue(createMockResponse({ iterations: [] })) await renderAndWaitForData() }) @@ -267,12 +271,20 @@ describe('AgentLogDetail', () => { tool_name: 'tool1', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 1' }, }, { tool_name: 'tool2', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 2' }, }, ], @@ -288,13 +300,17 @@ describe('AgentLogDetail', () => { tool_name: 'tool1', status: 'success', tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, tool_label: { 'en-US': 'Tool 1' }, }, ], }, ], }) - vi.mocked(fetchAgentLogDetail).mockResolvedValue(response) + fetchAgentLogDetail.mockResolvedValue(response) await renderAndWaitForData() diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index 28d814de454..c0816744d31 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useClickAway } from 'ahooks' -import { fetchAgentLogDetail } from '@/service/log' import AgentLogModal from '../index' const { mockToast } = vi.hoisted(() => { @@ -17,10 +16,14 @@ const { mockToast } = vi.hoisted(() => { return { mockToast } }) -vi.mock('@/service/log', () => ({ +const { fetchAgentLogDetail } = vi.hoisted(() => ({ fetchAgentLogDetail: vi.fn(), })) +vi.mock('@/service/console', () => ({ + consoleClient: { apps: { byAppId: { agent: { logs: { get: fetchAgentLogDetail } } } } }, +})) + vi.mock('@/app/notifications', () => ({ toast: mockToast, })) @@ -86,7 +89,7 @@ const mockProps = { describe('AgentLogModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(fetchAgentLogDetail).mockResolvedValue({ + fetchAgentLogDetail.mockResolvedValue({ meta: { status: 'succeeded', executor: 'User', @@ -146,7 +149,7 @@ describe('AgentLogModal', () => { }) it('should render the floating modal through a dialog portal', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) const { container } = render() @@ -157,7 +160,7 @@ describe('AgentLogModal', () => { }) it('should call onCancel when close button is clicked', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) render() @@ -168,7 +171,7 @@ describe('AgentLogModal', () => { }) it('should call onCancel when clicking away', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) let clickAwayHandler!: (event: Event) => void vi.mocked(useClickAway).mockImplementation((callback) => { @@ -182,7 +185,7 @@ describe('AgentLogModal', () => { }) it('should ignore click-away before mounted state is set', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) let invoked = false vi.mocked(useClickAway).mockImplementation((callback) => { if (!invoked) { @@ -197,7 +200,7 @@ describe('AgentLogModal', () => { }) it('should not use click-away to close the floating dialog', () => { - vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) + fetchAgentLogDetail.mockReturnValue(new Promise(() => {})) let clickAwayHandler!: (event: Event) => void vi.mocked(useClickAway).mockImplementation((callback) => { @@ -228,15 +231,13 @@ describe('AgentLogModal', () => { const pending = new Promise>>((resolve) => { resolveOldResponse = resolve }) - vi.mocked(fetchAgentLogDetail) - .mockResolvedValue(response) - .mockImplementationOnce(() => pending) + fetchAgentLogDetail.mockResolvedValue(response).mockImplementationOnce(() => pending) const { rerender } = render() rerender() await screen.findByText('New executor') expect(fetchAgentLogDetail).toHaveBeenLastCalledWith({ - appID: 'new-app', - params: { conversation_id: mockLog.conversationId, message_id: mockLog.id }, + params: { app_id: 'new-app' }, + query: { conversation_id: mockLog.conversationId, message_id: mockLog.id }, }) await act(async () => { resolveOldResponse({ ...response, meta: { ...response.meta, executor: 'Old executor' } }) @@ -263,8 +264,8 @@ describe('AgentLogModal', () => { expect(screen.getByRole('progressbar')).toBeInTheDocument() await waitFor(() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()) expect(fetchAgentLogDetail).toHaveBeenLastCalledWith({ - appID: 'app-id', - params: { conversation_id: mockLog.conversationId, message_id: 'next-message' }, + params: { app_id: 'app-id' }, + query: { conversation_id: mockLog.conversationId, message_id: 'next-message' }, }) }) @@ -273,7 +274,7 @@ describe('AgentLogModal', () => { const pending = new Promise>>((_, reject) => { rejectOldResponse = reject }) - vi.mocked(fetchAgentLogDetail).mockReturnValueOnce(pending) + fetchAgentLogDetail.mockReturnValueOnce(pending) const { rerender } = render() rerender() await screen.findByText('User') diff --git a/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx index f124d4856a6..3e841d76363 100644 --- a/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/iteration.spec.tsx @@ -26,6 +26,10 @@ const mockIterationInfo: AgentIteration = { tool_name: 'test_tool', tool_label: { en: 'Test Tool', 'en-US': 'Test Tool' }, tool_icon: null, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, }, ], tool_raw: { diff --git a/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx index c38caef3861..4d744ebf256 100644 --- a/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/tracing.spec.tsx @@ -22,7 +22,16 @@ const createIteration = (thought: string, tokens: number): AgentIteration => ({ thought, tokens, tool_calls: [ - { tool_name: 'tool1', status: 'success', tool_icon: null, tool_label: { 'en-US': 'Tool 1' } }, + { + tool_name: 'tool1', + status: 'success', + tool_icon: null, + tool_label: { 'en-US': 'Tool 1' }, + time_cost: 0, + tool_input: {}, + tool_output: {}, + tool_parameters: {}, + }, ], tool_raw: { inputs: '', outputs: '' }, }) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index 2db2606eddb..8c078660d6e 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -9,7 +9,7 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { toast } from '@/app/notifications' -import { fetchAgentLogDetail } from '@/service/log' +import { consoleClient } from '@/service/console' import ResultPanel from './result' import TracingPanel from './tracing' @@ -94,21 +94,23 @@ function AgentLogContent({ useEffect(() => { let active = true - fetchAgentLogDetail({ - appID: appId, - params: { conversation_id: conversationID, message_id: messageID }, - }).then( - (data) => { - if (!active) return - setRunDetail(data) - setLoading(false) - }, - (error) => { - if (!active) return - toast.error(error instanceof Error ? error.message : String(error)) - setLoading(false) - }, - ) + consoleClient.apps.byAppId.agent.logs + .get({ + params: { app_id: appId }, + query: { conversation_id: conversationID, message_id: messageID }, + }) + .then( + (data) => { + if (!active) return + setRunDetail(data) + setLoading(false) + }, + (error) => { + if (!active) return + toast.error(error instanceof Error ? error.message : String(error)) + setLoading(false) + }, + ) return () => { active = false } @@ -131,8 +133,7 @@ function AgentLogContent({ inputs={log.input} outputs={log.content} status={runDetail.meta.status} - error={runDetail.meta.error} - elapsed_time={runDetail.meta.elapsed_time} + elapsed_time={runDetail.meta.elapsed_time ?? undefined} total_tokens={runDetail.meta.total_tokens} created_at={runDetail.meta.start_time} created_by={runDetail.meta.executor} diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx index fbf8874471c..21c2779a261 100644 --- a/web/app/components/base/agent-log-modal/index.stories.tsx +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -14,7 +14,6 @@ const MOCK_RESPONSE: AgentLogDetailResponse = { total_tokens: 2589, agent_mode: 'ReACT', iterations: 2, - error: undefined, }, iterations: [ { @@ -33,6 +32,7 @@ const MOCK_RESPONSE: AgentLogDetailResponse = { 'en-US': 'Revenue Search', }, time_cost: 1.8, + tool_parameters: {}, }, ], tool_raw: { diff --git a/web/app/components/base/agent-log-modal/tool-call.tsx b/web/app/components/base/agent-log-modal/tool-call.tsx index 04d354289a1..1aab26d4e91 100644 --- a/web/app/components/base/agent-log-modal/tool-call.tsx +++ b/web/app/components/base/agent-log-modal/tool-call.tsx @@ -10,8 +10,15 @@ import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/ import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { BlockEnum } from '@/app/components/workflow/types' +const getToolIcon = (icon: ToolCall['tool_icon']) => { + if (typeof icon === 'string') return icon + if (icon?.content !== undefined && icon.background !== undefined) + return { content: icon.content, background: icon.background } + return undefined +} + type Props = Readonly<{ - toolCall: ToolCall + toolCall: Partial & Pick isLLM: boolean isFinal?: boolean tokens?: number @@ -31,7 +38,9 @@ const ToolCallItem: FC = ({ const locale = useLocale() const toolName = isLLM ? 'LLM' - : toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')] + : typeof toolCall.tool_label === 'string' + ? toolCall.tool_label + : toolCall.tool_label?.[locale] || toolCall.tool_label?.[locale.replaceAll('-', '_')] const getTime = (time: number) => { if (time < 1) return `${(time * 1000).toFixed(3)} ms` @@ -73,7 +82,7 @@ const ToolCallItem: FC = ({
- message: { role: string; text: string; files?: VisionFile[] }[] - message_tokens: number - answer_tokens: number - answer: string - provider_response_latency: number - created_at: number - annotation: LogAnnotation - annotation_hit_history: { - annotation_id: string - annotation_create_account: { - id: string - name: string - email: string - } - created_at: number - } - feedbacks: Array<{ - rating: 'like' | 'dislike' | null - content: string | null - from_source?: 'admin' | 'user' - from_end_user_id?: string - }> - message_files: VisionFile[] - metadata: Metadata - agent_thoughts: ThoughtItem[] - workflow_run_id: string - parent_message_id: string | null -} - -export type ChatMessagesRequest = { - conversation_id: string - first_id?: string - limit: number -} -export type ChatMessage = MessageContent - -export type ChatMessagesResponse = { - data: Array - has_more: boolean - limit: number -} - export type MessageRating = 'like' | 'dislike' | null -export type LogMessageFeedbacksRequest = { - message_id: string - rating: MessageRating - content?: string -} - -export type LogMessageFeedbacksResponse = { - result: 'success' | 'error' -} - -export type LogMessageAnnotationsRequest = Omit - -export type LogMessageAnnotationsResponse = LogMessageFeedbacksResponse - export const WorkflowRunTriggeredFrom = { DEBUGGING: 'debugging', APP_RUN: 'app-run', @@ -159,56 +101,6 @@ export type WorkflowRunDetailResponse = { exceptions_count?: number } -type AgentLogMeta = { - status: string - executor: string - start_time: string - elapsed_time: number - total_tokens: number - agent_mode: string - iterations: number - error?: string -} - -export type ToolCall = { - status: string - error?: string | null - time_cost?: number - tool_icon: any - tool_input?: any - tool_output?: any - tool_name?: string - tool_label?: any - tool_parameters?: any -} - -export type AgentIteration = { - created_at: string - files: string[] - thought: string - tokens: number - tool_calls: ToolCall[] - tool_raw: { - inputs: string - outputs: string - } -} - -type AgentLogFile = { - id: string - type: string - url: string - name: string - belongs_to: string -} - -export type AgentLogDetailRequest = { - conversation_id: string - message_id: string -} - -export type AgentLogDetailResponse = { - meta: AgentLogMeta - iterations: AgentIteration[] - files: AgentLogFile[] -} +export type AgentLogDetailResponse = AgentLogResponse +export type AgentIteration = AgentIterationLogResponse +export type ToolCall = AgentToolCallResponse diff --git a/web/service/log.ts b/web/service/log.ts index 32fdf191384..1595b548923 100644 --- a/web/service/log.ts +++ b/web/service/log.ts @@ -1,47 +1,6 @@ -import type { - AgentLogDetailRequest, - AgentLogDetailResponse, - ChatMessagesRequest, - ChatMessagesResponse, - LogMessageAnnotationsRequest, - LogMessageAnnotationsResponse, - LogMessageFeedbacksRequest, - LogMessageFeedbacksResponse, - WorkflowRunDetailResponse, -} from '@/models/log' +import type { WorkflowRunDetailResponse } from '@/models/log' import type { NodeTracingListResponse } from '@/types/workflow' -import { get, post } from './base' - -// (Chat Application) Message list in one session -export const fetchChatMessages = ({ - url, - params, -}: { - url: string - params: ChatMessagesRequest -}): Promise => { - return get(url, { params }) -} - -export const updateLogMessageFeedbacks = ({ - url, - body, -}: { - url: string - body: LogMessageFeedbacksRequest -}): Promise => { - return post(url, { body }) -} - -export const updateLogMessageAnnotations = ({ - url, - body, -}: { - url: string - body: LogMessageAnnotationsRequest -}): Promise => { - return post(url, { body }) -} +import { get } from './base' export const fetchRunDetail = (url: string): Promise => { return get(url) @@ -50,13 +9,3 @@ export const fetchRunDetail = (url: string): Promise export const fetchTracingList = ({ url }: { url: string }): Promise => { return get(url) } - -export const fetchAgentLogDetail = ({ - appID, - params, -}: { - appID: string - params: AgentLogDetailRequest -}): Promise => { - return get(`/apps/${appID}/agent/logs`, { params }) -}