feat: expand chat attachment processing
Add picker and MIME support for more source-code, diff, notebook, structured-log, SVG, and Draw.io formats. Convert Jupyter notebooks into readable text while omitting binary outputs, and sanitize HAR credentials, cookies, sensitive query parameters, and request/response bodies before files enter chat state. Convert HEIC and HEIF images to JPEG up front, centralize attachment preparation in a focused module, and cover the new validation and transformation behavior with regression tests.
This commit is contained in:
@@ -10,7 +10,8 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ
|
|||||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT } from '@/sync/attachment-files';
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||||
import * as sessionActions from '@/sync/session-actions';
|
import * as sessionActions from '@/sync/session-actions';
|
||||||
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
|
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
|
||||||
@@ -1215,8 +1216,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendableAttachedFiles = attachedFiles;
|
|
||||||
|
|
||||||
const knownAgentNames = React.useMemo(
|
const knownAgentNames = React.useMemo(
|
||||||
() => new Set(agents.map((agent) => agent.name.toLowerCase())),
|
() => new Set(agents.map((agent) => agent.name.toLowerCase())),
|
||||||
[agents]
|
[agents]
|
||||||
@@ -1317,18 +1316,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}, [inputMode, message, knownAgentNames]);
|
}, [inputMode, message, knownAgentNames]);
|
||||||
|
|
||||||
const attachmentCitationRanges = React.useMemo<HighlightRange[]>(() => {
|
const attachmentCitationRanges = React.useMemo<HighlightRange[]>(() => {
|
||||||
if (!message || !message.includes('[') || inputMode === 'shell' || sendableAttachedFiles.length === 0) {
|
if (!message || !message.includes('[') || inputMode === 'shell' || attachedFiles.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return findAttachmentCitationRanges(
|
return findAttachmentCitationRanges(
|
||||||
message,
|
message,
|
||||||
sendableAttachedFiles.map((file) => file.filename),
|
attachedFiles.map((file) => file.filename),
|
||||||
).map((range) => ({
|
).map((range) => ({
|
||||||
...range,
|
...range,
|
||||||
style: 'mentionFile' as const,
|
style: 'mentionFile' as const,
|
||||||
}));
|
}));
|
||||||
}, [inputMode, message, sendableAttachedFiles]);
|
}, [attachedFiles, inputMode, message]);
|
||||||
|
|
||||||
// Combined source-mode highlight: markdown syntax + @mentions. Returns null
|
// Combined source-mode highlight: markdown syntax + @mentions. Returns null
|
||||||
// when there's nothing to highlight so the overlay stays off for plain text.
|
// when there's nothing to highlight so the overlay stays off for plain text.
|
||||||
@@ -1757,7 +1756,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
}, [pendingInputText, consumePendingInputText]);
|
}, [pendingInputText, consumePendingInputText]);
|
||||||
|
|
||||||
const hasContent = message.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts;
|
const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts;
|
||||||
const hasQueuedMessages = queuedMessages.length > 0;
|
const hasQueuedMessages = queuedMessages.length > 0;
|
||||||
const canSend = hasContent || hasQueuedMessages;
|
const canSend = hasContent || hasQueuedMessages;
|
||||||
|
|
||||||
@@ -1767,9 +1766,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const currentMessage = textareaRef.current?.value ?? message;
|
const currentMessage = textareaRef.current?.value ?? message;
|
||||||
return {
|
return {
|
||||||
message: currentMessage,
|
message: currentMessage,
|
||||||
hasContent: currentMessage.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts,
|
hasContent: currentMessage.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
|
||||||
};
|
};
|
||||||
}, [hasDrafts, message, sendableAttachedFiles.length]);
|
}, [attachedFiles.length, hasDrafts, message]);
|
||||||
|
|
||||||
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
||||||
type SubmitOptions = {
|
type SubmitOptions = {
|
||||||
@@ -1794,7 +1793,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
if (drafts.length > 0) {
|
if (drafts.length > 0) {
|
||||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||||
}
|
}
|
||||||
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||||
|
|
||||||
addToQueue(messageQueueTarget, {
|
addToQueue(messageQueueTarget, {
|
||||||
content: messageToQueue,
|
content: messageToQueue,
|
||||||
@@ -1819,7 +1818,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||||
|
|
||||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||||
setMessage(content);
|
setMessage(content);
|
||||||
@@ -1856,7 +1855,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const inputSnapshot = options?.presetText != null
|
const inputSnapshot = options?.presetText != null
|
||||||
? {
|
? {
|
||||||
message: options.presetText,
|
message: options.presetText,
|
||||||
hasContent: options.presetText.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts,
|
hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts,
|
||||||
}
|
}
|
||||||
: getCurrentInputSnapshot();
|
: getCurrentInputSnapshot();
|
||||||
const queuedMessagesToSend = queuedMessageId
|
const queuedMessagesToSend = queuedMessageId
|
||||||
@@ -1958,7 +1957,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||||
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
||||||
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
const attachmentsToSend = sanitizeAttachmentsForSend(attachedFiles);
|
||||||
addMentionedSkills(messageText);
|
addMentionedSkills(messageText);
|
||||||
|
|
||||||
if (!agentMentionName && mention?.name) {
|
if (!agentMentionName && mention?.name) {
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ So:
|
|||||||
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
|
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
|
||||||
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
|
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
|
||||||
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
|
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
|
||||||
|
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
|
||||||
| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state |
|
| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state |
|
||||||
| `selection-store.ts` | Model/agent/variant selections | App UI state |
|
| `selection-store.ts` | Model/agent/variant selections | App UI state |
|
||||||
| `voice-store.ts` | Voice state | App UI state |
|
| `voice-store.ts` | Voice state | App UI state |
|
||||||
|
|
||||||
Local chat attachments are normalized before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Browser and VS Code pickers expose the same OpenCode-compatible extension allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
|
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
|
||||||
|
|
||||||
## Session list rules
|
## Session list rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
ACCEPTED_ATTACHMENT_EXTENSIONS,
|
||||||
|
ATTACHMENT_ACCEPT,
|
||||||
|
prepareAttachmentFile,
|
||||||
|
} from "./attachment-files"
|
||||||
|
|
||||||
|
mock.module("heic2any", () => ({
|
||||||
|
default: async () => new Blob(["jpeg-data"], { type: "image/jpeg" }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const prepare = (file: File) => Promise.resolve(prepareAttachmentFile(file))
|
||||||
|
|
||||||
|
describe("attachment file preparation", () => {
|
||||||
|
test("exposes the expanded code and structured-text formats to pickers", () => {
|
||||||
|
for (const extension of [
|
||||||
|
"diff", "patch", "ipynb", "jsonl", "ndjson", "har", "svg", "drawio",
|
||||||
|
"vue", "svelte", "php", "cs", "kt", "swift", "lua", "dart", "tf", "hcl", "proto",
|
||||||
|
]) {
|
||||||
|
expect(ACCEPTED_ATTACHMENT_EXTENSIONS.includes(extension)).toBe(true)
|
||||||
|
expect(ATTACHMENT_ACCEPT.includes(`.${extension}`)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders notebooks as readable markdown without binary outputs", async () => {
|
||||||
|
const notebook = {
|
||||||
|
metadata: { kernelspec: { language: "python" } },
|
||||||
|
cells: [
|
||||||
|
{ cell_type: "markdown", source: ["# Analysis\n", "Notes"] },
|
||||||
|
{
|
||||||
|
cell_type: "code",
|
||||||
|
source: ["print('ok')"],
|
||||||
|
outputs: [
|
||||||
|
{ text: ["ok\n"] },
|
||||||
|
{ data: { "text/plain": ["result"], "image/png": "base64-image" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prepare(new File([JSON.stringify(notebook)], "analysis.ipynb", { type: "application/json" }))
|
||||||
|
const text = await result?.file.text()
|
||||||
|
|
||||||
|
expect(result?.mimeType).toBe("text/plain")
|
||||||
|
expect(text?.includes("# Notebook: analysis.ipynb")).toBe(true)
|
||||||
|
expect(text?.includes("```python\nprint('ok')\n```")).toBe(true)
|
||||||
|
expect(text?.includes("ok")).toBe(true)
|
||||||
|
expect(text?.includes("[Non-text output omitted: image/png]")).toBe(true)
|
||||||
|
expect(text?.includes("base64-image")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("redacts credentials and omits bodies from HAR files", async () => {
|
||||||
|
const har = {
|
||||||
|
log: {
|
||||||
|
entries: [{
|
||||||
|
request: {
|
||||||
|
url: "https://example.com/api?token=secret&query=visible",
|
||||||
|
headers: [
|
||||||
|
{ name: "Authorization", value: "Bearer secret" },
|
||||||
|
{ name: "Accept", value: "application/json" },
|
||||||
|
],
|
||||||
|
cookies: [{ name: "session", value: "cookie-secret" }],
|
||||||
|
postData: { mimeType: "application/json", text: "{\"password\":\"secret\"}" },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
headers: [{ name: "Set-Cookie", value: "session=secret" }],
|
||||||
|
content: { mimeType: "application/json", encoding: "base64", text: "secret response" },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prepare(new File([JSON.stringify(har)], "network.har", { type: "application/json" }))
|
||||||
|
const text = await result?.file.text() ?? ""
|
||||||
|
const sanitized = JSON.parse(text)
|
||||||
|
const entry = sanitized.log.entries[0]
|
||||||
|
|
||||||
|
expect(result?.mimeType).toBe("text/plain")
|
||||||
|
expect(new URL(entry.request.url).searchParams.get("token")).toBe("[REDACTED]")
|
||||||
|
expect(entry.request.headers[0].value).toBe("[REDACTED]")
|
||||||
|
expect(entry.request.headers[1].value).toBe("application/json")
|
||||||
|
expect(entry.request.cookies[0].value).toBe("[REDACTED]")
|
||||||
|
expect(entry.request.postData.text).toBe("[OMITTED BY OPENCHAMBER]")
|
||||||
|
expect(entry.response.headers[0].value).toBe("[REDACTED]")
|
||||||
|
expect(entry.response.content.text).toBe("[OMITTED BY OPENCHAMBER]")
|
||||||
|
expect(entry.response.content.encoding).toBe("[OMITTED BY OPENCHAMBER]")
|
||||||
|
expect(text.includes("Bearer secret")).toBe(false)
|
||||||
|
expect(text.includes("secret response")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects malformed HAR files instead of leaking unsanitized content", async () => {
|
||||||
|
const result = await prepare(new File(["not valid HAR JSON"], "network.har", { type: "text/plain" }))
|
||||||
|
expect(result).toBe(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("treats SVG and Draw.io files as text", async () => {
|
||||||
|
const svg = await prepare(new File(["<svg></svg>"], "diagram.svg", { type: "image/svg+xml" }))
|
||||||
|
const drawio = await prepare(new File(["<mxfile></mxfile>"], "diagram.drawio", { type: "application/xml" }))
|
||||||
|
|
||||||
|
expect(svg?.mimeType).toBe("text/plain")
|
||||||
|
expect(drawio?.mimeType).toBe("text/plain")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("converts HEIC files to JPEG before attachment", async () => {
|
||||||
|
const result = await prepare(new File(["heic-data"], "photo.heic", { type: "image/heic" }))
|
||||||
|
|
||||||
|
expect(result?.mimeType).toBe("image/jpeg")
|
||||||
|
expect(result?.file.name).toBe("photo.jpg")
|
||||||
|
expect(result?.file.type).toBe("image/jpeg")
|
||||||
|
expect(await result?.file.text()).toBe("jpeg-data")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
const ACCEPTED_ATTACHMENT_TYPES = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
|
"application/pdf",
|
||||||
|
"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",
|
||||||
|
".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",
|
||||||
|
".patch",
|
||||||
|
".php",
|
||||||
|
".proto",
|
||||||
|
".ps1",
|
||||||
|
".py",
|
||||||
|
".r",
|
||||||
|
".rb",
|
||||||
|
".rs",
|
||||||
|
".sass",
|
||||||
|
".scala",
|
||||||
|
".scss",
|
||||||
|
".sh",
|
||||||
|
".sol",
|
||||||
|
".sql",
|
||||||
|
".svelte",
|
||||||
|
".svg",
|
||||||
|
".swift",
|
||||||
|
".tf",
|
||||||
|
".toml",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".txt",
|
||||||
|
".vue",
|
||||||
|
".xml",
|
||||||
|
".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/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"
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
}
|
||||||
@@ -5,115 +5,12 @@
|
|||||||
|
|
||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
||||||
|
import { prepareAttachmentFile } from "./attachment-files"
|
||||||
|
|
||||||
const FILE_URI_PREFIX = "file://"
|
const FILE_URI_PREFIX = "file://"
|
||||||
const pendingVSCodeSelectionKeys = new Set<string>()
|
const pendingVSCodeSelectionKeys = new Set<string>()
|
||||||
let attachmentReadGeneration = 0
|
let attachmentReadGeneration = 0
|
||||||
|
|
||||||
const ACCEPTED_ATTACHMENT_TYPES = [
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"application/pdf",
|
|
||||||
"text/*",
|
|
||||||
"application/json",
|
|
||||||
"application/ld+json",
|
|
||||||
"application/toml",
|
|
||||||
"application/x-toml",
|
|
||||||
"application/x-yaml",
|
|
||||||
"application/xml",
|
|
||||||
"application/yaml",
|
|
||||||
".c",
|
|
||||||
".cc",
|
|
||||||
".cjs",
|
|
||||||
".conf",
|
|
||||||
".cpp",
|
|
||||||
".css",
|
|
||||||
".csv",
|
|
||||||
".cts",
|
|
||||||
".env",
|
|
||||||
".go",
|
|
||||||
".gql",
|
|
||||||
".graphql",
|
|
||||||
".h",
|
|
||||||
".hh",
|
|
||||||
".hpp",
|
|
||||||
".htm",
|
|
||||||
".html",
|
|
||||||
".ini",
|
|
||||||
".java",
|
|
||||||
".js",
|
|
||||||
".json",
|
|
||||||
".jsx",
|
|
||||||
".log",
|
|
||||||
".md",
|
|
||||||
".mdx",
|
|
||||||
".mjs",
|
|
||||||
".mts",
|
|
||||||
".py",
|
|
||||||
".rb",
|
|
||||||
".rs",
|
|
||||||
".sass",
|
|
||||||
".scss",
|
|
||||||
".sh",
|
|
||||||
".sql",
|
|
||||||
".toml",
|
|
||||||
".ts",
|
|
||||||
".tsx",
|
|
||||||
".txt",
|
|
||||||
".xml",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".zsh",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const ATTACHMENT_ACCEPT = ACCEPTED_ATTACHMENT_TYPES.join(",")
|
|
||||||
|
|
||||||
const ATTACHMENT_MIME_EXTENSIONS = new Map<string, string>([
|
|
||||||
["image/png", "png"],
|
|
||||||
["image/jpeg", "jpg"],
|
|
||||||
["image/gif", "gif"],
|
|
||||||
["image/webp", "webp"],
|
|
||||||
["application/pdf", "pdf"],
|
|
||||||
["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 = ATTACHMENT_MIME_EXTENSIONS.get(type)
|
|
||||||
return extension ? [extension] : []
|
|
||||||
})
|
|
||||||
)).sort()
|
|
||||||
|
|
||||||
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
|
||||||
const IMAGE_EXTENSIONS = new Map([
|
|
||||||
["gif", "image/gif"],
|
|
||||||
["jpeg", "image/jpeg"],
|
|
||||||
["jpg", "image/jpeg"],
|
|
||||||
["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",
|
|
||||||
])
|
|
||||||
const ATTACHMENT_SAMPLE_BYTES = 4096
|
|
||||||
|
|
||||||
const encodeFilePath = (filepath: string): string => {
|
const encodeFilePath = (filepath: string): string => {
|
||||||
let normalized = filepath.replace(/\\/g, "/")
|
let normalized = filepath.replace(/\\/g, "/")
|
||||||
if (/^[A-Za-z]:/.test(normalized)) {
|
if (/^[A-Za-z]:/.test(normalized)) {
|
||||||
@@ -150,30 +47,6 @@ const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Pro
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 getAttachmentMime = (file: File): string | Promise<"text/plain" | undefined> | undefined => {
|
|
||||||
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
|
||||||
if (IMAGE_MIMES.has(type) || type === "application/pdf") return type
|
|
||||||
|
|
||||||
const extensionIndex = file.name.lastIndexOf(".")
|
|
||||||
const extension = extensionIndex === -1 ? "" : file.name.slice(extensionIndex + 1).toLowerCase()
|
|
||||||
const fallback = IMAGE_EXTENSIONS.get(extension) ?? (extension === "pdf" ? "application/pdf" : undefined)
|
|
||||||
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 getDataUrlByteSize = (url: string): number => {
|
const getDataUrlByteSize = (url: string): number => {
|
||||||
if (!url.startsWith("data:")) return 0
|
if (!url.startsWith("data:")) return 0
|
||||||
const commaIndex = url.indexOf(",")
|
const commaIndex = url.indexOf(",")
|
||||||
@@ -286,23 +159,24 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
addAttachedFile: async (file: File) => {
|
addAttachedFile: async (file: File) => {
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
const generation = attachmentReadGeneration
|
const generation = attachmentReadGeneration
|
||||||
const resolvedMime = getAttachmentMime(file)
|
const preparedOrPending = prepareAttachmentFile(file)
|
||||||
const mimeType = typeof resolvedMime === "string" ? resolvedMime : await resolvedMime
|
// Keep the synchronous preparation path synchronous so FileReader starts before this action yields.
|
||||||
if (!mimeType) return false
|
const prepared = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
|
||||||
|
if (!prepared) return false
|
||||||
let dataUrl: string
|
let dataUrl: string
|
||||||
try {
|
try {
|
||||||
dataUrl = await readFileAsDataUrl(file, mimeType)
|
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!dataUrl || generation !== attachmentReadGeneration) return false
|
if (!dataUrl || generation !== attachmentReadGeneration) return false
|
||||||
const attached: AttachedFile = {
|
const attached: AttachedFile = {
|
||||||
id,
|
id,
|
||||||
file,
|
file: prepared.file,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
mimeType,
|
mimeType: prepared.mimeType,
|
||||||
filename: file.name,
|
filename: prepared.file.name,
|
||||||
size: file.size,
|
size: prepared.file.size,
|
||||||
source: "local",
|
source: "local",
|
||||||
}
|
}
|
||||||
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
|
||||||
|
|||||||
Reference in New Issue
Block a user