fix(ui): compact Office attachment context
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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 line 2</t></is></c><c r="B1" t="inlineStr"><is><t>say "hi"</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([])
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user