mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🖼️ feat: Add Optional Client-Side Image Resizing to Prevent Upload Errors (#7909)
* feat: Add optional client-side image resizing to prevent upload errors * Addressing comments from author * Addressing eslint errors * Fixing the naming to clientresize from clientsideresize
This commit is contained in:
parent
d9a0fe03ed
commit
42977ac0d0
7 changed files with 480 additions and 10 deletions
84
client/src/hooks/Files/useClientResize.ts
Normal file
84
client/src/hooks/Files/useClientResize.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,
|
||||||
|
supportsClientResize,
|
||||||
|
type ResizeOptions,
|
||||||
|
type ResizeResult,
|
||||||
|
} from '~/utils/imageResize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for client-side image resizing functionality
|
||||||
|
* Integrates with LibreChat's file configuration system
|
||||||
|
*/
|
||||||
|
export const useClientResize = () => {
|
||||||
|
const { data: fileConfig = null } = useGetFileConfig({
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (!supportsClientResize()) {
|
||||||
|
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 { file, resized: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isEnabled, config],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEnabled,
|
||||||
|
isSupported: supportsClientResize(),
|
||||||
|
config,
|
||||||
|
resizeImageIfNeeded,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useClientResize;
|
||||||
|
|
@ -18,6 +18,7 @@ import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||||
import { useChatContext } from '~/Providers/ChatContext';
|
import { useChatContext } from '~/Providers/ChatContext';
|
||||||
import { useToastContext } from '~/Providers/ToastContext';
|
import { useToastContext } from '~/Providers/ToastContext';
|
||||||
import { logger, validateFiles } from '~/utils';
|
import { logger, validateFiles } from '~/utils';
|
||||||
|
import useClientResize from './useClientResize';
|
||||||
import { processFileForUpload } from '~/utils/heicConverter';
|
import { processFileForUpload } from '~/utils/heicConverter';
|
||||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||||
import useUpdateFiles from './useUpdateFiles';
|
import useUpdateFiles from './useUpdateFiles';
|
||||||
|
|
@ -41,6 +42,7 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||||
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
|
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
|
||||||
params?.fileSetter ?? setFiles,
|
params?.fileSetter ?? setFiles,
|
||||||
);
|
);
|
||||||
|
const { resizeImageIfNeeded } = useClientResize();
|
||||||
|
|
||||||
const agent_id = params?.additionalMetadata?.agent_id ?? '';
|
const agent_id = params?.additionalMetadata?.agent_id ?? '';
|
||||||
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
|
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
|
||||||
|
|
@ -298,7 +300,7 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process file for HEIC conversion if needed
|
// Process file for HEIC conversion if needed
|
||||||
const processedFile = await processFileForUpload(
|
const heicProcessedFile = await processFileForUpload(
|
||||||
originalFile,
|
originalFile,
|
||||||
0.9,
|
0.9,
|
||||||
(conversionProgress) => {
|
(conversionProgress) => {
|
||||||
|
|
@ -311,23 +313,50 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// If file was converted, update with new file and preview
|
let finalProcessedFile = heicProcessedFile;
|
||||||
if (processedFile !== originalFile) {
|
|
||||||
|
// Apply client-side resizing if available and appropriate
|
||||||
|
if (heicProcessedFile.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
const resizeResult = await resizeImageIfNeeded(heicProcessedFile);
|
||||||
|
finalProcessedFile = 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 HEIC processed file if resizing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file was processed (HEIC converted or resized), update with new file and preview
|
||||||
|
if (finalProcessedFile !== originalFile) {
|
||||||
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
||||||
const newPreview = URL.createObjectURL(processedFile);
|
const newPreview = URL.createObjectURL(finalProcessedFile);
|
||||||
|
|
||||||
const updatedExtendedFile: ExtendedFile = {
|
const updatedExtendedFile: ExtendedFile = {
|
||||||
...initialExtendedFile,
|
...initialExtendedFile,
|
||||||
file: processedFile,
|
file: finalProcessedFile,
|
||||||
type: processedFile.type,
|
type: finalProcessedFile.type,
|
||||||
preview: newPreview,
|
preview: newPreview,
|
||||||
progress: 0.5, // Conversion complete, ready for upload
|
progress: 0.5, // Processing complete, ready for upload
|
||||||
size: processedFile.size,
|
size: finalProcessedFile.size,
|
||||||
};
|
};
|
||||||
|
|
||||||
replaceFile(updatedExtendedFile);
|
replaceFile(updatedExtendedFile);
|
||||||
|
|
||||||
const isImage = processedFile.type.split('/')[0] === 'image';
|
const isImage = finalProcessedFile.type.split('/')[0] === 'image';
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
loadImage(updatedExtendedFile, newPreview);
|
loadImage(updatedExtendedFile, newPreview);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -335,7 +364,7 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||||
|
|
||||||
await startUpload(updatedExtendedFile);
|
await startUpload(updatedExtendedFile);
|
||||||
} else {
|
} else {
|
||||||
// File wasn't converted, proceed with original
|
// File wasn't processed, proceed with original
|
||||||
const isImage = originalFile.type.split('/')[0] === 'image';
|
const isImage = originalFile.type.split('/')[0] === 'image';
|
||||||
const tool_resource =
|
const tool_resource =
|
||||||
initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
||||||
|
|
|
||||||
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, supportsClientResize } 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('supportsClientResize', () => {
|
||||||
|
it('should return true when all required APIs are available', () => {
|
||||||
|
const result = supportsClientResize();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when HTMLCanvasElement is not available', () => {
|
||||||
|
const originalCanvas = global.HTMLCanvasElement;
|
||||||
|
// @ts-ignore
|
||||||
|
delete global.HTMLCanvasElement;
|
||||||
|
|
||||||
|
const result = supportsClientResize();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
215
client/src/utils/imageResize.ts
Normal file
215
client/src/utils/imageResize.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
/**
|
||||||
|
* 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 supportsClientResize(): 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 } {
|
||||||
|
const { 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 (!supportsClientResize()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -300,6 +300,12 @@ endpoints:
|
||||||
# imageGeneration: # Image Gen settings, either percentage or px
|
# imageGeneration: # Image Gen settings, either percentage or px
|
||||||
# percentage: 100
|
# percentage: 100
|
||||||
# px: 1024
|
# px: 1024
|
||||||
|
# # Client-side image resizing to prevent upload errors
|
||||||
|
# clientImageResize:
|
||||||
|
# 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:
|
# # See the Custom Configuration Guide for more information on Assistants Config:
|
||||||
# # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint
|
# # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,12 @@ export const fileConfig = {
|
||||||
},
|
},
|
||||||
serverFileSizeLimit: defaultSizeLimit,
|
serverFileSizeLimit: defaultSizeLimit,
|
||||||
avatarSizeLimit: mbToBytes(2),
|
avatarSizeLimit: mbToBytes(2),
|
||||||
|
clientImageResize: {
|
||||||
|
enabled: false,
|
||||||
|
maxWidth: 1900,
|
||||||
|
maxHeight: 1900,
|
||||||
|
quality: 0.92,
|
||||||
|
},
|
||||||
checkType: function (fileType: string, supportedTypes: RegExp[] = supportedMimeTypes) {
|
checkType: function (fileType: string, supportedTypes: RegExp[] = supportedMimeTypes) {
|
||||||
return supportedTypes.some((regex) => regex.test(fileType));
|
return supportedTypes.some((regex) => regex.test(fileType));
|
||||||
},
|
},
|
||||||
|
|
@ -232,6 +238,14 @@ export const fileConfigSchema = z.object({
|
||||||
px: z.number().min(0).optional(),
|
px: z.number().min(0).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
clientImageResize: 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 */
|
/** Helper function to safely convert string patterns to RegExp objects */
|
||||||
|
|
@ -260,6 +274,14 @@ export function mergeFileConfig(dynamic: z.infer<typeof fileConfigSchema> | unde
|
||||||
mergedConfig.avatarSizeLimit = mbToBytes(dynamic.avatarSizeLimit);
|
mergedConfig.avatarSizeLimit = mbToBytes(dynamic.avatarSizeLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge clientImageResize configuration
|
||||||
|
if (dynamic.clientImageResize !== undefined) {
|
||||||
|
mergedConfig.clientImageResize = {
|
||||||
|
...mergedConfig.clientImageResize,
|
||||||
|
...dynamic.clientImageResize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!dynamic.endpoints) {
|
if (!dynamic.endpoints) {
|
||||||
return mergedConfig;
|
return mergedConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ export type FileConfig = {
|
||||||
};
|
};
|
||||||
serverFileSizeLimit?: number;
|
serverFileSizeLimit?: number;
|
||||||
avatarSizeLimit?: number;
|
avatarSizeLimit?: number;
|
||||||
|
clientImageResize?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
quality?: number;
|
||||||
|
};
|
||||||
checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean;
|
checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue