From 5996880be15b3e60b4c4888b832d2cfa839aac82 Mon Sep 17 00:00:00 2001 From: Matthew Daniell Date: Tue, 31 Mar 2026 13:41:25 +1030 Subject: [PATCH 1/3] fix: correct client image resize threshold handling Add configurable minFileSizeKB support to clientImageResize and pass it through to shouldResizeImage so typical user photos are eligible for resize. Also document minFileSizeKB in librechat.example.yaml with a practical default. Made-with: Cursor --- client/src/hooks/Files/useClientResize.ts | 8 +++++--- librechat.example.yaml | 1 + packages/data-provider/src/file-config.ts | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/src/hooks/Files/useClientResize.ts b/client/src/hooks/Files/useClientResize.ts index 1da3848aeb..fcd1838a46 100644 --- a/client/src/hooks/Files/useClientResize.ts +++ b/client/src/hooks/Files/useClientResize.ts @@ -25,8 +25,10 @@ export const useClientResize = () => { maxWidth: 1900, maxHeight: 1900, quality: 0.92, + minFileSizeKB: 1024, }; const isEnabled = config?.enabled ?? false; + const minFileSizeBytes = (config?.minFileSizeKB ?? 1024) * 1024; /** * Resizes an image if client-side resizing is enabled and supported @@ -50,8 +52,8 @@ export const useClientResize = () => { return { file, resized: false }; } - // Return original file if it doesn't need resizing - if (!shouldResizeImage(file)) { + // Return original file if it's below the minimum size threshold + if (!shouldResizeImage(file, minFileSizeBytes)) { return { file, resized: false }; } @@ -70,7 +72,7 @@ export const useClientResize = () => { return { file, resized: false }; } }, - [isEnabled, config], + [isEnabled, config, minFileSizeBytes], ); return { diff --git a/librechat.example.yaml b/librechat.example.yaml index 03bb5f5bc2..6facac0765 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -570,6 +570,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 # Skip resizing files smaller than this size in KB (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 32a1a28cc9..e8781ba6b2 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -427,6 +427,7 @@ export const fileConfig = { maxWidth: 1900, maxHeight: 1900, quality: 0.92, + minFileSizeKB: 1024, }, ocr: { supportedMimeTypes: defaultOCRMimeTypes, @@ -484,6 +485,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().min(0).optional(), }) .optional(), ocr: z From c8a056ebb6cb5bdb0bc778135affccde98e1f337 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 16 Apr 2026 14:50:54 -0400 Subject: [PATCH 2/3] fix: address review for clientImageResize threshold handling - Remove internal `* 0.1` multiplier in `shouldResizeImage`; rename its second parameter to `minSizeBytes` so the threshold matches what callers pass, fixing the documented behavior of `minFileSizeKB`. - Add `minFileSizeKB` to `FileConfig` and `FileConfigInput` types so the field is visible to typed consumers; remove `as any` cast and the `eslint-disable` line in `useClientResize`. - Tighten Zod schema to `z.number().int().min(0)` to reject fractional KB values; update YAML comment to describe the actual semantics (including `minFileSizeKB: 0` as "disable threshold"). - Introduce `DEFAULT_MIN_FILE_SIZE_KB` shared constant to dedupe the default across config, hook, and tests. - Memoize `config` in `useClientResize` so its reference is stable when `fileConfig` is unchanged, satisfying `react-hooks/exhaustive-deps` without suppression. - Expand `imageResize.test.ts` with boundary coverage (exact threshold, one byte below, 150KB regression case, `minSizeBytes: 0` disables gate, non-image and GIF gating) and add an integration test for `useClientResize` exercising enabled/disabled, threshold skip, threshold resize, and default fallback paths. --- .../Files/__tests__/useClientResize.test.ts | 121 ++++++++++++++++++ client/src/hooks/Files/useClientResize.ts | 37 +++--- .../src/utils/__tests__/imageResize.test.ts | 85 ++++++------ client/src/utils/imageResize.ts | 17 +-- librechat.example.yaml | 2 +- packages/data-provider/src/file-config.ts | 7 +- packages/data-provider/src/types/files.ts | 2 + 7 files changed, 202 insertions(+), 69 deletions(-) create mode 100644 client/src/hooks/Files/__tests__/useClientResize.test.ts 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 fcd1838a46..9c43f306f9 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 { DEFAULT_MIN_FILE_SIZE_KB, mergeFileConfig } from 'librechat-data-provider'; +import { useCallback, useMemo } from 'react'; 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,17 +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, - minFileSizeKB: 1024, - }; - const isEnabled = config?.enabled ?? false; - const minFileSizeBytes = (config?.minFileSizeKB ?? 1024) * 1024; + 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 @@ -52,16 +55,16 @@ export const useClientResize = () => { return { file, resized: false }; } - // Return original file if it's below the minimum size threshold + // 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, }; 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 3be6e8d8c0..b90e3518fe 100644 --- a/client/src/utils/imageResize.ts +++ b/client/src/utils/imageResize.ts @@ -189,24 +189,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 6facac0765..5de08c58c7 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -570,7 +570,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 # Skip resizing files smaller than this size in KB (default: 1024) +# 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 9c2b3bf7df..03e54e6236 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -389,6 +389,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 defaultTokenLimit = 100000; const assistantsFileConfig = { @@ -427,7 +430,7 @@ export const fileConfig = { maxWidth: 1900, maxHeight: 1900, quality: 0.92, - minFileSizeKB: 1024, + minFileSizeKB: DEFAULT_MIN_FILE_SIZE_KB, }, ocr: { supportedMimeTypes: defaultOCRMimeTypes, @@ -470,7 +473,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().min(0).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 1eb8c200d6..c4d9db4101 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -56,6 +56,7 @@ export type FileConfig = { maxWidth?: number; maxHeight?: number; quality?: number; + minFileSizeKB?: number; }; ocr?: { supportedMimeTypes?: RegExp[]; @@ -80,6 +81,7 @@ export type FileConfigInput = { maxWidth?: number; maxHeight?: number; quality?: number; + minFileSizeKB?: number; }; ocr?: { supportedMimeTypes?: string[]; From 34ac1bfa58137ee5bb6a00772500442131bc21db Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 16 Apr 2026 15:01:13 -0400 Subject: [PATCH 3/3] style: Reorder Imports to Put react First in useClientResize --- client/src/hooks/Files/useClientResize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/hooks/Files/useClientResize.ts b/client/src/hooks/Files/useClientResize.ts index 9c43f306f9..d089e0cf27 100644 --- a/client/src/hooks/Files/useClientResize.ts +++ b/client/src/hooks/Files/useClientResize.ts @@ -1,5 +1,5 @@ -import { DEFAULT_MIN_FILE_SIZE_KB, mergeFileConfig } from 'librechat-data-provider'; import { useCallback, useMemo } from 'react'; +import { DEFAULT_MIN_FILE_SIZE_KB, mergeFileConfig } from 'librechat-data-provider'; import { useGetFileConfig } from '~/data-provider'; import { resizeImage,