⬆️ feat: Cancel chat file uploads; fix: Assistant uploads (#4433)

* refactor: move file mutations to dedicated file, improve typing

* refactor(ChatForm): utilize FileFormWrapper to consolidate file upload logic/rendering to single parent

* refactor: better TSX heirarchies between AttachFile and FileFormWrapper

* refactor: `abortUpload` WIP

* fix: file debugging and file upload issues

* refactor: reject promise outright if axios intercepted error does not include response property

* chore: bump data-provider version to 0.7.428

* refactor: Add return type to localize function in Translation.ts

* refactor: allow message file attachment upload request cancellations, and add localizations for file upload errors

* refactor: include Azure OpenAI in paramEndpoints set

* fix: assistant form uploads and better typing

* refactor: consolidate logic
This commit is contained in:
Danny Avila 2024-10-16 11:24:40 -04:00 committed by GitHub
parent 0870acd086
commit 65888c274a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 419 additions and 311 deletions

View file

@ -69,9 +69,10 @@ router.delete('/', async (req, res) => {
await processDeleteRequest({ req, files }); await processDeleteRequest({ req, files });
logger.debug( logger.debug(
`[/files] Files deleted successfully: ${files.map( `[/files] Files deleted successfully: ${files
(f, i) => `${f.file_id}${i < files.length - 1 ? ', ' : ''}`, .filter((f) => f.file_id)
)}`, .map((f) => f.file_id)
.join(', ')}`,
); );
res.status(200).json({ message: 'Files deleted successfully' }); res.status(200).json({ message: 'Files deleted successfully' });
} catch (error) { } catch (error) {
@ -220,7 +221,7 @@ router.post('/', async (req, res) => {
try { try {
await fs.unlink(file.path); await fs.unlink(file.path);
} catch (error) { } catch (error) {
logger.error('[/files/images] Error deleting file after file processing:', error); logger.error('[/files] Error deleting file after file processing:', error);
} }
} }
}); });

View file

