diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 015a590d55..eb5f86d3b9 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { + inferMimeType, EToolResources, EModelEndpoint, defaultAgentCapabilities, @@ -56,18 +57,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD const _options: FileOption[] = []; const currentProvider = provider || endpoint; + /** Helper to get inferred MIME type for a file */ + const getFileType = (file: File) => inferMimeType(file.name, file.type); + // Check if provider supports document upload if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) { const isGoogleProvider = currentProvider === EModelEndpoint.google; const validFileTypes = isGoogleProvider - ? files.every( - (file) => - file.type?.startsWith('image/') || - file.type?.startsWith('video/') || - file.type?.startsWith('audio/') || - file.type === 'application/pdf', - ) - : files.every((file) => file.type?.startsWith('image/') || file.type === 'application/pdf'); + ? files.every((file) => { + const type = getFileType(file); + return ( + type?.startsWith('image/') || + type?.startsWith('video/') || + type?.startsWith('audio/') || + type === 'application/pdf' + ); + }) + : files.every((file) => { + const type = getFileType(file); + return type?.startsWith('image/') || type === 'application/pdf'; + }); _options.push({ label: localize('com_ui_upload_provider'), @@ -81,7 +90,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD label: localize('com_ui_upload_image_input'), value: undefined, icon: , - condition: files.every((file) => file.type?.startsWith('image/')), + condition: files.every((file) => getFileType(file)?.startsWith('image/')), }); } if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) { diff --git a/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx index 2adad63b9a..44e632fa12 100644 --- a/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx +++ b/client/src/components/Chat/Input/Files/__tests__/DragDropModal.spec.tsx @@ -1,4 +1,8 @@ -import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider'; +import { + EModelEndpoint, + isDocumentSupportedProvider, + inferMimeType, +} from 'librechat-data-provider'; describe('DragDropModal - Provider Detection', () => { describe('endpointType priority over currentProvider', () => { @@ -118,4 +122,59 @@ describe('DragDropModal - Provider Detection', () => { ).toBe(true); }); }); + + describe('HEIC/HEIF file type inference', () => { + it('should infer image/heic for .heic files when browser returns empty type', () => { + const fileName = 'photo.heic'; + const browserType = ''; + + const inferredType = inferMimeType(fileName, browserType); + expect(inferredType).toBe('image/heic'); + }); + + it('should infer image/heif for .heif files when browser returns empty type', () => { + const fileName = 'photo.heif'; + const browserType = ''; + + const inferredType = inferMimeType(fileName, browserType); + expect(inferredType).toBe('image/heif'); + }); + + it('should handle uppercase .HEIC extension', () => { + const fileName = 'IMG_1234.HEIC'; + const browserType = ''; + + const inferredType = inferMimeType(fileName, browserType); + expect(inferredType).toBe('image/heic'); + }); + + it('should preserve browser-provided type when available', () => { + const fileName = 'photo.jpg'; + const browserType = 'image/jpeg'; + + const inferredType = inferMimeType(fileName, browserType); + expect(inferredType).toBe('image/jpeg'); + }); + + it('should not override browser type even if extension differs', () => { + const fileName = 'renamed.heic'; + const browserType = 'image/png'; + + const inferredType = inferMimeType(fileName, browserType); + expect(inferredType).toBe('image/png'); + }); + + it('should correctly identify HEIC as image type for upload options', () => { + const heicType = inferMimeType('photo.heic', ''); + expect(heicType.startsWith('image/')).toBe(true); + }); + + it('should return empty string for unknown extension with no browser type', () => { + const fileName = 'file.xyz'; + const browserType = ''; + + const inferredType = inferMimeType(fileName, browserType); + expect(inferredType).toBe(''); + }); + }); }); diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index 93c810e7ce..b4d362d456 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -9,9 +9,9 @@ import { import { megabyte, QueryKeys, + inferMimeType, excelMimeTypes, EToolResources, - codeTypeMapping, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; import type { TFile, EndpointFileConfig, FileConfig } from 'librechat-data-provider'; @@ -257,14 +257,7 @@ export const validateFiles = ({ for (let i = 0; i < fileList.length; i++) { let originalFile = fileList[i]; - let fileType = originalFile.type; - const extension = originalFile.name.split('.').pop() ?? ''; - const knownCodeType = codeTypeMapping[extension]; - - // Infer MIME type for Known Code files when the type is empty or a mismatch - if (knownCodeType && (!fileType || fileType !== knownCodeType)) { - fileType = knownCodeType; - } + const fileType = inferMimeType(originalFile.name, originalFile.type); // Check if the file type is still empty after the extension check if (!fileType) { diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 1ef41f0af5..e5c84e69d3 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -200,6 +200,27 @@ export const codeTypeMapping: { [key: string]: string } = { tsv: 'text/tab-separated-values', }; +/** Maps image extensions to MIME types for formats browsers may not recognize */ +export const imageTypeMapping: { [key: string]: string } = { + heic: 'image/heic', + heif: 'image/heif', +}; + +/** + * Infers the MIME type from a file's extension when the browser doesn't recognize it + * @param fileName - The name of the file including extension + * @param currentType - The current MIME type reported by the browser (may be empty) + * @returns The inferred MIME type if browser didn't provide one, otherwise the original type + */ +export function inferMimeType(fileName: string, currentType: string): string { + if (currentType) { + return currentType; + } + + const extension = fileName.split('.').pop()?.toLowerCase() ?? ''; + return codeTypeMapping[extension] || imageTypeMapping[extension] || currentType; +} + 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)))$/,