| | import React, { useCallback, useEffect, useRef, useMemo, 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, |
| | EToolResources, |
| | mergeFileConfig, |
| | isAssistantsEndpoint, |
| | getEndpointFileConfig, |
| | defaultAssistantsVersion, |
| | } from 'librechat-data-provider'; |
| | import debounce from 'lodash/debounce'; |
| | import type { 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; |
| | fileFilter?: (file: File) => boolean; |
| | 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 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 endpointType = useMemo(() => conversation?.endpointType, [conversation?.endpointType]); |
| | const endpoint = useMemo(() => conversation?.endpoint ?? 'default', [conversation?.endpoint]); |
| |
|
| | const { data: fileConfig = null } = useGetFileConfig({ |
| | select: (data) => mergeFileConfig(data), |
| | }); |
| |
|
| | const displayToast = useCallback(() => { |
| | if (errors.length > 1) { |
| | |
| | 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) { |
| | |
| | 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('endpointType', endpointType ?? ''); |
| | 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 (!isAssistantsEndpoint(endpointType ?? 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); |
| | } |
| |
|
| | 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); |
| | |
| | let filesAreValid: boolean; |
| | try { |
| | const endpointFileConfig = getEndpointFileConfig({ |
| | endpoint, |
| | fileConfig, |
| | endpointType, |
| | }); |
| |
|
| | filesAreValid = validateFiles({ |
| | files, |
| | fileList, |
| | setError, |
| | fileConfig, |
| | endpointFileConfig, |
| | toolResource: _toolResource, |
| | }); |
| | } catch (error) { |
| | console.error('file validation error', error); |
| | setError('com_error_files_validation'); |
| | return; |
| | } |
| | if (!filesAreValid) { |
| | setFilesLoading(false); |
| | return; |
| | } |
| |
|
| | |
| | for (const originalFile of fileList) { |
| | const file_id = v4(); |
| | try { |
| | |
| | const initialPreview = URL.createObjectURL(originalFile); |
| |
|
| | |
| | const initialExtendedFile: ExtendedFile = { |
| | file_id, |
| | file: originalFile, |
| | type: originalFile.type, |
| | preview: initialPreview, |
| | progress: 0.1, |
| | size: originalFile.size, |
| | }; |
| |
|
| | if (_toolResource != null && _toolResource !== '') { |
| | initialExtendedFile.tool_resource = _toolResource; |
| | } |
| |
|
| | |
| | addFile(initialExtendedFile); |
| |
|
| | |
| | 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, |
| | }); |
| | } |
| |
|
| | |
| | const heicProcessedFile = await processFileForUpload( |
| | originalFile, |
| | 0.9, |
| | (conversionProgress) => { |
| | |
| | const adjustedProgress = 0.1 + conversionProgress * 0.4; |
| | replaceFile({ |
| | ...initialExtendedFile, |
| | progress: adjustedProgress, |
| | }); |
| | }, |
| | ); |
| |
|
| | let finalProcessedFile = heicProcessedFile; |
| |
|
| | |
| | if (heicProcessedFile.type.startsWith('image/')) { |
| | try { |
| | const resizeResult = await resizeImageIfNeeded(heicProcessedFile); |
| | finalProcessedFile = resizeResult.file; |
| |
|
| | |
| | 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); |
| | |
| | } |
| | } |
| |
|
| | |
| | if (finalProcessedFile !== originalFile) { |
| | URL.revokeObjectURL(initialPreview); |
| | const newPreview = URL.createObjectURL(finalProcessedFile); |
| |
|
| | const updatedExtendedFile: ExtendedFile = { |
| | ...initialExtendedFile, |
| | file: finalProcessedFile, |
| | type: finalProcessedFile.type, |
| | preview: newPreview, |
| | progress: 0.5, |
| | size: finalProcessedFile.size, |
| | }; |
| |
|
| | replaceFile(updatedExtendedFile); |
| |
|
| | const isImage = finalProcessedFile.type.split('/')[0] === 'image'; |
| | if (isImage) { |
| | loadImage(updatedExtendedFile, newPreview); |
| | continue; |
| | } |
| |
|
| | await startUpload(updatedExtendedFile); |
| | } else { |
| | |
| | const isImage = originalFile.type.split('/')[0] === 'image'; |
| |
|
| | |
| | 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); |
| | |
| | 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; |
| |
|