This commit is contained in:
mattdaniell 2026-06-26 22:01:40 -04:00 committed by GitHub
commit 41d388665c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 204 additions and 66 deletions

View file

@ -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();
});
});

View file

@ -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<ResizeOptions> = {
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 {

View file

@ -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);
});
});
});

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -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[];