mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
📄 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:
parent
d602452c05
commit
b399ad8370
21 changed files with 622 additions and 98 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
79
client/src/components/Chat/Messages/Content/preview.ts
Normal file
79
client/src/components/Chat/Messages/Content/preview.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue