feat: Add optional client-side image resizing to prevent upload errors

This commit is contained in:
Rakshit Tiwari
2025-06-15 11:50:01 +05:30
parent 0103b4b08a
commit 8703b9c2d8
7 changed files with 490 additions and 21 deletions

View File

@@ -0,0 +1,84 @@
import { mergeFileConfig } from 'librechat-data-provider';
import { useCallback } from 'react';
import { useGetFileConfig } from '~/data-provider';
import {
resizeImage,
shouldResizeImage,
supportsClientSideResize,
type ResizeOptions,
type ResizeResult,
} from '~/utils/imageResize';
/**
* Hook for client-side image resizing functionality
* Integrates with LibreChat's file configuration system
*/
export const useClientSideResize = () => {
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
// Safe access to clientSideImageResize config with fallbacks
const config = (fileConfig as any)?.clientSideImageResize ?? {
enabled: false,
maxWidth: 1900,
maxHeight: 1900,
quality: 0.92,
};
const isEnabled = config?.enabled ?? false;
/**
* Resizes an image if client-side resizing is enabled and supported
* @param file - The image file to resize
* @param options - Optional resize options to override defaults
* @returns Promise resolving to either the resized file result or original file
*/
const resizeImageIfNeeded = useCallback(
async (
file: File,
options?: Partial<ResizeOptions>,
): Promise<{ file: File; resized: boolean; result?: ResizeResult }> => {
// Return original file if resizing is disabled
if (!isEnabled) {
return { file, resized: false };
}
// Return original file if browser doesn't support resizing
if (!supportsClientSideResize()) {
console.warn('Client-side image resizing not supported in this browser');
return { file, resized: false };
}
// Return original file if it doesn't need resizing
if (!shouldResizeImage(file)) {
return { file, resized: false };
}
try {
const resizeOptions: Partial<ResizeOptions> = {
maxWidth: config?.maxWidth,
maxHeight: config?.maxHeight,
quality: config?.quality,
...options,
};
const result = await resizeImage(file, resizeOptions);
return { file: result.file, resized: true, result };
} catch (error) {
console.warn('Client-side image resizing failed:', error);
// Return original file on error
return { file, resized: false };
}
},
[isEnabled, config],
);
return {
isEnabled,
isSupported: supportsClientSideResize(),
config,
resizeImageIfNeeded,
};
};
export default useClientSideResize;

View File

