Skip to content

Commit 94472ea

Browse files
committed
Enhance attachment handling by refining MIME type detection and categorization.
Updated the `attachments.py` file to improve image and text MIME type handling, introducing new constants for vision-compatible image types and text MIME types. Added a `resolve_kind` function to classify attachments more accurately based on their MIME type and filename. Refactored the `decode_attachment` and `build_human_content` functions to utilize the new classification logic. Updated the UI components to reflect these changes, ensuring proper attachment previews and categorization in the chat application.
1 parent 4c6cd88 commit 94472ea

3 files changed

Lines changed: 197 additions & 30 deletions

File tree

app/attachments.py

Lines changed: 141 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
1414

15+
# Vision APIs (OpenAI / compatible) only accept these image MIME types.
16+
# Do not treat other image/* types (SVG, BMP, TIFF, HEIC, …) as vision images.
1517
IMAGE_TYPES = frozenset(
1618
{"image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"}
1719
)
@@ -23,46 +25,154 @@
2325
"text/html",
2426
"text/css",
2527
"text/javascript",
28+
"text/typescript",
29+
"text/x-python",
30+
"text/x-script.python",
31+
"text/x-java-source",
32+
"text/x-c",
33+
"text/x-c++src",
34+
"text/x-golang",
35+
"text/x-ruby",
36+
"text/x-rust",
37+
"text/x-php",
38+
"text/x-shellscript",
39+
"text/x-sql",
40+
"text/tab-separated-values",
41+
"text/xml",
2642
"application/json",
43+
"application/ld+json",
2744
"application/xml",
45+
"application/xhtml+xml",
2846
"application/x-yaml",
2947
"application/yaml",
3048
"application/javascript",
3149
"application/typescript",
50+
"application/sql",
51+
"application/graphql",
52+
"application/x-sh",
53+
"application/x-httpd-php",
54+
"image/svg+xml",
3255
}
3356
)
3457
TEXT_EXTENSIONS = frozenset(
3558
{
3659
".txt",
3760
".md",
3861
".markdown",
62+
".mdx",
63+
".rst",
3964
".csv",
65+
".tsv",
4066
".json",
67+
".jsonl",
68+
".jsonc",
4169
".yaml",
4270
".yml",
4371
".xml",
72+
".svg",
4473
".html",
4574
".htm",
75+
".xhtml",
4676
".css",
77+
".scss",
78+
".sass",
79+
".less",
4780
".js",
4881
".jsx",
82+
".mjs",
83+
".cjs",
4984
".ts",
5085
".tsx",
86+
".vue",
87+
".svelte",
88+
".astro",
5189
".py",
90+
".pyi",
5291
".rb",
5392
".go",
5493
".rs",
5594
".java",
5695
".kt",
96+
".kts",
5797
".swift",
5898
".php",
99+
".phtml",
59100
".sql",
60101
".sh",
102+
".bash",
103+
".zsh",
104+
".fish",
105+
".ps1",
106+
".bat",
107+
".cmd",
61108
".env",
62109
".toml",
63110
".ini",
64111
".cfg",
112+
".conf",
113+
".config",
114+
".properties",
65115
".log",
116+
".c",
117+
".cc",
118+
".cpp",
119+
".cxx",
120+
".h",
121+
".hh",
122+
".hpp",
123+
".hxx",
124+
".cs",
125+
".fs",
126+
".fsx",
127+
".dart",
128+
".lua",
129+
".pl",
130+
".pm",
131+
".r",
132+
".rmd",
133+
".jl",
134+
".ex",
135+
".exs",
136+
".erl",
137+
".hrl",
138+
".clj",
139+
".cljs",
140+
".scala",
141+
".sc",
142+
".groovy",
143+
".gradle",
144+
".m",
145+
".mm",
146+
".zig",
147+
".nim",
148+
".v",
149+
".vb",
150+
".tf",
151+
".hcl",
152+
".graphql",
153+
".gql",
154+
".proto",
155+
".prisma",
156+
".dockerfile",
157+
".editorconfig",
158+
".gitignore",
159+
".gitattributes",
160+
".dockerignore",
161+
".npmrc",
162+
".nvmrc",
163+
".eslintrc",
164+
".prettierrc",
165+
".babelrc",
166+
".lock",
167+
".plist",
168+
}
169+
)
170+
171+
# Suffixes that look text-ish by name but are binary — never inline as text.
172+
BINARY_EXTENSIONS = frozenset(
173+
{
174+
".wasm",
175+
".svgz", # gzip-compressed SVG
66176
}
67177
)
68178

