diff --git a/client/src/hooks/Files/__tests__/useClientResize.test.ts b/client/src/hooks/Files/__tests__/useClientResize.test.ts new file mode 100644 index 0000000000..626b52f0bf --- /dev/null +++ b/client/src/hooks/Files/__tests__/useClientResize.test.ts @@ -0,0 +1,121 @@ +import { act, renderHook } from '@testing-library/react'; +import { DEFAULT_MIN_FILE_SIZE_KB } from 'librechat-data-provider'; + +let mockFileConfig: unknown = null; + +jest.mock('~/data-provider', () => ({ + useGetFileConfig: jest.fn((options: { select?: (data: unknown) => unknown }) => ({ + data: options?.select ? options.select(mockFileConfig) : mockFileConfig, + })), +})); + +const mockResizeImage = jest.fn(); + +jest.mock('~/utils/imageResize', () => { + const actual = jest.requireActual('~/utils/imageResize'); + return { + ...actual, + resizeImage: (...args: unknown[]) => mockResizeImage(...args), + supportsClientResize: () => true, + }; +}); + +const makeImage = (sizeBytes: number, type = 'image/jpeg') => { + const file = new File([''], 'photo.jpg', { type, lastModified: Date.now() }); + Object.defineProperty(file, 'size', { value: sizeBytes, writable: false }); + return file; +}; + +describe('useClientResize', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFileConfig = null; + }); + + const loadHook = async () => (await import('../useClientResize')).default; + + it('returns the original file when clientImageResize is disabled', async () => { + mockFileConfig = { clientImageResize: { enabled: false } }; + const useClientResize = await loadHook(); + const { result } = renderHook(() => useClientResize()); + + const file = makeImage(5 * 1024 * 1024); + let outcome: { file: File; resized: boolean } | undefined; + await act(async () => { + outcome = await result.current.resizeImageIfNeeded(file); + }); + + expect(outcome).toEqual({ file, resized: false }); + expect(mockResizeImage).not.toHaveBeenCalled(); + }); + + it('skips files below the configured minFileSizeKB threshold', async () => { + mockFileConfig = { + clientImageResize: { enabled: true, minFileSizeKB: 1024 }, + }; + const useClientResize = await loadHook(); + const { result } = renderHook(() => useClientResize()); + + const file = makeImage(150 * 1024); + let outcome: { file: File; resized: boolean } | undefined; + await act(async () => { + outcome = await result.current.resizeImageIfNeeded(file); + }); + + expect(outcome).toEqual({ file, resized: false }); + expect(mockResizeImage).not.toHaveBeenCalled(); + }); + + it('resizes files at or above the configured minFileSizeKB threshold', async () => { + mockFileConfig = { + clientImageResize: { + enabled: true, + minFileSizeKB: 1024, + maxWidth: 1900, + maxHeight: 1900, + quality: 0.92, + }, + }; + const resizedFile = makeImage(512 * 1024); + mockResizeImage.mockResolvedValueOnce({ + file: resizedFile, + originalSize: 2 * 1024 * 1024, + newSize: 512 * 1024, + originalDimensions: { width: 2560, height: 1440 }, + newDimensions: { width: 1900, height: 1069 }, + compressionRatio: 0.25, + }); + + const useClientResize = await loadHook(); + const { result } = renderHook(() => useClientResize()); + + const source = makeImage(2 * 1024 * 1024); + let outcome: { file: File; resized: boolean } | undefined; + await act(async () => { + outcome = await result.current.resizeImageIfNeeded(source); + }); + + expect(outcome?.resized).toBe(true); + expect(outcome?.file).toBe(resizedFile); + expect(mockResizeImage).toHaveBeenCalledWith(source, { + maxWidth: 1900, + maxHeight: 1900, + quality: 0.92, + }); + }); + + it('falls back to the default threshold when minFileSizeKB is not configured', async () => { + mockFileConfig = { clientImageResize: { enabled: true } }; + const useClientResize = await loadHook(); + const { result } = renderHook(() => useClientResize()); + + const belowDefault = makeImage((DEFAULT_MIN_FILE_SIZE_KB - 1) * 1024); + let belowOutcome: { file: File; resized: boolean } | undefined; + await act(async () => { + belowOutcome = await result.current.resizeImageIfNeeded(belowDefault); + }); + + expect(belowOutcome).toEqual({ file: belowDefault, resized: false }); + expect(mockResizeImage).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/Files/useClientResize.ts b/client/src/hooks/Files/useClientResize.ts index 1da3848aeb..d089e0cf27 100644 --- a/client/src/hooks/Files/useClientResize.ts +++ b/client/src/hooks/Files/useClientResize.ts @@ -1,5 +1,5 @@ -import { mergeFileConfig } from 'librechat-data-provider'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import { DEFAULT_MIN_FILE_SIZE_KB, mergeFileConfig } from 'librechat-data-provider'; import { useGetFileConfig } from '~/data-provider'; import { resizeImage, @@ -9,6 +9,14 @@ import { type ResizeResult, } from '~/utils/imageResize'; +const DEFAULT_CLIENT_IMAGE_RESIZE = { + enabled: false, + maxWidth: 1900, + maxHeight: 1900, + quality: 0.92, + minFileSizeKB: DEFAULT_MIN_FILE_SIZE_KB, +} as const; + /** * Hook for client-side image resizing functionality * Integrates with LibreChat's file configuration system @@ -18,15 +26,12 @@ export const useClientResize = () => { select: (data) => mergeFileConfig(data), }); - // Safe access to clientImageResize config with fallbacks - // eslint-disable-next-line react-hooks/exhaustive-deps - const config = (fileConfig as any)?.clientImageResize ?? { - enabled: false, - maxWidth: 1900, - maxHeight: 1900, - quality: 0.92, - }; - const isEnabled = config?.enabled ?? false; + const config = useMemo( + () => fileConfig?.clientImageResize ?? DEFAULT_CLIENT_IMAGE_RESIZE, + [fileConfig], + ); + const isEnabled = config.enabled ?? false; + const minFileSizeBytes = (config.minFileSizeKB ?? DEFAULT_MIN_FILE_SIZE_KB) * 1024; /** * Resizes an image if client-side resizing is enabled and supported @@ -50,16 +55,16 @@ export const useClientResize = () => { return { file, resized: false }; } - // Return original file if it doesn't need resizing - if (!shouldResizeImage(file)) { + // Skip when the file is too small, not an image, or a GIF + if (!shouldResizeImage(file, minFileSizeBytes)) { return { file, resized: false }; } try { const resizeOptions: Partial = { - maxWidth: config?.maxWidth, - maxHeight: config?.maxHeight, - quality: config?.quality, + maxWidth: config.maxWidth, + maxHeight: config.maxHeight, + quality: config.quality, ...options, }; @@ -70,7 +75,7 @@ export const useClientResize = () => { return { file, resized: false }; } }, - [isEnabled, config], + [isEnabled, config, minFileSizeBytes], ); return { diff --git a/client/src/utils/__tests__/imageResize.test.ts b/client/src/utils/__tests__/imageResize.test.ts index c09d2293d1..99d236b684 100644 --- a/client/src/utils/__tests__/imageResize.test.ts +++ b/client/src/utils/__tests__/imageResize.test.ts @@ -53,56 +53,63 @@ describe('imageResize utility', () => { }); describe('shouldResizeImage', () => { - it('should return true for large image files', () => { - const largeImageFile = new File([''], 'test.jpg', { - type: 'image/jpeg', - lastModified: Date.now(), - }); + const makeImage = (size: number, type = 'image/jpeg', name = 'test.jpg') => { + const file = new File([''], name, { type, lastModified: Date.now() }); + Object.defineProperty(file, 'size', { value: size, writable: false }); + return file; + }; - // Mock large file size - Object.defineProperty(largeImageFile, 'size', { - value: 100 * 1024 * 1024, // 100MB - writable: false, - }); - - const result = shouldResizeImage(largeImageFile, 50 * 1024 * 1024); // 50MB limit - expect(result).toBe(true); + it('returns true when image size is above the threshold', () => { + const file = makeImage(5 * 1024 * 1024); + expect(shouldResizeImage(file, 1024 * 1024)).toBe(true); }); - it('should return false for small image files', () => { - const smallImageFile = new File([''], 'test.jpg', { - type: 'image/jpeg', - lastModified: Date.now(), - }); - - // Mock small file size - Object.defineProperty(smallImageFile, 'size', { - value: 1024, // 1KB - writable: false, - }); - - const result = shouldResizeImage(smallImageFile, 50 * 1024 * 1024); // 50MB limit - expect(result).toBe(false); + it('returns false when image size is below the threshold', () => { + const file = makeImage(100 * 1024); + expect(shouldResizeImage(file, 1024 * 1024)).toBe(false); }); - it('should return false for non-image files', () => { + it('returns true when image size equals the threshold (strict-less-than boundary)', () => { + const threshold = 1024 * 1024; + const file = makeImage(threshold); + expect(shouldResizeImage(file, threshold)).toBe(true); + }); + + it('returns false when image size is one byte below the threshold', () => { + const threshold = 1024 * 1024; + const file = makeImage(threshold - 1); + expect(shouldResizeImage(file, threshold)).toBe(false); + }); + + it('treats a 150KB image as below the 1MB threshold (regression for clientImageResize fix)', () => { + const file = makeImage(150 * 1024); + expect(shouldResizeImage(file, 1024 * 1024)).toBe(false); + }); + + it('uses a 1MB default threshold when minSizeBytes is omitted', () => { + const belowDefault = makeImage(512 * 1024); + const aboveDefault = makeImage(2 * 1024 * 1024); + expect(shouldResizeImage(belowDefault)).toBe(false); + expect(shouldResizeImage(aboveDefault)).toBe(true); + }); + + it('allows disabling the size gate with minSizeBytes of 0', () => { + const tinyImage = makeImage(1); + expect(shouldResizeImage(tinyImage, 0)).toBe(true); + }); + + it('returns false for non-image files', () => { const textFile = new File([''], 'test.txt', { type: 'text/plain', lastModified: Date.now(), }); - - const result = shouldResizeImage(textFile); - expect(result).toBe(false); + Object.defineProperty(textFile, 'size', { value: 5 * 1024 * 1024, writable: false }); + expect(shouldResizeImage(textFile, 1024 * 1024)).toBe(false); }); - it('should return false for GIF files', () => { - const gifFile = new File([''], 'test.gif', { - type: 'image/gif', - lastModified: Date.now(), - }); - - const result = shouldResizeImage(gifFile); - expect(result).toBe(false); + it('returns false for GIF files even when above the threshold', () => { + const gifFile = makeImage(5 * 1024 * 1024, 'image/gif', 'test.gif'); + expect(shouldResizeImage(gifFile, 1024 * 1024)).toBe(false); }); }); }); diff --git a/client/src/utils/imageResize.ts b/client/src/utils/imageResize.ts index af13d97ff4..6d3cece67b 100644 --- a/client/src/utils/imageResize.ts +++ b/client/src/utils/imageResize.ts @@ -191,24 +191,21 @@ export function resizeImage( } /** - * Determines if an image should be resized based on size and dimensions + * Determines if an image should be resized. Returns `true` when the file is an + * eligible image type (non-GIF) and is at least `minSizeBytes` in size. + * + * @param file - The file under consideration + * @param minSizeBytes - Files strictly smaller than this are skipped. Defaults to 1 MB. */ -export function shouldResizeImage( - file: File, - fileSizeLimit: number = 512 * 1024 * 1024, // 512MB default -): boolean { - // Don't resize if file is already small - if (file.size < fileSizeLimit * 0.1) { - // Less than 10% of limit +export function shouldResizeImage(file: File, minSizeBytes: number = 1024 * 1024): boolean { + if (file.size < minSizeBytes) { return false; } - // Don't process non-images if (!file.type.startsWith('image/')) { return false; } - // Don't process GIFs (they might be animated) if (file.type === 'image/gif') { return false; } diff --git a/librechat.example.yaml b/librechat.example.yaml index bfdcc60148..5fe3110895 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -788,6 +788,7 @@ endpoints: # maxWidth: 1900 # Maximum width for resized images (default: 1900) # maxHeight: 1900 # Maximum height for resized images (default: 1900) # quality: 0.92 # JPEG quality for compression (0.0-1.0, default: 0.92) +# minFileSizeKB: 1024 # Minimum file size in KB before resize is attempted; files below this threshold are uploaded as-is. Set to 0 to disable the threshold (default: 1024) # # See the Custom Configuration Guide for more information on Assistants Config: # # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 06f6b4806d..612e86ea7d 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -399,6 +399,9 @@ export const megabyte = 1024 * 1024; /** Helper function to get megabytes value */ export const mbToBytes = (mb: number): number => mb * megabyte; +/** Default minimum file size (in KB) for client-side image resize eligibility. */ +export const DEFAULT_MIN_FILE_SIZE_KB = 1024; + const defaultSizeLimit = mbToBytes(512); const defaultSkillImportSizeLimit = mbToBytes(50); const defaultTokenLimit = 100000; @@ -441,6 +444,7 @@ export const fileConfig = { maxWidth: 1900, maxHeight: 1900, quality: 0.92, + minFileSizeKB: DEFAULT_MIN_FILE_SIZE_KB, }, ocr: { supportedMimeTypes: defaultOCRMimeTypes, @@ -488,6 +492,7 @@ export const fileConfigSchema = z.object({ maxWidth: z.number().min(0).optional(), maxHeight: z.number().min(0).optional(), quality: z.number().min(0).max(1).optional(), + minFileSizeKB: z.number().int().min(0).optional(), }) .optional(), ocr: z diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts index d977b1eb99..a8e950a8b6 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -62,6 +62,7 @@ export type FileConfig = { maxWidth?: number; maxHeight?: number; quality?: number; + minFileSizeKB?: number; }; ocr?: { supportedMimeTypes?: RegExp[]; @@ -89,6 +90,7 @@ export type FileConfigInput = { maxWidth?: number; maxHeight?: number; quality?: number; + minFileSizeKB?: number; }; ocr?: { supportedMimeTypes?: string[];