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?.();