mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
feat(web): new upload api for human input form page
This commit is contained in:
parent
21a9c8d59c
commit
651dfe5dca
@ -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' })
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
108
web/service/__tests__/share-human-input-upload.spec.ts
Normal file
108
web/service/__tests__/share-human-input-upload.spec.ts
Normal 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' }))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user