fix(ui): compact Office attachment context

This commit is contained in:
Bohdan Triapitsyn
2026-08-18 19:54:16 +03:00
parent 1efc7fb570
commit 215749a65f
7 changed files with 257 additions and 85 deletions
+85 -41
View File
@@ -7,11 +7,12 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useInputStore } from '@/sync/input-store';
import { prepareLocalAttachments, useInputStore } from '@/sync/input-store';
import {
ACCEPTED_ATTACHMENT_EXTENSIONS,
ATTACHMENT_ACCEPT,
getUnsupportedAttachmentInputs,
isDocumentAttachmentFilename,
type AttachmentInputModality,
} from '@/sync/attachment-files';
import type { AttachedFile } from '@/stores/types/sessionTypes';
@@ -24,6 +25,7 @@ import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
createChatDraftIdentity,
readChatDraft,
@@ -596,59 +598,62 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
[],
);
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
const resolveInlineFileMention = React.useCallback((mentionPath: string): { serverPath: string; filename: string } | null => {
const kind = classifyMention(mentionPath, {
knownAgentNames: knownAgentNamesRef.current,
confirmedMentions: confirmedMentionsRef.current,
});
if (kind !== 'file') return null;
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
if (!normalizedMentionPath) return null;
const clientDirectory = opencodeClient.getDirectory() || '';
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
let serverPath: string | null = null;
if (mentionPath.startsWith('/')) {
serverPath = mentionPath.replace(/\\/g, '/');
} else if (root) {
serverPath = `${root}/${normalizedMentionPath}`;
}
if (!serverPath) return null;
return {
serverPath: serverPath.replace(/\/+/g, '/'),
filename: normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath,
};
}, [chatSearchDirectory]);
const extractInlineFileMentions = React.useCallback((
rawText: string,
preparedDocumentMentions?: ReadonlyMap<string, AttachedFile[]>,
) => {
if (!rawText || !rawText.includes('@')) {
return { sanitizedText: rawText, attachments: [] };
}
const clientDirectory = opencodeClient.getDirectory() || '';
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
const seenPaths = new Set<string>();
const attachments: AttachedFile[] = [];
for (const token of scanMentions(rawText)) {
const mentionPath = token.name;
const kind = classifyMention(mentionPath, {
knownAgentNames: knownAgentNamesRef.current,
confirmedMentions: confirmedMentionsRef.current,
});
// Agents are routed separately by parseAgentMentions; only file
// references become attachments here.
if (kind !== 'file') {
const mention = resolveInlineFileMention(token.name);
if (!mention || seenPaths.has(mention.serverPath)) continue;
seenPaths.add(mention.serverPath);
const prepared = preparedDocumentMentions?.get(mention.serverPath);
if (prepared) {
attachments.push(...prepared);
continue;
}
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
if (!normalizedMentionPath) {
continue;
}
const serverPath = mentionPath.startsWith('/')
? mentionPath.replace(/\\/g, '/')
: root
? `${root}/${normalizedMentionPath}`
: null;
if (!serverPath) {
continue;
}
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
if (seenPaths.has(normalizedServerPath)) {
continue;
}
seenPaths.add(normalizedServerPath);
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
attachments.push({
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
file: new File([], filename, { type: 'text/plain' }),
filename,
file: new File([], mention.filename, { type: 'text/plain' }),
filename: mention.filename,
mimeType: 'text/plain',
size: 0,
dataUrl: toServerFileUrl(normalizedServerPath),
dataUrl: toServerFileUrl(mention.serverPath),
source: 'server',
serverPath: normalizedServerPath,
serverPath: mention.serverPath,
});
}
@@ -656,7 +661,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
sanitizedText: rawText,
attachments,
};
}, [chatSearchDirectory]);
}, [resolveInlineFileMention]);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWasAbortedRef = React.useRef(false);
@@ -960,6 +965,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
};
const handleSubmit = async (options?: SubmitOptions) => {
const submitRuntimeKey = getRuntimeKey();
const queuedOnly = options?.queuedOnly ?? false;
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
@@ -1051,6 +1057,44 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
: undefined;
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
const reservedFilenames = new Set([
...attachedFiles.map((attachment) => attachment.filename),
...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
]);
const mentionTexts = [
...queuedMessagesToSend.map((queued) => queued.content),
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
];
for (const rawText of mentionTexts) {
for (const token of scanMentions(rawText)) {
const mention = resolveInlineFileMention(token.name);
if (
!mention
|| !isDocumentAttachmentFilename(mention.filename)
|| preparedDocumentMentions.has(mention.serverPath)
) {
continue;
}
try {
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
const sourceBlob = await response.blob();
if (getRuntimeKey() !== submitRuntimeKey) return;
const source = new File([sourceBlob], mention.filename);
const prepared = await prepareLocalAttachments(source, reservedFilenames);
if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
if (getRuntimeKey() !== submitRuntimeKey) return;
preparedDocumentMentions.set(mention.serverPath, prepared);
for (const attachment of prepared) reservedFilenames.add(attachment.filename);
} catch {
if (getRuntimeKey() !== submitRuntimeKey) return;
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename }));
return;
}
}
}
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
const syntheticParts = consumePendingSyntheticParts();
@@ -1079,7 +1123,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return { text: sanitizedText, agentName: mention?.name };
},
extractFileMentions: (text) => {
const { sanitizedText, attachments } = extractInlineFileMentions(text);
const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions);
return { text: sanitizedText, attachments };
},
sanitizeAttachments: sanitizeAttachmentsForSend,
+1 -1
View File
@@ -57,7 +57,7 @@ So:
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.
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 250,000 characters so compact but dense Office files cannot consume an entire model context window. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 500,000 characters so compact but dense Office files cannot consume an entire model context window. XLSX dense rows are serialized as quoted TSV under a single source range instead of repeating every cell address; highly sparse rows retain explicit cell coordinates so distant cells do not generate vast empty TSV spans. Confirmed Office/OpenDocument `@file` mentions are loaded through the runtime filesystem route before submit and use this same extraction pipeline instead of being forwarded as `text/plain` `file://` parts that OpenCode rejects as binary. A failed mention load or extraction leaves the composer intact, and a runtime switch discards preparation from the previous runtime. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
The composer compares normalized attachment MIME types with the selected model's declared input modalities. It warns when a newly attached file or an existing attachment after a model change requires an unsupported modality, but does not block sending. Missing modality metadata remains unknown and does not produce a warning.
@@ -4,6 +4,7 @@ import {
ATTACHMENT_ACCEPT,
getAttachmentInputModality,
getUnsupportedAttachmentInputs,
isDocumentAttachmentFilename,
prepareAttachmentFile,
} from "./attachment-files"
@@ -47,6 +48,11 @@ describe("attachment file preparation", () => {
}
})
test("identifies Office and OpenDocument filenames for shared mention preparation", () => {
expect(isDocumentAttachmentFilename("reports/BUDGET.XLSX")).toBe(true)
expect(isDocumentAttachmentFilename("notes.txt")).toBe(false)
})
test("renders notebooks as readable markdown without binary outputs", async () => {
const notebook = {
metadata: { kernelspec: { language: "python" } },
+3 -1
View File
@@ -217,6 +217,8 @@ const extensionOf = (name: string): string => {
return index === -1 ? "" : name.slice(index + 1).toLowerCase()
}
export const isDocumentAttachmentFilename = (name: string): boolean => DOCUMENT_EXTENSIONS.has(extensionOf(name))
const declaredMimeOf = (file: File): string => file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => {
@@ -392,7 +394,7 @@ export const prepareAttachmentFiles = (
file: File,
reservedFilenames: Iterable<string> = [],
): PreparedAttachmentFile[] | Promise<PreparedAttachmentFile[] | undefined> | undefined => {
if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) {
if (!isDocumentAttachmentFilename(file.name)) {
const prepared = prepareAttachmentFile(file)
if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined)
return prepared ? [prepared] : undefined
@@ -82,7 +82,10 @@ describe("document attachment extraction", () => {
"xl/_rels/workbook.xml.rels": relationships([{ id: "rIdSheet", target: "worksheets/sheet1.xml" }]),
"xl/sharedStrings.xml": `<sst><si><t>Revenue</t></si></sst>`,
"xl/worksheets/sheet1.xml": `
<worksheet xmlns:r="r"><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row></sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
<worksheet xmlns:r="r"><sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>North</t></is></c><c r="B2"><v>17</v></c></row>
</sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
"xl/worksheets/_rels/sheet1.xml.rels": relationships([{ id: "rIdDrawing", target: "../drawings/drawing1.xml" }]),
"xl/drawings/drawing1.xml": `
<xdr:wsDr xmlns:xdr="xdr" xmlns:a="a" xmlns:r="r"><xdr:oneCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:row>2</xdr:row></xdr:from><a:blip r:embed="rIdImage"/></xdr:oneCellAnchor></xdr:wsDr>`,
@@ -94,11 +97,27 @@ describe("document attachment extraction", () => {
const text = await result?.textFile.text() ?? ""
expect(text.includes("## Sheet: Summary")).toBe(true)
expect(text.includes("A1: Revenue | B1: 42")).toBe(true)
expect(text.includes("Range: A1:B2\nRevenue\t42\nNorth\t17")).toBe(true)
expect(text.includes("Image at B3: [budget-image-1.webp]")).toBe(true)
expect(result?.images[0]?.name).toBe("budget-image-1.webp")
})
test("quotes TSV values and keeps sparse XLSX rows coordinate-based", async () => {
const file = zippedFile("sparse.xlsx", {
"xl/workbook.xml": `<workbook xmlns:r="r"><sheets><sheet name="Data" r:id="sheet"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": relationships([{ id: "sheet", target: "worksheets/sheet1.xml" }]),
"xl/worksheets/sheet1.xml": `<worksheet><sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>line 1&#10;line 2</t></is></c><c r="B1" t="inlineStr"><is><t>say &quot;hi&quot;</t></is></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>first</t></is></c><c r="XFD2" t="inlineStr"><is><t>last</t></is></c></row>
</sheetData></worksheet>`,
})
const text = await (await extractDocumentAttachments(file))?.textFile.text() ?? ""
expect(text.includes('Range: A1:B1\n"line 1\nline 2"\t"say ""hi"""')).toBe(true)
expect(text.includes("Cells: A2\tfirst | XFD2\tlast")).toBe(true)
})
test("extracts OpenDocument text, presentations, spreadsheets, and image positions", async () => {
const image = pngBytes()
const odt = zippedFile("notes.odt", {
@@ -196,7 +215,7 @@ describe("document attachment extraction", () => {
test("does not retain images whose citations fall beyond the text limit", async () => {
const file = zippedFile("long.docx", {
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(250_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(500_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
"word/_rels/document.xml.rels": relationships([{ id: "image", target: "media/image.png" }]),
"word/media/image.png": pngBytes(),
})
@@ -204,7 +223,7 @@ describe("document attachment extraction", () => {
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.length <= 250_000).toBe(true)
expect(text.length <= 500_000).toBe(true)
expect(text.endsWith("[Document text truncated by OpenChamber]\n")).toBe(true)
expect(text.includes("[long-image-1.png]")).toBe(false)
expect(result?.images).toEqual([])
+102 -10
View File
@@ -8,7 +8,7 @@ const MAX_ARCHIVE_ENTRIES = 5_000
const MAX_EMBEDDED_IMAGES = 50
const MAX_EMBEDDED_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_EMBEDDED_IMAGES_BYTES = 40 * 1024 * 1024
const MAX_EXTRACTED_TEXT_CHARS = 250_000
const MAX_EXTRACTED_TEXT_CHARS = 500_000
const MAX_ODF_SPACES_PER_ELEMENT = 100
const TEXT_TRUNCATION_NOTICE = "\n\n[Document text truncated by OpenChamber]\n"
@@ -310,6 +310,17 @@ const columnName = (index: number): string => {
return result
}
const columnIndex = (name: string): number => {
let result = 0
for (const character of name.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64
return result - 1
}
const tsvValue = (value: string): string => {
if (!/[\t\r\n"]/.test(value)) return value
return `"${value.replace(/"/g, '""')}"`
}
const cellValue = (cell: string, sharedStrings: string[]): string => {
const type = attribute(cell.match(/^<c\b[^>]*>/i)?.[0] ?? "", "t")
if (type === "inlineStr") {
@@ -321,6 +332,95 @@ const cellValue = (cell: string, sharedStrings: string[]): string => {
return decodeXml(value)
}
type SpreadsheetCell = {
reference: string
column: number
row: number
value: string
}
type SpreadsheetRow = {
cells: SpreadsheetCell[]
firstColumn: number
lastColumn: number
row: number
}
const isDenseSpreadsheetRow = (row: SpreadsheetRow): boolean => {
const width = row.lastColumn - row.firstColumn + 1
return width <= Math.max(32, row.cells.length * 4)
}
const serializeDenseSpreadsheetRow = (row: SpreadsheetRow): string => {
const valuesByColumn = new Map(row.cells.map((cell) => [cell.column, cell.value]))
return Array.from(
{ length: row.lastColumn - row.firstColumn + 1 },
(_, offset) => tsvValue(valuesByColumn.get(row.firstColumn + offset) ?? ""),
).join("\t")
}
const spreadsheetRows = (worksheet: string, sharedStrings: string[]): SpreadsheetRow[] => {
const rows: SpreadsheetRow[] = []
for (const rowXml of tagBlocks(worksheet, "row")) {
const cells: SpreadsheetCell[] = []
for (const match of rowXml.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi)) {
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
const reference = attribute(tag, "r")
const coordinates = reference?.match(/^([a-z]+)([1-9]\d*)$/i)
const value = cellValue(match[0], sharedStrings)
if (!reference || !coordinates || !value) continue
cells.push({
reference,
column: columnIndex(coordinates[1]),
row: Number(coordinates[2]),
value,
})
}
cells.sort((left, right) => left.column - right.column)
const first = cells[0]
const last = cells.at(-1)
if (!first || !last) continue
rows.push({ cells, firstColumn: first.column, lastColumn: last.column, row: first.row })
}
return rows
}
const serializeSpreadsheetRows = (rows: SpreadsheetRow[]): string[] => {
const sections: string[] = []
let denseBlock: SpreadsheetRow[] = []
const flushDenseBlock = () => {
const first = denseBlock[0]
const last = denseBlock.at(-1)
if (!first || !last) return
sections.push([
`Range: ${columnName(first.firstColumn)}${first.row}:${columnName(first.lastColumn)}${last.row}`,
...denseBlock.map(serializeDenseSpreadsheetRow),
].join("\n"))
denseBlock = []
}
for (const row of rows) {
const previous = denseBlock.at(-1)
if (!isDenseSpreadsheetRow(row)) {
flushDenseBlock()
sections.push(`Cells: ${row.cells.map((cell) => `${cell.reference}\t${tsvValue(cell.value)}`).join(" | ")}`)
continue
}
if (
previous
&& (row.row !== previous.row + 1
|| row.firstColumn !== previous.firstColumn
|| row.lastColumn !== previous.lastColumn)
) {
flushDenseBlock()
}
denseBlock.push(row)
}
flushDenseBlock()
return sections
}
const drawingCitations = (
archive: Unzipped,
worksheetPath: string,
@@ -362,15 +462,7 @@ const extractXlsx = (archive: Unzipped, images: EmbeddedImages): string | undefi
if (!worksheetPath) continue
sections.push(`## Sheet: ${name}`)
const rows: string[] = []
for (const row of tagBlocks(xml(archive, worksheetPath), "row")) {
const cells = Array.from(row.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi), (match) => {
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
const reference = attribute(tag, "r") ?? "?"
return `${reference}: ${cellValue(match[0], sharedStrings)}`
}).filter((value) => !value.endsWith(": "))
if (cells.length > 0) rows.push(cells.join(" | "))
}
const rows = serializeSpreadsheetRows(spreadsheetRows(xml(archive, worksheetPath), sharedStrings))
sections.push(...(rows.length > 0 ? rows : ["[Empty sheet]"]), ...drawingCitations(archive, worksheetPath, images))
}
return `${sections.join("\n\n")}\n`
+37 -28
View File
@@ -54,6 +54,35 @@ const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Pro
reader.readAsDataURL(file)
})
export const prepareLocalAttachments = async (
file: File,
reservedFilenames: Iterable<string> = [],
): Promise<AttachedFile[] | undefined> => {
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!preparedFiles || preparedFiles.length === 0) return
const sourceDocumentId = preparedFiles.length > 1
? `${Date.now()}-${Math.random().toString(36).slice(2)}`
: undefined
const attachedFiles: AttachedFile[] = []
for (const prepared of preparedFiles) {
const dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
if (!dataUrl) return
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",
sourceDocumentId,
})
}
return attachedFiles
}
const getDataUrlByteSize = (url: string): number => {
if (!url.startsWith("data:")) return 0
const commaIndex = url.indexOf(",")
@@ -167,37 +196,17 @@ export const useInputStore = create<InputState>()((set, get) => ({
const generation = attachmentReadGeneration
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[] = []
const isDocumentExtraction = preparedFiles.length > 1
const sourceDocumentId = isDocumentExtraction ? `${Date.now()}-${Math.random().toString(36).slice(2)}` : undefined
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",
sourceDocumentId,
})
let attachedFiles: AttachedFile[] | undefined
try {
attachedFiles = await prepareLocalAttachments(file, reservedFilenames)
} catch {
return false
}
if (!attachedFiles || generation !== attachmentReadGeneration) return false
const generatedFilenames = attachedFiles.slice(1).map((attachment) => attachment.filename)
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
return true
}