feat: warn about unsupported attachment inputs

Compare normalized attachment MIME types with the selected model's declared input modalities and show a non-blocking warning for incompatible files.

Recheck newly added attachments, restored drafts, async metadata, and existing files after model changes while avoiding warnings when capability metadata is unavailable. Summarize affected filenames and localize the warning across every supported locale.

Add focused modality compatibility coverage, document the composer behavior, and keep model metadata subscriptions stable to prevent startup render loops.
This commit is contained in:
Bohdan Triapitsyn
2026-07-22 14:40:40 +03:00
parent fd0f6a6bac
commit d654911925
14 changed files with 123 additions and 1 deletions
+2
View File
@@ -57,6 +57,8 @@ Local chat attachments are normalized by `attachment-files.ts` before entering `
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
The composer compares normalized attachment MIME types with the selected model's declared input modalities. It warns when a newly attached file or an existing attachment after a model change requires an unsupported modality, but does not block sending. Missing modality metadata remains unknown and does not produce a warning.
## Session list rules
### Directory bootstrap scheduling
@@ -2,6 +2,8 @@ import { describe, expect, mock, test } from "bun:test"
import {
ACCEPTED_ATTACHMENT_EXTENSIONS,
ATTACHMENT_ACCEPT,
getAttachmentInputModality,
getUnsupportedAttachmentInputs,
prepareAttachmentFile,
} from "./attachment-files"
@@ -12,6 +14,28 @@ mock.module("heic2any", () => ({
const prepare = (file: File) => Promise.resolve(prepareAttachmentFile(file))
describe("attachment file preparation", () => {
test("maps normalized attachment MIME types to model input modalities", () => {
expect(getAttachmentInputModality("text/plain;charset=utf-8")).toBe("text")
expect(getAttachmentInputModality("image/jpeg")).toBe("image")
expect(getAttachmentInputModality("application/pdf")).toBe("pdf")
expect(getAttachmentInputModality("audio/mpeg")).toBe("audio")
expect(getAttachmentInputModality("video/mp4")).toBe("video")
expect(getAttachmentInputModality("application/octet-stream")).toBe(undefined)
})
test("returns only attachment inputs unsupported by the model", () => {
const attachments = [
{ filename: "notes.txt", mimeType: "text/plain" },
{ filename: "photo.jpg", mimeType: "image/jpeg" },
{ filename: "report.pdf", mimeType: "application/pdf" },
{ filename: "unknown.bin", mimeType: "application/octet-stream" },
]
expect(getUnsupportedAttachmentInputs(attachments, ["TEXT", "pdf"])).toEqual([
{ attachment: attachments[1], modality: "image" },
])
})
test("exposes the expanded code and structured-text formats to pickers", () => {
for (const extension of [
"diff", "patch", "ipynb", "jsonl", "ndjson", "har", "svg", "drawio",
+27
View File
@@ -149,6 +149,33 @@ type OpenCodeAttachmentMimeType =
| "application/pdf"
| "text/plain"
export type AttachmentInputModality = "text" | "image" | "pdf" | "audio" | "video"
export const getAttachmentInputModality = (mimeType: string): AttachmentInputModality | undefined => {
const normalizedMimeType = mimeType.toLowerCase().split(";", 1)[0]?.trim() ?? ""
if (normalizedMimeType.startsWith("image/")) return "image"
if (normalizedMimeType.startsWith("audio/")) return "audio"
if (normalizedMimeType.startsWith("video/")) return "video"
if (normalizedMimeType === "application/pdf") return "pdf"
if (normalizedMimeType.startsWith("text/")) return "text"
return undefined
}
export const getUnsupportedAttachmentInputs = <T extends { mimeType: string }>(
attachments: T[],
supportedInputModalities: string[],
): Array<{ attachment: T; modality: AttachmentInputModality }> => {
const supportedModalities = new Set(supportedInputModalities.map((modality) => modality.toLowerCase()))
const unsupportedInputs: Array<{ attachment: T; modality: AttachmentInputModality }> = []
for (const attachment of attachments) {
const modality = getAttachmentInputModality(attachment.mimeType)
if (modality && !supportedModalities.has(modality)) {
unsupportedInputs.push({ attachment, modality })
}
}
return unsupportedInputs
}
const SUPPORTED_BINARY_MIMES = new Map<string, OpenCodeAttachmentMimeType>([
["image/png", "image/png"],
["image/jpeg", "image/jpeg"],