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 <noreply@anthropic.com>
This commit is contained in:
Stephen Zhou
2026-10-15 22:38:42 +08:00
co-authored by Claude Opus 5.5
parent bfe2915439
commit be52167547
17 changed files with 319 additions and 343 deletions
+2 -2
View File
@@ -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):
-10
View File
@@ -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
@@ -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
@@ -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()),
@@ -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>): 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',
@@ -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<typeof import('@/service/console')>()),
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<string, unknown> = {}) => ({
const createChatMessage = (
id: string,
overrides: Partial<MessageDetailResponse> = {},
): 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<string, unknown> = {})
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' }),
+115 -52
View File
@@ -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<ChatMessage['message_files']>[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<IChatItem['log']>
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}`,
})
})
+21 -24
View File
@@ -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<ConversationDetailProps> = ({
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<ConversationDetailProps> = ({
const handleAnnotation = async (mid: string, value: string): Promise<boolean> => {
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<ConversationDetailProps> = ({
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<boolean> => {
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' }))
@@ -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()
@@ -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(<AgentLogModal appId="app-id" {...mockProps} floating />)
@@ -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(<AgentLogModal appId="app-id" {...mockProps} />)
@@ -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<Awaited<ReturnType<typeof fetchAgentLogDetail>>>((resolve) => {
resolveOldResponse = resolve
})
vi.mocked(fetchAgentLogDetail)
.mockResolvedValue(response)
.mockImplementationOnce(() => pending)
fetchAgentLogDetail.mockResolvedValue(response).mockImplementationOnce(() => pending)
const { rerender } = render(<AgentLogModal appId="old-app" {...mockProps} />)
rerender(<AgentLogModal appId="new-app" {...mockProps} />)
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<Awaited<ReturnType<typeof fetchAgentLogDetail>>>((_, reject) => {
rejectOldResponse = reject
})
vi.mocked(fetchAgentLogDetail).mockReturnValueOnce(pending)
fetchAgentLogDetail.mockReturnValueOnce(pending)
const { rerender } = render(<AgentLogModal appId="old-app" {...mockProps} />)
rerender(<AgentLogModal appId="new-app" {...mockProps} />)
await screen.findByText('User')
@@ -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: {
@@ -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: '' },
})
@@ -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}
@@ -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: {
@@ -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<ToolCall> & Pick<ToolCall, 'status'>
isLLM: boolean
isFinal?: boolean
tokens?: number
@@ -31,7 +38,9 @@ const ToolCallItem: FC<Props> = ({
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<Props> = ({
<BlockIcon
className={cn('mr-2 shrink-0')}
type={isLLM ? BlockEnum.LLM : BlockEnum.Tool}
toolIcon={toolCall.tool_icon}
toolIcon={getToolIcon(toolCall.tool_icon)}
/>
<div
className={cn('grow truncate text-[13px] leading-4 font-semibold text-text-secondary')}
+8 -116
View File
@@ -1,7 +1,10 @@
import type {
AgentIterationLogResponse,
AgentLogResponse,
AgentToolCallResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { Viewport } from 'reactflow'
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'
export type LogAnnotation = {
id: string
@@ -21,69 +24,8 @@ export type Annotation = {
created_at?: number
}
type MessageContent = {
id: string
conversation_id: string
query: string
inputs: Record<string, any>
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<ChatMessage>
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<LogMessageFeedbacksRequest, 'rating'>
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
+2 -53
View File
@@ -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<ChatMessagesResponse> => {
return get<ChatMessagesResponse>(url, { params })
}
export const updateLogMessageFeedbacks = ({
url,
body,
}: {
url: string
body: LogMessageFeedbacksRequest
}): Promise<LogMessageFeedbacksResponse> => {
return post<LogMessageFeedbacksResponse>(url, { body })
}
export const updateLogMessageAnnotations = ({
url,
body,
}: {
url: string
body: LogMessageAnnotationsRequest
}): Promise<LogMessageAnnotationsResponse> => {
return post<LogMessageAnnotationsResponse>(url, { body })
}
import { get } from './base'
export const fetchRunDetail = (url: string): Promise<WorkflowRunDetailResponse> => {
return get<WorkflowRunDetailResponse>(url)
@@ -50,13 +9,3 @@ export const fetchRunDetail = (url: string): Promise<WorkflowRunDetailResponse>
export const fetchTracingList = ({ url }: { url: string }): Promise<NodeTracingListResponse> => {
return get<NodeTracingListResponse>(url)
}
export const fetchAgentLogDetail = ({
appID,
params,
}: {
appID: string
params: AgentLogDetailRequest
}): Promise<AgentLogDetailResponse> => {
return get<AgentLogDetailResponse>(`/apps/${appID}/agent/logs`, { params })
}