Files
openchamber/packages/ui/src/sync/attachment-files.ts
T
Bohdan Triapitsyn d654911925 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.
2026-07-22 14:40:40 +03:00

413 lines
13 KiB
TypeScript

const ACCEPTED_ATTACHMENT_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/heic",
"image/heif",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.spreadsheet",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".bat",
".c",
".cc",
".cjs",
".cmd",
".conf",
".cpp",
".cs",
".css",
".csv",
".cts",
".dart",
".diff",
".docx",
".drawio",
".env",
".erl",
".ex",
".exs",
".fs",
".fsx",
".go",
".gql",
".graphql",
".h",
".har",
".hh",
".hcl",
".heic",
".heif",
".hpp",
".hrl",
".htm",
".html",
".ini",
".ipynb",
".java",
".jl",
".js",
".json",
".jsonl",
".jsx",
".kt",
".kts",
".log",
".lua",
".md",
".mdx",
".mjs",
".mts",
".ndjson",
".odp",
".ods",
".odt",
".patch",
".php",
".proto",
".pptx",
".ps1",
".py",
".r",
".rb",
".rs",
".sass",
".scala",
".scss",
".sh",
".sol",
".sql",
".svelte",
".svg",
".swift",
".tf",
".toml",
".ts",
".tsx",
".txt",
".vue",
".xml",
".xlsx",
".yaml",
".yml",
".zig",
".zsh",
] as const
export const ATTACHMENT_ACCEPT = ACCEPTED_ATTACHMENT_TYPES.join(",")
const PICKER_MIME_EXTENSIONS = new Map<string, string>([
["image/png", "png"],
["image/jpeg", "jpg"],
["image/gif", "gif"],
["image/webp", "webp"],
["image/heic", "heic"],
["image/heif", "heif"],
["application/pdf", "pdf"],
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"],
["application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx"],
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"],
["application/vnd.oasis.opendocument.text", "odt"],
["application/vnd.oasis.opendocument.presentation", "odp"],
["application/vnd.oasis.opendocument.spreadsheet", "ods"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
["application/x-toml", "toml"],
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
])
const TEXT_ATTACHMENT_EXTENSIONS = ["txt", "text", "md", "markdown", "log", "csv"]
export const ACCEPTED_ATTACHMENT_EXTENSIONS = Array.from(new Set(
ACCEPTED_ATTACHMENT_TYPES.flatMap((type) => {
if (type.startsWith(".")) return [type.slice(1)]
if (type === "text/*") return TEXT_ATTACHMENT_EXTENSIONS
const extension = PICKER_MIME_EXTENSIONS.get(type)
return extension ? [extension] : []
})
)).sort()
type OpenCodeAttachmentMimeType =
| "image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "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"],
["image/gif", "image/gif"],
["image/webp", "image/webp"],
["application/pdf", "application/pdf"],
])
const SUPPORTED_BINARY_EXTENSIONS = new Map<string, OpenCodeAttachmentMimeType>([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["pdf", "application/pdf"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
"image/svg+xml",
])
const ATTACHMENT_SAMPLE_BYTES = 4096
const DOCUMENT_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"])
const REDACTED = "[REDACTED]"
const OMITTED = "[OMITTED BY OPENCHAMBER]"
const SENSITIVE_NAMES = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|client[-_]?secret|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i
type PreparedAttachmentFile = {
file: File
mimeType: string
}
const extensionOf = (name: string): string => {
const index = name.lastIndexOf(".")
return index === -1 ? "" : name.slice(index + 1).toLowerCase()
}
const declaredMimeOf = (file: File): string => file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => {
const bytes = new Uint8Array(await file.slice(0, ATTACHMENT_SAMPLE_BYTES).arrayBuffer())
if (bytes.some((byte) => byte === 0)) return
const controlBytes = bytes.filter((byte) => byte < 9 || (byte > 13 && byte < 32)).length
if (bytes.length > 0 && controlBytes / bytes.length > 0.3) return
return "text/plain"
}
const attachmentMime = (
file: File,
): OpenCodeAttachmentMimeType | Promise<"text/plain" | undefined> | undefined => {
const type = declaredMimeOf(file)
const supportedMime = SUPPORTED_BINARY_MIMES.get(type)
if (supportedMime) return supportedMime
const extension = extensionOf(file.name)
const fallback = SUPPORTED_BINARY_EXTENSIONS.get(extension)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (type.startsWith("text/") || TEXT_MIMES.has(type) || type.endsWith("+json") || type.endsWith("+xml")) {
return "text/plain"
}
return inspectTextContent(file)
}
const sourceText = (source: unknown): string => {
if (typeof source === "string") return source
if (Array.isArray(source)) return source.filter((line): line is string => typeof line === "string").join("")
return ""
}
const notebookText = (value: unknown, filename: string): string | undefined => {
if (!value || typeof value !== "object") return
const notebook = value as { cells?: unknown; metadata?: { kernelspec?: { language?: unknown } } }
if (!Array.isArray(notebook.cells)) return
const language = typeof notebook.metadata?.kernelspec?.language === "string"
? notebook.metadata.kernelspec.language
: ""
const sections = [`# Notebook: ${filename}`]
notebook.cells.forEach((rawCell, index) => {
if (!rawCell || typeof rawCell !== "object") return
const cell = rawCell as { cell_type?: unknown; source?: unknown; outputs?: unknown }
const content = sourceText(cell.source).trimEnd()
if (cell.cell_type === "markdown") {
sections.push(`## Markdown cell ${index + 1}\n\n${content}`)
return
}
if (cell.cell_type !== "code") return
sections.push(`## Code cell ${index + 1}\n\n\`\`\`${language}\n${content}\n\`\`\``)
if (!Array.isArray(cell.outputs)) return
const outputs: string[] = []
for (const rawOutput of cell.outputs) {
if (!rawOutput || typeof rawOutput !== "object") continue
const output = rawOutput as { text?: unknown; traceback?: unknown; data?: unknown; ename?: unknown; evalue?: unknown }
const text = sourceText(output.text) || sourceText(output.traceback)
if (text) {
outputs.push(text.trimEnd())
continue
}
if (output.data && typeof output.data === "object") {
const data = output.data as Record<string, unknown>
const plain = sourceText(data["text/plain"])
if (plain) outputs.push(plain.trimEnd())
const omitted = Object.keys(data).filter((type) => type !== "text/plain")
if (omitted.length > 0) outputs.push(`[Non-text output omitted: ${omitted.join(", ")}]`)
continue
}
if (typeof output.ename === "string" || typeof output.evalue === "string") {
outputs.push(`${String(output.ename ?? "Error")}: ${String(output.evalue ?? "")}`.trimEnd())
}
}
if (outputs.length > 0) sections.push(`### Output\n\n${outputs.join("\n\n")}`)
})
return `${sections.join("\n\n")}\n`
}
const redactUrl = (value: string): string => {
try {
const url = new URL(value)
for (const name of Array.from(url.searchParams.keys())) {
if (SENSITIVE_NAMES.test(name)) url.searchParams.set(name, REDACTED)
}
return url.toString()
} catch {
return value
}
}
const sanitizeHarValue = (value: unknown, key?: string): unknown => {
if (key && SENSITIVE_NAMES.test(key)) return REDACTED
if (key === "cookies" && Array.isArray(value)) {
return value.map((cookie) => {
if (!cookie || typeof cookie !== "object") return cookie
return { ...(cookie as Record<string, unknown>), value: REDACTED }
})
}
if (key === "text" || key === "encoding") return OMITTED
if (typeof value === "string") return key === "url" ? redactUrl(value) : value
if (Array.isArray(value)) return value.map((item) => sanitizeHarValue(item))
if (!value || typeof value !== "object") return value
const record = value as Record<string, unknown>
const sensitiveEntry = typeof record.name === "string" && SENSITIVE_NAMES.test(record.name)
return Object.fromEntries(Object.entries(record).map(([entryKey, entryValue]) => [
entryKey,
sensitiveEntry && entryKey === "value" ? REDACTED : sanitizeHarValue(entryValue, entryKey),
]))
}
const prepareStructuredText = async (file: File, extension: string): Promise<File | undefined> => {
const text = await file.text()
if (extension === "har") {
try {
const sanitized = sanitizeHarValue(JSON.parse(text))
return new File([`${JSON.stringify(sanitized, null, 2)}\n`], file.name, { type: "text/plain" })
} catch {
return
}
}
if (extension === "ipynb") {
try {
const rendered = notebookText(JSON.parse(text), file.name)
if (rendered) return new File([rendered], file.name, { type: "text/plain" })
} catch {
// Invalid notebooks can still be useful as plain text.
}
}
return new File([text], file.name, { type: "text/plain" })
}
const convertHeicToJpeg = async (file: File): Promise<File | undefined> => {
try {
const heic2any = (await import("heic2any")).default
const converted = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 })
const blob = Array.isArray(converted) ? converted[0] : converted
if (!blob) return
const filename = file.name.replace(/\.(heic|heif)$/i, ".jpg")
return new File([blob], filename, { type: "image/jpeg" })
} catch (error) {
console.warn("Failed to convert HEIC attachment to JPEG", error)
return
}
}
export const prepareAttachmentFile = (
file: File,
): PreparedAttachmentFile | Promise<PreparedAttachmentFile | undefined> | undefined => {
const extension = extensionOf(file.name)
const type = declaredMimeOf(file)
if (type === "image/heic" || type === "image/heif" || extension === "heic" || extension === "heif") {
return convertHeicToJpeg(file).then((converted) => converted
? { file: converted, mimeType: "image/jpeg" }
: undefined)
}
if (extension === "har" || extension === "ipynb") {
return prepareStructuredText(file, extension).then((prepared) => prepared
? { file: prepared, mimeType: "text/plain" }
: undefined)
}
const mime = attachmentMime(file)
if (typeof mime === "string") return { file, mimeType: mime }
return mime?.then((mimeType) => mimeType ? { file, mimeType } : undefined)
}
export const prepareAttachmentFiles = (
file: File,
reservedFilenames: Iterable<string> = [],
): PreparedAttachmentFile[] | Promise<PreparedAttachmentFile[] | undefined> | undefined => {
if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) {
const prepared = prepareAttachmentFile(file)
if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined)
return prepared ? [prepared] : undefined
}
return import("./document-attachments").then(async ({ extractDocumentAttachments }) => {
const extracted = await extractDocumentAttachments(file, reservedFilenames)
if (!extracted) return
const prepared: PreparedAttachmentFile[] = [{ file: extracted.textFile, mimeType: "text/plain" }]
for (const image of extracted.images) {
const output = await prepareAttachmentFile(image)
if (!output || !output.mimeType.startsWith("image/")) return
prepared.push(output)
}
return prepared
})
}