Skip to content

Commit 843f188

Browse files
authored
fix(app): support text attachments (#17335)
1 parent 05cb3c8 commit 843f188

28 files changed

Lines changed: 419 additions & 133 deletions

File tree

packages/app/src/components/prompt-input.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import { usePlatform } from "@/context/platform"
3838
import { useSessionLayout } from "@/pages/session/session-layout"
3939
import { createSessionTabs } from "@/pages/session/helpers"
4040
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
41-
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
41+
import { createPromptAttachments } from "./prompt-input/attachments"
42+
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
4243
import {
4344
canNavigateHistoryAtCursor,
4445
navigatePromptHistory,
@@ -1007,7 +1008,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
10071008
return true
10081009
}
10091010

1010-
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
1011+
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
10111012
editor: () => editorRef,
10121013
isFocused,
10131014
isDialogActive: () => !!dialog.active,
@@ -1247,7 +1248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
12471248
onOpen={(attachment) =>
12481249
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
12491250
}
1250-
onRemove={removeImageAttachment}
1251+
onRemove={removeAttachment}
12511252
removeLabel={language.t("prompt.attachment.remove")}
12521253
/>
12531254
<div
@@ -1311,7 +1312,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
13111312
class="hidden"
13121313
onChange={(e) => {
13131314
const file = e.currentTarget.files?.[0]
1314-
if (file) addImageAttachment(file)
1315+
if (file) void addAttachment(file)
13151316
e.currentTarget.value = ""
13161317
}}
13171318
/>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { attachmentMime } from "./files"
3+
4+
describe("attachmentMime", () => {
5+
test("keeps PDFs when the browser reports the mime", async () => {
6+
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
7+
expect(await attachmentMime(file)).toBe("application/pdf")
8+
})
9+
10+
test("normalizes structured text types to text/plain", async () => {
11+
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
12+
expect(await attachmentMime(file)).toBe("text/plain")
13+
})
14+
15+
test("accepts text files even with a misleading browser mime", async () => {
16+
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
17+
expect(await attachmentMime(file)).toBe("text/plain")
18+
})
19+
20+
test("rejects binary files", async () => {
21+
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
22+
expect(await attachmentMime(file)).toBeUndefined()
23+
})
24+
})

packages/app/src/components/prompt-input/attachments.ts

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,27 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
44
import { useLanguage } from "@/context/language"
55
import { uuid } from "@/utils/uuid"
66
import { getCursorPosition } from "./editor-dom"
7-
8-
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
9-
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
7+
import { attachmentMime } from "./files"
108
const LARGE_PASTE_CHARS = 8000
119
const LARGE_PASTE_BREAKS = 120
1210

11+
function dataUrl(file: File, mime: string) {
12+
return new Promise<string>((resolve) => {
13+
const reader = new FileReader()
14+
reader.addEventListener("error", () => resolve(""))
15+
reader.addEventListener("load", () => {
16+
const value = typeof reader.result === "string" ? reader.result : ""
17+
const idx = value.indexOf(",")
18+
if (idx === -1) {
19+
resolve(value)
20+
return
21+
}
22+
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
23+
})
24+
reader.readAsDataURL(file)
25+
})
26+
}
27+
1328
function largePaste(text: string) {
1429
if (text.length >= LARGE_PASTE_CHARS) return true
1530
let breaks = 0
@@ -35,28 +50,41 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
3550
const prompt = usePrompt()
3651
const language = useLanguage()
3752

38-
const addImageAttachment = async (file: File) => {
39-
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
53+
const warn = () => {
54+
showToast({
55+
title: language.t("prompt.toast.pasteUnsupported.title"),
56+
description: language.t("prompt.toast.pasteUnsupported.description"),
57+
})
58+
}
4059

41-
const reader = new FileReader()
42-
reader.onload = () => {
43-
const editor = input.editor()
44-
if (!editor) return
45-
const dataUrl = reader.result as string
46-
const attachment: ImageAttachmentPart = {
47-
type: "image",
48-
id: uuid(),
49-
filename: file.name,
50-
mime: file.type,
51-
dataUrl,
52-
}
53-
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
54-
prompt.set([...prompt.current(), attachment], cursorPosition)
60+
const add = async (file: File, toast = true) => {
61+
const mime = await attachmentMime(file)
62+
if (!mime) {
63+
if (toast) warn()
64+
return false
5565
}
56-
reader.readAsDataURL(file)
66+
67+
const editor = input.editor()
68+
if (!editor) return false
69+
70+
const url = await dataUrl(file, mime)
71+
if (!url) return false
72+
73+
const attachment: ImageAttachmentPart = {
74+
type: "image",
75+
id: uuid(),
76+
filename: file.name,
77+
mime,
78+
dataUrl: url,
79+
}
80+
const cursor = prompt.cursor() ?? getCursorPosition(editor)
81+
prompt.set([...prompt.current(), attachment], cursor)
82+
return true
5783
}
5884

59-
const removeImageAttachment = (id: string) => {
85+
const addAttachment = (file: File) => add(file)
86+
87+
const removeAttachment = (id: string) => {
6088
const current = prompt.current()
6189
const next = current.filter((part) => part.type !== "image" || part.id !== id)
6290
prompt.set(next, prompt.cursor())
@@ -72,21 +100,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
72100

73101
const items = Array.from(clipboardData.items)
74102
const fileItems = items.filter((item) => item.kind === "file")
75-
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
76103

77-
if (imageItems.length > 0) {
78-
for (const item of imageItems) {
104+
if (fileItems.length > 0) {
105+
let found = false
106+
for (const item of fileItems) {
79107
const file = item.getAsFile()
80-
if (file) await addImageAttachment(file)
108+
if (!file) continue
109+
const ok = await add(file, false)
110+
if (ok) found = true
81111
}
82-
return
83-
}
84-
85-
if (fileItems.length > 0) {
86-
showToast({
87-
title: language.t("prompt.toast.pasteUnsupported.title"),
88-
description: language.t("prompt.toast.pasteUnsupported.description"),
89-
})
112+
if (!found) warn()
90113
return
91114
}
92115

@@ -96,7 +119,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
96119
if (input.readClipboardImage && !plainText) {
97120
const file = await input.readClipboardImage()
98121
if (file) {
99-
await addImageAttachment(file)
122+
await addAttachment(file)
100123
return
101124
}
102125
}
@@ -153,11 +176,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
153176
const dropped = event.dataTransfer?.files
154177
if (!dropped) return
155178

179+
let found = false
156180
for (const file of Array.from(dropped)) {
157-
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
158-
await addImageAttachment(file)
159-
}
181+
const ok = await add(file, false)
182+
if (ok) found = true
160183
}
184+
if (!found && dropped.length > 0) warn()
161185
}
162186

163187
onMount(() => {
@@ -173,8 +197,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
173197
})
174198

175199
return {
176-
addImageAttachment,
177-
removeImageAttachment,
200+
addAttachment,
201+
removeAttachment,
178202
handlePaste,
179203
}
180204
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
2+
3+
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
4+
const IMAGE_EXTS = new Map([
5+
["gif", "image/gif"],
6+
["jpeg", "image/jpeg"],
7+
["jpg", "image/jpeg"],
8+
["png", "image/png"],
9+
["webp", "image/webp"],
10+
])
11+
const TEXT_MIMES = new Set([
12+
"application/json",
13+
"application/ld+json",
14+
"application/toml",
15+
"application/x-toml",
16+
"application/x-yaml",
17+
"application/xml",
18+
"application/yaml",
19+
])
20+
21+
export const ACCEPTED_FILE_TYPES = [
22+
...ACCEPTED_IMAGE_TYPES,
23+
"application/pdf",
24+
"text/*",
25+
"application/json",
26+
"application/ld+json",
27+
"application/toml",
28+
"application/x-toml",
29+
"application/x-yaml",
30+
"application/xml",
31+
"application/yaml",
32+
".c",
33+
".cc",
34+
".cjs",
35+
".conf",
36+
".cpp",
37+
".css",
38+
".csv",
39+
".cts",
40+
".env",
41+
".go",
42+
".gql",
43+
".graphql",
44+
".h",
45+
".hh",
46+
".hpp",
47+
".htm",
48+
".html",
49+
".ini",
50+
".java",
51+
".js",
52+
".json",
53+
".jsx",
54+
".log",
55+
".md",
56+
".mdx",
57+
".mjs",
58+
".mts",
59+
".py",
60+
".rb",
61+
".rs",
62+
".sass",
63+
".scss",
64+
".sh",
65+
".sql",
66+
".toml",
67+
".ts",
68+
".tsx",
69+
".txt",
70+
".xml",
71+
".yaml",
72+
".yml",
73+
".zsh",
74+
]
75+
76+
const SAMPLE = 4096
77+
78+
function kind(type: string) {
79+
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
80+
}
81+
82+
function ext(name: string) {
83+
const idx = name.lastIndexOf(".")
84+
if (idx === -1) return ""
85+
return name.slice(idx + 1).toLowerCase()
86+
}
87+
88+
function textMime(type: string) {
89+
if (!type) return false
90+
if (type.startsWith("text/")) return true
91+
if (TEXT_MIMES.has(type)) return true
92+
if (type.endsWith("+json")) return true
93+
return type.endsWith("+xml")
94+
}
95+
96+
function textBytes(bytes: Uint8Array) {
97+
if (bytes.length === 0) return true
98+
let count = 0
99+
for (const byte of bytes) {
100+
if (byte === 0) return false
101+
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
102+
}
103+
return count / bytes.length <= 0.3
104+
}
105+
106+
export async function attachmentMime(file: File) {
107+
const type = kind(file.type)
108+
if (IMAGE_MIMES.has(type)) return type
109+
if (type === "application/pdf") return type
110+
111+
const suffix = ext(file.name)
112+
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
113+
if ((!type || type === "application/octet-stream") && fallback) return fallback
114+
115+
if (textMime(type)) return "text/plain"
116+
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
117+
if (!textBytes(bytes)) return
118+
return "text/plain"
119+
}

packages/app/src/i18n/ar.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ export const dict = {
244244
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
245245
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
246246
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
247-
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
247+
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
248248
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
249249
"prompt.slash.badge.custom": "مخصص",
250250
"prompt.slash.badge.skill": "مهارة",
@@ -257,8 +257,8 @@ export const dict = {
257257
"prompt.attachment.remove": "إزالة المرفق",
258258
"prompt.action.send": "إرسال",
259259
"prompt.action.stop": "توقف",
260-
"prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
261-
"prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
260+
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
261+
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
262262
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
263263
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
264264
"prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",

packages/app/src/i18n/br.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ export const dict = {
244244
"prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
245245
"prompt.popover.emptyResults": "Nenhum resultado correspondente",
246246
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
247-
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
247+
"prompt.dropzone.label": "Arraste imagens, PDFs ou arquivos de texto aqui",
248248
"prompt.dropzone.file.label": "Solte para @mencionar arquivo",
249249
"prompt.slash.badge.custom": "personalizado",
250250
"prompt.slash.badge.skill": "skill",
@@ -257,8 +257,8 @@ export const dict = {
257257
"prompt.attachment.remove": "Remover anexo",
258258
"prompt.action.send": "Enviar",
259259
"prompt.action.stop": "Parar",
260-
"prompt.toast.pasteUnsupported.title": "Colagem não suportada",
261-
"prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
260+
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
261+
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
262262
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
263263
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
264264
"prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",

packages/app/src/i18n/bs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export const dict = {
264264

265265
"prompt.popover.emptyResults": "Nema rezultata",
266266
"prompt.popover.emptyCommands": "Nema komandi",
267-
"prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
267+
"prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
268268
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
269269
"prompt.slash.badge.custom": "prilagođeno",
270270
"prompt.slash.badge.skill": "skill",
@@ -278,8 +278,8 @@ export const dict = {
278278
"prompt.action.send": "Pošalji",
279279
"prompt.action.stop": "Zaustavi",
280280

281-
"prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje",
282-
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.",
281+
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
282+
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
283283
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
284284
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
285285
"prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",

0 commit comments

Comments
 (0)