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 }) =>
,
+}));
+
+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 }) =>
,
+}));
+
+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,
+ '- 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,
+ '- 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,
+ '- 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(
+ /()[\s\S]*?(<\/div>\s*
');
+ expect(bare).not.toContain('class="lc-pptx-list"');
+ expect(isOfficeFileShell(bare)).toBe(true);
+ expect(Buffer.byteLength(bare, 'utf-8')).toBeLessThanOrEqual(16 * 1024);
+ expect(Buffer.byteLength(html, 'utf-8')).toBeLessThanOrEqual(512 * 1024);
+ });
+});
diff --git a/packages/api/src/files/documents/html.ts b/packages/api/src/files/documents/html.ts
index 2ee0ff163b9..ff23d957065 100644
--- a/packages/api/src/files/documents/html.ts
+++ b/packages/api/src/files/documents/html.ts
@@ -1,5 +1,10 @@
import yauzl from 'yauzl';
-import { excelMimeTypes, megabyte } from 'librechat-data-provider';
+import {
+ excelMimeTypes,
+ megabyte,
+ OFFICE_DOC_DATA_SLOT,
+ OFFICE_FILE_SHELL_MARKER,
+} from 'librechat-data-provider';
import { tryLibreOfficePreview } from './libreoffice';
import { assertSafeZipSize } from './zipSafety';
@@ -363,6 +368,8 @@ const MAX_DOCX_CDN_BINARY_BYTES = 350 * 1024;
* `cap-mirrors-extract` test in `html.spec.ts` pins the relationship.
*/
const OFFICE_HTML_OUTPUT_CAP = 512 * 1024;
+const OFFICE_OVERSIZED_NOTICE =
+ 'This document is too large for the simplified preview. Download it to view the full content.';
/**
* Build the CDN-rendered HTML document for a DOCX. The base64 payload
@@ -377,7 +384,11 @@ const OFFICE_HTML_OUTPUT_CAP = 512 * 1024;
* for inline images), styles inline (`docx-preview` injects per-doc
* styles into `` at render time).
*/
-function buildDocxCdnDocument(base64: string, mammothFallbackHtml: string): string {
+function buildDocxCdnDocument(
+ base64: string,
+ mammothFallbackHtml: string,
+ fileShell = false,
+): string {
/* `connect-src` allows fetches to:
* - `'self'`: the sandpack-static-server origin the iframe runs in
* (covers any same-origin sourcemap fetches the bundler embedded)
@@ -415,6 +426,7 @@ function buildDocxCdnDocument(base64: string, mammothFallbackHtml: string): stri
+${fileShell ? OFFICE_FILE_SHELL_MARKER : ''}
Preview