fix: download original file from artifact preview panel for office documents

The preview panel download button serialized the rendered HTML preview
instead of the original binary for office artifacts (pptx/xlsx/docx)
produced by the code interpreter, so users got an `index.html` text
scrape rather than the file. The inline chat card was unaffected because
it downloads the real file via `useAttachmentLink`.

Thread the original-file download metadata (filepath/file_id/source/user)
through `fileToArtifact` onto the Artifact, and update `DownloadArtifact`
to fetch the original file through that same path for preview-only office
artifacts. Text, source, and markdown artifacts keep the blob path so
their in-panel content (and edits) still download as-is.

Closes #14002
This commit is contained in:
Marco Beretta 2026-06-30 04:48:55 +02:00
parent 186b738d2d
commit 7cf6e5bbc4
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 199 additions and 16 deletions

View file

@ -4,6 +4,20 @@ export interface CodeBlock {
content: string;
}
/**
* Original-file download metadata for artifacts backed by a real
* code-interpreter file (e.g. an office document whose panel preview is
* a server-rendered HTML render, not the binary itself). When present,
* the panel download button fetches this file instead of serializing
* the rendered preview `content`.
*/
export interface ArtifactDownload {
filepath?: string;
file_id?: string;
source?: string;
user?: string;
}
export interface Artifact {
id: string;
lastUpdateTime: number;
@ -14,6 +28,7 @@ export interface Artifact {
content?: string;
title?: string;
type?: string;
download?: ArtifactDownload;
}
export type ArtifactFiles =

View file

@ -1,8 +1,10 @@
import React, { useState } from 'react';
import { Button } from '@librechat/client';
import { Download, CircleCheckBig } from 'lucide-react';
import type { Artifact } from '~/common';
import { Button } from '@librechat/client';
import { useAttachmentLink } from '~/components/Chat/Messages/Content/Parts/LogLink';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { isPreviewOnlyArtifact } from '~/utils/artifacts';
import { useCodeState } from '~/Providers/EditorContext';
import { useLocalize } from '~/hooks';
@ -12,23 +14,54 @@ const DownloadArtifact = ({ artifact }: { artifact: Artifact }) => {
const [isDownloaded, setIsDownloaded] = useState(false);
const { fileKey: fileName } = useArtifactProps({ artifact });
const handleDownload = () => {
/* Office artifacts (pptx/xlsx/docx) render a server-generated HTML
* preview in `content`, not the binary file serializing that blob
* would download the preview instead of the original. Fetch the real
* file through the same path the inline card uses. Source-code and
* text artifacts keep the blob path: their `content` IS the file (and
* reflects any in-panel edits). */
const { download } = artifact;
const downloadOriginalFile =
isPreviewOnlyArtifact(artifact.type) &&
(download?.filepath != null || download?.file_id != null);
const { handleDownload: downloadAttachment } = useAttachmentLink({
href: download?.filepath ?? '',
filename: artifact.title ?? fileName,
file_id: download?.file_id,
user: download?.user,
source: download?.source,
});
const markDownloaded = () => {
setIsDownloaded(true);
setTimeout(() => setIsDownloaded(false), 3000);
};
const downloadContent = () => {
const content = currentCode ?? artifact.content ?? '';
if (!content) {
return;
}
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
markDownloaded();
};
const handleDownload = async (event: React.MouseEvent<HTMLButtonElement>) => {
try {
const content = currentCode ?? artifact.content ?? '';
if (!content) {
if (downloadOriginalFile) {
await downloadAttachment(event);
markDownloaded();
return;
}
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
setIsDownloaded(true);
setTimeout(() => setIsDownloaded(false), 3000);
downloadContent();
} catch (error) {
console.error('Download failed:', error);
}

View file

@ -0,0 +1,95 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import type { Artifact } from '~/common';
import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts';
import DownloadArtifact from '../DownloadArtifact';
const mockFileDownload = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string): string =>
key,
}));
jest.mock('~/hooks/Artifacts/useArtifactProps', () => ({
__esModule: true,
default: () => ({ fileKey: 'index.html', files: {}, template: 'static', sharedProps: {} }),
}));
jest.mock('~/Providers/EditorContext', () => ({
useCodeState: () => ({ currentCode: undefined }),
}));
jest.mock('~/components/Chat/Messages/Content/Parts/LogLink', () => ({
useAttachmentLink: () => ({ handleDownload: mockFileDownload }),
}));
const officeArtifact: Artifact = {
id: 'tool-artifact-fid-1',
lastUpdateTime: 0,
type: TOOL_ARTIFACT_TYPES.PRESENTATION,
title: 'deck.pptx',
content: '<html><body>slide text scrape</body></html>',
download: {
filepath: '/api/files/code/output/deck.pptx',
file_id: 'fid-1',
source: 'execute_code',
user: 'user-1',
},
};
const htmlArtifact: Artifact = {
id: 'llm-artifact-1',
lastUpdateTime: 0,
type: TOOL_ARTIFACT_TYPES.HTML,
title: 'Authored Page',
content: '<h1>hello</h1>',
};
describe('DownloadArtifact', () => {
let createObjectURL: jest.Mock;
let revokeObjectURL: jest.Mock;
let anchorClick: jest.SpyInstance;
beforeEach(() => {
mockFileDownload.mockClear();
createObjectURL = jest.fn(() => 'blob:mock');
revokeObjectURL = jest.fn();
Object.defineProperty(window.URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
anchorClick = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
});
afterEach(() => {
anchorClick.mockRestore();
});
it('downloads the original file (not the preview) for an office artifact', async () => {
render(<DownloadArtifact artifact={officeArtifact} />);
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
expect(mockFileDownload).toHaveBeenCalledTimes(1);
// The preview HTML must NOT be serialized into a blob download.
expect(createObjectURL).not.toHaveBeenCalled();
});
it('serializes content as a blob for a non-file-backed (LLM-authored) artifact', async () => {
render(<DownloadArtifact artifact={htmlArtifact} />);
await act(async () => {
fireEvent.click(screen.getByRole('button'));
});
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(mockFileDownload).not.toHaveBeenCalled();
});
});

View file

@ -1,3 +1,5 @@
import { FileSources } from 'librechat-data-provider';
import type { ToolArtifactType } from '../artifacts';
import {
buildSandpackOptions,
detectArtifactTypeFromFile,
@ -7,7 +9,6 @@ import {
languageForFilename,
TOOL_ARTIFACT_TYPES,
} from '../artifacts';
import type { ToolArtifactType } from '../artifacts';
const TAILWIND_CDN = 'https://cdn.tailwindcss.com/3.4.17#tailwind.js';
@ -691,6 +692,31 @@ describe('fileToArtifact', () => {
expect(fileToArtifact({ ...baseFile, filename: 'flow.mmd', type: '', text: '' })).toBeNull();
});
it('threads original-file download metadata onto the artifact', () => {
/* The panel download button needs the original-file coordinates to
* fetch the real binary (e.g. a pptx) instead of serializing the
* server-rendered HTML preview. `fileToArtifact` must carry them
* through from the attachment. */
const artifact = fileToArtifact({
...baseFile,
filename: 'deck.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
text: '<html><body>slides</body></html>',
textFormat: 'html',
filepath: '/api/files/code/output/deck.pptx',
source: FileSources.execute_code,
user: 'user-1',
});
expect(artifact).not.toBeNull();
expect(artifact!.type).toBe(TOOL_ARTIFACT_TYPES.PRESENTATION);
expect(artifact!.download).toEqual({
filepath: '/api/files/code/output/deck.pptx',
file_id: 'fid-1',
source: FileSources.execute_code,
user: 'user-1',
});
});
it('uses the caller-provided placeholder when a deferred-extraction file has no text', () => {
/* Plain-text and markdown remain on the lenient empty-text gate so the
* artifact card can render a "preparing preview…" placeholder while

View file

@ -842,6 +842,8 @@ export function fileToArtifact(
| 'textFormat'
| 'updatedAt'
| 'createdAt'
| 'source'
| 'user'
>
>,
options?: FileToArtifactOptions,
@ -894,6 +896,18 @@ export function fileToArtifact(
language,
messageId: attachment.messageId ?? undefined,
lastUpdateTime: toLastUpdate(attachment),
/* Preserve the original-file download coordinates so the panel's
* download button can fetch the real file (matching the inline
* card's `useAttachmentLink` path). Critical for office buckets
* whose `content` is a server-rendered HTML preview, not the
* binary serializing `content` would hand the user the preview
* instead of the .pptx/.xlsx/.docx. */
download: {
filepath: attachment.filepath,
file_id: attachment.file_id,
source: attachment.source,
user: attachment.user,
},
};
}