mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-29 02:41:26 +00:00
Merge 6cfeef9a6d into 186b738d2d
This commit is contained in:
commit
41d388665c
7 changed files with 204 additions and 66 deletions
121
client/src/hooks/Files/__tests__/useClientResize.test.ts
Normal file
121
client/src/hooks/Files/__tests__/useClientResize.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue