fix(frontend): validate attachment limits before upload (#3900)

* fix(frontend): validate attachment limits before upload

* fix(frontend): avoid duplicate upload limit toasts
This commit is contained in:
Huixin615 2026-07-02 10:32:53 +08:00 committed by GitHub
parent 70d53da787
commit 3748344303
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 458 additions and 4 deletions

View File

@ -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<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const promptHistoryIndexRef = useRef<number | null>(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({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="px-2!" />
<AddAttachmentsButton
className="px-2!"
uploadLimits={uploadLimits}
/>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@ -1330,13 +1411,28 @@ function SuggestionList({
);
}
function AddAttachmentsButton({ className }: { className?: string }) {
function AddAttachmentsButton({
className,
uploadLimits,
}: {
className?: string;
uploadLimits?: UploadLimits;
}) {
const { t } = useI18n();
const attachments = usePromptInputAttachments();
const tooltipContent = uploadLimits
? t.uploads.limitsHint(
uploadLimits.max_files,
formatUploadSize(uploadLimits.max_file_size),
formatUploadSize(uploadLimits.max_total_size),
)
: t.inputBox.addAttachments;
return (
<Tooltip content={t.inputBox.addAttachments}>
<Tooltip content={<span className="block max-w-80">{tooltipContent}</span>}>
<PromptInputButton
aria-label={t.inputBox.addAttachments}
className={cn("px-2!", className)}
data-testid="add-attachments-button"
onClick={() => attachments.openFileDialog()}
>
<PaperclipIcon className="size-3" />

View File

@ -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: {

View File

@ -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

View File

@ -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: {

View File

@ -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<UploadLimits> {
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
*/

View File

@ -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<UploadLimitViolationCode, File[]> = {
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<UploadLimitViolationCode, number> = {
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`;
}

View File

@ -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
*/

View File

@ -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,
}) => {

View File

@ -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();

View File

@ -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");
});