📄 fix: Serve Stored Text for "Upload as Text" File Downloads (#14723)

* 📄 fix: Serve Stored Text for Text-Source File Downloads

"Upload as Text" attachments store extracted content in the DB with
source 'text'; OCR uploads persist the OCR strategy name (e.g.
'mistral_ocr') as a filepath placeholder since no backing file exists.
The download route resolved these records to the local strategy and
passed the placeholder to fs.createReadStream, which failed with
ENOENT — and the response was never ended after the stream error, so
the request hung until the client timed out.

Serve the stored text directly as a .txt download for text-source
files (re-fetched by _id, as getFiles excludes 'text' by default),
and end the response on stream errors: 500 without the download
headers before headers are sent, otherwise abort the truncated
response so clients detect the failure.

* fix: Preserve text-source preview semantics

* fix: Complete text-source download coverage

* fix: Tie text downloads to blob lifecycle

* fix: Isolate preview downloads and share text snapshots

* fix: Keep shared previews in share scope

* style: Sort text download imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Oliver Faust 2026-08-21 19:24:43 +01:00 committed by GitHub
parent d602452c05
commit b399ad8370
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 622 additions and 98 deletions

View file

@ -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() {

View file

@ -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();

View file

@ -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', () => {

View file

@ -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';

View file

@ -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}
/>
)}

View file

@ -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<number, number>;
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<string | null>(null);
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(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 && (
<>
<div className="pointer-events-none sticky top-0 z-10 flex justify-end pr-1">
<CopyButton

View file

@ -48,6 +48,7 @@ const Files = ({ message }: { message?: TMessage }) => {
fileId={selectedFile?.file_id}
filePath={selectedFile?.filepath}
fileType={selectedFile?.type ?? undefined}
fileSource={selectedFile?.source}
fileSize={(selectedFile as TFile)?.bytes}
/>
</>

View file

@ -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);

View file

@ -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}
/>
)}

View file

@ -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<typeof mockTriggerDownload>) =>
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(
<LogLink
href="/api/share/share-9/files/file-1"
file_id="file-1"
filename={filename}
source={FileSources.text}
>
{filename}
</LogLink>,
);
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(
<LogLink
user="user-1"
file_id="file-1"
filename={filename}
source={FileSources.text}
href="mistral_ocr"
>
{filename}
</LogLink>,
);
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();
});
});

View file

@ -23,6 +23,7 @@ interface FileSource {
pageRelevance: Record<number, number>;
fileType?: string;
fileBytes?: number;
fileSource?: string;
metadata?: Record<string, unknown>;
}
@ -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<number, number>;
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}
/>
</div>
);

View file

@ -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);
});
});

View file

@ -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 ? (
<div data-testid="file-preview-dialog" data-file-id={fileId}>
<div data-testid="file-preview-dialog" data-file-id={fileId} data-file-source={fileSource}>
{fileName}
</div>
) : 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', () => {

View file

@ -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);
}

View file

@ -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}
/>
)}

View file

@ -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 ? (
<div data-testid="file-preview-dialog" data-file-id={fileId}>
<div data-testid="file-preview-dialog" data-file-id={fileId} data-file-source={fileSource}>
{fileName}
</div>
) : 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', () => {

View file

@ -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 = <TData = t.TFile[] | boolean>(
@ -56,6 +61,7 @@ export const useGetFileConfig = <TData = t.TFileConfig>(
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<string> => {
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<string> => {
return useQuery(
[QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? ''],
[QueryKeys.fileDownload, 'share', shareId ?? '', file_id ?? '', purpose],
async () => {
if (!shareId || !file_id) {
return;

View file

@ -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', () => {

View file

@ -1,6 +1,32 @@
import { FileSources } from 'librechat-data-provider';
const blobDownloadFilenames = new Map<string, string>();
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.<ext>`). 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);
}
}

View file

@ -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 () => {

View file

@ -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<string>([
FileSources.local,
@ -144,6 +144,7 @@ const SNAPSHOT_STREAMABLE_SOURCES = new Set<string>([
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<string>,
textSourceIds?: Set<string>,
): 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<string>;
textSourceIds?: Set<string>;
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<string>,
textSourceIds: Set<string>,
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<string>((fileSnapshots ?? []).map((snapshot) => snapshot.file_id))
: new Set<string>();
const textSourceIds = includeFiles
? new Set<string>(
(fileSnapshots ?? [])
.filter((snapshot) => snapshot.source === FileSources.text)
.map((snapshot) => snapshot.file_id),
)
: new Set<string>();
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,