mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📄 feat: Auto-render Text-Based Code Execution Artifacts Inline (#12829)
* 📄 feat: Auto-render Text-Based Code Execution Artifacts Inline Eagerly extract text content from non-image artifacts produced by code execution tools and render it inline in the message instead of behind a click-to-download file card. Reuses the SkillFiles binary-detection helper and the existing parseDocument dispatcher so docx, xlsx, csv, html, code, and other text-renderable formats land directly under the tool call. PPTX is intentionally classified but not yet extracted — follow-up. * 🌐 chore: Remove unused com_download_expires locale key Removed in en/translation.json so the detect-unused-i18n-keys CI check passes. The only reference was a commented-out localize() call in LogContent.tsx that was deleted in the previous commit. * 🩹 fix: Address PR review on code artifact text extraction - extract.ts: build the temp document path from a randomUUID and pass path.basename(name) as originalname so a malicious artifact name cannot escape os.tmpdir() (P1 traversal flagged by codex/Copilot). - process.js: classify and extract using safeName, not the raw name — defense in depth alongside the temp-path fix. - classify.ts: add a bare-name lookup so extensionless text artifacts (Makefile, Dockerfile, …) classify as utf8-text instead of falling through to other. - Attachment.tsx: wire aria-expanded / aria-controls on the show-all toggle for screen reader support. - LogContent.tsx: restore a download chip (LogLink) on inline-text attachments so users can still pull down the underlying file. - Tests: cover extensionless filenames and the temp-path traversal invariant. * 🩹 fix: Address comprehensive PR review on code artifact extraction - extract.ts: walk back to a UTF-8 code-point boundary before truncating so cuts cannot land mid-multibyte and emit U+FFFD (CJK/emoji concern). truncate() now accepts the original buffer to skip a redundant encode. - extract.ts: add an 8s timeout around parseDocument via Promise.race so a pathological docx/xlsx cannot stall the response path. - process.js: always set `text` (string or null) on the file payload — createFile uses findOneAndUpdate with $set semantics, so omitting the field leaves a stale value behind when an artifact's content changes. - Attachment.tsx: switch the show-all toggle from char-count threshold to a useLayoutEffect ref measurement on scrollHeight, and use overflow-hidden when collapsed (overflow-auto when expanded) so the collapsed box has a single clear interaction model. - Attachment.tsx + LogContent.tsx: lift `isImageAttachment` / `isTextAttachment` into a shared attachmentTypes module. LogContent keeps its looser image check (no width/height required) because the legacy log surface receives attachments without dimensions. - Tests: cover multi-byte boundary, the always-set-text contract on updates, and the new shared predicates. * 🧪 test: Component test for TextAttachment + direct withTimeout coverage - Attachment.tsx: re-order local imports longest-to-shortest per AGENTS.md (attachmentTypes ahead of FileContainer/Image). - extract.ts: export withTimeout so it can be unit-tested directly (it's also used internally — exporting carries no runtime cost). - extract.spec.ts: three small unit tests on withTimeout that cover resolve, propagated rejection, and timeout rejection paths with real timers. - TextAttachment.test.tsx: ten cases for the new React component — text rendering in <pre>, download chip presence/absence, ref-based collapse measurement (with scrollHeight stubbed via prototype), aria-expanded toggle, fall-through to FileAttachment for missing and empty text, and AttachmentGroup routing. * 🩹 fix: Canonicalize document MIME by extension before parseDocument When the classifier puts a file on the document path via its extension (.docx, .xlsx, …) but the buffer sniffer returned a generic value like application/zip or application/octet-stream, we previously forwarded that generic MIME to parseDocument, which dispatches strictly by MIME and silently rejected it — exactly defeating the extension-first classification this PR added. extractDocument now remaps the MIME from the extension (falling back to the original sniffed MIME if the extension is unrecognized, so files that reached the document branch via MIME detection still work). Adds a parameterized test across docx/xlsx/xls/ods/odt against zip/octet sniffs to guard the regression. * 🩹 fix: Reuse existing withTimeout from utils/promise The previous commit's local withTimeout export collided with the already-exported `withTimeout` from `~/utils/promise`, breaking the @librechat/api tsc job (TS2308 ambiguous re-export). Drops the duplicate, imports from `~/utils/promise`, and removes the now-redundant unit tests (the helper has its own coverage in utils/promise.spec.ts). The third argument shifts from a label to the fully-formed timeout error message that the existing helper expects. * 🧹 chore: TextAttachment test polish (NITs) - Use the conventional `import Attachment, { AttachmentGroup }` form rather than `default as Attachment`. - Save the original `scrollHeight` property descriptor and restore it in afterAll, so the prototype patch never leaks past this suite.
This commit is contained in:
parent
596f806f60
commit
8c073b4400
16 changed files with 1191 additions and 35 deletions
|
|
@ -23,6 +23,8 @@ jest.mock('@librechat/api', () => {
|
|||
getBasePath: jest.fn(() => ''),
|
||||
sanitizeFilename: mockSanitizeFilename,
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
classifyCodeArtifact: jest.fn(() => 'other'),
|
||||
extractCodeArtifactText: jest.fn(async () => null),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ const {
|
|||
logAxiosError,
|
||||
sanitizeFilename,
|
||||
createAxiosInstance,
|
||||
classifyCodeArtifact,
|
||||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
extractCodeArtifactText,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
|
|
@ -222,6 +224,9 @@ const processCodeOutput = async ({
|
|||
basePath: 'uploads',
|
||||
});
|
||||
|
||||
const category = classifyCodeArtifact(safeName, mimeType);
|
||||
const text = await extractCodeArtifactText(buffer, safeName, mimeType, category);
|
||||
|
||||
const file = {
|
||||
file_id,
|
||||
filepath,
|
||||
|
|
@ -238,6 +243,11 @@ const processCodeOutput = async ({
|
|||
context: FileContext.execute_code,
|
||||
usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
|
||||
createdAt: isUpdate ? claimed.createdAt : formattedDate,
|
||||
// Always set `text` explicitly (string or null) so that an update which
|
||||
// produces a binary or oversized artifact clears any previously cached
|
||||
// text — `createFile` uses findOneAndUpdate with $set semantics, which
|
||||
// would otherwise leave a stale value behind.
|
||||
text: text ?? null,
|
||||
};
|
||||
|
||||
await createFile(file, true);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ const mockAxios = jest.fn();
|
|||
mockAxios.post = jest.fn();
|
||||
mockAxios.isAxiosError = jest.fn(() => false);
|
||||
|
||||
const mockClassifyCodeArtifact = jest.fn(() => 'other');
|
||||
const mockExtractCodeArtifactText = jest.fn(async () => null);
|
||||
jest.mock('@librechat/api', () => {
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
|
@ -49,6 +51,8 @@ jest.mock('@librechat/api', () => {
|
|||
getBasePath: jest.fn(() => ''),
|
||||
sanitizeFilename: jest.fn((name) => name),
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
classifyCodeArtifact: (...args) => mockClassifyCodeArtifact(...args),
|
||||
extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
};
|
||||
|
|
@ -283,6 +287,78 @@ describe('Code Process', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('inline text extraction', () => {
|
||||
it('should populate text on the file when extractor returns content', async () => {
|
||||
const buffer = Buffer.from('hello world\n', 'utf-8');
|
||||
mockAxios.mockResolvedValue({ data: buffer });
|
||||
determineFileType.mockResolvedValue({ mime: 'text/plain' });
|
||||
mockClassifyCodeArtifact.mockReturnValueOnce('utf8-text');
|
||||
mockExtractCodeArtifactText.mockResolvedValueOnce('hello world\n');
|
||||
|
||||
const result = await processCodeOutput({ ...baseParams, name: 'note.txt' });
|
||||
|
||||
expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('note.txt', 'text/plain');
|
||||
expect(mockExtractCodeArtifactText).toHaveBeenCalledWith(
|
||||
buffer,
|
||||
'note.txt',
|
||||
'text/plain',
|
||||
'utf8-text',
|
||||
);
|
||||
expect(result.text).toBe('hello world\n');
|
||||
expect(createFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: 'hello world\n' }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set text to null when extractor returns null so updates clear stale values', async () => {
|
||||
const buffer = Buffer.alloc(100);
|
||||
mockAxios.mockResolvedValue({ data: buffer });
|
||||
determineFileType.mockResolvedValue({ mime: 'application/octet-stream' });
|
||||
mockClassifyCodeArtifact.mockReturnValueOnce('other');
|
||||
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await processCodeOutput({ ...baseParams, name: 'archive.zip' });
|
||||
|
||||
expect(result.text).toBeNull();
|
||||
const createCall = createFile.mock.calls[0][0];
|
||||
expect(createCall.text).toBeNull();
|
||||
});
|
||||
|
||||
it('should overwrite a previously-stored text value when re-emitting a now-binary file', async () => {
|
||||
// Same filename + conversationId already has a stored text value;
|
||||
// claimCodeFile returns the existing record (isUpdate path).
|
||||
mockClaimCodeFile.mockResolvedValueOnce({
|
||||
file_id: 'existing-id',
|
||||
filename: 'output.bin',
|
||||
usage: 1,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
const binaryBuffer = Buffer.from([0x00, 0xff, 0x00, 0xff]);
|
||||
mockAxios.mockResolvedValue({ data: binaryBuffer });
|
||||
determineFileType.mockResolvedValue({ mime: 'application/octet-stream' });
|
||||
mockClassifyCodeArtifact.mockReturnValueOnce('other');
|
||||
mockExtractCodeArtifactText.mockResolvedValueOnce(null);
|
||||
|
||||
await processCodeOutput({ ...baseParams, name: 'output.bin' });
|
||||
|
||||
// null (not omitted) so $set clears any prior `text` value.
|
||||
const createCall = createFile.mock.calls[0][0];
|
||||
expect(createCall).toHaveProperty('text', null);
|
||||
});
|
||||
|
||||
it('should not invoke text extraction for image files', async () => {
|
||||
const imageBuffer = Buffer.alloc(500);
|
||||
mockAxios.mockResolvedValue({ data: imageBuffer });
|
||||
convertImage.mockResolvedValue({ filepath: '/uploads/x.webp', bytes: 400 });
|
||||
|
||||
await processCodeOutput({ ...baseParams, name: 'chart.png' });
|
||||
|
||||
expect(mockClassifyCodeArtifact).not.toHaveBeenCalled();
|
||||
expect(mockExtractCodeArtifactText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file size limit enforcement', () => {
|
||||
it('should fallback to download URL when file exceeds size limit', async () => {
|
||||
// Set a small file size limit for this test
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { memo, useState, useEffect } from 'react';
|
||||
import { imageExtRegex, Tools } from 'librechat-data-provider';
|
||||
import { memo, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import { isImageAttachment, isTextAttachment } from './attachmentTypes';
|
||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import { useAttachmentLink } from './LogLink';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const COLLAPSED_MAX_HEIGHT = 320;
|
||||
|
||||
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const file = attachment as TFile & TAttachmentMetadata;
|
||||
|
|
@ -50,6 +54,92 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
|||
);
|
||||
});
|
||||
|
||||
const TextAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
||||
const localize = useLocalize();
|
||||
const preId = useId();
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Decided once after layout: does the text actually overflow the collapsed
|
||||
// height? Char count is a poor proxy (a 100-char file with many newlines can
|
||||
// overflow; 800 chars of dense single-line text may not), so we measure.
|
||||
const [overflowed, setOverflowed] = useState(false);
|
||||
const file = attachment as TFile & TAttachmentMetadata;
|
||||
const { handleDownload } = useAttachmentLink({
|
||||
href: attachment.filepath ?? '',
|
||||
filename: attachment.filename ?? '',
|
||||
file_id: file.file_id,
|
||||
user: file.user,
|
||||
source: file.source,
|
||||
});
|
||||
const extension = attachment.filename?.split('.').pop();
|
||||
const text = file.text ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = preRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
setOverflowed(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 1);
|
||||
}, [text]);
|
||||
|
||||
const isClamped = overflowed && !expanded;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-attachment-container flex w-full flex-col gap-1.5',
|
||||
'transition-all duration-300 ease-out',
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
{attachment.filepath && (
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
)}
|
||||
<div className="rounded-lg bg-surface-secondary p-4">
|
||||
<pre
|
||||
id={preId}
|
||||
ref={preRef}
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words font-mono text-sm leading-6 text-text-primary',
|
||||
isClamped ? 'overflow-hidden' : 'overflow-auto',
|
||||
)}
|
||||
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
{overflowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={preId}
|
||||
className="mt-2 text-xs text-text-secondary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
>
|
||||
{expanded ? localize('com_ui_collapse') : localize('com_ui_show_all')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
|
|
@ -92,14 +182,13 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
|
|||
return null;
|
||||
}
|
||||
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage = attachment.filename
|
||||
? imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null
|
||||
: false;
|
||||
|
||||
if (isImage) {
|
||||
if (isImageAttachment(attachment)) {
|
||||
return <ImageAttachment attachment={attachment} />;
|
||||
} else if (!attachment.filepath) {
|
||||
}
|
||||
if (isTextAttachment(attachment)) {
|
||||
return <TextAttachment attachment={attachment} />;
|
||||
}
|
||||
if (!attachment.filepath) {
|
||||
return null;
|
||||
}
|
||||
return <FileAttachment attachment={attachment} />;
|
||||
|
|
@ -112,21 +201,21 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }
|
|||
|
||||
const fileAttachments: TAttachment[] = [];
|
||||
const imageAttachments: TAttachment[] = [];
|
||||
const textAttachments: TAttachment[] = [];
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage = attachment.filename
|
||||
? imageExtRegex.test(attachment.filename) &&
|
||||
width != null &&
|
||||
height != null &&
|
||||
filepath != null
|
||||
: false;
|
||||
|
||||
if (isImage) {
|
||||
imageAttachments.push(attachment);
|
||||
} else if (attachment.type !== Tools.web_search) {
|
||||
fileAttachments.push(attachment);
|
||||
if (attachment.type === Tools.web_search) {
|
||||
return;
|
||||
}
|
||||
if (isImageAttachment(attachment)) {
|
||||
imageAttachments.push(attachment);
|
||||
return;
|
||||
}
|
||||
if (isTextAttachment(attachment)) {
|
||||
textAttachments.push(attachment);
|
||||
return;
|
||||
}
|
||||
fileAttachments.push(attachment);
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -140,6 +229,13 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{textAttachments.length > 0 && (
|
||||
<div className="my-2 flex flex-col gap-3">
|
||||
{textAttachments.map((attachment, index) => (
|
||||
<TextAttachment attachment={attachment} key={`text-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap items-center">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
|
|||
import { imageExtRegex } from 'librechat-data-provider';
|
||||
import type { TFile, TAttachment, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import { isTextAttachment } from './attachmentTypes';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import LogLink from './LogLink';
|
||||
|
||||
|
|
@ -26,23 +27,32 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
|||
return parts[0].trim();
|
||||
}, [output]);
|
||||
|
||||
const { imageAttachments, nonImageAttachments } = useMemo(() => {
|
||||
const { imageAttachments, textAttachments, nonInlineAttachments } = useMemo(() => {
|
||||
const imageAtts: ImageAttachment[] = [];
|
||||
const nonImageAtts: TAttachment[] = [];
|
||||
const textAtts: Array<TFile & TAttachmentMetadata> = [];
|
||||
const otherAtts: TAttachment[] = [];
|
||||
|
||||
attachments?.forEach((attachment) => {
|
||||
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const fileData = attachment as TFile & TAttachmentMetadata;
|
||||
const { filepath = null } = fileData;
|
||||
// LogContent uses a looser image check than Attachment.tsx (no
|
||||
// width/height requirement) to keep parity with the legacy log surface.
|
||||
const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null;
|
||||
if (isImage) {
|
||||
imageAtts.push(attachment as ImageAttachment);
|
||||
} else {
|
||||
nonImageAtts.push(attachment);
|
||||
return;
|
||||
}
|
||||
if (isTextAttachment(attachment)) {
|
||||
textAtts.push(fileData);
|
||||
return;
|
||||
}
|
||||
otherAtts.push(attachment);
|
||||
});
|
||||
|
||||
return {
|
||||
imageAttachments: renderImages === true ? imageAtts : null,
|
||||
nonImageAttachments: nonImageAtts,
|
||||
textAttachments: textAtts,
|
||||
nonInlineAttachments: otherAtts,
|
||||
};
|
||||
}, [attachments, renderImages]);
|
||||
|
||||
|
|
@ -60,10 +70,6 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
|||
const fileData = file as TFile & TAttachmentMetadata;
|
||||
const filepath = file.filepath || '';
|
||||
|
||||
// const expirationText = expiresAt
|
||||
// ? ` ${localize('com_download_expires', { 0: format(expiresAt, 'MM/dd/yy HH:mm') })}`
|
||||
// : ` ${localize('com_click_to_download')}`;
|
||||
|
||||
return (
|
||||
<LogLink
|
||||
href={filepath}
|
||||
|
|
@ -81,17 +87,48 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
|||
return (
|
||||
<>
|
||||
{processedContent && <div>{processedContent}</div>}
|
||||
{nonImageAttachments.length > 0 && (
|
||||
{nonInlineAttachments.length > 0 && (
|
||||
<div>
|
||||
<p>{localize('com_generated_files')}</p>
|
||||
{nonImageAttachments.map((file, index) => (
|
||||
{nonInlineAttachments.map((file, index) => (
|
||||
<React.Fragment key={file.filepath}>
|
||||
{renderAttachment(file)}
|
||||
{index < nonImageAttachments.length - 1 && ', '}
|
||||
{index < nonInlineAttachments.length - 1 && ', '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{textAttachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
{textAttachments.map((file) => (
|
||||
<div
|
||||
key={file.filepath ?? file.file_id ?? file.filename}
|
||||
className="rounded-lg bg-surface-secondary p-3"
|
||||
>
|
||||
{file.filename && (
|
||||
<div className="mb-1 truncate text-[10px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{file.filepath ? (
|
||||
<LogLink
|
||||
href={file.filepath}
|
||||
filename={file.filename}
|
||||
file_id={file.file_id}
|
||||
user={file.user}
|
||||
source={file.source}
|
||||
>
|
||||
{file.filename}
|
||||
</LogLink>
|
||||
) : (
|
||||
file.filename
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5 text-text-primary">
|
||||
{file.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments?.map((attachment) => (
|
||||
<Image
|
||||
width={attachment.width}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import Attachment, { AttachmentGroup } from '../Attachment';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
() =>
|
||||
(key: string): string => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_show_all: 'Show all',
|
||||
com_ui_collapse: 'Collapse',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHandleDownload = jest.fn();
|
||||
jest.mock('../LogLink', () => ({
|
||||
useAttachmentLink: () => ({ handleDownload: mockHandleDownload }),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Input/Files/FileContainer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ file, onClick }: { file: { filename?: string }; onClick?: () => void }) => (
|
||||
<button type="button" data-testid="file-container" onClick={onClick}>
|
||||
{file.filename ?? ''}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
|
||||
__esModule: true,
|
||||
default: ({ altText }: { altText?: string }) => <img alt={altText ?? ''} data-testid="image" />,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const textAttachment = (overrides: Partial<TAttachment> = {}): TAttachment =>
|
||||
({
|
||||
file_id: 'file-1',
|
||||
filename: 'output.csv',
|
||||
filepath: '/files/output.csv',
|
||||
type: 'text/csv',
|
||||
text: 'a,b,c\n1,2,3',
|
||||
...overrides,
|
||||
}) as TAttachment;
|
||||
|
||||
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLPreElement.prototype,
|
||||
'scrollHeight',
|
||||
);
|
||||
|
||||
const setScrollHeight = (value: number) => {
|
||||
Object.defineProperty(HTMLPreElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const restoreScrollHeight = () => {
|
||||
if (originalScrollHeightDescriptor) {
|
||||
Object.defineProperty(HTMLPreElement.prototype, 'scrollHeight', originalScrollHeightDescriptor);
|
||||
} else {
|
||||
// No own descriptor existed before — delete the override so the prototype
|
||||
// chain falls back to the inherited HTMLElement implementation.
|
||||
|
||||
delete (HTMLPreElement.prototype as any).scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
afterAll(() => {
|
||||
restoreScrollHeight();
|
||||
});
|
||||
|
||||
describe('TextAttachment (via Attachment default export)', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleDownload.mockReset();
|
||||
setScrollHeight(0);
|
||||
});
|
||||
|
||||
it('renders the text content inside a <pre>', () => {
|
||||
const { container } = render(<Attachment attachment={textAttachment()} />);
|
||||
const pre = container.querySelector('pre');
|
||||
expect(pre).not.toBeNull();
|
||||
expect(pre!.textContent).toBe('a,b,c\n1,2,3');
|
||||
});
|
||||
|
||||
it('renders a download chip when filepath is present', () => {
|
||||
render(<Attachment attachment={textAttachment()} />);
|
||||
expect(screen.getByTestId('file-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the download chip when filepath is absent', () => {
|
||||
render(<Attachment attachment={textAttachment({ filepath: '' })} />);
|
||||
expect(screen.queryByTestId('file-container')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a show/collapse button when content fits', () => {
|
||||
setScrollHeight(100); // < COLLAPSED_MAX_HEIGHT (320)
|
||||
render(<Attachment attachment={textAttachment()} />);
|
||||
expect(screen.queryByRole('button', { name: /show all|collapse/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an expand button with aria-expanded=false when content overflows', () => {
|
||||
setScrollHeight(800); // > COLLAPSED_MAX_HEIGHT (320)
|
||||
render(<Attachment attachment={textAttachment()} />);
|
||||
const button = screen.getByRole('button', { name: 'Show all' });
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(button).toHaveAttribute('aria-controls');
|
||||
});
|
||||
|
||||
it('toggles aria-expanded and label when the button is clicked', () => {
|
||||
setScrollHeight(800);
|
||||
render(<Attachment attachment={textAttachment()} />);
|
||||
const button = screen.getByRole('button', { name: 'Show all' });
|
||||
act(() => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
const expanded = screen.getByRole('button', { name: 'Collapse' });
|
||||
expect(expanded).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('falls through to FileAttachment when text is missing', () => {
|
||||
const noText = textAttachment({ text: undefined as unknown as string });
|
||||
render(<Attachment attachment={noText} />);
|
||||
// FileAttachment also renders the FileContainer mock — we assert the
|
||||
// <pre> is absent to confirm the text branch was not taken.
|
||||
expect(screen.queryByText('a,b,c\n1,2,3')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('file-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls through to FileAttachment when text is the empty string', () => {
|
||||
const empty = textAttachment({ text: '' });
|
||||
const { container } = render(<Attachment attachment={empty} />);
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
expect(screen.getByTestId('file-container')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttachmentGroup', () => {
|
||||
beforeEach(() => {
|
||||
setScrollHeight(0);
|
||||
});
|
||||
|
||||
it('routes text-bearing attachments through the text rendering path', () => {
|
||||
const attachments = [textAttachment({ file_id: 'a', filename: 'a.txt' })] as TAttachment[];
|
||||
const { container } = render(<AttachmentGroup attachments={attachments} />);
|
||||
expect(container.querySelector('pre')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('routes plain-file attachments to the FileAttachment branch', () => {
|
||||
const attachments = [
|
||||
textAttachment({
|
||||
file_id: 'b',
|
||||
filename: 'archive.zip',
|
||||
type: 'application/zip',
|
||||
text: undefined as unknown as string,
|
||||
}),
|
||||
] as TAttachment[];
|
||||
const { container } = render(<AttachmentGroup attachments={attachments} />);
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import { isImageAttachment, isTextAttachment } from '../attachmentTypes';
|
||||
|
||||
const baseAttachment = (overrides: Partial<TAttachment> = {}): TAttachment =>
|
||||
({
|
||||
file_id: 'file-1',
|
||||
filename: 'unset',
|
||||
filepath: '/files/file-1',
|
||||
type: 'application/octet-stream',
|
||||
...overrides,
|
||||
}) as TAttachment;
|
||||
|
||||
describe('isImageAttachment', () => {
|
||||
it('returns true for image filenames with width, height, and filepath', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'chart.png',
|
||||
width: 800,
|
||||
height: 600,
|
||||
filepath: '/files/chart.png',
|
||||
} as Partial<TAttachment>);
|
||||
expect(isImageAttachment(attachment)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when filename is missing', () => {
|
||||
const attachment = baseAttachment({ filename: undefined as unknown as string });
|
||||
expect(isImageAttachment(attachment)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-image extensions', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'notes.txt',
|
||||
width: 800,
|
||||
height: 600,
|
||||
} as Partial<TAttachment>);
|
||||
expect(isImageAttachment(attachment)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when width is missing', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'chart.png',
|
||||
height: 600,
|
||||
} as Partial<TAttachment>);
|
||||
expect(isImageAttachment(attachment)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when height is missing', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'chart.png',
|
||||
width: 800,
|
||||
} as Partial<TAttachment>);
|
||||
expect(isImageAttachment(attachment)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when filepath is null', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'chart.png',
|
||||
width: 800,
|
||||
height: 600,
|
||||
filepath: null as unknown as string,
|
||||
} as Partial<TAttachment>);
|
||||
expect(isImageAttachment(attachment)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTextAttachment', () => {
|
||||
it('returns true when text is a non-empty string', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'output.csv',
|
||||
text: 'a,b,c\n1,2,3',
|
||||
} as Partial<TAttachment>);
|
||||
expect(isTextAttachment(attachment)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when text is missing', () => {
|
||||
expect(isTextAttachment(baseAttachment({ filename: 'output.csv' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when text is an empty string', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'empty.txt',
|
||||
text: '',
|
||||
} as Partial<TAttachment>);
|
||||
expect(isTextAttachment(attachment)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when text is non-string (e.g. null)', () => {
|
||||
const attachment = baseAttachment({
|
||||
filename: 'broken.txt',
|
||||
text: null as unknown as string,
|
||||
} as Partial<TAttachment>);
|
||||
expect(isTextAttachment(attachment)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { imageExtRegex } from 'librechat-data-provider';
|
||||
import type { TAttachment, TAttachmentMetadata, TFile } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* An attachment is treated as an image only when it has the dimensions and
|
||||
* filepath needed to render via `<Image>`. Without width/height the image
|
||||
* cannot reserve layout space, so we fall back to the file card.
|
||||
*/
|
||||
export const isImageAttachment = (attachment: TAttachment): boolean => {
|
||||
if (!attachment.filename) {
|
||||
return false;
|
||||
}
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
return (
|
||||
imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An attachment renders inline as text when the backend has populated a
|
||||
* non-empty `text` field on the underlying file record. Empty strings are
|
||||
* treated as "no inline text available" and fall through to the download UI.
|
||||
*/
|
||||
export const isTextAttachment = (attachment: TAttachment): boolean => {
|
||||
const { text } = attachment as TFile & TAttachmentMetadata;
|
||||
return typeof text === 'string' && text.length > 0;
|
||||
};
|
||||
|
|
@ -220,7 +220,6 @@
|
|||
"com_citation_source": "Source",
|
||||
"com_click_to_download": "(click here to download)",
|
||||
"com_download_expired": "(download expired)",
|
||||
"com_download_expires": "(click here to download - expires {{0}})",
|
||||
"com_endpoint": "Endpoint",
|
||||
"com_endpoint_agent": "Agent",
|
||||
"com_endpoint_agent_placeholder": "Please select an Agent",
|
||||
|
|
|
|||
142
packages/api/src/files/code/classify.spec.ts
Normal file
142
packages/api/src/files/code/classify.spec.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { classifyCodeArtifact } from './classify';
|
||||
|
||||
describe('classifyCodeArtifact', () => {
|
||||
describe('utf8-text by extension', () => {
|
||||
it.each([
|
||||
['report.txt', 'application/octet-stream'],
|
||||
['notes.md', 'application/octet-stream'],
|
||||
['data.csv', 'application/octet-stream'],
|
||||
['rows.tsv', 'application/octet-stream'],
|
||||
['payload.json', 'application/octet-stream'],
|
||||
['stream.jsonl', 'application/octet-stream'],
|
||||
['config.yaml', 'application/octet-stream'],
|
||||
['feed.xml', 'application/octet-stream'],
|
||||
['index.html', 'application/octet-stream'],
|
||||
['icon.svg', 'application/octet-stream'],
|
||||
['build.log', 'application/octet-stream'],
|
||||
['server.py', 'application/octet-stream'],
|
||||
['handler.ts', 'application/octet-stream'],
|
||||
['app.tsx', 'application/octet-stream'],
|
||||
['main.go', 'application/octet-stream'],
|
||||
['lib.rs', 'application/octet-stream'],
|
||||
['Service.java', 'application/octet-stream'],
|
||||
['query.sql', 'application/octet-stream'],
|
||||
['schema.graphql', 'application/octet-stream'],
|
||||
['Makefile.txt', 'application/octet-stream'],
|
||||
])('classifies %s as utf8-text', (name, mime) => {
|
||||
expect(classifyCodeArtifact(name, mime)).toBe('utf8-text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('utf8-text by MIME', () => {
|
||||
it.each([
|
||||
['unknown', 'text/plain'],
|
||||
['unknown', 'text/csv'],
|
||||
['unknown', 'application/json'],
|
||||
['unknown', 'application/xml'],
|
||||
['unknown', 'application/javascript'],
|
||||
['unknown', 'image/svg+xml'],
|
||||
])('classifies %s with mime %s as utf8-text', (name, mime) => {
|
||||
expect(classifyCodeArtifact(name, mime)).toBe('utf8-text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document by extension', () => {
|
||||
it.each([
|
||||
['report.docx', 'application/octet-stream'],
|
||||
['data.xlsx', 'application/octet-stream'],
|
||||
['legacy.xls', 'application/octet-stream'],
|
||||
['sheet.ods', 'application/octet-stream'],
|
||||
['notes.odt', 'application/octet-stream'],
|
||||
])('classifies %s as document', (name, mime) => {
|
||||
expect(classifyCodeArtifact(name, mime)).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document by MIME', () => {
|
||||
it('classifies docx mime', () => {
|
||||
expect(
|
||||
classifyCodeArtifact(
|
||||
'unknown',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
),
|
||||
).toBe('document');
|
||||
});
|
||||
|
||||
it('classifies xlsx mime', () => {
|
||||
expect(
|
||||
classifyCodeArtifact(
|
||||
'unknown',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
),
|
||||
).toBe('document');
|
||||
});
|
||||
|
||||
it('classifies ods mime', () => {
|
||||
expect(
|
||||
classifyCodeArtifact('unknown', 'application/vnd.oasis.opendocument.spreadsheet'),
|
||||
).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pptx', () => {
|
||||
it('classifies .pptx by extension', () => {
|
||||
expect(classifyCodeArtifact('slides.pptx', 'application/octet-stream')).toBe('pptx');
|
||||
});
|
||||
|
||||
it('classifies pptx by mime', () => {
|
||||
expect(
|
||||
classifyCodeArtifact(
|
||||
'unknown',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
),
|
||||
).toBe('pptx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('other', () => {
|
||||
it.each([
|
||||
['photo.png', 'image/png'],
|
||||
['archive.zip', 'application/zip'],
|
||||
['binary.exe', 'application/octet-stream'],
|
||||
['noext', 'application/octet-stream'],
|
||||
['', ''],
|
||||
['trailing.dot.', 'application/octet-stream'],
|
||||
])('classifies %s as other', (name, mime) => {
|
||||
expect(classifyCodeArtifact(name, mime)).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension wins over MIME', () => {
|
||||
it('treats .py as utf8-text even when MIME is octet-stream', () => {
|
||||
expect(classifyCodeArtifact('script.py', 'application/octet-stream')).toBe('utf8-text');
|
||||
});
|
||||
|
||||
it('treats .docx as document even when MIME is octet-stream', () => {
|
||||
expect(classifyCodeArtifact('letter.docx', 'application/octet-stream')).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
it('is case-insensitive on extensions', () => {
|
||||
expect(classifyCodeArtifact('PHOTO.PNG', 'application/octet-stream')).toBe('other');
|
||||
expect(classifyCodeArtifact('REPORT.DOCX', 'application/octet-stream')).toBe('document');
|
||||
expect(classifyCodeArtifact('SCRIPT.PY', 'application/octet-stream')).toBe('utf8-text');
|
||||
});
|
||||
|
||||
describe('extensionless filenames', () => {
|
||||
it.each([
|
||||
['Makefile', 'application/octet-stream'],
|
||||
['makefile', 'application/octet-stream'],
|
||||
['MAKEFILE', 'application/octet-stream'],
|
||||
['Dockerfile', 'application/octet-stream'],
|
||||
['dockerfile', 'application/octet-stream'],
|
||||
['/tmp/Dockerfile', 'application/octet-stream'],
|
||||
])('matches %s as utf8-text', (name, mime) => {
|
||||
expect(classifyCodeArtifact(name, mime)).toBe('utf8-text');
|
||||
});
|
||||
|
||||
it('falls through to other for unknown bare names', () => {
|
||||
expect(classifyCodeArtifact('mystery', 'application/octet-stream')).toBe('other');
|
||||
});
|
||||
});
|
||||
});
|
||||
193
packages/api/src/files/code/classify.ts
Normal file
193
packages/api/src/files/code/classify.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { excelMimeTypes } from 'librechat-data-provider';
|
||||
|
||||
export type CodeArtifactCategory = 'utf8-text' | 'document' | 'pptx' | 'other';
|
||||
|
||||
const UTF8_TEXT_EXTENSIONS = new Set<string>([
|
||||
// plaintext / data
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'rst',
|
||||
'csv',
|
||||
'tsv',
|
||||
'json',
|
||||
'jsonl',
|
||||
'ndjson',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'log',
|
||||
'html',
|
||||
'htm',
|
||||
'svg',
|
||||
'env',
|
||||
// shell / scripts
|
||||
'sh',
|
||||
'bash',
|
||||
'zsh',
|
||||
'fish',
|
||||
'ps1',
|
||||
'bat',
|
||||
'cmd',
|
||||
// web
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'jsx',
|
||||
'ts',
|
||||
'tsx',
|
||||
'css',
|
||||
'scss',
|
||||
'sass',
|
||||
'less',
|
||||
'vue',
|
||||
'svelte',
|
||||
// popular languages
|
||||
'py',
|
||||
'pyi',
|
||||
'ipynb',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'java',
|
||||
'kt',
|
||||
'kts',
|
||||
'scala',
|
||||
'c',
|
||||
'h',
|
||||
'cc',
|
||||
'cpp',
|
||||
'hpp',
|
||||
'cs',
|
||||
'm',
|
||||
'mm',
|
||||
'swift',
|
||||
'php',
|
||||
'pl',
|
||||
'pm',
|
||||
'r',
|
||||
'jl',
|
||||
'lua',
|
||||
'dart',
|
||||
'ex',
|
||||
'exs',
|
||||
'erl',
|
||||
'hs',
|
||||
'clj',
|
||||
'cljs',
|
||||
'fs',
|
||||
'fsx',
|
||||
// data / build / config
|
||||
'sql',
|
||||
'graphql',
|
||||
'gql',
|
||||
'proto',
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'gradle',
|
||||
'tf',
|
||||
'hcl',
|
||||
'patch',
|
||||
'diff',
|
||||
]);
|
||||
|
||||
const UTF8_TEXT_MIME_PREFIXES = ['text/'] as const;
|
||||
const UTF8_TEXT_MIME_EXACT = new Set<string>([
|
||||
'application/json',
|
||||
'application/ld+json',
|
||||
'application/xml',
|
||||
'application/x-yaml',
|
||||
'application/yaml',
|
||||
'application/x-sh',
|
||||
'application/javascript',
|
||||
'application/x-javascript',
|
||||
'application/typescript',
|
||||
'application/x-typescript',
|
||||
'application/x-httpd-php',
|
||||
'application/sql',
|
||||
'application/graphql',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
|
||||
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
||||
|
||||
const DOCUMENT_EXTENSIONS = new Set<string>(['docx', 'odt', 'xlsx', 'xls', 'ods']);
|
||||
const PPTX_EXTENSIONS = new Set<string>(['pptx']);
|
||||
|
||||
const extensionOf = (name: string): string => {
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot < 0 || dot === name.length - 1) {
|
||||
return '';
|
||||
}
|
||||
return name.slice(dot + 1).toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Lookup key for extensionless filenames (Makefile, Dockerfile, etc.).
|
||||
* Matches against the same UTF8_TEXT_EXTENSIONS set so familiar bare names
|
||||
* still get inline text rendering.
|
||||
*/
|
||||
const bareNameOf = (name: string): string => {
|
||||
if (name.includes('.')) {
|
||||
return '';
|
||||
}
|
||||
const slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
|
||||
return name.slice(slash + 1).toLowerCase();
|
||||
};
|
||||
|
||||
const isUtf8TextMime = (mime: string): boolean => {
|
||||
if (UTF8_TEXT_MIME_EXACT.has(mime)) {
|
||||
return true;
|
||||
}
|
||||
for (const prefix of UTF8_TEXT_MIME_PREFIXES) {
|
||||
if (mime.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isDocumentMime = (mime: string): boolean => {
|
||||
if (mime === DOCX_MIME || mime === ODT_MIME) {
|
||||
return true;
|
||||
}
|
||||
return excelMimeTypes.test(mime) || mime === 'application/vnd.oasis.opendocument.spreadsheet';
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide how to render a file produced by the code-execution sandbox.
|
||||
* Extension wins over MIME for code/text files because content sniffing tends
|
||||
* to label `.py`/`.json`/`.csv` as `application/octet-stream`.
|
||||
*/
|
||||
export function classifyCodeArtifact(name: string, mimeType: string): CodeArtifactCategory {
|
||||
const ext = extensionOf(name);
|
||||
if (ext && UTF8_TEXT_EXTENSIONS.has(ext)) {
|
||||
return 'utf8-text';
|
||||
}
|
||||
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
|
||||
return 'document';
|
||||
}
|
||||
if (ext && PPTX_EXTENSIONS.has(ext)) {
|
||||
return 'pptx';
|
||||
}
|
||||
const bare = bareNameOf(name);
|
||||
if (bare && UTF8_TEXT_EXTENSIONS.has(bare)) {
|
||||
return 'utf8-text';
|
||||
}
|
||||
if (isUtf8TextMime(mimeType)) {
|
||||
return 'utf8-text';
|
||||
}
|
||||
if (isDocumentMime(mimeType)) {
|
||||
return 'document';
|
||||
}
|
||||
if (mimeType === PPTX_MIME) {
|
||||
return 'pptx';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
172
packages/api/src/files/code/extract.spec.ts
Normal file
172
packages/api/src/files/code/extract.spec.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { extractCodeArtifactText, MAX_TEXT_CACHE_BYTES, MAX_TEXT_EXTRACT_BYTES } from './extract';
|
||||
|
||||
const docxText = '__DOCX_PARSED__';
|
||||
const docxFailureName = 'force-docx-failure.docx';
|
||||
const parseDocumentCalls: Array<{ path: string; originalname: string }> = [];
|
||||
|
||||
jest.mock('~/files/documents/crud', () => ({
|
||||
parseDocument: jest.fn(async ({ file }: { file: { path: string; originalname: string } }) => {
|
||||
parseDocumentCalls.push({ path: file.path, originalname: file.originalname });
|
||||
if (file.originalname === docxFailureName) {
|
||||
throw new Error('parse failed');
|
||||
}
|
||||
return { text: docxText, filename: file.originalname, bytes: docxText.length };
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('extractCodeArtifactText', () => {
|
||||
describe('utf8-text', () => {
|
||||
it('decodes a UTF-8 buffer', async () => {
|
||||
const buffer = Buffer.from('hello world\n', 'utf-8');
|
||||
const text = await extractCodeArtifactText(buffer, 'note.txt', 'text/plain', 'utf8-text');
|
||||
expect(text).toBe('hello world\n');
|
||||
});
|
||||
|
||||
it('returns null for binary content (null byte)', async () => {
|
||||
const buffer = Buffer.from([0x68, 0x69, 0x00, 0x6f]);
|
||||
const text = await extractCodeArtifactText(buffer, 'fake.txt', 'text/plain', 'utf8-text');
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for buffers larger than the extract cap', async () => {
|
||||
const buffer = Buffer.alloc(MAX_TEXT_EXTRACT_BYTES + 1, 'a');
|
||||
const text = await extractCodeArtifactText(buffer, 'big.txt', 'text/plain', 'utf8-text');
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
|
||||
it('truncates content larger than the cache cap with a marker', async () => {
|
||||
const cacheCapPlus = MAX_TEXT_CACHE_BYTES + 1024;
|
||||
const buffer = Buffer.alloc(cacheCapPlus, 'a');
|
||||
const text = await extractCodeArtifactText(buffer, 'big.txt', 'text/plain', 'utf8-text');
|
||||
expect(text).not.toBeNull();
|
||||
expect(text!.endsWith('…[truncated]')).toBe(true);
|
||||
expect(Buffer.byteLength(text!, 'utf-8')).toBeLessThanOrEqual(MAX_TEXT_CACHE_BYTES);
|
||||
});
|
||||
|
||||
it('does not split a multi-byte UTF-8 character at the truncation boundary', async () => {
|
||||
// 你 is U+4F60, which encodes as 3 bytes in UTF-8 (E4 BD A0).
|
||||
// Build a buffer where the cut would otherwise land mid-character.
|
||||
const filler = 'a'.repeat(MAX_TEXT_CACHE_BYTES - 30);
|
||||
const tail = '你'.repeat(50);
|
||||
const buffer = Buffer.from(filler + tail, 'utf-8');
|
||||
const text = await extractCodeArtifactText(buffer, 'cjk.txt', 'text/plain', 'utf8-text');
|
||||
expect(text).not.toBeNull();
|
||||
expect(text!.endsWith('…[truncated]')).toBe(true);
|
||||
// U+FFFD (replacement) signals a corrupted boundary — must not appear.
|
||||
expect(text).not.toContain('<27>');
|
||||
});
|
||||
|
||||
it('returns the empty string for an empty buffer', async () => {
|
||||
const text = await extractCodeArtifactText(
|
||||
Buffer.alloc(0),
|
||||
'empty.txt',
|
||||
'text/plain',
|
||||
'utf8-text',
|
||||
);
|
||||
expect(text).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('document', () => {
|
||||
beforeEach(() => {
|
||||
parseDocumentCalls.length = 0;
|
||||
});
|
||||
|
||||
it('routes through parseDocument and returns its text', async () => {
|
||||
const buffer = Buffer.from('PKfake-docx');
|
||||
const text = await extractCodeArtifactText(
|
||||
buffer,
|
||||
'report.docx',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'document',
|
||||
);
|
||||
expect(text).toBe(docxText);
|
||||
});
|
||||
|
||||
it('returns null when parseDocument throws', async () => {
|
||||
const buffer = Buffer.from('PKfake-docx');
|
||||
const text = await extractCodeArtifactText(
|
||||
buffer,
|
||||
docxFailureName,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'document',
|
||||
);
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites a generic sniffed MIME to the canonical document MIME by extension', async () => {
|
||||
// Code-output buffers for office docs are commonly sniffed as
|
||||
// application/zip — without canonicalization, parseDocument would
|
||||
// reject these and inline previews would silently disappear.
|
||||
const buffer = Buffer.from('PKfake-docx');
|
||||
await extractCodeArtifactText(buffer, 'report.docx', 'application/zip', 'document');
|
||||
expect(parseDocumentCalls[0]?.originalname).toBe('report.docx');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'report.docx',
|
||||
'application/zip',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
],
|
||||
[
|
||||
'data.xlsx',
|
||||
'application/octet-stream',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
],
|
||||
['legacy.xls', 'application/octet-stream', 'application/vnd.ms-excel'],
|
||||
['sheet.ods', 'application/zip', 'application/vnd.oasis.opendocument.spreadsheet'],
|
||||
['notes.odt', 'application/zip', 'application/vnd.oasis.opendocument.text'],
|
||||
])('passes canonical mimetype for %s when sniff returns %s', async (name, sniffed, _canon) => {
|
||||
const parseDocumentMock = (
|
||||
jest.requireMock('~/files/documents/crud') as {
|
||||
parseDocument: jest.Mock;
|
||||
}
|
||||
).parseDocument;
|
||||
parseDocumentMock.mockClear();
|
||||
await extractCodeArtifactText(Buffer.from('PK'), name, sniffed, 'document');
|
||||
const call = parseDocumentMock.mock.calls[0]?.[0];
|
||||
expect(call?.file?.mimetype).toBe(_canon);
|
||||
});
|
||||
|
||||
it('writes the temp file inside os.tmpdir() regardless of artifact name', async () => {
|
||||
const buffer = Buffer.from('PKfake-docx');
|
||||
await extractCodeArtifactText(
|
||||
buffer,
|
||||
'../../../etc/passwd.docx',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'document',
|
||||
);
|
||||
const call = parseDocumentCalls[0];
|
||||
expect(call).toBeDefined();
|
||||
const tmpRoot = path.resolve(os.tmpdir());
|
||||
expect(path.resolve(call.path).startsWith(tmpRoot)).toBe(true);
|
||||
expect(call.path).not.toContain('..');
|
||||
expect(call.originalname).toBe('passwd.docx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipped categories', () => {
|
||||
it('returns null for pptx', async () => {
|
||||
const text = await extractCodeArtifactText(
|
||||
Buffer.from('PK'),
|
||||
'slides.pptx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'pptx',
|
||||
);
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for other (binary)', async () => {
|
||||
const text = await extractCodeArtifactText(
|
||||
Buffer.from([0xff, 0xd8, 0xff]),
|
||||
'photo.jpg',
|
||||
'image/jpeg',
|
||||
'other',
|
||||
);
|
||||
expect(text).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
136
packages/api/src/files/code/extract.ts
Normal file
136
packages/api/src/files/code/extract.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { CodeArtifactCategory } from './classify';
|
||||
import { parseDocument } from '~/files/documents/crud';
|
||||
import { isBinaryBuffer } from '~/skills/binary';
|
||||
import { withTimeout } from '~/utils/promise';
|
||||
|
||||
export const MAX_TEXT_CACHE_BYTES = 512 * 1024;
|
||||
export const MAX_TEXT_EXTRACT_BYTES = 1024 * 1024;
|
||||
const DOCUMENT_PARSE_TIMEOUT_MS = 8_000;
|
||||
const TRUNCATION_MARKER = '\n\n…[truncated]';
|
||||
const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, 'utf-8');
|
||||
|
||||
/**
|
||||
* Truncate UTF-8 content to fit within MAX_TEXT_CACHE_BYTES. Walks back to a
|
||||
* code-point boundary so the cut never lands inside a multi-byte sequence
|
||||
* (which would emit a U+FFFD replacement character — a real concern for CJK
|
||||
* and emoji-heavy content). Accepts an optional pre-built buffer to avoid
|
||||
* re-encoding when the caller already has one.
|
||||
*/
|
||||
const truncate = (text: string, originalBuffer?: Buffer): string => {
|
||||
const buffer = originalBuffer ?? Buffer.from(text, 'utf-8');
|
||||
if (buffer.length <= MAX_TEXT_CACHE_BYTES) {
|
||||
return text;
|
||||
}
|
||||
let sliceLen = Math.max(0, MAX_TEXT_CACHE_BYTES - TRUNCATION_MARKER_BYTES);
|
||||
// UTF-8 continuation bytes match 0b10xxxxxx; keep walking back while the
|
||||
// proposed cut would split a sequence.
|
||||
while (sliceLen > 0 && (buffer[sliceLen] & 0xc0) === 0x80) {
|
||||
sliceLen--;
|
||||
}
|
||||
return buffer.subarray(0, sliceLen).toString('utf-8') + TRUNCATION_MARKER;
|
||||
};
|
||||
|
||||
const extractUtf8 = (buffer: Buffer): string | null => {
|
||||
if (isBinaryBuffer(buffer)) {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length <= MAX_TEXT_CACHE_BYTES) {
|
||||
return buffer.toString('utf-8');
|
||||
}
|
||||
return truncate(buffer.toString('utf-8'), buffer);
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a known office-document extension back to its canonical MIME so we can
|
||||
* route through `parseDocument` even when buffer-sniffing yielded a generic
|
||||
* value like `application/zip` or `application/octet-stream`. `parseDocument`
|
||||
* dispatches strictly by MIME, so without this remap a `.docx` with a sniffed
|
||||
* `application/zip` would silently fall back to `null`.
|
||||
*/
|
||||
const documentMimeFromExtension = (name: string): string | null => {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.docx':
|
||||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
case '.xlsx':
|
||||
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
case '.xls':
|
||||
return 'application/vnd.ms-excel';
|
||||
case '.ods':
|
||||
return 'application/vnd.oasis.opendocument.spreadsheet';
|
||||
case '.odt':
|
||||
return 'application/vnd.oasis.opendocument.text';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const extractDocument = async (
|
||||
buffer: Buffer,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
): Promise<string | null> => {
|
||||
const canonicalMime = documentMimeFromExtension(name) ?? mimeType;
|
||||
const tempPath = path.join(os.tmpdir(), `code-artifact-${randomUUID()}`);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
parseDocument({
|
||||
file: {
|
||||
path: tempPath,
|
||||
size: buffer.length,
|
||||
mimetype: canonicalMime,
|
||||
originalname: path.basename(name),
|
||||
} as Express.Multer.File,
|
||||
}),
|
||||
DOCUMENT_PARSE_TIMEOUT_MS,
|
||||
`parseDocument exceeded ${DOCUMENT_PARSE_TIMEOUT_MS}ms`,
|
||||
);
|
||||
if (!result?.text) {
|
||||
return null;
|
||||
}
|
||||
return truncate(result.text);
|
||||
} finally {
|
||||
fs.unlink(tempPath).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a UTF-8 text representation of a code-execution artifact for inline
|
||||
* rendering. Returns `null` for binary, oversized, or unsupported files; the
|
||||
* caller should fall back to the standard download UI in that case.
|
||||
*
|
||||
* - utf8-text: decodes the buffer (with a binary safety net)
|
||||
* - document: dispatches to the existing PDF/DOCX/XLSX/ODT parser
|
||||
* - pptx: not yet supported in this PR — returns null (follow-up work)
|
||||
* - other: returns null (binary file, no inline preview)
|
||||
*/
|
||||
export async function extractCodeArtifactText(
|
||||
buffer: Buffer,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
category: CodeArtifactCategory,
|
||||
): Promise<string | null> {
|
||||
if (category === 'other' || category === 'pptx') {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length > MAX_TEXT_EXTRACT_BYTES) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (category === 'utf8-text') {
|
||||
return extractUtf8(buffer);
|
||||
}
|
||||
return await extractDocument(buffer, name, mimeType);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[extractCodeArtifactText] Failed to extract "${name}" (${mimeType}): ${(error as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
packages/api/src/files/code/index.ts
Normal file
2
packages/api/src/files/code/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './classify';
|
||||
export * from './extract';
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './agents';
|
||||
export * from './audio';
|
||||
export * from './code';
|
||||
export * from './context';
|
||||
export * from './documents/crud';
|
||||
export * from './encode';
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export type TFile = {
|
|||
height?: number;
|
||||
expiresAt?: string | Date;
|
||||
preview?: string;
|
||||
text?: string;
|
||||
metadata?: { fileIdentifier?: string };
|
||||
createdAt?: string | Date;
|
||||
updatedAt?: string | Date;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue