mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-29 22:58:51 +01:00
* feat: init @librechat/client * feat: Add common types and interfaces for accessibility, agents, artifacts, assistants, and tools * feat: Add jotai as a peer dependency * fix build client package * feat: cleanup unused types from common/index.ts - Remove 104 unused type exports from packages/client/src/common/index.ts - Keep only 7 actually used exports (93% reduction) - Add cleanup script with enhanced import pattern detection - Support both named imports and namespace imports (* as t) - Create automatic backups and comprehensive documentation - Maintain type safety with build verification - No breaking changes to existing code Kept exports: - TShowToast, Option, OptionWithIcon, DropdownValueSetter - MentionOption, NotificationSeverity, MenuItemProps Scripts: cleanup-common-types-safe.js, README-CLEANUP.md * fix: cleanup * fix: package; refactor: tsconfig * feat: add back `recoil` * fix: move dependencies to peerDependencies in client package * feat: add @librechat/client as a dependency in package.json and package-lock.json * feat: update client package configuration and dependencies - Added new dependencies for Rollup plugins and updated existing ones in package.json and package-lock.json. - Introduced a new Rollup configuration file for building the client package. - Refactored build scripts to include a dedicated build command for the client. - Updated TypeScript configuration for improved module resolution and type declaration output. - Integrated a Toast component from the client package into the main App component. * feat: enhance Rollup configuration for client package - Updated terser plugin settings to preserve directives like 'use client'. - Added custom warning handler to ignore "use client" directive warnings during the build process. * chore: rename package/client build script command * feat: update client package dependencies and Rollup configuration - Added rollup-plugin-postcss to package.json and updated package-lock.json. - Enhanced Rollup configuration to include postcss plugin for CSS handling. - Updated index.ts to export all components from the components directory for better modularity. * feat: add client package directory to update configuration - Included the 'client' package directory in the update.js configuration to ensure it is recognized during updates. * feat: export Toast component in client package - Added export for the Toast component in index.ts to enhance modularity and accessibility of components. * feat: /client transition to @librechat/client * chore: fixed formatting issues * fix: update peer dependencies in @librechat/client to prevent bundling them * fix: correct useSprings implementation in SplitText component * fix: circular dependencies in DataTable * fix: add remaining peer dependencies and match actual versions previously used in `client/package.json` * fix: correct frontend:ci script to include client package build * chore: enhance unused package detection for @librechat/client and improve dependency extraction * fix: add missing peer dependency for @radix-ui/react-collapsible * chore: include "packages/client" in unused i18next keys detection * test: update AgentFooter tests to use document.querySelector for spinner checks test: mock window.matchMedia in setupTests.js for consistent test environment * feat: add react-hook-form dependency and update FormInput component to use its types * chore: linting * refactor: remove unused defaultSelectedValues prop from MCPSelect and MultiSelect components * chore: linting * feat: update GitHub Actions workflow to publish @librechat/client * chore: update GitHub Actions workflow to install and build data-provider and client dependencies * chore: add missing @testing-library/react dependency to client package * chore: update tsconfig.json to exclude additional test files * chore: fix build issues, resolve latest LC changes * chore: move MCP components outside of `~/components/ui` * feat: implement dynamic theme system with environment variable support and Tailwind CSS integration * chore: remove unnecessary logging of sttExternal and ttsExternal in Speech component * chore: squashed cleanup commits chore: move @tanstack/react-virtual to dependencies and remove recoil from package.json chore: move dependencies to peerDependencies in package.json feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration feat: update package.json and rollup.config.js to include jotai and enhance bundling configuration refactor: reorganize exports in index.ts for improved clarity refactor: remove unused types and interfaces from common files refactor: update peer dependencies and improve component typings - Removed duplicate peer dependencies from package.json and organized them. - Updated rollup.config.js to disable TypeScript checking during the build process. - Modified AnimatedTabs component to use React.ReactNode for label and content types, and added TypeScript workarounds for compatibility. - Enhanced Label and Separator components to accept an optional className prop and improved prop spreading. - Updated Slider component to include an optional className prop and refined prop handling for better type safety. refactor: clean up client workflow and update package dependencies refactor: update package dependencies and improve PostCSS and Rollup configurations chore: bump version to 0.1.2 in package.json chore: bump client version to 0.1.2 in package-lock.json chore: bump client version to 0.1.3 and update dependencies chore: bump client version to 0.1.4 and update @react-spring dependencies chore: update package version to 0.1.5 and adjust peer dependencies - Bump version in package.json from 0.1.4 to 0.1.5. - Update peer dependency for @tanstack/react-query to allow version 5.0.0. - Add @tanstack/react-table and @tanstack/react-virtual as dependencies. - Update various dependencies to their latest compatible versions. - Simplify postcss.config.js by removing unnecessary options. - Clean up rollup.config.js by removing ignored PostCSS warnings. - Update CheckboxButton component to cast icon as React JSX element. - Adjust Combobox component's class names for better styling. - Change DropdownPopup component to use React's namespace import. - Modify InputOTP component to use 'any' type for OTPInputContext. - Ensure displayLabel and value in ModelParameters are converted to strings. - Update MultiSearch component's placeholder to ensure it's a string. - Cast selectIcon in MultiSelect as React JSX element for consistency. - Update OGDialogTemplate to cast selectText as React JSX element. - Initialize animationRef in PixelCard with undefined for clarity. - Add TypeScript ignore comments in Select and SelectDropDown components for Radix UI type conflicts. - Ensure title in SelectDropDown is a string and adjust rendering of options. - Update useLocalize hook to cast options as any for compatibility. refactor: code structure; chore: translations cleanup chore: remove unused imports and clean up code in NewChat component refactor: enhance Menu component to support custom render functions for menu items style: update itemClassName in ToolsDropdown for improved UI consistency fix: merge conflicts chore: update @radix-ui/react-accordion to version 1.2.11 * refactor: remove unnecessary TypeScript type assertions in AnimatedTabs, Label, Separator, and Slider components * feat: enhance theme system with localStorage persistence and new theme atoms * chore: bump version of @librechat/client to 0.1.7 * chore: fix ci/cd warnings/errors related to linting and unused localization keys * chore: update dependencies for class-variance-authority, clsx, and match-sorter * chore: bump @librechat/client to v0.1.8 * feat: add utility colors for theme customization and remove unused tailwindConfig * v0.1.9 --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { v4 } from 'uuid';
|
|
import { useToastContext } from '@librechat/client';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
QueryKeys,
|
|
EModelEndpoint,
|
|
mergeFileConfig,
|
|
isAgentsEndpoint,
|
|
isAssistantsEndpoint,
|
|
defaultAssistantsVersion,
|
|
fileConfig as defaultFileConfig,
|
|
} from 'librechat-data-provider';
|
|
import debounce from 'lodash/debounce';
|
|
import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider';
|
|
import type { ExtendedFile, FileSetter } from '~/common';
|
|
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
|
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
|
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
|
import { processFileForUpload } from '~/utils/heicConverter';
|
|
import { useChatContext } from '~/Providers/ChatContext';
|
|
import { logger, validateFiles } from '~/utils';
|
|
import useClientResize from './useClientResize';
|
|
import useUpdateFiles from './useUpdateFiles';
|
|
|
|
type UseFileHandling = {
|
|
fileSetter?: FileSetter;
|
|
overrideEndpoint?: EModelEndpoint;
|
|
fileFilter?: (file: File) => boolean;
|
|
overrideEndpointFileConfig?: EndpointFileConfig;
|
|
additionalMetadata?: Record<string, string | undefined>;
|
|
};
|
|
|
|
const useFileHandling = (params?: UseFileHandling) => {
|
|
const localize = useLocalize();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToastContext();
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast();
|
|
const { files, setFiles, setFilesLoading, conversation } = useChatContext();
|
|
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
|
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
|
|
params?.fileSetter ?? setFiles,
|
|
);
|
|
const { resizeImageIfNeeded } = useClientResize();
|
|
|
|
const agent_id = params?.additionalMetadata?.agent_id ?? '';
|
|
const assistant_id = params?.additionalMetadata?.assistant_id ?? '';
|
|
|
|
const { data: fileConfig = null } = useGetFileConfig({
|
|
select: (data) => mergeFileConfig(data),
|
|
});
|
|
|
|
const endpoint = useMemo(
|
|
() =>
|
|
params?.overrideEndpoint ?? conversation?.endpointType ?? conversation?.endpoint ?? 'default',
|
|
[params?.overrideEndpoint, conversation?.endpointType, conversation?.endpoint],
|
|
);
|
|
|
|
const displayToast = useCallback(() => {
|
|
if (errors.length > 1) {
|
|
// TODO: this should not be a dynamic localize input!!
|
|
const errorList = Array.from(new Set(errors))
|
|
.map((e, i) => `${i > 0 ? '• ' : ''}${localize(e as TranslationKeys) || e}\n`)
|
|
.join('');
|
|
showToast({
|
|
message: errorList,
|
|
status: 'error',
|
|
duration: 5000,
|
|
});
|
|
} else if (errors.length === 1) {
|
|
// TODO: this should not be a dynamic localize input!!
|
|
const message = localize(errors[0] as TranslationKeys) || errors[0];
|
|
showToast({
|
|
message,
|
|
status: 'error',
|
|
duration: 5000,
|
|
});
|
|
}
|
|
|
|
setErrors([]);
|
|
}, [errors, showToast, localize]);
|
|
|
|
const debouncedDisplayToast = debounce(displayToast, 250);
|
|
|
|
useEffect(() => {
|
|
if (errors.length > 0) {
|
|
debouncedDisplayToast();
|
|
}
|
|
|
|
return () => debouncedDisplayToast.cancel();
|
|
}, [errors, debouncedDisplayToast]);
|
|
|
|
const uploadFile = useUploadFileMutation(
|
|
{
|
|
onSuccess: (data) => {
|
|
clearUploadTimer(data.temp_file_id);
|
|
console.log('upload success', data);
|
|
if (agent_id) {
|
|
queryClient.refetchQueries([QueryKeys.agent, agent_id]);
|
|
return;
|
|
}
|
|
updateFileById(
|
|
data.temp_file_id,
|
|
{
|
|
progress: 0.9,
|
|
filepath: data.filepath,
|
|
},
|
|
assistant_id ? true : false,
|
|
);
|
|
|
|
setTimeout(() => {
|
|
updateFileById(
|
|
data.temp_file_id,
|
|
{
|
|
progress: 1,
|
|
file_id: data.file_id,
|
|
temp_file_id: data.temp_file_id,
|
|
filepath: data.filepath,
|
|
type: data.type,
|
|
height: data.height,
|
|
width: data.width,
|
|
filename: data.filename,
|
|
source: data.source,
|
|
embedded: data.embedded,
|
|
},
|
|
assistant_id ? true : false,
|
|
);
|
|
}, 300);
|
|
},
|
|
onError: (_error, body) => {
|
|
const error = _error as TError | undefined;
|
|
console.log('upload error', error);
|
|
const file_id = body.get('file_id');
|
|
clearUploadTimer(file_id as string);
|
|
deleteFileById(file_id as string);
|
|
const errorMessage =
|
|
error?.code === 'ERR_CANCELED'
|
|
? 'com_error_files_upload_canceled'
|
|
: (error?.response?.data?.message ?? 'com_error_files_upload');
|
|
setError(errorMessage);
|
|
},
|
|
},
|
|
abortControllerRef.current?.signal,
|
|
);
|
|
|
|
const startUpload = async (extendedFile: ExtendedFile) => {
|
|
const filename = extendedFile.file?.name ?? 'File';
|
|
startUploadTimer(extendedFile.file_id, filename, extendedFile.size);
|
|
|
|
const formData = new FormData();
|
|
formData.append('endpoint', endpoint);
|
|
formData.append(
|
|
'original_endpoint',
|
|
conversation?.endpointType || conversation?.endpoint || '',
|
|
);
|
|
formData.append('file', extendedFile.file as File, encodeURIComponent(filename));
|
|
formData.append('file_id', extendedFile.file_id);
|
|
|
|
const width = extendedFile.width ?? 0;
|
|
const height = extendedFile.height ?? 0;
|
|
if (width) {
|
|
formData.append('width', width.toString());
|
|
}
|
|
if (height) {
|
|
formData.append('height', height.toString());
|
|
}
|
|
|
|
const metadata = params?.additionalMetadata ?? {};
|
|
if (params?.additionalMetadata) {
|
|
for (const [key, value = ''] of Object.entries(metadata)) {
|
|
if (value) {
|
|
formData.append(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isAgentsEndpoint(endpoint)) {
|
|
if (!agent_id) {
|
|
formData.append('message_file', 'true');
|
|
}
|
|
const tool_resource = extendedFile.tool_resource;
|
|
if (tool_resource != null) {
|
|
formData.append('tool_resource', tool_resource);
|
|
}
|
|
if (conversation?.agent_id != null && formData.get('agent_id') == null) {
|
|
formData.append('agent_id', conversation.agent_id);
|
|
}
|
|
}
|
|
|
|
if (!isAssistantsEndpoint(endpoint)) {
|
|
uploadFile.mutate(formData);
|
|
return;
|
|
}
|
|
|
|
const convoModel = conversation?.model ?? '';
|
|
const convoAssistantId = conversation?.assistant_id ?? '';
|
|
|
|
if (!assistant_id) {
|
|
formData.append('message_file', 'true');
|
|
}
|
|
|
|
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
|
|
const version = endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint];
|
|
|
|
if (!assistant_id && convoAssistantId) {
|
|
formData.append('version', version);
|
|
formData.append('model', convoModel);
|
|
formData.append('assistant_id', convoAssistantId);
|
|
}
|
|
|
|
const formVersion = (formData.get('version') ?? '') as string;
|
|
if (!formVersion) {
|
|
formData.append('version', version);
|
|
}
|
|
|
|
const formModel = (formData.get('model') ?? '') as string;
|
|
if (!formModel) {
|
|
formData.append('model', convoModel);
|
|
}
|
|
|
|
uploadFile.mutate(formData);
|
|
};
|
|
|
|
const loadImage = (extendedFile: ExtendedFile, preview: string) => {
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
extendedFile.width = img.width;
|
|
extendedFile.height = img.height;
|
|
extendedFile = {
|
|
...extendedFile,
|
|
progress: 0.6,
|
|
};
|
|
replaceFile(extendedFile);
|
|
|
|
await startUpload(extendedFile);
|
|
URL.revokeObjectURL(preview);
|
|
};
|
|
img.src = preview;
|
|
};
|
|
|
|
const handleFiles = async (_files: FileList | File[], _toolResource?: string) => {
|
|
abortControllerRef.current = new AbortController();
|
|
const fileList = Array.from(_files);
|
|
/* Validate files */
|
|
let filesAreValid: boolean;
|
|
try {
|
|
filesAreValid = validateFiles({
|
|
files,
|
|
fileList,
|
|
setError,
|
|
endpointFileConfig:
|
|
params?.overrideEndpointFileConfig ??
|
|
fileConfig?.endpoints?.[endpoint] ??
|
|
fileConfig?.endpoints?.default ??
|
|
defaultFileConfig.endpoints[endpoint] ??
|
|
defaultFileConfig.endpoints.default,
|
|
});
|
|
} catch (error) {
|
|
console.error('file validation error', error);
|
|
setError('com_error_files_validation');
|
|
return;
|
|
}
|
|
if (!filesAreValid) {
|
|
setFilesLoading(false);
|
|
return;
|
|
}
|
|
|
|
/* Process files */
|
|
for (const originalFile of fileList) {
|
|
const file_id = v4();
|
|
try {
|
|
// Create initial preview with original file
|
|
const initialPreview = URL.createObjectURL(originalFile);
|
|
|
|
// Create initial ExtendedFile to show immediately
|
|
const initialExtendedFile: ExtendedFile = {
|
|
file_id,
|
|
file: originalFile,
|
|
type: originalFile.type,
|
|
preview: initialPreview,
|
|
progress: 0.1, // Show as processing
|
|
size: originalFile.size,
|
|
};
|
|
|
|
if (_toolResource != null && _toolResource !== '') {
|
|
initialExtendedFile.tool_resource = _toolResource;
|
|
}
|
|
|
|
// Add file immediately to show in UI
|
|
addFile(initialExtendedFile);
|
|
|
|
// Check if HEIC conversion is needed and show toast
|
|
const isHEIC =
|
|
originalFile.type === 'image/heic' ||
|
|
originalFile.type === 'image/heif' ||
|
|
originalFile.name.toLowerCase().match(/\.(heic|heif)$/);
|
|
|
|
if (isHEIC) {
|
|
showToast({
|
|
message: localize('com_info_heic_converting'),
|
|
status: 'info',
|
|
duration: 3000,
|
|
});
|
|
}
|
|
|
|
// Process file for HEIC conversion if needed
|
|
const heicProcessedFile = await processFileForUpload(
|
|
originalFile,
|
|
0.9,
|
|
(conversionProgress) => {
|
|
// Update progress during HEIC conversion (0.1 to 0.5 range for conversion)
|
|
const adjustedProgress = 0.1 + conversionProgress * 0.4;
|
|
replaceFile({
|
|
...initialExtendedFile,
|
|
progress: adjustedProgress,
|
|
});
|
|
},
|
|
);
|
|
|
|
let finalProcessedFile = heicProcessedFile;
|
|
|
|
// 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
|
|
const newPreview = URL.createObjectURL(finalProcessedFile);
|
|
|
|
const updatedExtendedFile: ExtendedFile = {
|
|
...initialExtendedFile,
|
|
file: finalProcessedFile,
|
|
type: finalProcessedFile.type,
|
|
preview: newPreview,
|
|
progress: 0.5, // Processing complete, ready for upload
|
|
size: finalProcessedFile.size,
|
|
};
|
|
|
|
replaceFile(updatedExtendedFile);
|
|
|
|
const isImage = finalProcessedFile.type.split('/')[0] === 'image';
|
|
if (isImage) {
|
|
loadImage(updatedExtendedFile, newPreview);
|
|
continue;
|
|
}
|
|
|
|
await startUpload(updatedExtendedFile);
|
|
} else {
|
|
// File wasn't processed, proceed with original
|
|
const isImage = originalFile.type.split('/')[0] === 'image';
|
|
const tool_resource =
|
|
initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
|
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
|
|
/** Note: this needs to be removed when we can support files to providers */
|
|
setError('com_error_files_unsupported_capability');
|
|
continue;
|
|
}
|
|
|
|
// Update progress to show ready for upload
|
|
const readyExtendedFile = {
|
|
...initialExtendedFile,
|
|
progress: 0.2,
|
|
};
|
|
replaceFile(readyExtendedFile);
|
|
|
|
if (isImage) {
|
|
loadImage(readyExtendedFile, initialPreview);
|
|
continue;
|
|
}
|
|
|
|
await startUpload(readyExtendedFile);
|
|
}
|
|
} catch (error) {
|
|
deleteFileById(file_id);
|
|
console.log('file handling error', error);
|
|
if (error instanceof Error && error.message.includes('HEIC')) {
|
|
setError('com_error_heic_conversion');
|
|
} else {
|
|
setError('com_error_files_process');
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>, _toolResource?: string) => {
|
|
event.stopPropagation();
|
|
if (event.target.files) {
|
|
setFilesLoading(true);
|
|
handleFiles(event.target.files, _toolResource);
|
|
// reset the input
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
const abortUpload = () => {
|
|
if (abortControllerRef.current) {
|
|
logger.log('files', 'Aborting upload');
|
|
abortControllerRef.current.abort('User aborted upload');
|
|
abortControllerRef.current = null;
|
|
}
|
|
};
|
|
|
|
return {
|
|
handleFileChange,
|
|
handleFiles,
|
|
abortUpload,
|
|
setFiles,
|
|
files,
|
|
};
|
|
};
|
|
|
|
export default useFileHandling;
|