@ -8,9 +8,9 @@ import {
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import { import {
useChatContext, useChatContext,
useChatFormContext,
useAddedChatContext, useAddedChatContext,
useAssistantsMapContext, useAssistantsMapContext,
useChatFormContext,
} from '~/Providers'; } from '~/Providers';
import { import {
useTextarea, useTextarea,
@ -20,18 +20,17 @@ import {
useQueryParams, useQueryParams,
useSubmitMessage, useSubmitMessage,
} from '~/hooks'; } from '~/hooks';
import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui'; import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils'; import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader'; import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand'; import PromptsCommand from './PromptsCommand';
import AttachFile from './Files/AttachFile';
import AudioRecorder from './AudioRecorder'; import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common'; import { mainTextareaId } from '~/common';
import StreamAudio from './StreamAudio'; import StreamAudio from './StreamAudio';
import StopButton from './StopButton'; import StopButton from './StopButton';
import SendButton from './SendButton'; import SendButton from './SendButton';
import FileRow from './Files/FileRow';
import Mention from './Mention'; import Mention from './Mention';
import store from '~/store'; import store from '~/store';
@ -73,7 +72,6 @@ const ChatForm = ({ index = 0 }) => {
conversation, conversation,
isSubmitting, isSubmitting,
filesLoading, filesLoading,
setFilesLoading,
newConversation, newConversation,
handleStopGenerating, handleStopGenerating,
} = useChatContext(); } = useChatContext();
@ -130,6 +128,9 @@ const ChatForm = ({ index = 0 }) => {
} }
}, [isSearching, disableInputs]); }, [isSearching, disableInputs]);
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
return ( return (
<form <form
onSubmit={methods.handleSubmit((data) => submitMessage(data))} onSubmit={methods.handleSubmit((data) => submitMessage(data))}
@ -157,17 +158,7 @@ const ChatForm = ({ index = 0 }) => {
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} /> <PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500"> <div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} /> <TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileRow <FileFormWrapper disableInputs={disableInputs}>
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => (
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
{children}
</div>
)}
/>
{endpoint && ( {endpoint && (
<TextareaAutosize <TextareaAutosize
{...registerProps} {...registerProps}
@ -187,7 +178,7 @@ const ChatForm = ({ index = 0 }) => {
style={{ height: 44, overflowY: 'auto' }} style={{ height: 44, overflowY: 'auto' }}
rows={1} rows={1}
className={cn( className={cn(
supportsFiles[endpointType ?? endpoint ?? ''] && !endpointFileConfig?.disabled endpointSupportsFiles && !isUploadDisabled
? 'pl-10 md:pl-[55px]' ? 'pl-10 md:pl-[55px]'
: 'pl-3 md:pl-4', : 'pl-3 md:pl-4',
'm-0 w-full resize-none border-0 bg-transparent py-[10px] placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 ', 'm-0 w-full resize-none border-0 bg-transparent py-[10px] placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 ',
@ -197,12 +188,7 @@ const ChatForm = ({ index = 0 }) => {
)} )}
/> />
)} )}
<AttachFile </FileFormWrapper>
endpoint={_endpoint ?? ''}
endpointType={endpointType}
isRTL={isRTL}
disabled={disableInputs}
/>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? ( {(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton <StopButton
stop={handleStopGenerating} stop={handleStopGenerating}

View file

@ -1,37 +1,20 @@
import React from 'react'; import React from 'react';
import {
EModelEndpoint,
supportsFiles,
fileConfig as defaultFileConfig,
mergeFileConfig,
} from 'librechat-data-provider';
import { FileUpload, TooltipAnchor } from '~/components/ui'; import { FileUpload, TooltipAnchor } from '~/components/ui';
import { useFileHandling, useLocalize } from '~/hooks';
import { useGetFileConfig } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg'; import { AttachmentIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
const AttachFile = ({ const AttachFile = ({
endpoint,
endpointType,
isRTL, isRTL,
disabled = false, disabled,
handleFileChange,
}: { }: {
endpoint: EModelEndpoint | '';
endpointType?: EModelEndpoint;
isRTL: boolean; isRTL: boolean;
disabled?: boolean | null; disabled?: boolean | null;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) => { }) => {
const localize = useLocalize(); const localize = useLocalize();
const { handleFileChange } = useFileHandling(); const isUploadDisabled = disabled ?? false;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
if (!supportsFiles[endpointType ?? endpoint ?? ''] || endpointFileConfig?.disabled) {
return null;
}
return ( return (
<div <div
@ -45,8 +28,8 @@ const AttachFile = ({
<FileUpload handleFileChange={handleFileChange} className="flex"> <FileUpload handleFileChange={handleFileChange} className="flex">
<TooltipAnchor <TooltipAnchor
id="audio-recorder" id="audio-recorder"
disabled={isUploadDisabled}
aria-label={localize('com_sidepanel_attach_files')} aria-label={localize('com_sidepanel_attach_files')}
disabled={!!disabled}
className="btn relative text-black focus:outline-none focus:ring-2 focus:ring-border-xheavy focus:ring-opacity-50 dark:text-white" className="btn relative text-black focus:outline-none focus:ring-2 focus:ring-border-xheavy focus:ring-opacity-50 dark:text-white"
style={{ padding: 0 }} style={{ padding: 0 }}
description={localize('com_sidepanel_attach_files')} description={localize('com_sidepanel_attach_files')}

View file

@ -0,0 +1,62 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import AttachFile from './AttachFile';
import FileRow from './FileRow';
import store from '~/store';
function FileFormWrapper({ children, disableInputs } : {
disableInputs: boolean;
children?: React.ReactNode;
}) {
const { handleFileChange, abortUpload } = useFileHandling();
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const {
files,
setFiles,
conversation,
setFilesLoading,
} = useChatContext();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const isRTL = chatDirection === 'rtl';
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as EndpointFileConfig | undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
return (<>
<FileRow
files={files}
setFiles={setFiles}
abortUpload={abortUpload}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => (
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
{children}
</div>
)}
/>
{children}
{endpointSupportsFiles && !isUploadDisabled && <AttachFile
isRTL={isRTL}
disabled={disableInputs}
handleFileChange={handleFileChange}
/>}
</>);
}
export default memo(FileFormWrapper);

View file

@ -4,11 +4,13 @@ import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider'; import { useDeleteFilesMutation } from '~/data-provider';
import { useFileDeletion } from '~/hooks/Files'; import { useFileDeletion } from '~/hooks/Files';
import FileContainer from './FileContainer'; import FileContainer from './FileContainer';
import { logger } from '~/utils';
import Image from './Image'; import Image from './Image';
export default function FileRow({ export default function FileRow({
files: _files, files: _files,
setFiles, setFiles,
abortUpload,
setFilesLoading, setFilesLoading,
assistant_id, assistant_id,
agent_id, agent_id,
@ -18,6 +20,7 @@ export default function FileRow({
Wrapper, Wrapper,
}: { }: {
files: Map<string, ExtendedFile> | undefined; files: Map<string, ExtendedFile> | undefined;
abortUpload?: () => void;
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>; setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>; setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
fileFilter?: (file: ExtendedFile) => boolean; fileFilter?: (file: ExtendedFile) => boolean;
@ -33,7 +36,8 @@ export default function FileRow({
const { mutateAsync } = useDeleteFilesMutation({ const { mutateAsync } = useDeleteFilesMutation({
onMutate: async () => onMutate: async () =>
console.log( logger.log(
'agents',
'Deleting files: agent_id, assistant_id, tool_resource', 'Deleting files: agent_id, assistant_id, tool_resource',
agent_id, agent_id,
assistant_id, assistant_id,
@ -86,7 +90,12 @@ export default function FileRow({
{ map: new Map(), uniqueFiles: [] as ExtendedFile[] }, { map: new Map(), uniqueFiles: [] as ExtendedFile[] },
) )
.uniqueFiles.map((file: ExtendedFile, index: number) => { .uniqueFiles.map((file: ExtendedFile, index: number) => {
const handleDelete = () => deleteFile({ file, setFiles }); const handleDelete = () => {
if (abortUpload && file.progress < 1) {
abortUpload();
}
deleteFile({ file, setFiles });
};
const isImage = file.type?.startsWith('image') ?? false; const isImage = file.type?.startsWith('image') ?? false;
if (isImage) { if (isImage) {
return ( return (

View file

@ -5,6 +5,7 @@ import {
mergeFileConfig, mergeFileConfig,
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile } from '~/common';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
@ -40,9 +41,12 @@ export default function CodeFiles({
} }
}, [_files]); }, [_files]);
const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents]; const endpointFileConfig = fileConfig.endpoints[EModelEndpoint.agents] as
| EndpointFileConfig
| undefined;
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (endpointFileConfig?.disabled) { if (isUploadDisabled) {
return null; return null;
} }

View file

@ -4,7 +4,7 @@ import {
mergeFileConfig, mergeFileConfig,
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider'; import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile } from '~/common';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
@ -43,9 +43,10 @@ export default function CodeFiles({
} }
}, [_files]); }, [_files]);
const endpointFileConfig = fileConfig.endpoints[endpoint]; const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined;
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (endpointFileConfig.disabled) { if (isUploadDisabled) {
return null; return null;
} }

View file

@ -4,7 +4,7 @@ import {
retrievalMimeTypes, retrievalMimeTypes,
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider'; import type { AssistantsEndpoint, EndpointFileConfig } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common'; import type { ExtendedFile } from '~/common';
import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
@ -53,9 +53,10 @@ export default function Knowledge({
} }
}, [_files]); }, [_files]);
const endpointFileConfig = fileConfig.endpoints[endpoint]; const endpointFileConfig = fileConfig.endpoints[endpoint] as EndpointFileConfig | undefined;
const isUploadDisabled = endpointFileConfig?.disabled ?? false;
if (endpointFileConfig?.disabled) { if (isUploadDisabled) {
return null; return null;
} }

View file

@ -1 +1,2 @@
export * from './queries'; export * from './queries';
export * from './mutations';

View file

@ -0,0 +1,159 @@
import { EToolResources } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
export type TGenTitleMutation = UseMutationResult<
t.TGenTitleResponse,
unknown,
t.TGenTitleRequest,
unknown
>;
export const useUploadFileMutation = (
_options?: t.UploadMutationOptions,
signal?: AbortSignal | null,
): UseMutationResult<
t.TFileUpload, // response data
unknown, // error
FormData, // request
unknown // context
> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options || {};
return useMutation([MutationKeys.fileUpload], {
mutationFn: (body: FormData) => {
const width = body.get('width') ?? '';
const height = body.get('height') ?? '';
const version = body.get('version') ?? '';
if (width !== '' && height !== '' && (version !== '' || version.toString() !== '2')) {
return dataService.uploadImage(body, signal);
}
return dataService.uploadFile(body, signal);
},
...options,
onSuccess: (data, formData, context) => {
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (_files) => [
data,
...(_files ?? []),
]);
const endpoint = formData.get('endpoint');
const message_file = formData.get('message_file');
const agent_id = (formData.get('agent_id') as string | undefined) ?? '';
const assistant_id = (formData.get('assistant_id') as string | undefined) ?? '';
const tool_resource = (formData.get('tool_resource') as string | undefined) ?? '';
if (message_file === 'true') {
onSuccess?.(data, formData, context);
return;
}
if (agent_id && tool_resource) {
queryClient.setQueryData<t.Agent>([QueryKeys.agent, agent_id], (agent) => {
if (!agent) {
return agent;
}
const update = {};
const prevResources = agent.tool_resources ?? {};
const prevResource: t.ExecuteCodeResource | t.AgentFileSearchResource = agent
.tool_resources?.[tool_resource] ?? {
file_ids: [],
};
if (!prevResource.file_ids) {
prevResource.file_ids = [];
}
prevResource.file_ids.push(data.file_id);
update['tool_resources'] = {
...prevResources,
[tool_resource]: prevResource,
};
return {
...agent,
...update,
};
});
}
if (!assistant_id) {
onSuccess?.(data, formData, context);
return;
}
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, endpoint, defaultOrderQuery],
(prev) => {
if (!prev) {
return prev;
}
return {
...prev,
data: prev.data.map((assistant) => {
if (assistant.id !== assistant_id) {
return assistant;
}
const update = {};
if (!tool_resource) {
update['file_ids'] = [...(assistant.file_ids ?? []), data.file_id];
}
if (tool_resource === EToolResources.code_interpreter) {
const prevResources = assistant.tool_resources ?? {};
const prevResource = assistant.tool_resources?.[tool_resource] ?? {
file_ids: [],
};
if (!prevResource.file_ids) {
prevResource.file_ids = [];
}
prevResource.file_ids.push(data.file_id);
update['tool_resources'] = {
...prevResources,
[tool_resource]: prevResource,
};
}
return {
...assistant,
...update,
};
}),
};
},
);
onSuccess?.(data, formData, context);
},
});
};
export const useDeleteFilesMutation = (
_options?: t.DeleteMutationOptions,
): UseMutationResult<
t.DeleteFilesResponse, // response data
unknown, // error
t.DeleteFilesBody, // request
unknown // context
> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options || {};
return useMutation([MutationKeys.fileDelete], {
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body),
...options,
onSuccess: (data, ...args) => {
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
const { files: filesDeleted } = args[0];
const fileMap = filesDeleted.reduce((acc, file) => {
acc.set(file.file_id, file);
return acc;
}, new Map<string, t.BatchFile>());
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
});
onSuccess?.(data, ...args);
},
});
};

View file

@ -1,6 +1,5 @@
import { import {
Constants, Constants,
EToolResources,
LocalStorageKeys, LocalStorageKeys,
InfiniteCollections, InfiniteCollections,
defaultAssistantsVersion, defaultAssistantsVersion,
@ -622,151 +621,6 @@ export const useUploadConversationsMutation = (
}); });
}; };
export const useUploadFileMutation = (
_options?: t.UploadMutationOptions,
): UseMutationResult<
t.TFileUpload, // response data
unknown, // error
FormData, // request
unknown // context
> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options || {};
return useMutation([MutationKeys.fileUpload], {
mutationFn: (body: FormData) => {
const width = body.get('width');
const height = body.get('height');
const version = body.get('version') as number | string;
if (height && width && (!version || version != 2)) {
return dataService.uploadImage(body);
}
return dataService.uploadFile(body);
},
...options,
onSuccess: (data, formData, context) => {
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (_files) => [
data,
...(_files ?? []),
]);
const endpoint = formData.get('endpoint');
const message_file = formData.get('message_file');
const agent_id = (formData.get('agent_id') as string | undefined) ?? '';
const assistant_id = (formData.get('assistant_id') as string | undefined) ?? '';
const tool_resource = (formData.get('tool_resource') as string | undefined) ?? '';
if (message_file === 'true') {
onSuccess?.(data, formData, context);
return;
}
if (agent_id && tool_resource) {
queryClient.setQueryData<t.Agent>([QueryKeys.agent, agent_id], (agent) => {
if (!agent) {
return agent;
}
const update = {};
const prevResources = agent.tool_resources ?? {};
const prevResource: t.ExecuteCodeResource | t.AgentFileSearchResource = agent
.tool_resources?.[tool_resource] ?? {
file_ids: [],
};
if (!prevResource.file_ids) {
prevResource.file_ids = [];
}
prevResource.file_ids.push(data.file_id);
update['tool_resources'] = {
...prevResources,
[tool_resource]: prevResource,
};
return {
...agent,
...update,
};
});
}
if (!assistant_id) {
onSuccess?.(data, formData, context);
return;
}
queryClient.setQueryData<t.AssistantListResponse>(
[QueryKeys.assistants, endpoint, defaultOrderQuery],
(prev) => {
if (!prev) {
return prev;
}
return {
...prev,
data: prev.data.map((assistant) => {
if (assistant.id !== assistant_id) {
return assistant;
}
const update = {};
if (!tool_resource) {
update['file_ids'] = [...(assistant.file_ids ?? []), data.file_id];
}
if (tool_resource === EToolResources.code_interpreter) {
const prevResources = assistant.tool_resources ?? {};
const prevResource = assistant.tool_resources?.[tool_resource] ?? {
file_ids: [],
};
if (!prevResource.file_ids) {
prevResource.file_ids = [];
}
prevResource.file_ids.push(data.file_id);
update['tool_resources'] = {
...prevResources,
[tool_resource]: prevResource,
};
}
return {
...assistant,
...update,
};
}),
};
},
);
onSuccess?.(data, formData, context);
},
});
};
export const useDeleteFilesMutation = (
_options?: t.DeleteMutationOptions,
): UseMutationResult<
t.DeleteFilesResponse, // response data
unknown, // error
t.DeleteFilesBody, // request
unknown // context
> => {
const queryClient = useQueryClient();
const { onSuccess, ...options } = _options || {};
return useMutation([MutationKeys.fileDelete], {
mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body),
...options,
onSuccess: (data, ...args) => {
queryClient.setQueryData<t.TFile[] | undefined>([QueryKeys.files], (cachefiles) => {
const { files: filesDeleted } = args[0];
const fileMap = filesDeleted.reduce((acc, file) => {
acc.set(file.file_id, file);
return acc;
}, new Map<string, t.BatchFile>());
return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id));
});
onSuccess?.(data, ...args);
},
});
};
export const useUpdatePresetMutation = ( export const useUpdatePresetMutation = (
options?: t.UpdatePresetOptions, options?: t.UpdatePresetOptions,
): UseMutationResult< ): UseMutationResult<

View file

@ -1,7 +1,7 @@
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
megabyte, megabyte,
QueryKeys, QueryKeys,
@ -18,7 +18,9 @@ import { useUploadFileMutation, useGetFileConfig } from '~/data-provider';
import { useDelayedUploadToast } from './useDelayedUploadToast'; import { useDelayedUploadToast } from './useDelayedUploadToast';
import { useToastContext } from '~/Providers/ToastContext'; import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext'; import { useChatContext } from '~/Providers/ChatContext';
import useLocalize from '~/hooks/useLocalize';
import useUpdateFiles from './useUpdateFiles'; import useUpdateFiles from './useUpdateFiles';
import { logger } from '~/utils';
const { checkType } = defaultFileConfig; const { checkType } = defaultFileConfig;
@ -30,9 +32,11 @@ type UseFileHandling = {
}; };
const useFileHandling = (params?: UseFileHandling) => { const useFileHandling = (params?: UseFileHandling) => {
const localize = useLocalize();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [errors, setErrors] = useState<string[]>([]); const [errors, setErrors] = useState<string[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast(); const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast();
const { files, setFiles, setFilesLoading, conversation } = useChatContext(); const { files, setFiles, setFilesLoading, conversation } = useChatContext();
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
@ -55,7 +59,7 @@ const useFileHandling = (params?: UseFileHandling) => {
const displayToast = useCallback(() => { const displayToast = useCallback(() => {
if (errors.length > 1) { if (errors.length > 1) {
const errorList = Array.from(new Set(errors)) const errorList = Array.from(new Set(errors))
.map((e, i) => `${i > 0 ? '• ' : ''}${e}\n`) .map((e, i) => `${i > 0 ? '• ' : ''}${localize(e) || e}\n`)
.join(''); .join('');
showToast({ showToast({
message: errorList, message: errorList,
@ -63,15 +67,16 @@ const useFileHandling = (params?: UseFileHandling) => {
duration: 5000, duration: 5000,
}); });
} else if (errors.length === 1) { } else if (errors.length === 1) {
const message = localize(errors[0]) || errors[0];
showToast({ showToast({
message: errors[0], message,
status: 'error', status: 'error',
duration: 5000, duration: 5000,
}); });
} }
setErrors([]); setErrors([]);
}, [errors, showToast]); }, [errors, showToast, localize]);
const debouncedDisplayToast = debounce(displayToast, 250); const debouncedDisplayToast = debounce(displayToast, 250);
@ -83,7 +88,8 @@ const useFileHandling = (params?: UseFileHandling) => {
return () => debouncedDisplayToast.cancel(); return () => debouncedDisplayToast.cancel();
}, [errors, debouncedDisplayToast]); }, [errors, debouncedDisplayToast]);
const uploadFile = useUploadFileMutation({ const uploadFile = useUploadFileMutation(
{
onSuccess: (data) => { onSuccess: (data) => {
clearUploadTimer(data.temp_file_id); clearUploadTimer(data.temp_file_id);
console.log('upload success', data); console.log('upload success', data);
@ -119,17 +125,21 @@ const useFileHandling = (params?: UseFileHandling) => {
); );
}, 300); }, 300);
}, },
onError: (error, body) => { onError: (_error, body) => {
const error = _error as TError | undefined;
console.log('upload error', error); console.log('upload error', error);
const file_id = body.get('file_id'); const file_id = body.get('file_id');
clearUploadTimer(file_id as string); clearUploadTimer(file_id as string);
deleteFileById(file_id as string); deleteFileById(file_id as string);
setError( const errorMessage =
(error as TError | undefined)?.response?.data?.message ?? error?.code === 'ERR_CANCELED'
'An error occurred while uploading the file.', ? 'com_error_files_upload_canceled'
); : error?.response?.data?.message ?? 'com_error_files_upload';
setError(errorMessage);
}, },
}); },
abortControllerRef.current?.signal,
);
const startUpload = async (extendedFile: ExtendedFile) => { const startUpload = async (extendedFile: ExtendedFile) => {
const filename = extendedFile.file?.name ?? 'File'; const filename = extendedFile.file?.name ?? 'File';
@ -148,33 +158,47 @@ const useFileHandling = (params?: UseFileHandling) => {
formData.append('height', height.toString()); formData.append('height', height.toString());
} }
const metadata = params?.additionalMetadata ?? {};
if (params?.additionalMetadata) { if (params?.additionalMetadata) {
for (const [key, value = ''] of Object.entries(params.additionalMetadata)) { for (const [key, value = ''] of Object.entries(metadata)) {
if (value) { if (value) {
formData.append(key, value); formData.append(key, value);
} }
} }
} }
const convoAssistantId = conversation?.assistant_id ?? ''; formData.append('endpoint', endpoint);
const convoModel = conversation?.model ?? '';
if (isAssistantsEndpoint(endpoint) && !formData.get('assistant_id') && convoAssistantId) { if (!isAssistantsEndpoint(endpoint)) {
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]); uploadFile.mutate(formData);
const version = endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]; return;
formData.append('version', version);
formData.append('assistant_id', convoAssistantId);
formData.append('model', convoModel);
formData.append('message_file', 'true');
} }
if (isAssistantsEndpoint(endpoint) && !formData.get('version')) {
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]); const convoModel = conversation?.model ?? '';
const version = endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]; const convoAssistantId = conversation?.assistant_id ?? '';
formData.append('version', version);
formData.append('model', conversation?.model ?? ''); if (!assistant_id) {
formData.append('message_file', 'true'); formData.append('message_file', 'true');
} }
formData.append('endpoint', endpoint); 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); uploadFile.mutate(formData);
}; };
@ -183,7 +207,7 @@ const useFileHandling = (params?: UseFileHandling) => {
const existingFiles = Array.from(files.values()); const existingFiles = Array.from(files.values());
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0); const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
if (incomingTotalSize === 0) { if (incomingTotalSize === 0) {
setError('Empty files are not allowed.'); setError('com_error_files_empty');
return false; return false;
} }
const currentTotalSize = existingFiles.reduce((total, file) => total + file.size, 0); const currentTotalSize = existingFiles.reduce((total, file) => total + file.size, 0);
@ -248,7 +272,7 @@ const useFileHandling = (params?: UseFileHandling) => {
const uniqueFilesSet = new Set(combinedFilesInfo); const uniqueFilesSet = new Set(combinedFilesInfo);
if (uniqueFilesSet.size !== combinedFilesInfo.length) { if (uniqueFilesSet.size !== combinedFilesInfo.length) {
setError('Duplicate file detected.'); setError('com_error_files_dupe');
return false; return false;
} }
@ -273,6 +297,7 @@ const useFileHandling = (params?: UseFileHandling) => {
}; };
const handleFiles = async (_files: FileList | File[]) => { const handleFiles = async (_files: FileList | File[]) => {
abortControllerRef.current = new AbortController();
const fileList = Array.from(_files); const fileList = Array.from(_files);
/* Validate files */ /* Validate files */
let filesAreValid: boolean; let filesAreValid: boolean;
@ -280,7 +305,7 @@ const useFileHandling = (params?: UseFileHandling) => {
filesAreValid = validateFiles(fileList); filesAreValid = validateFiles(fileList);
} catch (error) { } catch (error) {
console.error('file validation error', error); console.error('file validation error', error);
setError('An error occurred while validating the file.'); setError('com_error_files_validation');
return; return;
} }
if (!filesAreValid) { if (!filesAreValid) {
@ -313,7 +338,7 @@ const useFileHandling = (params?: UseFileHandling) => {
} catch (error) { } catch (error) {
deleteFileById(file_id); deleteFileById(file_id);
console.log('file handling error', error); console.log('file handling error', error);
setError('An error occurred while processing the file.'); setError('com_error_files_process');
} }
} }
}; };
@ -328,11 +353,20 @@ const useFileHandling = (params?: UseFileHandling) => {
} }
}; };
const abortUpload = () => {
if (abortControllerRef.current) {
logger.log('files', 'Aborting upload');
abortControllerRef.current.abort('User aborted upload');
abortControllerRef.current = null;
}
};
return { return {
handleFileChange, handleFileChange,
handleFiles, handleFiles,
files, abortUpload,
setFiles, setFiles,
files,
}; };
}; };

