From 651dfe5dcafdf81d8e577f4b96f3f4264f88a1c7 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Wed, 6 May 2026 17:54:41 +0800 Subject: [PATCH] feat(web): new upload api for human input form page --- .../file-uploader/__tests__/hooks.spec.ts | 74 +++++++++++- .../components/base/file-uploader/hooks.ts | 55 +++++++-- .../share-human-input-upload.spec.ts | 108 ++++++++++++++++++ web/service/fetch.ts | 14 ++- web/service/share.ts | 104 +++++++++++++++++ 5 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 web/service/__tests__/share-human-input-upload.spec.ts diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 72f28603db..278c3f328b 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -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' }) diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 9aa70e4487..be8890d41b 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -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[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[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) => { const file = e.clipboardData?.files[0] diff --git a/web/service/__tests__/share-human-input-upload.spec.ts b/web/service/__tests__/share-human-input-upload.spec.ts new file mode 100644 index 0000000000..3fb2eb065a --- /dev/null +++ b/web/service/__tests__/share-human-input-upload.spec.ts @@ -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' })) + }) +}) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 34bd07160a..bca302ca4b 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -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 diff --git a/web/service/share.ts b/web/service/share.ts index 2a0ed69cc1..aba276c3cf 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -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(`/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() +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(`/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 => { + const uploadToken = await getHumanInputFormUploadToken(formToken) + + return post( + '/form/human_input/files/remote-upload', + { + body: { url }, + headers: { + Authorization: `bearer ${uploadToken}`, + }, + }, + ) +} + export const submitHumanInputForm = (token: string, data: { inputs: Record action: string