@@ -81,13 +191,35 @@ def guess_mime(filename: str, content_type: str | None = None) -> str:
81191

82192

83193
def is_image(mime: str) -> bool:
84-
return mime in IMAGE_TYPES or mime.startswith("image/")
194+
"""True only for vision-API-compatible image MIME types."""
195+
return mime.split(";")[0].strip().lower() in IMAGE_TYPES
85196

86197

87198
def is_text_like(mime: str, filename: str) -> bool:
199+
mime = mime.split(";")[0].strip().lower()
200+
suffix = Path(filename).suffix.lower()
201+
if suffix in BINARY_EXTENSIONS:
202+
return False
88203
if mime in TEXT_TYPES or mime.startswith("text/"):
89204
return True
90-
return Path(filename).suffix.lower() in TEXT_EXTENSIONS
205+
# SVG often arrives as application/octet-stream from some browsers.
206+
if suffix == ".svg":
207+
return True
208+
return suffix in TEXT_EXTENSIONS and suffix not in BINARY_EXTENSIONS
209+
210+
211+
def resolve_kind(mime: str, filename: str, hint: str | None = None) -> str:
212+
"""Classify attachment for the LLM, ignoring unsafe client image hints."""
213+
if is_image(mime):
214+
return "image"
215+
if is_text_like(mime, filename):
216+
return "text"
217+
hint = (hint or "").strip().lower()
218+
if hint == "text":
219+
return "text"
220+
# Clients often mark any image/* (including SVG) as "image". Only honor
221+
# that hint when the MIME is vision-safe — already handled above.
222+
return "file"
91223

92224

