📄 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

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