feat(web): new upload api for human input form page

This commit is contained in:
JzoNg 2026-05-06 17:54:41 +08:00
parent 21a9c8d59c
commit 651dfe5dca
5 changed files with 336 additions and 19 deletions

View File

@ -5,9 +5,14 @@ import { act, renderHook } from '@testing-library/react'
import { useFile, useFileSizeLimit } from '../hooks'
const mockNotify = vi.fn()
const mockNavigationState = vi.hoisted(() => ({
params: {} as { token?: string },
pathname: '/chat',
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: undefined }),
useParams: () => mockNavigationState.params,
usePathname: () => mockNavigationState.pathname,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -42,6 +47,13 @@ vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
}))
const mockUploadHumanInputFormLocalFile = vi.fn()
const mockUploadHumanInputFormRemoteFileInfo = vi.fn()
vi.mock('@/service/share', () => ({
uploadHumanInputFormLocalFile: (...args: unknown[]) => mockUploadHumanInputFormLocalFile(...args),
uploadHumanInputFormRemoteFileInfo: (...args: unknown[]) => mockUploadHumanInputFormRemoteFileInfo(...args),
}))
vi.mock('uuid', () => ({
v4: () => 'mock-uuid',
}))
@ -109,6 +121,8 @@ describe('useFile', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStoreFiles = []
mockNavigationState.params = {}
mockNavigationState.pathname = '/chat'
mockIsAllowedFileExtension.mockReturnValue(true)
mockGetSupportFileType.mockReturnValue('document')
})
@ -201,6 +215,31 @@ describe('useFile', () => {
expect(mockFileUpload).toHaveBeenCalled()
})
it('should use human input form local upload when re-uploading on form page', () => {
mockNavigationState.params = { token: 'form-token' }
mockNavigationState.pathname = '/form/form-token'
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
mockStoreFiles = [{
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: -1,
transferMethod: 'local_file',
supportFileType: 'document',
originalFile,
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('file-1')
expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({
formToken: 'form-token',
file: originalFile,
}))
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should not re-upload when file id is not found', () => {
mockStoreFiles = []
const { result } = renderHook(() => useFile(defaultFileConfig))
@ -315,6 +354,24 @@ describe('useFile', () => {
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
})
it('should use human input form remote upload on form page', () => {
mockNavigationState.params = { token: 'form-token' }
mockNavigationState.pathname = '/form/form-token'
mockUploadHumanInputFormRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'text/plain',
size: 100,
name: 'remote.txt',
url: 'https://example.com/remote.txt',
})
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLoadFileFromLink('https://example.com/file.txt')
expect(mockUploadHumanInputFormRemoteFileInfo).toHaveBeenCalledWith('form-token', 'https://example.com/file.txt')
expect(mockUploadRemoteFileInfo).not.toHaveBeenCalled()
})
it('should remove file when extension is not allowed', async () => {
mockIsAllowedFileExtension.mockReturnValue(false)
mockUploadRemoteFileInfo.mockResolvedValue({
@ -702,6 +759,21 @@ describe('useFile', () => {
expect(mockSetFiles).toHaveBeenCalled()
})
it('should use human input form local upload on form page', () => {
mockNavigationState.params = { token: 'form-token' }
mockNavigationState.pathname = '/form/form-token'
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockUploadHumanInputFormLocalFile).toHaveBeenCalledWith(expect.objectContaining({
formToken: 'form-token',
file,
}))
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should handle fileUpload error callback', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' })

View File

@ -19,8 +19,12 @@ import {
VIDEO_SIZE_LIMIT,
} from '@/app/components/base/file-uploader/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useParams } from '@/next/navigation'
import { useParams, usePathname } from '@/next/navigation'
import { uploadRemoteFileInfo } from '@/service/common'
import {
uploadHumanInputFormLocalFile,
uploadHumanInputFormRemoteFileInfo,
} from '@/service/share'
import { TransferMethod } from '@/types/app'
import { formatFileSize } from '@/utils/format'
import { useFileStore } from './store'
@ -51,7 +55,10 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
const { t } = useTranslation()
const fileStore = useFileStore()
const params = useParams()
const pathname = usePathname()
const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(fileConfig.fileUploadConfig)
const formToken = typeof params.token === 'string' ? params.token : undefined
const isHumanInputFormPage = !!formToken && /(?:^|\/)form\/[^/]+$/.test(pathname)
const checkSizeLimit = useCallback((fileType: string, fileSize: number) => {
switch (fileType) {
@ -156,7 +163,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
draft[index]!.progress = 0
})
setFiles(newFiles)
fileUpload({
const uploadParams: Parameters<typeof fileUpload>[0] = {
file: uploadingFile!.originalFile!,
onProgressCallback: (progress) => {
handleUpdateFile({ ...uploadingFile, progress })
@ -164,14 +171,24 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
onSuccessCallback: (res) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
onErrorCallback: (error?: unknown) => {
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t)
toast.error(errorMessage)
handleUpdateFile({ ...uploadingFile, progress: -1 })
},
}, !!params.token)
}
if (isHumanInputFormPage) {
uploadHumanInputFormLocalFile({
formToken: formToken!,
...uploadParams,
})
}
else {
fileUpload(uploadParams, !!params.token)
}
}
}, [fileStore, t, handleUpdateFile, params])
}, [fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token])
const startProgressTimer = useCallback((fileId: string) => {
const timer = setInterval(() => {
@ -201,7 +218,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
handleAddFile(uploadingFile)
startProgressTimer(uploadingFile.id)
uploadRemoteFileInfo(url, !!params.token).then((res) => {
const remoteUpload = isHumanInputFormPage
? uploadHumanInputFormRemoteFileInfo(formToken!, url)
: uploadRemoteFileInfo(url, !!params.token)
remoteUpload.then((res) => {
const newFile = {
...uploadingFile,
type: res.mime_type,
@ -223,7 +244,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
toast.error(t('fileUploader.pasteFileLinkInvalid', { ns: 'common' }))
handleRemoveFile(uploadingFile.id)
})
}, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, params.token])
}, [checkSizeLimit, handleAddFile, handleUpdateFile, t, handleRemoveFile, fileConfig?.allowed_file_types, fileConfig.allowed_file_extensions, startProgressTimer, isHumanInputFormPage, formToken, params.token])
const handleLoadFileFromLinkSuccess = useCallback(noop, [])
@ -269,7 +290,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
base64Url: isImage ? reader.result as string : '',
}
handleAddFile(uploadingFile)
fileUpload({
const uploadParams: Parameters<typeof fileUpload>[0] = {
file: uploadingFile.originalFile,
onProgressCallback: (progress) => {
handleUpdateFile({ ...uploadingFile, progress })
@ -277,12 +298,22 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
onSuccessCallback: (res) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
},
onErrorCallback: (error?: any) => {
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any)
onErrorCallback: (error?: unknown) => {
const errorMessage = getFileUploadErrorMessage(error, t('fileUploader.uploadFromComputerUploadError', { ns: 'common' }), t)
toast.error(errorMessage)
handleUpdateFile({ ...uploadingFile, progress: -1 })
},
}, !!params.token)
}
if (isHumanInputFormPage) {
uploadHumanInputFormLocalFile({
formToken: formToken!,
...uploadParams,
})
}
else {
fileUpload(uploadParams, !!params.token)
}
},
false,
)
@ -294,7 +325,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
false,
)
reader.readAsDataURL(file)
}, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
}, [noNeedToCheckEnable, checkSizeLimit, t, handleAddFile, handleUpdateFile, isHumanInputFormPage, formToken, params.token, fileConfig?.allowed_file_types, fileConfig?.allowed_file_extensions, fileConfig?.enabled])
const handleClipboardPasteFile = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
const file = e.clipboardData?.files[0]

