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 }) -}