diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js index 0b8548445d..cf6b416261 100644 --- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js +++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js @@ -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 }), }; diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 028b8c1872..992d56309f 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -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); diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index a805ee2bcc..f4e79806f0 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -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 diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index 31e30772dc..2d7fb56ffc 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -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 }) => { const [isVisible, setIsVisible] = useState(false); const file = attachment as TFile & TAttachmentMetadata; @@ -50,6 +54,92 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial ); }); +const TextAttachment = memo(({ attachment }: { attachment: Partial }) => { + const localize = useLocalize(); + const preId = useId(); + const preRef = useRef(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 ( +
+ {attachment.filepath && ( + + )} +
+
+          {text}
+        
+ {overflowed && ( + + )} +
+
+ ); +}); + 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 ; - } else if (!attachment.filepath) { + } + if (isTextAttachment(attachment)) { + return ; + } + if (!attachment.filepath) { return null; } return ; @@ -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[] } )} )} + {textAttachments.length > 0 && ( +
+ {textAttachments.map((attachment, index) => ( + + ))} +
+ )} {imageAttachments.length > 0 && (
{imageAttachments.map((attachment, index) => ( diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index a675ff06d8..3de9628024 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -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 = ({ 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 = []; + 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 = ({ 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 ( = ({ output = '', renderImages, atta return ( <> {processedContent &&
{processedContent}
} - {nonImageAttachments.length > 0 && ( + {nonInlineAttachments.length > 0 && (

{localize('com_generated_files')}

- {nonImageAttachments.map((file, index) => ( + {nonInlineAttachments.map((file, index) => ( {renderAttachment(file)} - {index < nonImageAttachments.length - 1 && ', '} + {index < nonInlineAttachments.length - 1 && ', '} ))}
)} + {textAttachments.length > 0 && ( +
+ {textAttachments.map((file) => ( +
+ {file.filename && ( +
+ {file.filepath ? ( + + {file.filename} + + ) : ( + file.filename + )} +
+ )} +
+                {file.text}
+              
+
+ ))} +
+ )} {imageAttachments?.map((attachment) => ( ({ + useLocalize: + () => + (key: string): string => { + const translations: Record = { + 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 }) => ( + + ), +})); + +jest.mock('~/components/Chat/Messages/Content/Image', () => ({ + __esModule: true, + default: ({ altText }: { altText?: string }) => {altText, +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); + +const textAttachment = (overrides: Partial = {}): 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
', () => {
+    const { container } = render();
+    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();
+    expect(screen.getByTestId('file-container')).toBeInTheDocument();
+  });
+
+  it('hides the download chip when filepath is absent', () => {
+    render();
+    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();
+    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();
+    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();
+    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();
+    // FileAttachment also renders the FileContainer mock — we assert the
+    // 
 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();
+    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();
+    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();
+    expect(container.querySelector('pre')).toBeNull();
+    expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0);
+  });
+});
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts
new file mode 100644
index 0000000000..ecbbd19eff
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts
@@ -0,0 +1,93 @@
+import type { TAttachment } from 'librechat-data-provider';
+import { isImageAttachment, isTextAttachment } from '../attachmentTypes';
+
+const baseAttachment = (overrides: Partial = {}): 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);
+    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);
+    expect(isImageAttachment(attachment)).toBe(false);
+  });
+
+  it('returns false when width is missing', () => {
+    const attachment = baseAttachment({
+      filename: 'chart.png',
+      height: 600,
+    } as Partial);
+    expect(isImageAttachment(attachment)).toBe(false);
+  });
+
+  it('returns false when height is missing', () => {
+    const attachment = baseAttachment({
+      filename: 'chart.png',
+      width: 800,
+    } as Partial);
+    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);
+    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);
+    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);
+    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);
+    expect(isTextAttachment(attachment)).toBe(false);
+  });
+});
diff --git a/client/src/components/Chat/Messages/Content/Parts/attachmentTypes.ts b/client/src/components/Chat/Messages/Content/Parts/attachmentTypes.ts
new file mode 100644
index 0000000000..a53b882394
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/attachmentTypes.ts
@@ -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 ``. 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;
+};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 757d571150..c59f3f203b 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/packages/api/src/files/code/classify.spec.ts b/packages/api/src/files/code/classify.spec.ts
new file mode 100644
index 0000000000..8a1e3d9f5c
--- /dev/null
+++ b/packages/api/src/files/code/classify.spec.ts
@@ -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');
+    });
+  });
+});
diff --git a/packages/api/src/files/code/classify.ts b/packages/api/src/files/code/classify.ts
new file mode 100644
index 0000000000..4c4b9c319b
--- /dev/null
+++ b/packages/api/src/files/code/classify.ts
@@ -0,0 +1,193 @@
+import { excelMimeTypes } from 'librechat-data-provider';
+
+export type CodeArtifactCategory = 'utf8-text' | 'document' | 'pptx' | 'other';
+
+const UTF8_TEXT_EXTENSIONS = new Set([
+  // 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([
+  '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(['docx', 'odt', 'xlsx', 'xls', 'ods']);
+const PPTX_EXTENSIONS = new Set(['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';
+}
diff --git a/packages/api/src/files/code/extract.spec.ts b/packages/api/src/files/code/extract.spec.ts
new file mode 100644
index 0000000000..4614a0cfef
--- /dev/null
+++ b/packages/api/src/files/code/extract.spec.ts
@@ -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('�');
+    });
+
+    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();
+    });
+  });
+});
diff --git a/packages/api/src/files/code/extract.ts b/packages/api/src/files/code/extract.ts
new file mode 100644
index 0000000000..d0a1ff58ba
--- /dev/null
+++ b/packages/api/src/files/code/extract.ts
@@ -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 => {
+  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 {
+  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;
+  }
+}
diff --git a/packages/api/src/files/code/index.ts b/packages/api/src/files/code/index.ts
new file mode 100644
index 0000000000..47852d1abd
--- /dev/null
+++ b/packages/api/src/files/code/index.ts
@@ -0,0 +1,2 @@
+export * from './classify';
+export * from './extract';
diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts
index c3bdb49478..8200f21195 100644
--- a/packages/api/src/files/index.ts
+++ b/packages/api/src/files/index.ts
@@ -1,5 +1,6 @@
 export * from './agents';
 export * from './audio';
+export * from './code';
 export * from './context';
 export * from './documents/crud';
 export * from './encode';
diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts
index 69d2916d69..9e856d059f 100644
--- a/packages/data-provider/src/types/files.ts
+++ b/packages/data-provider/src/types/files.ts
@@ -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;