mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
📄 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:
parent
3170bd8b22
commit
9ab4f040f3
17 changed files with 1535 additions and 89 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
46
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
220
packages/api/src/files/documents/html.spec.ts
Normal file
220
packages/api/src/files/documents/html.spec.ts
Normal 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 & B', body: ['x < y'] }]);
|
||||
const html = await pptxToSlideListHtml(pptx);
|
||||
expect(html).toContain('A & B'); // re-escaped on output
|
||||
expect(html).toContain('x < 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
679
packages/api/src/files/documents/html.ts
Normal file
679
packages/api/src/files/documents/html.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> = {
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
};
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue