From 2c27f21a900073ec815697a90c9fbf3329c92c6c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 22:45:05 +0700 Subject: [PATCH] Treat SVG attachments as files --- .../app/src/attachments/file-types.test.ts | 31 ++++++ packages/app/src/attachments/file-types.ts | 94 +++++++++++++++++++ packages/app/src/attachments/utils.test.ts | 4 + packages/app/src/attachments/utils.ts | 3 +- .../app/src/hooks/image-attachment-picker.ts | 17 +--- packages/app/src/hooks/use-file-drop-zone.ts | 43 ++------- packages/app/src/hooks/use-file-picker.ts | 57 +---------- packages/app/src/panels/agent-panel.tsx | 30 ++---- .../image-attachments-from-files.test.ts | 18 ++++ .../src/utils/image-attachments-from-files.ts | 3 +- 10 files changed, 170 insertions(+), 130 deletions(-) create mode 100644 packages/app/src/attachments/file-types.test.ts create mode 100644 packages/app/src/attachments/file-types.ts diff --git a/packages/app/src/attachments/file-types.test.ts b/packages/app/src/attachments/file-types.test.ts new file mode 100644 index 000000000..cb90e3667 --- /dev/null +++ b/packages/app/src/attachments/file-types.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + getMimeTypeFromPath, + getRasterImageMimeTypeFromPath, + isRasterImageFile, + isRasterImageMimeType, + isRasterImagePath, + RASTER_IMAGE_FILE_EXTENSIONS, +} from "./file-types"; + +describe("attachment file types", () => { + it("keeps SVG as a file while treating raster image files as images", () => { + expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("image/svg+xml"); + expect(isRasterImagePath("/tmp/logo.svg")).toBe(false); + expect(isRasterImageMimeType("image/svg+xml")).toBe(false); + expect(isRasterImageFile(new File([""], "logo.svg", { type: "image/svg+xml" }))).toBe( + false, + ); + + expect(getRasterImageMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png"); + expect(isRasterImagePath("/tmp/screenshot.PNG?cache=1")).toBe(true); + expect(isRasterImageMimeType("image/png; charset=binary")).toBe(true); + expect(isRasterImageFile(new File([new Uint8Array([0])], "screenshot.png"))).toBe(true); + }); + + it("does not offer SVG in the image picker extension list", () => { + expect(new Set(RASTER_IMAGE_FILE_EXTENSIONS)).toEqual( + new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]), + ); + }); +}); diff --git a/packages/app/src/attachments/file-types.ts b/packages/app/src/attachments/file-types.ts new file mode 100644 index 000000000..9144518ce --- /dev/null +++ b/packages/app/src/attachments/file-types.ts @@ -0,0 +1,94 @@ +const MIME_TYPE_BY_EXTENSION: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".heic": "image/heic", + ".heif": "image/heif", + ".avif": "image/avif", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".md": "text/markdown", + ".json": "application/json", + ".js": "text/javascript", + ".ts": "text/typescript", + ".tsx": "text/typescript-jsx", + ".jsx": "text/javascript", + ".html": "text/html", + ".css": "text/css", + ".xml": "text/xml", + ".csv": "text/csv", + ".zip": "application/zip", + ".gz": "application/gzip", + ".tar": "application/x-tar", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".wav": "audio/wav", + ".ogg": "audio/ogg", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +}; + +const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".heic": "image/heic", + ".heif": "image/heif", + ".avif": "image/avif", + ".tif": "image/tiff", + ".tiff": "image/tiff", +}; + +const RASTER_IMAGE_MIME_TYPES = new Set(Object.values(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION)); + +export const RASTER_IMAGE_FILE_EXTENSIONS = Object.keys(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION).map( + (extension) => extension.slice(1), +); + +export function getFileExtension(path: string): string { + const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; + const extensionIndex = normalizedPath.lastIndexOf("."); + if (extensionIndex < 0) { + return ""; + } + return normalizedPath.slice(extensionIndex).toLowerCase(); +} + +export function getMimeTypeFromPath(path: string): string { + return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream"; +} + +export function getRasterImageMimeTypeFromPath(path: string): string | null { + return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null; +} + +export function isRasterImagePath(path: string): boolean { + return getRasterImageMimeTypeFromPath(path) !== null; +} + +export function isRasterImageMimeType(mimeType: string | null | undefined): boolean { + const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase(); + return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized)); +} + +export function isRasterImageFile(file: Pick): boolean { + if (isRasterImageMimeType(file.type)) { + return true; + } + return file.type.trim().length === 0 && isRasterImagePath(file.name); +} diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d8e8a6a37..270b8860d 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -53,4 +53,8 @@ describe("parseImageDataUrl", () => { it("ignores non-image data URLs", () => { expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull(); }); + + it("ignores SVG data URLs", () => { + expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull(); + }); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 397a67f65..713d21ca2 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -1,5 +1,6 @@ import { generateMessageId } from "@/types/stream"; import { isAbsolutePath } from "@/utils/path"; +import { isRasterImageMimeType } from "./file-types"; export function generateAttachmentId(): string { return `att_${generateMessageId()}`; @@ -52,7 +53,7 @@ export function parseImageDataUrl( try { const parsed = parseDataUrl(uri); - if (!parsed.mimeType.toLowerCase().startsWith("image/")) { + if (!isRasterImageMimeType(parsed.mimeType)) { return null; } const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`; diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index 19c57f7e4..fc13350c7 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,4 +1,5 @@ import type { DesktopDialogBridge } from "@/desktop/host"; +import { RASTER_IMAGE_FILE_EXTENSIONS } from "@/attachments/file-types"; import { i18n } from "@/i18n/i18next"; import { isAbsolutePath } from "@/utils/path"; @@ -17,20 +18,6 @@ export interface ExpoImagePickerAssetLike { file?: File | null; } -const IMAGE_FILE_EXTENSIONS = [ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "avif", - "heic", - "heif", - "tiff", - "bmp", - "svg", -]; - function shouldTreatAsFileUri(uri: string): boolean { return uri.startsWith("file://") || isAbsolutePath(uri); } @@ -89,7 +76,7 @@ export async function openImagePathsWithDesktopDialog( filters: [ { name: i18n.t("imageAttachmentPicker.dialogFilterName"), - extensions: IMAGE_FILE_EXTENSIONS, + extensions: RASTER_IMAGE_FILE_EXTENSIONS, }, ], title: i18n.t("imageAttachmentPicker.dialogTitle"), diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index 82ef2c8fc..710faf1f5 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -2,6 +2,11 @@ import { useState, useRef, useEffect } from "react"; import type { ImageAttachment } from "@/composer/types"; import { getDesktopHost } from "@/desktop/host"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; +import { + getRasterImageMimeTypeFromPath, + isRasterImageFile, + isRasterImagePath, +} from "@/attachments/file-types"; import { isWeb } from "@/constants/platform"; export interface DroppedFileItem { @@ -26,20 +31,6 @@ interface UseFileDropZoneReturn { } const IS_WEB = isWeb; -const IMAGE_MIME_BY_EXTENSION: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".svg": "image/svg+xml", - ".heic": "image/heic", - ".heif": "image/heif", - ".avif": "image/avif", - ".tif": "image/tiff", - ".tiff": "image/tiff", -}; type DesktopDragDropPayload = | { @@ -61,26 +52,8 @@ interface DesktopDragDropEvent { payload: DesktopDragDropPayload; } -function isImageFile(file: File): boolean { - return file.type.startsWith("image/"); -} - -function getFileExtension(path: string): string { - const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; - const extensionIndex = normalizedPath.lastIndexOf("."); - if (extensionIndex < 0) { - return ""; - } - return normalizedPath.slice(extensionIndex).toLowerCase(); -} - -function isImagePath(path: string): boolean { - return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION; -} - async function filePathToImageAttachment(path: string): Promise { - const extension = getFileExtension(path); - const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; + const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg"; return await persistAttachmentFromFileUri({ uri: path, mimeType }); } @@ -182,7 +155,7 @@ export function useFileDropZone({ onGenericFilesDroppedRef.current(items); } - const imagePaths = payload.paths.filter(isImagePath); + const imagePaths = payload.paths.filter(isRasterImagePath); if (imagePaths.length === 0) { return; } @@ -273,7 +246,7 @@ export function useFileDropZone({ onGenericFilesDroppedRef.current(genericItems); } - const imageFiles = files.filter(isImageFile); + const imageFiles = files.filter(isRasterImageFile); if (imageFiles.length === 0) return; diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts index 3139eb53f..f0cdcc064 100644 --- a/packages/app/src/hooks/use-file-picker.ts +++ b/packages/app/src/hooks/use-file-picker.ts @@ -3,6 +3,7 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands"; import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url"; import { isWeb } from "@/constants/platform"; +import { getFileExtension, getMimeTypeFromPath } from "@/attachments/file-types"; export interface PickedFile { fileName: string; @@ -10,62 +11,6 @@ export interface PickedFile { bytes: Uint8Array; } -const MIME_BY_EXTENSION: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".svg": "image/svg+xml", - ".heic": "image/heic", - ".heif": "image/heif", - ".avif": "image/avif", - ".tif": "image/tiff", - ".tiff": "image/tiff", - ".pdf": "application/pdf", - ".txt": "text/plain", - ".md": "text/markdown", - ".json": "application/json", - ".js": "text/javascript", - ".ts": "text/typescript", - ".tsx": "text/typescript-jsx", - ".jsx": "text/javascript", - ".html": "text/html", - ".css": "text/css", - ".xml": "text/xml", - ".csv": "text/csv", - ".zip": "application/zip", - ".gz": "application/gzip", - ".tar": "application/x-tar", - ".mp3": "audio/mpeg", - ".mp4": "video/mp4", - ".mov": "video/quicktime", - ".webm": "video/webm", - ".wav": "audio/wav", - ".ogg": "audio/ogg", - ".doc": "application/msword", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xls": "application/vnd.ms-excel", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".ppt": "application/vnd.ms-powerpoint", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", -}; - -function getFileExtension(path: string): string { - const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; - const extensionIndex = normalizedPath.lastIndexOf("."); - if (extensionIndex < 0) { - return ""; - } - return normalizedPath.slice(extensionIndex).toLowerCase(); -} - -export function getMimeTypeFromPath(path: string): string { - const ext = getFileExtension(path); - return MIME_BY_EXTENSION[ext] ?? "application/octet-stream"; -} - function base64ToUint8Array(base64: string): Uint8Array { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 265f2f554..3dd9afaf2 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -16,7 +16,12 @@ import { Composer } from "@/composer"; import { AgentModeControl } from "@/composer/agent-controls/mode-control"; import { FileDropZone } from "@/components/file-drop-zone"; import { uploadFileAttachments } from "@/composer/actions"; -import { readDesktopFileBytes, getMimeTypeFromPath } from "@/hooks/use-file-picker"; +import { + getMimeTypeFromPath, + isRasterImageFile, + isRasterImagePath, +} from "@/attachments/file-types"; +import { readDesktopFileBytes } from "@/hooks/use-file-picker"; import type { DroppedItem } from "@/hooks/use-file-drop-zone"; import type { UserComposerAttachment } from "@/attachments/types"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; @@ -719,8 +724,8 @@ function ChatAgentContent({ async (items: DroppedItem[]) => { if (!client || !isConnected) return; const nonImageItems = items.filter((item) => { - if (item.kind === "web-file") return !item.file.type.startsWith("image/"); - return !isImagePath(item.path); + if (item.kind === "web-file") return !isRasterImageFile(item.file); + return !isRasterImagePath(item.path); }); if (nonImageItems.length === 0) return; try { @@ -1114,25 +1119,6 @@ function ChatAgentContent({ ); } -function isImagePath(path: string): boolean { - const ext = path.slice(path.lastIndexOf(".")).toLowerCase(); - const imageExts = new Set([ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".svg", - ".heic", - ".heif", - ".avif", - ".tif", - ".tiff", - ]); - return imageExts.has(ext); -} - const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId, agentId, diff --git a/packages/app/src/utils/image-attachments-from-files.test.ts b/packages/app/src/utils/image-attachments-from-files.test.ts index 4316567c9..d59dc1000 100644 --- a/packages/app/src/utils/image-attachments-from-files.test.ts +++ b/packages/app/src/utils/image-attachments-from-files.test.ts @@ -95,6 +95,24 @@ describe("collectImageFilesFromClipboardData", () => { expect(files).toEqual([imagePng]); }); + it("ignores SVG clipboard files", () => { + const svgFile = new File([""], "logo.svg", { + type: "image/svg+xml", + }); + + const files = collectImageFilesFromClipboardData({ + items: [ + createClipboardItem({ + kind: "file", + type: "image/svg+xml", + file: svgFile, + }), + ], + }); + + expect(files).toEqual([]); + }); + it("returns an empty array when clipboard data is missing", () => { expect(collectImageFilesFromClipboardData(undefined)).toEqual([]); }); diff --git a/packages/app/src/utils/image-attachments-from-files.ts b/packages/app/src/utils/image-attachments-from-files.ts index 7076ebae5..11483aba5 100644 --- a/packages/app/src/utils/image-attachments-from-files.ts +++ b/packages/app/src/utils/image-attachments-from-files.ts @@ -1,5 +1,6 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { persistAttachmentFromBlob } from "@/attachments/service"; +import { isRasterImageMimeType } from "@/attachments/file-types"; export interface ClipboardItemLike { kind?: string; @@ -25,7 +26,7 @@ export function collectImageFilesFromClipboardData( if (item?.kind !== "file") { continue; } - if (!item.type?.startsWith("image/")) { + if (!isRasterImageMimeType(item.type)) { continue; } const file = item.getAsFile?.();