@@ -1,24 +1,25 @@
import { v4 } from 'uuid';
import debounce from 'lodash/debounce';
import { useQueryClient } from '@tanstack/react-query';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
QueryKeys,
EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
isAssistantsEndpoint,
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
import {
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
EModelEndpoint,
isAgentsEndpoint,
isAssistantsEndpoint,
mergeFileConfig,
QueryKeys,
} from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid';
import type { ExtendedFile, FileSetter } from '~/common';
import { useUploadFileMutation, useGetFileConfig } from '~/data-provider';
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext';
import { useToastContext } from '~/Providers/ToastContext';
import { logger, validateFiles } from '~/utils';
import useClientSideResize from './useClientSideResize';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import useUpdateFiles from './useUpdateFiles';
type UseFileHandling = {
@@ -40,6 +41,7 @@ const useFileHandling = (params?: UseFileHandling) => {
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
params?.fileSetter ?? setFiles,
);
const { resizeImageIfNeeded } = useClientSideResize();
const agent_id = params?.additionalMetadata?.agent_id ?? '';
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
@@ -262,21 +264,48 @@ const useFileHandling = (params?: UseFileHandling) => {
for (const originalFile of fileList) {
const file_id = v4();
try {
const preview = URL.createObjectURL(originalFile);
let processedFile = originalFile;
// Apply client-side resizing if available and appropriate
if (originalFile.type.startsWith('image/')) {
try {
const resizeResult = await resizeImageIfNeeded(originalFile);
processedFile = resizeResult.file;
// Show toast notification if image was resized
if (resizeResult.resized && resizeResult.result) {
const { originalSize, newSize, compressionRatio } = resizeResult.result;
const originalSizeMB = (originalSize / (1024 * 1024)).toFixed(1);
const newSizeMB = (newSize / (1024 * 1024)).toFixed(1);
const savedPercent = Math.round((1 - compressionRatio) * 100);
showToast({
message: `Image resized: ${originalSizeMB}MB → ${newSizeMB}MB (${savedPercent}% smaller)`,
status: 'success',
duration: 3000,
});
}
} catch (resizeError) {
console.warn('Image resize failed, using original:', resizeError);
// Continue with original file if resizing fails
}
}
const preview = URL.createObjectURL(processedFile);
const extendedFile: ExtendedFile = {
file_id,
file: originalFile,
type: originalFile.type,
file: processedFile,
type: processedFile.type,
preview,
progress: 0.2,
size: originalFile.size,
size: processedFile.size,
};
if (_toolResource != null && _toolResource !== '') {
extendedFile.tool_resource = _toolResource;
}
const isImage = originalFile.type.split('/')[0] === 'image';
const isImage = processedFile.type.split('/')[0] === 'image';
const tool_resource =
extendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {

View File

@@ -0,0 +1,108 @@
/**
* Tests for client-side image resizing utility
*/
import { shouldResizeImage, supportsClientSideResize } from '../imageResize';
// Mock browser APIs for testing
Object.defineProperty(global, 'HTMLCanvasElement', {
value: function () {
return {
getContext: () => ({
drawImage: jest.fn(),
}),
toBlob: jest.fn(),
};
},
writable: true,
});
Object.defineProperty(global, 'FileReader', {
value: function () {
return {
readAsDataURL: jest.fn(),
};
},
writable: true,
});
Object.defineProperty(global, 'Image', {
value: function () {
return {};
},
writable: true,
});
describe('imageResize utility', () => {
describe('supportsClientSideResize', () => {
it('should return true when all required APIs are available', () => {
const result = supportsClientSideResize();
expect(result).toBe(true);
});
it('should return false when HTMLCanvasElement is not available', () => {
const originalCanvas = global.HTMLCanvasElement;
// @ts-ignore
delete global.HTMLCanvasElement;
const result = supportsClientSideResize();
expect(result).toBe(false);
global.HTMLCanvasElement = originalCanvas;
});
});
describe('shouldResizeImage', () => {
it('should return true for large image files', () => {
const largeImageFile = new File([''], 'test.jpg', {
type: 'image/jpeg',
lastModified: Date.now(),
});
// 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('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('should return 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);
});
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);
});
});
});

View File

@@ -0,0 +1,214 @@
/**
* Client-side image resizing utility for LibreChat
* Resizes images to prevent backend upload errors while maintaining quality
*/
export interface ResizeOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
format?: 'jpeg' | 'png' | 'webp';
}
export interface ResizeResult {
file: File;
originalSize: number;
newSize: number;
originalDimensions: { width: number; height: number };
newDimensions: { width: number; height: number };
compressionRatio: number;
}
/**
* Default resize options based on backend 'high' resolution settings
* Backend 'high' uses maxShortSide=768, maxLongSide=2000
* We use slightly smaller values to ensure no backend resizing is triggered
*/
const DEFAULT_RESIZE_OPTIONS: ResizeOptions = {
maxWidth: 1900, // Slightly less than backend maxLongSide=2000
maxHeight: 1900, // Slightly less than backend maxLongSide=2000
quality: 0.92, // High quality while reducing file size
format: 'jpeg', // Most compatible format
};
/**
* Checks if the browser supports canvas-based image resizing
*/
export function supportsClientSideResize(): boolean {
try {
// Check for required APIs
if (typeof HTMLCanvasElement === 'undefined') return false;
if (typeof FileReader === 'undefined') return false;
if (typeof Image === 'undefined') return false;
// Test canvas creation
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
return !!(ctx && ctx.drawImage && canvas.toBlob);
} catch {
return false;
}
}
/**
* Calculates new dimensions while maintaining aspect ratio
*/
function calculateDimensions(
originalWidth: number,
originalHeight: number,
maxWidth: number,
maxHeight: number,
): { width: number; height: number } {
let { width, height } = { width: originalWidth, height: originalHeight };
// If image is smaller than max dimensions, don't upscale
if (width <= maxWidth && height <= maxHeight) {
return { width, height };
}
// Calculate scaling factor
const widthRatio = maxWidth / width;
const heightRatio = maxHeight / height;
const scalingFactor = Math.min(widthRatio, heightRatio);
return {
width: Math.round(width * scalingFactor),
height: Math.round(height * scalingFactor),
};
}
/**
* Resizes an image file using canvas
*/
export function resizeImage(
file: File,
options: Partial<ResizeOptions> = {},
): Promise<ResizeResult> {
return new Promise((resolve, reject) => {
// Check browser support
if (!supportsClientSideResize()) {
reject(new Error('Browser does not support client-side image resizing'));
return;
}
// Only process image files
if (!file.type.startsWith('image/')) {
reject(new Error('File is not an image'));
return;
}
const opts = { ...DEFAULT_RESIZE_OPTIONS, ...options };
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
try {
const originalDimensions = { width: img.width, height: img.height };
const newDimensions = calculateDimensions(
img.width,
img.height,
opts.maxWidth!,
opts.maxHeight!,
);
// If no resizing needed, return original file
if (
newDimensions.width === originalDimensions.width &&
newDimensions.height === originalDimensions.height
) {
resolve({
file,
originalSize: file.size,
newSize: file.size,
originalDimensions,
newDimensions,
compressionRatio: 1,
});
return;
}
// Create canvas and resize
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = newDimensions.width;
canvas.height = newDimensions.height;
// Use high-quality image smoothing
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Draw resized image
ctx.drawImage(img, 0, 0, newDimensions.width, newDimensions.height);
// Convert to blob
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to create blob from canvas'));
return;
}
// Create new file with same name but potentially different extension
const extension = opts.format === 'jpeg' ? '.jpg' : `.${opts.format}`;
const baseName = file.name.replace(/\.[^/.]+$/, '');
const newFileName = `${baseName}${extension}`;
const resizedFile = new File([blob], newFileName, {
type: `image/${opts.format}`,
lastModified: Date.now(),
});
resolve({
file: resizedFile,
originalSize: file.size,
newSize: resizedFile.size,
originalDimensions,
newDimensions,
compressionRatio: resizedFile.size / file.size,
});
},
`image/${opts.format}`,
opts.quality,
);
} catch (error) {
reject(error);
}
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = event.target?.result as string;
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
/**
* Determines if an image should be resized based on size and dimensions
*/
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
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;
}
return true;
}

