LibreChat/client/src/hooks/Files/useFileHandling.ts
Danny Avila 1a815f5e19
🎉 feat: Code Interpreter API and Agents Release (#4860)
* feat: Code Interpreter API & File Search Agent Uploads

chore: add back code files

wip: first pass, abstract key dialog

refactor: influence checkbox on key changes

refactor: update localization keys for 'execute code' to 'run code'

wip: run code button

refactor: add throwError parameter to loadAuthValues and getUserPluginAuthValue functions

feat: first pass, API tool calling

fix: handle missing toolId in callTool function and return 404 for non-existent tools

feat: show code outputs

fix: improve error handling in callTool function and log errors

fix: handle potential null value for filepath in attachment destructuring

fix: normalize language before rendering and prevent null return

fix: add loading indicator in RunCode component while executing code

feat: add support for conditional code execution in Markdown components

feat: attachments

refactor: remove bash

fix: pass abort signal to graph/run

refactor: debounce and rate limit tool call

refactor: increase debounce delay for execute function

feat: set code output attachments

feat: image attachments

refactor: apply message context

refactor: pass `partIndex`

feat: toolCall schema/model/methods

feat: block indexing

feat: get tool calls

chore: imports

chore: typing

chore: condense type imports

feat: get tool calls

fix: block indexing

chore: typing

refactor: update tool calls mapping to support multiple results

fix: add unique key to nav link for rendering

wip: first pass, tool call results

refactor: update query cache from successful tool call mutation

style: improve result switcher styling

chore: note on using \`.toObject()\`

feat: add agent_id field to conversation schema

chore: typing

refactor: rename agentMap to agentsMap for consistency

feat: Agent Name as chat input placeholder

chore: bump agents

📦 chore: update @langchain dependencies to latest versions to match agents package

📦 chore: update @librechat/agents dependency to version 1.8.0

fix: Aborting agent stream removes sender; fix(bedrock): completion removes preset name label

refactor: remove direct file parameter to use req.file, add `processAgentFileUpload` for image uploads

feat: upload menu

feat: prime message_file resources

feat: implement conversation access validation in chat route

refactor: remove file parameter from processFileUpload and use req.file instead

feat: add savedMessageIds set to track saved message IDs in BaseClient, to prevent unnecessary double-write to db

feat: prevent duplicate message saves by checking savedMessageIds in AgentController

refactor: skip legacy RAG API handling for agents

feat: add files field to convoSchema

refactor: update request type annotations from Express.Request to ServerRequest in file processing functions

feat: track conversation files

fix: resendFiles, addPreviousAttachments handling

feat: add ID validation for session_id and file_id in download route

feat: entity_id for code file uploads/downloads

fix: code file edge cases

feat: delete related tool calls

feat: add stream rate handling for LLM configuration

feat: enhance system content with attached file information

fix: improve error logging in resource priming function

* WIP: PoC, sequential agents

WIP: PoC Sequential Agents, first pass content data + bump agents package

fix: package-lock

WIP: PoC, o1 support, refactor bufferString

feat: convertJsonSchemaToZod

fix: form issues and schema defining erroneous model

fix: max length issue on agent form instructions, limit conversation messages to sequential agents

feat: add abort signal support to createRun function and AgentClient

feat: PoC, hide prior sequential agent steps

fix: update parameter naming from config to metadata in event handlers for clarity, add model to usage data

refactor: use only last contentData, track model for usage data

chore: bump agents package

fix: content parts issue

refactor: filter contentParts to include tool calls and relevant indices

feat: show function calls

refactor: filter context messages to exclude tool calls when no tools are available to the agent

fix: ensure tool call content is not undefined in formatMessages

feat: add agent_id field to conversationPreset schema

feat: hide sequential agents

feat: increase upload toast duration to 10 seconds

* refactor: tool context handling & update Code API Key Dialog

feat: toolContextMap

chore: skipSpecs -> useSpecs

ci: fix handleTools tests

feat: API Key Dialog

* feat: Agent Permissions Admin Controls

feat: replace label with button for prompt permission toggle

feat: update agent permissions

feat: enable experimental agents and streamline capability configuration

feat: implement access control for agents and enhance endpoint menu items

feat: add welcome message for agent selection in localization

feat: add agents permission to access control and update version to 0.7.57

* fix: update types in useAssistantListMap and useMentions hooks for better null handling

* feat: mention agents

* fix: agent tool resource race conditions when deleting agent tool resource files

* feat: add error handling for code execution with user feedback

* refactor: rename AdminControls to AdminSettings for clarity

* style: add gap to button in AdminSettings for improved layout

* refactor: separate agent query hooks and check access to enable fetching

* fix: remove unused provider from agent initialization options, creates issue with custom endpoints

* refactor: remove redundant/deprecated modelOptions from AgentClient processes

* chore: update @librechat/agents to version 1.8.5 in package.json and package-lock.json

* fix: minor styling issues + agent panel uniformity

* fix: agent edge cases when set endpoint is no longer defined

* refactor: remove unused cleanup function call from AppService

* fix: update link in ApiKeyDialog to point to pricing page

* fix: improve type handling and layout calculations in SidePanel component

* fix: add missing localization string for agent selection in SidePanel

* chore: form styling and localizations for upload filesearch/code interpreter

* fix: model selection placeholder logic in AgentConfig component

* style: agent capabilities

* fix: add localization for provider selection and improve dropdown styling in ModelPanel

* refactor: use gpt-4o-mini > gpt-3.5-turbo

* fix: agents configuration for loadDefaultInterface and update related tests

* feat: DALLE Agents support
2024-12-04 15:48:13 -05:00

405 lines
12 KiB
TypeScript

import { v4 } from 'uuid';
import debounce from 'lodash/debounce';
import { useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
megabyte,
QueryKeys,
EModelEndpoint,
codeTypeMapping,
mergeFileConfig,
isAgentsEndpoint,
isAssistantsEndpoint,
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common';
import { useUploadFileMutation, useGetFileConfig } from '~/data-provider';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext';
import useLocalize from '~/hooks/useLocalize';
import useUpdateFiles from './useUpdateFiles';
import { logger } from '~/utils';
const { checkType } = defaultFileConfig;
type UseFileHandling = {
overrideEndpoint?: EModelEndpoint;
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 [toolResource, setToolResource] = useState<string | undefined>();
const { files, setFiles, setFilesLoading, conversation } = useChatContext();
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
params?.fileSetter ?? setFiles,
);
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 { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes } = useMemo(
() =>
fileConfig?.endpoints[endpoint] ??
fileConfig?.endpoints.default ??
defaultFileConfig.endpoints[endpoint] ??
defaultFileConfig.endpoints.default,
[fileConfig, endpoint],
);
const displayToast = useCallback(() => {
if (errors.length > 1) {
const errorList = Array.from(new Set(errors))
.map((e, i) => `${i > 0 ? '• ' : ''}${localize(e) || e}\n`)
.join('');
showToast({
message: errorList,
status: 'error',
duration: 5000,
});
} else if (errors.length === 1) {
const message = localize(errors[0]) || 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);
},
onMutate: () => {
setToolResource(undefined);
},
},
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('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');
}
if (toolResource != null) {
formData.append('tool_resource', toolResource);
}
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 validateFiles = useCallback(
(fileList: File[]) => {
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 (fileList.length + files.size > fileLimit) {
setError(`You can only upload up to ${fileLimit} files at a time.`);
return false;
}
for (let i = 0; i < fileList.length; i++) {
let originalFile = fileList[i];
let fileType = originalFile.type;
const extension = originalFile.name.split('.').pop() ?? '';
const knownCodeType = codeTypeMapping[extension];
// Infer MIME type for Known Code files when the type is empty or a mismatch
if (knownCodeType && (!fileType || fileType !== knownCodeType)) {
fileType = knownCodeType;
}
// Check if the file type is still empty after the extension check
if (!fileType) {
setError('Unable to determine file type for: ' + originalFile.name);
return false;
}
// Replace empty type with inferred type
if (originalFile.type !== fileType) {
const newFile = new File([originalFile], originalFile.name, { type: fileType });
originalFile = newFile;
fileList[i] = newFile;
}
if (!checkType(originalFile.type, supportedMimeTypes)) {
console.log(originalFile);
setError('Currently, unsupported file type: ' + originalFile.type);
return false;
}
if (originalFile.size >= fileSizeLimit) {
setError(`File size exceeds ${fileSizeLimit / megabyte} MB.`);
return false;
}
}
if (currentTotalSize + incomingTotalSize > totalSizeLimit) {
setError(`The total size of the files cannot exceed ${totalSizeLimit / megabyte} MB.`);
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;
},
[files, fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes],
);
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[]) => {
abortControllerRef.current = new AbortController();
const fileList = Array.from(_files);
/* Validate files */
let filesAreValid: boolean;
try {
filesAreValid = validateFiles(fileList);
} 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 {
const preview = URL.createObjectURL(originalFile);
const extendedFile: ExtendedFile = {
file_id,
file: originalFile,
type: originalFile.type,
preview,
progress: 0.2,
size: originalFile.size,
};
addFile(extendedFile);
if (originalFile.type.split('/')[0] === 'image') {
loadImage(extendedFile, preview);
continue;
}
await startUpload(extendedFile);
} catch (error) {
deleteFileById(file_id);
console.log('file handling error', error);
setError('com_error_files_process');
}
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.target.files) {
setFilesLoading(true);
handleFiles(event.target.files);
// 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,
setToolResource,
handleFiles,
abortUpload,
setFiles,
files,
};
};
export default useFileHandling;