diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index bd82f8c502..ceb987c9b4 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -945,6 +945,28 @@ describe('share-scoped file routes', () => { expect(response.headers['content-disposition']).toContain('attachment'); }); + it('downloads stored text for a snapshotted text-source file', async () => { + getFiles.mockResolvedValue([{ status: 'ready', text: 'Shared extracted text' }]); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'text', + filepath: 'mistral_ocr', + type: 'application/pdf', + filename: 'report.pdf', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/download'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.headers['content-disposition']).toContain('attachment; report.pdf.txt'); + expect(response.text).toBe('Shared extracted text'); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + it('returns 500 when the backing stream fails before sending bytes', async () => { const failingStream = new Readable({ read() { diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 4f0caee52a..612c970576 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -570,6 +570,26 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { // Access already validated by fileAccess middleware const file = req.fileAccess.file; + // Text-source files store extracted content in the DB; there is no backing file to stream + if (file.source === FileSources.text) { + /** `getFiles` excludes `text` by default, so the authorized record is re-fetched by `_id` */ + const [textFile] = (await db.getFiles({ _id: file._id }, null, { text: 1 })) ?? []; + if (textFile?.text == null) { + logger.warn(`File download requested by user ${userId} has no stored text: ${file_id}`); + return res.status(404).send('No file content found'); + } + const textFilename = file.filename?.toLowerCase().endsWith('.txt') + ? file.filename + : `${file.filename || file_id}.txt`; + res.setHeader('Content-Disposition', getContentDisposition(textFilename)); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader( + 'X-File-Metadata', + encodeURIComponent(JSON.stringify(getDownloadFileMetadata(file))), + ); + return res.send(textFile.text); + } + if (checkOpenAIStorage(file.source) && !file.model) { logger.warn(`File download requested by user ${userId} has no associated model: ${file_id}`); return res.status(400).send('The model used when creating this file is not available'); @@ -642,6 +662,16 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => { fileStream.on('error', (streamError) => { logger.error('[DOWNLOAD ROUTE] Stream error:', streamError); + if (res.headersSent) { + if (!res.writableEnded) { + res.destroy(); + } + return; + } + res.removeHeader('Content-Disposition'); + res.removeHeader('Content-Type'); + res.removeHeader('X-File-Metadata'); + res.status(500).send('Error downloading file'); }); setHeaders(); diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 4fcdd3a62a..34894e2a56 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -940,6 +940,160 @@ describe('File Routes - Delete with Agent Access', () => { }), ); }); + + it('serves stored text for text-source files instead of streaming', async () => { + const userFileId = uuidv4(); + const getDownloadStream = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'screenshot.png', + filepath: FileSources.mistral_ocr, + bytes: 70, + type: 'text/plain', + source: FileSources.text, + text: 'Extracted OCR text', + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.headers['content-disposition']).toContain('screenshot.png.txt'); + expect(response.text).toBe('Extracted OCR text'); + const metadata = JSON.parse(decodeURIComponent(response.headers['x-file-metadata'])); + expect(metadata).toMatchObject({ file_id: userFileId, source: FileSources.text }); + expect(metadata).not.toHaveProperty('text'); + expect(getDownloadStream).not.toHaveBeenCalled(); + }); + + it('does not append .txt when the text-source filename already ends in .txt', async () => { + const userFileId = uuidv4(); + getStrategyFunctions.mockReturnValue({}); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'NOTES.TXT', + filepath: FileSources.mistral_ocr, + bytes: 20, + type: 'text/plain', + source: FileSources.text, + text: 'plain text notes', + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(200); + expect(response.headers['content-disposition']).toContain('filename="NOTES.TXT"'); + expect(response.headers['content-disposition']).not.toContain('NOTES.TXT.txt'); + expect(response.text).toBe('plain text notes'); + }); + + it('returns 404 for text-source files without stored text', async () => { + const userFileId = uuidv4(); + const getDownloadStream = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'empty.png', + filepath: FileSources.mistral_ocr, + bytes: 0, + type: 'text/plain', + source: FileSources.text, + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(404); + expect(response.text).toBe('No file content found'); + expect(getDownloadStream).not.toHaveBeenCalled(); + }); + + it('serves a valid empty stored-text result', async () => { + const userFileId = uuidv4(); + const getDownloadStream = jest.fn(); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'empty.txt', + filepath: '/uploads/empty.txt', + bytes: 0, + type: 'text/plain', + source: FileSources.text, + text: '', + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('text/plain'); + expect(response.text).toBe(''); + expect(getDownloadStream).not.toHaveBeenCalled(); + }); + + it('responds with 500 when the download stream errors before data is sent', async () => { + const userFileId = uuidv4(); + const erroringStream = new Readable({ + read() { + this.destroy(new Error('ENOENT: no such file or directory')); + }, + }); + const getDownloadStream = jest.fn().mockResolvedValue(erroringStream); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'gone.bin', + filepath: '/uploads/user/gone.bin', + bytes: 5, + type: 'application/octet-stream', + source: FileSources.local, + }); + + const response = await request(app).get(`/files/download/${otherUserId}/${userFileId}`); + + expect(response.status).toBe(500); + expect(response.text).toBe('Error downloading file'); + }); + + it('aborts the response when the download stream errors mid-transfer', async () => { + const userFileId = uuidv4(); + let pushed = false; + const erroringStream = new Readable({ + read() { + if (!pushed) { + pushed = true; + this.push('partial content'); + return; + } + this.destroy(new Error('read failed mid-stream')); + }, + }); + const getDownloadStream = jest.fn().mockResolvedValue(erroringStream); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'truncated.bin', + filepath: '/uploads/user/truncated.bin', + bytes: 100, + type: 'application/octet-stream', + source: FileSources.local, + }); + + await expect( + request(app).get(`/files/download/${otherUserId}/${userFileId}`), + ).rejects.toThrow(/aborted|socket hang up|ECONNRESET/i); + }); }); describe('POST /files/usage', () => { diff --git a/api/server/routes/share.js b/api/server/routes/share.js index c44ee8b7e6..c8068e11a1 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -197,7 +197,6 @@ const resolveShareFile = async (req, res, next) => { /** Stream (or redirect to) a snapshotted file from its original stored object. */ const streamSharedFile = async (req, res, file, requestedDisposition) => { const source = file.source || FileSources.local; - const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source); // An update keeps the shareId, so these URLs are stable across re-publishes. Without // revalidation a viewer's cached copy would outlive a revoked "share files" choice or a @@ -209,6 +208,22 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => { return res.status(304).end(); } + if (source === FileSources.text) { + if (req.liveFile?.text == null) { + return res.status(404).send('No file content found'); + } + const textFilename = file.filename?.toLowerCase().endsWith('.txt') + ? file.filename + : `${file.filename || file.file_id}.txt`; + const disposition = requestedDisposition === 'inline' ? 'inline' : 'attachment'; + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', getContentDisposition(textFilename, disposition)); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + return res.send(req.liveFile.text); + } + + const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source); + // Inline only safe preview types; anything else is forced to attachment. const disposition = requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment'; diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index 31502e4017..633e6250a9 100644 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -552,6 +552,7 @@ const InFlightSteer = memo(function InFlightSteer({ fileId={selectedFile?.file_id} filePath={selectedFile?.filepath} fileType={selectedFile?.type ?? undefined} + fileSource={selectedFile?.source} fileSize={(selectedFile as TFile | null)?.bytes} /> )} diff --git a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx index 79886c5d73..958be9f4ab 100644 --- a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx +++ b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import copy from 'copy-to-clipboard'; -import { useRecoilValue } from 'recoil'; import { Download } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client'; -import { useFileDownload, useSharedFileDownload } from '~/data-provider'; -import { logger, sortPagesByRelevance, triggerDownload } from '~/utils'; +import { getDownloadFilename, logger, sortPagesByRelevance, triggerDownload } from '~/utils'; +import { revokeDownloadURL, useFileDownload, useSharedFileDownload } from '~/data-provider'; +import { getFileExtension, getPreviewKind, shouldUseSharedFileDownload } from './preview'; import CopyButton from '~/components/Messages/Content/CopyButton'; import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; @@ -20,69 +21,10 @@ interface FilePreviewDialogProps { pages?: number[]; pageRelevance?: Record; fileType?: string; + fileSource?: string; fileSize?: number; } -function getFileExtension(filename: string): string { - const dot = filename.lastIndexOf('.'); - return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ''; -} - -function canPreviewByMime(mime?: string): 'pdf' | 'text' | false { - if (!mime) { - return false; - } - if (mime.includes('pdf')) { - return 'pdf'; - } - if ( - mime.startsWith('text/') || - mime.includes('json') || - mime.includes('xml') || - mime.includes('javascript') || - mime.includes('typescript') || - mime.includes('yaml') || - mime.includes('csv') - ) { - return 'text'; - } - return false; -} - -function canPreviewByExt(filename: string): 'pdf' | 'text' | false { - const ext = getFileExtension(filename); - if (ext === 'pdf') { - return 'pdf'; - } - const textExts = new Set([ - 'txt', - 'md', - 'csv', - 'json', - 'xml', - 'yaml', - 'yml', - 'html', - 'css', - 'js', - 'ts', - 'jsx', - 'tsx', - 'py', - 'rb', - 'java', - 'c', - 'cpp', - 'h', - 'go', - 'rs', - 'sh', - 'sql', - 'log', - ]); - return textExts.has(ext) ? 'text' : false; -} - /** Formats bytes with unit suffix (differs from ~/utils/formatBytes which returns a raw number). */ function formatBytes(bytes: number): string { if (bytes >= 1048576) { @@ -130,22 +72,30 @@ export default function FilePreviewDialog({ onOpenChange, fileName, fileId, - filePath, relevance, pages, pageRelevance, fileType, + fileSource, fileSize, }: FilePreviewDialogProps) { const localize = useLocalize(); const user = useRecoilValue(store.user); const { shareId } = useShareContext(); + // Preview reads revoke their blob after consumption, so they need a separate + // query identity from user-triggered downloads that may be in flight concurrently. const { refetch: downloadOwned } = useFileDownload(user?.id ?? '', fileId, { direct: false }); const { refetch: downloadShared } = useSharedFileDownload(shareId, fileId); - // Use the share route only for snapshotted files (filepath rewritten to the - // share path); otherwise fall back to the owner route. - const useShared = !!shareId && (filePath?.startsWith('/api/share/') ?? false); + const { refetch: previewOwned } = useFileDownload(user?.id ?? '', fileId, { + direct: false, + purpose: 'preview', + }); + const { refetch: previewShared } = useSharedFileDownload(shareId, fileId, 'preview'); + // A shared viewer must stay inside the share-scoped authorization boundary; + // citation and retrieval previews do not carry a rewritten filepath signal. + const useShared = shouldUseSharedFileDownload(shareId, fileId); const downloadFile = useShared ? downloadShared : downloadOwned; + const previewFile = useShared ? previewShared : previewOwned; const [fileContent, setFileContent] = useState(null); const [fileBlobUrl, setFileBlobUrl] = useState(null); @@ -154,7 +104,8 @@ export default function FilePreviewDialog({ const [isCopied, setIsCopied] = useState(false); const loadingRef = useRef(false); - const previewKind = canPreviewByMime(fileType) || canPreviewByExt(fileName); + const previewKind = getPreviewKind(fileName, fileType, fileSource); + const downloadFilename = getDownloadFilename(fileName, fileId, fileSource); const cancelledRef = useRef(false); @@ -168,16 +119,25 @@ export default function FilePreviewDialog({ setPreviewError(false); try { - const result = await downloadFile(); - if (cancelledRef.current || !result.data) { + const result = await previewFile(); + if (!result.data) { if (!cancelledRef.current) { setPreviewError(true); } return; } + if (cancelledRef.current) { + revokeDownloadURL(result.data); + return; + } - const resp = await fetch(result.data); - const blob = await resp.blob(); + let blob: Blob; + try { + const resp = await fetch(result.data); + blob = await resp.blob(); + } finally { + revokeDownloadURL(result.data); + } if (cancelledRef.current) { return; @@ -199,7 +159,7 @@ export default function FilePreviewDialog({ setLoading(false); } } - }, [fileId, previewKind, downloadFile]); + }, [fileId, previewKind, previewFile]); const handleDownload = useCallback(async () => { if (!fileId) { @@ -210,14 +170,14 @@ export default function FilePreviewDialog({ if (!result.data) { return; } - triggerDownload(result.data, fileName); + triggerDownload(result.data, downloadFilename); } catch (err) { logger.error('[FilePreviewDialog] Download failed:', err); } - }, [downloadFile, fileId, fileName]); + }, [downloadFile, downloadFilename, fileId]); useEffect(() => { - if (open && previewKind && !fileContent && !fileBlobUrl) { + if (open && previewKind && fileContent === null && !fileBlobUrl) { loadPreview(); } }, [open, previewKind, fileContent, fileBlobUrl, loadPreview]); @@ -315,7 +275,7 @@ export default function FilePreviewDialog({ className="h-[70vh] w-full rounded-lg border border-border-light" /> )} - {fileContent && ( + {fileContent !== null && ( <>
{ fileId={selectedFile?.file_id} filePath={selectedFile?.filepath} fileType={selectedFile?.type ?? undefined} + fileSource={selectedFile?.source} fileSize={(selectedFile as TFile)?.bytes} /> diff --git a/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx b/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx index 147e11cd90..d6760358df 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { useToastContext } from '@librechat/client'; import { FileSources, sharedFileDownload } from 'librechat-data-provider'; +import { getDownloadFilename, isHttpDownloadTarget, triggerDownload } from '~/utils'; import { useCodeOutputDownload, useFileDownload } from '~/data-provider'; -import { isHttpDownloadTarget, triggerDownload } from '~/utils'; import { useShareContext } from '~/Providers'; interface LogLinkProps { @@ -37,6 +37,7 @@ export const isLocallyStoredSource = (source?: string): boolean => { FileSources.s3, FileSources.cloudfront, FileSources.azure_blob, + FileSources.text, ].includes(source as FileSources); }; @@ -53,6 +54,7 @@ export const useAttachmentLink = ({ const useLocalDownload = isLocallyStoredSource(source) && !!file_id && !!user; const { refetch: downloadFromApi } = useFileDownload(user, file_id, { source }); const { refetch: downloadFromUrl } = useCodeOutputDownload(href); + const downloadFilename = getDownloadFilename(filename, file_id, source); /** * Triggers the download and reports whether a file was actually @@ -71,12 +73,12 @@ export const useAttachmentLink = ({ // permission, not owner ACL). Non-snapshotted files fall through so the // original href / code-output path still works when snapshots are disabled. if (shareId && file_id && href.startsWith('/api/share/')) { - triggerDownload(sharedFileDownload(shareId, file_id), filename); + triggerDownload(sharedFileDownload(shareId, file_id), downloadFilename); return true; } if (!useLocalDownload && isHttpDownloadTarget(href)) { - triggerDownload(href, filename); + triggerDownload(href, downloadFilename); return true; } @@ -89,7 +91,7 @@ export const useAttachmentLink = ({ }); return false; } - triggerDownload(stream.data, filename); + triggerDownload(stream.data, downloadFilename); return true; } catch (error) { console.error('Error downloading file:', error); diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx index 237235d149..dc1ab443e2 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx @@ -133,6 +133,7 @@ const SteerPart = memo(function SteerPart({ fileId={selectedFile?.file_id} filePath={selectedFile?.filepath} fileType={selectedFile?.type ?? undefined} + fileSource={selectedFile?.source} fileSize={(selectedFile as TFile | null)?.bytes} /> )} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx index f4e1257a70..8152714bc2 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx @@ -23,6 +23,12 @@ jest.mock('~/Providers', () => ({ })); jest.mock('~/utils', () => ({ + getDownloadFilename: (filename: string, fileId?: string, source?: string) => { + const resolvedFilename = filename || fileId || 'download'; + return source === 'text' && !resolvedFilename.toLowerCase().endsWith('.txt') + ? `${resolvedFilename}.txt` + : resolvedFilename; + }, isHttpDownloadTarget: (target?: string | null) => /^https?:\/\//i.test(target ?? ''), triggerDownload: (...args: Parameters) => mockTriggerDownload(...args), @@ -129,4 +135,57 @@ describe('LogLink download routing', () => { expect(mockDownloadFromUrl).toHaveBeenCalledTimes(1); expect(mockDownloadFromApi).not.toHaveBeenCalled(); }); + + it('uses a text filename for shared text-source downloads', async () => { + mockShareContext = { shareId: 'share-9' }; + const filename = 'report.pdf'; + + render( + + {filename} + , + ); + + fireEvent.click(screen.getByRole('link', { name: filename })); + + await waitFor(() => { + expect(mockTriggerDownload).toHaveBeenCalledWith( + '/api/share/share-9/files/file-1/download', + 'report.pdf.txt', + ); + }); + }); + + it('uses the authorized file route and a text filename for owned text-source files', async () => { + const filename = 'report.pdf'; + mockDownloadFromApi.mockResolvedValue({ data: 'blob:https://app.example.com/text-file' }); + + render( + + {filename} + , + ); + + fireEvent.click(screen.getByRole('link', { name: filename })); + + await waitFor(() => { + expect(mockTriggerDownload).toHaveBeenCalledWith( + 'blob:https://app.example.com/text-file', + 'report.pdf.txt', + ); + }); + expect(mockDownloadFromApi).toHaveBeenCalledTimes(1); + expect(mockDownloadFromUrl).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx index 73a1d67d20..ea596b97fe 100644 --- a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx +++ b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx @@ -23,6 +23,7 @@ interface FileSource { pageRelevance: Record; fileType?: string; fileBytes?: number; + fileSource?: string; metadata?: Record; } @@ -68,6 +69,7 @@ function extractFileSources(attachments?: TAttachment[]): FileSource[] { pageRelevance: source.pageRelevance || {}, fileType: (meta?.fileType as string) || undefined, fileBytes: (meta?.fileBytes as number) || undefined, + fileSource: (meta?.storageType as string) || undefined, metadata: meta, }); } @@ -92,6 +94,7 @@ interface DisplayResult { pageRelevance?: Record; fileType?: string; fileBytes?: number; + fileSource?: string; } interface FileMatch { @@ -99,6 +102,7 @@ interface FileMatch { fileName: string; fileType?: string; fileBytes?: number; + fileSource?: string; } function normalizeFilename(filename: string): string { @@ -142,6 +146,7 @@ function buildFileLookup( fileName: source.fileName, fileType: source.fileType, fileBytes: source.fileBytes, + fileSource: source.fileSource, }); } @@ -160,6 +165,7 @@ function buildFileLookup( fileName: file.filename, fileType: file.type ?? undefined, fileBytes: file.bytes, + fileSource: file.source ?? undefined, }); } @@ -181,6 +187,7 @@ function mergeRetrievalResults( pageRelevance: source.pageRelevance, fileType: source.fileType, fileBytes: source.fileBytes, + fileSource: source.fileSource, })); } @@ -197,6 +204,7 @@ function mergeRetrievalResults( content: result.content, fileType: match?.fileType, fileBytes: match?.fileBytes, + fileSource: match?.fileSource, }; }); } @@ -424,6 +432,7 @@ export default function RetrievalCall({ pages: result.pages, pageRelevance: result.pageRelevance, fileType: result.fileType, + fileSource: result.fileSource, }; }, [displayResults, previewIndex]); @@ -522,6 +531,7 @@ export default function RetrievalCall({ pages={previewData?.pages} pageRelevance={previewData?.pageRelevance} fileType={previewData?.fileType} + fileSource={previewData?.fileSource} />
); diff --git a/client/src/components/Chat/Messages/Content/__tests__/FilePreviewDialog.test.ts b/client/src/components/Chat/Messages/Content/__tests__/FilePreviewDialog.test.ts new file mode 100644 index 0000000000..6e8438dbb1 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/FilePreviewDialog.test.ts @@ -0,0 +1,25 @@ +import { FileSources } from 'librechat-data-provider'; +import { getPreviewKind, shouldUseSharedFileDownload } from '../preview'; +import { getDownloadFilename } from '~/utils/downloadFile'; + +describe('FilePreviewDialog text-source behavior', () => { + it('previews extracted PDF content as text', () => { + expect(getPreviewKind('report.pdf', 'application/pdf', FileSources.text)).toBe('text'); + }); + + it('downloads extracted content with a text extension', () => { + expect(getDownloadFilename('report.pdf', 'file-1', FileSources.text)).toBe('report.pdf.txt'); + expect(getDownloadFilename('notes.txt', 'file-2', FileSources.text)).toBe('notes.txt'); + }); + + it('preserves the original behavior for stored files', () => { + expect(getPreviewKind('report.pdf', 'application/pdf', FileSources.local)).toBe('pdf'); + expect(getDownloadFilename('report.pdf', 'file-3', FileSources.local)).toBe('report.pdf'); + }); + + it('routes any identified file through the share boundary in a shared view', () => { + expect(shouldUseSharedFileDownload('share-1', 'file-1')).toBe(true); + expect(shouldUseSharedFileDownload('share-1', undefined)).toBe(false); + expect(shouldUseSharedFileDownload(undefined, 'file-1')).toBe(false); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/RetrievalCall.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/RetrievalCall.test.tsx index 6df45a3210..19b8c3296d 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/RetrievalCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/RetrievalCall.test.tsx @@ -82,9 +82,19 @@ jest.mock('~/data-provider', () => ({ jest.mock('../FilePreviewDialog', () => ({ __esModule: true, - default: ({ open, fileId, fileName }: { open: boolean; fileId?: string; fileName: string }) => + default: ({ + open, + fileId, + fileName, + fileSource, + }: { + open: boolean; + fileId?: string; + fileName: string; + fileSource?: string; + }) => open ? ( -
+
{fileName}
) : null, @@ -220,6 +230,7 @@ describe('RetrievalCall - file preview resolution', () => { filename: 'Tutorial Imazing.pdf', bytes: 2048, type: 'application/pdf', + source: 'text', }, ], }); @@ -235,6 +246,7 @@ describe('RetrievalCall - file preview resolution', () => { fireEvent.click(screen.getByRole('button', { name: 'Preview: Tutorial Imazing.pdf' })); expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-id', 'file-123'); + expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-source', 'text'); }); it('keeps multiple parsed results clickable when only one attachment source is available', () => { diff --git a/client/src/components/Chat/Messages/Content/preview.ts b/client/src/components/Chat/Messages/Content/preview.ts new file mode 100644 index 0000000000..d369cbf597 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/preview.ts @@ -0,0 +1,79 @@ +import { FileSources } from 'librechat-data-provider'; + +type PreviewKind = 'pdf' | 'text' | false; + +const TEXT_EXTENSIONS = new Set([ + 'txt', + 'md', + 'csv', + 'json', + 'xml', + 'yaml', + 'yml', + 'html', + 'css', + 'js', + 'ts', + 'jsx', + 'tsx', + 'py', + 'rb', + 'java', + 'c', + 'cpp', + 'h', + 'go', + 'rs', + 'sh', + 'sql', + 'log', +]); + +export function getFileExtension(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ''; +} + +export function shouldUseSharedFileDownload(shareId?: string, fileId?: string): boolean { + return !!shareId && !!fileId; +} + +function getPreviewKindByMime(mime?: string): PreviewKind { + if (!mime) { + return false; + } + if (mime.includes('pdf')) { + return 'pdf'; + } + if ( + mime.startsWith('text/') || + mime.includes('json') || + mime.includes('xml') || + mime.includes('javascript') || + mime.includes('typescript') || + mime.includes('yaml') || + mime.includes('csv') + ) { + return 'text'; + } + return false; +} + +function getPreviewKindByExtension(filename: string): PreviewKind { + const extension = getFileExtension(filename); + if (extension === 'pdf') { + return 'pdf'; + } + return TEXT_EXTENSIONS.has(extension) ? 'text' : false; +} + +export function getPreviewKind( + fileName: string, + fileType?: string, + fileSource?: string, +): PreviewKind { + if (fileSource === FileSources.text) { + return 'text'; + } + return getPreviewKindByMime(fileType) || getPreviewKindByExtension(fileName); +} diff --git a/client/src/components/Web/Citation.tsx b/client/src/components/Web/Citation.tsx index d6661e6216..67f3ffafba 100644 --- a/client/src/components/Web/Citation.tsx +++ b/client/src/components/Web/Citation.tsx @@ -11,6 +11,7 @@ import { useLocalize } from '~/hooks'; interface FileCitationMetadata { fileBytes?: number; fileType?: string; + storageType?: string; } interface FileCitationSource { @@ -282,6 +283,7 @@ export function CompositeCitation(props: CompositeCitationProps) { pages={filePages} pageRelevance={filePageRelevance} fileType={fileMeta?.fileType} + fileSource={fileMeta?.storageType} fileSize={fileMeta?.fileBytes} /> )} @@ -358,6 +360,7 @@ export function Citation(props: CitationComponentProps) { pages={filePages} pageRelevance={filePageRelevance} fileType={fileMeta?.fileType} + fileSource={fileMeta?.storageType} fileSize={fileMeta?.fileBytes} /> )} diff --git a/client/src/components/Web/__tests__/Citation.test.tsx b/client/src/components/Web/__tests__/Citation.test.tsx index 6ec16d4c04..d25cb2d761 100644 --- a/client/src/components/Web/__tests__/Citation.test.tsx +++ b/client/src/components/Web/__tests__/Citation.test.tsx @@ -29,9 +29,19 @@ jest.mock('~/hooks', () => ({ jest.mock('~/components/Chat/Messages/Content/FilePreviewDialog', () => ({ __esModule: true, - default: ({ open, fileId, fileName }: { open: boolean; fileId?: string; fileName: string }) => + default: ({ + open, + fileId, + fileName, + fileSource, + }: { + open: boolean; + fileId?: string; + fileName: string; + fileSource?: string; + }) => open ? ( -
+
{fileName}
) : null, @@ -76,6 +86,7 @@ describe('Citation', () => { metadata: { fileBytes: 2048, fileType: 'application/pdf', + storageType: 'text', }, pageRelevance: { 1: 0.92 }, pages: [1], @@ -107,6 +118,7 @@ describe('Citation', () => { fireEvent.click(fileButton); expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-id', 'file-123'); + expect(screen.getByTestId('file-preview-dialog')).toHaveAttribute('data-file-source', 'text'); }); it('keeps standalone web citations as links', () => { diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 81f161d628..da6981571e 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -3,8 +3,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { FileSources, QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider'; import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; +import { + addFileToCache, + getDownloadFilename, + registerDownloadFilename, + unregisterDownloadFilename, +} from '~/utils'; import { isEphemeralAgent } from '~/common'; -import { addFileToCache } from '~/utils'; import store from '~/store'; export const useGetFiles = ( @@ -56,6 +61,7 @@ export const useGetFileConfig = ( type FileDownloadOptions = { source?: string | null; direct?: boolean; + purpose?: 'download' | 'preview'; }; export const isDirectDownloadSource = (source?: string | null): boolean => @@ -65,6 +71,7 @@ export const revokeDownloadURL = (url?: string | null): void => { if (!url?.startsWith('blob:')) { return; } + unregisterDownloadFilename(url); window.URL.revokeObjectURL(url); }; @@ -75,7 +82,13 @@ export const useFileDownload = ( ): QueryObserverResult => { const queryClient = useQueryClient(); return useQuery( - [QueryKeys.fileDownload, file_id, options.source ?? '', options.direct ?? true], + [ + QueryKeys.fileDownload, + file_id, + options.source ?? '', + options.direct ?? true, + options.purpose ?? 'download', + ], async () => { if (!userId || !file_id) { console.warn('No user ID provided for file download'); @@ -104,6 +117,10 @@ export const useFileDownload = ( return downloadURL; } + registerDownloadFilename( + downloadURL, + getDownloadFilename(metadata.filename, metadata.file_id, metadata.source), + ); addFileToCache(queryClient, metadata); } catch (e) { console.error('Error parsing file metadata, skipped updating file query cache', e); @@ -126,9 +143,10 @@ export const useFileDownload = ( export const useSharedFileDownload = ( shareId?: string, file_id?: string, + purpose: 'download' | 'preview' = 'download', ): QueryObserverResult => { return useQuery( - [QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? ''], + [QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? '', purpose], async () => { if (!shareId || !file_id) { return; diff --git a/client/src/utils/__tests__/downloadFile.test.ts b/client/src/utils/__tests__/downloadFile.test.ts index ed11c3df85..9d64e961ef 100644 --- a/client/src/utils/__tests__/downloadFile.test.ts +++ b/client/src/utils/__tests__/downloadFile.test.ts @@ -1,4 +1,12 @@ -import { getCodeBlockFilename, isHttpDownloadTarget, triggerDownload } from '../downloadFile'; +import { FileSources } from 'librechat-data-provider'; +import { + getCodeBlockFilename, + getDownloadFilename, + isHttpDownloadTarget, + registerDownloadFilename, + triggerDownload, + unregisterDownloadFilename, +} from '../downloadFile'; describe('downloadFile utilities', () => { let clickSpy: jest.SpyInstance; @@ -68,6 +76,50 @@ describe('downloadFile utilities', () => { jest.advanceTimersByTime(1000); expect(revokeSpy).toHaveBeenCalledWith('blob:https://app.example.com/download-id'); }); + + it('uses registered response metadata to name blob downloads', () => { + const target = 'blob:https://app.example.com/text-download'; + registerDownloadFilename(target, 'report.pdf.txt'); + + triggerDownload(target, 'report.pdf'); + + expect(appendedLink?.download).toBe('report.pdf.txt'); + }); + + it('keeps registered names available for concurrent blob downloads', () => { + const target = 'blob:https://app.example.com/concurrent-download'; + registerDownloadFilename(target, 'report.pdf.txt'); + + triggerDownload(target, 'report.pdf'); + expect(appendedLink?.download).toBe('report.pdf.txt'); + + triggerDownload(target, 'report.pdf'); + expect(appendedLink?.download).toBe('report.pdf.txt'); + }); + + it('clears registered names when blob URLs are released', () => { + const target = 'blob:https://app.example.com/released-download'; + registerDownloadFilename(target, 'report.pdf.txt'); + unregisterDownloadFilename(target); + + triggerDownload(target, 'report.pdf'); + + expect(appendedLink?.download).toBe('report.pdf'); + }); +}); + +describe('getDownloadFilename', () => { + it('adds a text extension for text-source files', () => { + expect(getDownloadFilename('report.pdf', 'file-1', FileSources.text)).toBe('report.pdf.txt'); + }); + + it('recognizes existing text extensions case-insensitively', () => { + expect(getDownloadFilename('NOTES.TXT', 'file-2', FileSources.text)).toBe('NOTES.TXT'); + }); + + it('preserves filenames for other storage sources', () => { + expect(getDownloadFilename('report.pdf', 'file-3', FileSources.local)).toBe('report.pdf'); + }); }); describe('getCodeBlockFilename', () => { diff --git a/client/src/utils/downloadFile.ts b/client/src/utils/downloadFile.ts index ddccc53a2e..73e5f99e9d 100644 --- a/client/src/utils/downloadFile.ts +++ b/client/src/utils/downloadFile.ts @@ -1,6 +1,32 @@ +import { FileSources } from 'librechat-data-provider'; + +const blobDownloadFilenames = new Map(); + export const isHttpDownloadTarget = (target?: string | null): boolean => /^https?:\/\//i.test(target ?? ''); +export function getDownloadFilename( + fileName: string, + fileId?: string, + fileSource?: string | null, +): string { + const filename = fileName || fileId || 'download'; + if (fileSource !== FileSources.text || filename.toLowerCase().endsWith('.txt')) { + return filename; + } + return `${filename}.txt`; +} + +export function registerDownloadFilename(target: string, filename: string): void { + if (target.startsWith('blob:')) { + blobDownloadFilenames.set(target, filename); + } +} + +export function unregisterDownloadFilename(target: string): void { + blobDownloadFilenames.delete(target); +} + /** * Maps a fenced-block language hint to a file extension. Used to name * downloads of chat code blocks (`code.`). Only languages whose common @@ -63,11 +89,14 @@ export function triggerDownload(target: string, filename: string): void { const isBlob = target.startsWith('blob:'); const link = document.createElement('a'); link.href = target; - link.setAttribute('download', filename); + link.setAttribute('download', blobDownloadFilenames.get(target) ?? filename); document.body.appendChild(link); link.click(); document.body.removeChild(link); if (isBlob) { - setTimeout(() => URL.revokeObjectURL(target), 1000); + setTimeout(() => { + unregisterDownloadFilename(target); + URL.revokeObjectURL(target); + }, 1000); } } diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index fbd1189445..4f75634593 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -2794,11 +2794,15 @@ describe('Share Methods', () => { expect(result?.updatedAt?.getTime()).toBe(published?.updatedAt?.getTime()); }); - test('does not snapshot transient text-source files', async () => { + test('snapshots database-backed text-source files without embedding their text', async () => { const userId = new mongoose.Types.ObjectId().toString(); const conversationId = `conv_${nanoid()}`; await seedConversation(userId, conversationId); - const textId = await createFile(userId, { source: 'text' }); + const textId = await createFile(userId, { + source: 'text', + filepath: 'mistral_ocr', + text: 'Extracted text', + }); await Message.create({ messageId: `msg_${nanoid()}`, conversationId, @@ -2810,7 +2814,20 @@ describe('Share Methods', () => { const result = await shareMethods.createSharedLink(userId, conversationId); const saved = await SharedLink.findOne({ shareId: result.shareId }).lean(); - expect(saved?.fileSnapshots ?? []).toHaveLength(0); + expect(saved?.fileSnapshots).toHaveLength(1); + expect(saved?.fileSnapshots?.[0]).toMatchObject({ + file_id: textId, + source: 'text', + filepath: 'mistral_ocr', + }); + expect(saved?.fileSnapshots?.[0]).not.toHaveProperty('text'); + + const shared = await shareMethods.getSharedMessages(result.shareId); + expect(shared?.messages[0].files?.[0]).toMatchObject({ + file_id: textId, + source: 'text', + filepath: `/api/share/${result.shareId}/files/${textId}`, + }); }); test('updateSharedLink clears snapshots when snapshotFiles is disabled', async () => { diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 87d470efc1..73f1640a86 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -135,8 +135,8 @@ function sanitizeSharedAttachments(attachments: unknown): t.SharedFile[] | undef * stream with only `storageKey`/`filepath` + the request. Sources requiring * owner-specific credentials (openai/azure assistants, execute_code, vectordb, * OCR/parser pipelines) are skipped — those files degrade to a 404 in the share - * view. `FileSources.text` is intentionally excluded: its `filepath` is a Multer - * temp path that the upload route deletes, so there is nothing durable to stream. + * view. Text-source files are eligible because the share route serves their + * database-backed extracted text instead of the deleted Multer temp path. */ const SNAPSHOT_STREAMABLE_SOURCES = new Set([ FileSources.local, @@ -144,6 +144,7 @@ const SNAPSHOT_STREAMABLE_SOURCES = new Set([ FileSources.cloudfront, FileSources.azure_blob, FileSources.firebase, + FileSources.text, ]); /** Collect `file_id`s from a message's `files`/`attachments` array into `target`. */ @@ -358,11 +359,18 @@ function applyShareFileRoute( file: t.SharedFile, shareId: string, snapshotIds: Set, + textSourceIds?: Set, ): t.SharedFile { const fileId = file.file_id; if (typeof fileId === 'string' && snapshotIds.has(fileId)) { const route = shareFileRoute(shareId, fileId); - const next: t.SharedFile = { ...file, filepath: route }; + const next: t.SharedFile = { + ...file, + filepath: route, + // General storage sources stay private, but `text` is a render semantic: + // clients must preview the database-backed payload as text, not the original MIME. + ...(textSourceIds?.has(fileId) && { source: FileSources.text }), + }; if (file.preview !== undefined) { next.preview = route; } @@ -390,6 +398,7 @@ export function anonymizeSharedContent( newMessageId: string; shareId: string; snapshotIds: Set; + textSourceIds?: Set; includeFiles: boolean; sanitizeUIResourceMarkers?: boolean; }, @@ -420,6 +429,7 @@ export function anonymizeSharedContent( }, params.shareId, params.snapshotIds, + params.textSourceIds, ), ) : undefined; @@ -456,6 +466,7 @@ function anonymizeMessages( newConvoId: string, shareId: string, snapshotIds: Set, + textSourceIds: Set, includeFiles: boolean, anonymizeMessageId: (id: string) => string, anonymizeAssistantId: (id: string) => string, @@ -481,6 +492,7 @@ function anonymizeMessages( }, shareId, snapshotIds, + textSourceIds, ), ) : undefined; @@ -496,6 +508,7 @@ function anonymizeMessages( }, shareId, snapshotIds, + textSourceIds, ), ) : undefined; @@ -517,6 +530,7 @@ function anonymizeMessages( newMessageId, shareId, snapshotIds, + textSourceIds, includeFiles, sanitizeUIResourceMarkers: message.isCreatedByUser !== true, }), @@ -830,6 +844,13 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { const snapshotIds = includeFiles ? new Set((fileSnapshots ?? []).map((snapshot) => snapshot.file_id)) : new Set(); + const textSourceIds = includeFiles + ? new Set( + (fileSnapshots ?? []) + .filter((snapshot) => snapshot.source === FileSources.text) + .map((snapshot) => snapshot.file_id), + ) + : new Set(); const result: t.SharedMessagesResult = { shareId: resolvedShareId, title: share.title, @@ -841,6 +862,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')): { newConvoId, resolvedShareId, snapshotIds, + textSourceIds, includeFiles, anonymizeMessageId, anonymizeAssistantId,