LibreChat/client/src/hooks/Files/useFileHandling.ts
Danny Avila bcd97aad2f
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
📎 feat: Direct Provider Attachment Support for Multimodal Content (#9994)
* 📎 feat: Direct Provider Attachment Support for Multimodal Content

* 📑 feat: Anthropic Direct Provider Upload (#9072)

* feat: implement Anthropic native PDF support with document preservation

- Add comprehensive debug logging throughout PDF processing pipeline
- Refactor attachment processing to separate image and document handling
- Create distinct addImageURLs(), addDocuments(), and processAttachments() methods
- Fix critical bugs in stream handling and parameter passing
- Add streamToBuffer utility for proper stream-to-buffer conversion
- Remove api/agents submodule from repository

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: remove out of scope formatting changes

* fix: stop duplication of file in chat on end of response stream

* chore: bring back file search and ocr options

* chore: localize upload to provider string in file menu

* refactor: change createMenuItems args to fit new pattern introduced by anthropic-native-pdf-support

* feat: add cache point for pdfs processed by anthropic endpoint since they are unlikely to change and should benefit from caching

* feat: combine Upload Image into Upload to Provider since they both perform direct upload and change provider upload icon to reflect multimodal upload

* feat: add citations support according to docs

* refactor: remove redundant 'document' check since documents are handled properly by formatMessage in the agents repo now

* refactor: change upload logic so anthropic endpoint isn't exempted from normal upload path using Agents for consistency with the rest of the upload logic

* fix: include width and height in return from uploadLocalFile so images are correctly identified when going through an AgentUpload in addImageURLs

* chore: remove client specific handling since the direct provider stuff is handled by the agent client

* feat: handle documents in AgentClient so no need for change to agents repo

* chore: removed unused changes

* chore: remove auto generated comments from OG commit

* feat: add logic for agents to use direct to provider uploads if supported (currently just anthropic)

* fix: reintroduce role check to fix render error because of undefined value for Content Part

* fix: actually fix render bug by using proper isCreatedByUser check and making sure our mutation of formattedMessage.content is consistent

---------

Co-authored-by: Andres Restrepo <andres@thelinuxkid.com>
Co-authored-by: Claude <noreply@anthropic.com>

📁 feat: Send Attachments Directly to Provider (OpenAI) (#9098)

* refactor: change references from direct upload to direct attach to better reflect functionality

since we are just using base64 encoding strategy now rather than Files/File API for sending our attachments directly to the provider, the upload nomenclature no longer makes sense. direct_attach better describes the different methods of sending attachments to providers anyways even if we later introduce direct upload support

* feat: add upload to provider option for openai (and agent) ui

* chore: move anthropic pdf validator over to packages/api

* feat: simple pdf validation according to openai docs

* feat: add provider agnostic validatePdf logic to start handling multiple endpoints

* feat: add handling for openai specific documentPart formatting

* refactor: move require statement to proper place at top of file

* chore: add in openAI endpoint for the rest of the document handling logic

* feat: add direct attach support for azureOpenAI endpoint and agents

* feat: add pdf validation for azureOpenAI endpoint

* refactor: unify all the endpoint checks with isDocumentSupportedEndpoint

* refactor: consolidate Upload to Provider vs Upload image logic for clarity

* refactor: remove anthropic from anthropic_multimodal fileType since we support multiple providers now

🗂️ feat: Send Attachments Directly to Provider (Google) (#9100)

* feat: add validation for google PDFs and add google endpoint as a document supporting endpoint

* feat: add proper pdf formatting for google endpoints (requires PR #14 in agents)

* feat: add multimodal support for google endpoint attachments

* feat: add audio file svg

* fix: refactor attachments logic so multi-attachment messages work properly

* feat: add video file svg

* fix: allows for followup questions of uploaded multimodal attachments

* fix: remove incorrect final message filtering that was breaking Attachment component rendering

fix: manualy rename 'documents' to 'Documents' in git since it wasn't picked up due to case insensitivity in dir name

fix: add logic so filepicker for a google agent has proper filetype filtering

🛫 refactor: Move Encoding Logic to packages/api (#9182)

* refactor: move audio encode over to TS

* refactor: audio encoding now functional in LC again

* refactor: move video encode over to TS

* refactor: move document encode over to TS

* refactor: video encoding now functional in LC again

* refactor: document encoding now functional in LC again

* fix: extend file type options in AttachFileMenu to include 'google_multimodal' and update dependency array to include agent?.provider

* feat: only accept pdfs if responses api is enabled for openai convos

chore: address ESLint comments

chore: add missing audio mimetype

* fix: type safety for message content parts and improve null handling

* chore: reorder AttachFileMenuProps for consistency and clarity

* chore: import order in AttachFileMenu

* fix: improve null handling for text parts in parseTextParts function

* fix: remove no longer used unsupported capability error message for file uploads

* fix: OpenAI Direct File Attachment Format

* fix: update encodeAndFormatDocuments to support  OpenAI responses API and enhance document result types

* refactor: broaden providers supported for documents

* feat: enhance DragDrop context and modal to support document uploads based on provider capabilities

* fix: reorder import statements for consistency in video encoding module

---------

Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
2025-10-06 17:30:16 -04:00

449 lines
14 KiB
TypeScript

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<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 { 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<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,
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<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;