diff --git a/client/src/common/artifacts.ts b/client/src/common/artifacts.ts index 168b0d56e3..3630ac3f24 100644 --- a/client/src/common/artifacts.ts +++ b/client/src/common/artifacts.ts @@ -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 = diff --git a/client/src/components/Artifacts/DownloadArtifact.tsx b/client/src/components/Artifacts/DownloadArtifact.tsx index b6d2873c46..4e836ba798 100644 --- a/client/src/components/Artifacts/DownloadArtifact.tsx +++ b/client/src/components/Artifacts/DownloadArtifact.tsx @@ -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) => { 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); } diff --git a/client/src/components/Artifacts/__tests__/DownloadArtifact.test.tsx b/client/src/components/Artifacts/__tests__/DownloadArtifact.test.tsx new file mode 100644 index 0000000000..8078dfbe3b --- /dev/null +++ b/client/src/components/Artifacts/__tests__/DownloadArtifact.test.tsx @@ -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: 'slide text scrape', + 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: '

hello

', +}; + +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(); + 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(); + await act(async () => { + fireEvent.click(screen.getByRole('button')); + }); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(mockFileDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/utils/__tests__/artifacts.test.ts b/client/src/utils/__tests__/artifacts.test.ts index be65a095e1..b8439008d5 100644 --- a/client/src/utils/__tests__/artifacts.test.ts +++ b/client/src/utils/__tests__/artifacts.test.ts @@ -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: 'slides', + 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 diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index b5ff9552e7..fd1de0de4f 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -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, + }, }; }