mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
186b738d2d
commit
7cf6e5bbc4
5 changed files with 199 additions and 16 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue