From b32c9007615f08207dc4412ef880261e7b5bb6e0 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 30 Jul 2026 15:57:53 +0800 Subject: [PATCH] feat: add upload size guidance and validation for agent files and skills (#39798) --- .../file-uploader/__tests__/hooks.spec.ts | 5 ++ .../base/file-uploader/constants.ts | 1 + .../components/base/file-uploader/hooks.ts | 4 ++ .../files/__tests__/index.spec.tsx | 71 +++++++++++++++++++ .../orchestrate/files/upload-dialog.tsx | 46 ++++++++++++ .../skills/__tests__/index.spec.tsx | 61 ++++++++++++++++ .../orchestrate/skills/upload-dialog.tsx | 18 +++++ web/i18n/ar-TN/agent-v-2.json | 1 + web/i18n/de-DE/agent-v-2.json | 1 + web/i18n/en-US/agent-v-2.json | 1 + web/i18n/es-ES/agent-v-2.json | 1 + web/i18n/fa-IR/agent-v-2.json | 1 + web/i18n/fr-FR/agent-v-2.json | 1 + web/i18n/hi-IN/agent-v-2.json | 1 + web/i18n/id-ID/agent-v-2.json | 1 + web/i18n/it-IT/agent-v-2.json | 1 + web/i18n/ja-JP/agent-v-2.json | 1 + web/i18n/ko-KR/agent-v-2.json | 1 + web/i18n/nl-NL/agent-v-2.json | 1 + web/i18n/pl-PL/agent-v-2.json | 1 + web/i18n/pt-BR/agent-v-2.json | 1 + web/i18n/ro-RO/agent-v-2.json | 1 + web/i18n/ru-RU/agent-v-2.json | 1 + web/i18n/sl-SI/agent-v-2.json | 1 + web/i18n/th-TH/agent-v-2.json | 1 + web/i18n/tr-TR/agent-v-2.json | 1 + web/i18n/uk-UA/agent-v-2.json | 1 + web/i18n/vi-VN/agent-v-2.json | 1 + web/i18n/zh-Hans/agent-v-2.json | 1 + web/i18n/zh-Hant/agent-v-2.json | 1 + web/models/common.ts | 1 + 31 files changed, 230 insertions(+) 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 dad8afc8668..3ba3ce25395 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -79,6 +79,7 @@ describe('useFileSizeLimit', () => { expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024) expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024) expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024) + expect(result.current.skillSizeLimit).toBe(50 * 1024 * 1024) expect(result.current.maxFileUploadLimit).toBe(10) }) @@ -88,6 +89,7 @@ describe('useFileSizeLimit', () => { file_size_limit: 30, audio_file_size_limit: 100, video_file_size_limit: 200, + skill_file_size_limit: 60, workflow_file_upload_limit: 20, } as FileUploadConfigResponse @@ -97,6 +99,7 @@ describe('useFileSizeLimit', () => { expect(result.current.docSizeLimit).toBe(30 * 1024 * 1024) expect(result.current.audioSizeLimit).toBe(100 * 1024 * 1024) expect(result.current.videoSizeLimit).toBe(200 * 1024 * 1024) + expect(result.current.skillSizeLimit).toBe(60 * 1024 * 1024) expect(result.current.maxFileUploadLimit).toBe(20) }) @@ -106,6 +109,7 @@ describe('useFileSizeLimit', () => { file_size_limit: 0, audio_file_size_limit: 0, video_file_size_limit: 0, + skill_file_size_limit: 0, workflow_file_upload_limit: 0, } as FileUploadConfigResponse @@ -115,6 +119,7 @@ describe('useFileSizeLimit', () => { expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024) expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024) expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024) + expect(result.current.skillSizeLimit).toBe(50 * 1024 * 1024) expect(result.current.maxFileUploadLimit).toBe(10) }) }) diff --git a/web/app/components/base/file-uploader/constants.ts b/web/app/components/base/file-uploader/constants.ts index a749d73c740..8694fce20b0 100644 --- a/web/app/components/base/file-uploader/constants.ts +++ b/web/app/components/base/file-uploader/constants.ts @@ -3,6 +3,7 @@ export const IMG_SIZE_LIMIT = 10 * 1024 * 1024 export const FILE_SIZE_LIMIT = 15 * 1024 * 1024 export const AUDIO_SIZE_LIMIT = 50 * 1024 * 1024 export const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024 +export const SKILL_FILE_SIZE_LIMIT = 50 * 1024 * 1024 export const MAX_FILE_UPLOAD_LIMIT = 10 export const FILE_URL_REGEX = /^(https?|ftp):\/\// diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 0f8ad3c646e..1e62e59ab3b 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -13,6 +13,7 @@ import { FILE_SIZE_LIMIT, IMG_SIZE_LIMIT, MAX_FILE_UPLOAD_LIMIT, + SKILL_FILE_SIZE_LIMIT, VIDEO_SIZE_LIMIT, } from '@/app/components/base/file-uploader/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' @@ -38,6 +39,8 @@ export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => Number(fileUploadConfig?.audio_file_size_limit) * 1024 * 1024 || AUDIO_SIZE_LIMIT const videoSizeLimit = Number(fileUploadConfig?.video_file_size_limit) * 1024 * 1024 || VIDEO_SIZE_LIMIT + const skillSizeLimit = + Number(fileUploadConfig?.skill_file_size_limit) * 1024 * 1024 || SKILL_FILE_SIZE_LIMIT const maxFileUploadLimit = Number(fileUploadConfig?.workflow_file_upload_limit) || MAX_FILE_UPLOAD_LIMIT @@ -46,6 +49,7 @@ export const useFileSizeLimit = (fileUploadConfig?: FileUploadConfigResponse) => docSizeLimit, audioSizeLimit, videoSizeLimit, + skillSizeLimit, maxFileUploadLimit, } } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index 1c78561972b..17b8e368ed1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -45,6 +45,13 @@ const mocks = vi.hoisted(() => ({ downloadQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})), downloadBlob: vi.fn(), downloadUrl: vi.fn(), + fileUploadConfig: { + file_size_limit: 15, + image_file_size_limit: 10, + audio_file_size_limit: 50, + video_file_size_limit: 100, + workflow_file_upload_limit: 10, + }, })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -59,6 +66,10 @@ vi.mock('@/utils/download', () => ({ downloadUrl: mocks.downloadUrl, })) +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: mocks.fileUploadConfig }), +})) + vi.mock('@/service/client', () => ({ consoleQuery: { systemFeatures: { @@ -204,6 +215,13 @@ function renderAgentFiles({ describe('AgentFiles', () => { beforeEach(() => { vi.clearAllMocks() + Object.assign(mocks.fileUploadConfig, { + file_size_limit: 15, + image_file_size_limit: 10, + audio_file_size_limit: 50, + video_file_size_limit: 100, + workflow_file_upload_limit: 10, + }) mocks.previewQueryOptions.mockImplementation(({ input }) => ({ queryKey: ['preview-config-file', input], queryFn: async () => ({ @@ -351,6 +369,59 @@ describe('AgentFiles', () => { expect(toast.success).toHaveBeenCalled() }) + it('should show the configured size limit for every supported file type', async () => { + const user = userEvent.setup() + renderAgentFiles({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }), + ) + + const sizeLimitCopy = await screen.findByText(/appDebug\.variableConfig\.maxNumberTip/) + expect(sizeLimitCopy).toHaveTextContent('"docLimit":"15.00 MB"') + expect(sizeLimitCopy).toHaveTextContent('"imgLimit":"10.00 MB"') + expect(sizeLimitCopy).toHaveTextContent('"audioLimit":"50.00 MB"') + expect(sizeLimitCopy).toHaveTextContent('"videoLimit":"100.00 MB"') + }) + + it.each([ + ['document', 'oversized.pdf', 'application/pdf'], + ['image', 'oversized.png', 'image/png'], + ['audio', 'oversized.mp3', 'audio/mpeg'], + ['video', 'oversized.mp4', 'video/mp4'], + ])('should reject an oversized %s file before upload', async (fileType, fileName, mimeType) => { + Object.assign(mocks.fileUploadConfig, { + file_size_limit: 1, + image_file_size_limit: 1, + audio_file_size_limit: 1, + video_file_size_limit: 1, + }) + const user = userEvent.setup() + renderAgentFiles({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }), + ) + + const input = await waitFor(() => { + const element = document.querySelector('input[type="file"]') + expect(element).not.toBeNull() + return element as HTMLInputElement + }) + const file = new File([new Uint8Array(1024 * 1024 + 1)], fileName, { type: mimeType }) + await user.upload(input, file) + + expect(toast.error).toHaveBeenCalledWith( + `common.fileUploader.uploadFromComputerLimit:{"type":"${fileType}","size":"1.00 MB"}`, + ) + expect(mocks.uploadFileMutationFn).not.toHaveBeenCalled() + expect( + screen.getByRole('button', { + name: /agentDetail\.configure\.files\.upload\.action/i, + }), + ).toBeDisabled() + }) + it('should use workflow config file endpoints with node_id for preview and upload', async () => { const user = userEvent.setup() renderAgentFiles({ diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx index ee14db280a8..91a94ab6799 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/upload-dialog.tsx @@ -23,7 +23,11 @@ import { useMutation } from '@tanstack/react-query' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' +import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' +import { getSupportFileType } from '@/app/components/base/file-uploader/utils' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { consoleQuery } from '@/service/client' +import { useFileUploadConfig } from '@/service/use-common' import { formatFileSize } from '@/utils/format' import { getFileIconType } from './file-icon' @@ -46,10 +50,33 @@ function hasDraggedFiles(event: DragEvent) { function AgentFileUploader({ file, onChange }: { file?: File; onChange: (file?: File) => void }) { const { t } = useTranslation('agentV2') + const { t: tAppDebug } = useTranslation('appDebug') + const { t: tCommon } = useTranslation('common') + const { data: fileUploadConfig } = useFileUploadConfig() + const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = + useFileSizeLimit(fileUploadConfig) const fileInputRef = useRef(null) const dragDepthRef = useRef(0) const [dragging, setDragging] = useState(false) + const getSizeLimit = (uploadFile: File) => { + const fileType = getSupportFileType(uploadFile.name, uploadFile.type) + + switch (fileType) { + case SupportUploadFileTypes.image: + return { fileType, sizeLimit: imgSizeLimit } + case SupportUploadFileTypes.audio: + return { fileType, sizeLimit: audioSizeLimit } + case SupportUploadFileTypes.video: + return { fileType, sizeLimit: videoSizeLimit } + default: + return { + fileType: SupportUploadFileTypes.document, + sizeLimit: docSizeLimit, + } + } + } + const setUploadFiles = (files: File[]) => { const [uploadFile] = files if (files.length !== 1 || !uploadFile) { @@ -57,6 +84,17 @@ function AgentFileUploader({ file, onChange }: { file?: File; onChange: (file?: return } + const { fileType, sizeLimit } = getSizeLimit(uploadFile) + if (uploadFile.size > sizeLimit) { + toast.error( + tCommon(($) => $['fileUploader.uploadFromComputerLimit'], { + type: fileType, + size: formatFileSize(sizeLimit), + }), + ) + return + } + onChange(uploadFile) } @@ -158,6 +196,14 @@ function AgentFileUploader({ file, onChange }: { file?: File; onChange: (file?: )} +

+ {tAppDebug(($) => $['variableConfig.maxNumberTip'], { + docLimit: formatFileSize(docSizeLimit), + imgLimit: formatFileSize(imgSizeLimit), + audioLimit: formatFileSize(audioSizeLimit), + videoLimit: formatFileSize(videoSizeLimit), + })} +

) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx index 11dca29cd8f..3db177165de 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx @@ -62,6 +62,9 @@ const mocks = vi.hoisted(() => ({ downloadBlob: vi.fn(), downloadUrl: vi.fn(), fetch: vi.fn(), + fileUploadConfig: { + skill_file_size_limit: 64, + }, })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -76,6 +79,10 @@ vi.mock('@/utils/download', () => ({ downloadUrl: mocks.downloadUrl, })) +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ data: mocks.fileUploadConfig }), +})) + vi.mock('@/config', async (importOriginal) => ({ ...(await importOriginal()), API_PREFIX: 'http://localhost:5001/console/api', @@ -218,6 +225,7 @@ function renderAgentSkills({ describe('AgentSkills', () => { beforeEach(() => { vi.clearAllMocks() + mocks.fileUploadConfig.skill_file_size_limit = 64 vi.stubGlobal('fetch', mocks.fetch) document.cookie = 'csrf_token=csrf-token; path=/' mocks.fetch.mockResolvedValue( @@ -418,6 +426,59 @@ describe('AgentSkills', () => { expect(toast.success).toHaveBeenCalled() }) + it('should show the configured skill package size limit', async () => { + const user = userEvent.setup() + renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }), + ) + + expect( + await screen.findByText( + 'agentV2.agentDetail.configure.skills.upload.sizeLimit:{"sizeLimit":"64.00 MB"}', + ), + ).toBeInTheDocument() + }) + + it('should reject skill packages over the configured size limit', async () => { + const user = userEvent.setup() + mocks.fileUploadConfig.skill_file_size_limit = 1 + renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click( + screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }), + ) + + const input = await waitFor(() => { + const element = document.querySelector('input[type="file"]') + expect(element).not.toBeNull() + return element as HTMLInputElement + }) + const oversizedFile = new File([new Uint8Array(1024 * 1024 + 1)], 'oversized-skill.skill', { + type: 'application/zip', + }) + await user.upload(input, oversizedFile) + + expect(toast.error).toHaveBeenCalledWith( + 'agentV2.agentDetail.configure.skills.upload.sizeLimit:{"sizeLimit":"1.00 MB"}', + ) + expect( + screen.getByRole('button', { + name: /agentDetail\.configure\.skills\.upload\.action/i, + }), + ).toBeDisabled() + + vi.mocked(toast.error).mockClear() + const allowedFile = new File([new Uint8Array(1024 * 1024)], 'allowed-skill.skill', { + type: 'application/zip', + }) + await user.upload(input, allowedFile) + + expect(screen.getByText('allowed-skill.skill')).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + }) + it('should hide skill package guidance before an upload fails', async () => { const user = userEvent.setup() renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx index 78ab5a2fe41..f478de90774 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx @@ -19,8 +19,10 @@ import { useMutation } from '@tanstack/react-query' import { useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' +import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks' import Link from '@/next/link' import { consoleQuery } from '@/service/client' +import { useFileUploadConfig } from '@/service/use-common' import { formatFileSize } from '@/utils/format' const skillPackageAccept = '.zip,.skill' @@ -68,6 +70,8 @@ function AgentSkillPackageUploader({ showWarning: boolean }) { const { t } = useTranslation('agentV2') + const { data: fileUploadConfig } = useFileUploadConfig() + const { skillSizeLimit } = useFileSizeLimit(fileUploadConfig) const fileInputRef = useRef(null) const dragDepthRef = useRef(0) const [dragging, setDragging] = useState(false) @@ -79,6 +83,15 @@ function AgentSkillPackageUploader({ return } + if (uploadFile.size > skillSizeLimit) { + toast.error( + t(($) => $['agentDetail.configure.skills.upload.sizeLimit'], { + sizeLimit: formatFileSize(skillSizeLimit), + }), + ) + return + } + onChange(uploadFile) } @@ -186,6 +199,11 @@ function AgentSkillPackageUploader({ )} +

+ {t(($) => $['agentDetail.configure.skills.upload.sizeLimit'], { + sizeLimit: formatFileSize(skillSizeLimit), + })} +

{showWarning && (