📄 feat: accept PowerPoint template MIME type (#14761)

This commit is contained in:
snapydziuba 2026-08-14 06:22:01 +02:00 committed by GitHub
parent 6cbfd82772
commit 6c46fd1252
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 102 additions and 41 deletions

View file

@ -991,7 +991,7 @@ HELP_AND_FAQ_URL=https://librechat.ai
# such as the below example of 250 mib
# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
# Max size (bytes) of a code-execution artifact (docx/xlsx/csv/pptx/text/pdf) rendered as an
# Max size (bytes) of a code-execution artifact (docx/xlsx/csv/pptx/potx/text/pdf) rendered as an
# inline preview. Larger files fall back to download-only. Default: 2 MB (2097152). Note the
# rendered HTML is independently capped at 512 KB, so very rich files may still skip preview.
# FILE_PREVIEW_MAX_EXTRACT_BYTES=2097152

View file

@ -204,6 +204,7 @@ function getMimeTypeFromFileName(fileName: string): string {
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
potx: 'application/vnd.openxmlformats-officedocument.presentationml.template',
txt: 'text/plain',
csv: 'text/csv',

View file

@ -66,6 +66,7 @@ describe('detectArtifactTypeFromFile', () => {
['legacy.xls', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['sheet.ods', TOOL_ARTIFACT_TYPES.SPREADSHEET],
['slides.pptx', TOOL_ARTIFACT_TYPES.PRESENTATION],
['template.potx', TOOL_ARTIFACT_TYPES.PRESENTATION],
])('classifies %s by extension', (filename, expected) => {
/* Office types require `textFormat: 'html'` to route to their HTML
* preview buckets the security gate added for Codex P1 review on
@ -155,6 +156,10 @@ describe('detectArtifactTypeFromFile', () => {
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
TOOL_ARTIFACT_TYPES.PRESENTATION,
],
[
'application/vnd.openxmlformats-officedocument.presentationml.template',
TOOL_ARTIFACT_TYPES.PRESENTATION,
],
])('routes office MIME %s to its preview bucket when extension is missing', (mime, expected) => {
/* `textFormat: 'html'` is required so the security gate (Codex P1 on
* PR #12934) lets routing proceed to the office HTML bucket without

View file

@ -3,6 +3,7 @@ import type { FileConfig } from 'librechat-data-provider';
import { getViableUploadOptions, type UploadOptionContext } from '../files';
const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const POTX = 'application/vnd.openxmlformats-officedocument.presentationml.template';
/** context accepts plain text + csv (text), pdf + xlsx (ocr); nothing else */
const fileConfig = {
@ -49,6 +50,13 @@ describe('getViableUploadOptions', () => {
]);
});
it('routes a PowerPoint template to file search and code, not the provider', () => {
expect(getViableUploadOptions([file(POTX, 'brand-template.potx')], baseCtx())).toEqual([
EToolResources.file_search,
EToolResources.execute_code,
]);
});
it('offers every destination for a PDF', () => {
expect(getViableUploadOptions([file('application/pdf', 'doc.pdf')], baseCtx())).toEqual([
undefined,

View file

@ -590,6 +590,7 @@ const EXTENSION_TO_TOOL_ARTIFACT_TYPE: Record<string, ToolArtifactType> = {
xls: TOOL_ARTIFACT_TYPES.SPREADSHEET,
ods: TOOL_ARTIFACT_TYPES.SPREADSHEET,
pptx: TOOL_ARTIFACT_TYPES.PRESENTATION,
potx: TOOL_ARTIFACT_TYPES.PRESENTATION,
};
/* Append every entry in `CODE_EXTENSION_TO_LANGUAGE` to the routing map
@ -667,6 +668,8 @@ const MIME_TO_TOOL_ARTIFACT_TYPE: Record<string, ToolArtifactType> = {
'text/comma-separated-values': TOOL_ARTIFACT_TYPES.SPREADSHEET,
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
TOOL_ARTIFACT_TYPES.PRESENTATION,
'application/vnd.openxmlformats-officedocument.presentationml.template':
TOOL_ARTIFACT_TYPES.PRESENTATION,
// Note: bare `text/plain` is NOT mapped here. The extension map handles
// `.txt` explicitly; routing every unrecognized-extension `text/plain`
// file (extensionless scripts, .env, etc.) through the panel would be a

View file

@ -1211,6 +1211,7 @@ const BINARY_EXTENSIONS_NEVER_READABLE = new Set([
'.xlsx',
'.ppt',
'.pptx',
'.potx',
'.odt',
'.ods',
'.odp',

View file

@ -79,18 +79,14 @@ describe('classifyCodeArtifact', () => {
});
});
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('presentation', () => {
it.each([
['slides.pptx', 'application/octet-stream'],
['template.potx', 'application/octet-stream'],
['unknown', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
['unknown', 'application/vnd.openxmlformats-officedocument.presentationml.template'],
])('classifies %s with MIME %s as a presentation', (name, mime) => {
expect(classifyCodeArtifact(name, mime)).toBe('presentation');
});
});

View file

@ -1,6 +1,6 @@
import { excelMimeTypes } from 'librechat-data-provider';
export type CodeArtifactCategory = 'utf8-text' | 'document' | 'pptx' | 'other';
export type CodeArtifactCategory = 'utf8-text' | 'document' | 'presentation' | 'other';
const UTF8_TEXT_EXTENSIONS = new Set<string>([
// plaintext / data
@ -119,9 +119,10 @@ const UTF8_TEXT_MIME_EXACT = new Set<string>([
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 POTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.template';
const DOCUMENT_EXTENSIONS = new Set<string>(['docx', 'odt', 'xlsx', 'xls', 'ods']);
const PPTX_EXTENSIONS = new Set<string>(['pptx']);
const PRESENTATION_EXTENSIONS = new Set<string>(['pptx', 'potx']);
const extensionOf = (name: string): string => {
const dot = name.lastIndexOf('.');
@ -176,8 +177,8 @@ export function classifyCodeArtifact(name: string, mimeType: string): CodeArtifa
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
return 'document';
}
if (ext && PPTX_EXTENSIONS.has(ext)) {
return 'pptx';
if (ext && PRESENTATION_EXTENSIONS.has(ext)) {
return 'presentation';
}
const bare = bareNameOf(name);
if (bare && UTF8_TEXT_EXTENSIONS.has(bare)) {
@ -189,8 +190,8 @@ export function classifyCodeArtifact(name: string, mimeType: string): CodeArtifa
if (isDocumentMime(mimeType)) {
return 'document';
}
if (mimeType === PPTX_MIME) {
return 'pptx';
if (mimeType === PPTX_MIME || mimeType === POTX_MIME) {
return 'presentation';
}
return 'other';
}

View file

@ -203,13 +203,13 @@ describe('extractCodeArtifactText', () => {
});
describe('skipped categories', () => {
it('returns null for pptx when HTML rendering also fails', async () => {
it('returns null for presentations when HTML rendering also fails', async () => {
mockOfficeHtml.mockResolvedValueOnce(null);
const text = await extractCodeArtifactText(
Buffer.from('PK'),
'slides.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'pptx',
'presentation',
);
expect(text).toBeNull();
});
@ -249,11 +249,23 @@ describe('extractCodeArtifactText', () => {
Buffer.from('PK'),
'deck.pptx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'pptx',
'presentation',
);
expect(text).toContain('slides');
});
it('returns the HTML rendering for a potx when the producer succeeds', async () => {
const html = '<!DOCTYPE html><html><body>template</body></html>';
mockOfficeHtml.mockResolvedValueOnce(html);
const text = await extractCodeArtifactText(
Buffer.from('PK'),
'template.potx',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'presentation',
);
expect(text).toBe(html);
});
it('returns the HTML rendering for csv (overriding utf8-text raw output)', async () => {
mockOfficeHtml.mockResolvedValueOnce('<!DOCTYPE html><table><tr><td>a</td></tr></table>');
const text = await extractCodeArtifactText(
@ -422,9 +434,9 @@ describe('extractCodeArtifactText', () => {
['deck', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
])('routes extensionless office files by MIME alone (%s, %s)', async (name, mime) => {
mockOfficeHtml.mockResolvedValueOnce('<!DOCTYPE html><body>x</body></html>');
let category: 'pptx' | 'utf8-text' | 'document' = 'document';
let category: 'presentation' | 'utf8-text' | 'document' = 'document';
if (mime.includes('presentation')) {
category = 'pptx';
category = 'presentation';
} else if (mime.startsWith('text/')) {
category = 'utf8-text';
}

View file

@ -260,13 +260,13 @@ const renderOfficeHtml = async (
* rendering. Returns `null` for binary, oversized, or unsupported files; the
* caller should fall back to the standard download UI in that case.
*
* Office types (docx, xlsx/xls/ods, csv, pptx) are rendered as sanitized
* Office types (docx, xlsx/xls/ods, csv, pptx/potx) are rendered as sanitized
* HTML by the producers in `~/files/documents/html`. The frontend feeds the
* HTML into the Sandpack `static` template via `index.html`. CSV is special-
* cased here its category is `utf8-text` (raw CSV is text), but we want
* the styled-table preview when the file extension says CSV.
*
* - office (docx/xlsx/xls/ods/csv/pptx): sanitized HTML preview
* - office (docx/xlsx/xls/ods/csv/pptx/potx): sanitized HTML preview
* - utf8-text: decodes the buffer (with a binary safety net)
* - document: dispatches to the existing PDF/ODT parser
* - other: returns null (binary file, no inline preview)
@ -318,8 +318,8 @@ export async function extractCodeArtifactText(
* the markdown viewer with proper escaping). Plain text is safe. */
return await extractDocument(buffer, name, mimeType);
}
/* category === 'pptx' that didn't go through the office HTML path
* (shouldn't happen pptx ext is in OFFICE_HTML_EXTENSIONS but
/* category === 'presentation' that didn't go through the office HTML path
* (shouldn't happen presentation extensions use the office HTML path but
* defended in depth). */
return null;
} catch (error) {

View file

@ -729,11 +729,17 @@ describe('Office HTML producers', () => {
['noext', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'spreadsheet'],
['noext', 'application/vnd.ms-excel', 'spreadsheet'],
['noext', 'application/vnd.oasis.opendocument.spreadsheet', 'spreadsheet'],
['deck.pptx', '', 'pptx'],
['deck.pptx', '', 'presentation'],
['template.potx', '', 'presentation'],
[
'noext',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'pptx',
'presentation',
],
[
'noext',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'presentation',
],
])('classifies (%s, %s) as %s', (name, mime, expected) => {
expect(officeHtmlBucket(name, mime)).toBe(expected);
@ -760,7 +766,7 @@ describe('Office HTML producers', () => {
* documented "extension wins" precedence is enforced by checking
* extensions exhaustively before any MIME pattern fires. */
it.each([
['deck.pptx', 'text/csv', 'pptx'],
['deck.pptx', 'text/csv', 'presentation'],
['workbook.xlsx', 'text/csv', 'spreadsheet'],
[
'legacy.xls',
@ -838,7 +844,7 @@ describe('Office HTML producers', () => {
[
'deck',
'application/vnd.openxmlformats-officedocument.presentationml.presentation; foo=bar',
'pptx',
'presentation',
],
])('strips MIME parameters before matching: (%s, %s) → %s', (name, mime, expected) => {
expect(officeHtmlBucket(name, mime)).toBe(expected);

View file

@ -1501,6 +1501,7 @@ export const _internal: {
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
const POTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.template';
const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
const CSV_MIME_PATTERN = /^(text\/csv|application\/csv|text\/comma-separated-values)$/i;
@ -1541,7 +1542,7 @@ function baseMime(mime: string): string {
* `text/csv`), which then routed to the SPREADSHEET bucket on the
* client expecting full HTML and got raw text instead.
*/
export type OfficeHtmlBucket = 'docx' | 'spreadsheet' | 'csv' | 'pptx';
export type OfficeHtmlBucket = 'docx' | 'spreadsheet' | 'csv' | 'presentation';
const OFFICE_EXTENSIONS: Record<string, OfficeHtmlBucket> = {
docx: 'docx',
@ -1549,7 +1550,8 @@ const OFFICE_EXTENSIONS: Record<string, OfficeHtmlBucket> = {
xlsx: 'spreadsheet',
xls: 'spreadsheet',
ods: 'spreadsheet',
pptx: 'pptx',
pptx: 'presentation',
potx: 'presentation',
};
/**
@ -1608,8 +1610,8 @@ export function officeHtmlBucket(name: string, mimeType: string): OfficeHtmlBuck
if (excelMimeTypes.test(normalized) || normalized === ODS_MIME) {
return 'spreadsheet';
}
if (normalized === PPTX_MIME) {
return 'pptx';
if (normalized === PPTX_MIME || normalized === POTX_MIME) {
return 'presentation';
}
return null;
}
@ -1632,7 +1634,7 @@ export async function bufferToOfficeHtml(
return csvToHtml(buffer);
case 'spreadsheet':
return excelSheetToHtml(buffer);
case 'pptx':
case 'presentation':
return pptxToHtml(buffer);
default:
return null;

View file

@ -98,12 +98,16 @@ describe('inferMimeType', () => {
expect(inferMimeType('deck.pptx', '')).toBe(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
);
expect(inferMimeType('template.potx', '')).toBe(
'application/vnd.openxmlformats-officedocument.presentationml.template',
);
});
it('produces Office types accepted by checkType after inference', () => {
expect(baseFileConfig.checkType(inferMimeType('report.docx', ''))).toBe(true);
expect(baseFileConfig.checkType(inferMimeType('sheet.xlsx', ''))).toBe(true);
expect(baseFileConfig.checkType(inferMimeType('legacy.doc', ''))).toBe(true);
expect(baseFileConfig.checkType(inferMimeType('template.potx', ''))).toBe(true);
});
});
@ -170,7 +174,8 @@ describe('defaultOCRMimeTypes', () => {
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
])('matches ODF type for OCR: %s', (mimeType) => {
'application/vnd.openxmlformats-officedocument.presentationml.template',
])('matches configured OCR type: %s', (mimeType) => {
expect(checkOCRType(mimeType)).toBe(true);
});
});
@ -184,7 +189,8 @@ describe('supportedMimeTypes', () => {
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.graphics',
])('ODF type flows through supportedMimeTypes: %s', (mimeType) => {
'application/vnd.openxmlformats-officedocument.presentationml.template',
])('document type flows through supportedMimeTypes: %s', (mimeType) => {
expect(checkSupported(mimeType)).toBe(true);
});
@ -1491,6 +1497,20 @@ describe('getConfiguredMimeAccept', () => {
expect(accept.has('video/*')).toBe(false);
});
it('translates a PowerPoint template allowlist to the template extension and MIME', () => {
const templateMime = 'application/vnd.openxmlformats-officedocument.presentationml.template';
const accept = toSet(
getConfiguredMimeAccept(
convertStringsToRegex([
'^application/vnd\\.openxmlformats-officedocument\\.presentationml\\.template$',
]),
IMAGE_DOC,
),
);
expect(accept).toEqual(new Set(['.potx', templateMime]));
});
it('emits image/* for an image-only allowlist', () => {
const accept = toSet(getConfiguredMimeAccept([/^image\/(jpeg|png)$/], IMAGE_DOC));
expect(accept.has('image/*')).toBe(true);

View file

@ -39,6 +39,7 @@ export const fullMimeTypesList = [
'application/pdf',
'text/x-php',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'text/x-python',
'text/x-script.python',
'text/x-ruby',
@ -106,6 +107,7 @@ export const codeInterpreterMimeTypesList = [
'application/pdf',
'text/x-php',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'text/x-python',
'text/x-script.python',
'text/x-ruby',
@ -139,6 +141,7 @@ export const retrievalMimeTypesList = [
'application/pdf',
'text/x-php',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'text/x-python',
'text/x-script.python',
'text/x-ruby',
@ -217,7 +220,7 @@ export const textMimeTypes =
/^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml|calendar))$/;
export const applicationMimeTypes =
/^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|x-zip-compressed|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/;
/^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|x-zip-compressed|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.(presentation|template)|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/;
export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/;
@ -231,6 +234,7 @@ export const defaultOCRMimeTypes = [
excelMimeTypes,
/^application\/pdf$/,
/^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)$/,
/^application\/vnd\.openxmlformats-officedocument\.presentationml\.template$/,
/^application\/vnd\.ms-(word|powerpoint)$/,
/^application\/epub\+zip$/,
/^application\/vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)$/,
@ -394,6 +398,7 @@ export const codeTypeMapping: { [key: string]: string } = {
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx - Excel
ppt: 'application/vnd.ms-powerpoint', // .ppt - PowerPoint (legacy)
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx - PowerPoint
potx: 'application/vnd.openxmlformats-officedocument.presentationml.template', // .potx - PowerPoint template
ics: 'text/calendar', // .ics - iCalendar
ical: 'text/calendar', // .ical - iCalendar
ifb: 'text/calendar', // .ifb - iCalendar free/busy
@ -431,7 +436,7 @@ export function inferMimeType(fileName: string, currentType: string): string {
export const retrievalMimeTypes = [
/^(text\/(x-c|x-c\+\+|x-h|html|x-java|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|vtt|xml))$/,
/^(application\/(json|pdf|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)))$/,
/^(application\/(json|pdf|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.(presentation|template))))$/,
];
export const megabyte = 1024 * 1024;
@ -682,6 +687,7 @@ const documentMimeExtensions: ReadonlyArray<readonly [string, readonly string[]]
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ['.xlsx']],
['application/vnd.ms-powerpoint', ['.ppt']],
['application/vnd.openxmlformats-officedocument.presentationml.presentation', ['.pptx']],
['application/vnd.openxmlformats-officedocument.presentationml.template', ['.potx']],
['application/vnd.oasis.opendocument.text', ['.odt']],
['application/vnd.oasis.opendocument.spreadsheet', ['.ods']],
['application/vnd.oasis.opendocument.presentation', ['.odp']],