View File

@@ -297,6 +297,12 @@ endpoints:
# imageGeneration: # Image Gen settings, either percentage or px
# percentage: 100
# px: 1024
# # Client-side image resizing to prevent upload errors
# clientSideImageResize:
# enabled: false # Enable/disable client-side image resizing (default: false)
# 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)
# # 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

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { EModelEndpoint } from './schemas';
import type { FileConfig, EndpointFileConfig } from './types/files';
import type { EndpointFileConfig, FileConfig } from './types/files';
export const supportsFiles = {
[EModelEndpoint.openAI]: true,
@@ -187,6 +187,12 @@ export const fileConfig = {
},
serverFileSizeLimit: defaultSizeLimit,
avatarSizeLimit: mbToBytes(2),
clientSideImageResize: {
enabled: false, // Disabled by default as per Danny's requirement
maxWidth: 1900,
maxHeight: 1900,
quality: 0.92,
},
checkType: function (fileType: string, supportedTypes: RegExp[] = supportedMimeTypes) {
return supportedTypes.some((regex) => regex.test(fileType));
},
@@ -227,6 +233,14 @@ export const fileConfigSchema = z.object({
px: z.number().min(0).optional(),
})
.optional(),
clientSideImageResize: z
.object({
enabled: z.boolean().optional(),
maxWidth: z.number().min(0).optional(),
maxHeight: z.number().min(0).optional(),
quality: z.number().min(0).max(1).optional(),
})
.optional(),
});
/** Helper function to safely convert string patterns to RegExp objects */
@@ -255,6 +269,14 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
mergedConfig.avatarSizeLimit = mbToBytes(dynamic.avatarSizeLimit);
}
// Merge clientSideImageResize configuration
if (dynamic.clientSideImageResize !== undefined) {
mergedConfig.clientSideImageResize = {
...mergedConfig.clientSideImageResize,
...dynamic.clientSideImageResize,
};
}
if (!dynamic.endpoints) {
return mergedConfig;
}

View File

@@ -48,6 +48,12 @@ export type FileConfig = {
};
serverFileSizeLimit?: number;
avatarSizeLimit?: number;
clientSideImageResize?: {
enabled?: boolean;
maxWidth?: number;
maxHeight?: number;
quality?: number;
};
checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean;
};