View File

@ -0,0 +1,108 @@
const mockPostPublic = vi.hoisted(() => vi.fn())
const mockUpload = vi.hoisted(() => vi.fn())
vi.mock('../base', () => ({
del: vi.fn(),
get: vi.fn(),
patch: vi.fn(),
post: vi.fn(),
delPublic: vi.fn(),
getPublic: vi.fn(),
patchPublic: vi.fn(),
postPublic: (...args: unknown[]) => mockPostPublic(...args),
ssePost: vi.fn(),
upload: (...args: unknown[]) => mockUpload(...args),
}))
vi.mock('../webapp-auth', () => ({
getWebAppAccessToken: vi.fn(),
}))
describe('human input form upload services', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-06T00:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('should fetch upload token before local file upload', async () => {
const { uploadHumanInputFormLocalFile } = await import('../share')
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const onProgressCallback = vi.fn()
const onSuccessCallback = vi.fn()
const onErrorCallback = vi.fn()
mockPostPublic.mockResolvedValueOnce({
upload_token: 'hitl-upload-token',
expires_at: Math.floor(Date.now() / 1000) + 60,
})
mockUpload.mockResolvedValueOnce({
id: 'file-1',
name: 'test.txt',
size: 7,
extension: 'txt',
mime_type: 'text/plain',
created_by: 'actor-1',
created_at: Math.floor(Date.now() / 1000),
preview_url: null,
source_url: 'https://example.com/file-preview',
})
await uploadHumanInputFormLocalFile({
formToken: 'local-form-token',
file,
onProgressCallback,
onSuccessCallback,
onErrorCallback,
})
expect(mockPostPublic).toHaveBeenCalledWith('/form/human_input/local-form-token/upload-token')
expect(mockUpload).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.any(FormData),
headers: {
Authorization: 'bearer hitl-upload-token',
},
}),
true,
'/form/human_input/files/upload',
)
expect(onSuccessCallback).toHaveBeenCalledWith(expect.objectContaining({ id: 'file-1' }))
expect(onErrorCallback).not.toHaveBeenCalled()
})
it('should fetch upload token before remote file upload', async () => {
const { uploadHumanInputFormRemoteFileInfo } = await import('../share')
mockPostPublic
.mockResolvedValueOnce({
upload_token: 'hitl-remote-token',
expires_at: Math.floor(Date.now() / 1000) + 60,
})
.mockResolvedValueOnce({
id: 'remote-file-1',
name: 'remote.txt',
size: 10,
extension: 'txt',
mime_type: 'text/plain',
created_by: 'actor-1',
created_at: Math.floor(Date.now() / 1000),
url: 'https://example.com/remote.txt',
})
const response = await uploadHumanInputFormRemoteFileInfo('remote-form-token', 'https://example.com/file.txt')
expect(mockPostPublic).toHaveBeenNthCalledWith(1, '/form/human_input/remote-form-token/upload-token')
expect(mockPostPublic).toHaveBeenNthCalledWith(2, '/form/human_input/files/remote-upload', {
body: { url: 'https://example.com/file.txt' },
headers: {
Authorization: 'bearer hitl-remote-token',
},
})
expect(response).toEqual(expect.objectContaining({ id: 'remote-file-1' }))
})
})

View File

@ -60,7 +60,7 @@ const createResponseFromHTTPError = (error: HTTPError): Response => {
const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => {
return async ({ response }) => {
if (!/^([23])\d{2}$/.test(String(response.status))) {
if (!/^[23]\d{2}$/.test(String(response.status))) {
let errorData: ResponseError | null = null
try {
const data: unknown = await response.clone().json()
@ -101,11 +101,13 @@ const resolveShareCode = () => {
}
const beforeRequestPublicWithCode: BeforeRequestHook = ({ request }) => {
const accessToken = getWebAppAccessToken()
if (accessToken)
request.headers.set('Authorization', `Bearer ${accessToken}`)
else
request.headers.delete('Authorization')
if (!request.headers.has('Authorization')) {
const accessToken = getWebAppAccessToken()
if (accessToken)
request.headers.set('Authorization', `Bearer ${accessToken}`)
else
request.headers.delete('Authorization')
}
const shareCode = resolveShareCode()
if (!shareCode)
return

View File

@ -26,6 +26,7 @@ import {
patchPublic as patch,
postPublic as post,
ssePost,
upload,
} from './base'
import { getWebAppAccessToken } from './webapp-auth'
@ -274,6 +275,109 @@ export const getHumanInputForm = (token: string) => {
return get<HumanInputFormData>(`/form/human_input/${token}`)
}
type HumanInputFormUploadTokenResponse = {
upload_token: string
expires_at: number
}
type HumanInputFormLocalFileUploadResponse = {
created_at: number
created_by: string
extension: string
id: string
mime_type: string
name: string
preview_url: string | null
size: number
source_url: string
}
type HumanInputFormRemoteFileUploadResponse = {
created_at: number
created_by: string
extension: string
id: string
mime_type: string
name: string
size: number
url: string
}
type HumanInputFormLocalFileUploadParams = {
formToken: string
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: HumanInputFormLocalFileUploadResponse) => void
onErrorCallback: (error?: unknown) => void
}
const humanInputFormUploadTokenCache = new Map<string, HumanInputFormUploadTokenResponse>()
const UPLOAD_TOKEN_REFRESH_BUFFER_SECONDS = 30
export const getHumanInputFormUploadToken = async (formToken: string) => {
const cachedToken = humanInputFormUploadTokenCache.get(formToken)
const now = Math.floor(Date.now() / 1000)
if (cachedToken && cachedToken.expires_at > now + UPLOAD_TOKEN_REFRESH_BUFFER_SECONDS)
return cachedToken.upload_token
const tokenResponse = await post<HumanInputFormUploadTokenResponse>(`/form/human_input/${formToken}/upload-token`)
humanInputFormUploadTokenCache.set(formToken, tokenResponse)
return tokenResponse.upload_token
}
export const uploadHumanInputFormLocalFile = async ({
formToken,
file,
onProgressCallback,
onSuccessCallback,
onErrorCallback,
}: HumanInputFormLocalFileUploadParams) => {
const formData = new FormData()
formData.append('file', file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
onProgressCallback(percent)
}
}
try {
const uploadToken = await getHumanInputFormUploadToken(formToken)
const response = await upload({
xhr: new XMLHttpRequest(),
data: formData,
headers: {
Authorization: `bearer ${uploadToken}`,
},
onprogress: onProgress,
}, true, '/form/human_input/files/upload') as HumanInputFormLocalFileUploadResponse
onSuccessCallback(response)
}
catch (error) {
onErrorCallback(error)
}
}
export const uploadHumanInputFormRemoteFileInfo = async (
formToken: string,
url: string,
): Promise<HumanInputFormRemoteFileUploadResponse> => {
const uploadToken = await getHumanInputFormUploadToken(formToken)
return post<HumanInputFormRemoteFileUploadResponse>(
'/form/human_input/files/remote-upload',
{
body: { url },
headers: {
Authorization: `bearer ${uploadToken}`,
},
},
)
}
export const submitHumanInputForm = (token: string, data: {
inputs: Record<string, unknown>
action: string