diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 594e1aba38b..9b29b4daa62 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -22,6 +22,7 @@ const { codeServerHttpAgent, codeServerHttpsAgent, extractCodeArtifactText, + officePreviewFailure, extractCodeArtifactRawText, extractCodeArtifactInspectionText, prepareCodeOutputBufferForInspection, @@ -433,12 +434,13 @@ const finalizePreview = async ({ category, file_id, previewRevision, + officePreview, }) => { let text = null; let previewError; try { text = await withTimeout( - extractCodeArtifactText(buffer, leafName, mimeType, category), + extractCodeArtifactText(buffer, leafName, mimeType, category, officePreview), PREVIEW_FINALIZE_TIMEOUT_MS, `Preview extraction exceeded ${PREVIEW_FINALIZE_TIMEOUT_MS}ms`, ); @@ -458,7 +460,7 @@ const finalizePreview = async ({ const failed = text == null; const status = failed ? 'failed' : 'ready'; if (failed && !previewError) { - previewError = 'parser-error'; + previewError = officePreviewFailure(buffer.length, leafName, mimeType, officePreview); } try { /* Conditional update: commit only if `previewRevision` still diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index cce7a4ba29b..bfc0e2eb392 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -48,6 +48,7 @@ const mockExtractCodeArtifactInspectionText = jest.fn(async () => ({ complete: false, })); const mockExtractCodeArtifactText = jest.fn(async () => null); +const mockOfficePreviewFailure = jest.fn(() => 'parser-error'); const mockExecuteWorkspaceTool = jest.fn(); const mockGetExtractedTextFormat = jest.fn((_name, _mime, text) => (text == null ? null : 'text')); /* `hasOfficeHtmlPath` gates the persist-then-render split: when true, processCodeOutput @@ -160,6 +161,7 @@ jest.mock('@librechat/api', () => { extractCodeArtifactRawText: (...args) => mockExtractCodeArtifactRawText(...args), extractCodeArtifactInspectionText: (...args) => mockExtractCodeArtifactInspectionText(...args), extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args), + officePreviewFailure: (...args) => mockOfficePreviewFailure(...args), getBoundedCodeOutputByteLimit: (configured) => typeof configured === 'number' && Number.isFinite(configured) && configured > 0 ? Math.min(configured, 64 * 1024 * 1024) @@ -1722,6 +1724,7 @@ describe('Code Process', () => { * The `hasOfficeHtmlPath` mock is the gate. Other tests keep it * at `false` (legacy single-phase path); we flip it on here. */ const { updateFile } = require('~/models'); + const pptxMime = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; beforeEach(() => { mockHasOfficeHtmlPath.mockReturnValue(true); @@ -1810,6 +1813,71 @@ describe('Code Process', () => { ); }); + it('finalize() stores the failure label the helper returns for the buffer size, name, type and setting', async () => { + mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); + determineFileType.mockResolvedValue({ + mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }); + mockExtractCodeArtifactText.mockResolvedValueOnce(null); + mockOfficePreviewFailure.mockReturnValueOnce('too-large'); + + const { finalize } = await processCodeOutput({ ...baseParams, name: 'deck.pptx' }); + await finalize(); + + expect(mockOfficePreviewFailure).toHaveBeenCalledWith( + 100, + 'deck.pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + expect.objectContaining({ enabled: true }), + ); + expect(updateFile).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', previewError: 'too-large' }), + { previewRevision: 'mock-uuid-1234' }, + ); + }); + + it('finalize() passes the resolved officePreview setting to the extractor', async () => { + mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); + determineFileType.mockResolvedValue({ + mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }); + mockExtractCodeArtifactText.mockResolvedValueOnce(''); + + const { finalize } = await processCodeOutput({ ...baseParams, name: 'deck.pptx' }); + await finalize(); + + expect(mockExtractCodeArtifactText).toHaveBeenCalledWith( + expect.any(Buffer), + 'deck.pptx', + expect.any(String), + expect.any(String), + expect.objectContaining({ enabled: true, fileSizeLimit: 25 * 1024 * 1024 }), + ); + }); + + it('finalize() hands a yaml fileSizeLimit in megabytes to the extractor and the failure label in bytes', async () => { + mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); + determineFileType.mockResolvedValue({ mime: pptxMime }); + mockExtractCodeArtifactText.mockResolvedValueOnce(null); + const req = { + ...mockReq, + config: { ...mockReq.config, fileConfig: { officePreview: { fileSizeLimit: 5 } } }, + }; + const merged = { enabled: true, fileSizeLimit: 5 * 1024 * 1024 }; + + const { finalize } = await processCodeOutput({ ...baseParams, req, name: 'deck.pptx' }); + await finalize(); + + expect(mockExtractCodeArtifactText).toHaveBeenCalledWith( + expect.any(Buffer), + 'deck.pptx', + pptxMime, + expect.any(String), + merged, + ); + expect(mockOfficePreviewFailure).toHaveBeenCalledWith(100, 'deck.pptx', pptxMime, merged); + }); + it('finalize() transitions to failed with previewError:timeout when the outer timeout rejects', async () => { /* The passthrough `withTimeout` mock at the file scope returns * its inner promise unchanged, so the only way the catch branch diff --git a/client/src/components/Artifacts/ArtifactTabs.test.tsx b/client/src/components/Artifacts/ArtifactTabs.test.tsx index c6111419c24..f56e9a1d344 100644 --- a/client/src/components/Artifacts/ArtifactTabs.test.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.test.tsx @@ -65,6 +65,7 @@ jest.mock('~/data-provider', () => ({ useGetStartupConfig: (options: unknown) => mockUseGetStartupConfig(options), useGetSharedStartupConfig: (shareId: unknown, options: unknown) => mockUseGetSharedStartupConfig(shareId, options), + useFilePreviewBlob: () => ({ refetch: jest.fn() }), })); jest.mock('~/hooks/Artifacts/useArtifactProps', () => ({ diff --git a/client/src/components/Artifacts/SandboxArtifactTabs.test.tsx b/client/src/components/Artifacts/SandboxArtifactTabs.test.tsx index f3cbe14bce4..2e72dd9d862 100644 --- a/client/src/components/Artifacts/SandboxArtifactTabs.test.tsx +++ b/client/src/components/Artifacts/SandboxArtifactTabs.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import * as Tabs from '@radix-ui/react-tabs'; -import { render } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { OFFICE_DOC_DATA_SLOT, OFFICE_FILE_SHELL_MARKER } from 'librechat-data-provider'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; import type { Artifact } from '~/common'; import SandboxArtifactTabs from './SandboxArtifactTabs'; @@ -12,11 +13,15 @@ interface PreviewProps { } const mockPreview = jest.fn((_props: PreviewProps) => null); +const mockEditor = jest.fn((_props: { artifact: Artifact }) => null); +const mockRefetch = jest.fn(); +let mockUseRealShell = false; let mockCurrentCode: string | undefined; +let mockShell: { content: string | undefined; isLoading: boolean } | undefined; let mockCodeArtifactId: string | undefined; jest.mock('./ArtifactCodeEditor', () => ({ - ArtifactCodeEditor: () => null, + ArtifactCodeEditor: (props: { artifact: Artifact }) => mockEditor(props), })); jest.mock('./ArtifactPreview', () => ({ @@ -28,6 +33,19 @@ jest.mock('~/Providers/EditorContext', () => ({ mockCodeArtifactId === artifactId ? mockCurrentCode : undefined, })); +jest.mock('~/hooks/Artifacts/useOfficeFileShell', () => ({ + __esModule: true, + default: (artifact: Artifact) => + mockShell ?? + (mockUseRealShell + ? jest.requireActual('~/hooks/Artifacts/useOfficeFileShell').default(artifact) + : { content: artifact.content, isLoading: false }), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + jest.mock('~/Providers', () => ({ useShareContext: () => ({ shareId: undefined }), })); @@ -35,6 +53,7 @@ jest.mock('~/Providers', () => ({ jest.mock('~/data-provider', () => ({ useGetStartupConfig: () => ({ data: {} }), useGetSharedStartupConfig: () => ({ data: {} }), + useFilePreviewBlob: () => ({ refetch: mockRefetch }), })); const previewRef = { @@ -134,3 +153,79 @@ describe('SandboxArtifactTabs SVG preview', () => { expect(call?.currentCode).toBe('

edited

'); }); }); + +describe('SandboxArtifactTabs office file shell', () => { + const shellArtifact: Artifact = { + id: 'deck-1', + type: 'text/html', + title: 'Deck', + content: '

shell

', + lastUpdateTime: 1, + download: { file_id: 'file-1', user: 'user-1' }, + }; + + beforeEach(() => { + mockCurrentCode = undefined; + mockPreview.mockClear(); + }); + + afterEach(() => { + mockShell = undefined; + }); + + it('shows the preparing state instead of the preview while the shell is filled', () => { + mockShell = { content: '

shell

', isLoading: true }; + renderTabs(shellArtifact); + expect(screen.getByText('com_ui_preview_preparing')).toBeInTheDocument(); + expect(mockPreview).not.toHaveBeenCalled(); + }); + + it('previews the filled document once loading ends', () => { + mockShell = { content: '

filled

', isLoading: false }; + renderTabs(shellArtifact); + expect(screen.queryByText('com_ui_preview_preparing')).not.toBeInTheDocument(); + expect(lastFiles()['index.html']).toBe('

filled

'); + }); +}); + +describe('SandboxArtifactTabs with a stored office shell', () => { + const shell = `${OFFICE_FILE_SHELL_MARKER}${OFFICE_DOC_DATA_SLOT}`; + const deck: Artifact = { + id: 'deck-2', + type: 'text/html', + title: 'Deck', + content: shell, + lastUpdateTime: 1, + download: { file_id: 'file-2', user: 'user-1' }, + }; + + beforeEach(() => { + mockUseRealShell = true; + mockCurrentCode = undefined; + mockPreview.mockClear(); + mockEditor.mockClear(); + mockRefetch.mockReset(); + }); + + afterEach(() => { + mockUseRealShell = false; + }); + + it('fills only the preview and leaves the editor on the stored shell', async () => { + mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) }); + const { rerender } = renderTabs(deck); + expect(screen.getByText('com_ui_preview_preparing')).toBeInTheDocument(); + expect(mockPreview).not.toHaveBeenCalled(); + + await waitFor(() => expect(mockPreview).toHaveBeenCalled()); + expect(lastFiles()['index.html']).toContain('QUJD'); + expect(lastFiles()['index.html']).not.toContain(OFFICE_DOC_DATA_SLOT); + + rerender( + + + , + ); + expect(mockEditor.mock.calls.at(-1)?.[0].artifact.content).toBe(shell); + }); +}); diff --git a/client/src/components/Artifacts/SandboxArtifactTabs.tsx b/client/src/components/Artifacts/SandboxArtifactTabs.tsx index 27fa0dc1297..a9c1a97428a 100644 --- a/client/src/components/Artifacts/SandboxArtifactTabs.tsx +++ b/client/src/components/Artifacts/SandboxArtifactTabs.tsx @@ -1,17 +1,20 @@ import { useMemo, useRef } from 'react'; +import { Spinner } from '@librechat/client'; import * as Tabs from '@radix-ui/react-tabs'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; import type { editor } from 'monaco-editor'; import type { Artifact } from '~/common'; import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider'; +import useOfficeFileShell from '~/hooks/Artifacts/useOfficeFileShell'; import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; import { useArtifactCode } from '~/Providers/EditorContext'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { ArtifactPreview } from './ArtifactPreview'; import { useShareContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; export default function SandboxArtifactTabs({ - artifact, + artifact: storedArtifact, previewRef, isSharedConvo, }: { @@ -19,6 +22,13 @@ export default function SandboxArtifactTabs({ previewRef: React.MutableRefObject; isSharedConvo?: boolean; }) { + const localize = useLocalize(); + const { content, isLoading } = useOfficeFileShell(storedArtifact); + const previewArtifact = useMemo( + () => ({ ...storedArtifact, content }), + [storedArtifact, content], + ); + const artifact = storedArtifact; const { shareId } = useShareContext(); const shouldUseSharedConfig = isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0; @@ -34,7 +44,9 @@ export default function SandboxArtifactTabs({ * another artifact displaced is just as much this artifact's text. */ const editedCode = useArtifactCode(artifact.id); - const { files, fileKey, template, sharedProps, deriveFiles } = useArtifactProps({ artifact }); + const { files, fileKey, template, sharedProps, deriveFiles } = useArtifactProps({ + artifact: previewArtifact, + }); /* An artifact whose preview entry is derived from its source needs the whole * set rebuilt from the editor text; `ArtifactPreview` can only swap the file @@ -44,6 +56,15 @@ export default function SandboxArtifactTabs({ [deriveFiles, editedCode, files], ); + if (isLoading) { + return ( +
+ + {localize('com_ui_preview_preparing')} +
+ ); + } + return (
attachments.filter((attachment) => Boolean(attachment.filepath)), - [attachments], - ); + const visibleAttachments = useMemo(() => { + // Same file identity can arrive twice (e.g. two tool calls touching + // one file in a message) — keep the last occurrence so the folded + // row lists it once instead of listing the same name twice. + const byIdentity = new Map(); + let unidentifiedCount = 0; + for (const attachment of attachments) { + if (!attachment.filepath) { + continue; + } + const key = fileIdentity(attachment) ?? `__unidentified-${unidentifiedCount++}`; + byIdentity.delete(key); + byIdentity.set(key, attachment); + } + return Array.from(byIdentity.values()); + }, [attachments]); const count = visibleAttachments.length; const summary = useMemo(() => { const names = visibleAttachments.map((attachment) => displayFilename(attachment.filename)); diff --git a/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx b/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx index c3f9fa5dd91..06a457ad747 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ToolArtifactCard.tsx @@ -1,8 +1,9 @@ -import { memo, useEffect, useId, useLayoutEffect, useRef } from 'react'; +import { memo, useEffect, useLayoutEffect, useRef } from 'react'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import { artifactRowKind, isCodeOnlyArtifact } from '~/utils/artifacts'; +import useToolArtifactClaim, { isStrictlyNewer } from './claim'; import { displayFilename } from './attachmentTypes'; import { useAttachmentLink } from './LogLink'; import ArtifactRow from './ArtifactRow'; @@ -17,27 +18,30 @@ interface ToolArtifactCardProps { * * Three effects, separately scoped: * - * 1. **Dedup claim** (`useLayoutEffect`, runs synchronously before - * paint). The same file can appear in multiple tool calls within a - * single message (e.g. the agent reads back what it just wrote) or - * across messages. Each card claims `toolArtifactClaim(artifact.id)` - * with its unique component-instance key on mount; the latest card - * to mount wins, so older duplicates re-render to `null`. The atom - * is family-keyed by artifact id, so claims for unrelated artifacts - * don't trigger re-renders here. Cleanup releases the claim if it's - * still ours so a subsequent re-mount can take it. + * 1. **Dedup claim** (`useToolArtifactClaim`, via `useLayoutEffect` + * inside it, runs synchronously before paint). The same file can + * appear in multiple tool calls within a single message (e.g. the + * agent reads back what it just wrote) or across messages. The + * shared hook scopes the claim to the mounting message (falling + * back to the bare artifact id where no message is known), so the + * same file shows one card per message instead of one card total. + * Within one message the latest card to mount wins, so older + * duplicates re-render to `null`. * * 2. **Self-heal registration** subscribes to the per-id selector * `artifactByIdSelector(artifact.id)` and writes only when the - * entry is missing or the cached content/type/title drifted. The + * entry is missing or the cached content/type/title drifted AND + * this card's version is at least as new (`lastUpdateTime`). The * panel's `useArtifacts` hook resets `artifactsState` on close, so * this re-fires deterministically once the slice transitions back - * to `undefined` — without the no-deps render-loop pattern. The - * write is also gated on `isMyClaim`, making the registration - * single-writer per id: when two cards exist for the same file - * across turns, only the latest (claim-holder) updates state. - * Without that guard, both cards would observe each other's write - * and trade overwrites in a loop. + * to `undefined` — without the no-deps render-loop pattern. A + * strictly newer version always wins regardless of mount order; + * when two versions share a `lastUpdateTime` (no timestamp signal), + * the tie breaks on a SEPARATE global "latest mount" claim kept + * only for this purpose — today's semantics, scoped to the id alone + * so it survives across messages. That single-writer tie-break is + * what stops two cards for the same file from observing each + * other's write and trading overwrites in a loop. * * 3. **Focus + open on ownership change** (deps: artifact.id, artifact.type, ownerMessageId) — * gated on this message's `isSubmitting` captured on mount AND @@ -58,8 +62,11 @@ interface ToolArtifactCardProps { * of context. */ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) => { - const claimKey = useId(); - const { useMessage, useArtifactPanel, useToolArtifactClaim } = useMessagePartsHost(); + const { + useMessage, + useArtifactPanel, + useToolArtifactClaim: useGlobalClaim, + } = useMessagePartsHost(); const { isSubmitting, messageId } = useMessage(); const ownerMessageId = messageId || attachment.messageId || ''; const file = attachment as TFile & TAttachmentMetadata; @@ -72,9 +79,15 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) close, consumeJustResolved, } = useArtifactPanel(artifact.id); - const [claim, setClaim] = useToolArtifactClaim(artifact.id); + const { isMyClaim: canRender, claimKey } = useToolArtifactClaim(artifact.id); + // Global (message-independent) claim — used only as the registration + // tie-break below, never for the render-null gate. Reuses the same + // `claimKey` as the display claim above so the two never fight over the + // SAME atom entry in the no-message fallback, where both keys collapse + // to `toolArtifactClaim(artifact.id)`. + const [globalClaim, setGlobalClaim] = useGlobalClaim(artifact.id); + const isMyGlobalClaim = globalClaim === claimKey; const isSelected = artifact.id === currentArtifactId; - const isMyClaim = claim === claimKey; /* `consumeJustResolved` reads and resets only this response's flag, without * subscribing to other file or message flags. The deferred-preview hook flips * it to `true` on the pending→ready edge; we consume it once, so repeat mounts @@ -86,35 +99,41 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) } useLayoutEffect(() => { - // Always (re)claim on mount — a later card for the same id displaces - // an earlier one, so the chip migrates to the most recent mention. - setClaim(claimKey); + // Always (re)claim the global slot on mount — keeps today's + // latest-mount semantics as the tie-break input for the registration + // effect below, independent of which message a card renders under. + setGlobalClaim(claimKey); return () => { // Only release when the claim is still ours; if a sibling already // took over we don't want to clobber its claim. - setClaim((prev) => (prev === claimKey ? null : prev)); + setGlobalClaim((prev) => (prev === claimKey ? null : prev)); }; - }, [claimKey, setClaim]); + }, [claimKey, setGlobalClaim]); useEffect(() => { - // Only the claim-winner writes. Two cards with the same `artifact.id` - // but divergent content (same file_id reused across turns) would - // otherwise see each other's write through `existingEntry`, detect - // drift, and trade overwrites in a loop. Gating on `isMyClaim` - // makes registration single-writer per id. - if (!isMyClaim) { - return; - } - if ( + const contentDrifted = !( existingEntry != null && existingEntry.content === artifact.content && existingEntry.type === artifact.type && existingEntry.title === artifact.title - ) { + ); + if (!contentDrifted) { + return; + } + // A strictly newer version always wins, regardless of mount order. + // When two versions share a `lastUpdateTime` (no timestamp signal to + // order them), fall back to the global "latest mount" claim so a + // ping-ponging pair still converges on ONE writer instead of trading + // overwrites in a loop. + const isNewerOrTied = + existingEntry == null || + isStrictlyNewer(artifact, existingEntry) || + (artifact.lastUpdateTime === existingEntry.lastUpdateTime && isMyGlobalClaim); + if (!isNewerOrTied) { return; } register(artifact); - }, [artifact, existingEntry, isMyClaim, register]); + }, [artifact, existingEntry, isMyGlobalClaim, register]); useEffect(() => { if (isCodeOnlyArtifact(artifact.type)) { @@ -172,9 +191,9 @@ const ToolArtifactCard = memo(({ attachment, artifact }: ToolArtifactCardProps) open(artifact.id); }; - // Another card with the same artifact id has the active claim — render - // nothing here, that row is the canonical trigger for this file. - if (claim != null && !isMyClaim) { + // Another card holds the message-scoped display claim for this file — + // render nothing here, that row is the canonical trigger for this file. + if (!canRender) { return null; } diff --git a/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx b/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx index 79d64c24358..829938faf06 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ToolMermaidArtifact.tsx @@ -1,8 +1,9 @@ -import { memo, useId, useLayoutEffect, useMemo, useState } from 'react'; +import { memo, useLayoutEffect, useMemo, useState } from 'react'; +import { useAtom } from 'jotai'; import { Download } from 'lucide-react'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; +import useToolArtifactClaim, { isStrictlyNewer, newestToolArtifactFamily } from './claim'; import { fileToArtifact, TOOL_ARTIFACT_TYPES, toolArtifactKey } from '~/utils/artifacts'; -import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import Mermaid from '~/components/Messages/Content/Mermaid/Mermaid'; import { displayFilename } from './attachmentTypes'; import { useAttachmentLink } from './LogLink'; @@ -19,28 +20,29 @@ interface ToolMermaidArtifactProps { * user opens it in the Artifact panel. The compact card keeps the file * available in chat without rendering the same diagram twice. * - * Shares the `toolArtifactClaim` dedup atom with `ToolArtifactCard` so - * the same `.mmd` file can't double-render across tool calls / messages. + * Shares `useToolArtifactClaim` with `ToolArtifactCard` so the same + * `.mmd` file dedups identically: one card per message (falling back to + * one card total where no message is known). + * + * A diagram remade in a later turn (same file identity, newer + * `lastUpdateTime`) must still open the newest version from an EARLIER + * message's card. Every mount offers this instance's own artifact to + * `newestToolArtifactFamily`, keyed by file identity; the artifact + * handed to `Mermaid` (which is what gets registered once opened) is + * upgraded to that newest entry when it's ahead of this instance's own + * version, while the inline diagram below keeps rendering `text` — this + * message's own source — unchanged. */ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps) => { const localize = useLocalize(); const file = attachment as TFile & TAttachmentMetadata; - const claimKey = useId(); - const { useToolArtifactClaim } = useMessagePartsHost(); - const [claim, setClaim] = useToolArtifactClaim(toolArtifactKey(file)); - const isMyClaim = claim === claimKey; + const fileKey = toolArtifactKey(file); + const { isMyClaim } = useToolArtifactClaim(fileKey); /* Once the diagram collapses into its trigger row, that row carries the * filename and the download itself, so this header would repeat both * beside it. */ const [isRowMode, setIsRowMode] = useState(false); - useLayoutEffect(() => { - setClaim(claimKey); - return () => { - setClaim((prev) => (prev === claimKey ? null : prev)); - }; - }, [claimKey, setClaim]); - const { handleDownload } = useAttachmentLink({ href: attachment.filepath ?? '', filename: attachment.filename ?? '', @@ -53,12 +55,32 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps fileToArtifact({ ...attachment, text }, { preClassifiedType: TOOL_ARTIFACT_TYPES.MERMAID }), [attachment, text], ); + const [newestArtifact, setNewestArtifact] = useAtom(newestToolArtifactFamily(fileKey)); + + useLayoutEffect(() => { + if (artifact == null) { + return; + } + setNewestArtifact((current) => + current == null || isStrictlyNewer(artifact, current) ? artifact : current, + ); + }, [artifact, setNewestArtifact]); - if (claim != null && !isMyClaim) { + if (!isMyClaim) { return null; } const visibleFilename = displayFilename(attachment.filename); + // Adopt the shared family's entry unless THIS instance's own artifact is + // strictly newer than it (can't happen once its own mount effect has + // offered it, but guards the pre-effect render). A tied `lastUpdateTime` + // is "not strictly newer" in either direction, so every mounted instance + // converges on whichever version the family already settled on instead + // of each preferring its own on a tie. + const registeredArtifact = + artifact != null && newestArtifact != null && !isStrictlyNewer(artifact, newestArtifact) + ? newestArtifact + : artifact; return (
@@ -95,7 +117,7 @@ const ToolMermaidArtifact = memo(({ attachment, text }: ToolMermaidArtifactProps {file.file_id ? ( ) : ( ({ + useLocalize: + () => + (key: string): string => + key, + useAttachmentPreviewSync: () => ({ status: 'ready', previewError: undefined, isPolling: false }), + useExpandCollapse: (isExpanded: boolean) => ({ + style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' }, + ref: { current: null }, + }), +})); + +jest.mock('../LogLink', () => ({ + useAttachmentLink: () => ({ handleDownload: jest.fn() }), +})); + +jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({ + __esModule: true, + default: ({ file, displayName }: { file: { filename?: string }; displayName?: string }) => ( +
{displayName ?? file.filename ?? ''}
+ ), +})); + +jest.mock('~/components/Chat/Input/Files/FilePreview', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/components/Chat/Messages/Content/Image', () => ({ + __esModule: true, + default: ({ altText }: { altText?: string }) => {altText, +})); + +jest.mock('~/components/Messages/Content/Mermaid/Mermaid', () => ({ + __esModule: true, + default: ({ children }: { children: string }) => ( +
{children}
+ ), +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getFileType: () => ({ paths: [], color: '', title: 'Artifact' }), + logger: { log: jest.fn(), warn: jest.fn(), error: jest.fn() }, + isArtifactRoute: () => false, +})); + +const baseAttachment = (overrides: Partial = {}): TAttachment => + ({ + file_id: 'file-1', + filename: 'unset', + filepath: '/files/file-1', + type: 'application/octet-stream', + ...overrides, + }) as TAttachment; + +const renderWith = (ui: React.ReactElement) => render({ui}); + +describe('FileAttachmentGroup identity dedup', () => { + it('collapses two attachments sharing a file identity into a single, non-folded chip', () => { + const first = baseAttachment({ file_id: 'dup', filename: 'report.pptx', bytes: 100 }); + const second = baseAttachment({ file_id: 'dup', filename: 'report.pptx', bytes: 100 }); + const { container } = renderWith(); + + // A folded row only appears once the unique count exceeds one — dedup + // must run before the count that decides whether to render it. + expect(screen.queryByRole('button', { name: 'com_ui_show_n_files' })).not.toBeInTheDocument(); + const chips = container.querySelectorAll('[data-testid="file-container"]'); + expect(chips.length).toBe(1); + expect(chips[0].textContent).toBe('report.pptx'); + }); + + it('folds distinct identities only, using the last occurrence for a repeated identity', () => { + const older = baseAttachment({ file_id: 'dup', filename: 'old-name.zip', bytes: 100 }); + const distinct = baseAttachment({ file_id: 'other', filename: 'notes.zip', bytes: 50 }); + const newer = baseAttachment({ file_id: 'dup', filename: 'new-name.zip', bytes: 100 }); + + const { container } = renderWith(); + + const toggle = screen.getByRole('button', { name: 'com_ui_show_n_files' }); + fireEvent.click(toggle); + const chips = Array.from(container.querySelectorAll('[data-testid="file-container"]')); + const names = chips.map((chip) => chip.textContent); + expect(chips.length).toBe(2); + expect(names).toContain('new-name.zip'); + expect(names).not.toContain('old-name.zip'); + }); +}); + +describe('FileAttachmentGroup identity dedup for id-less files', () => { + it('keeps two id-less attachments with the same filename but different filepaths as two chips', () => { + const first = baseAttachment({ + file_id: undefined, + filename: 'output.csv', + filepath: '/uploads/session-a/output.csv', + }); + const second = baseAttachment({ + file_id: undefined, + filename: 'output.csv', + filepath: '/uploads/session-b/output.csv', + }); + + const { container } = renderWith(); + + const toggle = screen.getByRole('button', { name: 'com_ui_show_n_files' }); + fireEvent.click(toggle); + const chips = container.querySelectorAll('[data-testid="file-container"]'); + expect(chips.length).toBe(2); + }); + + it('collapses two id-less attachments sharing the same filepath into one chip', () => { + const first = baseAttachment({ + file_id: undefined, + filename: 'output.csv', + filepath: '/uploads/session-a/output.csv', + }); + const second = baseAttachment({ + file_id: undefined, + filename: 'output.csv', + filepath: '/uploads/session-a/output.csv', + }); + + const { container } = renderWith(); + + expect(screen.queryByRole('button', { name: 'com_ui_show_n_files' })).not.toBeInTheDocument(); + const chips = container.querySelectorAll('[data-testid="file-container"]'); + expect(chips.length).toBe(1); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ToolArtifactCard.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ToolArtifactCard.test.tsx new file mode 100644 index 00000000000..78196379c53 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ToolArtifactCard.test.tsx @@ -0,0 +1,650 @@ +import React from 'react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; +import { ContentTypes, Tools } from 'librechat-data-provider'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { TAttachment, TMessage, TMessageContentParts } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import SearchContent from '~/components/Chat/Messages/Content/SearchContent'; +import { AttachmentGroup } from '../Attachment'; +import { MessageContext } from '~/Providers'; +import store from '~/store'; + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string): string => + key, + useAttachmentPreviewSync: () => ({ status: 'ready', previewError: undefined, isPolling: false }), + useExpandCollapse: (isExpanded: boolean) => ({ + style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' }, + ref: { current: null }, + }), +})); + +jest.mock('../LogLink', () => ({ + useAttachmentLink: () => ({ handleDownload: jest.fn() }), +})); + +jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({ + __esModule: true, + default: ({ file, displayName }: { file: { filename?: string }; displayName?: string }) => ( +
{displayName ?? file.filename ?? ''}
+ ), +})); + +jest.mock('~/components/Chat/Input/Files/FilePreview', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/components/Chat/Messages/Content/Image', () => ({ + __esModule: true, + default: ({ altText }: { altText?: string }) => {altText, +})); + +jest.mock('~/components/Messages/Content/Mermaid/Mermaid', () => ({ + __esModule: true, + default: ({ + children, + artifact, + }: { + children: string; + artifact?: { id: string; title?: string; type?: string; content?: string }; + }) => ( +
+ {children} +
+ ), +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getFileType: () => ({ paths: [], color: '', title: 'Artifact' }), + logger: { log: jest.fn(), warn: jest.fn(), error: jest.fn() }, + isArtifactRoute: () => false, + // `SearchContent` (rendered by the search-result tests below) maps attachments to + // their owning tool call with the real implementation. + mapAttachments: jest.requireActual('~/utils/map').mapAttachments, +})); + +/** + * `SearchContent` routes an `execute_code` tool call through the real + * `Part` -> `Parts` barrel -> `ExecuteCode`, whose unconditional + * `PtcToolTrace` child needs MCP query hooks (and thus a `QueryClient`) + * this suite doesn't set up. Only that one card is replaced with the + * same `AttachmentGroup` it renders for real — the file-identity + * routing under test (`SearchContent` -> `MessageContext` -> + * `AttachmentGroup` -> `ToolArtifactCard`) stays real. + */ +jest.mock('..', () => { + const actual = jest.requireActual('..'); + return { + __esModule: true, + ...actual, + ExecuteCode: ({ attachments }: { attachments?: TAttachment[] }) => ( + + ), + }; +}); + +const baseAttachment = (overrides: Partial = {}): TAttachment => + ({ + file_id: 'file-1', + filename: 'unset', + filepath: '/files/file-1', + type: 'application/octet-stream', + ...overrides, + }) as TAttachment; + +/** Minimal message context; `isExpanded` is required by the type but unused here. */ +const messageScope = (messageId: string) => ({ messageId, isExpanded: false }); + +const ArtifactContentProbe = ({ + artifactId, + onSnapshot, +}: { + artifactId: string; + onSnapshot: (content: string | null) => void; +}) => { + const artifacts = useRecoilValue(store.artifactsState); + React.useEffect(() => { + onSnapshot(artifacts?.[artifactId]?.content ?? null); + }); + return null; +}; + +describe('ToolArtifactCard message-scoped dedup and newest-version selection', () => { + it('shows a card on every message that holds the same file identity', () => { + const html = () => + baseAttachment({ file_id: 'shared-file', filename: 'index.html', text: '

hi

' }); + const { container } = render( + + + + + + + + , + ); + expect(container.querySelectorAll('[data-artifact-trigger]')).toHaveLength(2); + expect(screen.getAllByText('index.html')).toHaveLength(2); + }); + + it('collapses two cards for the same file within one message to one card', () => { + const dup = baseAttachment({ + file_id: 'dup-in-message', + filename: 'index.html', + text: '

v1

', + }); + const { container } = render( + + + + + + , + ); + expect(container.querySelectorAll('[data-artifact-trigger]')).toHaveLength(1); + }); + + it('keeps the panel on the newest version even when the older card mounts after the newer one', () => { + const newer = baseAttachment({ + file_id: 'versioned', + filename: 'report.html', + text: '

v2 (newer)

', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + const older = baseAttachment({ + file_id: 'versioned', + filename: 'report.html', + text: '

v1 (older)

', + updatedAt: '2024-01-01T00:00:00.000Z', + }); + let content: string | null = null; + render( + + { + content = snapshot; + }} + /> + + + + + + + , + ); + expect(content).toBe('

v2 (newer)

'); + }); + + it('keeps the newer content after the newer card unmounts and the older card mounts fresh', () => { + const newer = baseAttachment({ + file_id: 'remount', + filename: 'notes.html', + text: '

v2 (newer)

', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + const older = baseAttachment({ + file_id: 'remount', + filename: 'notes.html', + text: '

v1 (older)

', + updatedAt: '2024-01-01T00:00:00.000Z', + }); + let content: string | null = null; + const onSnapshot = (snapshot: string | null) => { + content = snapshot; + }; + const { rerender } = render( + + + + + + , + ); + expect(content).toBe('

v2 (newer)

'); + + rerender( + + + + + + , + ); + expect(content).toBe('

v2 (newer)

'); + }); +}); + +describe('ToolArtifactCard file identity for id-less attachments', () => { + it('renders two card triggers for two id-less files sharing a filename but differing by filepath, each opening its own content', () => { + const first = baseAttachment({ + file_id: undefined, + filename: 'index.html', + filepath: '/uploads/session-a/index.html', + text: '

A

', + }); + const second = baseAttachment({ + file_id: undefined, + filename: 'index.html', + filepath: '/uploads/session-b/index.html', + text: '

B

', + }); + + let snapshot: Record = {}; + const ArtifactsSnapshotProbe = () => { + const artifacts = useRecoilValue(store.artifactsState); + React.useEffect(() => { + snapshot = artifacts ?? {}; + }); + return null; + }; + let currentId: string | null = null; + const CurrentArtifactProbe = () => { + const id = useRecoilValue(store.currentArtifactId); + React.useEffect(() => { + currentId = id; + }); + return null; + }; + + const { container } = render( + + + + + + + , + ); + + const triggers = container.querySelectorAll('[data-artifact-trigger]'); + expect(triggers).toHaveLength(2); + + const firstId = 'tool-artifact-/uploads/session-a/index.html'; + const secondId = 'tool-artifact-/uploads/session-b/index.html'; + const triggerIds = Array.from(triggers).map((el) => el.getAttribute('data-artifact-trigger')); + expect([...triggerIds].sort()).toEqual([firstId, secondId].sort()); + + // Both files' own content registers, keyed by their own identity — + // a colliding key would have one file's mount overwrite the other's. + expect(snapshot[firstId]?.content).toBe('

A

'); + expect(snapshot[secondId]?.content).toBe('

B

'); + + const firstTrigger = container.querySelector(`[data-artifact-trigger="${firstId}"]`); + const secondTrigger = container.querySelector(`[data-artifact-trigger="${secondId}"]`); + expect(firstTrigger).not.toBeNull(); + expect(secondTrigger).not.toBeNull(); + + fireEvent.click(firstTrigger as HTMLElement); + expect(currentId).toBe(firstId); + + fireEvent.click(secondTrigger as HTMLElement); + expect(currentId).toBe(secondId); + }); +}); + +describe('ToolMermaidArtifact message-scoped dedup', () => { + it('shows a diagram card on every message that holds the same file identity', () => { + const mmd = () => + baseAttachment({ file_id: 'diagram', filename: 'flow.mmd', text: 'graph TD\nA-->B' }); + render( + + + + + + + + , + ); + expect(screen.getAllByTestId('mermaid-render')).toHaveLength(2); + }); + + it('collapses two diagram cards for the same file within one message to one', () => { + const mmd = baseAttachment({ + file_id: 'diagram-dup', + filename: 'flow.mmd', + text: 'graph TD\nA-->B', + }); + render( + + + + + + , + ); + expect(screen.getAllByTestId('mermaid-render')).toHaveLength(1); + }); +}); + +describe('ToolMermaidArtifact newest-version selection', () => { + const mermaidCards = () => screen.getAllByTestId('mermaid-render'); + + const ArtifactKeysProbe = ({ onSnapshot }: { onSnapshot: (keys: string[]) => void }) => { + const artifacts = useRecoilValue(store.artifactsState); + React.useEffect(() => { + onSnapshot(Object.keys(artifacts ?? {})); + }); + return null; + }; + + it('offers the older message card the newer content when the newer message mounts after it', () => { + const older = baseAttachment({ + file_id: 'diagram-mount-order-a', + filename: 'flow.mmd', + text: 'graph TD\nA-->B', + updatedAt: '2024-01-01T00:00:00.000Z', + }); + const newer = baseAttachment({ + file_id: 'diagram-mount-order-a', + filename: 'flow.mmd', + text: 'graph TD\nA-->C', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + let artifactKeys: string[] = []; + render( + + (artifactKeys = keys)} /> + + + + + + + , + ); + const cards = mermaidCards(); + expect(cards).toHaveLength(2); + // Opening EITHER card — including the older message's — would register + // the newest version. + cards.forEach((card) => { + expect(card).toHaveAttribute('data-artifact-content', 'graph TD\nA-->C'); + }); + // Each inline diagram still renders its own message's source. + expect(cards[0].textContent).toBe('graph TD\nA-->B'); + expect(cards[1].textContent).toBe('graph TD\nA-->C'); + // Mounting alone never writes into artifactsState (navigator unaffected). + expect(artifactKeys).toHaveLength(0); + }); + + it('keeps the newest content when the newer message mounts before the older one', () => { + const newer = baseAttachment({ + file_id: 'diagram-mount-order-b', + filename: 'flow.mmd', + text: 'graph TD\nA-->C', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + const older = baseAttachment({ + file_id: 'diagram-mount-order-b', + filename: 'flow.mmd', + text: 'graph TD\nA-->B', + updatedAt: '2024-01-01T00:00:00.000Z', + }); + render( + + + + + + + + , + ); + const cards = mermaidCards(); + expect(cards).toHaveLength(2); + cards.forEach((card) => { + expect(card).toHaveAttribute('data-artifact-content', 'graph TD\nA-->C'); + }); + expect(cards[0].textContent).toBe('graph TD\nA-->C'); + expect(cards[1].textContent).toBe('graph TD\nA-->B'); + }); + + it('keeps the newer content after the newer card unmounts and the older card mounts fresh', () => { + const fileId = 'diagram-remount'; + const newer = baseAttachment({ + file_id: fileId, + filename: 'flow.mmd', + text: 'graph TD\nA-->C', + updatedAt: '2024-01-02T00:00:00.000Z', + }); + const older = baseAttachment({ + file_id: fileId, + filename: 'flow.mmd', + text: 'graph TD\nA-->B', + updatedAt: '2024-01-01T00:00:00.000Z', + }); + const { unmount } = render( + + + + + , + ); + expect(screen.getByTestId('mermaid-render')).toHaveAttribute( + 'data-artifact-content', + 'graph TD\nA-->C', + ); + unmount(); + + render( + + + + + , + ); + const reopened = screen.getByTestId('mermaid-render'); + expect(reopened).toHaveAttribute('data-artifact-content', 'graph TD\nA-->C'); + expect(reopened.textContent).toBe('graph TD\nA-->B'); + }); + + it('settles on one version when two diagrams for the same file share lastUpdateTime (no ping-pong)', () => { + const fileId = 'diagram-tie'; + const versionA = () => + baseAttachment({ file_id: fileId, filename: 'tie.mmd', text: 'graph TD\nA-->B' }); + const versionB = () => + baseAttachment({ file_id: fileId, filename: 'tie.mmd', text: 'graph TD\nA-->C' }); + + const renderPair = () => + render( + + + + + + + + , + ); + + const { unmount } = renderPair(); + const firstCards = mermaidCards(); + const settled = firstCards[0].getAttribute('data-artifact-content'); + expect(settled).not.toBeNull(); + expect(['graph TD\nA-->B', 'graph TD\nA-->C']).toContain(settled); + expect(firstCards[1]).toHaveAttribute('data-artifact-content', settled as string); + unmount(); + + renderPair(); + const secondCards = mermaidCards(); + expect(secondCards[0]).toHaveAttribute('data-artifact-content', settled as string); + expect(secondCards[1]).toHaveAttribute('data-artifact-content', settled as string); + }); +}); + +describe('ToolArtifactCard tied writes settle once and do not ping-pong', () => { + const tieId = 'tool-artifact-tie-file'; + const versionA = () => + baseAttachment({ file_id: 'tie-file', filename: 'tie.html', text: '

version A

' }); + const versionB = () => + baseAttachment({ file_id: 'tie-file', filename: 'tie.html', text: '

version B

' }); + + /** Records each distinct object identity `artifactsState[tieId]` takes on, + * i.e. one entry per real write — not one per render. */ + const WriteHistoryProbe = ({ onWrite }: { onWrite: (content: string | null) => void }) => { + const artifacts = useRecoilValue(store.artifactsState); + const entry = artifacts?.[tieId]; + const lastSeen = React.useRef(undefined); + React.useEffect(() => { + if (entry !== lastSeen.current) { + lastSeen.current = entry; + onWrite(entry?.content ?? null); + } + }); + return null; + }; + + it('settles on one version when the second card mounts after the first (sequential)', () => { + const cardA = versionA(); + const cardB = versionB(); + const writes: (string | null)[] = []; + const onWrite = (content: string | null) => writes.push(content); + + const { rerender } = render( + + + + + + , + ); + + rerender( + + + + + + + + + , + ); + + expect(writes.length).toBeLessThanOrEqual(2); + const settled = writes[writes.length - 1]; + expect(['

version A

', '

version B

']).toContain(settled); + + // Flush again with an unchanged tree — must not drift or write again. + rerender( + + + + + + + + + , + ); + expect(writes.length).toBeLessThanOrEqual(2); + expect(writes[writes.length - 1]).toBe(settled); + }); + + it('settles on one version when both cards mount together', () => { + const cardA = versionA(); + const cardB = versionB(); + const writes: (string | null)[] = []; + const onWrite = (content: string | null) => writes.push(content); + + const { rerender } = render( + + + + + + + + + , + ); + + expect(writes.length).toBeLessThanOrEqual(2); + const settled = writes[writes.length - 1]; + expect(['

version A

', '

version B

']).toContain(settled); + + // Flush again with an unchanged tree — must not drift or write again. + rerender( + + + + + + + + + , + ); + expect(writes.length).toBeLessThanOrEqual(2); + expect(writes[writes.length - 1]).toBe(settled); + }); +}); + +describe('SearchContent places cards per message', () => { + const searchMessage = (overrides: Partial = {}): TMessage => + ({ messageId: 'm', text: '', ...overrides }) as TMessage; + + const toolCallPart = (toolCallId: string): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { id: toolCallId, name: Tools.execute_code, args: '{}' }, + }) as unknown as TMessageContentParts; + + it('shows a card under each search-result message that holds the same file identity', () => { + const fileAttachment = (toolCallId: string) => + baseAttachment({ + file_id: 'search-shared-file', + filename: 'result.html', + text: '

result

', + toolCallId, + } as Partial); + + const { container } = render( + + + + , + ); + + expect(container.querySelectorAll('[data-artifact-trigger]')).toHaveLength(2); + expect(screen.getAllByText('result.html')).toHaveLength(2); + }); + + it('collapses two cards for the same file within one search-result message to one card', () => { + const dup = baseAttachment({ + file_id: 'search-dup-file', + filename: 'dup.html', + text: '

dup

', + toolCallId: 'call-dup', + } as Partial); + + const { container } = render( + + + , + ); + + expect(container.querySelectorAll('[data-artifact-trigger]')).toHaveLength(1); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx index 85237fdf382..1844965cedc 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/host.test.tsx @@ -136,7 +136,7 @@ describe('message parts host', () => { , ); - expect(panel.register).not.toHaveBeenCalled(); + expect(panel.register).toHaveBeenCalledWith(artifact); expect(panel.open).toHaveBeenCalledWith('artifact-1'); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/claim.ts b/client/src/components/Chat/Messages/Content/Parts/claim.ts new file mode 100644 index 00000000000..2bb181c28b9 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/claim.ts @@ -0,0 +1,78 @@ +import { useId, useLayoutEffect } from 'react'; +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import type { Artifact } from '~/common'; +import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; + +interface ToolArtifactClaim { + /** False only while another mounted instance holds this display key. */ + isMyClaim: boolean; + /** + * This instance's stable claim key. `ToolArtifactCard` reuses it to also + * claim the id-only (message-independent) `toolArtifactClaim(id)` atom for + * its registration tie-break, so that claim and this hook's display claim + * never fight over the SAME atom entry in the no-message fallback, where + * both keys collapse to the bare `id`. + */ + claimKey: string; +} + +/** + * Scopes a tool artifact's chat-row dedup to the message that mounts it, so + * the same file shows one card per message but collapses repeat + * mounts within a single message to one. Falls back to the bare + * file id when no message is known — only the search-results route + * (`routes/Search.tsx` → `SearchMessage`) renders without an ambient + * `MessageContext`; `Share/Message.tsx` already provides one with + * `messageId` set. That fallback keeps today's cross-message dedup for + * the search route — `ArtifactRouting.test.tsx`'s "latest mount wins" and + * "does not ping-pong" cases render this way and must stay green. + * + * Shared by `ToolArtifactCard` and `ToolMermaidArtifact` so the same file + * dedups identically whether it renders as a panel card or an inline + * diagram. + */ +export default function useToolArtifactClaim(id: string): ToolArtifactClaim { + const claimKey = useId(); + const { useMessage, useToolArtifactClaim: useClaimState } = useMessagePartsHost(); + const { messageId } = useMessage(); + const displayKey = messageId ? `${messageId}::${id}` : id; + const [claim, setClaim] = useClaimState(displayKey); + + useLayoutEffect(() => { + // Always (re)claim on mount — a later card for the same key displaces + // an earlier one, so the chip migrates to the most recent mention. + setClaim(claimKey); + return () => { + // Only release when the claim is still ours; if a sibling already + // took over we don't want to clobber its claim. + setClaim((prev) => (prev === claimKey ? null : prev)); + }; + }, [claimKey, setClaim]); + + return { isMyClaim: claim == null || claim === claimKey, claimKey }; +} + +/** + * Per-file-identity "newest version seen" for tool-diagram artifacts. + * Every mounted `ToolMermaidArtifact` offers its own artifact here on + * mount, keeping whichever entry has the larger `lastUpdateTime` (ties + * keep the current entry, so equal-time offers don't drift). Entries + * persist after a card unmounts, so a message whose diagram was + * superseded stays discoverable for any other message sharing the same + * file identity even after the newer card leaves the DOM. + */ +export const newestToolArtifactFamily = atomFamily((_id: string) => atom(null)); + +/** + * Shared by `ToolArtifactCard`'s self-heal registration and + * `ToolMermaidArtifact`/`Mermaid`'s newest-version handling so both use + * the same "is `candidate` strictly newer than `other`" rule instead of + * two copies that could drift out of sync. + */ +export function isStrictlyNewer( + candidate: Pick, + other: Pick | null | undefined, +): boolean { + return other != null && candidate.lastUpdateTime > other.lastUpdateTime; +} diff --git a/client/src/components/Chat/Messages/Content/SearchContent.tsx b/client/src/components/Chat/Messages/Content/SearchContent.tsx index 127ec3cfdd9..073d1fa8ab2 100644 --- a/client/src/components/Chat/Messages/Content/SearchContent.tsx +++ b/client/src/components/Chat/Messages/Content/SearchContent.tsx @@ -70,7 +70,6 @@ const SearchContent = ({ } const partElement: ReactElement = ( ); - /** An error part resolves the agent a handoff made active from its own position, - * which it reads from `MessageContext`; persisted content is compacted, so `idx` is + /** Every part gets the row's message id so file-card dedup + * (`useToolArtifactClaim`) scopes correctly regardless of + * caller: the search-results route renders `SearchContent` + * with no ambient `MessageContext`, while `Share/Message.tsx` + * already provides one with `messageId` set — this explicit + * per-part provider keeps both paths consistent instead of + * relying on whichever context happens to be ambient. + * An error part additionally resolves the agent a handoff made + * active from its own position, which it reads from + * `MessageContext`; persisted content is compacted, so `idx` is * that position in `message.content`. */ - const rendered: ReactElement = - part.type === ContentTypes.ERROR ? ( - - {partElement} - - ) : ( - partElement - ); + const rendered: ReactElement = ( + + {partElement} + + ); if (!resumesAfterSteer) { return rendered; } diff --git a/client/src/components/Messages/Content/Mermaid/Mermaid.tsx b/client/src/components/Messages/Content/Mermaid/Mermaid.tsx index 1edb1bb7184..19873f566b7 100644 --- a/client/src/components/Messages/Content/Mermaid/Mermaid.tsx +++ b/client/src/components/Messages/Content/Mermaid/Mermaid.tsx @@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom'; import { Button, Spinner } from '@librechat/client'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import type { ProcessedMermaidSvg } from '~/utils/diagram/export'; +import { isStrictlyNewer } from '~/components/Chat/Messages/Content/Parts/claim'; import ArtifactRow from '~/components/Chat/Messages/Content/Parts/ArtifactRow'; import { MERMAID_ARTIFACT_TYPE, type Artifact } from '~/common/artifacts'; import { artifactRowKind } from '~/utils/artifacts'; @@ -81,11 +82,15 @@ const Mermaid: React.FC = memo((props) => { const artifactId = `mermaid-artifact-${artifactScope}-${id || instanceId}`; const artifact = useMemo(() => { if (artifactProp != null) { + /* Unlike the no-`artifactProp` branch below, `content` is NOT forced + * to `children` here: `ToolMermaidArtifact` may hand this a newer + * sibling's content (via `newestToolArtifactFamily`) so opening THIS + * card registers that newer version, while `children` below (the + * inline diagram) keeps rendering this message's own source. */ return { ...artifactProp, type: artifactProp.type ?? MERMAID_ARTIFACT_TYPE, title: artifactProp.title ?? defaultTitle, - content: children, messageId: artifactProp.messageId ?? messageId, }; } @@ -114,6 +119,13 @@ const Mermaid: React.FC = memo((props) => { ) { return previousArtifacts; } + // Never downgrade an already-registered version that's strictly + // newer than what this instance is about to write — protects + // against a stale self-heal re-fire racing a sibling card's newer + // write for the same file identity. + if (existingArtifact != null && isStrictlyNewer(existingArtifact, artifact)) { + return previousArtifacts; + } return { ...(previousArtifacts ?? {}), [artifact.id]: artifact }; }); diff --git a/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts b/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts new file mode 100644 index 00000000000..07dc4882e08 --- /dev/null +++ b/client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts @@ -0,0 +1,104 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { + fillOfficeFileShell, + OFFICE_DOC_DATA_SLOT, + OFFICE_FILE_SHELL_MARKER, +} from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import useOfficeFileShell from '../useOfficeFileShell'; + +const mockRefetch = jest.fn(); +const mockUseFilePreviewBlob = jest.fn((..._args: (string | undefined)[]) => ({ + refetch: mockRefetch, +})); +let mockShareId: string | undefined; + +jest.mock('~/data-provider', () => ({ + useFilePreviewBlob: (...args: (string | undefined)[]) => mockUseFilePreviewBlob(...args), +})); + +jest.mock('~/Providers', () => ({ + useShareContext: () => ({ shareId: mockShareId }), +})); + +const shell = `${OFFICE_FILE_SHELL_MARKER}${OFFICE_DOC_DATA_SLOT}`; + +const shellArtifact: Artifact = { + id: 'a1', + lastUpdateTime: 1, + content: shell, + download: { file_id: 'file-1', user: 'user-1' }, +}; + +describe('useOfficeFileShell', () => { + beforeEach(() => { + mockRefetch.mockReset(); + mockUseFilePreviewBlob.mockClear(); + mockShareId = undefined; + }); + + it('fills the shell with the fetched bytes as base64 after loading', async () => { + mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) }); + const { result } = renderHook(() => useOfficeFileShell(shellArtifact)); + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.content).toBe(fillOfficeFileShell(shell, 'QUJD')); + expect(mockRefetch).toHaveBeenCalledTimes(1); + expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined); + }); + + it('passes the share id so shared viewers use the share-scoped route', async () => { + mockShareId = 'share-9'; + mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) }); + const { result } = renderHook(() => useOfficeFileShell(shellArtifact)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', 'share-9'); + }); + + it('keeps the stored shell when the fetch fails', async () => { + mockRefetch.mockResolvedValue({ data: undefined, error: new Error('nope') }); + const { result } = renderHook(() => useOfficeFileShell(shellArtifact)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.content).toBe(shell); + }); + + it('keeps the stored shell when the fetch rejects', async () => { + mockRefetch.mockRejectedValue(new Error('nope')); + const { result } = renderHook(() => useOfficeFileShell(shellArtifact)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.content).toBe(shell); + }); + + it('reports loading for a second artifact with an identical shell', async () => { + mockRefetch.mockResolvedValueOnce({ data: new Blob(['ABC']) }); + const { result, rerender } = renderHook(({ artifact }) => useOfficeFileShell(artifact), { + initialProps: { artifact: shellArtifact }, + }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const filledA = fillOfficeFileShell(shell, 'QUJD'); + expect(result.current.content).toBe(filledA); + + mockRefetch.mockReturnValueOnce(new Promise(() => undefined)); + const other: Artifact = { + ...shellArtifact, + id: 'a2', + download: { file_id: 'file-2', user: 'user-1' }, + }; + rerender({ artifact: other }); + expect(result.current.isLoading).toBe(true); + expect(result.current.content).toBe(shell); + }); + + it('does not fetch for a non-shell artifact', () => { + const plain: Artifact = { ...shellArtifact, content: 'inline' }; + const { result } = renderHook(() => useOfficeFileShell(plain)); + expect(result.current).toEqual({ content: 'inline', isLoading: false }); + expect(mockRefetch).not.toHaveBeenCalled(); + }); + + it('does not fetch for a null artifact', () => { + const { result } = renderHook(() => useOfficeFileShell(null)); + expect(result.current).toEqual({ content: undefined, isLoading: false }); + expect(mockRefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/Artifacts/useOfficeFileShell.ts b/client/src/hooks/Artifacts/useOfficeFileShell.ts new file mode 100644 index 00000000000..e67e2dcc8e6 --- /dev/null +++ b/client/src/hooks/Artifacts/useOfficeFileShell.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react'; +import { fillOfficeFileShell, isOfficeFileShell } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import { useFilePreviewBlob } from '~/data-provider'; +import { useShareContext } from '~/Providers'; + +const readBase64 = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => + resolve(String(reader.result).slice(String(reader.result).indexOf(',') + 1)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + +interface FilledShell { + key: string; + shell: string; + content: string; +} + +/** Fills a stored office-preview shell with its file's bytes; a failed fetch leaves the shell as stored. */ +export default function useOfficeFileShell(artifact: Artifact | null): { + content: string | undefined; + isLoading: boolean; +} { + const { shareId } = useShareContext(); + const { refetch } = useFilePreviewBlob( + artifact?.download?.user, + artifact?.download?.file_id, + shareId, + ); + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + const [filled, setFilled] = useState(null); + + const stored = artifact?.content; + const isShell = + stored != null && artifact?.download?.file_id != null && isOfficeFileShell(stored); + + const key = `${artifact?.id}:${artifact?.download?.file_id}`; + + useEffect(() => { + if (!isShell) { + return; + } + let cancelled = false; + const settle = (content: string) => { + if (!cancelled) { + setFilled({ key, shell: stored, content }); + } + }; + refetchRef + .current() + .then(({ data }) => (data instanceof Blob ? readBase64(data) : Promise.reject(new Error()))) + .then((base64) => settle(fillOfficeFileShell(stored, base64))) + .catch(() => settle(stored)); + return () => { + cancelled = true; + }; + }, [isShell, stored, key]); + + if (!isShell) { + return { content: stored, isLoading: false }; + } + const isCurrent = filled?.key === key && filled.shell === stored; + return { content: isCurrent ? filled.content : stored, isLoading: !isCurrent }; +} diff --git a/client/src/utils/__tests__/artifacts.test.ts b/client/src/utils/__tests__/artifacts.test.ts index 11d2fedb6de..f221569e7c6 100644 --- a/client/src/utils/__tests__/artifacts.test.ts +++ b/client/src/utils/__tests__/artifacts.test.ts @@ -14,6 +14,7 @@ import { isPreviewOnlyArtifact, isSvgArtifactType, languageForFilename, + toolArtifactKey, TOOL_ARTIFACT_TYPES, } from '../artifacts'; @@ -654,6 +655,35 @@ describe('languageForFilename', () => { }); }); +describe('toolArtifactKey', () => { + it('prefers file_id over filepath and filename', () => { + expect( + toolArtifactKey({ + file_id: 'fid-1', + filepath: '/uploads/session-a/index.html', + filename: 'index.html', + }), + ).toBe('tool-artifact-fid-1'); + }); + + it('falls back to filepath before filename when file_id is missing', () => { + /* Id-less attachments are download fallbacks with a unique per-session + * filepath; keying by filename would merge genuinely different files + * that happen to share a display name. */ + expect( + toolArtifactKey({ filepath: '/uploads/session-a/index.html', filename: 'index.html' }), + ).toBe('tool-artifact-/uploads/session-a/index.html'); + }); + + it('falls back to filename when neither file_id nor filepath is present', () => { + expect(toolArtifactKey({ filename: 'index.html' })).toBe('tool-artifact-index.html'); + }); + + it("falls back to 'unknown' when nothing identifies the file", () => { + expect(toolArtifactKey({})).toBe('tool-artifact-unknown'); + }); +}); + describe('fileToArtifact', () => { const baseFile = { file_id: 'fid-1', diff --git a/client/src/utils/__tests__/map.test.ts b/client/src/utils/__tests__/map.test.ts index 6dc54dc42b1..25fdc1b4d4d 100644 --- a/client/src/utils/__tests__/map.test.ts +++ b/client/src/utils/__tests__/map.test.ts @@ -62,7 +62,110 @@ describe('filterAttachmentsForPart', () => { describe('mapAttachments', () => { it('groups by toolCallId and drops unkeyed entries', () => { - const map = mapAttachments([att({}), att({ toolCallId: 'call_1' }), att({ toolCallId: '' })]); + const map = mapAttachments([ + att({ file_id: 'f1' }), + att({ toolCallId: 'call_1', file_id: 'f2' }), + att({ toolCallId: '', file_id: 'f3' }), + ]); expect(Object.keys(map).sort()).toEqual(['call_0', 'call_1']); }); + + it('keeps a repeated file_id only under its later toolCallId', () => { + const first = att({ toolCallId: 'call_0', file_id: 'f1' }); + const second = att({ toolCallId: 'call_1', file_id: 'f1' }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toBeUndefined(); + expect(map['call_1']).toEqual([second]); + }); + + it('drops an earlier duplicate of the same file within one toolCallId', () => { + const first = att({ toolCallId: 'call_0', file_id: 'f1' }); + const second = att({ toolCallId: 'call_0', file_id: 'f1' }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toEqual([second]); + }); + + it('keeps two attachments with the same filename but different file_ids', () => { + const first = att({ toolCallId: 'call_0', file_id: 'f1', filename: 'data.zip' }); + const second = att({ toolCallId: 'call_0', file_id: 'f2', filename: 'data.zip' }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toEqual([first, second]); + }); + + it('keeps every non-file attachment even when they share a toolCallId', () => { + const first = att({ toolCallId: 'call_0', file_id: undefined }); + const second = att({ toolCallId: 'call_0', file_id: undefined }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toEqual([first, second]); + }); + + it('skips null and undefined entries', () => { + const map = mapAttachments([null, att({ toolCallId: 'call_0' }), undefined]); + expect(map['call_0']).toHaveLength(1); + }); + + it('keeps the newer write when copies are already in chronological order', () => { + const older = att({ toolCallId: 'call_0', file_id: 'f1', updatedAt: '2024-01-01T00:00:00Z' }); + const newer = att({ toolCallId: 'call_1', file_id: 'f1', updatedAt: '2024-01-02T00:00:00Z' }); + const map = mapAttachments([older, newer]); + expect(map['call_0']).toBeUndefined(); + expect(map['call_1']).toEqual([newer]); + }); + + it('keeps the newer write when an older duplicate sits at a higher array index', () => { + const newer = att({ toolCallId: 'call_1', file_id: 'f1', updatedAt: '2024-01-02T00:00:00Z' }); + const older = att({ toolCallId: 'call_0', file_id: 'f1', updatedAt: '2024-01-01T00:00:00Z' }); + const map = mapAttachments([newer, older]); + expect(map['call_0']).toBeUndefined(); + expect(map['call_1']).toEqual([newer]); + }); + + it('breaks a tie in write time by keeping the higher array index', () => { + const first = att({ toolCallId: 'call_0', file_id: 'f1', updatedAt: '2024-01-01T00:00:00Z' }); + const second = att({ toolCallId: 'call_1', file_id: 'f1', updatedAt: '2024-01-01T00:00:00Z' }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toBeUndefined(); + expect(map['call_1']).toEqual([second]); + }); + + it('keeps the linked copy when an unlinked duplicate of the same file follows it', () => { + const linked = att({ toolCallId: 'call_0', file_id: 'f1' }); + const unlinked = att({ toolCallId: '', file_id: 'f1' }); + const map = mapAttachments([linked, unlinked]); + expect(map['call_0']).toEqual([linked]); + }); + + it('keeps two id-less attachments with the same filename but different filepaths', () => { + const first = att({ + toolCallId: 'call_0', + file_id: undefined, + filename: 'data.zip', + filepath: '/uploads/session-a/data.zip', + }); + const second = att({ + toolCallId: 'call_1', + file_id: undefined, + filename: 'data.zip', + filepath: '/uploads/session-b/data.zip', + }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toEqual([first]); + expect(map['call_1']).toEqual([second]); + }); + + it('drops an earlier id-less duplicate at the same filepath', () => { + const first = att({ + toolCallId: 'call_0', + file_id: undefined, + filepath: '/uploads/session-a/data.zip', + }); + const second = att({ + toolCallId: 'call_1', + file_id: undefined, + filepath: '/uploads/session-a/data.zip', + }); + const map = mapAttachments([first, second]); + expect(map['call_0']).toBeUndefined(); + expect(map['call_1']).toEqual([second]); + }); }); diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index 8c02ee4b744..654dfa44acb 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -1106,12 +1106,14 @@ export function detectArtifactTypeFromFile( * Stable per-file key used for both the artifactsState entry and the * `toolArtifactClaim` atom that dedups duplicate cards. Same call shape * everywhere so a panel card and a mermaid card for the same file share - * the same claim. Falls through `file_id` → `filename` → `filepath` to - * minimise collision risk for any caller that (rarely) lacks `file_id`. + * the same claim. Falls through `file_id` → `filepath` → `filename`: + * id-less attachments are download fallbacks with a unique per-session + * filepath, so keying them by display name would merge genuinely + * different files that happen to share a filename. */ export const toolArtifactKey = ( file: Partial>, -): string => `tool-artifact-${file.file_id ?? file.filename ?? file.filepath ?? 'unknown'}`; +): string => `tool-artifact-${file.file_id ?? file.filepath ?? file.filename ?? 'unknown'}`; /** * Stable epoch fallback (instead of `Date.now()`) when neither timestamp diff --git a/client/src/utils/map.ts b/client/src/utils/map.ts index 9f43d5a5023..748d2de6e6b 100644 --- a/client/src/utils/map.ts +++ b/client/src/utils/map.ts @@ -1,17 +1,84 @@ import type * as t from 'librechat-data-provider'; import type { TPluginMap } from '~/common'; +import { toolArtifactKey } from './artifacts'; -/** Maps Attachments by `toolCallId` for quick lookup */ +/** + * Identity for a file-backed attachment (one with a `file_id` or a + * `filepath`), or `null` for anything else (e.g. web search results), which + * is never collapsed. For file-backed attachments this is exactly the + * artifact card's key, `toolArtifactKey` (`file_id` → `filepath` → + * `filename`), so an id-less file keys by its unique per-session filepath + * rather than a display name two different files can share. An attachment + * with only a `filename` is not treated as a file here, although + * `toolArtifactKey` would still key it by that name. + */ +export const fileIdentity = (attachment: t.TAttachment): string | null => { + const file = attachment as Partial; + if (file.file_id != null || file.filepath != null) { + return toolArtifactKey(file); + } + return null; +}; + +/** `updatedAt ?? createdAt`, parsed to ms; missing or unparseable → 0. */ +const writeTimeMs = (attachment: t.TAttachment): number => { + const file = attachment as Partial; + const value = file.updatedAt ?? file.createdAt; + if (value == null) { + return 0; + } + const ms = new Date(value as string | number | Date).getTime(); + return Number.isFinite(ms) ? ms : 0; +}; + +/** + * Maps Attachments by `toolCallId` for quick lookup. Attachments are assumed + * to belong to one message: when the same file repeats — e.g. a tool call + * rewrites the file it produced earlier in the message — only the copy with + * the newest write time survives (ties keep the higher array index), so a + * message never shows the same file twice. Array order isn't chronological + * (a background-run harvest can append an older copy after a newer + * foreground rewrite), so only entries that will actually be grouped + * (non-empty `toolCallId`) compete for survivorship; an unlinked duplicate + * is dropped as always but never hides a linked copy. Non-file attachments + * (no `file_id`/`filepath`) never collapse. + */ export function mapAttachments(attachments: Array) { const attachmentMap: Record = {}; - for (const attachment of attachments) { + const identities = attachments.map((attachment) => + attachment == null ? null : fileIdentity(attachment), + ); + const survivorByIdentity = new Map(); + attachments.forEach((attachment, index) => { + if (attachment == null) { + return; + } + const identity = identities[index]; + if (identity == null || !attachment.toolCallId) { + return; + } + const time = writeTimeMs(attachment); + const current = survivorByIdentity.get(identity); + if (!current || time > current.time || (time === current.time && index > current.index)) { + survivorByIdentity.set(identity, { index, time }); + } + }); + + attachments.forEach((attachment, index) => { if (attachment === null || attachment === undefined) { - continue; + return; + } + const identity = identities[index]; + if (identity != null) { + const survivor = survivorByIdentity.get(identity); + if (survivor && survivor.index !== index) { + return; + } } const key = attachment.toolCallId || ''; if (key.length === 0) { - continue; + return; } if (!attachmentMap[key]) { @@ -19,7 +86,7 @@ export function mapAttachments(attachments: Array ({ const mockOfficeHtml = jest.fn( async (_buffer: Buffer, _name: string, _mime: string) => null as string | null, ); +const mockShellRender = jest.fn( + async (_buffer: Buffer, _options: { fileShell?: boolean }) => null as string | null, +); jest.mock('~/files/documents/html', () => { const actual = jest.requireActual('~/files/documents/html'); return { + pptxToHtml: (buffer: Buffer, options: { fileShell?: boolean }) => + mockShellRender(buffer, options), + wordDocToHtml: (buffer: Buffer, options: { fileShell?: boolean }) => + mockShellRender(buffer, options), bufferToOfficeHtml: (buffer: Buffer, name: string, mime: string) => mockOfficeHtml(buffer, name, mime), officeHtmlBucket: actual.officeHtmlBucket, @@ -855,3 +865,198 @@ describe('resolveMaxTextExtractBytes', () => { ); }); }); + +describe('office preview shell routing', () => { + const INLINE_CAP = 350 * 1024; + const LIMIT = 25 * 1024 * 1024; + const pptx = 'deck.pptx'; + const pptxMime = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + const docx = 'report.docx'; + const docxMime = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + const on = { enabled: true, fileSizeLimit: LIMIT }; + + beforeEach(() => { + mockOfficeHtml.mockReset(); + mockShellRender.mockReset(); + mockOfficeHtml.mockResolvedValue('inline'); + mockShellRender.mockResolvedValue('shell'); + }); + + it('keeps a file of exactly 350 KB on the inline path', async () => { + const text = await extractCodeArtifactText( + Buffer.alloc(INLINE_CAP), + pptx, + pptxMime, + 'presentation', + on, + ); + expect(text).toBe('inline'); + expect(mockShellRender).not.toHaveBeenCalled(); + }); + + it.each([ + [pptx, pptxMime, 'presentation'], + [docx, docxMime, 'document'], + ] as const)('renders a shell for %s at 350 KB + 1 byte', async (name, mime, category) => { + const buffer = Buffer.alloc(INLINE_CAP + 1); + const text = await extractCodeArtifactText(buffer, name, mime, category, on); + expect(text).toBe('shell'); + expect(mockShellRender).toHaveBeenCalledWith(buffer, { fileShell: true }); + expect(mockOfficeHtml).not.toHaveBeenCalled(); + }); + + it('renders a shell for a 4 MB deck', async () => { + const text = await extractCodeArtifactText( + Buffer.alloc(4 * 1024 * 1024), + pptx, + pptxMime, + 'presentation', + on, + ); + expect(text).toBe('shell'); + }); + + it('renders a shell at exactly the limit and returns null one byte above', async () => { + const atLimit = await extractCodeArtifactText( + Buffer.alloc(LIMIT), + pptx, + pptxMime, + 'presentation', + on, + ); + const above = await extractCodeArtifactText( + Buffer.alloc(LIMIT + 1), + pptx, + pptxMime, + 'presentation', + on, + ); + expect(atLimit).toBe('shell'); + expect(above).toBeNull(); + expect(mockShellRender).toHaveBeenCalledTimes(1); + }); + + it('returns null when the shell render fails', async () => { + mockShellRender.mockResolvedValueOnce(null); + const text = await extractCodeArtifactText( + Buffer.alloc(INLINE_CAP + 1), + pptx, + pptxMime, + 'presentation', + on, + ); + expect(text).toBeNull(); + }); + + it('restores the 2 MB gate and inline routing when disabled', async () => { + const disabled = { enabled: false, fileSizeLimit: LIMIT }; + const mid = await extractCodeArtifactText( + Buffer.alloc(INLINE_CAP + 1), + pptx, + pptxMime, + 'presentation', + disabled, + ); + const big = await extractCodeArtifactText( + Buffer.alloc(MAX_TEXT_EXTRACT_BYTES + 1), + pptx, + pptxMime, + 'presentation', + disabled, + ); + expect(mid).toBe('inline'); + expect(big).toBeNull(); + expect(mockShellRender).not.toHaveBeenCalled(); + }); + + it('does not shell spreadsheets above the inline cap', async () => { + const text = await extractCodeArtifactText( + Buffer.alloc(INLINE_CAP + 1), + 'data.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'document', + on, + ); + expect(text).toBe('inline'); + expect(mockShellRender).not.toHaveBeenCalled(); + }); + + describe('officePreviewByteLimit', () => { + it('is the configured limit for pptx and docx when enabled', () => { + expect(officePreviewByteLimit(pptx, pptxMime, on)).toBe(LIMIT); + expect(officePreviewByteLimit(docx, docxMime, on)).toBe(LIMIT); + }); + + it('never drops below the extract ceiling', () => { + expect(officePreviewByteLimit(pptx, pptxMime, { enabled: true, fileSizeLimit: 1 })).toBe( + MAX_TEXT_EXTRACT_BYTES, + ); + }); + + it('is the extract ceiling when disabled, unset, or for other files', () => { + expect(officePreviewByteLimit(pptx, pptxMime, { enabled: false, fileSizeLimit: LIMIT })).toBe( + MAX_TEXT_EXTRACT_BYTES, + ); + expect(officePreviewByteLimit(pptx, pptxMime)).toBe(MAX_TEXT_EXTRACT_BYTES); + expect(officePreviewByteLimit('data.xlsx', 'application/vnd.ms-excel', on)).toBe( + MAX_TEXT_EXTRACT_BYTES, + ); + }); + + describe('officePreviewFailure', () => { + const off = { enabled: false, fileSizeLimit: LIMIT }; + + it('is too-large only past the configured limit for an enabled pptx', () => { + expect(officePreviewFailure(LIMIT + 1, pptx, pptxMime, on)).toBe('too-large'); + expect(officePreviewFailure(LIMIT, pptx, pptxMime, on)).toBe('parser-error'); + expect(officePreviewFailure(1024 * 1024, pptx, pptxMime, on)).toBe('parser-error'); + }); + + it('uses the extract ceiling when the setting is disabled', () => { + expect(officePreviewFailure(MAX_TEXT_EXTRACT_BYTES + 1, pptx, pptxMime, off)).toBe( + 'too-large', + ); + expect(officePreviewFailure(MAX_TEXT_EXTRACT_BYTES, pptx, pptxMime, off)).toBe( + 'parser-error', + ); + }); + + it('uses the extract ceiling for files that are not decks or documents', () => { + expect( + officePreviewFailure( + MAX_TEXT_EXTRACT_BYTES + 1, + 'data.xlsx', + 'application/vnd.ms-excel', + on, + ), + ).toBe('too-large'); + }); + }); + }); + + describe('limits set in librechat.yaml', () => { + const mb = 1024 * 1024; + const fromYaml = (officePreview: { enabled?: boolean; fileSizeLimit?: number }) => + mergeFileConfig({ officePreview }).officePreview; + const route = (size: number, setting: ReturnType) => + extractCodeArtifactText(Buffer.alloc(size), pptx, pptxMime, 'presentation', setting); + + it('applies a 5 MB limit given in megabytes as bytes', async () => { + const setting = fromYaml({ fileSizeLimit: 5 }); + expect(setting).toEqual({ enabled: true, fileSizeLimit: 5 * mb }); + expect(officePreviewByteLimit(pptx, pptxMime, setting)).toBe(5 * mb); + expect(officePreviewFailure(5 * mb + 1, pptx, pptxMime, setting)).toBe('too-large'); + expect(officePreviewFailure(5 * mb, pptx, pptxMime, setting)).toBe('parser-error'); + expect(await route(4 * mb, setting)).toBe('shell'); + expect(await route(5 * mb + 1, setting)).toBeNull(); + }); + + it('falls back to the 2 MB ceiling when the preview is turned off', async () => { + const setting = fromYaml({ enabled: false }); + expect(officePreviewByteLimit(pptx, pptxMime, setting)).toBe(MAX_TEXT_EXTRACT_BYTES); + expect(officePreviewFailure(3 * mb, pptx, pptxMime, setting)).toBe('too-large'); + expect(await route(3 * mb, setting)).toBeNull(); + expect(mockShellRender).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/files/code/extract.ts b/packages/api/src/files/code/extract.ts index 88b7e7ab3ce..c8cbf15d5ab 100644 --- a/packages/api/src/files/code/extract.ts +++ b/packages/api/src/files/code/extract.ts @@ -3,8 +3,14 @@ import * as path from 'path'; import * as fs from 'fs/promises'; import { randomUUID } from 'crypto'; import { logger } from '@librechat/data-schemas'; +import type { TFileConfig } from 'librechat-data-provider'; import type { CodeArtifactCategory } from './classify'; -import { bufferToOfficeHtml, officeHtmlBucket } from '~/files/documents/html'; +import { + bufferToOfficeHtml, + officeHtmlBucket, + pptxToHtml, + wordDocToHtml, +} from '~/files/documents/html'; import { createConcurrencyLimiter, withTimeout } from '~/utils/promise'; import { parseDocument } from '~/files/documents/crud'; import { isBinaryBuffer } from '~/skills/binary'; @@ -43,6 +49,48 @@ export function resolveMaxTextExtractBytes(value: string | undefined): number { export const MAX_TEXT_EXTRACT_BYTES: number = resolveMaxTextExtractBytes( process.env.FILE_PREVIEW_MAX_EXTRACT_BYTES, ); +/** Largest office file whose bytes still ride inside the stored preview HTML. */ +const INLINE_OFFICE_BYTES = 350 * 1024; + +export type OfficePreviewSetting = TFileConfig['officePreview']; + +const isShellEligible = ( + name: string, + mimeType: string, + officePreview?: OfficePreviewSetting, +): boolean => { + const bucket = officeHtmlBucket(name, mimeType); + return officePreview?.enabled === true && (bucket === 'docx' || bucket === 'presentation'); +}; + +/** + * Largest buffer a preview is attempted for. Decks and documents may go up to the + * configured `officePreview.fileSizeLimit` when the storage-backed path is on; + * every other file keeps the extraction ceiling. + */ +export function officePreviewByteLimit( + name: string, + mimeType: string, + officePreview?: OfficePreviewSetting, +): number { + if (!isShellEligible(name, mimeType, officePreview)) { + return MAX_TEXT_EXTRACT_BYTES; + } + return Math.max(MAX_TEXT_EXTRACT_BYTES, officePreview?.fileSizeLimit ?? 0); +} + +/** Why a preview came back empty: over the size limit, or the parser gave up. */ +export function officePreviewFailure( + byteLength: number, + name: string, + mimeType: string, + officePreview?: OfficePreviewSetting, +): 'too-large' | 'parser-error' { + return byteLength > officePreviewByteLimit(name, mimeType, officePreview) + ? 'too-large' + : 'parser-error'; +} + const DOCUMENT_PARSE_TIMEOUT_MS = 8_000; const OFFICE_HTML_TIMEOUT_MS = 12_000; const TRUNCATION_MARKER = '\n\n…[truncated]'; @@ -254,11 +302,20 @@ const renderOfficeHtml = async ( buffer: Buffer, name: string, mimeType: string, + fileShell = false, ): Promise => { + const render = (): Promise => { + if (!fileShell) { + return bufferToOfficeHtml(buffer, name, mimeType); + } + return officeHtmlBucket(name, mimeType) === 'docx' + ? wordDocToHtml(buffer, { fileShell }) + : pptxToHtml(buffer, { fileShell }); + }; try { const html = await officeHtmlLimit(() => withTimeout( - bufferToOfficeHtml(buffer, name, mimeType), + render(), OFFICE_HTML_TIMEOUT_MS, `bufferToOfficeHtml exceeded ${OFFICE_HTML_TIMEOUT_MS}ms`, ), @@ -299,8 +356,9 @@ export async function extractCodeArtifactText( name: string, mimeType: string, category: CodeArtifactCategory, + officePreview?: OfficePreviewSetting, ): Promise { - if (buffer.length > MAX_TEXT_EXTRACT_BYTES) { + if (buffer.length > officePreviewByteLimit(name, mimeType, officePreview)) { return null; } try { @@ -325,8 +383,9 @@ export async function extractCodeArtifactText( * text gate keep the artifact off the panel and fall back to the * regular download UI, matching what PPTX already does. */ if (hasOfficeHtmlPath(name, mimeType)) { - const html = await renderOfficeHtml(buffer, name, mimeType); - return html; + const fileShell = + buffer.length > INLINE_OFFICE_BYTES && isShellEligible(name, mimeType, officePreview); + return await renderOfficeHtml(buffer, name, mimeType, fileShell); } if (category === 'other') { return null; diff --git a/packages/api/src/files/code/process.ts b/packages/api/src/files/code/process.ts index 9aa4d1d9705..6cc03a188b9 100644 --- a/packages/api/src/files/code/process.ts +++ b/packages/api/src/files/code/process.ts @@ -5,6 +5,7 @@ import { fileConfig, imageExtRegex, inferMimeType, + mergeFileConfig, mergeCodeEnvRef, } from 'librechat-data-provider'; import type { CodeEnvRef, FileSources, getEndpointFileConfig } from 'librechat-data-provider'; @@ -17,9 +18,9 @@ import type { CodeOutputDownloadFallback, ProcessPublishedCodeOutputInput, } from './publication'; +import type { hasOfficeHtmlPath, getExtractedTextFormat, OfficePreviewSetting } from './extract'; import type { sanitizeArtifactPath, flattenArtifactPath } from '~/utils/files'; import type { classifyCodeArtifact, CodeArtifactCategory } from './classify'; -import type { hasOfficeHtmlPath, getExtractedTextFormat } from './extract'; import type { RetentionExpiry } from '~/files/retention'; import type { extractCodeArtifactText } from './extract'; import type { logAxiosError } from '~/utils/axios'; @@ -97,6 +98,7 @@ export interface CodeOutputProcessorDeps { category: CodeArtifactCategory; file_id: string; previewRevision?: string | null; + officePreview?: OfficePreviewSetting; }) => Promise; hasOfficeHtmlPath: typeof hasOfficeHtmlPath; sanitizeArtifactPath: typeof sanitizeArtifactPath; @@ -535,6 +537,7 @@ export async function processCodeOutput( category, file_id: file.file_id, previewRevision: file.previewRevision, + officePreview: mergeFileConfig(req.config?.fileConfig).officePreview, }), ), previewRevision: file.previewRevision, diff --git a/packages/api/src/files/documents/html.spec.ts b/packages/api/src/files/documents/html.spec.ts index e320abddf9c..a46a6682788 100644 --- a/packages/api/src/files/documents/html.spec.ts +++ b/packages/api/src/files/documents/html.spec.ts @@ -1,7 +1,14 @@ import path from 'path'; import * as fs from 'fs'; import JSZip from 'jszip'; -import { megabyte } from 'librechat-data-provider'; +import { randomBytes } from 'crypto'; +import { + megabyte, + isOfficeFileShell, + fillOfficeFileShell, + OFFICE_DOC_DATA_SLOT, + OFFICE_FILE_SHELL_MARKER, +} from 'librechat-data-provider'; import { _internal, bufferToOfficeHtml, @@ -618,6 +625,52 @@ describe('Office HTML producers', () => { * on resize never measures an already-transformed box. */ expect(html).toContain('lcNativeW'); }); + + test('initializes pptx-preview with only a width so it never boxes the deck into a fixed-height viewport', async () => { + /* Passing `height` bounded the librarys own render box, which + * produced a nested scroll region for multi-slide decks instead + * of letting the panel itself scroll to the last slide. Width + * alone is enough — the wrap+scale step above fits each slide + * to the panel. */ + const pptx = await buildPptx([{ title: 'A' }]); + const html = await _internal.pptxToHtmlViaCdn( + pptx, + '
  1. fb
', + ); + expect(html).toContain('pptxPreview.init(container, { width: SLIDE_W })'); + expect(html).not.toContain('height: SLIDE_H'); + }); + + test('wraps each rendered slide directly instead of the containers immediate children', async () => { + /* pptx-preview nests every `.pptx-preview-slide-wrapper` inside + * one library-owned `.pptx-preview-wrapper` box, so + * `container.children` only ever finds that single box — + * wrapping it as one unit jammed every slide into one shared + * block. Querying `.pptx-preview-slide-wrapper` directly finds + * each slide wherever the library actually nested it, and each + * wrap is inserted next to its own slide via `slide.parentNode` + * rather than `container`. */ + const pptx = await buildPptx([{ title: 'A' }, { title: 'B' }]); + const html = await _internal.pptxToHtmlViaCdn( + pptx, + '
  1. fb
', + ); + expect(html).toContain("container.querySelectorAll('.pptx-preview-slide-wrapper')"); + expect(html).not.toContain('container.children'); + expect(html).toContain('slide.parentNode.insertBefore(wrap, slide)'); + }); + + test('overrides the librarys own wrapper box to hug the stacked slides width with a transparent background', async () => { + const pptx = await buildPptx([{ title: 'A' }]); + const html = await _internal.pptxToHtmlViaCdn( + pptx, + '
  1. fb
', + ); + expect(html).toMatch(/\.pptx-preview-wrapper\s*\{[^}]*width:\s*auto\s*!important/); + expect(html).toMatch( + /\.pptx-preview-wrapper\s*\{[^}]*background:\s*transparent\s*!important/, + ); + }); }); describe('OFFICE_PREVIEW_DISABLE_CDN escape hatch', () => { @@ -979,3 +1032,196 @@ describe('Office HTML producers', () => { }); }); }); + +describe('office file shells', () => { + const padded = async (fixture: string, entry: string, bytes: number): Promise => { + const zip = await JSZip.loadAsync(readFixture(fixture)); + zip.file(entry, randomBytes(bytes), { compression: 'STORE' }); + return zip.generateAsync({ type: 'nodebuffer' }); + }; + const cap = _internal.OFFICE_HTML_OUTPUT_CAP; + + test('builds a pptx shell with an empty slot', async () => { + const pptx = await padded('sample.pptx', 'ppt/media/padding.bin', megabyte); + const html = await pptxToHtml(pptx, { fileShell: true }); + expect(isOfficeFileShell(html)).toBe(true); + expect(html).toContain(OFFICE_FILE_SHELL_MARKER); + expect(html.split(OFFICE_DOC_DATA_SLOT)).toHaveLength(2); + expect(html).toContain('class="lc-pptx-list"'); + expect(Buffer.byteLength(html, 'utf-8')).toBeLessThanOrEqual(cap); + }); + + test('builds a docx shell with an empty slot', async () => { + const docx = await padded('sample.docx', 'word/media/padding.bin', megabyte); + const html = await wordDocToHtml(docx, { fileShell: true }); + expect(isOfficeFileShell(html)).toBe(true); + expect(html).toContain(OFFICE_FILE_SHELL_MARKER); + expect(html.split(OFFICE_DOC_DATA_SLOT)).toHaveLength(2); + expect(html).toContain('
'); + expect(Buffer.byteLength(html, 'utf-8')).toBeLessThanOrEqual(cap); + }); + + test('keeps small files inline', async () => { + const pptx = readFixture('sample.pptx'); + const docx = readFixture('sample.docx'); + const pptxHtml = await pptxToHtml(pptx); + const docxHtml = await wordDocToHtml(docx); + expect(isOfficeFileShell(pptxHtml)).toBe(false); + expect(isOfficeFileShell(docxHtml)).toBe(false); + expect(pptxHtml).toContain(pptx.toString('base64')); + expect(docxHtml).toContain(docx.toString('base64')); + }); + + test('trims an oversized fallback in a shell', async () => { + const zip = await JSZip.loadAsync(readFixture('sample.pptx')); + const line = 'x'.repeat(3000); + const slideXml = (n: number) => + `Slide ${n}${line}`; + Object.keys(zip.files) + .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name)) + .forEach((name) => zip.remove(name)); + for (let n = 1; n <= 300; n++) { + zip.file(`ppt/slides/slide${n}.xml`, slideXml(n)); + } + const pptx = await zip.generateAsync({ type: 'nodebuffer' }); + const untrimmed = await pptxToSlideListHtml(pptx); + expect(Buffer.byteLength(untrimmed, 'utf-8')).toBeGreaterThan(cap); + + const html = await pptxToHtml(pptx, { fileShell: true }); + expect(isOfficeFileShell(html)).toBe(true); + expect(html).toContain('id="lc-fallback-notice"'); + expect(html).not.toContain('class="lc-pptx-list"'); + expect(html).not.toContain(line); + expect(Buffer.byteLength(html, 'utf-8')).toBeLessThanOrEqual(cap); + }); + const OVERSIZED_NOTICE = + 'This document is too large for the simplified preview. Download it to view the full content.'; + + const oversizedNoticeChecks = (html: string): string | undefined => { + expect(isOfficeFileShell(html)).toBe(true); + expect(html).toContain(OVERSIZED_NOTICE); + const notice = /

([^<]*)<\/p>/.exec(html)?.[1]; + expect(notice).toBe(OVERSIZED_NOTICE); + expect(notice).not.toContain('below'); + expect(html).toContain('id="lc-doc-data"'); + expect(Buffer.byteLength(html, 'utf-8')).toBeLessThanOrEqual(cap); + return notice; + }; + + test('shows a short notice when a pptx shell drops its oversized fallback', async () => { + const zip = await JSZip.loadAsync(readFixture('sample.pptx')); + const line = 'x'.repeat(3000); + Object.keys(zip.files) + .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name)) + .forEach((name) => zip.remove(name)); + for (let n = 1; n <= 300; n++) { + zip.file( + `ppt/slides/slide${n}.xml`, + `${line}`, + ); + } + const pptx = await zip.generateAsync({ type: 'nodebuffer' }); + expect(oversizedNoticeChecks(await pptxToHtml(pptx, { fileShell: true }))).toBe( + OVERSIZED_NOTICE, + ); + }); + + test('shows a short notice when a docx shell drops its oversized fallback', async () => { + const zip = await JSZip.loadAsync(readFixture('sample.docx')); + const paragraph = `${'y'.repeat(3000)}`; + const doc = await zip.file('word/document.xml')!.async('string'); + zip.file('word/document.xml', doc.replace('', `${paragraph.repeat(300)}`)); + const docx = await zip.generateAsync({ type: 'nodebuffer' }); + expect(oversizedNoticeChecks(await wordDocToHtml(docx, { fileShell: true }))).toBe( + OVERSIZED_NOTICE, + ); + }); +}); + +describe('office shell base64 encoding', () => { + const padded = async (fixture: string, entry: string): Promise => { + const zip = await JSZip.loadAsync(readFixture(fixture)); + zip.file(entry, randomBytes(400 * 1024), { compression: 'STORE' }); + return zip.generateAsync({ type: 'nodebuffer' }); + }; + const base64Calls = (spy: jest.SpyInstance): number => + spy.mock.calls.filter(([encoding]) => encoding === 'base64').length; + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(Buffer.prototype, 'toString'); + }); + afterEach(() => spy.mockRestore()); + + test('does not base64-encode a pptx or docx when building a shell', async () => { + const pptx = await padded('sample.pptx', 'ppt/media/padding.bin'); + const docx = await padded('sample.docx', 'word/media/padding.bin'); + spy.mockClear(); + await pptxToHtml(pptx, { fileShell: true }); + await wordDocToHtml(docx, { fileShell: true }); + expect(base64Calls(spy)).toBe(0); + }); + + test('still base64-encodes a small pptx and docx on the inline path', async () => { + spy.mockClear(); + await pptxToHtml(readFixture('sample.pptx')); + await wordDocToHtml(readFixture('sample.docx')); + expect(base64Calls(spy)).toBe(2); + }); +}); + +describe('office shell size and head', () => { + const padded = async (fixture: string, entry: string, bytes: number): Promise => { + const zip = await JSZip.loadAsync(readFixture(fixture)); + zip.file(entry, randomBytes(bytes), { compression: 'STORE' }); + return zip.generateAsync({ type: 'nodebuffer' }); + }; + const headOf = (html: string): string => /[\s\S]*?<\/head>/.exec(html)?.[0] ?? ''; + const cdnScripts = (head: string): string[] => head.match(/'); + }, + ); + + it('keeps a 4 MB deck shell small once its fallback is removed', async () => { + const pptx = await padded('sample.pptx', 'ppt/media/padding.bin', 4 * megabyte); + expect(pptx.length).toBeGreaterThanOrEqual(4 * megabyte); + const html = await pptxToHtml(pptx, { fileShell: true }); + expect(html).toContain('class="lc-pptx-list"'); + const bare = html.replace( + /(