View file

@ -83,7 +83,7 @@ export const getTranslations = (langCode: string): Language => {
// input: language code in string & phrase key in string // input: language code in string & phrase key in string
// returns an corresponding phrase value in string // returns an corresponding phrase value in string
export const localize = (langCode: string, phraseKey: string, ...values: string[]) => { export const localize = (langCode: string, phraseKey: string, ...values: string[]): string => {
const lang = getTranslations(langCode); const lang = getTranslations(langCode);
const phrase = lang[phraseKey] || English[phraseKey] || ''; const phrase = lang[phraseKey] || English[phraseKey] || '';

View file

@ -33,6 +33,13 @@ export default {
'Provided key for {0} expired at {1}. Please provide a new key and try again.', 'Provided key for {0} expired at {1}. Please provide a new key and try again.',
com_error_input_length: com_error_input_length:
'The latest message token count is too long, exceeding the token limit ({0} respectively). Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.', 'The latest message token count is too long, exceeding the token limit ({0} respectively). Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.',
com_error_files_empty: 'Empty files are not allowed.',
com_error_files_dupe: 'Duplicate file detected.',
com_error_files_validation: 'An error occurred while validating the file.',
com_error_files_process: 'An error occurred while processing the file.',
com_error_files_upload: 'An error occurred while uploading the file.',
com_error_files_upload_canceled:
'The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.',
com_files_no_results: 'No results.', com_files_no_results: 'No results.',
com_files_filter: 'Filter files...', com_files_filter: 'Filter files...',
com_generated_files: 'Generated files:', com_generated_files: 'Generated files:',

2
package-lock.json generated
View file

@ -36522,7 +36522,7 @@
}, },
"packages/data-provider": { "packages/data-provider": {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.7.427", "version": "0.7.428",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",

View file

@ -1,6 +1,6 @@
{ {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.7.427", "version": "0.7.428",
"description": "data services for librechat apps", "description": "data services for librechat apps",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.es.js", "module": "dist/index.es.js",

View file

@ -44,8 +44,8 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
export const listSharedLinks = ( export const listSharedLinks = (
params?: q.SharedLinkListParams, params?: q.SharedLinkListParams,
): Promise<q.SharedLinksResponse> => { ): Promise<q.SharedLinksResponse> => {
const pageNumber = params?.pageNumber || '1'; // Default to page 1 if not provided const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
const isPublic = params?.isPublic || true; // Default to true if not provided const isPublic = params?.isPublic ?? true; // Default to true if not provided
return request.get(endpoints.getSharedLinks(pageNumber, isPublic)); return request.get(endpoints.getSharedLinks(pageNumber, isPublic));
}; };
@ -314,12 +314,14 @@ export const getFileConfig = (): Promise<f.FileConfig> => {
return request.get(`${endpoints.files()}/config`); return request.get(`${endpoints.files()}/config`);
}; };
export const uploadImage = (data: FormData): Promise<f.TFileUpload> => { export const uploadImage = (data: FormData, signal?: AbortSignal | null): Promise<f.TFileUpload> => {
return request.postMultiPart(endpoints.images(), data); const requestConfig = signal ? { signal } : undefined;
return request.postMultiPart(endpoints.images(), data, requestConfig);
}; };
export const uploadFile = (data: FormData): Promise<f.TFileUpload> => { export const uploadFile = (data: FormData, signal?: AbortSignal | null): Promise<f.TFileUpload> => {
return request.postMultiPart(endpoints.files(), data); const requestConfig = signal ? { signal } : undefined;
return request.postMultiPart(endpoints.files(), data, requestConfig);
}; };
/* actions */ /* actions */
@ -538,8 +540,8 @@ export const listConversations = (
params?: q.ConversationListParams, params?: q.ConversationListParams,
): Promise<q.ConversationListResponse> => { ): Promise<q.ConversationListResponse> => {
// Assuming params has a pageNumber property // Assuming params has a pageNumber property
const pageNumber = params?.pageNumber || '1'; // Default to page 1 if not provided const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
const isArchived = params?.isArchived || false; // Default to false if not provided const isArchived = params?.isArchived ?? false; // Default to false if not provided
const tags = params?.tags || []; // Default to an empty array if not provided const tags = params?.tags || []; // Default to an empty array if not provided
return request.get(endpoints.conversations(pageNumber, isArchived, tags)); return request.get(endpoints.conversations(pageNumber, isArchived, tags));
}; };
@ -547,8 +549,8 @@ export const listConversations = (
export const listConversationsByQuery = ( export const listConversationsByQuery = (
params?: q.ConversationListParams & { searchQuery?: string }, params?: q.ConversationListParams & { searchQuery?: string },
): Promise<q.ConversationListResponse> => { ): Promise<q.ConversationListResponse> => {
const pageNumber = params?.pageNumber || '1'; // Default to page 1 if not provided const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
const searchQuery = params?.searchQuery || ''; // If no search query is provided, default to an empty string const searchQuery = params?.searchQuery ?? ''; // If no search query is provided, default to an empty string
// Update the endpoint to handle a search query // Update the endpoint to handle a search query
if (searchQuery !== '') { if (searchQuery !== '') {
return request.get(endpoints.search(searchQuery, pageNumber)); return request.get(endpoints.search(searchQuery, pageNumber));

View file

@ -80,6 +80,9 @@ axios.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
if (!error.response) {
return Promise.reject(error);
}
if (error.response.status === 401 && !originalRequest._retry) { if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;

View file

@ -30,8 +30,9 @@ export enum EModelEndpoint {
export const paramEndpoints = new Set<EModelEndpoint | string>([ export const paramEndpoints = new Set<EModelEndpoint | string>([
EModelEndpoint.agents, EModelEndpoint.agents,
EModelEndpoint.bedrock,
EModelEndpoint.openAI, EModelEndpoint.openAI,
EModelEndpoint.bedrock,
EModelEndpoint.azureOpenAI,
EModelEndpoint.anthropic, EModelEndpoint.anthropic,
EModelEndpoint.custom, EModelEndpoint.custom,
]); ]);

View file

@ -93,7 +93,7 @@ export type TCategory = {
export type TError = { export type TError = {
message: string; message: string;
code?: number; code?: number | string;
response?: { response?: {
data?: { data?: {
message?: string; message?: string;