feat: support Office documents in chat attachments

Users can now attach Microsoft Office documents (.docx, .pptx, and .xlsx) and OpenDocument files (.odt, .odp, and .ods) from the shared web, desktop, mobile, and VS Code chat surfaces.

Document text is extracted locally and sent as a text/plain file part with the original filename, keeping the visible user message clean. Supported embedded PNG, JPEG, GIF, and WebP images are sent as separate image parts, with matching [filename] citations preserved near their source paragraph, slide object, spreadsheet cell anchor, or OpenDocument position. Presentation notes, spreadsheet values, headers, and footers are included where available.

Document expansion is metadata-validated and bounded against oversized entries, excessive uncompressed data, unsafe paths, invalid image signatures, attachment-name races, and dangling citations after truncation. Generated document parts are published to the composer atomically.

Add fflate for worker-backed ZIP extraction and narrowly allow blob workers in the VS Code webview CSP without permitting blob scripts. Include focused fixtures for every supported format, extraction limits, positional citations, collision recovery, atomic attachment state, and CSP behavior.
This commit is contained in:
Bohdan Triapitsyn
2026-07-22 13:11:42 +03:00
parent d7a93c5ec0
commit fd0f6a6bac
12 changed files with 985 additions and 25 deletions
+41 -23
View File
@@ -5,9 +5,10 @@
import { create } from "zustand"
import type { AttachedFile } from "@/stores/types/sessionTypes"
import { prepareAttachmentFile } from "./attachment-files"
import { prepareAttachmentFiles } from "./attachment-files"
const FILE_URI_PREFIX = "file://"
const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3
const pendingVSCodeSelectionKeys = new Set<string>()
let attachmentReadGeneration = 0
@@ -35,6 +36,12 @@ const toFileUrl = (filepath: string): string => {
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
const hasGeneratedFilenameCollision = (filenames: string[], attachedFiles: AttachedFile[]): boolean => {
if (filenames.length === 0) return false
const attachedFilenames = new Set(attachedFiles.map((attachment) => attachment.filename.toLowerCase()))
return filenames.some((filename) => attachedFilenames.has(filename.toLowerCase()))
}
const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
@@ -157,30 +164,41 @@ export const useInputStore = create<InputState>()((set, get) => ({
},
addAttachedFile: async (file: File) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const generation = attachmentReadGeneration
const preparedOrPending = prepareAttachmentFile(file)
// Keep the synchronous preparation path synchronous so FileReader starts before this action yields.
const prepared = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!prepared) return false
let dataUrl: string
try {
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
} catch {
return false
for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) {
const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename)
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!preparedFiles || preparedFiles.length === 0 || generation !== attachmentReadGeneration) return false
const generatedFilenames = preparedFiles.slice(1).map((prepared) => prepared.file.name)
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
const attachedFiles: AttachedFile[] = []
for (const prepared of preparedFiles) {
let dataUrl: string
try {
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
} catch {
return false
}
if (!dataUrl || generation !== attachmentReadGeneration) return false
attachedFiles.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
})
}
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
return true
}
if (!dataUrl || generation !== attachmentReadGeneration) return false
const attached: AttachedFile = {
id,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
return true
return false
},
removeAttachedFile: (id) =>