diff --git a/api/package.json b/api/package.json index 7007791088..c5b588610e 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 2af4d5b451..e41b2a9d7e 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -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); } diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index e2a322b1ad..c9ddac3d17 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -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([ + 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: , - }, - { - value: 'preview', - label: localize('com_ui_preview'), - icon: , - }, - ]; + const allTabOptions = useMemo( + () => [ + { + value: 'code', + label: localize('com_ui_code'), + icon: , + }, + { + value: 'preview', + label: localize('com_ui_preview'), + icon: , + }, + ], + [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) { diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx index 8a4d6728fe..4b6c93ae92 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx @@ -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
 for unsupported text types (CSV)', () => {
-    const csv = baseAttachment({
-      filename: 'data.csv',
-      text: 'a,b,c\n1,2,3',
+  it('falls through to the inline 
 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);
-    const { container } = renderWith();
+    const { container } = renderWith();
     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: '
x
', + } as Partial); + renderWith(); + 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), 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), 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); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx index fa14cca11c..bb34bb1dee 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx @@ -103,13 +103,17 @@ describe('LogContent attachment routing', () => { expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument(); }); - it('routes text-bearing CSV through inline
, not the panel', () => {
-    const csv = baseAttachment({
+  it('routes text-bearing JSON through inline 
, 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);
-    const { container } = renderWith();
+    const { container } = renderWith();
     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: '
  1. Slide 1
', } as Partial); renderWith(); 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); + renderWith(); + 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: '
  1. Slide 1
', expiresAt: Date.now() - 60_000, } as Partial); renderWith(); @@ -183,8 +206,13 @@ describe('LogContent attachment routing', () => { } as Partial), baseAttachment({ file_id: 't', - filename: 'notes.csv', - text: 'a,b\n1,2', + /* JSON stays on the inline `
` 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),
     ] 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();
   });
 });
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx
index 7a6d0b003e..c5a8369796 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx
@@ -56,10 +56,15 @@ jest.mock('~/utils', () => ({
 const textAttachment = (overrides: Partial = {}): 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 `
` 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();
     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 
 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 
+     * 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();
     expect(container.querySelector('pre')).not.toBeNull();
   });
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts
index e396ab1418..02113c8a25 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts
@@ -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);
     expect(artifactTypeForAttachment(attachment)).toBeNull();
   });
diff --git a/client/src/hooks/Artifacts/useArtifactProps.ts b/client/src/hooks/Artifacts/useArtifactProps.ts
index 3a7b17c24a..f76093a997 100644
--- a/client/src/hooks/Artifacts/useArtifactProps.ts
+++ b/client/src/hooks/Artifacts/useArtifactProps.ts
@@ -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,
diff --git a/client/src/utils/__tests__/artifacts.test.ts b/client/src/utils/__tests__/artifacts.test.ts
index 02e7494c15..f8ad7a6a58 100644
--- a/client/src/utils/__tests__/artifacts.test.ts
+++ b/client/src/utils/__tests__/artifacts.test.ts
@@ -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: 'x' }),
+    ).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: '
1
', + }); + expect(csv).not.toBeNull(); + expect(csv!.type).toBe(TOOL_ARTIFACT_TYPES.SPREADSHEET); + expect(csv!.title).toBe('data.csv'); + expect(csv!.content).toContain(''); + + const xlsx = fileToArtifact({ + ...baseFile, + filename: 'workbook.xlsx', + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + text: 'sheet', + }); + 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: '

hello

', + }); + 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: '
  1. Slide 1
', + }); + 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." diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index 3a17c843b2..6d4eede87d 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -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 = { 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 = { '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 diff --git a/package-lock.json b/package-lock.json index a6963ab7a8..183eff0c19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/api/package.json b/packages/api/package.json index 9ef4178e3b..d8e59db260 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/agents/responses/handlers.ts b/packages/api/src/agents/responses/handlers.ts index 712be852bd..3a1c06171f 100644 --- a/packages/api/src/agents/responses/handlers.ts +++ b/packages/api/src/agents/responses/handlers.ts @@ -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; } diff --git a/packages/api/src/files/code/extract.spec.ts b/packages/api/src/files/code/extract.spec.ts index 4614a0cfef..dc705cd85d 100644 --- a/packages/api/src/files/code/extract.spec.ts +++ b/packages/api/src/files/code/extract.spec.ts @@ -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('docx html'); + const text = await extractCodeArtifactText( + Buffer.from('PK'), + 'report.docx', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'document', + ); + expect(text).toBe('docx html'); + expect(parseDocumentCalls.length).toBe(0); + }); + + it('returns the HTML rendering for a pptx when the producer succeeds', async () => { + mockOfficeHtml.mockResolvedValueOnce('slides'); + 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('
a
'); + const text = await extractCodeArtifactText( + Buffer.from('a,b\n1,2', 'utf-8'), + 'data.csv', + 'text/csv', + 'utf8-text', + ); + expect(text).toContain(''); + }); + + 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'); + }); + }); }); diff --git a/packages/api/src/files/code/extract.ts b/packages/api/src/files/code/extract.ts index d0a1ff58ba..7c7a8c2e6a 100644 --- a/packages/api/src/files/code/extract.ts +++ b/packages/api/src/files/code/extract.ts @@ -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(['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 => { + 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 { - 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}`, diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts new file mode 100644 index 0000000000..ea9a81be57 --- /dev/null +++ b/packages/api/src/files/documents/html.spec.ts @@ -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(/^/); + expect(html).toContain('
'); + expect(html).toContain('This is a sample DOCX file.'); + }); + + test('strips