diff --git a/client/src/utils/__tests__/artifacts.test.ts b/client/src/utils/__tests__/artifacts.test.ts index 607297f70b..d3629a8d0c 100644 --- a/client/src/utils/__tests__/artifacts.test.ts +++ b/client/src/utils/__tests__/artifacts.test.ts @@ -113,6 +113,19 @@ describe('detectArtifactTypeFromFile', () => { * would be skipped despite the backend producing valid HTML. * Regression for Codex P3 review on PR #12934. */ ['text/comma-separated-values', TOOL_ARTIFACT_TYPES.SPREADSHEET], + /* Legacy XLS MIME aliases — backend's `excelMimeTypes` regex + * accepts the full set used by older browsers/servers; the client + * mirrors via the same regex so an extensionless XLS with any of + * these legacy MIMEs gets routed to the spreadsheet bucket and + * the artifact actually shows up on the panel. Regression for + * Codex P1 review on PR #12934. */ + ['application/x-ms-excel', TOOL_ARTIFACT_TYPES.SPREADSHEET], + ['application/x-msexcel', TOOL_ARTIFACT_TYPES.SPREADSHEET], + ['application/msexcel', TOOL_ARTIFACT_TYPES.SPREADSHEET], + ['application/x-excel', TOOL_ARTIFACT_TYPES.SPREADSHEET], + ['application/x-dos_ms_excel', TOOL_ARTIFACT_TYPES.SPREADSHEET], + ['application/xls', TOOL_ARTIFACT_TYPES.SPREADSHEET], + ['application/x-xls', TOOL_ARTIFACT_TYPES.SPREADSHEET], [ 'application/vnd.openxmlformats-officedocument.presentationml.presentation', TOOL_ARTIFACT_TYPES.PRESENTATION, diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index 4b36b1db69..af4ef2cfca 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -1,5 +1,5 @@ import dedent from 'dedent'; -import { shadcnComponents } from 'librechat-data-provider'; +import { excelMimeTypes, shadcnComponents } from 'librechat-data-provider'; import type { SandpackProviderProps, SandpackPredefinedTemplate, @@ -679,8 +679,18 @@ export function detectArtifactTypeFromFile( const byBareName = byExtension ? undefined : EXTENSION_TO_TOOL_ARTIFACT_TYPE[bareNameFromBasename(base)]; - const type = - byExtension ?? byBareName ?? MIME_TO_TOOL_ARTIFACT_TYPE[baseMime(attachment.type)] ?? null; + /* Exact-match MIME lookup first; for the spreadsheet bucket the + * backend's `officeHtmlBucket` accepts the broad `excelMimeTypes` + * regex (covers `application/x-ms-excel`, `application/x-xls`, + * `application/msexcel`, `application/x-dos_ms_excel`, etc.). The + * client must accept the same set or extensionless XLS uploads with + * legacy MIMEs would have backend HTML produced but never get + * routed/registered on the panel. */ + const normalizedMime = baseMime(attachment.type); + const byMime = + MIME_TO_TOOL_ARTIFACT_TYPE[normalizedMime] ?? + (excelMimeTypes.test(normalizedMime) ? TOOL_ARTIFACT_TYPES.SPREADSHEET : undefined); + const type = byExtension ?? byBareName ?? byMime ?? null; if (type == null) { return null; } diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index 6b206e4db8..441c6e1fa3 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -131,6 +131,48 @@ describe('Office HTML producers', () => { expect(_internal.MAX_DOCX_CDN_BINARY_BYTES).toBe(350 * 1024); }); }); + + describe('OFFICE_PREVIEW_DISABLE_CDN escape hatch', () => { + /* Air-gapped / corporate-filtered networks where jsdelivr is + * unreachable need a way to force the mammoth path so DOCX + * previews don't degrade to "Preview unavailable" on every open. + * Codex P2 review on PR #12934. */ + const ORIGINAL = process.env.OFFICE_PREVIEW_DISABLE_CDN; + afterEach(() => { + if (ORIGINAL === undefined) { + delete process.env.OFFICE_PREVIEW_DISABLE_CDN; + } else { + process.env.OFFICE_PREVIEW_DISABLE_CDN = ORIGINAL; + } + }); + + test('default behavior (env unset): small docx → CDN path', async () => { + delete process.env.OFFICE_PREVIEW_DISABLE_CDN; + const html = await wordDocToHtml(readFixture('sample.docx')); + expect(html).toContain('id="lc-doc-data"'); + }); + + it.each([['true'], ['1'], ['yes'], ['TRUE'], ['Yes'], [' true ']])( + 'env=%s forces the mammoth fallback even for small files', + async (value) => { + process.env.OFFICE_PREVIEW_DISABLE_CDN = value; + const html = await wordDocToHtml(readFixture('sample.docx')); + // Mammoth-path signature, NOT CDN path. + expect(html).toContain('
'); + expect(html).not.toContain('id="lc-doc-data"'); + expect(html).not.toContain('cdn.jsdelivr.net'); + }, + ); + + it.each([['false'], ['0'], ['no'], [''], ['anything-else']])( + 'env=%j does not disable the CDN path', + async (value) => { + process.env.OFFICE_PREVIEW_DISABLE_CDN = value; + const html = await wordDocToHtml(readFixture('sample.docx')); + expect(html).toContain('id="lc-doc-data"'); + }, + ); + }); }); describe('excelSheetToHtml', () => { diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts index 6abef40749..d7e7928150 100644 --- a/packages/api/src/files/documents/html.ts +++ b/packages/api/src/files/documents/html.ts @@ -464,6 +464,28 @@ async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise { return wrapAsDocument(`
${sanitized}
`, DOCX_EXTRA_CSS); } +/** + * Whether the CDN-rendered DOCX path is enabled for this process. + * Operators on air-gapped or filtered corporate networks (where + * `cdn.jsdelivr.net` is blocked) should set + * `OFFICE_PREVIEW_DISABLE_CDN=true` so DOCX previews fall back to the + * server-side mammoth renderer instead of degrading to "Preview + * unavailable" in the iframe — Codex P2 review on PR #12934. + * + * Read at call time (rather than module load) so jest tests can flip + * the env in a `beforeEach` without `jest.resetModules()`. The cost is + * a single property access per render. Truthy values: `true`, `1`, + * `yes` (case-insensitive). Anything else (including unset) means + * "use the CDN path when the size dispatcher picks it." + */ +function isOfficePreviewCdnDisabled(): boolean { + const v = process.env.OFFICE_PREVIEW_DISABLE_CDN; + if (v == null) { + return false; + } + return /^(1|true|yes)$/i.test(v.trim()); +} + /** * Convert a `.docx` buffer to a sandboxed HTML document. Two render * paths, chosen by file size: @@ -472,11 +494,12 @@ async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise { * binary as base64 and lets `docx-preview` render it inside the * Sandpack iframe. High visual fidelity — preserves cell shading, * run-level colors/fonts, headers/footers, columns, and images. - * 2. **Mammoth (fallback for larger files)**: server-side semantic - * HTML conversion. Lower fidelity (flat paragraphs, no shading) - * but produces compact output that fits the - * `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text` even - * for large documents. + * 2. **Mammoth (fallback for larger files OR when the CDN path is + * explicitly disabled via `OFFICE_PREVIEW_DISABLE_CDN=true`)**: + * server-side semantic HTML conversion. Lower fidelity (flat + * paragraphs, no shading) but produces compact output that fits + * the `MAX_TEXT_CACHE_BYTES` (512 KB) cap on `attachment.text` + * even for large documents, and works without external network. * * Both paths pre-flight through `assertSafeZipSize` so a zip-bomb DOCX * is rejected before either renderer touches it — mammoth's internal @@ -486,6 +509,9 @@ async function wordDocToHtmlViaMammoth(buffer: Buffer): Promise { */ export async function wordDocToHtml(buffer: Buffer): Promise { await assertSafeZipSize(buffer, { name: 'docx' }); + if (isOfficePreviewCdnDisabled()) { + return wordDocToHtmlViaMammoth(buffer); + } if (buffer.length <= MAX_DOCX_CDN_BINARY_BYTES) { return wordDocToHtmlViaCdn(buffer); }