feat: Add optional client-side image resizing to prevent upload errors
This commit is contained in:
84
client/src/hooks/Files/useClientSideResize.ts
Normal file
84
client/src/hooks/Files/useClientSideResize.ts
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
108
client/src/utils/__tests__/imageResize.test.ts
Normal file
108
client/src/utils/__tests__/imageResize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
214
client/src/utils/imageResize.ts
Normal file
214
client/src/utils/imageResize.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user