diff --git a/client/src/components/Nav/Settings/__tests__/ImageResize.spec.tsx b/client/src/components/Nav/Settings/__tests__/ImageResize.spec.tsx
new file mode 100644
index 0000000000..fdd59ccced
--- /dev/null
+++ b/client/src/components/Nav/Settings/__tests__/ImageResize.spec.tsx
@@ -0,0 +1,106 @@
+import { render, screen } from '@testing-library/react';
+import ImageResize from '../../SettingsTabs/Chat/ImageResize';
+
+const mockSetUserPreference = jest.fn();
+let mockResizeState = {
+ isEnabled: true,
+ isEnforced: false,
+ isSupported: true,
+ isConfigPending: false,
+ isConfigUnavailable: false,
+};
+
+jest.mock('recoil', () => ({
+ useSetRecoilState: () => mockSetUserPreference,
+}));
+
+jest.mock('~/store', () => ({
+ __esModule: true,
+ default: { clientImageResize: { key: 'clientImageResize' } },
+}));
+
+jest.mock('~/hooks', () => ({
+ useClientResize: () => mockResizeState,
+ useLocalize: () => (key: string) => key,
+}));
+
+describe('ImageResize', () => {
+ beforeEach(() => {
+ mockResizeState = {
+ isEnabled: true,
+ isEnforced: false,
+ isSupported: true,
+ isConfigPending: false,
+ isConfigUnavailable: false,
+ };
+ });
+
+ it('keeps the supported user setting interactive', () => {
+ render();
+
+ expect(screen.getByTestId('clientImageResize')).toBeEnabled();
+ expect(
+ screen.getByRole('button', { name: 'com_nav_info_client_image_resize' }),
+ ).toBeInTheDocument();
+ });
+
+ it('disables the setting and explains when client resizing is unsupported', () => {
+ mockResizeState.isSupported = false;
+
+ render();
+
+ expect(screen.getByTestId('clientImageResize')).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: 'com_nav_info_client_image_resize_unsupported' }),
+ ).toBeInTheDocument();
+ });
+
+ it('prioritizes the unsupported explanation over the enforced explanation', () => {
+ mockResizeState.isSupported = false;
+ mockResizeState.isEnforced = true;
+
+ render();
+
+ expect(screen.getByTestId('clientImageResize')).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: 'com_nav_info_client_image_resize_unsupported' }),
+ ).toBeInTheDocument();
+ });
+
+ it('disables the setting while the file config is pending', () => {
+ mockResizeState.isEnabled = false;
+ mockResizeState.isConfigPending = true;
+
+ render();
+
+ const toggle = screen.getByTestId('clientImageResize');
+ expect(toggle).toBeDisabled();
+ expect(toggle).toHaveAttribute('aria-busy', 'true');
+ expect(
+ screen.getByRole('button', { name: 'com_nav_info_client_image_resize' }),
+ ).toBeInTheDocument();
+ });
+
+ it('disables the setting and explains when the file config is unavailable', () => {
+ mockResizeState.isEnabled = false;
+ mockResizeState.isConfigUnavailable = true;
+
+ render();
+
+ expect(screen.getByTestId('clientImageResize')).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: 'com_nav_info_client_image_resize_unavailable' }),
+ ).toBeInTheDocument();
+ });
+
+ it('keeps the enforced behavior when client resizing is supported', () => {
+ mockResizeState.isEnforced = true;
+
+ render();
+
+ expect(screen.getByTestId('clientImageResize')).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: 'com_nav_info_client_image_resize_enforced' }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx
index 426c3aaa8a..daea5f3791 100644
--- a/client/src/components/Nav/Settings/registry.tsx
+++ b/client/src/components/Nav/Settings/registry.tsx
@@ -38,6 +38,7 @@ import { ClearChats } from '../SettingsTabs/Data/ClearChats';
import { TokenCredits, AutoRefill } from './BillingControls';
import AdminPanel from '../SettingsTabs/General/AdminPanel';
import SharedLinks from '../SettingsTabs/Data/SharedLinks';
+import ImageResize from '../SettingsTabs/Chat/ImageResize';
import { showThinkingAtom } from '~/store/showThinking';
import ProviderKeys from '../SettingsTabs/ProviderKeys';
import Avatar from '../SettingsTabs/Account/Avatar';
@@ -195,6 +196,14 @@ export const registry: SettingEntry[] = [
hoverCardText: 'com_nav_info_save_draft',
}),
},
+ {
+ id: 'clientImageResize',
+ tab: CHAT,
+ section: 'sending',
+ labelKey: 'com_nav_client_image_resize',
+ keywords: ['image', 'resize', 'compress', 'upload', 'attachment', 'photo'],
+ Component: ImageResize,
+ },
{
id: 'saveBadgesState',
tab: CHAT,
diff --git a/client/src/components/Nav/SettingsTabs/Chat/ImageResize.tsx b/client/src/components/Nav/SettingsTabs/Chat/ImageResize.tsx
new file mode 100644
index 0000000000..4bb436cae9
--- /dev/null
+++ b/client/src/components/Nav/SettingsTabs/Chat/ImageResize.tsx
@@ -0,0 +1,46 @@
+import { useSetRecoilState } from 'recoil';
+import { Switch, InfoHoverCard, ESide } from '@librechat/client';
+import { useLocalize, useClientResize } from '~/hooks';
+import store from '~/store';
+
+const getInfoKey = (isSupported: boolean, isEnforced: boolean, isConfigUnavailable: boolean) => {
+ if (!isSupported) {
+ return 'com_nav_info_client_image_resize_unsupported' as const;
+ }
+ if (isConfigUnavailable) {
+ return 'com_nav_info_client_image_resize_unavailable' as const;
+ }
+ if (isEnforced) {
+ return 'com_nav_info_client_image_resize_enforced' as const;
+ }
+ return 'com_nav_info_client_image_resize' as const;
+};
+
+export default function ImageResize() {
+ const localize = useLocalize();
+ const setUserPreference = useSetRecoilState(store.clientImageResize);
+ const { isEnabled, isEnforced, isSupported, isConfigPending, isConfigUnavailable } =
+ useClientResize();
+
+ const labelId = 'clientImageResize-label';
+ const infoKey = getInfoKey(isSupported, isEnforced, isConfigUnavailable);
+
+ return (
+
+
+
{localize('com_nav_client_image_resize')}
+
+
+
+
+ );
+}
diff --git a/client/src/hooks/Files/__tests__/useClientResize.spec.tsx b/client/src/hooks/Files/__tests__/useClientResize.spec.tsx
new file mode 100644
index 0000000000..7699a3c229
--- /dev/null
+++ b/client/src/hooks/Files/__tests__/useClientResize.spec.tsx
@@ -0,0 +1,194 @@
+import React from 'react';
+import { RecoilRoot } from 'recoil';
+import { renderHook } from '@testing-library/react';
+import type { FileConfigInput } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import useClientResize from '../useClientResize';
+
+let mockAdminConfig: FileConfigInput | undefined;
+let mockConfigIsSuccess = true;
+let mockConfigIsError = false;
+let mockConfigIsPaused = false;
+
+jest.mock('~/data-provider', () => ({
+ useGetFileConfig: ({ select }: { select: (data: unknown) => T }) => ({
+ data: select(mockAdminConfig),
+ isError: mockConfigIsError,
+ isPaused: mockConfigIsPaused,
+ isSuccess: mockConfigIsSuccess,
+ }),
+}));
+
+const wrapper = ({ children }: { children: ReactNode }) => {children};
+
+const setUserPreference = (value: boolean) =>
+ localStorage.setItem('clientImageResize', JSON.stringify(value));
+
+describe('useClientResize', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ mockAdminConfig = undefined;
+ mockConfigIsSuccess = true;
+ mockConfigIsError = false;
+ mockConfigIsPaused = false;
+ });
+
+ it('keeps resizing off until the file config resolves', () => {
+ mockConfigIsSuccess = false;
+ setUserPreference(true);
+
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(false);
+ expect(result.current.isConfigPending).toBe(true);
+ });
+
+ it('reports the config as unavailable when the request errors', () => {
+ mockConfigIsSuccess = false;
+ mockConfigIsError = true;
+ setUserPreference(true);
+
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isConfigPending).toBe(false);
+ expect(result.current.isConfigUnavailable).toBe(true);
+ expect(result.current.isEnabled).toBe(false);
+ });
+
+ it('does not report the config as unavailable once it resolves', () => {
+ setUserPreference(true);
+
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isConfigUnavailable).toBe(false);
+ expect(result.current.isEnabled).toBe(true);
+ });
+
+ it('releases uploads when the file config request errors', async () => {
+ mockConfigIsSuccess = false;
+ const { result, rerender } = renderHook(() => useClientResize(), { wrapper });
+ let waitFinished = false;
+ const pendingWait = result.current.waitForConfig().then(() => {
+ waitFinished = true;
+ });
+
+ await Promise.resolve();
+ expect(waitFinished).toBe(false);
+
+ mockConfigIsError = true;
+ rerender();
+ await pendingWait;
+
+ expect(result.current.isConfigPending).toBe(false);
+ expect(waitFinished).toBe(true);
+ });
+
+ it('does not block uploads when the file config request is paused offline', async () => {
+ mockConfigIsSuccess = false;
+ mockConfigIsPaused = true;
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ await expect(result.current.waitForConfig()).resolves.toBeUndefined();
+ expect(result.current.isConfigPending).toBe(false);
+ });
+
+ it('fails open after a bounded wait when the file config request never settles', async () => {
+ jest.useFakeTimers();
+ try {
+ mockConfigIsSuccess = false;
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+ let waitFinished = false;
+ const pendingWait = result.current.waitForConfig().then(() => {
+ waitFinished = true;
+ });
+
+ await Promise.resolve();
+ expect(waitFinished).toBe(false);
+
+ jest.advanceTimersByTime(10_000);
+ await pendingWait;
+
+ expect(waitFinished).toBe(true);
+ await expect(result.current.waitForConfig()).resolves.toBeUndefined();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ describe('without an admin-configured value', () => {
+ it('follows the user setting when it is off', () => {
+ setUserPreference(false);
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(false);
+ expect(result.current.isEnforced).toBe(false);
+ });
+
+ it('follows the user setting when it is on', () => {
+ setUserPreference(true);
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(result.current.isEnforced).toBe(false);
+ });
+
+ it('defaults to off when the user has no stored setting', () => {
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(false);
+ });
+ });
+
+ describe('with an admin-configured value', () => {
+ it('overrides a user setting that is on', () => {
+ mockAdminConfig = { clientImageResize: { enabled: false } };
+ setUserPreference(true);
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(false);
+ expect(result.current.isEnforced).toBe(true);
+ });
+
+ it('overrides a user setting that is off', () => {
+ mockAdminConfig = { clientImageResize: { enabled: true } };
+ setUserPreference(false);
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(result.current.isEnforced).toBe(true);
+ });
+
+ it('applies resize parameters while leaving the toggle to the user', () => {
+ mockAdminConfig = { clientImageResize: { maxWidth: 1024, quality: 0.8 } };
+ setUserPreference(true);
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(result.current.isEnforced).toBe(false);
+ expect(result.current.config.maxWidth).toBe(1024);
+ expect(result.current.config.quality).toBe(0.8);
+ });
+
+ it.each(['maxWidth', 'maxHeight'] as const)(
+ 'keeps resizing off when the admin config has a non-positive %s',
+ (dimension) => {
+ mockAdminConfig = { clientImageResize: { enabled: true, [dimension]: 0 } };
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ expect(result.current.isEnabled).toBe(false);
+ },
+ );
+ });
+
+ it('returns the original file untouched while disabled', async () => {
+ mockAdminConfig = { clientImageResize: { enabled: false } };
+ setUserPreference(true);
+ const { result } = renderHook(() => useClientResize(), { wrapper });
+
+ const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
+ const outcome = await result.current.resizeImageIfNeeded(file);
+
+ expect(outcome.resized).toBe(false);
+ expect(outcome.file).toBe(file);
+ });
+});
diff --git a/client/src/hooks/Files/__tests__/useFileHandling.test.ts b/client/src/hooks/Files/__tests__/useFileHandling.test.ts
index efb4defcab..1d1ed765cf 100644
--- a/client/src/hooks/Files/__tests__/useFileHandling.test.ts
+++ b/client/src/hooks/Files/__tests__/useFileHandling.test.ts
@@ -1,5 +1,11 @@
import { renderHook, act } from '@testing-library/react';
-import { Constants, EModelEndpoint, getEndpointFileConfig } from 'librechat-data-provider';
+import {
+ megabyte,
+ Constants,
+ mergeFileConfig,
+ EModelEndpoint,
+ getEndpointFileConfig,
+} from 'librechat-data-provider';
beforeAll(() => {
global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
@@ -24,9 +30,19 @@ const mockMutate = jest.fn();
const mockProcessFileForUpload = jest.fn(
async (_file: File, _quality?: number, _onProgress?: (progress: number) => void) => _file,
);
+const mockResizeImageIfNeeded = jest.fn(async (file: File) => ({ file, resized: false }));
+const mockWaitForConfig = jest.fn((): Promise => Promise.resolve());
const mockLocalize = jest.fn((key: string) => key);
+const makeSizedFile = (name: string, type: string, size: number): File => {
+ const file = new File(['content'], name, { type });
+ Object.defineProperty(file, 'size', { value: size });
+ return file;
+};
+
let mockConversation: Record = {};
+let mockFileConfig: ReturnType | null = null;
+let mockIsConfigPending = false;
let mockIsTemporary = false;
jest.mock('~/Providers/ChatContext', () => ({
@@ -64,7 +80,7 @@ jest.mock('@tanstack/react-query', () => ({
}));
jest.mock('~/data-provider', () => ({
- useGetFileConfig: jest.fn(() => ({ data: null })),
+ useGetFileConfig: jest.fn(() => ({ data: mockFileConfig })),
useUploadFileMutation: jest.fn((_opts: Record) => ({
mutate: mockMutate,
})),
@@ -92,7 +108,9 @@ jest.mock('~/utils/heicConverter', () => ({
jest.mock('../useClientResize', () => ({
__esModule: true,
default: jest.fn(() => ({
- resizeImageIfNeeded: jest.fn(async (file: File) => ({ file, resized: false })),
+ isConfigPending: mockIsConfigPending,
+ waitForConfig: mockWaitForConfig,
+ resizeImageIfNeeded: mockResizeImageIfNeeded,
})),
}));
@@ -106,20 +124,37 @@ jest.mock('../useUpdateFiles', () => ({
})),
}));
-jest.mock('~/utils', () => ({
- logger: { log: jest.fn() },
- validateFiles: jest.fn(() => true),
- cachePreview: jest.fn(),
- getCachedPreview: jest.fn(() => undefined),
-}));
+jest.mock('~/utils', () => {
+ const { validateFileSizes, validateFileDuplicates } = jest.requireActual('~/utils/files');
+ return {
+ logger: { log: jest.fn() },
+ validateFiles: jest.fn(() => true),
+ validateFileSizes: jest.fn(validateFileSizes),
+ validateFileDuplicates: jest.fn(validateFileDuplicates),
+ cachePreview: jest.fn(),
+ getCachedPreview: jest.fn(() => undefined),
+ removePreviewEntry: jest.fn(),
+ };
+});
const mockValidateFiles = jest.requireMock('~/utils').validateFiles;
+const mockValidateFileSizes = jest.requireMock('~/utils').validateFileSizes;
+const mockValidateFileDuplicates = jest.requireMock('~/utils').validateFileDuplicates;
describe('useFileHandling', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockValidateFiles.mockImplementation(() => true);
+ mockValidateFileSizes.mockImplementation(jest.requireActual('~/utils/files').validateFileSizes);
+ mockValidateFileDuplicates.mockImplementation(
+ jest.requireActual('~/utils/files').validateFileDuplicates,
+ );
mockProcessFileForUpload.mockImplementation(async (file: File) => file);
+ mockResizeImageIfNeeded.mockImplementation(async (file: File) => ({ file, resized: false }));
+ mockWaitForConfig.mockResolvedValue(undefined);
mockConversation = {};
+ mockFileConfig = null;
+ mockIsConfigPending = false;
mockIsTemporary = false;
});
@@ -158,6 +193,391 @@ describe('useFileHandling', () => {
expect(mockMutate).toHaveBeenCalledTimes(1);
});
+ it('waits for a pending resize config before processing an upload', async () => {
+ let resolveConfig: () => void = () => undefined;
+ mockIsConfigPending = true;
+ mockWaitForConfig.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveConfig = resolve;
+ }),
+ );
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+ const imageFile = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });
+ let handlingPromise: Promise = Promise.resolve();
+
+ await act(async () => {
+ handlingPromise = result.current.handleFiles([imageFile]);
+ await Promise.resolve();
+ });
+
+ expect(mockWaitForConfig).toHaveBeenCalledTimes(1);
+ expect(mockValidateFiles).not.toHaveBeenCalled();
+ expect(mockResizeImageIfNeeded).not.toHaveBeenCalled();
+ expect(mockMutate).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveConfig();
+ await handlingPromise;
+ await Promise.resolve();
+ });
+
+ expect(mockValidateFiles).toHaveBeenCalledTimes(1);
+ expect(mockResizeImageIfNeeded).toHaveBeenCalledWith(imageFile);
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it('processes uploads when a resize config error has settled', async () => {
+ mockIsConfigPending = false;
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+ const imageFile = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });
+
+ await act(async () => {
+ await result.current.handleFiles([imageFile]);
+ await Promise.resolve();
+ });
+
+ expect(mockWaitForConfig).not.toHaveBeenCalled();
+ expect(mockResizeImageIfNeeded).toHaveBeenCalledWith(imageFile);
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it('accepts an image that resizing brings under the individual size limit', async () => {
+ mockFileConfig = mergeFileConfig({
+ endpoints: { default: { fileSizeLimit: 5 } },
+ });
+ const originalFile = makeSizedFile('photo.jpg', 'image/jpeg', 6 * megabyte);
+ const resizedFile = makeSizedFile('photo.jpg', 'image/jpeg', 4 * megabyte);
+ mockResizeImageIfNeeded.mockResolvedValueOnce({ file: resizedFile, resized: true });
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ await result.current.handleFiles([originalFile]);
+ await Promise.resolve();
+ });
+
+ expect(mockValidateFiles).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileList: [originalFile],
+ skipSizeValidation: true,
+ }),
+ );
+ expect(mockValidateFileSizes).toHaveBeenCalledWith(
+ expect.objectContaining({ fileList: [resizedFile] }),
+ );
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects an image still at the individual size limit after resizing', async () => {
+ jest.useFakeTimers();
+ try {
+ mockFileConfig = mergeFileConfig({
+ endpoints: { default: { fileSizeLimit: 5 } },
+ });
+ const originalFile = makeSizedFile('photo.jpg', 'image/jpeg', 6 * megabyte);
+ const resizedFile = makeSizedFile('photo.jpg', 'image/jpeg', 5 * megabyte);
+ mockResizeImageIfNeeded.mockResolvedValueOnce({ file: resizedFile, resized: true });
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ await result.current.handleFiles([originalFile]);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(mockValidateFileSizes).toHaveBeenCalledWith(
+ expect.objectContaining({ fileList: [resizedFile] }),
+ );
+ expect(mockMutate).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith({
+ message: 'File size limit exceeded: 5 MB',
+ status: 'error',
+ duration: 5000,
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('applies the total size limit to the complete transformed batch', async () => {
+ jest.useFakeTimers();
+ try {
+ mockFileConfig = mergeFileConfig({
+ endpoints: { default: { fileSizeLimit: 10, totalSizeLimit: 7 } },
+ });
+ const originals = [
+ makeSizedFile('one.jpg', 'image/jpeg', 6 * megabyte),
+ makeSizedFile('two.jpg', 'image/jpeg', 6 * megabyte),
+ ];
+ const resizedFiles = [
+ makeSizedFile('one.jpg', 'image/jpeg', 4 * megabyte),
+ makeSizedFile('two.jpg', 'image/jpeg', 4 * megabyte),
+ ];
+ mockResizeImageIfNeeded
+ .mockResolvedValueOnce({ file: resizedFiles[0], resized: true })
+ .mockResolvedValueOnce({ file: resizedFiles[1], resized: true });
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ await result.current.handleFiles(originals);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(mockValidateFileSizes).toHaveBeenCalledWith(
+ expect.objectContaining({ fileList: resizedFiles }),
+ );
+ expect(mockMutate).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledWith({
+ message: 'Total file size limit exceeded: 7 MB',
+ status: 'error',
+ duration: 5000,
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('uploads the selected file when the input is reset before processing starts', async () => {
+ const selectedFile = makeSizedFile('notes.txt', 'text/plain', 1024);
+ /** Mirrors the live `FileList` an input clears in place when its value is reset */
+ const liveFiles = [selectedFile];
+ const target = {
+ files: liveFiles as unknown as FileList,
+ get value() {
+ return '';
+ },
+ set value(_next: string) {
+ liveFiles.length = 0;
+ },
+ };
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ result.current.handleFileChange({
+ stopPropagation: jest.fn(),
+ target,
+ } as unknown as React.ChangeEvent);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(target.files).toHaveLength(0);
+ expect(mockValidateFiles).toHaveBeenCalledWith(
+ expect.objectContaining({ fileList: [selectedFile] }),
+ );
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a reattached image that resizing turns into a duplicate', async () => {
+ jest.useFakeTimers({ doNotFake: ['queueMicrotask'] });
+ try {
+ const originalFile = makeSizedFile('photo.jpg', 'image/jpeg', 6 * megabyte);
+ mockResizeImageIfNeeded.mockImplementation(async () => ({
+ file: makeSizedFile('photo.jpg', 'image/jpeg', 4 * megabyte),
+ resized: true,
+ }));
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ await result.current.handleFiles([originalFile]);
+ await Promise.resolve();
+ });
+
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await result.current.handleFiles([originalFile]);
+ await Promise.resolve();
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(mockValidateFileDuplicates).toHaveBeenCalledTimes(2);
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith({
+ message: 'com_error_files_dupe',
+ status: 'error',
+ duration: 5000,
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('does not retain reservations when uploads target a custom file setter', async () => {
+ mockFileConfig = mergeFileConfig({
+ endpoints: { default: { fileSizeLimit: 10, totalSizeLimit: 7 } },
+ });
+ const customSetter = jest.fn();
+ const useFileHandling = await loadHook();
+ const { result, rerender } = renderHook(() => useFileHandling({ fileSetter: customSetter }));
+
+ await act(async () => {
+ await result.current.handleFiles([makeSizedFile('one.txt', 'text/plain', 4 * megabyte)]);
+ });
+ /** The custom setter owns the uploads, so a rerender restores the observable chat state */
+ rerender();
+ await act(async () => {
+ await result.current.handleFiles([makeSizedFile('two.txt', 'text/plain', 4 * megabyte)]);
+ });
+
+ expect(mockMutate).toHaveBeenCalledTimes(2);
+ });
+
+ it('starts the config wait for queued batches without stacking the timeouts', async () => {
+ mockIsConfigPending = true;
+ let releaseConfig: () => void = () => undefined;
+ const configGate = new Promise((resolve) => {
+ releaseConfig = resolve;
+ });
+ mockWaitForConfig.mockImplementation(() => configGate);
+ const sharedState = {
+ files: new Map(),
+ setFiles: jest.fn(),
+ setFilesLoading: mockSetFilesLoading,
+ };
+ const { useFileHandlingNoChatContext } = await import('../useFileHandling');
+ const menu = renderHook(() => useFileHandlingNoChatContext(undefined, sharedState));
+ const composer = renderHook(() => useFileHandlingNoChatContext(undefined, sharedState));
+
+ let uploads: Promise = Promise.resolve([]);
+ await act(async () => {
+ uploads = Promise.all([
+ menu.result.current.handleFiles([makeSizedFile('one.txt', 'text/plain', 1024)]),
+ composer.result.current.handleFiles([makeSizedFile('two.txt', 'text/plain', 1024)]),
+ ]);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockWaitForConfig).toHaveBeenCalledTimes(2);
+
+ await act(async () => {
+ releaseConfig();
+ await uploads;
+ });
+
+ expect(mockMutate).toHaveBeenCalledTimes(2);
+ });
+
+ it('shares the total size limit across hook instances writing the same files', async () => {
+ jest.useFakeTimers({ doNotFake: ['queueMicrotask'] });
+ try {
+ mockFileConfig = mergeFileConfig({
+ endpoints: { default: { fileSizeLimit: 10, totalSizeLimit: 7 } },
+ });
+ const sharedState = {
+ files: new Map(),
+ setFiles: jest.fn(),
+ setFilesLoading: mockSetFilesLoading,
+ };
+ const { useFileHandlingNoChatContext } = await import('../useFileHandling');
+ const menu = renderHook(() => useFileHandlingNoChatContext(undefined, sharedState));
+ const composer = renderHook(() => useFileHandlingNoChatContext(undefined, sharedState));
+
+ await act(async () => {
+ await Promise.all([
+ menu.result.current.handleFiles([makeSizedFile('one.txt', 'text/plain', 4 * megabyte)]),
+ composer.result.current.handleFiles([
+ makeSizedFile('two.txt', 'text/plain', 4 * megabyte),
+ ]),
+ ]);
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith({
+ message: 'Total file size limit exceeded: 7 MB',
+ status: 'error',
+ duration: 5000,
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('shares the total size limit across concurrent upload batches', async () => {
+ jest.useFakeTimers();
+ try {
+ mockFileConfig = mergeFileConfig({
+ endpoints: { default: { fileSizeLimit: 10, totalSizeLimit: 7 } },
+ });
+ const firstFile = makeSizedFile('one.jpg', 'image/jpeg', 4 * megabyte);
+ const secondFile = makeSizedFile('two.jpg', 'image/jpeg', 4 * megabyte);
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ await Promise.all([
+ result.current.handleFiles([firstFile]),
+ result.current.handleFiles([secondFile]),
+ ]);
+ await Promise.resolve();
+ });
+ await act(async () => {
+ jest.advanceTimersByTime(250);
+ });
+
+ expect(mockValidateFileSizes).toHaveBeenCalledTimes(2);
+ expect(mockValidateFileSizes.mock.calls[1][0].files.size).toBe(1);
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith({
+ message: 'Total file size limit exceeded: 7 MB',
+ status: 'error',
+ duration: 5000,
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('localizes the image resize success toast with size details', async () => {
+ const originalFile = new File(['original'], 'photo.jpg', { type: 'image/jpeg' });
+ const resizedFile = new File(['resized'], 'photo.jpg', { type: 'image/jpeg' });
+ mockResizeImageIfNeeded.mockImplementationOnce(async () => ({
+ file: resizedFile,
+ resized: true,
+ result: {
+ file: resizedFile,
+ originalSize: 2 * 1024 * 1024,
+ newSize: 1024 * 1024,
+ originalDimensions: { width: 2400, height: 1600 },
+ newDimensions: { width: 1200, height: 800 },
+ compressionRatio: 0.5,
+ },
+ }));
+
+ const useFileHandling = await loadHook();
+ const { result } = renderHook(() => useFileHandling());
+
+ await act(async () => {
+ await result.current.handleFiles([originalFile]);
+ });
+
+ expect(mockLocalize).toHaveBeenCalledWith('com_info_image_resized', {
+ 0: '2.0',
+ 1: '1.0',
+ 2: 50,
+ });
+ expect(mockShowToast).toHaveBeenCalledWith({
+ message: 'com_info_image_resized',
+ status: 'success',
+ duration: 3000,
+ });
+ });
+
it('uses conversation endpoint when no override is provided', async () => {
mockConversation = {
conversationId: 'convo-1',
diff --git a/client/src/hooks/Files/index.ts b/client/src/hooks/Files/index.ts
index 28a46d66d0..21654a95e4 100644
--- a/client/src/hooks/Files/index.ts
+++ b/client/src/hooks/Files/index.ts
@@ -3,6 +3,7 @@ export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable';
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
export { default as useFileHandling, useFileHandlingNoChatContext } from './useFileHandling';
export { default as useFileDeletion } from './useFileDeletion';
+export { default as useClientResize } from './useClientResize';
export { default as useUpdateFiles } from './useUpdateFiles';
export { default as useDragHelpers } from './useDragHelpers';
export { default as useUploadOptions } from './useUploadOptions';
diff --git a/client/src/hooks/Files/useClientResize.ts b/client/src/hooks/Files/useClientResize.ts
index 1da3848aeb..b9c98236e4 100644
--- a/client/src/hooks/Files/useClientResize.ts
+++ b/client/src/hooks/Files/useClientResize.ts
@@ -1,32 +1,108 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useRecoilValue } from 'recoil';
import { mergeFileConfig } from 'librechat-data-provider';
-import { useCallback } from 'react';
+import type { FileConfig } from 'librechat-data-provider';
+import type { ResizeOptions, ResizeResult } from '~/utils/imageResize';
+import { resizeImage, shouldResizeImage, supportsClientResize } from '~/utils/imageResize';
import { useGetFileConfig } from '~/data-provider';
-import {
- resizeImage,
- shouldResizeImage,
- supportsClientResize,
- type ResizeOptions,
- type ResizeResult,
-} from '~/utils/imageResize';
+import store from '~/store';
+
+type ClientImageResizeConfig = NonNullable;
+const FILE_CONFIG_WAIT_TIMEOUT_MS = 10_000;
+
+const defaultConfig: ClientImageResizeConfig = {
+ enabled: false,
+ maxWidth: 1900,
+ maxHeight: 1900,
+ quality: 0.92,
+ enforced: false,
+};
/**
* Hook for client-side image resizing functionality
- * Integrates with LibreChat's file configuration system
+ *
+ * Resolution order is admin config, then user setting, then off: when
+ * `clientImageResize.enabled` is set in `librechat.yaml` it is reported as
+ * `enforced` and the user's setting is ignored.
*/
export const useClientResize = () => {
- const { data: fileConfig = null } = useGetFileConfig({
+ const userPreference = useRecoilValue(store.clientImageResize);
+ const {
+ data: fileConfig = null,
+ isError: isFileConfigError,
+ isPaused: isFileConfigPaused,
+ isSuccess: isFileConfigLoaded,
+ } = 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;
+ const config = fileConfig?.clientImageResize ?? defaultConfig;
+ const { maxWidth, maxHeight, quality } = config;
+ const isSupported = supportsClientResize();
+ const isEnforced = config.enforced === true;
+ const isConfigPending = !isFileConfigLoaded && !isFileConfigError && !isFileConfigPaused;
+ const isConfigUnavailable = !isFileConfigLoaded && !isConfigPending;
+ const hasValidDimensions =
+ typeof maxWidth === 'number' &&
+ Number.isFinite(maxWidth) &&
+ maxWidth > 0 &&
+ typeof maxHeight === 'number' &&
+ Number.isFinite(maxHeight) &&
+ maxHeight > 0;
+ const isEnabled =
+ isFileConfigLoaded &&
+ hasValidDimensions &&
+ (isEnforced ? config.enabled === true : userPreference);
+ const isConfigPendingRef = useRef(isConfigPending);
+ const configWaitTimedOutRef = useRef(false);
+ const configWaitersRef = useRef(new Set<() => void>());
+ const resizeStateRef = useRef({ isEnabled, isSupported, maxWidth, maxHeight, quality });
+
+ isConfigPendingRef.current = isConfigPending;
+ if (!isConfigPending) {
+ configWaitTimedOutRef.current = false;
+ }
+ resizeStateRef.current = { isEnabled, isSupported, maxWidth, maxHeight, quality };
+
+ const waitForConfig = useCallback((): Promise => {
+ if (!isConfigPendingRef.current || configWaitTimedOutRef.current) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve) => {
+ const finishWaiting = () => {
+ clearTimeout(timeoutId);
+ configWaitersRef.current.delete(finishWaiting);
+ resolve();
+ };
+ const timeoutId = setTimeout(() => {
+ configWaitTimedOutRef.current = true;
+ finishWaiting();
+ }, FILE_CONFIG_WAIT_TIMEOUT_MS);
+ configWaitersRef.current.add(finishWaiting);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (isConfigPending) {
+ return;
+ }
+
+ for (const resolve of configWaitersRef.current) {
+ resolve();
+ }
+ configWaitersRef.current.clear();
+ }, [isConfigPending]);
+
+ useEffect(
+ () => () => {
+ for (const resolve of configWaitersRef.current) {
+ resolve();
+ }
+ configWaitersRef.current.clear();
+ },
+ [],
+ );
/**
* Resizes an image if client-side resizing is enabled and supported
@@ -39,13 +115,23 @@ export const useClientResize = () => {
file: File,
options?: Partial,
): Promise<{ file: File; resized: boolean; result?: ResizeResult }> => {
+ await waitForConfig();
+
+ const {
+ isEnabled: isResizeEnabled,
+ isSupported: isResizeSupported,
+ maxWidth: currentMaxWidth,
+ maxHeight: currentMaxHeight,
+ quality: currentQuality,
+ } = resizeStateRef.current;
+
// Return original file if resizing is disabled
- if (!isEnabled) {
+ if (!isResizeEnabled) {
return { file, resized: false };
}
// Return original file if browser doesn't support resizing
- if (!supportsClientResize()) {
+ if (!isResizeSupported) {
console.warn('Client-side image resizing not supported in this browser');
return { file, resized: false };
}
@@ -57,26 +143,30 @@ export const useClientResize = () => {
try {
const resizeOptions: Partial = {
- maxWidth: config?.maxWidth,
- maxHeight: config?.maxHeight,
- quality: config?.quality,
+ maxWidth: currentMaxWidth,
+ maxHeight: currentMaxHeight,
+ quality: currentQuality,
...options,
};
const result = await resizeImage(file, resizeOptions);
- return { file: result.file, resized: true, result };
+ return { file: result.file, resized: result.file !== file, result };
} catch (error) {
console.warn('Client-side image resizing failed:', error);
return { file, resized: false };
}
},
- [isEnabled, config],
+ [waitForConfig],
);
return {
isEnabled,
- isSupported: supportsClientResize(),
+ isEnforced,
+ isSupported,
+ isConfigPending,
+ isConfigUnavailable,
config,
+ waitForConfig,
resizeImageIfNeeded,
};
};
diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts
index ec67c20686..14236e9f73 100644
--- a/client/src/hooks/Files/useFileHandling.ts
+++ b/client/src/hooks/Files/useFileHandling.ts
@@ -16,7 +16,15 @@ import {
import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
-import { logger, validateFiles, cachePreview, getCachedPreview, removePreviewEntry } from '~/utils';
+import {
+ logger,
+ validateFiles,
+ cachePreview,
+ validateFileSizes,
+ getCachedPreview,
+ removePreviewEntry,
+ validateFileDuplicates,
+} from '~/utils';
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
import { useDelayedUploadToast } from './useDelayedUploadToast';
@@ -42,8 +50,58 @@ export type FileHandlingState = {
conversation?: TConversation | null;
};
+type ProcessedUpload = {
+ extendedFile: ExtendedFile;
+ preview: string;
+ resizeDetails?: {
+ originalSize: number;
+ newSize: number;
+ compressionRatio: number;
+ };
+};
+
const noop = () => {};
+type UploadScope = {
+ queue: Promise;
+ /** Accepted uploads that have not been observed in the shared file state yet */
+ recent: Map;
+};
+
+/**
+ * Upload batches are validated against the file map they write to, so every hook instance
+ * sharing a setter (attachment menu, paste routing, SharePoint) must share one queue.
+ */
+const uploadScopes = new WeakMap();
+
+const getUploadScope = (fileSetter: FileSetter): UploadScope => {
+ const scope = uploadScopes.get(fileSetter);
+ if (scope != null) {
+ return scope;
+ }
+
+ const created: UploadScope = { queue: Promise.resolve(), recent: new Map() };
+ uploadScopes.set(fileSetter, created);
+ return created;
+};
+
+const mergeRecentUploads = (
+ files: Map,
+ recent: Map,
+): Map => {
+ if (recent.size === 0) {
+ return files;
+ }
+
+ const merged = new Map(files);
+ for (const [file_id, extendedFile] of recent) {
+ if (!merged.has(file_id)) {
+ merged.set(file_id, extendedFile);
+ }
+ }
+ return merged;
+};
+
const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: FileHandlingState) => {
const localize = useLocalize();
const queryClient = useQueryClient();
@@ -52,16 +110,27 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
const abortControllerRef = useRef(null);
const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast();
const { files, setFiles, conversation } = fileState;
+ const filesRef = useRef(files);
+ filesRef.current = files;
+ const fileSetter = params?.fileSetter ?? setFiles;
+ const uploadScope = getUploadScope(fileSetter);
+ /** Reservations are only observable when the rendered state is the state being written */
+ const tracksReservations = fileSetter === setFiles;
+ if (tracksReservations) {
+ for (const file_id of uploadScope.recent.keys()) {
+ if (files.has(file_id)) {
+ uploadScope.recent.delete(file_id);
+ }
+ }
+ }
const setFilesLoading = fileState.setFilesLoading ?? noop;
const setEphemeralAgent = useSetRecoilState(
ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
);
const isTemporary = useRecoilValue(store.isTemporary);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
- const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
- params?.fileSetter ?? setFiles,
- );
- const { resizeImageIfNeeded } = useClientResize();
+ const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(fileSetter);
+ const { isConfigPending, waitForConfig, resizeImageIfNeeded } = useClientResize();
const agent_id = params?.additionalMetadata?.agent_id ?? '';
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
@@ -80,6 +149,8 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
const { data: fileConfig = null } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
+ const fileConfigRef = useRef(fileConfig);
+ fileConfigRef.current = fileConfig;
const displayToast = useCallback(() => {
if (errors.length > 1) {
@@ -283,25 +354,30 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
img.src = preview;
};
- const handleFiles = async (_files: FileList | File[], _toolResource?: string) => {
+ const processFiles = async (fileList: File[], _toolResource?: string) => {
abortControllerRef.current = new AbortController();
- const fileList = Array.from(_files);
+
+ const existingFiles = tracksReservations
+ ? mergeRecentUploads(filesRef.current, uploadScope.recent)
+ : filesRef.current;
+ const currentFileConfig = fileConfigRef.current;
+ const endpointFileConfig = getEndpointFileConfig({
+ endpoint,
+ fileConfig: currentFileConfig,
+ endpointType,
+ });
+
/* Validate files */
let filesAreValid: boolean;
try {
- const endpointFileConfig = getEndpointFileConfig({
- endpoint,
- fileConfig,
- endpointType,
- });
-
filesAreValid = validateFiles({
- files,
+ files: existingFiles,
fileList,
setError,
- fileConfig,
+ fileConfig: currentFileConfig,
endpointFileConfig,
toolResource: _toolResource,
+ skipSizeValidation: true,
});
} catch (error) {
console.error('file validation error', error);
@@ -315,6 +391,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
}
/* Process files */
+ const processedUploads: ProcessedUpload[] = [];
for (const originalFile of fileList) {
const file_id = v4();
try {
@@ -368,6 +445,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
: originalFile;
let finalProcessedFile = heicProcessedFile;
+ let resizeDetails: ProcessedUpload['resizeDetails'];
// Apply client-side resizing if available and appropriate
if (heicProcessedFile.type.startsWith('image/')) {
@@ -375,18 +453,9 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
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,
- });
+ resizeDetails = { originalSize, newSize, compressionRatio };
}
} catch (resizeError) {
console.warn('Image resize failed, using original:', resizeError);
@@ -410,31 +479,23 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
};
replaceFile(updatedExtendedFile);
-
- const isImage = finalProcessedFile.type.split('/')[0] === 'image';
- if (isImage) {
- loadImage(updatedExtendedFile, newPreview);
- continue;
- }
-
- await startUpload(updatedExtendedFile);
+ processedUploads.push({
+ extendedFile: updatedExtendedFile,
+ preview: newPreview,
+ resizeDetails,
+ });
} else {
- // File wasn't processed, proceed with original
- const isImage = originalFile.type.split('/')[0] === 'image';
-
// Update progress to show ready for upload
const readyExtendedFile = {
...initialExtendedFile,
progress: 0.2,
};
replaceFile(readyExtendedFile);
-
- if (isImage) {
- loadImage(readyExtendedFile, initialPreview);
- continue;
- }
-
- await startUpload(readyExtendedFile);
+ processedUploads.push({
+ extendedFile: readyExtendedFile,
+ preview: initialPreview,
+ resizeDetails,
+ });
}
} catch (error) {
deleteFileById(file_id);
@@ -446,6 +507,95 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
}
}
}
+
+ const discardProcessedUploads = () => {
+ for (const { extendedFile, preview } of processedUploads) {
+ deleteFileById(extendedFile.file_id);
+ removePreviewEntry(extendedFile.file_id);
+ URL.revokeObjectURL(preview);
+ }
+ filesRef.current = existingFiles;
+ };
+
+ const processedFileList = processedUploads.map(({ extendedFile }) => extendedFile.file as File);
+
+ let batchIsValid: boolean;
+ try {
+ batchIsValid =
+ validateFileDuplicates({
+ files: existingFiles,
+ fileList: processedFileList,
+ setError,
+ }) &&
+ validateFileSizes({
+ files: existingFiles,
+ fileList: processedFileList,
+ setError,
+ endpointFileConfig,
+ });
+ } catch (error) {
+ console.error('file validation error', error);
+ setError('com_error_files_validation');
+ discardProcessedUploads();
+ setFilesLoading(false);
+ return;
+ }
+ if (!batchIsValid) {
+ discardProcessedUploads();
+ setFilesLoading(false);
+ return;
+ }
+
+ const filesWithProcessedUploads = new Map(existingFiles);
+ for (const { extendedFile } of processedUploads) {
+ filesWithProcessedUploads.set(extendedFile.file_id, extendedFile);
+ if (tracksReservations) {
+ uploadScope.recent.set(extendedFile.file_id, extendedFile);
+ }
+ }
+ filesRef.current = filesWithProcessedUploads;
+
+ for (const { extendedFile, preview, resizeDetails } of processedUploads) {
+ if (resizeDetails) {
+ const { originalSize, newSize, compressionRatio } = resizeDetails;
+ showToast({
+ message: localize('com_info_image_resized', {
+ 0: (originalSize / (1024 * 1024)).toFixed(1),
+ 1: (newSize / (1024 * 1024)).toFixed(1),
+ 2: Math.round((1 - compressionRatio) * 100),
+ }),
+ status: 'success',
+ duration: 3000,
+ });
+ }
+
+ if (extendedFile.file?.type.startsWith('image/') === true) {
+ loadImage(extendedFile, preview);
+ continue;
+ }
+
+ await startUpload(extendedFile);
+ }
+ };
+
+ const handleFiles = async (_files: FileList | File[], _toolResource?: string) => {
+ /** `FileList` is live: copy it before yielding, as callers reset the input synchronously */
+ const fileList = Array.from(_files);
+ /** Started before queueing so every waiting batch shares one bounded config window */
+ const configReady = isConfigPending ? waitForConfig() : undefined;
+ const previousProcessing = uploadScope.queue;
+ let releaseProcessing: () => void = () => undefined;
+ uploadScope.queue = new Promise((resolve) => {
+ releaseProcessing = resolve;
+ });
+
+ await previousProcessing;
+ try {
+ await configReady;
+ await processFiles(fileList, _toolResource);
+ } finally {
+ releaseProcessing();
+ }
};
const handleFileChange = (event: React.ChangeEvent, _toolResource?: string) => {
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index d9c6391a20..d03593fa84 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -425,6 +425,7 @@
"com_generated_files": "Generated files:",
"com_hide_examples": "Hide Examples",
"com_info_heic_converting": "Converting HEIC image to JPEG...",
+ "com_info_image_resized": "Image resized: {{0}} MB to {{1}} MB ({{2}}% smaller)",
"com_nav_2fa": "Two-Factor Authentication (2FA)",
"com_nav_about_branch": "Branch",
"com_nav_about_build_date": "Built",
@@ -482,6 +483,7 @@
"com_nav_clear_cache_confirm_message": "Are you sure you want to clear the cache?",
"com_nav_clear_conversation": "Clear conversations",
"com_nav_clear_conversation_confirm_message": "Are you sure you want to clear all conversations? This is irreversible.",
+ "com_nav_client_image_resize": "Resize images before upload",
"com_nav_close_sidebar": "Close sidebar",
"com_nav_confirm_clear": "Confirm Clear",
"com_nav_control_panel": "Control Panel",
@@ -525,6 +527,10 @@
"com_nav_help": "Help",
"com_nav_help_faq": "Help & FAQ",
"com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)",
+ "com_nav_info_client_image_resize": "When enabled, large images are downscaled in your browser before they are uploaded, which speeds up uploads and uses less bandwidth. Images already within the size limits are sent unchanged.",
+ "com_nav_info_client_image_resize_enforced": "This setting is managed by your administrator and cannot be changed here.",
+ "com_nav_info_client_image_resize_unavailable": "Image resizing is unavailable because the file settings could not be loaded. Reload the page to try again.",
+ "com_nav_info_client_image_resize_unsupported": "Image resizing is unavailable because this browser does not support the required image processing features.",
"com_nav_info_code_background": "When enabled, the model can run code executions in the background: the conversation continues immediately while the code runs, and the model retrieves the result later with the background task tool. Output and generated files still attach to the original code run. Requires the app-level background tools capability.",
"com_nav_info_default_temporary_chat": "When enabled, new chats will start with temporary chat mode activated by default. Temporary chats are not saved to your history.",
"com_nav_info_during_run_action": "Choose what pressing Enter does while the assistant is still responding. \"Steer the response\" inserts your message into the current response at its next step; \"Queue for after\" sends it as a new turn once the response finishes. You can always override this per message from the send button.",
diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts
index 0716c86b46..4b5041f1a5 100644
--- a/client/src/store/settings.ts
+++ b/client/src/store/settings.ts
@@ -85,6 +85,8 @@ const localStorageAtoms = {
splitAtTarget: atomWithLocalStorage('splitAtTarget', false),
rememberDefaultFork: atomWithLocalStorage(LocalStorageKeys.REMEMBER_FORK_OPTION, false),
saveBadgesState: atomWithLocalStorage('saveBadgesState', false),
+ /** User preference for downscaling images before upload; ignored when the admin config sets it */
+ clientImageResize: atomWithLocalStorage('clientImageResize', false),
// Beta features settings
modularChat: atomWithLocalStorage('modularChat', true),
diff --git a/client/src/utils/__tests__/imageResize.test.ts b/client/src/utils/__tests__/imageResize.test.ts
index c09d2293d1..8f9d4eb9b1 100644
--- a/client/src/utils/__tests__/imageResize.test.ts
+++ b/client/src/utils/__tests__/imageResize.test.ts
@@ -2,36 +2,76 @@
* Tests for client-side image resizing utility
*/
-import { shouldResizeImage, supportsClientResize } from '../imageResize';
+import {
+ resizeImage,
+ isAnimatedImage,
+ 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,
-});
+const createChunk = (type: string, data: number[] = []): number[] => [
+ (data.length >>> 24) & 0xff,
+ (data.length >>> 16) & 0xff,
+ (data.length >>> 8) & 0xff,
+ data.length & 0xff,
+ ...Array.from(type, (character) => character.charCodeAt(0)),
+ ...data,
+ 0,
+ 0,
+ 0,
+ 0,
+];
-Object.defineProperty(global, 'FileReader', {
- value: function () {
- return {
- readAsDataURL: jest.fn(),
- };
- },
- writable: true,
-});
+const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
-Object.defineProperty(global, 'Image', {
- value: function () {
- return {};
- },
- writable: true,
-});
+const createPngWithChunks = (chunks: number[][]): File =>
+ new File([new Uint8Array([...pngSignature, ...chunks.flat()])], 'image.png', {
+ type: 'image/png',
+ });
+
+const createPng = (chunkType: 'acTL' | 'IDAT'): File =>
+ createPngWithChunks([
+ createChunk(chunkType, chunkType === 'acTL' ? [0, 0, 0, 1, 0, 0, 0, 0] : []),
+ ]);
+
+const createWebPChunk = (type: string, data: number[] = []): number[] => [
+ ...Array.from(type, (character) => character.charCodeAt(0)),
+ data.length & 0xff,
+ (data.length >>> 8) & 0xff,
+ (data.length >>> 16) & 0xff,
+ (data.length >>> 24) & 0xff,
+ ...data,
+ ...(data.length % 2 === 1 ? [0] : []),
+];
+
+const createWebPWithChunks = (chunks: number[][]): File => {
+ const payload = chunks.flat();
+ const riffSize = 4 + payload.length;
+ return new File(
+ [
+ new Uint8Array([
+ 82,
+ 73,
+ 70,
+ 70,
+ riffSize & 0xff,
+ (riffSize >>> 8) & 0xff,
+ (riffSize >>> 16) & 0xff,
+ (riffSize >>> 24) & 0xff,
+ 87,
+ 69,
+ 66,
+ 80,
+ ...payload,
+ ]),
+ ],
+ 'image.webp',
+ { type: 'image/webp' },
+ );
+};
+
+const createWebP = (flags: number): File =>
+ createWebPWithChunks([createWebPChunk('VP8X', [flags, 0, 0, 0, 0, 0, 0, 0, 0, 0])]);
describe('imageResize utility', () => {
describe('supportsClientResize', () => {
@@ -53,36 +93,14 @@ 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(),
- });
+ it.each([
+ ['image/jpeg', 'photo.jpg'],
+ ['image/png', 'photo.png'],
+ ['image/webp', 'photo.webp'],
+ ])('should return true for supported format %s regardless of file size', (type, name) => {
+ const smallImage = new File(['x'], name, { type });
- // 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);
+ expect(shouldResizeImage(smallImage)).toBe(true);
});
it('should return false for non-image files', () => {
@@ -104,5 +122,287 @@ describe('imageResize utility', () => {
const result = shouldResizeImage(gifFile);
expect(result).toBe(false);
});
+
+ it.each([
+ ['image/svg', 'test.svg'],
+ ['image/svg+xml', 'test.svg'],
+ ['image/heic', 'test.heic'],
+ ['image/heif', 'test.heif'],
+ ])('should return false for unsupported image type %s', (type, name) => {
+ const unsupportedImage = new File([''], name, {
+ type,
+ lastModified: Date.now(),
+ });
+
+ Object.defineProperty(unsupportedImage, 'size', {
+ value: 3 * 1024 * 1024,
+ writable: false,
+ });
+
+ expect(shouldResizeImage(unsupportedImage)).toBe(false);
+ });
+ });
+
+ describe('isAnimatedImage', () => {
+ it('detects APNG content served as image/png', async () => {
+ await expect(isAnimatedImage(createPng('acTL'))).resolves.toBe(true);
+ });
+
+ it('allows static PNG content', async () => {
+ await expect(isAnimatedImage(createPng('IDAT'))).resolves.toBe(false);
+ });
+
+ it('detects animated WebP content', async () => {
+ await expect(isAnimatedImage(createWebP(0x02))).resolves.toBe(true);
+ });
+
+ it('allows static WebP content', async () => {
+ await expect(isAnimatedImage(createWebP(0x00))).resolves.toBe(false);
+ });
+
+ it.each([
+ ['ANIM', 6],
+ ['ANMF', 16],
+ ])('detects animated WebP content from a %s chunk', async (chunkType, dataLength) => {
+ const file = createWebPWithChunks([
+ createWebPChunk(chunkType, new Array(dataLength).fill(0)),
+ ]);
+
+ await expect(isAnimatedImage(file)).resolves.toBe(true);
+ });
+
+ it('scans many PNG chunks with one FileReader operation', async () => {
+ const readAsArrayBuffer = jest.spyOn(FileReader.prototype, 'readAsArrayBuffer');
+ const ancillaryChunks = Array.from({ length: 1000 }, () => createChunk('tEXt'));
+ const file = createPngWithChunks([
+ ...ancillaryChunks,
+ createChunk('acTL', [0, 0, 0, 1, 0, 0, 0, 0]),
+ ]);
+
+ await expect(isAnimatedImage(file)).resolves.toBe(true);
+ expect(readAsArrayBuffer).toHaveBeenCalledTimes(1);
+ readAsArrayBuffer.mockRestore();
+ });
+
+ it('scans many WebP chunks with one FileReader operation', async () => {
+ const readAsArrayBuffer = jest.spyOn(FileReader.prototype, 'readAsArrayBuffer');
+ const ancillaryChunks = Array.from({ length: 1000 }, () => createWebPChunk('EXIF'));
+ const file = createWebPWithChunks([
+ ...ancillaryChunks,
+ createWebPChunk('ANIM', new Array(6).fill(0)),
+ ]);
+
+ await expect(isAnimatedImage(file)).resolves.toBe(true);
+ expect(readAsArrayBuffer).toHaveBeenCalledTimes(1);
+ readAsArrayBuffer.mockRestore();
+ });
+
+ it('conservatively skips resizing when the PNG scan bound is exceeded', async () => {
+ const file = createPngWithChunks([
+ createChunk('tEXt', new Array(64 * 1024).fill(0)),
+ createChunk('IDAT'),
+ ]);
+
+ await expect(isAnimatedImage(file)).resolves.toBe(true);
+ });
+
+ it('allows static image data that extends beyond the scan bound', async () => {
+ const largePng = createPngWithChunks([
+ createChunk('IDAT', new Array(64 * 1024).fill(0)),
+ ]);
+
+ await expect(isAnimatedImage(largePng)).resolves.toBe(false);
+ });
+
+ it.each([
+ ['VP8 ', 'EXIF'],
+ ['VP8L', 'XMP '],
+ ])(
+ 'allows a large static WebP with a %s payload followed by %s metadata',
+ async (imageChunk, metadataChunk) => {
+ const largeWebP = createWebPWithChunks([
+ createWebPChunk(imageChunk, new Array(64 * 1024).fill(0)),
+ createWebPChunk(metadataChunk, [1, 2, 3, 4]),
+ ]);
+
+ await expect(isAnimatedImage(largeWebP)).resolves.toBe(false);
+ },
+ );
+ });
+
+ describe('resizeImage', () => {
+ let imageWidth = 2400;
+ let imageHeight = 1600;
+ const originalImage = global.Image;
+ const originalGetContext = Object.getOwnPropertyDescriptor(
+ HTMLCanvasElement.prototype,
+ 'getContext',
+ );
+ const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'toBlob');
+ const canvasToBlob = jest.fn();
+ const drawImage = jest.fn();
+ const originalCreateObjectURL = global.URL.createObjectURL;
+ const originalRevokeObjectURL = global.URL.revokeObjectURL;
+ const createObjectURL = jest.fn(() => 'blob:image-source');
+ const revokeObjectURL = jest.fn();
+
+ beforeAll(() => {
+ global.URL.createObjectURL = createObjectURL;
+ global.URL.revokeObjectURL = revokeObjectURL;
+
+ Object.defineProperty(global, 'Image', {
+ configurable: true,
+ value: class {
+ width = imageWidth;
+ height = imageHeight;
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ set src(_value: string) {
+ queueMicrotask(() => this.onload?.());
+ }
+ },
+ });
+
+ Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
+ configurable: true,
+ value: canvasToBlob,
+ });
+ Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
+ configurable: true,
+ value: () => ({
+ drawImage,
+ imageSmoothingEnabled: false,
+ imageSmoothingQuality: 'low',
+ }),
+ });
+ });
+
+ afterAll(() => {
+ global.URL.createObjectURL = originalCreateObjectURL;
+ global.URL.revokeObjectURL = originalRevokeObjectURL;
+ Object.defineProperty(global, 'Image', {
+ configurable: true,
+ value: originalImage,
+ });
+ if (originalGetContext) {
+ Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext);
+ }
+ if (originalToBlob) {
+ Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', originalToBlob);
+ }
+ });
+
+ beforeEach(() => {
+ imageWidth = 2400;
+ imageHeight = 1600;
+ canvasToBlob.mockClear();
+ drawImage.mockClear();
+ createObjectURL.mockClear();
+ revokeObjectURL.mockClear();
+ });
+
+ it('does not flatten an animated image', async () => {
+ await expect(resizeImage(createPng('acTL'))).rejects.toThrow(
+ 'Animated images cannot be resized without losing animation',
+ );
+ expect(drawImage).not.toHaveBeenCalled();
+ });
+
+ it('decodes through an object URL and releases it', async () => {
+ const file = new File(['source image data'], 'photo.jpg', { type: 'image/jpeg' });
+ canvasToBlob.mockImplementationOnce((callback: BlobCallback, type?: string) => {
+ callback(new Blob(['x'], { type }));
+ });
+
+ await resizeImage(file);
+
+ expect(createObjectURL).toHaveBeenCalledWith(file);
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:image-source');
+ });
+
+ it('preserves a PNG MIME type while resizing', async () => {
+ const file = new File(['source png data'], 'photo.png', { type: 'image/png' });
+ canvasToBlob.mockImplementationOnce((callback: BlobCallback, type?: string) => {
+ callback(new Blob(['x'], { type }));
+ });
+
+ const result = await resizeImage(file);
+
+ expect(canvasToBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png', 0.92);
+ expect(result.file.name).toBe('photo.png');
+ expect(result.file.type).toBe('image/png');
+ });
+
+ it('keeps the original file when the canvas falls back to a different MIME type', async () => {
+ const file = new File(['source webp data'], 'photo.webp', { type: 'image/webp' });
+ canvasToBlob.mockImplementationOnce((callback: BlobCallback) => {
+ callback(new Blob(['x'], { type: 'image/png' }));
+ });
+
+ const result = await resizeImage(file);
+
+ expect(canvasToBlob).toHaveBeenCalledWith(expect.any(Function), 'image/webp', 0.92);
+ expect(result.file).toBe(file);
+ expect(result.file.name).toBe('photo.webp');
+ expect(result.file.type).toBe('image/webp');
+ });
+
+ it.each(['maxWidth', 'maxHeight'] as const)(
+ 'rejects a non-positive %s before drawing',
+ async (dimension) => {
+ const file = new File(['source image data'], 'photo.jpg', { type: 'image/jpeg' });
+
+ await expect(resizeImage(file, { [dimension]: 0 })).rejects.toThrow(
+ 'Resize dimensions must be finite numbers greater than zero',
+ );
+ expect(drawImage).not.toHaveBeenCalled();
+ expect(canvasToBlob).not.toHaveBeenCalled();
+ },
+ );
+
+ it('clamps panoramic image dimensions to at least one pixel', async () => {
+ imageWidth = 4000;
+ imageHeight = 1000;
+ const file = new File(['source image data'], 'panorama.jpg', { type: 'image/jpeg' });
+ canvasToBlob.mockImplementationOnce((callback: BlobCallback, type?: string) => {
+ callback(new Blob(['x'], { type }));
+ });
+
+ const result = await resizeImage(file, { maxWidth: 1, maxHeight: 1 });
+
+ expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1, 1);
+ expect(result.newDimensions).toEqual({ width: 1, height: 1 });
+ });
+
+ it('resizes a small encoded image when its dimensions exceed the configured bounds', async () => {
+ imageWidth = 4000;
+ imageHeight = 3000;
+ const file = new File([new ArrayBuffer(400 * 1024)], 'compressed.jpg', {
+ type: 'image/jpeg',
+ });
+ canvasToBlob.mockImplementationOnce((callback: BlobCallback, type?: string) => {
+ callback(new Blob(['smaller'], { type }));
+ });
+
+ const result = await resizeImage(file, { maxWidth: 1900, maxHeight: 1900 });
+
+ expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 1900, 1425);
+ expect(result.newDimensions).toEqual({ width: 1900, height: 1425 });
+ expect(result.file).not.toBe(file);
+ });
+
+ it('keeps the original file when the resized blob is not smaller', async () => {
+ const file = new File(['small'], 'photo.jpg', { type: 'image/jpeg' });
+ canvasToBlob.mockImplementationOnce((callback: BlobCallback, type?: string) => {
+ callback(new Blob(['larger encoded image'], { type }));
+ });
+
+ const result = await resizeImage(file);
+
+ expect(result.file).toBe(file);
+ expect(result.newSize).toBe(file.size);
+ expect(result.compressionRatio).toBe(1);
+ });
});
});
diff --git a/client/src/utils/__tests__/validateFiles.spec.ts b/client/src/utils/__tests__/validateFiles.spec.ts
index b7f13d6474..a2a5d9f4f3 100644
--- a/client/src/utils/__tests__/validateFiles.spec.ts
+++ b/client/src/utils/__tests__/validateFiles.spec.ts
@@ -1,7 +1,7 @@
import { megabyte, fileConfig as defaultFileConfig } from 'librechat-data-provider';
import type { EndpointFileConfig, FileConfig } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
-import { validateFiles } from '../files';
+import { validateFiles, validateFileSizes } from '../files';
const supportedMimeTypes = defaultFileConfig.endpoints.default.supportedMimeTypes;
@@ -131,6 +131,46 @@ describe('validateFiles', () => {
expect(result).toBe(true);
});
+ it('can defer size validation until after files are transformed', () => {
+ const limit = 5 * megabyte;
+ endpointFileConfig = makeEndpointConfig({ fileSizeLimit: limit });
+ const fileList = [makeFile('photo.jpg', 'image/jpeg', limit + 1)];
+
+ const metadataResult = validateFiles({
+ files,
+ fileList,
+ setError,
+ fileConfig,
+ endpointFileConfig,
+ skipSizeValidation: true,
+ });
+ const transformedResult = validateFileSizes({
+ files,
+ fileList: [makeFile('photo.jpg', 'image/jpeg', limit - 1)],
+ setError,
+ endpointFileConfig,
+ });
+
+ expect(metadataResult).toBe(true);
+ expect(transformedResult).toBe(true);
+ expect(setError).not.toHaveBeenCalled();
+ });
+
+ it('preserves the individual size error after transformation', () => {
+ const limit = 5 * megabyte;
+ endpointFileConfig = makeEndpointConfig({ fileSizeLimit: limit });
+
+ const result = validateFileSizes({
+ files,
+ fileList: [makeFile('photo.jpg', 'image/jpeg', limit)],
+ setError,
+ endpointFileConfig,
+ });
+
+ expect(result).toBe(false);
+ expect(setError).toHaveBeenCalledWith(`File size limit exceeded: ${limit / megabyte} MB`);
+ });
+
it('rejects when totalSizeLimit would be exceeded', () => {
const limit = 10 * megabyte;
endpointFileConfig = makeEndpointConfig({ totalSizeLimit: limit });
@@ -150,6 +190,24 @@ describe('validateFiles', () => {
expect(result).toBe(true);
});
+ it('checks the total size across the transformed batch', () => {
+ const limit = 10 * megabyte;
+ endpointFileConfig = makeEndpointConfig({ totalSizeLimit: limit });
+
+ const result = validateFileSizes({
+ files,
+ fileList: [
+ makeFile('one.jpg', 'image/jpeg', 6 * megabyte),
+ makeFile('two.jpg', 'image/jpeg', 5 * megabyte),
+ ],
+ setError,
+ endpointFileConfig,
+ });
+
+ expect(result).toBe(false);
+ expect(setError).toHaveBeenCalledWith(`Total file size limit exceeded: ${limit / megabyte} MB`);
+ });
+
it('rejects duplicate files', () => {
files = new Map([
[
diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts
index 8c3d2bc892..667d4e8c0c 100644
--- a/client/src/utils/files.ts
+++ b/client/src/utils/files.ts
@@ -236,6 +236,75 @@ export function formatBytes(bytes: number, decimals = 2) {
const { checkType } = defaultFileConfig;
+type FileSizeValidationParams = {
+ fileList: File[];
+ files: Map;
+ setError: (error: string) => void;
+ endpointFileConfig: EndpointFileConfig;
+};
+
+export const validateFileSizes = ({
+ files,
+ fileList,
+ setError,
+ endpointFileConfig,
+}: FileSizeValidationParams): boolean => {
+ const { fileSizeLimit, totalSizeLimit } = endpointFileConfig;
+
+ for (const file of fileList) {
+ if (fileSizeLimit && file.size >= fileSizeLimit) {
+ setError(`File size limit exceeded: ${fileSizeLimit / megabyte} MB`);
+ return false;
+ }
+ }
+
+ if (totalSizeLimit) {
+ const currentTotalSize = Array.from(files.values()).reduce(
+ (total, file) => total + file.size,
+ 0,
+ );
+ const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
+ if (currentTotalSize + incomingTotalSize > totalSizeLimit) {
+ setError(`Total file size limit exceeded: ${totalSizeLimit / megabyte} MB`);
+ return false;
+ }
+ }
+
+ return true;
+};
+
+type FileDuplicateValidationParams = {
+ fileList: File[];
+ files: Map;
+ setError: (error: string) => void;
+};
+
+export const validateFileDuplicates = ({
+ files,
+ fileList,
+ setError,
+}: FileDuplicateValidationParams): boolean => {
+ const combinedFilesInfo = [
+ ...Array.from(files.values()).map(
+ (file) =>
+ `${file.file?.name ?? file.filename}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`,
+ ),
+ ...fileList.map(
+ (file: File | undefined) =>
+ `${file?.name}-${file?.size}-${file?.type.split('/')[0] ?? 'file'}`,
+ ),
+ ];
+
+ const uniqueFilesSet = new Set(combinedFilesInfo);
+
+ if (uniqueFilesSet.size !== combinedFilesInfo.length) {
+ setError('com_error_files_dupe');
+ return false;
+ }
+
+ return true;
+};
+
export const validateFiles = ({
files,
fileList,
@@ -243,6 +312,7 @@ export const validateFiles = ({
endpointFileConfig,
toolResource,
fileConfig,
+ skipSizeValidation = false,
}: {
fileList: File[];
files: Map;
@@ -250,21 +320,19 @@ export const validateFiles = ({
endpointFileConfig: EndpointFileConfig;
toolResource?: string;
fileConfig: FileConfig | null;
+ skipSizeValidation?: boolean;
}) => {
- const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes, disabled } =
- endpointFileConfig;
+ const { fileLimit, supportedMimeTypes, disabled } = endpointFileConfig;
/** Block all uploads if the endpoint is explicitly disabled */
if (disabled === true) {
setError('com_ui_attach_error_disabled');
return false;
}
- const existingFiles = Array.from(files.values());
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
if (incomingTotalSize === 0) {
setError('com_error_files_empty');
return false;
}
- const currentTotalSize = existingFiles.reduce((total, file) => total + file.size, 0);
if (fileLimit && fileList.length + files.size > fileLimit) {
setError(`File limit reached: ${fileLimit} files`);
@@ -301,37 +369,16 @@ export const validateFiles = ({
setError(`Unsupported file type: ${originalFile.type}`);
return false;
}
-
- if (fileSizeLimit && originalFile.size >= fileSizeLimit) {
- setError(`File size limit exceeded: ${fileSizeLimit / megabyte} MB`);
- return false;
- }
}
- if (totalSizeLimit && currentTotalSize + incomingTotalSize > totalSizeLimit) {
- setError(`Total file size limit exceeded: ${totalSizeLimit / megabyte} MB`);
+ if (
+ !skipSizeValidation &&
+ !validateFileSizes({ files, fileList, setError, endpointFileConfig })
+ ) {
return false;
}
- const combinedFilesInfo = [
- ...existingFiles.map(
- (file) =>
- `${file.file?.name ?? file.filename}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`,
- ),
- ...fileList.map(
- (file: File | undefined) =>
- `${file?.name}-${file?.size}-${file?.type.split('/')[0] ?? 'file'}`,
- ),
- ];
-
- const uniqueFilesSet = new Set(combinedFilesInfo);
-
- if (uniqueFilesSet.size !== combinedFilesInfo.length) {
- setError('com_error_files_dupe');
- return false;
- }
-
- return true;
+ return validateFileDuplicates({ files, fileList, setError });
};
export type UploadOptionContext = {
diff --git a/client/src/utils/imageResize.ts b/client/src/utils/imageResize.ts
index af13d97ff4..cb44979dab 100644
--- a/client/src/utils/imageResize.ts
+++ b/client/src/utils/imageResize.ts
@@ -19,6 +19,8 @@ export interface ResizeResult {
compressionRatio: number;
}
+type ResizeFormat = NonNullable;
+
/**
* Default resize options based on backend 'high' resolution settings
* Backend 'high' uses maxShortSide=768, maxLongSide=2000
@@ -31,6 +33,127 @@ const DEFAULT_RESIZE_OPTIONS: ResizeOptions = {
format: 'jpeg', // Most compatible format
};
+const RESIZE_FORMAT_BY_MIME_TYPE: Partial> = {
+ 'image/jpeg': 'jpeg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+};
+
+const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const;
+const WEBP_ANIMATION_FLAG = 0x02;
+const MAX_ANIMATION_SCAN_BYTES = 64 * 1024;
+
+const readBytes = (file: File, start: number, length: number): Promise =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (!(reader.result instanceof ArrayBuffer)) {
+ reject(new Error('Failed to read image container'));
+ return;
+ }
+ resolve(new Uint8Array(reader.result));
+ };
+ reader.onerror = () => reject(new Error('Failed to read image container'));
+ reader.readAsArrayBuffer(file.slice(start, start + length));
+ });
+
+const readAscii = (bytes: Uint8Array, start: number, length: number): string =>
+ String.fromCharCode(...bytes.slice(start, start + length));
+
+const hasSignature = (bytes: Uint8Array, signature: readonly number[]): boolean =>
+ bytes.length >= signature.length && signature.every((value, index) => bytes[index] === value);
+
+const isAnimatedPng = async (file: File): Promise => {
+ const bytes = await readBytes(file, 0, Math.min(file.size, MAX_ANIMATION_SCAN_BYTES));
+ if (!hasSignature(bytes, PNG_SIGNATURE)) {
+ return false;
+ }
+
+ let offset: number = PNG_SIGNATURE.length;
+ while (offset + 12 <= bytes.length) {
+ const header = bytes.subarray(offset, offset + 8);
+ const chunkLength = new DataView(header.buffer, header.byteOffset, header.byteLength).getUint32(
+ 0,
+ );
+ const chunkType = readAscii(header, 4, 4);
+ const nextOffset = offset + 12 + chunkLength;
+ if (nextOffset > file.size) {
+ return false;
+ }
+ if (chunkType === 'acTL') {
+ return true;
+ }
+ if (chunkType === 'IDAT' || chunkType === 'IEND') {
+ return false;
+ }
+ if (nextOffset > bytes.length) {
+ return nextOffset < file.size;
+ }
+
+ offset = nextOffset;
+ }
+
+ return bytes.length < file.size;
+};
+
+const isAnimatedWebP = async (file: File): Promise => {
+ const bytes = await readBytes(file, 0, Math.min(file.size, MAX_ANIMATION_SCAN_BYTES));
+ if (readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
+ return false;
+ }
+
+ let offset = 12;
+ while (offset + 8 <= bytes.length) {
+ const header = bytes.subarray(offset, offset + 8);
+ const chunkType = readAscii(header, 0, 4);
+ const chunkSize = new DataView(header.buffer, header.byteOffset, header.byteLength).getUint32(
+ 4,
+ true,
+ );
+ const dataEnd = offset + 8 + chunkSize;
+ if (dataEnd > file.size) {
+ return false;
+ }
+ if (chunkType === 'ANIM' || chunkType === 'ANMF') {
+ return true;
+ }
+ if (chunkType === 'VP8 ' || chunkType === 'VP8L') {
+ return false;
+ }
+ if (chunkType === 'VP8X' && chunkSize > 0) {
+ const flags = bytes[offset + 8];
+ if (flags == null) {
+ return true;
+ }
+ if ((flags & WEBP_ANIMATION_FLAG) !== 0) {
+ return true;
+ }
+ }
+
+ const nextOffset = dataEnd + (chunkSize % 2);
+ if (nextOffset > bytes.length) {
+ return nextOffset < file.size;
+ }
+ offset = nextOffset;
+ }
+
+ return bytes.length < file.size;
+};
+
+/** Checks image containers whose animation would be flattened by canvas. */
+export const isAnimatedImage = async (file: File): Promise => {
+ if (file.type === 'image/apng') {
+ return true;
+ }
+ if (file.type === 'image/png') {
+ return isAnimatedPng(file);
+ }
+ if (file.type === 'image/webp') {
+ return isAnimatedWebP(file);
+ }
+ return false;
+};
+
/**
* Checks if the browser supports canvas-based image resizing
*/
@@ -75,18 +198,22 @@ function calculateDimensions(
const scalingFactor = Math.min(widthRatio, heightRatio);
return {
- width: Math.round(width * scalingFactor),
- height: Math.round(height * scalingFactor),
+ width: Math.max(1, Math.round(width * scalingFactor)),
+ height: Math.max(1, Math.round(height * scalingFactor)),
};
}
/**
* Resizes an image file using canvas
*/
-export function resizeImage(
+export async function resizeImage(
file: File,
options: Partial = {},
): Promise {
+ if (await isAnimatedImage(file)) {
+ throw new Error('Animated images cannot be resized without losing animation');
+ }
+
return new Promise((resolve, reject) => {
// Check browser support
if (!supportsClientResize()) {
@@ -100,118 +227,137 @@ export function resizeImage(
return;
}
- const opts = { ...DEFAULT_RESIZE_OPTIONS, ...options };
- const reader = new FileReader();
+ const sourceFormat = RESIZE_FORMAT_BY_MIME_TYPE[file.type];
+ const opts = {
+ ...DEFAULT_RESIZE_OPTIONS,
+ format: sourceFormat ?? DEFAULT_RESIZE_OPTIONS.format,
+ ...options,
+ };
+ const { maxWidth, maxHeight } = opts;
+ if (
+ maxWidth == null ||
+ maxHeight == null ||
+ !Number.isFinite(maxWidth) ||
+ !Number.isFinite(maxHeight) ||
+ maxWidth <= 0 ||
+ maxHeight <= 0
+ ) {
+ reject(new Error('Resize dimensions must be finite numbers greater than zero'));
+ return;
+ }
+ const requestedMimeType = `image/${opts.format}`;
+ /** Decoding through an object URL avoids the ~33% larger base64 copy a data URL would hold */
+ const sourceUrl = URL.createObjectURL(file);
+ const img = new Image();
- reader.onload = (event) => {
- const img = new Image();
+ img.onload = () => {
+ URL.revokeObjectURL(sourceUrl);
+ try {
+ const originalDimensions = { width: img.width, height: img.height };
+ const newDimensions = calculateDimensions(img.width, img.height, maxWidth, maxHeight);
- 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;
+ }
+
+ if (blob.size >= file.size) {
+ resolve({
+ file,
+ originalSize: file.size,
+ newSize: file.size,
+ originalDimensions,
+ newDimensions: originalDimensions,
+ compressionRatio: 1,
+ });
+ return;
+ }
+
+ const outputFormat = RESIZE_FORMAT_BY_MIME_TYPE[blob.type];
+ if (blob.type !== requestedMimeType || outputFormat == null) {
+ resolve({
+ file,
+ originalSize: file.size,
+ newSize: file.size,
+ originalDimensions,
+ newDimensions: originalDimensions,
+ compressionRatio: 1,
+ });
+ return;
+ }
+
+ // Create new file with same name but potentially different extension
+ const extension = outputFormat === 'jpeg' ? '.jpg' : `.${outputFormat}`;
+ const baseName = file.name.replace(/\.[^/.]+$/, '');
+ const newFileName = `${baseName}${extension}`;
+
+ const resizedFile = new File([blob], newFileName, {
+ type: blob.type,
+ lastModified: Date.now(),
+ });
- // If no resizing needed, return original file
- if (
- newDimensions.width === originalDimensions.width &&
- newDimensions.height === originalDimensions.height
- ) {
resolve({
- file,
+ file: resizedFile,
originalSize: file.size,
- newSize: file.size,
+ newSize: resizedFile.size,
originalDimensions,
newDimensions,
- compressionRatio: 1,
+ compressionRatio: resizedFile.size / file.size,
});
- 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;
+ },
+ requestedMimeType,
+ opts.quality,
+ );
+ } catch (error) {
+ reject(error);
+ }
};
- reader.onerror = () => reject(new Error('Failed to read file'));
- reader.readAsDataURL(file);
+ img.onerror = () => {
+ URL.revokeObjectURL(sourceUrl);
+ reject(new Error('Failed to load image'));
+ };
+ img.src = sourceUrl;
});
}
/**
- * Determines if an image should be resized based on size and dimensions
+ * Determines if an image uses a format supported by the resize pipeline.
+ * Dimensions decide whether re-encoding is necessary after the image is decoded.
*/
-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;
+export function shouldResizeImage(file: File): boolean {
+ return RESIZE_FORMAT_BY_MIME_TYPE[file.type] != null;
}
diff --git a/librechat.example.yaml b/librechat.example.yaml
index 57ecc4b715..830e115423 100644
--- a/librechat.example.yaml
+++ b/librechat.example.yaml
@@ -890,6 +890,8 @@ endpoints:
# percentage: 100
# px: 1024
# # Client-side image resizing to prevent upload errors
+# # Users can toggle this in Settings > Chat. Setting `enabled` here overrides
+# # that choice for everyone and locks the toggle; omit it to leave users in control.
# clientImageResize:
# enabled: false # Enable/disable client-side image resizing (default: false)
# maxWidth: 1900 # Maximum width for resized images (default: 1900)
diff --git a/packages/data-provider/src/file-config.spec.ts b/packages/data-provider/src/file-config.spec.ts
index 84f2a9dc15..3bd44a6a1a 100644
--- a/packages/data-provider/src/file-config.spec.ts
+++ b/packages/data-provider/src/file-config.spec.ts
@@ -2,6 +2,7 @@ import type { MimeUploadCapability } from './file-config';
import type { FileConfig } from './types/files';
import {
fileConfig as baseFileConfig,
+ fileConfigSchema,
isAnthropicTextDocumentType,
getConfiguredMimeAccept,
bedrockDocumentMimeTypes,
@@ -1690,3 +1691,48 @@ describe('setFileConfigRegexCompiler (MIME pattern compiler seam)', () => {
expect(compiled[0].test('anything')).toBe(false);
});
});
+
+describe('mergeFileConfig clientImageResize', () => {
+ it('leaves the user in control when no dynamic config is provided', () => {
+ const merged = mergeFileConfig(undefined);
+ expect(merged.clientImageResize?.enabled).toBe(false);
+ expect(merged.clientImageResize?.enforced).toBe(false);
+ });
+
+ it('leaves the user in control when the admin config omits clientImageResize', () => {
+ const merged = mergeFileConfig({ serverFileSizeLimit: 100 });
+ expect(merged.clientImageResize?.enforced).toBe(false);
+ });
+
+ it('enforces an admin-enabled value', () => {
+ const merged = mergeFileConfig({ clientImageResize: { enabled: true } });
+ expect(merged.clientImageResize?.enabled).toBe(true);
+ expect(merged.clientImageResize?.enforced).toBe(true);
+ });
+
+ it('enforces an admin-disabled value', () => {
+ const merged = mergeFileConfig({ clientImageResize: { enabled: false } });
+ expect(merged.clientImageResize?.enabled).toBe(false);
+ expect(merged.clientImageResize?.enforced).toBe(true);
+ });
+
+ it('applies admin resize parameters without enforcing the toggle', () => {
+ const merged = mergeFileConfig({
+ clientImageResize: { maxWidth: 1024, maxHeight: 1024, quality: 0.8 },
+ });
+ expect(merged.clientImageResize?.maxWidth).toBe(1024);
+ expect(merged.clientImageResize?.maxHeight).toBe(1024);
+ expect(merged.clientImageResize?.quality).toBe(0.8);
+ expect(merged.clientImageResize?.enforced).toBe(false);
+ });
+});
+
+describe('fileConfigSchema clientImageResize', () => {
+ it.each(['maxWidth', 'maxHeight'] as const)('rejects a non-positive %s', (dimension) => {
+ const result = fileConfigSchema.safeParse({
+ clientImageResize: { [dimension]: 0 },
+ });
+
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts
index 5cfbe6b2b3..133174dbe7 100644
--- a/packages/data-provider/src/file-config.ts
+++ b/packages/data-provider/src/file-config.ts
@@ -489,6 +489,7 @@ export const fileConfig = {
maxWidth: 1900,
maxHeight: 1900,
quality: 0.92,
+ enforced: false,
},
ocr: {
supportedMimeTypes: defaultOCRMimeTypes,
@@ -533,8 +534,8 @@ export const fileConfigSchema = z.object({
clientImageResize: z
.object({
enabled: z.boolean().optional(),
- maxWidth: z.number().min(0).optional(),
- maxHeight: z.number().min(0).optional(),
+ maxWidth: z.number().min(1).optional(),
+ maxHeight: z.number().min(1).optional(),
quality: z.number().min(0).max(1).optional(),
})
.optional(),
@@ -1020,11 +1021,12 @@ export function mergeFileConfig(dynamic: z.infer | unde
};
}
- // Merge clientImageResize configuration
+ // Merge clientImageResize configuration; an admin-provided `enabled` overrides the user's setting
if (dynamic.clientImageResize !== undefined) {
mergedConfig.clientImageResize = {
...mergedConfig.clientImageResize,
...dynamic.clientImageResize,
+ enforced: dynamic.clientImageResize.enabled !== undefined,
};
}
diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts
index 9ab4e64192..ed745b3f91 100644
--- a/packages/data-provider/src/types/files.ts
+++ b/packages/data-provider/src/types/files.ts
@@ -65,6 +65,8 @@ export type FileConfig = {
maxWidth?: number;
maxHeight?: number;
quality?: number;
+ /** Set when `enabled` came from the admin config, in which case it wins over the user's setting */
+ enforced?: boolean;
};
ocr?: {
supportedMimeTypes?: RegexLike[];