📄 feat: Rich File Artifact Previews for DOCX, CSV, XLSX, PPTX

Render office files emitted by tools as interactive previews in the
artifact panel instead of raw extracted text. The backend produces a
sanitized HTML document via mammoth (DOCX), SheetJS (CSV/XLSX/XLS/ODS),
or yauzl-based slide extraction (PPTX) and ships it through the
existing SSE attachment payload; the client routes it through the
Sandpack `static` template's `index.html` slot — no new browser deps,
no client-side blob fetch, no React renderer components.
This commit is contained in:
Danny Avila 2026-05-04 05:58:39 +09:00
parent 3170bd8b22
commit 9ab4f040f3
17 changed files with 1535 additions and 89 deletions

View file

@ -105,6 +105,7 @@
"passport-local": "^1.0.0",
"pdfjs-dist": "^5.4.624",
"rate-limit-redis": "^4.2.0",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"traverse": "^0.6.7",
"ua-parser-js": "^1.0.36",
@ -116,6 +117,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/sanitize-html": "^2.13.0",
"jest": "^30.2.0",
"mongodb-memory-server": "^11.0.1",
"nodemon": "^3.0.3",

View file

@ -824,6 +824,12 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
width: fileMetadata.width,
height: fileMetadata.height,
tool_call_id: output.tool_call_id,
/* Inline text / sanitized HTML preview from
* `extractCodeArtifactText` — drives the file artifact panel's
* rich preview for DOCX/XLSX/CSV/PPTX. Pass null explicitly
* (rather than undefined) so the wire format is stable for the
* empty-text gate on the client. */
text: fileMetadata.text ?? null,
};
writeResponsesAttachment(res, tracker, attachment, metadata);
}

View file

@ -1,4 +1,4 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import copy from 'copy-to-clipboard';
import * as Tabs from '@radix-ui/react-tabs';
import { Code, Play, RefreshCw, X } from 'lucide-react';
@ -11,10 +11,22 @@ import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
import ArtifactVersion from './ArtifactVersion';
import ArtifactTabs from './ArtifactTabs';
import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
/**
* Artifact types whose preview is server-rendered HTML — there's no source
* for a "code" view (the underlying file is binary). Hide the code tab and
* snap the active tab to "preview" when one of these is selected.
*/
const PREVIEW_ONLY_ARTIFACT_TYPES = new Set<string>([
TOOL_ARTIFACT_TYPES.DOCX,
TOOL_ARTIFACT_TYPES.SPREADSHEET,
TOOL_ARTIFACT_TYPES.PRESENTATION,
]);
const MAX_BLUR_AMOUNT = 32;
const MAX_BACKDROP_OPACITY = 0.3;
@ -37,18 +49,21 @@ export default function Artifacts() {
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const tabOptions = [
{
value: 'code',
label: localize('com_ui_code'),
icon: <Code className="size-4" />,
},
{
value: 'preview',
label: localize('com_ui_preview'),
icon: <Play className="size-4" />,
},
];
const allTabOptions = useMemo(
() => [
{
value: 'code',
label: localize('com_ui_code'),
icon: <Code className="size-4" />,
},
{
value: 'preview',
label: localize('com_ui_preview'),
icon: <Play className="size-4" />,
},
],
[localize],
);
useEffect(() => {
setIsMounted(true);
@ -88,6 +103,23 @@ export default function Artifacts() {
setCurrentArtifactId,
} = useArtifacts();
/* Office artifacts (DOCX/SPREADSHEET/PRESENTATION) have no source view —
* the underlying file is binary and the "code" tab would display the
* generated HTML blob, which isn't useful. Filter the tab options and
* snap the active tab when the user lands on an office artifact while
* the code tab is selected. */
const isPreviewOnly =
currentArtifact?.type != null && PREVIEW_ONLY_ARTIFACT_TYPES.has(currentArtifact.type);
const tabOptions = useMemo(
() => (isPreviewOnly ? allTabOptions.filter((opt) => opt.value !== 'code') : allTabOptions),
[allTabOptions, isPreviewOnly],
);
useEffect(() => {
if (isPreviewOnly && activeTab === 'code') {
setActiveTab('preview');
}
}, [isPreviewOnly, activeTab, setActiveTab]);
const handleCopyArtifact = useCallback(() => {
const content = currentArtifact?.content ?? '';
if (!content) {

View file

@ -183,15 +183,40 @@ describe('Attachment routing for tool artifacts', () => {
expect(screen.queryByText('com_ui_artifact_click')).not.toBeInTheDocument();
});
it('falls through to the inline <pre> for unsupported text types (CSV)', () => {
const csv = baseAttachment({
filename: 'data.csv',
text: 'a,b,c\n1,2,3',
it('falls through to the inline <pre> for unsupported text types (JSON)', () => {
/* CSV used to fall through here, but now routes to the SPREADSHEET
* preview bucket. JSON is still inline-only (no dedicated viewer
* yet); use it as the canonical "unrouted text" example. */
const json = baseAttachment({
filename: 'data.json',
type: 'application/json',
text: '{"a":1,"b":2}',
} as Partial<TAttachment>);
const { container } = renderWith(<Attachment attachment={csv} />);
const { container } = renderWith(<Attachment attachment={json} />);
expect(container.querySelector('pre')).not.toBeNull();
expect(screen.queryByTestId('mermaid-render')).not.toBeInTheDocument();
});
it.each([
['data.csv', 'text/csv'],
['workbook.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
['report.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['deck.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
])('routes %s through the office preview panel (panel artifact)', (filename, type) => {
const att = baseAttachment({
file_id: `office-${filename}`,
filename,
type,
text: '<!DOCTYPE html><body><table><tr><td>x</td></tr></table></body>',
} as Partial<TAttachment>);
renderWith(<Attachment attachment={att} />);
expect(screen.getByText(filename)).toBeInTheDocument();
/* Auto-pressed open button (streaming + non-CODE bucket) — same UX as
* the HTML panel artifact above. */
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument();
const downloadPattern = new RegExp(`com_ui_download.*${filename.replace('.', '\\.')}`, 'i');
expect(screen.getByRole('button', { name: downloadPattern })).toBeInTheDocument();
});
});
describe('ToolArtifactCard click behaviour', () => {
@ -645,8 +670,9 @@ describe('AttachmentGroup routing', () => {
} as Partial<TAttachment>),
baseAttachment({
file_id: 'c',
filename: 'data.csv',
text: 'a,b,c\n1,2,3',
filename: 'data.json',
type: 'application/json',
text: '{"a":1}',
} as Partial<TAttachment>),
baseAttachment({
file_id: 'd',
@ -661,7 +687,7 @@ describe('AttachmentGroup routing', () => {
expect(screen.getByText('index.html')).toBeInTheDocument();
// Mermaid render
expect(screen.getByTestId('mermaid-render')).toBeInTheDocument();
// Inline text fallback for CSV
// Inline text fallback for JSON (CSV now goes to SPREADSHEET)
expect(container.querySelector('pre')).not.toBeNull();
// FileContainer for the plain zip (and potentially others)
expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0);

View file

@ -103,13 +103,17 @@ describe('LogContent attachment routing', () => {
expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument();
});
it('routes text-bearing CSV through inline <pre>, not the panel', () => {
const csv = baseAttachment({
it('routes text-bearing JSON through inline <pre>, not the panel', () => {
/* CSV used to fall through here, but now routes to the SPREADSHEET
* preview bucket. JSON has no dedicated viewer yet, so it remains
* the canonical "unrouted text" example. */
const json = baseAttachment({
file_id: 'c',
filename: 'data.csv',
text: 'a,b,c\n1,2,3',
filename: 'data.json',
type: 'application/json',
text: '{"a":1,"b":2}',
} as Partial<TAttachment>);
const { container } = renderWith(<LogContent output="" attachments={[csv]} />);
const { container } = renderWith(<LogContent output="" attachments={[json]} />);
expect(container.querySelector('pre')).not.toBeNull();
expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument();
expect(screen.queryByTestId('mermaid-render')).not.toBeInTheDocument();
@ -125,28 +129,47 @@ describe('LogContent attachment routing', () => {
expect(screen.getByTestId('log-link')).toHaveAttribute('data-filename', 'archive.zip');
});
it('renders a panel card for pptx with empty text using the placeholder', () => {
// pptx text extraction is deferred; the artifact still routes through
// the panel and gets the localized placeholder content. Here the
// localize mock returns the key, so we assert by class/structure.
it('renders a panel card for a pptx with backend-rendered HTML in text', () => {
/* PPTX (and DOCX/XLSX/CSV) now route through the office preview
* bucket with a strict empty-text gate — the artifact only registers
* once the backend's `bufferToOfficeHtml` has produced the slide-list
* HTML and shipped it via `attachment.text`. */
const pptx = baseAttachment({
file_id: 'e',
filename: 'slides.pptx',
text: undefined as unknown as string,
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: '<!DOCTYPE html><body><ol><li>Slide 1</li></ol></body>',
} as Partial<TAttachment>);
renderWith(<LogContent output="" attachments={[pptx]} />);
expect(screen.getByText('slides.pptx')).toBeInTheDocument();
});
it('falls back to the legacy download branch for an office file with no extracted text', () => {
/* Empty `text` for an office type fails the strict gate, so the
* artifact stays unregistered and the file flows to the download
* fallback (LogLink), not a half-rendered panel card. */
const pptx = baseAttachment({
file_id: 'e2',
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: undefined as unknown as string,
} as Partial<TAttachment>);
renderWith(<LogContent output="" attachments={[pptx]} />);
expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument();
expect(screen.getByTestId('log-link')).toHaveAttribute('data-filename', 'slides.pptx');
});
it('routes an expired panel-eligible attachment through the legacy expired path', () => {
// Without this gate, an expired pptx/html/etc. would render as a
// clickable artifact card backed by a dead download link. The
// legacy "download expired" message must win for any panel-eligible
// entry whose `expiresAt` is in the past.
/* Without this gate, an expired pptx/html/etc. with extracted HTML
* would render as a clickable artifact card backed by a dead
* download link. The legacy "download expired" message must win for
* any panel-eligible entry whose `expiresAt` is in the past — even
* when the office HTML is present. */
const expired = baseAttachment({
file_id: 'x-expired',
filename: 'slides.pptx',
text: undefined as unknown as string,
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: '<!DOCTYPE html><body><ol><li>Slide 1</li></ol></body>',
expiresAt: Date.now() - 60_000,
} as Partial<TAttachment>);
renderWith(<LogContent output="" attachments={[expired]} />);
@ -183,8 +206,13 @@ describe('LogContent attachment routing', () => {
} as Partial<TAttachment>),
baseAttachment({
file_id: 't',
filename: 'notes.csv',
text: 'a,b\n1,2',
/* JSON stays on the inline `<pre>` rendering path. CSV used to
* live here too but now routes to the SPREADSHEET preview
* bucket, so it would no longer satisfy the "inline pre" check
* below. */
filename: 'notes.json',
type: 'application/json',
text: '{"a":1,"b":2}',
} as Partial<TAttachment>),
] as TAttachment[];
const { container } = renderWith(
@ -194,7 +222,7 @@ describe('LogContent attachment routing', () => {
expect(screen.getByText('index.html')).toBeInTheDocument();
// mermaid render present
expect(screen.getByTestId('mermaid-render')).toBeInTheDocument();
// CSV inline pre present
// JSON inline pre present
expect(container.querySelector('pre')).not.toBeNull();
});
});

View file

@ -56,10 +56,15 @@ jest.mock('~/utils', () => ({
const textAttachment = (overrides: Partial<TAttachment> = {}): TAttachment =>
({
file_id: 'file-1',
filename: 'output.csv',
filepath: '/files/output.csv',
type: 'text/csv',
text: 'a,b,c\n1,2,3',
/* JSON stays on the inline `<pre>` rendering path. CSV used to live
* here too but now routes through the SPREADSHEET artifact panel
* (Recoil-bound), so a CSV fixture would force every test in this
* file to add a `RecoilRoot` wrapper. JSON has the same shape (text-
* bearing, downloadable, expandable) without the panel coupling. */
filename: 'output.json',
filepath: '/files/output.json',
type: 'application/json',
text: '{"a":1,"b":2,"c":3}',
...overrides,
}) as TAttachment;
@ -102,7 +107,7 @@ describe('TextAttachment (via Attachment default export)', () => {
const { container } = render(<Attachment attachment={textAttachment()} />);
const pre = container.querySelector('pre');
expect(pre).not.toBeNull();
expect(pre!.textContent).toBe('a,b,c\n1,2,3');
expect(pre!.textContent).toBe('{"a":1,"b":2,"c":3}');
});
it('renders a download chip when filepath is present', () => {
@ -163,10 +168,11 @@ describe('AttachmentGroup', () => {
});
it('routes text-bearing attachments through the text rendering path', () => {
// `.csv` is text-bearing but not artifact-eligible (CSV gets a
// dedicated viewer in a follow-up), so it falls through to the
// inline <pre> renderer rather than the side panel card.
const attachments = [textAttachment({ file_id: 'a', filename: 'a.csv' })] as TAttachment[];
/* `.json` is text-bearing but not artifact-eligible (JSON has no
* dedicated viewer yet), so it falls through to the inline <pre>
* renderer rather than the side panel card. CSV used to live here
* too but now routes through the SPREADSHEET artifact panel. */
const attachments = [textAttachment({ file_id: 'a', filename: 'a.json' })] as TAttachment[];
const { container } = render(<AttachmentGroup attachments={attachments} />);
expect(container.querySelector('pre')).not.toBeNull();
});

View file

@ -133,9 +133,13 @@ describe('artifactTypeForAttachment', () => {
});
it('returns null for unsupported extensions', () => {
/* CSV / DOCX / XLSX / PPTX now route through the office preview
* buckets (rich HTML preview); use a binary type with no preview
* pipeline instead. */
const attachment = baseAttachment({
filename: 'data.csv',
text: 'a,b,c',
filename: 'photo.jpg',
type: 'image/jpeg',
text: undefined,
} as Partial<TAttachment>);
expect(artifactTypeForAttachment(attachment)).toBeNull();
});

View file

@ -45,6 +45,21 @@ export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
return ['content.md', getMarkdownFiles(artifact.content ?? '')];
}
/* Office preview buckets (DOCX/SPREADSHEET/PRESENTATION): the backend
* already produced a complete sanitized HTML document via
* `bufferToOfficeHtml` and shipped it as `attachment.text`. Hand it
* to the Sandpack `static` template's `index.html` slot directly —
* no wrapping, no transformation, no client-side parsing libs. The
* empty-text gate in `detectArtifactTypeFromFile` guarantees we
* never reach this branch with an empty content payload. */
if (
type === TOOL_ARTIFACT_TYPES.DOCX ||
type === TOOL_ARTIFACT_TYPES.SPREADSHEET ||
type === TOOL_ARTIFACT_TYPES.PRESENTATION
) {
return ['index.html', { 'index.html': artifact.content ?? '' }];
}
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);
const files = removeNullishValues({
[fileKey]: artifact.content,

View file

@ -55,9 +55,13 @@ describe('detectArtifactTypeFromFile', () => {
['flow.mmd', TOOL_ARTIFACT_TYPES.MERMAID],
['flow.mermaid', TOOL_ARTIFACT_TYPES.MERMAID],
['readme.txt', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['report.docx', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['notes.odt', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['slides.pptx', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['report.docx', TOOL_ARTIFACT_TYPES.DOCX],
['data.csv', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['workbook.xlsx', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['legacy.xls', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['sheet.ods', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['slides.pptx', TOOL_ARTIFACT_TYPES.PRESENTATION],
])('classifies %s by extension', (filename, expected) => {
expect(detectArtifactTypeFromFile({ filename, type: '', text: 'content' })).toBe(expected);
});
@ -66,24 +70,53 @@ describe('detectArtifactTypeFromFile', () => {
['index.html', TOOL_ARTIFACT_TYPES.HTML],
['App.tsx', TOOL_ARTIFACT_TYPES.REACT],
['flow.mmd', TOOL_ARTIFACT_TYPES.MERMAID],
])('returns null when %s has no text (%s viewer needs real content)', (filename) => {
/* Office preview buckets need server-rendered HTML in `text` to render
* — the empty-text gate keeps the artifact off the panel until the
* backend's `bufferToOfficeHtml` finishes. */
['report.docx', TOOL_ARTIFACT_TYPES.DOCX],
['data.csv', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['workbook.xlsx', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['slides.pptx', TOOL_ARTIFACT_TYPES.PRESENTATION],
])('returns null when %s has no text (renderer needs real content)', (filename, _expected) => {
expect(detectArtifactTypeFromFile({ filename, type: '', text: '' })).toBeNull();
expect(detectArtifactTypeFromFile({ filename, type: '', text: undefined })).toBeNull();
});
it.each([
['readme.txt', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['slides.pptx', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['report.docx', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
['notes.md', TOOL_ARTIFACT_TYPES.MARKDOWN],
['notes.odt', TOOL_ARTIFACT_TYPES.PLAIN_TEXT],
])(
'still routes %s through the panel without text (deferred-extraction case)',
'still routes %s through the panel without text (deferred-extraction case for plain-text/markdown)',
(filename, expected) => {
expect(detectArtifactTypeFromFile({ filename, type: '', text: '' })).toBe(expected);
expect(detectArtifactTypeFromFile({ filename, type: '', text: undefined })).toBe(expected);
},
);
it.each([
[
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
TOOL_ARTIFACT_TYPES.DOCX,
],
[
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
TOOL_ARTIFACT_TYPES.SPREADSHEET,
],
['application/vnd.ms-excel', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/vnd.oasis.opendocument.spreadsheet', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['text/csv', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['application/csv', TOOL_ARTIFACT_TYPES.SPREADSHEET],
[
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
TOOL_ARTIFACT_TYPES.PRESENTATION,
],
])('routes office MIME %s to its preview bucket when extension is missing', (mime, expected) => {
expect(
detectArtifactTypeFromFile({ filename: 'noext', type: mime, text: '<html>x</html>' }),
).toBe(expected);
});
it('falls back to MIME when the extension is unknown', () => {
expect(detectArtifactTypeFromFile({ filename: 'noext', type: 'text/html', text: 'x' })).toBe(
TOOL_ARTIFACT_TYPES.HTML,
@ -114,12 +147,14 @@ describe('detectArtifactTypeFromFile', () => {
});
it('returns null for unsupported types', () => {
expect(
detectArtifactTypeFromFile({ filename: 'output.csv', type: 'text/csv', text: 'a,b' }),
).toBeNull();
/* PDFs have no rich preview path on the client (the artifact panel
* doesn't host a PDF viewer); they fall back to the download UI. */
expect(
detectArtifactTypeFromFile({ filename: 'doc.pdf', type: 'application/pdf', text: 'x' }),
).toBeNull();
expect(
detectArtifactTypeFromFile({ filename: 'photo.jpg', type: 'image/jpeg', text: 'binary' }),
).toBeNull();
});
it('does not route bare text/plain MIME without a recognized extension', () => {
@ -177,12 +212,11 @@ describe('detectArtifactTypeFromFile', () => {
);
});
it('does NOT route data formats to CODE (CSV / JSON / YAML / TOML / XML)', () => {
it('does NOT route data formats to CODE (JSON / YAML / TOML / XML)', () => {
/* These get dedicated viewers in follow-ups; for now they fall
* through to inline rendering (return null). */
expect(
detectArtifactTypeFromFile({ filename: 'data.csv', type: 'text/csv', text: 'a,b' }),
).toBeNull();
* through to inline rendering (return null). CSV is the exception:
* it routes to the SPREADSHEET preview bucket — covered separately
* in the "classifies %s by extension" suite above. */
expect(
detectArtifactTypeFromFile({ filename: 'data.json', type: 'application/json', text: '{}' }),
).toBeNull();
@ -397,7 +431,10 @@ describe('fileToArtifact', () => {
});
it('returns null for unsupported types so callers can fall through', () => {
expect(fileToArtifact({ ...baseFile, filename: 'data.csv', type: 'text/csv' })).toBeNull();
expect(fileToArtifact({ ...baseFile, filename: 'photo.jpg', type: 'image/jpeg' })).toBeNull();
expect(
fileToArtifact({ ...baseFile, filename: 'doc.pdf', type: 'application/pdf' }),
).toBeNull();
});
/* End-to-end test for the CODE bucket. The classification path is
@ -502,13 +539,18 @@ describe('fileToArtifact', () => {
});
it('uses the caller-provided placeholder when a deferred-extraction file has no text', () => {
// Backend extractor returns null for pptx (deferred). Client sees
// `text === null` and substitutes the localized placeholder.
/* Plain-text and markdown remain on the lenient empty-text gate so the
* artifact card can render a "preparing preview…" placeholder while
* extraction is in flight. (Office preview buckets — DOCX, SPREADSHEET,
* PRESENTATION — use the strict gate instead: their renderers need
* server-rendered HTML, so the artifact stays unregistered until the
* `text` field arrives. See the strict-gate test in the
* `detectArtifactTypeFromFile` suite.) */
const artifact = fileToArtifact(
{
...baseFile,
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
filename: 'notes.txt',
type: 'text/plain',
text: null as unknown as string,
},
{ placeholder: '_Coming soon_' },
@ -521,13 +563,85 @@ describe('fileToArtifact', () => {
it('falls back to empty content when no placeholder is supplied and text is missing', () => {
const artifact = fileToArtifact({
...baseFile,
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
filename: 'notes.txt',
type: 'text/plain',
text: undefined,
});
expect(artifact).not.toBeNull();
expect(artifact!.content).toBe('');
});
it('returns null for office preview buckets without text (strict gate)', () => {
expect(
fileToArtifact({
...baseFile,
filename: 'slides.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: undefined,
}),
).toBeNull();
expect(
fileToArtifact({
...baseFile,
filename: 'report.docx',
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
text: '',
}),
).toBeNull();
expect(
fileToArtifact({
...baseFile,
filename: 'data.csv',
type: 'text/csv',
text: undefined,
}),
).toBeNull();
});
it('builds a SPREADSHEET artifact for csv/xlsx with backend-rendered HTML in text', () => {
const csv = fileToArtifact({
...baseFile,
filename: 'data.csv',
type: 'text/csv',
text: '<!DOCTYPE html><table><tr><td>1</td></tr></table>',
});
expect(csv).not.toBeNull();
expect(csv!.type).toBe(TOOL_ARTIFACT_TYPES.SPREADSHEET);
expect(csv!.title).toBe('data.csv');
expect(csv!.content).toContain('<table>');
const xlsx = fileToArtifact({
...baseFile,
filename: 'workbook.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
text: '<!DOCTYPE html><body>sheet</body>',
});
expect(xlsx).not.toBeNull();
expect(xlsx!.type).toBe(TOOL_ARTIFACT_TYPES.SPREADSHEET);
});
it('builds a DOCX artifact for .docx with backend-rendered HTML in text', () => {
const artifact = fileToArtifact({
...baseFile,
filename: 'report.docx',
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
text: '<!DOCTYPE html><body><p>hello</p></body>',
});
expect(artifact).not.toBeNull();
expect(artifact!.type).toBe(TOOL_ARTIFACT_TYPES.DOCX);
});
it('builds a PRESENTATION artifact for .pptx with backend-rendered HTML in text', () => {
const artifact = fileToArtifact({
...baseFile,
filename: 'deck.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: '<!DOCTYPE html><body><ol><li>Slide 1</li></ol></body>',
});
expect(artifact).not.toBeNull();
expect(artifact!.type).toBe(TOOL_ARTIFACT_TYPES.PRESENTATION);
});
it('preserves an empty string as legitimate content (does not fall through to placeholder)', () => {
// A user can write a 0-byte `.md` or `.txt`; that's a valid artifact
// with empty content, not "extraction unavailable."

View file

@ -12,6 +12,13 @@ const artifactFilename = {
'application/vnd.ant.react': 'App.tsx',
'text/html': 'index.html',
'application/vnd.code-html': 'index.html',
/* Office preview buckets — the backend produces a complete sanitized
* `index.html` document (head + body) and ships it via `attachment.text`.
* The Sandpack `static` template loads it as-is. See
* `packages/api/src/files/documents/html.ts`. */
'application/vnd.librechat.docx-preview': 'index.html',
'application/vnd.librechat.spreadsheet-preview': 'index.html',
'application/vnd.librechat.presentation-preview': 'index.html',
// mermaid and markdown types are handled separately in useArtifactProps.ts
default: 'index.html',
// 'css': 'css',
@ -44,6 +51,11 @@ const artifactTemplate: Record<
'text/markdown': 'static',
'text/md': 'static',
'text/plain': 'static',
/* Office preview buckets ride the same static pipeline — the backend
* already sanitized the HTML, so we just hand it to Sandpack. */
'application/vnd.librechat.docx-preview': 'static',
'application/vnd.librechat.spreadsheet-preview': 'static',
'application/vnd.librechat.presentation-preview': 'static',
default: 'static',
// 'css': 'css',
// 'javascript': 'js',
@ -137,6 +149,11 @@ const dependenciesMap: Record<
'text/markdown': {},
'text/md': {},
'text/plain': {},
/* Office preview HTML is fully self-contained (CSS-only sheet tabs, no
* JS), so no Sandpack-side packages are needed. */
'application/vnd.librechat.docx-preview': {},
'application/vnd.librechat.spreadsheet-preview': {},
'application/vnd.librechat.presentation-preview': {},
default: standardDependencies,
};
@ -265,6 +282,16 @@ export const TOOL_ARTIFACT_TYPES = {
MERMAID: 'application/vnd.mermaid',
PLAIN_TEXT: 'text/plain',
CODE: 'application/vnd.code',
/* Office-format rich previews. The backend renders the binary file as a
* complete sanitized HTML document and ships it via `attachment.text`;
* the client routes these types through the Sandpack `static` template's
* `index.html` slot. The values are synthetic LibreChat-internal MIMEs
* — they don't appear on disk or in HTTP headers, only on the artifact
* object — so they can't collide with the canonical office MIMEs that
* the routing maps key off of. */
DOCX: 'application/vnd.librechat.docx-preview',
SPREADSHEET: 'application/vnd.librechat.spreadsheet-preview',
PRESENTATION: 'application/vnd.librechat.presentation-preview',
} as const;
export type ToolArtifactType = (typeof TOOL_ARTIFACT_TYPES)[keyof typeof TOOL_ARTIFACT_TYPES];
@ -501,13 +528,19 @@ const EXTENSION_TO_TOOL_ARTIFACT_TYPE: Record<string, ToolArtifactType> = {
mdx: TOOL_ARTIFACT_TYPES.MARKDOWN,
mmd: TOOL_ARTIFACT_TYPES.MERMAID,
mermaid: TOOL_ARTIFACT_TYPES.MERMAID,
// Plain text + office documents fall through to the markdown-style
// viewer until dedicated renderers land. `pptx` is wired up here so
// the routing fires as soon as backend text extraction is added.
txt: TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
docx: TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
// ODT has no rich HTML producer — it stays on the markdown-text path.
odt: TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
pptx: TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
/* Office formats with rich HTML previews. The backend's
* `extractCodeArtifactText` path produces a complete sanitized HTML
* document via `bufferToOfficeHtml` and ships it through
* `attachment.text`. */
docx: TOOL_ARTIFACT_TYPES.DOCX,
csv: TOOL_ARTIFACT_TYPES.SPREADSHEET,
xlsx: TOOL_ARTIFACT_TYPES.SPREADSHEET,
xls: TOOL_ARTIFACT_TYPES.SPREADSHEET,
ods: TOOL_ARTIFACT_TYPES.SPREADSHEET,
pptx: TOOL_ARTIFACT_TYPES.PRESENTATION,
};
/* Append every entry in `CODE_EXTENSION_TO_LANGUAGE` to the routing map
@ -563,14 +596,21 @@ const MIME_TO_TOOL_ARTIFACT_TYPE: Record<string, ToolArtifactType> = {
'text/x-lua': TOOL_ARTIFACT_TYPES.CODE,
'text/x-swift': TOOL_ARTIFACT_TYPES.CODE,
'text/css': TOOL_ARTIFACT_TYPES.CODE,
// Office MIME types fall through to the plain-text bucket here too —
// matches the extension map so a file whose extension was stripped
// somewhere upstream still routes to the panel.
// Office MIME types — route to the rich HTML preview buckets when the
// canonical MIME is present. ODT remains on the plain-text path (no
// dedicated HTML producer). These complement the extension map for
// extensionless filenames.
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
TOOL_ARTIFACT_TYPES.DOCX,
'application/vnd.oasis.opendocument.text': TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
TOOL_ARTIFACT_TYPES.SPREADSHEET,
'application/vnd.ms-excel': TOOL_ARTIFACT_TYPES.SPREADSHEET,
'application/vnd.oasis.opendocument.spreadsheet': TOOL_ARTIFACT_TYPES.SPREADSHEET,
'text/csv': TOOL_ARTIFACT_TYPES.SPREADSHEET,
'application/csv': TOOL_ARTIFACT_TYPES.SPREADSHEET,
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
TOOL_ARTIFACT_TYPES.PLAIN_TEXT,
TOOL_ARTIFACT_TYPES.PRESENTATION,
// Note: bare `text/plain` is NOT mapped here. The extension map handles
// `.txt` explicitly; routing every unrecognized-extension `text/plain`
// file (extensionless scripts, .env, etc.) through the panel would be a
@ -619,6 +659,12 @@ export function detectArtifactTypeFromFile(
type !== TOOL_ARTIFACT_TYPES.MARKDOWN &&
type !== TOOL_ARTIFACT_TYPES.CODE
) {
/* HTML, REACT, MERMAID, and the office preview buckets all require
* real content — their renderers (sandpack iframes / mermaid.js /
* the office HTML pipeline) error or render blank without it. The
* artifact stays unregistered until the backend produces text;
* `ToolArtifactCard`'s self-heal effect re-fires on drift so the
* card transitions cleanly when text arrives. */
return null;
}
return type;
@ -700,6 +746,12 @@ export function fileToArtifact(
type !== TOOL_ARTIFACT_TYPES.MARKDOWN &&
type !== TOOL_ARTIFACT_TYPES.CODE
) {
/* HTML, REACT, MERMAID, and the office preview buckets all require
* real content — their renderers (sandpack iframes / mermaid.js /
* the office HTML pipeline) error or render blank without it. The
* artifact stays unregistered until the backend produces text;
* `ToolArtifactCard`'s self-heal effect re-fires on drift so the
* card transitions cleanly when text arrives. */
return null;
}
/* For CODE artifacts, resolve the language hint at construction time

46
package-lock.json generated
View file

@ -120,6 +120,7 @@
"passport-local": "^1.0.0",
"pdfjs-dist": "^5.4.624",
"rate-limit-redis": "^4.2.0",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"traverse": "^0.6.7",
"ua-parser-js": "^1.0.36",
@ -131,6 +132,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/sanitize-html": "^2.13.0",
"jest": "^30.2.0",
"mongodb-memory-server": "^11.0.1",
"nodemon": "^3.0.3",
@ -21178,6 +21180,16 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@ -25380,7 +25392,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -26177,7 +26188,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": {
"node": ">=10"
},
@ -29585,6 +29595,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -35341,6 +35360,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -39963,6 +39988,20 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sanitize-html": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
"integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
@ -44243,6 +44282,7 @@
"@types/node": "^20.3.0",
"@types/node-fetch": "^2.6.13",
"@types/react": "^18.2.18",
"@types/sanitize-html": "^2.13.0",
"@types/winston": "^2.4.4",
"@types/yauzl": "^2.10.3",
"aws-sdk-client-mock": "^4.1.0",
@ -44256,6 +44296,7 @@
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"sanitize-html": "^2.13.0",
"ts-node": "^10.9.2",
"typescript": "^5.0.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
@ -44297,6 +44338,7 @@
"node-fetch": "2.7.0",
"pdfjs-dist": "^5.4.624",
"rate-limit-redis": "^4.2.0",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"undici": "^7.24.1",
"yauzl": "^3.2.1",

View file

@ -64,6 +64,7 @@
"@types/node": "^20.3.0",
"@types/node-fetch": "^2.6.13",
"@types/react": "^18.2.18",
"@types/sanitize-html": "^2.13.0",
"@types/winston": "^2.4.4",
"@types/yauzl": "^2.10.3",
"aws-sdk-client-mock": "^4.1.0",
@ -77,6 +78,7 @@
"rimraf": "^6.1.3",
"rollup": "^4.34.9",
"rollup-plugin-peer-deps-external": "^2.2.4",
"sanitize-html": "^2.13.0",
"ts-node": "^10.9.2",
"typescript": "^5.0.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
@ -121,6 +123,7 @@
"node-fetch": "2.7.0",
"pdfjs-dist": "^5.4.624",
"rate-limit-redis": "^4.2.0",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"undici": "^7.24.1",
"yauzl": "^3.2.1",

View file

@ -823,6 +823,15 @@ export interface AttachmentData {
height?: number;
/** Associated tool call ID */
tool_call_id?: string;
/**
* Inline text or sanitized HTML preview (sized to MAX_TEXT_CACHE_BYTES).
* Populated by `extractCodeArtifactText` for tool-output files: raw text
* for plain-text artifacts, sanitized rich HTML for office formats
* (DOCX/XLSX/CSV/PPTX). The frontend feeds HTML through the Sandpack
* `static` template via `index.html`. Null if extraction was unavailable
* (binary, oversized, or unsupported).
*/
text?: string | null;
/** Additional metadata */
[key: string]: unknown;
}

View file

@ -16,6 +16,19 @@ jest.mock('~/files/documents/crud', () => ({
}),
}));
/* The office HTML producer is mocked here so the existing fallback-path
* assertions (parseDocument receives the canonical MIME) keep exercising
* `extractDocument`. Tests that need real HTML output drive `bufferToOfficeHtml`
* directly via its own spec file (`html.spec.ts`); a separate `office-html`
* describe block below exercises the integration with this mock relaxed. */
const mockOfficeHtml = jest.fn(
async (_buffer: Buffer, _name: string, _mime: string) => null as string | null,
);
jest.mock('~/files/documents/html', () => ({
bufferToOfficeHtml: (buffer: Buffer, name: string, mime: string) =>
mockOfficeHtml(buffer, name, mime),
}));
describe('extractCodeArtifactText', () => {
describe('utf8-text', () => {
it('decodes a UTF-8 buffer', async () => {
@ -149,7 +162,8 @@ describe('extractCodeArtifactText', () => {
});
describe('skipped categories', () => {
it('returns null for pptx', async () => {
it('returns null for pptx when HTML rendering also fails', async () => {
mockOfficeHtml.mockResolvedValueOnce(null);
const text = await extractCodeArtifactText(
Buffer.from('PK'),
'slides.pptx',
@ -169,4 +183,120 @@ describe('extractCodeArtifactText', () => {
expect(text).toBeNull();
});
});
describe('office-html', () => {
beforeEach(() => {
mockOfficeHtml.mockReset();
parseDocumentCalls.length = 0;
});
it('returns the HTML rendering for a docx when the producer succeeds', async () => {
mockOfficeHtml.mockResolvedValueOnce('<!DOCTYPE html><html><body>docx html</body></html>');
const text = await extractCodeArtifactText(
Buffer.from('PK'),
'report.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'document',
);
expect(text).toBe('<!DOCTYPE html><html><body>docx html</body></html>');
expect(parseDocumentCalls.length).toBe(0);
});
it('returns the HTML rendering for a pptx when the producer succeeds', async () => {
mockOfficeHtml.mockResolvedValueOnce('<!DOCTYPE html><html><body>slides</body></html>');
const text = await extractCodeArtifactText(
Buffer.from('PK'),
'deck.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'pptx',
);
expect(text).toContain('slides');
});
it('returns the HTML rendering for csv (overriding utf8-text raw output)', async () => {
mockOfficeHtml.mockResolvedValueOnce('<!DOCTYPE html><table><tr><td>a</td></tr></table>');
const text = await extractCodeArtifactText(
Buffer.from('a,b\n1,2', 'utf-8'),
'data.csv',
'text/csv',
'utf8-text',
);
expect(text).toContain('<table>');
});
it('falls back to raw utf-8 text for csv when HTML rendering returns null', async () => {
mockOfficeHtml.mockResolvedValueOnce(null);
const text = await extractCodeArtifactText(
Buffer.from('a,b\n1,2\n', 'utf-8'),
'data.csv',
'text/csv',
'utf8-text',
);
expect(text).toBe('a,b\n1,2\n');
});
it('falls back to parseDocument for docx when HTML rendering returns null', async () => {
mockOfficeHtml.mockResolvedValueOnce(null);
const text = await extractCodeArtifactText(
Buffer.from('PKfake-docx'),
'report.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'document',
);
expect(text).toBe(docxText);
expect(parseDocumentCalls[0]?.originalname).toBe('report.docx');
});
it('falls back to parseDocument for docx when HTML rendering throws', async () => {
mockOfficeHtml.mockRejectedValueOnce(new Error('mammoth blew up'));
const text = await extractCodeArtifactText(
Buffer.from('PKfake-docx'),
'report.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'document',
);
expect(text).toBe(docxText);
expect(parseDocumentCalls[0]?.originalname).toBe('report.docx');
});
it('truncates HTML output when it exceeds the cache cap', async () => {
const huge = 'X'.repeat(MAX_TEXT_CACHE_BYTES + 5_000);
mockOfficeHtml.mockResolvedValueOnce(huge);
const text = await extractCodeArtifactText(
Buffer.from('PK'),
'big.docx',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'document',
);
expect(text).not.toBeNull();
expect(text!.endsWith('…[truncated]')).toBe(true);
expect(Buffer.byteLength(text!, 'utf-8')).toBeLessThanOrEqual(MAX_TEXT_CACHE_BYTES);
});
it('falls back to parseDocument for pdf (HTML producer returns null for unsupported types)', async () => {
// Default mock returns null — the producer's own dispatcher would do the
// same for PDF since pdf has no HTML rendering. Whether we call it or
// skip it is an implementation detail; what matters is that PDF still
// routes to parseDocument and yields the docx-mock text.
const text = await extractCodeArtifactText(
Buffer.from('%PDF-1.4'),
'doc.pdf',
'application/pdf',
'document',
);
expect(text).toBe(docxText);
expect(parseDocumentCalls[0]?.originalname).toBe('doc.pdf');
});
it('does not call the office HTML producer for plain .txt utf8-text', async () => {
const text = await extractCodeArtifactText(
Buffer.from('hello world\n', 'utf-8'),
'note.txt',
'text/plain',
'utf8-text',
);
expect(mockOfficeHtml).not.toHaveBeenCalled();
expect(text).toBe('hello world\n');
});
});
});

View file

@ -5,15 +5,33 @@ import { randomUUID } from 'crypto';
import { logger } from '@librechat/data-schemas';
import type { CodeArtifactCategory } from './classify';
import { parseDocument } from '~/files/documents/crud';
import { bufferToOfficeHtml } from '~/files/documents/html';
import { isBinaryBuffer } from '~/skills/binary';
import { withTimeout } from '~/utils/promise';
export const MAX_TEXT_CACHE_BYTES = 512 * 1024;
export const MAX_TEXT_EXTRACT_BYTES = 1024 * 1024;
const DOCUMENT_PARSE_TIMEOUT_MS = 8_000;
const OFFICE_HTML_TIMEOUT_MS = 12_000;
const TRUNCATION_MARKER = '\n\n…[truncated]';
const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, 'utf-8');
/**
* Extensions whose buffers should be rendered as HTML (rich preview) rather
* than plain text. CSV is included here even though it lives in the
* `utf8-text` category — when an office HTML producer is available we want
* the styled-table preview, not raw comma-separated text.
*/
const OFFICE_HTML_EXTENSIONS = new Set<string>(['docx', 'xlsx', 'xls', 'ods', 'pptx', 'csv']);
const extensionOf = (name: string): string => {
const dot = name.lastIndexOf('.');
if (dot < 0 || dot === name.length - 1) {
return '';
}
return name.slice(dot + 1).toLowerCase();
};
/**
* Truncate UTF-8 content to fit within MAX_TEXT_CACHE_BYTES. Walks back to a
* code-point boundary so the cut never lands inside a multi-byte sequence
@ -101,13 +119,53 @@ const extractDocument = async (
};
/**
* Extract a UTF-8 text representation of a code-execution artifact for inline
* Render an office-format buffer as a sanitized HTML preview document. Used
* for docx, xlsx/xls/ods, csv, and pptx — produces interactive HTML the
* frontend feeds into the Sandpack `static` template via `index.html`.
*
* Soft-fails: returns `null` if the file isn't an office type, rendering
* timed out, or the parser threw. Caller can fall through to the existing
* text-extraction path on null. This is intentional graceful degradation —
* a malformed `.docx` whose HTML rendering fails should still get a plain-
* text preview if `parseDocument` can salvage one.
*/
const renderOfficeHtml = async (
buffer: Buffer,
name: string,
mimeType: string,
): Promise<string | null> => {
try {
const html = await withTimeout(
bufferToOfficeHtml(buffer, name, mimeType),
OFFICE_HTML_TIMEOUT_MS,
`bufferToOfficeHtml exceeded ${OFFICE_HTML_TIMEOUT_MS}ms`,
);
if (html == null) {
return null;
}
return truncate(html);
} catch (error) {
logger.debug(
`[renderOfficeHtml] Failed to render "${name}" (${mimeType}): ${(error as Error).message}`,
);
return null;
}
};
/**
* Extract a string representation of a code-execution artifact for inline
* rendering. Returns `null` for binary, oversized, or unsupported files; the
* caller should fall back to the standard download UI in that case.
*
* Office types (docx, xlsx/xls/ods, csv, pptx) are rendered as sanitized
* HTML by the producers in `~/files/documents/html`. The frontend feeds the
* HTML into the Sandpack `static` template via `index.html`. CSV is special-
* cased here — its category is `utf8-text` (raw CSV is text), but we want
* the styled-table preview when the file extension says CSV.
*
* - office (docx/xlsx/xls/ods/csv/pptx): sanitized HTML preview
* - utf8-text: decodes the buffer (with a binary safety net)
* - document: dispatches to the existing PDF/DOCX/XLSX/ODT parser
* - pptx: not yet supported in this PR — returns null (follow-up work)
* - document: dispatches to the existing PDF/ODT parser
* - other: returns null (binary file, no inline preview)
*/
export async function extractCodeArtifactText(
@ -116,17 +174,37 @@ export async function extractCodeArtifactText(
mimeType: string,
category: CodeArtifactCategory,
): Promise<string | null> {
if (category === 'other' || category === 'pptx') {
if (category === 'other') {
return null;
}
if (buffer.length > MAX_TEXT_EXTRACT_BYTES) {
return null;
}
try {
/* Office HTML preview takes precedence for any file whose extension marks
* it as a rich-preview type. We check extension first because content
* sniffing routinely labels these formats as `application/zip` or
* `application/octet-stream`. The HTML producer's own dispatcher will
* also fall back to MIME for extensionless inputs. */
const ext = extensionOf(name);
const officeByExt = OFFICE_HTML_EXTENSIONS.has(ext);
if (officeByExt || category === 'document' || category === 'pptx') {
const html = await renderOfficeHtml(buffer, name, mimeType);
if (html != null) {
return html;
}
/* HTML producer returned null — fall through to text-only extraction
* for the `document` category (PDFs and ODT still need text), or to
* utf8-text for CSV. PPTX has no text fallback (returns null below). */
}
if (category === 'utf8-text') {
return extractUtf8(buffer);
}
return await extractDocument(buffer, name, mimeType);
if (category === 'document') {
return await extractDocument(buffer, name, mimeType);
}
/* category === 'pptx' with no HTML output → no preview available. */
return null;
} catch (error) {
logger.debug(
`[extractCodeArtifactText] Failed to extract "${name}" (${mimeType}): ${(error as Error).message}`,

View file

@ -0,0 +1,220 @@
import path from 'path';
import * as fs from 'fs';
import JSZip from 'jszip';
import {
bufferToOfficeHtml,
csvToHtml,
excelSheetToHtml,
pptxToSlideListHtml,
wordDocToHtml,
} from './html';
const fixturesDir = __dirname;
const readFixture = (name: string): Buffer => fs.readFileSync(path.join(fixturesDir, name));
describe('Office HTML producers', () => {
describe('wordDocToHtml', () => {
test('renders a docx with its paragraph text in a sanitized HTML document', async () => {
const html = await wordDocToHtml(readFixture('sample.docx'));
expect(html).toMatch(/^<!DOCTYPE html>/);
expect(html).toContain('<article class="lc-docx">');
expect(html).toContain('This is a sample DOCX file.');
});
test('strips <script> tags from the body and rejects event handlers', async () => {
// Build a docx-shaped HTML body and run it through the same sanitizer
// by stuffing it through the producer's output check. We can't easily
// inject script into the docx fixture, so we exercise the sanitizer
// contract directly via the public producer with a synthesized buffer
// that mammoth turns into a known paragraph — and assert no scripts
// could ever appear in the resulting wrapper.
const html = await wordDocToHtml(readFixture('sample.docx'));
expect(html).not.toMatch(/<script\b/i);
expect(html).not.toMatch(/onerror=/i);
});
});
describe('excelSheetToHtml', () => {
test('renders all sheets of a multi-sheet workbook into the HTML document', async () => {
const html = await excelSheetToHtml(readFixture('sample.xlsx'));
expect(html).toMatch(/^<!DOCTYPE html>/);
// Each sheet name should appear as a tab label.
expect(html).toContain('Sheet One');
expect(html).toContain('Second Sheet');
// Cell values from both sheets should render.
expect(html).toContain('first');
expect(html).toContain('Second');
// Tab strip uses pure-CSS radio inputs — verify the chrome wired up.
expect(html).toContain('lc-sheet-tab-radio');
expect(html).toContain('lc-sheet-panel-0');
expect(html).toContain('lc-sheet-panel-1');
});
test('omits the tab strip when only one sheet is present', async () => {
const html = await excelSheetToHtml(readFixture('sample.xls'));
// Cell content should render even though there's no tab strip.
expect(html).toContain('first');
expect(html).toContain('<table');
// Single sheet — no <nav class="lc-sheet-tabs">.
expect(html).not.toContain('class="lc-sheet-tabs"');
});
test('renders ods workbooks the same way', async () => {
const html = await excelSheetToHtml(readFixture('sample.ods'));
expect(html).toContain('Sheet One');
expect(html).toContain('Second Sheet');
});
});
describe('csvToHtml', () => {
test('renders a basic CSV as a single-table HTML document with no tab strip', async () => {
const csv = Buffer.from('name,age,city\nAlice,30,NYC\nBob,25,SF\n', 'utf-8');
const html = await csvToHtml(csv);
expect(html).toMatch(/^<!DOCTYPE html>/);
expect(html).toContain('Alice');
expect(html).toContain('NYC');
expect(html).toContain('<table');
expect(html).not.toContain('class="lc-sheet-tabs"');
});
test('handles CSV with embedded commas via quoted fields', async () => {
const csv = Buffer.from('label,value\n"hello, world",42\n', 'utf-8');
const html = await csvToHtml(csv);
expect(html).toContain('hello, world');
expect(html).toContain('42');
});
test('handles an empty CSV without crashing', async () => {
const html = await csvToHtml(Buffer.from('', 'utf-8'));
expect(html).toMatch(/^<!DOCTYPE html>/);
});
});
describe('pptxToSlideListHtml', () => {
/** Build a minimal valid PPTX with N synthesized slides for testing. */
const buildPptx = async (
slides: Array<{ title: string; body?: string[] }>,
): Promise<Buffer> => {
const zip = new JSZip();
const slideXml = (title: string, body: string[] = []) => {
const titleP = `<a:p><a:r><a:t>${title}</a:t></a:r></a:p>`;
const bodyPs = body.map((b) => `<a:p><a:r><a:t>${b}</a:t></a:r></a:p>`).join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
${titleP}
${bodyPs}
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>`;
};
slides.forEach((s, i) => {
zip.file(`ppt/slides/slide${i + 1}.xml`, slideXml(s.title, s.body));
});
// Add a dummy non-slide entry so we exercise the filter.
zip.file('docProps/core.xml', '<core/>');
return zip.generateAsync({ type: 'nodebuffer' });
};
test('extracts slide titles and body bullets in slide-number order', async () => {
const pptx = await buildPptx([
{ title: 'Welcome', body: ['First point', 'Second point'] },
{ title: 'Agenda', body: ['Item A', 'Item B', 'Item C'] },
{ title: 'Thanks!' },
]);
const html = await pptxToSlideListHtml(pptx);
expect(html).toMatch(/^<!DOCTYPE html>/);
expect(html).toContain('Slide 1');
expect(html).toContain('Welcome');
expect(html).toContain('First point');
expect(html).toContain('Slide 2');
expect(html).toContain('Agenda');
expect(html).toContain('Item C');
expect(html).toContain('Slide 3');
expect(html).toContain('Thanks!');
// Title appears before body in the doc.
expect(html.indexOf('Welcome')).toBeLessThan(html.indexOf('First point'));
});
test('handles a slide with no extractable text gracefully', async () => {
const pptx = await buildPptx([{ title: '' }]);
const html = await pptxToSlideListHtml(pptx);
expect(html).toContain('Slide 1');
expect(html).toContain('(empty slide)');
});
test('returns a friendly empty-state document when no slides are present', async () => {
const zip = new JSZip();
zip.file('docProps/core.xml', '<core/>');
const pptx = await zip.generateAsync({ type: 'nodebuffer' });
const html = await pptxToSlideListHtml(pptx);
expect(html).toContain('contains no readable slides');
});
test('decodes XML entities in slide text', async () => {
const pptx = await buildPptx([{ title: 'A &amp; B', body: ['x &lt; y'] }]);
const html = await pptxToSlideListHtml(pptx);
expect(html).toContain('A &amp; B'); // re-escaped on output
expect(html).toContain('x &lt; y');
});
});
describe('bufferToOfficeHtml dispatcher', () => {
test('routes by extension when MIME is generic', async () => {
const html = await bufferToOfficeHtml(
readFixture('sample.docx'),
'sample.docx',
'application/octet-stream',
);
expect(html).not.toBeNull();
expect(html!).toContain('This is a sample DOCX file.');
});
test('routes by MIME when extension is missing', async () => {
const html = await bufferToOfficeHtml(
readFixture('sample.xlsx'),
'workbook',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
expect(html).not.toBeNull();
expect(html!).toContain('Sheet One');
});
test('routes csv by extension', async () => {
const html = await bufferToOfficeHtml(
Buffer.from('a,b\n1,2', 'utf-8'),
'data.csv',
'application/octet-stream',
);
expect(html).not.toBeNull();
expect(html!).toContain('<table');
});
test('routes csv by MIME when extension is missing', async () => {
const html = await bufferToOfficeHtml(Buffer.from('a,b\n1,2', 'utf-8'), 'data', 'text/csv');
expect(html).not.toBeNull();
expect(html!).toContain('<table');
});
test('returns null for unrecognized types', async () => {
const html = await bufferToOfficeHtml(Buffer.from('hello'), 'notes.txt', 'text/plain');
expect(html).toBeNull();
});
test('extension wins over MIME (sniff misclassifies docx as application/zip)', async () => {
const html = await bufferToOfficeHtml(
readFixture('sample.docx'),
'sample.docx',
'application/zip',
);
expect(html).not.toBeNull();
expect(html!).toContain('lc-docx');
});
});
});

View file

@ -0,0 +1,679 @@
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs/promises';
import { randomUUID } from 'crypto';
import yauzl from 'yauzl';
import { excelMimeTypes, megabyte } from 'librechat-data-provider';
/**
* Maximum decompressed size we'll accept from a single PPTX entry. Mirrors the
* `ODT_MAX_DECOMPRESSED_SIZE` cap in `crud.ts` — both pptx and odt are zip
* archives whose central directory's `uncompressedSize` cannot be trusted.
*/
const PPTX_MAX_ENTRY_SIZE = 50 * megabyte;
/** Cap on the number of slides we'll render — pathological decks bail early. */
const PPTX_MAX_SLIDES = 500;
/** Per-sheet row cap for spreadsheet rendering. Above this we truncate with a
* visible banner so a 100k-row sheet doesn't blow the 512KB cache cap. */
const SPREADSHEET_MAX_ROWS_PER_SHEET = 5_000;
/** Lazy-loaded `sanitize-html` module (commonjs interop). */
let sanitizeHtmlModule: typeof import('sanitize-html') | null = null;
async function getSanitizer(): Promise<typeof import('sanitize-html')> {
if (sanitizeHtmlModule) {
return sanitizeHtmlModule;
}
const mod = await import('sanitize-html');
sanitizeHtmlModule = (mod.default ?? mod) as typeof import('sanitize-html');
return sanitizeHtmlModule;
}
/**
* Strip color-bearing inline styles so the dark/light wrapper around the
* sandboxed iframe controls foreground/background. Mammoth and SheetJS both
* emit hardcoded `color: black` / `background-color: white` declarations that
* make the preview unreadable in dark mode otherwise.
*/
const COLOR_PROPERTY_PATTERN = /(?:^|;)\s*(?:color|background|background-color)\s*:[^;]*/gi;
function stripColorStyles(style: string | undefined): string | undefined {
if (!style) {
return style;
}
const cleaned = style
.replace(COLOR_PROPERTY_PATTERN, '')
.replace(/^\s*;+\s*/, '')
.trim();
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* Sanitize HTML produced by mammoth / SheetJS / our own pptx renderer for
* embedding in the Sandpack `static` iframe. Allows the structural and
* formatting tags those producers emit, plus inline `data:` images (mammoth
* inlines DOCX images as base64). Strips `<script>`, `<iframe>`, event
* handlers, `javascript:` URLs, and color-bearing inline styles.
*
* The Sandpack iframe is itself a sandbox — this is defense in depth, not
* the sole barrier.
*/
async function sanitizeOfficeHtml(html: string): Promise<string> {
const sanitizeHtml = await getSanitizer();
return sanitizeHtml(html, {
allowedTags: [
'a',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'div',
'em',
'figure',
'figcaption',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
'section',
'small',
'span',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'u',
'ul',
],
allowedAttributes: {
a: ['href', 'name', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
table: ['border', 'cellpadding', 'cellspacing', 'class'],
td: ['colspan', 'rowspan', 'class', 'style'],
th: ['colspan', 'rowspan', 'scope', 'class', 'style'],
col: ['span', 'width', 'style'],
colgroup: ['span', 'width', 'style'],
div: ['class'],
span: ['class'],
section: ['class', 'aria-labelledby'],
h1: ['id'],
h2: ['id'],
h3: ['id'],
h4: ['id'],
h5: ['id'],
h6: ['id'],
},
allowedSchemes: ['http', 'https', 'data', 'mailto'],
allowedSchemesByTag: {
img: ['http', 'https', 'data'],
},
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: { ...attribs, rel: 'noopener noreferrer', target: '_blank' },
}),
},
exclusiveFilter: () => false,
/* sanitize-html runs `allowedAttributes` BEFORE per-attribute filtering, so
* `style` only survives where we explicitly allow it (td/th/col/colgroup).
* For those we still want to drop color declarations — defense against the
* mammoth/SheetJS hardcoded color-on-white issue. */
allowedStyles: {
'*': {
'text-align': [/^left$|^right$|^center$|^justify$/],
'font-weight': [/^[1-9]00$|^bold$|^normal$/],
'font-style': [/^italic$|^normal$/],
width: [/^\d+(?:\.\d+)?(?:px|%|em|rem)?$/],
height: [/^\d+(?:\.\d+)?(?:px|%|em|rem)?$/],
'min-width': [/^\d+(?:\.\d+)?(?:px|%|em|rem)?$/],
'vertical-align': [/^top$|^middle$|^bottom$|^baseline$/],
},
},
});
}
/**
* Wrap a sanitized HTML body in a complete document with the styles we want
* inside the Sandpack iframe. The CSS palette uses `prefers-color-scheme` so
* the iframe inherits dark/light from its parent (Sandpack iframes inherit
* the prefers-color-scheme media query from the host document).
*/
function wrapAsDocument(bodyHtml: string, extraHeadHtml = ''): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Preview</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1f2937;
--muted: #6b7280;
--border: rgba(128, 128, 128, 0.25);
--row-alt: rgba(128, 128, 128, 0.04);
--row-hover: rgba(59, 130, 246, 0.08);
--header-bg: rgba(243, 244, 246, 0.95);
--tab-active-bg: rgba(229, 231, 235, 0.95);
--tab-bg: transparent;
--link: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a2e;
--fg: #e5e7eb;
--muted: #9ca3af;
--border: rgba(128, 128, 128, 0.35);
--row-alt: rgba(255, 255, 255, 0.03);
--row-hover: rgba(59, 130, 246, 0.12);
--header-bg: rgba(31, 41, 55, 0.95);
--tab-active-bg: rgba(55, 65, 81, 0.95);
--link: #60a5fa;
}
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
}
body { padding: 16px; }
a { color: var(--link); }
img { max-width: 100%; height: auto; }
h1 { font-size: 1.5rem; font-weight: 700; margin: 0.75em 0 0.5em; }
h2 { font-size: 1.25rem; font-weight: 600; margin: 0.75em 0 0.5em; }
h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; }
h4, h5, h6 { font-weight: 600; margin: 0.5em 0 0.25em; }
p { margin: 0.4em 0; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
table { border-collapse: collapse; font-size: 0.85rem; line-height: 1.3; margin: 0.5em 0; max-width: 100%; }
th, td {
border: 1px solid var(--border);
padding: 0.4em 0.6em;
text-align: left;
vertical-align: top;
white-space: nowrap;
}
thead th, table th[scope="col"] {
background: var(--header-bg);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
tbody tr:nth-child(even) { background: var(--row-alt); }
tbody tr:hover { background: var(--row-hover); }
.lc-banner {
background: var(--row-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
margin: 0 0 8px;
font-size: 0.8rem;
color: var(--muted);
}
.lc-table-scroll { overflow-x: auto; max-width: 100%; }
${extraHeadHtml}
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`;
}
/* =============================================================================
* DOCX → HTML
* ============================================================================= */
/**
* Convert a `.docx` buffer to a sandboxed HTML document. Uses mammoth's
* default style map (paragraph styles, runs, tables, hyperlinks, lists) and
* inlines images as base64 `data:` URIs. The output is sanitized and wrapped
* in our document template before return.
*/
export async function wordDocToHtml(buffer: Buffer): Promise<string> {
const { convertToHtml } = await import('mammoth');
const result = await convertToHtml({ buffer });
const sanitized = await sanitizeOfficeHtml(result.value);
return wrapAsDocument(`<article class="lc-docx">${sanitized}</article>`);
}
/* =============================================================================
* XLSX / XLS / ODS → HTML (multi-sheet with pure-CSS tab strip)
* ============================================================================= */
/** A workbook sheet rendered to its `<table>` HTML and metadata. */
interface RenderedSheet {
name: string;
html: string;
truncated: boolean;
totalRows: number;
}
async function renderWorkbookSheets(
workbook: import('xlsx').WorkBook,
XLSX: typeof import('xlsx'),
): Promise<RenderedSheet[]> {
const sheets: RenderedSheet[] = [];
for (const sheetName of workbook.SheetNames) {
const ws = workbook.Sheets[sheetName];
const ref = ws['!ref'];
let totalRows = 0;
let truncated = false;
if (ref) {
const range = XLSX.utils.decode_range(ref);
totalRows = range.e.r - range.s.r + 1;
if (totalRows > SPREADSHEET_MAX_ROWS_PER_SHEET) {
const cappedEnd = range.s.r + SPREADSHEET_MAX_ROWS_PER_SHEET - 1;
ws['!ref'] = XLSX.utils.encode_range({
s: range.s,
e: { r: cappedEnd, c: range.e.c },
});
truncated = true;
}
}
const html = XLSX.utils.sheet_to_html(ws, { editable: false, header: '', footer: '' });
sheets.push({ name: sheetName, html, truncated, totalRows });
}
return sheets;
}
/**
* Build a self-contained HTML document with a pure-CSS tab strip for
* multi-sheet workbooks. Sheet switching uses radio inputs + `:checked ~`
* sibling selectors so no JavaScript is needed inside the iframe.
*/
function renderSpreadsheetHtml(sheets: RenderedSheet[]): string {
if (sheets.length === 0) {
return wrapAsDocument('<p class="lc-banner">This workbook contains no sheets.</p>');
}
const tabStripCss = `
.lc-sheet-tabs { display: flex; flex-wrap: wrap; gap: 2px; padding: 0 0 8px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
.lc-sheet-tab-radio { position: absolute; opacity: 0; pointer-events: none; }
.lc-sheet-tab-label {
cursor: pointer;
padding: 4px 12px;
border-radius: 6px 6px 0 0;
font-size: 0.8rem;
color: var(--muted);
border: 1px solid transparent;
border-bottom: none;
user-select: none;
}
.lc-sheet-tab-label:hover { color: var(--fg); background: var(--row-alt); }
.lc-sheet-panel { display: none; }
${sheets
.map(
(_, i) =>
`#lc-sheet-tab-${i}:checked ~ .lc-sheet-tabs label[for="lc-sheet-tab-${i}"] { color: var(--fg); background: var(--tab-active-bg); border-color: var(--border); font-weight: 600; }
#lc-sheet-tab-${i}:checked ~ #lc-sheet-panel-${i} { display: block; }`,
)
.join('\n')}
`.trim();
const radios = sheets
.map(
(_, i) =>
`<input type="radio" name="lc-sheet" id="lc-sheet-tab-${i}" class="lc-sheet-tab-radio"${i === 0 ? ' checked' : ''}>`,
)
.join('\n');
const tabs =
sheets.length > 1
? `<nav class="lc-sheet-tabs">${sheets
.map(
(s, i) =>
`<label class="lc-sheet-tab-label" for="lc-sheet-tab-${i}">${escapeHtml(s.name)}</label>`,
)
.join('')}</nav>`
: '';
const panels = sheets
.map((s, i) => {
const banner = s.truncated
? `<div class="lc-banner">Showing first ${SPREADSHEET_MAX_ROWS_PER_SHEET.toLocaleString()} of ${s.totalRows.toLocaleString()} rows. Download the file to see the rest.</div>`
: '';
return `<section class="lc-sheet-panel" id="lc-sheet-panel-${i}">${banner}<div class="lc-table-scroll">${s.html}</div></section>`;
})
.join('\n');
return wrapAsDocument(`${radios}\n${tabs}\n${panels}`, tabStripCss);
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Convert a workbook buffer (`.xlsx`, `.xls`, `.ods`) to a sandboxed HTML
* document. Each sheet is rendered as its own `<table>` and the document
* carries a pure-CSS tab strip for sheet switching.
*/
export async function excelSheetToHtml(buffer: Buffer): Promise<string> {
const XLSX = await import('xlsx');
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheets = await renderWorkbookSheets(workbook, XLSX);
/* The per-sheet HTML from `sheet_to_html` is generally well-formed but we
* still sanitize it (defense in depth). The chrome (tab strip, banners) is
* our own code and doesn't need sanitization. */
const sanitizedSheets = await Promise.all(
sheets.map(async (s) => ({ ...s, html: await sanitizeOfficeHtml(s.html) })),
);
return renderSpreadsheetHtml(sanitizedSheets);
}
/* =============================================================================
* CSV → HTML
* ============================================================================= */
/** Convert a CSV buffer to a sandboxed HTML document with a single table. */
export async function csvToHtml(buffer: Buffer): Promise<string> {
const XLSX = await import('xlsx');
const text = buffer.toString('utf-8');
/* `XLSX.read` with `type: 'string'` accepts CSV as well as XML/JSON
* formats; the default sheet name for CSV is `Sheet1` which we relabel
* below for a friendlier tab. */
const workbook = XLSX.read(text, { type: 'string', raw: true });
if (workbook.SheetNames.length > 0) {
const original = workbook.SheetNames[0];
workbook.Sheets[original]['!_lc_csv_label'] = 'CSV';
}
const sheets = await renderWorkbookSheets(workbook, XLSX);
// Single sheet for CSV — relabel to "CSV" for clarity, no tab strip emitted.
const sanitized = await Promise.all(
sheets.map(async (s) => ({ ...s, name: 'CSV', html: await sanitizeOfficeHtml(s.html) })),
);
return renderSpreadsheetHtml(sanitized);
}
/* =============================================================================
* PPTX → slide-list HTML
* ============================================================================= */
interface PptxSlide {
number: number;
title: string;
body: string[];
}
/**
* Stream `ppt/slides/slide*.xml` out of a PPTX buffer using yauzl. Mirrors
* the anti-zip-bomb pattern used by `extractOdtContentXml` in `crud.ts` —
* counts real decompressed bytes mid-inflate so a falsified central-directory
* `uncompressedSize` cannot bypass the cap. Returns slides in slide-number
* order; ignores everything else in the archive.
*/
async function extractPptxSlideXml(
buffer: Buffer,
): Promise<Array<{ number: number; xml: string }>> {
const tempPath = path.join(os.tmpdir(), `pptx-${randomUUID()}`);
await fs.writeFile(tempPath, buffer);
try {
return await readSlidesFromZip(tempPath);
} finally {
fs.unlink(tempPath).catch(() => {});
}
}
function readSlidesFromZip(filePath: string): Promise<Array<{ number: number; xml: string }>> {
return new Promise((resolve, reject) => {
yauzl.open(filePath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
return reject(err);
}
if (!zipfile) {
return reject(new Error('Failed to open PPTX file'));
}
let settled = false;
const slides: Array<{ number: number; xml: string }> = [];
const finish = (error: Error | null) => {
if (settled) {
return;
}
settled = true;
zipfile.close();
if (error) {
reject(error);
} else {
slides.sort((a, b) => a.number - b.number);
resolve(slides);
}
};
zipfile.readEntry();
zipfile.on('entry', (entry: yauzl.Entry) => {
const slideMatch = entry.fileName.match(/^ppt\/slides\/slide(\d+)\.xml$/);
if (!slideMatch) {
zipfile.readEntry();
return;
}
if (slides.length >= PPTX_MAX_SLIDES) {
// Cap reached — drain remaining entries silently.
zipfile.readEntry();
return;
}
const slideNumber = Number.parseInt(slideMatch[1], 10);
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
return finish(streamErr ?? new Error('Failed to open slide stream'));
}
let totalBytes = 0;
const chunks: Buffer[] = [];
readStream.on('data', (chunk: Buffer) => {
totalBytes += chunk.byteLength;
if (totalBytes > PPTX_MAX_ENTRY_SIZE) {
readStream.destroy(
new Error(
`PPTX slide${slideNumber}.xml exceeds the ${PPTX_MAX_ENTRY_SIZE / megabyte}MB decompressed limit`,
),
);
return;
}
chunks.push(chunk);
});
readStream.on('end', () => {
slides.push({ number: slideNumber, xml: Buffer.concat(chunks).toString('utf-8') });
zipfile.readEntry();
});
readStream.on('error', (readErr: Error) => finish(readErr));
});
});
zipfile.on('end', () => finish(null));
zipfile.on('error', (zipErr: Error) => finish(zipErr));
});
});
}
/**
* Pull the visible text out of a slide XML. PPTX text lives in `<a:t>`
* elements, grouped by `<a:p>` (paragraph). We keep paragraph boundaries so
* the rendered slide preserves bullet/line structure.
*
* Pure regex (no DOMParser available in Node by default; the markup is
* tightly defined by the OOXML spec, so regex is robust enough for the text-
* only preview).
*/
function extractSlideText(xml: string): { title: string; body: string[] } {
const paragraphs: string[] = [];
const paragraphMatches = xml.matchAll(/<a:p\b[^>]*>([\s\S]*?)<\/a:p>/g);
for (const match of paragraphMatches) {
const innerXml = match[1];
const runs: string[] = [];
const runMatches = innerXml.matchAll(/<a:t\b[^>]*>([\s\S]*?)<\/a:t>/g);
for (const run of runMatches) {
runs.push(decodeXmlEntities(run[1]));
}
const text = runs.join('').trim();
if (text.length > 0) {
paragraphs.push(text);
}
}
const title = paragraphs[0] ?? '';
const body = paragraphs.slice(1);
return { title, body };
}
const XML_ENTITIES: Record<string, string> = {
'&lt;': '<',
'&gt;': '>',
'&amp;': '&',
'&quot;': '"',
'&apos;': "'",
};
function decodeXmlEntities(input: string): string {
return input.replace(/&(?:lt|gt|amp|quot|apos);/g, (m) => XML_ENTITIES[m] ?? m);
}
function renderPptxSlidesHtml(slides: PptxSlide[]): string {
if (slides.length === 0) {
return wrapAsDocument(
'<p class="lc-banner">This presentation contains no readable slides.</p>',
);
}
const slideCss = `
.lc-pptx-list { display: flex; flex-direction: column; gap: 16px; padding: 0; margin: 0; list-style: none; }
.lc-pptx-slide {
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
background: var(--bg);
position: relative;
}
.lc-pptx-slide-number {
display: inline-block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 6px;
}
.lc-pptx-slide-title { font-size: 1.15rem; font-weight: 600; margin: 0 0 8px; }
.lc-pptx-slide-body { margin: 0; padding-left: 1.25em; }
.lc-pptx-slide-body li { margin: 0.2em 0; }
.lc-pptx-slide-empty { color: var(--muted); font-style: italic; margin: 0; }
`.trim();
const items = slides
.map((slide) => {
const titleHtml = slide.title
? `<h2 class="lc-pptx-slide-title">${escapeHtml(slide.title)}</h2>`
: '';
const bodyHtml =
slide.body.length > 0
? `<ul class="lc-pptx-slide-body">${slide.body.map((line) => `<li>${escapeHtml(line)}</li>`).join('')}</ul>`
: !slide.title
? '<p class="lc-pptx-slide-empty">(empty slide)</p>'
: '';
return `<li class="lc-pptx-slide">
<span class="lc-pptx-slide-number">Slide ${slide.number}</span>
${titleHtml}
${bodyHtml}
</li>`;
})
.join('\n');
return wrapAsDocument(`<ol class="lc-pptx-list">${items}</ol>`, slideCss);
}
/**
* Convert a `.pptx` buffer to a sandboxed HTML document rendering each slide
* as a card (slide number, title, body bullets). Honest about being a
* text-only preview — complex visual layouts, charts, and embedded media are
* not represented. Higher-fidelity rendering is a deferred follow-up.
*/
export async function pptxToSlideListHtml(buffer: Buffer): Promise<string> {
const rawSlides = await extractPptxSlideXml(buffer);
const slides: PptxSlide[] = rawSlides.map(({ number, xml }) => {
const { title, body } = extractSlideText(xml);
return { number, title, body };
});
return renderPptxSlidesHtml(slides);
}
/* =============================================================================
* Dispatcher
* ============================================================================= */
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
const CSV_MIME_PATTERN = /^(text\/csv|application\/csv|text\/comma-separated-values)$/i;
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
if (dot < 0 || dot === name.length - 1) {
return '';
}
return name.slice(dot + 1).toLowerCase();
}
/**
* Route an office-format buffer to the matching HTML producer. Returns `null`
* if the file isn't a recognized office type — caller should fall through to
* its existing text/binary handling.
*
* Extension wins over MIME because content sniffing routinely labels these
* formats as `application/zip` or `application/octet-stream`.
*/
export async function bufferToOfficeHtml(
buffer: Buffer,
name: string,
mimeType: string,
): Promise<string | null> {
const ext = extensionOf(name);
if (ext === 'docx' || mimeType === DOCX_MIME) {
return wordDocToHtml(buffer);
}
if (ext === 'csv' || CSV_MIME_PATTERN.test(mimeType)) {
return csvToHtml(buffer);
}
if (
ext === 'xlsx' ||
ext === 'xls' ||
ext === 'ods' ||
excelMimeTypes.test(mimeType) ||
mimeType === ODS_MIME
) {
return excelSheetToHtml(buffer);
}
if (ext === 'pptx' || mimeType === PPTX_MIME) {
return pptxToSlideListHtml(buffer);
}
return null;
}