93225
def decode_attachment(att: dict[str, Any]) -> tuple[dict[str, Any], bytes]:
@@ -109,16 +241,7 @@ def decode_attachment(att: dict[str, Any]) -> tuple[dict[str, Any], bytes]:
109241
f"Attachment {name!r} exceeds max size "
110242
f"({settings.attachments_max_bytes} bytes)"
111243
)
112-
kind = (
113-
str(att.get("kind") or "")
114-
or (
115-
"image"
116-
if is_image(mime)
117-
else "text"
118-
if is_text_like(mime, name)
119-
else "file"
120-
)
121-
)
244+
kind = resolve_kind(mime, name, str(att.get("kind") or "") or None)
122245
meta = {
123246
"id": str(att.get("id") or name),
124247
"name": name,
@@ -167,25 +290,27 @@ def build_human_content(
167290
name = att.get("name") or "file"
168291
mime = att.get("mime") or "application/octet-stream"
169292
size = int(att.get("size") or 0)
170-
kind = att.get("kind") or "file"
171293
data: bytes | None = att.get("_bytes")
172294
if data is None and att.get("content_base64"):
173295
try:
174296
_, data = decode_attachment(att)
175297
except Exception: # noqa: BLE001
176298
data = None
177299
aid = att.get("id") or name
300+
kind = resolve_kind(mime, str(name), str(att.get("kind") or "") or None)
178301

179-
if (kind == "image" or is_image(mime)) and data:
302+
if kind == "image" and data:
303+
# Normalize jpeg alias for providers that reject image/jpg.
304+
vision_mime = "image/jpeg" if mime == "image/jpg" else mime
180305
b64 = base64.b64encode(data).decode("ascii")
181306
image_parts.append(
182307
{
183308
"type": "image_url",
184-
"image_url": {"url": f"data:{mime};base64,{b64}"},
309+
"image_url": {"url": f"data:{vision_mime};base64,{b64}"},
185310
}
186311
)
187312
notes.append(f"- image `{name}` (id=`{aid}`, {mime}, {size} bytes)")
188-
elif (kind == "text" or is_text_like(mime, name)) and data:
313+
elif kind == "text" and data:
189314
body = text_from_bytes(data, max_chars=8_000)
190315
notes.append(
191316
f"- text file `{name}` (id=`{aid}`, {mime}, {size} bytes):\n"

ui/src/components/chat-app.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
import { Markdown } from "@/components/markdown"
104104
import { MessageMetadataSheet } from "@/components/message-metadata-sheet"
105105
import {
106+
attachmentKind,
106107
encodeAttachment,
107108
ensureApiKey,
108109
fetchReady,
@@ -168,17 +169,21 @@ function FileChip({
168169
}
169170
onRemove?: () => void
170171
}) {
171-
const isImage = file.kind === "image" || file.mime.startsWith("image/")
172+
const showPreview = file.mime.startsWith("image/") && Boolean(file.previewUrl)
172173
const Icon =
173-
isImage ? ImageIcon : file.kind === "text" ? FileTextIcon : FileIcon
174+
file.kind === "image" || file.mime.startsWith("image/")
175+
? ImageIcon
176+
: file.kind === "text"
177+
? FileTextIcon
178+
: FileIcon
174179
return (
175180
<Attachment
176181
size="sm"
177182
state={file.state || "done"}
178183
className="max-w-[14rem]"
179184
>
180-
<AttachmentMedia variant={isImage && file.previewUrl ? "image" : "icon"}>
181-
{isImage && file.previewUrl ? (
185+
<AttachmentMedia variant={showPreview ? "image" : "icon"}>
186+
{showPreview ? (
182187
<img src={file.previewUrl} alt={file.name} />
183188
) : (
184189
<Icon />
@@ -407,7 +412,7 @@ export function ChatApp() {
407412
name: file.name,
408413
mime: file.type || "application/octet-stream",
409414
size: file.size,
410-
kind: file.type.startsWith("image/") ? "image" : "file",
415+
kind: attachmentKind(file.type || "application/octet-stream", file.name),
411416
previewUrl,
412417
state: "uploading",
413418
},

ui/src/lib/api.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -298,23 +298,60 @@ function fileToBase64(file: File): Promise<string> {
298298
})
299299
}
300300

301+
const VISION_IMAGE_TYPES = new Set([
302+
"image/png",
303+
"image/jpeg",
304+
"image/jpg",
305+
"image/gif",
306+
"image/webp",
307+
])
308+
309+
const TEXT_MIME_TYPES = new Set([
310+
"application/json",
311+
"application/ld+json",
312+
"application/xml",
313+
"application/xhtml+xml",
314+
"application/x-yaml",
315+
"application/yaml",
316+
"application/javascript",
317+
"application/typescript",
318+
"application/sql",
319+
"application/graphql",
320+
"application/x-sh",
321+
"application/x-httpd-php",
322+
"image/svg+xml",
323+
])
324+
325+
const TEXT_EXTENSION_RE =
326+
/\.(txt|md|mdx|markdown|rst|json|jsonl|jsonc|ya?ml|xml|svg|csv|tsv|html?|xhtml|css|scss|sass|less|js|jsx|mjs|cjs|ts|tsx|vue|svelte|astro|php|phtml|py|pyi|go|rs|java|kt|kts|swift|rb|c|cc|cpp|cxx|h|hh|hpp|hxx|cs|fs|fsx|dart|lua|pl|pm|r|rmd|jl|ex|exs|erl|hrl|clj|cljs|scala|sc|groovy|gradle|m|mm|zig|nim|v|vb|tf|hcl|graphql|gql|proto|prisma|sql|sh|bash|zsh|fish|ps1|bat|cmd|env|toml|ini|cfg|conf|config|properties|log|dockerfile|editorconfig|gitignore|gitattributes|dockerignore|npmrc|nvmrc|eslintrc|prettierrc|babelrc|lock|plist)$/i
327+
328+
export function attachmentKind(
329+
mime: string,
330+
name: string
331+
): "image" | "text" | "file" {
332+
const normalized = mime.split(";")[0]?.trim().toLowerCase() || ""
333+
if (VISION_IMAGE_TYPES.has(normalized)) {
334+
return "image"
335+
}
336+
if (
337+
normalized.startsWith("text/") ||
338+
TEXT_MIME_TYPES.has(normalized) ||
339+
TEXT_EXTENSION_RE.test(name)
340+
) {
341+
return "text"
342+
}
343+
return "file"
344+
}
345+
301346
export async function encodeAttachment(file: File): Promise<ChatAttachment> {
302347
const content_base64 = await fileToBase64(file)
303348
const mime = file.type || "application/octet-stream"
304-
const kind = mime.startsWith("image/")
305-
? "image"
306-
: mime.startsWith("text/") ||
307-
/\.(txt|md|json|ya?ml|csv|xml|html?|css|js|ts|tsx|jsx|py)$/i.test(
308-
file.name
309-
)
310-
? "text"
311-
: "file"
312349
return {
313350
id: `${file.name}-${file.size}-${file.lastModified}`,
314351
name: file.name,
315352
mime,
316353
size: file.size,
317-
kind,
354+
kind: attachmentKind(mime, file.name),
318355
content_base64,
319356
}
320357
}

0 commit comments

Comments
 (0)