This commit is contained in:
Haohao 2026-03-24 13:29:04 +08:00 committed by GitHub
commit ab47d7d638
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 266 additions and 21 deletions

View File

@ -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<typeof sortAgentSorts>[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<typeof addFileInfos>[1]
const sorted = sortAgentSorts(thoughts)

View File

@ -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[] }) => (
<div data-testid="file-list-component">
@ -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(<AgentContent item={itemWithFiles as ChatItem} />)
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', () => {

View File

@ -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<AgentContentProps> = ({
data-testid="agent-content-markdown"
/>
) : agent_thoughts?.map((thought, index) => (
<div key={index} className="px-2 py-1" data-testid="agent-thought-item">
<div key={thought.id || index} className="px-2 py-1" data-testid="agent-thought-item">
{thought.thought && (
<Markdown
content={thought.thought}
@ -59,7 +58,7 @@ const AgentContent: FC<AgentContentProps> = ({
{
!!thought.message_files?.length && (
<FileList
files={getProcessedFilesFromResponse(thought.message_files.map((item: any) => ({ ...item, related_id: item.id })))}
files={thought.message_files}
showDeleteAction={false}
showDownloadAction={true}
canPreview={true}

View File

@ -1,9 +1,29 @@
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 { addFileInfos, sortAgentSorts } from '../index'
import { TransferMethod } from '@/types/app'
import {
addFileInfos,
getVisionFileMimeType,
getVisionFileName,
getVisionFileSupportType,
sortAgentSorts,
} from '../index'
describe('tools/utils', () => {
const createFileEntity = (overrides: Partial<FileEntity>): 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()
@ -40,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()
@ -52,8 +112,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 +123,84 @@ 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('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' },
@ -73,7 +211,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])

View File

@ -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,107 @@ 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
type VisionFileSupportType = 'image' | 'video' | 'audio' | 'document'
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
}
export 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'
}
}
export const getVisionFileSupportType = (fileType: string): VisionFileSupportType => {
if (fileType.includes('/')) {
const [mainType] = fileType.split('/')
if (['image', 'video', 'audio'].includes(mainType))
return mainType as 'image' | 'video' | 'audio'
return 'document'
}
switch (fileType) {
case 'image':
case 'video':
case 'audio':
return fileType
default:
return 'document'
}
}
export const getVisionFileName = (url: string, supportFileType: VisionFileSupportType) => {
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

View File

@ -1735,9 +1735,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": {