Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/app/src/components/prompt-input/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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)
})
})
49 changes: 32 additions & 17 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -33,6 +34,8 @@ type PromptAttachmentsInput = {
readClipboardImage?: () => Promise<File | null>
}

type AddState = "added" | "failed" | "unsupported" | "limit"

export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
Expand All @@ -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<AddState> => {
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",
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/components/prompt-input/limit.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading