import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { v4 } from 'uuid'; import { useSetRecoilState } from 'recoil'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, Constants, EModelEndpoint, EToolResources, 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 { ephemeralAgentByConvoId } from '~/store'; 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; }; const useFileHandling = (params?: UseFileHandling) => { const localize = useLocalize(); const queryClient = useQueryClient(); const { showToast } = useToastContext(); const [errors, setErrors] = useState([]); const abortControllerRef = useRef(null); const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast(); const { files, setFiles, setFilesLoading, conversation } = useChatContext(); const setEphemeralAgent = useSetRecoilState( ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), ); 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'); const tool_resource = body.get('tool_resource'); if (tool_resource === EToolResources.execute_code) { setEphemeralAgent((prev) => ({ ...prev, [EToolResources.execute_code]: false, })); } clearUploadTimer(file_id as string); deleteFileById(file_id as string); let errorMessage = 'com_error_files_upload'; if (error?.code === 'ERR_CANCELED') { errorMessage = 'com_error_files_upload_canceled'; } else if (error?.response?.data?.message) { errorMessage = error.response.data.message; } 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([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, toolResource: _toolResource, fileConfig: fileConfig, }); } 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'; // 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, _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;