diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts index 43f7d425bd14..a9376a3b94d2 100644 --- a/packages/app/src/components/prompt-input/attachments.test.ts +++ b/packages/app/src/components/prompt-input/attachments.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { attachmentMime } from "./files" +import { MAX_ATTACHMENT_BYTES, estimateAttachment, totalAttachments, wouldExceedAttachmentLimit } from "./limit" import { pasteMode } from "./paste" describe("attachmentMime", () => { @@ -42,3 +43,19 @@ describe("pasteMode", () => { expect(pasteMode("x".repeat(8000))).toBe("manual") }) }) + +describe("attachment limit", () => { + test("estimates encoded attachment size", () => { + expect(estimateAttachment({ size: 3 }, "image/png")).toBe("data:image/png;base64,".length + 4) + }) + + test("totals current attachments", () => { + expect(totalAttachments([{ dataUrl: "abc" }, { dataUrl: "de" }])).toBe(5) + }) + + test("flags uploads that exceed the total limit", () => { + const list = [{ dataUrl: "a".repeat(MAX_ATTACHMENT_BYTES - 4) }] + expect(wouldExceedAttachmentLimit(list, { size: 3 }, "image/png")).toBe(true) + expect(wouldExceedAttachmentLimit([], { size: 3 }, "image/png")).toBe(false) + }) +}) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index fa9930f6839a..0ff123bf0586 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -5,6 +5,7 @@ import { useLanguage } from "@/context/language" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" import { attachmentMime } from "./files" +import { wouldExceedAttachmentLimit } from "./limit" import { normalizePaste, pasteMode } from "./paste" function dataUrl(file: File, mime: string) { @@ -33,6 +34,8 @@ type PromptAttachmentsInput = { readClipboardImage?: () => Promise } +type AddState = "added" | "failed" | "unsupported" | "limit" + export function createPromptAttachments(input: PromptAttachmentsInput) { const prompt = usePrompt() const language = useLanguage() @@ -44,18 +47,27 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { }) } - const add = async (file: File, toast = true) => { + const warnLimit = () => { + showToast({ + title: language.t("prompt.toast.attachmentLimit.title"), + description: language.t("prompt.toast.attachmentLimit.description"), + }) + } + + const add = async (file: File): Promise => { const mime = await attachmentMime(file) if (!mime) { - if (toast) warn() - return false + return "unsupported" as const } const editor = input.editor() - if (!editor) return false + if (!editor) return "failed" as const + + const images = prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image") + if (wouldExceedAttachmentLimit(images, file, mime)) return "limit" as const const url = await dataUrl(file, mime) - if (!url) return false + if (!url) return "failed" as const const attachment: ImageAttachmentPart = { type: "image", @@ -66,23 +78,26 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const cursor = prompt.cursor() ?? getCursorPosition(editor) prompt.set([...prompt.current(), attachment], cursor) - return true + return "added" as const } - const addAttachment = (file: File) => add(file) - - const addAttachments = async (files: File[], toast = true) => { - let found = false - - for (const file of files) { - const ok = await add(file, false) - if (ok) found = true + const addAttachments = async (list: File[]) => { + const result = { added: false, unsupported: false } + for (const file of list) { + const state = await add(file) + if (state === "limit") { + warnLimit() + return result.added + } + result.added = result.added || state === "added" + result.unsupported = result.unsupported || state === "unsupported" } - - if (!found && files.length > 0 && toast) warn() - return found + if (!result.added && result.unsupported) warn() + return result.added } + const addAttachment = (file: File) => addAttachments([file]) + const removeAttachment = (id: string) => { const current = prompt.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) diff --git a/packages/app/src/components/prompt-input/limit.ts b/packages/app/src/components/prompt-input/limit.ts new file mode 100644 index 000000000000..3a90262a5f73 --- /dev/null +++ b/packages/app/src/components/prompt-input/limit.ts @@ -0,0 +1,15 @@ +const MB = 1024 * 1024 + +export const MAX_ATTACHMENT_BYTES = 5 * MB + +export function estimateAttachment(file: { size: number }, mime: string) { + return `data:${mime};base64,`.length + Math.ceil(file.size / 3) * 4 +} + +export function totalAttachments(list: Array<{ dataUrl: string }>) { + return list.reduce((sum, part) => sum + part.dataUrl.length, 0) +} + +export function wouldExceedAttachmentLimit(list: Array<{ dataUrl: string }>, file: { size: number }, mime: string) { + return totalAttachments(list) + estimateAttachment(file, mime) > MAX_ATTACHMENT_BYTES +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 579b740d3aee..58089ffd8136 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -281,6 +281,8 @@ export const dict = { "prompt.action.send": "Send", "prompt.action.stop": "Stop", + "prompt.toast.attachmentLimit.title": "Attachment limit reached", + "prompt.toast.attachmentLimit.description": "Attachments can total up to 5 MB. Try smaller or fewer files.", "prompt.toast.pasteUnsupported.title": "Unsupported attachment", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.", "prompt.toast.modelAgentRequired.title": "Select an agent and model",