From ed28675599d57ac7c3b749d61609384aa768dfda Mon Sep 17 00:00:00 2001 From: Haohao-end <2227625024@qq.com> Date: Sat, 14 Mar 2026 12:54:01 +0800 Subject: [PATCH 1/3] fix(web): normalize agent thought files before rendering attachments --- .../tools/tool-data-processing.test.ts | 21 +++- .../answer/__tests__/agent-content.spec.tsx | 13 +-- .../base/chat/chat/answer/agent-content.tsx | 5 +- .../tools/utils/__tests__/index.spec.ts | 74 +++++++++++- web/app/components/tools/utils/index.ts | 108 +++++++++++++++++- 5 files changed, 204 insertions(+), 17 deletions(-) diff --git a/web/__tests__/tools/tool-data-processing.test.ts b/web/__tests__/tools/tool-data-processing.test.ts index 120461201f..f72cef79e5 100644 --- a/web/__tests__/tools/tool-data-processing.test.ts +++ b/web/__tests__/tools/tool-data-processing.test.ts @@ -19,6 +19,7 @@ import { toType, triggerEventParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' +import { TransferMethod } from '@/types/app' describe('Tool Data Processing Pipeline Integration', () => { describe('End-to-end: API schema → form schema → form value', () => { @@ -184,8 +185,24 @@ describe('Tool Data Processing Pipeline Integration', () => { ] as Parameters[0] const messageFiles = [ - { id: 'f1', name: 'result.txt', type: 'document' }, - { id: 'f2', name: 'summary.pdf', type: 'document' }, + { + id: 'f1', + name: 'result.txt', + size: 0, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.remote_url, + supportFileType: 'document', + }, + { + id: 'f2', + name: 'summary.pdf', + size: 0, + type: 'application/pdf', + progress: 100, + transferMethod: TransferMethod.remote_url, + supportFileType: 'document', + }, ] as Parameters[1] const sorted = sortAgentSorts(thoughts) diff --git a/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx index 66d7bc9301..845c3dff9f 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/agent-content.spec.tsx @@ -23,7 +23,7 @@ vi.mock('@/app/components/base/chat/chat/thought', () => ({ ), })) -// Mock FileList and Utils +// Mock FileList vi.mock('@/app/components/base/file-uploader', () => ({ FileList: ({ files }: { files: FileEntity[] }) => (
@@ -32,10 +32,6 @@ vi.mock('@/app/components/base/file-uploader', () => ({ ), })) -vi.mock('@/app/components/base/file-uploader/utils', () => ({ - getProcessedFilesFromResponse: (files: FileEntity[]) => files.map(f => ({ ...f, name: `processed-${f.id}` })), -})) - describe('AgentContent', () => { const mockItem: ChatItem = { id: '1', @@ -119,12 +115,15 @@ describe('AgentContent', () => { agent_thoughts: [ { thought: 'T1', - message_files: [{ id: 'file1' }, { id: 'file2' }], + message_files: [ + { id: 'file1', name: 'image-1.png' }, + { id: 'file2', name: 'image-2.png' }, + ], }, ], } render() - expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2') + expect(screen.getByTestId('file-list-component')).toHaveTextContent('image-1.png, image-2.png') }) it('renders nothing if no annotation, content, or thoughts', () => { diff --git a/web/app/components/base/chat/chat/answer/agent-content.tsx b/web/app/components/base/chat/chat/answer/agent-content.tsx index 579c1836e9..376b11d395 100644 --- a/web/app/components/base/chat/chat/answer/agent-content.tsx +++ b/web/app/components/base/chat/chat/answer/agent-content.tsx @@ -5,7 +5,6 @@ import type { import { memo } from 'react' import Thought from '@/app/components/base/chat/chat/thought' import { FileList } from '@/app/components/base/file-uploader' -import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { Markdown } from '@/app/components/base/markdown' type AgentContentProps = { @@ -40,7 +39,7 @@ const AgentContent: FC = ({ data-testid="agent-content-markdown" /> ) : agent_thoughts?.map((thought, index) => ( -
+
{thought.thought && ( = ({ { !!thought.message_files?.length && ( ({ ...item, related_id: item.id })))} + files={thought.message_files} showDeleteAction={false} showDownloadAction={true} canPreview={true} diff --git a/web/app/components/tools/utils/__tests__/index.spec.ts b/web/app/components/tools/utils/__tests__/index.spec.ts index 829846bc86..17af0d7ae8 100644 --- a/web/app/components/tools/utils/__tests__/index.spec.ts +++ b/web/app/components/tools/utils/__tests__/index.spec.ts @@ -1,9 +1,23 @@ import type { ThoughtItem } from '@/app/components/base/chat/chat/type' import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { VisionFile } from '@/types/app' +import type { FileResponse } from '@/types/workflow' import { describe, expect, it } from 'vitest' +import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '../index' describe('tools/utils', () => { + const createFileEntity = (overrides: Partial): FileEntity => ({ + id: 'file-1', + name: 'file.txt', + size: 0, + type: 'text/plain', + progress: 100, + transferMethod: TransferMethod.remote_url, + supportFileType: 'document', + ...overrides, + }) + describe('sortAgentSorts', () => { it('returns null/undefined input as-is', () => { expect(sortAgentSorts(null as unknown as ThoughtItem[])).toBeNull() @@ -52,8 +66,8 @@ describe('tools/utils', () => { }) it('adds message_files by matching file IDs', () => { - const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity - const file2 = { id: 'file-2', name: 'img.png' } as FileEntity + const file1 = createFileEntity({ id: 'file-1', name: 'doc.pdf', type: 'application/pdf' }) + const file2 = createFileEntity({ id: 'file-2', name: 'img.png', type: 'image/png', supportFileType: 'image' }) const items = [ { id: '1', files: ['file-1', 'file-2'] }, { id: '2', files: [] }, @@ -63,6 +77,60 @@ describe('tools/utils', () => { expect((result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files).toEqual([file1, file2]) }) + it('normalizes backend agent file payloads into FileEntity objects', () => { + const rawFile = { + id: 'file-1', + related_id: 'tool-file-1', + extension: '.png', + filename: 'generated.png', + size: 128, + mime_type: 'image/png', + transfer_method: TransferMethod.remote_url, + type: 'image', + url: 'https://example.com/generated.png', + upload_file_id: 'tool-file-1', + remote_url: '', + } satisfies FileResponse & { id: string } + + const items = [{ id: '1', files: ['file-1'] }] as unknown as ThoughtItem[] + + const result = addFileInfos(items, [rawFile]) + const messageFiles = (result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files + + expect(messageFiles).toHaveLength(1) + expect(messageFiles[0]).toEqual(expect.objectContaining({ + id: 'file-1', + name: 'generated.png', + type: 'image/png', + supportFileType: 'image', + })) + }) + + it('normalizes VisionFile payloads into FileEntity objects', () => { + const visionFile: VisionFile = { + id: 'file-vision-1', + type: 'image', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/generated.png?signature=1', + upload_file_id: 'upload-vision-1', + } + + const items = [{ id: '1', files: ['file-vision-1'] }] as unknown as ThoughtItem[] + + const result = addFileInfos(items, [visionFile]) + const messageFiles = (result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files + + expect(messageFiles).toHaveLength(1) + expect(messageFiles[0]).toEqual(expect.objectContaining({ + id: 'file-vision-1', + name: 'generated.png', + type: 'image/png', + transferMethod: TransferMethod.remote_url, + supportFileType: 'image', + uploadedId: 'upload-vision-1', + })) + }) + it('returns items without files unchanged', () => { const items = [ { id: '1' }, @@ -73,7 +141,7 @@ describe('tools/utils', () => { }) it('does not mutate original items', () => { - const file1 = { id: 'file-1', name: 'doc.pdf' } as FileEntity + const file1 = createFileEntity({ id: 'file-1', name: 'doc.pdf', type: 'application/pdf' }) const items = [{ id: '1', files: ['file-1'] }] as unknown as ThoughtItem[] const result = addFileInfos(items, [file1]) expect(result[0]).not.toBe(items[0]) diff --git a/web/app/components/tools/utils/index.ts b/web/app/components/tools/utils/index.ts index 4db5ae9081..d8071dede4 100644 --- a/web/app/components/tools/utils/index.ts +++ b/web/app/components/tools/utils/index.ts @@ -1,6 +1,8 @@ import type { ThoughtItem } from '@/app/components/base/chat/chat/type' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { VisionFile } from '@/types/app' +import type { FileResponse } from '@/types/workflow' +import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { ToolTypeEnum } from '../../workflow/block-selector/types' export const getToolType = (type: string) => { @@ -28,14 +30,116 @@ export const sortAgentSorts = (list: ThoughtItem[]) => { return temp } -export const addFileInfos = (list: ThoughtItem[], messageFiles: (FileEntity | VisionFile)[]) => { +type AgentThoughtHistoryFile = FileResponse & { id: string } +type AgentThoughtMessageFile = FileEntity | VisionFile | AgentThoughtHistoryFile + +const isFileEntity = (file: AgentThoughtMessageFile): file is FileEntity => { + return 'transferMethod' in file +} + +const isAgentThoughtHistoryFile = (file: AgentThoughtMessageFile): file is AgentThoughtHistoryFile => { + return 'filename' in file && 'mime_type' in file +} + +const getVisionFileMimeType = (fileType: string) => { + if (fileType.includes('/')) + return fileType + + switch (fileType) { + case 'image': + return 'image/png' + case 'video': + return 'video/mp4' + case 'audio': + return 'audio/mpeg' + default: + return 'application/octet-stream' + } +} + +const getVisionFileSupportType = (fileType: string) => { + if (fileType.includes('/')) { + const [mainType, subType] = fileType.split('/') + if (mainType === 'image') + return 'image' + if (mainType === 'video') + return 'video' + if (mainType === 'audio') + return 'audio' + if (subType === 'pdf') + return 'document' + return 'document' + } + + switch (fileType) { + case 'image': + return 'image' + case 'video': + return 'video' + case 'audio': + return 'audio' + case 'document': + return 'document' + default: + return 'document' + } +} + +const getVisionFileName = (url: string, supportFileType: string) => { + const fileName = url.split('/').pop()?.split('?')[0] + if (fileName) + return fileName + + switch (supportFileType) { + case 'image': + return 'generated_image.png' + case 'video': + return 'generated_video.mp4' + case 'audio': + return 'generated_audio.mp3' + default: + return 'generated_file.bin' + } +} + +const normalizeAgentThoughtMessageFile = (file: AgentThoughtMessageFile): FileEntity => { + if (isFileEntity(file)) + return file + + if (!isAgentThoughtHistoryFile(file)) { + const supportFileType = getVisionFileSupportType(file.type) + return { + id: file.id || file.upload_file_id, + name: getVisionFileName(file.url, supportFileType), + size: 0, + type: getVisionFileMimeType(file.type), + progress: 100, + transferMethod: file.transfer_method, + supportFileType, + uploadedId: file.upload_file_id, + url: file.url, + } + } + + return getProcessedFilesFromResponse([{ + ...file, + related_id: file.id, + }])[0] +} + +export const addFileInfos = (list: ThoughtItem[], messageFiles: AgentThoughtMessageFile[]) => { if (!list || !messageFiles) return list return list.map((item) => { if (item.files && item.files?.length > 0) { + const matchedFiles = item.files + .map(fileId => messageFiles.find(file => (file.id || ('upload_file_id' in file ? file.upload_file_id : '')) === fileId)) + .filter((file): file is AgentThoughtMessageFile => Boolean(file)) + .map(normalizeAgentThoughtMessageFile) + return { ...item, - message_files: item.files.map(fileId => messageFiles.find(file => file.id === fileId)) as FileEntity[], + message_files: matchedFiles, } } return item From c7256e6891c70595532b7e66e1cf43124b6ce2c3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:58:27 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- web/eslint-suppressions.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 141e3d6983..f3dda78d80 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1645,9 +1645,6 @@ "app/components/base/chat/chat/answer/agent-content.tsx": { "style/multiline-ternary": { "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 } }, "app/components/base/chat/chat/answer/human-input-content/utils.ts": { From 5be09d8df19a4bb951e8feee36ec9344135cbfc2 Mon Sep 17 00:00:00 2001 From: Haohao-end <2227625024@qq.com> Date: Sat, 14 Mar 2026 14:31:46 +0800 Subject: [PATCH 3/3] test(web): cover VisionFile normalization branches --- .../tools/utils/__tests__/index.spec.ts | 72 ++++++++++++++++++- web/app/components/tools/utils/index.ts | 25 +++---- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/web/app/components/tools/utils/__tests__/index.spec.ts b/web/app/components/tools/utils/__tests__/index.spec.ts index 17af0d7ae8..94aa9192ad 100644 --- a/web/app/components/tools/utils/__tests__/index.spec.ts +++ b/web/app/components/tools/utils/__tests__/index.spec.ts @@ -4,7 +4,13 @@ import type { VisionFile } from '@/types/app' import type { FileResponse } from '@/types/workflow' import { describe, expect, it } from 'vitest' import { TransferMethod } from '@/types/app' -import { addFileInfos, sortAgentSorts } from '../index' +import { + addFileInfos, + getVisionFileMimeType, + getVisionFileName, + getVisionFileSupportType, + sortAgentSorts, +} from '../index' describe('tools/utils', () => { const createFileEntity = (overrides: Partial): FileEntity => ({ @@ -54,6 +60,46 @@ describe('tools/utils', () => { }) }) + describe('VisionFile helpers', () => { + it.each([ + ['image', 'image/png'], + ['video', 'video/mp4'], + ['audio', 'audio/mpeg'], + ['application/pdf', 'application/pdf'], + ['document', 'application/octet-stream'], + ['unknown', 'application/octet-stream'], + ])('returns %s mime type as %s', (fileType, expectedMimeType) => { + expect(getVisionFileMimeType(fileType)).toBe(expectedMimeType) + }) + + it.each([ + ['image', 'image'], + ['video', 'video'], + ['audio', 'audio'], + ['document', 'document'], + ['image/png', 'image'], + ['video/mp4', 'video'], + ['audio/mpeg', 'audio'], + ['application/pdf', 'document'], + ['application/json', 'document'], + ] as const)('returns %s support type as %s', (fileType, expectedSupportType) => { + expect(getVisionFileSupportType(fileType)).toBe(expectedSupportType) + }) + + it('extracts the file name from URLs with query params', () => { + expect(getVisionFileName('https://example.com/generated.png?signature=1', 'image')).toBe('generated.png') + }) + + it.each([ + ['image', 'generated_image.png'], + ['video', 'generated_video.mp4'], + ['audio', 'generated_audio.mp3'], + ['document', 'generated_file.bin'], + ] as const)('returns a fallback file name for %s URLs without a file segment', (supportFileType, expectedFileName) => { + expect(getVisionFileName('https://example.com/', supportFileType)).toBe(expectedFileName) + }) + }) + describe('addFileInfos', () => { it('returns null/undefined input as-is', () => { expect(addFileInfos(null as unknown as ThoughtItem[], [])).toBeNull() @@ -131,6 +177,30 @@ describe('tools/utils', () => { })) }) + it('matches VisionFile payloads by upload_file_id when id is missing', () => { + const visionFile: VisionFile = { + type: 'document', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/', + upload_file_id: 'upload-vision-fallback', + } + + const items = [{ id: '1', files: ['upload-vision-fallback'] }] as unknown as ThoughtItem[] + + const result = addFileInfos(items, [visionFile]) + const messageFiles = (result[0] as ThoughtItem & { message_files: FileEntity[] }).message_files + + expect(messageFiles).toHaveLength(1) + expect(messageFiles[0]).toEqual(expect.objectContaining({ + id: 'upload-vision-fallback', + name: 'generated_file.bin', + type: 'application/octet-stream', + transferMethod: TransferMethod.remote_url, + supportFileType: 'document', + uploadedId: 'upload-vision-fallback', + })) + }) + it('returns items without files unchanged', () => { const items = [ { id: '1' }, diff --git a/web/app/components/tools/utils/index.ts b/web/app/components/tools/utils/index.ts index d8071dede4..76df49bd0a 100644 --- a/web/app/components/tools/utils/index.ts +++ b/web/app/components/tools/utils/index.ts @@ -32,6 +32,7 @@ export const sortAgentSorts = (list: ThoughtItem[]) => { type AgentThoughtHistoryFile = FileResponse & { id: string } type AgentThoughtMessageFile = FileEntity | VisionFile | AgentThoughtHistoryFile +type VisionFileSupportType = 'image' | 'video' | 'audio' | 'document' const isFileEntity = (file: AgentThoughtMessageFile): file is FileEntity => { return 'transferMethod' in file @@ -41,7 +42,7 @@ const isAgentThoughtHistoryFile = (file: AgentThoughtMessageFile): file is Agent return 'filename' in file && 'mime_type' in file } -const getVisionFileMimeType = (fileType: string) => { +export const getVisionFileMimeType = (fileType: string) => { if (fileType.includes('/')) return fileType @@ -57,35 +58,25 @@ const getVisionFileMimeType = (fileType: string) => { } } -const getVisionFileSupportType = (fileType: string) => { +export const getVisionFileSupportType = (fileType: string): VisionFileSupportType => { if (fileType.includes('/')) { - const [mainType, subType] = fileType.split('/') - if (mainType === 'image') - return 'image' - if (mainType === 'video') - return 'video' - if (mainType === 'audio') - return 'audio' - if (subType === 'pdf') - return 'document' + const [mainType] = fileType.split('/') + if (['image', 'video', 'audio'].includes(mainType)) + return mainType as 'image' | 'video' | 'audio' return 'document' } switch (fileType) { case 'image': - return 'image' case 'video': - return 'video' case 'audio': - return 'audio' - case 'document': - return 'document' + return fileType default: return 'document' } } -const getVisionFileName = (url: string, supportFileType: string) => { +export const getVisionFileName = (url: string, supportFileType: VisionFileSupportType) => { const fileName = url.split('/').pop()?.split('?')[0] if (fileName) return fileName