From 3748344303e0a59f1ecaee3c8d48e93d3dc39807 Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Thu, 2 Jul 2026 10:32:53 +0800 Subject: [PATCH] fix(frontend): validate attachment limits before upload (#3900) * fix(frontend): validate attachment limits before upload * fix(frontend): avoid duplicate upload limit toasts --- .../src/components/workspace/input-box.tsx | 104 ++++++++++++++++- frontend/src/core/i18n/locales/en-US.ts | 8 ++ frontend/src/core/i18n/locales/types.ts | 8 ++ frontend/src/core/i18n/locales/zh-CN.ts | 8 ++ frontend/src/core/uploads/api.ts | 23 ++++ frontend/src/core/uploads/file-validation.ts | 97 ++++++++++++++++ frontend/src/core/uploads/hooks.ts | 15 +++ frontend/tests/e2e/chat.spec.ts | 107 ++++++++++++++++++ frontend/tests/e2e/utils/mock-api.ts | 21 ++++ .../unit/core/uploads/file-validation.test.ts | 71 ++++++++++++ 10 files changed, 458 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 766ea5998..1d14aa259 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -68,6 +68,13 @@ import { useSkills } from "@/core/skills/hooks"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; +import { + formatUploadSize, + useUploadLimits, + validateUploadLimits, + type UploadLimits, + type UploadLimitViolation, +} from "@/core/uploads"; import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; @@ -216,8 +223,11 @@ export function InputBox({ const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); const { thread, isMock } = useThread(); - const { textInput } = usePromptInputController(); + const { attachments, textInput } = usePromptInputController(); + const attachmentParts = attachments.files; + const removeAttachment = attachments.remove; const { skills } = useSkills(); + const { data: uploadLimits } = useUploadLimits(threadId); const promptRootRef = useRef(null); const textareaRef = useRef(null); const promptHistoryIndexRef = useRef(null); @@ -242,6 +252,66 @@ export function InputBox({ null, ); + const reportUploadLimitViolations = useCallback( + (violations: UploadLimitViolation[]) => { + for (const violation of violations) { + if (violation.code === "max_file_size") { + toast.error( + t.uploads.filesTooLarge( + violation.files.map((file) => file.name).join(", "), + formatUploadSize(violation.limit), + ), + ); + } else if (violation.code === "max_files") { + toast.error( + t.uploads.tooManyFiles(violation.files.length, violation.limit), + ); + } else { + toast.error( + t.uploads.totalSizeTooLarge( + violation.files.length, + formatUploadSize(violation.limit), + ), + ); + } + } + }, + [t.uploads], + ); + + useEffect(() => { + if (!uploadLimits) { + return; + } + + const attachmentEntries = attachmentParts.flatMap((attachment) => + attachment.file instanceof File + ? [{ id: attachment.id, file: attachment.file }] + : [], + ); + const validation = validateUploadLimits( + [], + attachmentEntries.map(({ file }) => file), + uploadLimits, + ); + if (validation.rejected.length === 0) { + return; + } + + const rejected = new Set(validation.rejected); + for (const entry of attachmentEntries) { + if (rejected.has(entry.file)) { + removeAttachment(entry.id); + } + } + reportUploadLimitViolations(validation.violations); + }, [ + attachmentParts, + removeAttachment, + reportUploadLimitViolations, + uploadLimits, + ]); + useEffect(() => { if (models.length === 0) { return; @@ -374,6 +444,13 @@ export function InputBox({ if (!message.text.trim() && message.files.length === 0) { return; } + const files = message.files.flatMap((file) => + file.file instanceof File ? [file.file] : [], + ); + const uploadValidation = validateUploadLimits([], files, uploadLimits); + if (uploadValidation.violations.length > 0) { + return Promise.reject(new Error("Attachment limits exceeded.")); + } const placeholder = findSuggestionTemplatePlaceholder(message.text); if (placeholder) { toast.error(t.inputBox.suggestionPlaceholderRequired); @@ -424,6 +501,7 @@ export function InputBox({ selectedModel?.supports_thinking, status, t.inputBox.suggestionPlaceholderRequired, + uploadLimits, ], ); @@ -888,7 +966,10 @@ export function InputBox({ /> */} - + + {tooltipContent}}> attachments.openFileDialog()} > diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 3628243d4..603820b12 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -331,6 +331,14 @@ export const enUS: Translations = { uploads: { uploading: "Uploading...", uploadingFiles: "Uploading files, please wait...", + limitsHint: (maxFiles: number, maxFileSize: string, maxTotalSize: string) => + `Add attachments (up to ${maxFiles} files, ${maxFileSize} each, ${maxTotalSize} total). Most regular file types are supported; compress macOS .app bundles first.`, + filesTooLarge: (files: string, maxFileSize: string) => + `Files exceeding the ${maxFileSize} per-file limit were not added: ${files}.`, + tooManyFiles: (count: number, maxFiles: number) => + `${count} file${count === 1 ? " was" : "s were"} not added. You can attach up to ${maxFiles} files at once.`, + totalSizeTooLarge: (count: number, maxTotalSize: string) => + `${count} file${count === 1 ? " was" : "s were"} not added. Attachments can total up to ${maxTotalSize}.`, }, subtasks: { diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 61ff46bc7..124845bd4 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -251,6 +251,14 @@ export interface Translations { uploads: { uploading: string; uploadingFiles: string; + limitsHint: ( + maxFiles: number, + maxFileSize: string, + maxTotalSize: string, + ) => string; + filesTooLarge: (files: string, maxFileSize: string) => string; + tooManyFiles: (count: number, maxFiles: number) => string; + totalSizeTooLarge: (count: number, maxTotalSize: string) => string; }; // Subtasks diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index d1845349e..f1c17431f 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -316,6 +316,14 @@ export const zhCN: Translations = { uploads: { uploading: "上传中...", uploadingFiles: "文件上传中,请稍候...", + limitsHint: (maxFiles: number, maxFileSize: string, maxTotalSize: string) => + `添加附件(最多 ${maxFiles} 个,单文件不超过 ${maxFileSize},总计不超过 ${maxTotalSize})。支持常规文件类型;macOS .app 应先压缩。`, + filesTooLarge: (files: string, maxFileSize: string) => + `${files} 超过单文件 ${maxFileSize} 的限制,未被添加。`, + tooManyFiles: (count: number, maxFiles: number) => + `有 ${count} 个文件未被添加;一次最多添加 ${maxFiles} 个文件。`, + totalSizeTooLarge: (count: number, maxTotalSize: string) => + `有 ${count} 个文件未被添加;附件总大小不能超过 ${maxTotalSize}。`, }, subtasks: { diff --git a/frontend/src/core/uploads/api.ts b/frontend/src/core/uploads/api.ts index 6c3983bac..e70279061 100644 --- a/frontend/src/core/uploads/api.ts +++ b/frontend/src/core/uploads/api.ts @@ -31,6 +31,12 @@ export interface ListFilesResponse { count: number; } +export interface UploadLimits { + max_files: number; + max_file_size: number; + max_total_size: number; +} + async function readErrorDetail( response: Response, fallback: string, @@ -67,6 +73,23 @@ export async function uploadFiles( return response.json(); } +/** + * Load the upload limits enforced by the gateway for a thread + */ +export async function getUploadLimits(threadId: string): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/threads/${threadId}/uploads/limits`, + ); + + if (!response.ok) { + throw new Error( + await readErrorDetail(response, "Failed to load upload limits"), + ); + } + + return response.json(); +} + /** * List all uploaded files for a thread */ diff --git a/frontend/src/core/uploads/file-validation.ts b/frontend/src/core/uploads/file-validation.ts index 1b796a9ac..f9957930c 100644 --- a/frontend/src/core/uploads/file-validation.ts +++ b/frontend/src/core/uploads/file-validation.ts @@ -1,3 +1,5 @@ +import type { UploadLimits } from "./api"; + const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([ "", "application/octet-stream", @@ -32,3 +34,98 @@ export function splitUnsupportedUploadFiles(fileList: File[] | FileList) { message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined, }; } + +export type UploadLimitViolationCode = + | "max_file_size" + | "max_files" + | "max_total_size"; + +export interface UploadLimitViolation { + code: UploadLimitViolationCode; + files: File[]; + limit: number; +} + +export interface UploadLimitValidationResult { + accepted: File[]; + rejected: File[]; + violations: UploadLimitViolation[]; +} + +/** + * Validate files against the same per-request limits enforced by the gateway. + * Existing files keep priority and incoming files are accepted in selection order. + */ +export function validateUploadLimits( + existingFiles: File[], + incomingFiles: File[] | FileList, + limits?: UploadLimits, +): UploadLimitValidationResult { + const incoming = Array.from(incomingFiles); + if (!limits) { + return { accepted: incoming, rejected: [], violations: [] }; + } + + let fileCount = existingFiles.length; + let totalSize = existingFiles.reduce((total, file) => total + file.size, 0); + const accepted: File[] = []; + const rejectedByCode: Record = { + max_file_size: [], + max_files: [], + max_total_size: [], + }; + + for (const file of incoming) { + if (file.size > limits.max_file_size) { + rejectedByCode.max_file_size.push(file); + continue; + } + if (fileCount >= limits.max_files) { + rejectedByCode.max_files.push(file); + continue; + } + if (totalSize + file.size > limits.max_total_size) { + rejectedByCode.max_total_size.push(file); + continue; + } + + accepted.push(file); + fileCount += 1; + totalSize += file.size; + } + + const limitByCode: Record = { + max_file_size: limits.max_file_size, + max_files: limits.max_files, + max_total_size: limits.max_total_size, + }; + const codes: UploadLimitViolationCode[] = [ + "max_file_size", + "max_files", + "max_total_size", + ]; + const violations = codes.flatMap((code) => + rejectedByCode[code].length > 0 + ? [{ code, files: rejectedByCode[code], limit: limitByCode[code] }] + : [], + ); + + return { + accepted, + rejected: violations.flatMap((violation) => violation.files), + violations, + }; +} + +export function formatUploadSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${Number((bytes / 1024).toFixed(1))} KiB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${Number((bytes / (1024 * 1024)).toFixed(1))} MiB`; + } + return `${Number((bytes / (1024 * 1024 * 1024)).toFixed(1))} GiB`; +} diff --git a/frontend/src/core/uploads/hooks.ts b/frontend/src/core/uploads/hooks.ts index 9e7e5ce2b..2da493922 100644 --- a/frontend/src/core/uploads/hooks.ts +++ b/frontend/src/core/uploads/hooks.ts @@ -7,12 +7,27 @@ import { useCallback } from "react"; import { deleteUploadedFile, + getUploadLimits, listUploadedFiles, uploadFiles, type UploadedFileInfo, type UploadResponse, } from "./api"; +/** + * Hook to load the gateway-enforced upload limits. + * Callers intentionally degrade to server-side validation if this request fails. + */ +export function useUploadLimits(threadId: string) { + return useQuery({ + queryKey: ["uploads", "limits", threadId], + queryFn: () => getUploadLimits(threadId), + enabled: !!threadId, + retry: false, + staleTime: 60_000, + }); +} + /** * Hook to upload files */ diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 5dc2e9ddc..2f8ef0a57 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -358,6 +358,113 @@ test.describe("Chat workspace", () => { }); }); + test("shows gateway upload limits on the attachment entry point", async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + + const addAttachments = page.getByTestId("add-attachments-button"); + await expect(addAttachments).toBeVisible({ timeout: 15_000 }); + await addAttachments.hover(); + + await expect(page.getByRole("tooltip")).toContainText("50 MiB"); + await expect(page.getByRole("tooltip")).toContainText("100 MiB"); + }); + + test("rejects an oversized attachment before upload", async ({ page }) => { + let uploadCalled = false; + await page.route("**/api/threads/*/uploads", (route) => { + if (route.request().method() === "POST") { + uploadCalled = true; + } + return route.fallback(); + }); + await page.route("**/api/threads/*/uploads/limits", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + max_files: 10, + max_file_size: 5, + max_total_size: 20, + }), + }), + ); + + await page.goto("/workspace/chats/new"); + const addAttachments = page.getByTestId("add-attachments-button"); + await addAttachments.hover(); + await expect(page.getByRole("tooltip")).toContainText("5 B"); + + await page.getByLabel("Upload files").setInputFiles({ + name: "too-large.txt", + mimeType: "text/plain", + buffer: Buffer.from("123456"), + }); + + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "too-large.txt" }), + ).toBeVisible(); + await expect(page.locator("form").getByText("too-large.txt")).toBeHidden(); + + const textarea = page.locator('textarea[name="message"]'); + await textarea.fill("Continue without the rejected attachment"); + await textarea.press("Enter"); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + expect(uploadCalled).toBe(false); + }); + + test("keeps valid attachments in order when the total limit is exceeded", async ({ + page, + }) => { + await page.route("**/api/threads/*/uploads/limits", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + max_files: 3, + max_file_size: 10, + max_total_size: 5, + }), + }), + ); + + await page.goto("/workspace/chats/new"); + const addAttachments = page.getByTestId("add-attachments-button"); + await addAttachments.hover(); + await expect(page.getByRole("tooltip")).toContainText("5 B"); + + await page.getByLabel("Upload files").setInputFiles([ + { + name: "first.txt", + mimeType: "text/plain", + buffer: Buffer.from("1234"), + }, + { + name: "over-total.txt", + mimeType: "text/plain", + buffer: Buffer.from("12"), + }, + { + name: "second.txt", + mimeType: "text/plain", + buffer: Buffer.from("1"), + }, + ]); + + const promptForm = page.locator("form").filter({ + has: page.locator('textarea[name="message"]'), + }); + await expect(promptForm.getByText("first.txt")).toBeVisible(); + await expect(promptForm.getByText("second.txt")).toBeVisible(); + await expect(promptForm.getByText("over-total.txt")).toBeHidden(); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "5 B" }), + ).toBeVisible(); + }); + test("keeps attachments visible while upload submit is pending", async ({ page, }) => { diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 1338ea327..18acde50e 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -55,6 +55,11 @@ export type MockAPIOptions = { threads?: MockThread[]; agents?: MockAgent[]; skills?: MockSkill[]; + uploadLimits?: { + max_files: number; + max_file_size: number; + max_total_size: number; + }; }; const DEFAULT_SKILLS: MockSkill[] = [ @@ -106,6 +111,11 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { let threads = [...(options?.threads ?? [])]; const agents = options?.agents ?? []; const skills = options?.skills ?? DEFAULT_SKILLS; + const uploadLimits = options?.uploadLimits ?? { + max_files: 10, + max_file_size: 50 * 1024 * 1024, + max_total_size: 100 * 1024 * 1024, + }; const upsertThread = (thread: MockThread) => { threads = [ @@ -283,6 +293,17 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { return route.fallback(); }); + void page.route("**/api/threads/*/uploads/limits", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(uploadLimits), + }); + } + return route.fallback(); + }); + // Thread history — useStream fetches state history on mount void page.route("**/api/langgraph/threads/*/history", (route) => { const url = route.request().url(); diff --git a/frontend/tests/unit/core/uploads/file-validation.test.ts b/frontend/tests/unit/core/uploads/file-validation.test.ts index 25cb1d4af..4df605d2c 100644 --- a/frontend/tests/unit/core/uploads/file-validation.test.ts +++ b/frontend/tests/unit/core/uploads/file-validation.test.ts @@ -2,10 +2,18 @@ import { expect, test } from "@rstest/core"; import { MACOS_APP_BUNDLE_UPLOAD_MESSAGE, + formatUploadSize, isLikelyMacOSAppBundle, splitUnsupportedUploadFiles, + validateUploadLimits, } from "@/core/uploads/file-validation"; +const limits = { + max_files: 2, + max_file_size: 5, + max_total_size: 7, +}; + test("identifies Finder-style .app bundle uploads as unsupported", () => { expect( isLikelyMacOSAppBundle({ @@ -51,3 +59,66 @@ test("returns no message when every file is supported", () => { expect(result.rejected.length).toBe(0); expect(result.message).toBeUndefined(); }); + +test("accepts a file at the per-file limit and rejects one byte over", () => { + const atLimit = new File(["12345"], "at-limit.txt"); + const overLimit = new File(["123456"], "over-limit.txt"); + + const result = validateUploadLimits([], [atLimit, overLimit], limits); + + expect(result.accepted).toEqual([atLimit]); + expect(result.rejected).toEqual([overLimit]); + expect(result.violations).toEqual([ + { code: "max_file_size", files: [overLimit], limit: 5 }, + ]); +}); + +test("counts existing files and their size before incoming files", () => { + const existing = new File(["12"], "existing.txt"); + const accepted = new File(["12345"], "accepted.txt"); + const overCount = new File(["1"], "over-count.txt"); + + const result = validateUploadLimits( + [existing], + [accepted, overCount], + limits, + ); + + expect(result.accepted).toEqual([accepted]); + expect(result.rejected).toEqual([overCount]); + expect(result.violations[0]?.code).toBe("max_files"); +}); + +test("keeps selection order and aggregates each violation category", () => { + const first = new File(["1234"], "first.txt"); + const overSize = new File(["123456"], "over-size.txt"); + const overTotal = new File(["1234"], "over-total.txt"); + const second = new File(["1"], "second.txt"); + + const result = validateUploadLimits( + [], + [first, overSize, overTotal, second], + limits, + ); + + expect(result.accepted).toEqual([first, second]); + expect(result.violations).toEqual([ + { code: "max_file_size", files: [overSize], limit: 5 }, + { code: "max_total_size", files: [overTotal], limit: 7 }, + ]); +}); + +test("does not block files when upload limits are unavailable", () => { + const file = new File(["123456"], "fallback.txt"); + + const result = validateUploadLimits([], [file]); + + expect(result.accepted).toEqual([file]); + expect(result.rejected).toEqual([]); + expect(result.violations).toEqual([]); +}); + +test("formats binary upload limits for display", () => { + expect(formatUploadSize(50 * 1024 * 1024)).toBe("50 MiB"); + expect(formatUploadSize(1536)).toBe("1.5 KiB"); +});