mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
✨ feat: Assistants API, General File Support, Side Panel, File Explorer (#1696)
* feat: assistant name/icon in Landing & Header * feat: assistname in textarea placeholder, and use `Assistant` as default name * feat: display non-image files in user messages * fix: only render files if files.length is > 0 * refactor(config -> file-config): move file related configuration values to separate module, add excel types * chore: spreadsheet file rendering * fix(Landing): dark mode style for Assistant Name * refactor: move progress incrementing to own hook, start smaller, cap near limit \(1\) * refactor(useContentHandler): add empty Text part if last part was completed tool or image * chore: add accordion trigger border styling for dark mode * feat: Assistant Builder model selection * chore: use Spinner when Assistant is mutating * fix(get/assistants): return correct response object `AssistantListResponse` * refactor(Spinner): pass size as prop * refactor: make assistant crud mutations optimistic, add types for options * chore: remove assistants route and view * chore: move assistant builder components to separate directory * feat(ContextButton): delete Assistant via context button/dialog, add localization * refactor: conditionally show use and context menu buttons, add localization for create assistant * feat: save side panel states to localStorage * style(SidePanel): improve avatar menu and assistant select styling for dark mode * refactor: make NavToggle reusable for either side (left or right), add SidePanel Toggle with ability to close it completely * fix: resize handle and navToggle behavior * fix(/avatar/:assistant_id): await `deleteFile` and assign unique name to uploaded image * WIP: file UI components from PR #576 * refactor(OpenAIMinimalIcon): pass className * feat: formatDate helper fn * feat: DataTableColumnHeader * feat: add row selection, formatted row values, number of rows selected * WIP: add files to Side panel temporarily * feat: `LB_QueueAsyncCall`: Leaky Bucket queue for external APIs, use in `processDeleteRequest` * fix(TFile): correct `source` type with `FileSources` * fix(useFileHandling): use `continue` instead of return when iterating multiple files, add file type to extendedFile * chore: add generic setter type * refactor(processDeleteRequest): settle promises to prevent rejections from processing deletions, log errors * feat: `useFileDeletion` to reuse file deletion logic * refactor(useFileDeletion): make `setFiles` an optional param and use object as param * feat: useDeleteFilesFromTable * feat: use real `files` data and add deletion action to data table * fix(Table): make headers sticky * feat: add dynamic filtering for columns; only show to user Host or OpenAI storage type * style(DropdownMenu): replace `slate` with `gray` * style(DataTable): apply dark mode themes and other misc styling * style(Columns): add color to OpenAI Storage option * refactor(FileContainer): make file preview reusable * refactor(Images): make image preview reusable * refactor(FilePreview): make file prop optional for FileIcon and FilePreview, fix relative style * feat(Columns): add file/image previews, set a minimum size to show for file size in bytes * WIP: File Panel with real files and formatted * feat: open files dialog from panel * style: file data table mobile and general column styling fixes * refactor(api/files): return files sorted by the most recently updated * refactor: provide fileMap through context to prevent re-selecting files to map in different areas; remove unused imports commented out in PanelColumns * refactor(ExtendFile): make File type optional, add `attached` to prevent attached files from being deleted on remove, make Message.files a partial TFile type * feat: attach files through file panel * refactor(useFileHandling): move files to the start of cache list when uploaded * refactor(useDeleteFilesMutation): delete files from cache when successfully deleted from server * fix(FileRow): handle possible edge case of duplication due to attaching recently uploaded file * style(SidePanel): make resize grip border transparent, remove unnecessary styling on close sidepanel button * feat: action utilities and tests * refactor(actions): add `ValidationResult` type and change wording for no server URL found * refactor(actions): check for empty server URL * fix(data-provider): revert tsconfig to fix type issue resolution * feat(client): first pass of actions input for assistants * refactor(FunctionSignature): change method to output object instead of string * refactor(models/Assistant): add actions field to schema, use searchParams object for methods, and add `getAssistant` * feat: post actions input first pass - create new Action document - add actions to Assistant DB document - create /action/:assistant_id POST route - pass more props down from PanelSwitcher, derive assistant_id from switcher - move privacy policy to ActionInput - reset data on input change/validation - add `useUpdateAction` - conform FunctionSignature type to FunctionTool - add action, assistant doc, update hook related types * refactor: optimize assistant/actions relationship - past domain in metadata as hostname and not a URL - include domain in tool name - add `getActions` for actions retrieval by user - add `getAssistants` for assistant docs retrieval by user - add `assistant_id` to Action schema - move actions to own module as a subroute to `api/assistants` - add `useGetActionsQuery` and `useGetAssistantDocsQuery` hooks - fix Action type def * feat: show assistant actions in assistant builder * feat: switch to actions on action click, editing action styling * fix: add Assistant state for builder panel to allow immediate selection of newly created assistants as well as retaining the current assistant when switching to a different panel within the builder * refactor(SidePanel/NavToggle): offset less from right when SidePanel is completely collapsed * chore: rename `processActions` -> `processRequiredActions` * chore: rename Assistant API Action to RequiredAction * refactor(actions): avoid nesting actual API params under generic `requestBody` to optimize LLM token usage * fix(handleTools): avoid calling `validTool` if not defined, add optional param to skip the loading of specs, which throws an error in the context of assistants * WIP: working first pass of toolCalls generated from openapi specs * WIP: first pass ToolCall styling * feat: programmatic iv encryption/decryption helpers * fix: correct ActionAuth types/enums, and define type for AuthForm * feat: encryption/decryption helpers for Action AuthMetadata * refactor(getActions): remove sensitive fields from query response * refactor(POST/actions): encrypt and remove sensitive fields from mutation response * fix(ActionService): change ESM import to CJS * feat: frontend auth handling for actions + optimistic update on action update/creation * refactor(actions): use the correct variables and types for setAuth method * refactor: POST /:assistant_id action can now handle updating an existing action, add `saved_auth_fields` to determine when user explicitly saves new auth creds. only send auth metadata if user explicitly saved fields * refactor(createActionTool): catch errors and send back meaningful error message, add flag to `getActions` to determine whether to retrieve sensitive values or not * refactor(ToolService): add `action` property to ToolCall PartMetadata to determine if the tool call was an action, fix parsing function name issue with actionDelimiter * fix(ActionRequest): use URL class to correctly join endpoint parts for `execute` call * feat: delete assistant actions * refactor: conditionally show Available actions * refactor: show `retrieval` and `code_interpreter` as Capabilities, swap `Switch` for `Checkbox` * chore: remove shadow-stroke from messages * WIP: first pass of Assistants Knowledge attachments * refactor: remove AssistantsProvider in favor of FormProvider, fix selectedAssistant re-render bug, map Assistant file_ids to files via fileMap, initialize Knowledge component with mapped files if any exist * fix: prevent deleting files on assistant file upload * chore: remove console.log * refactor(useUploadFileMutation): update files and assistants cache on upload * chore: disable oauth option as not supported yet * feat: cancel assistant runs * refactor: initialize OpenAI client with helper function, resolve all related circular dependencies * fix(DALL-E): initialization * fix(process): openai client initialization * fix: select an existing Assistant when the active one is deleted * chore: allow attaching files for assistant endpoint, send back relevant OpenAI error message when uploading, deconstruct openAI initialization correctly, add `message_file` to formData when a file is attached to the message but not the assistant * fix: add assistant_id on newConvo * fix(initializeClient): import fix * chore: swap setAssistant for setOption in useEffect * fix(DALL-E): add processFileURL to loadTools call * chore: add customConfig to debug logs * feat: delete threads on convo delete * chore: replace Assistants icon * chore: remove console.dir() in `abortRun` * feat(AssistantService): accumulate text values from run in openai.responseText * feat: titling for assistants endpoint * chore: move panel file components to appropriate directory, add file checks for attaching files, change icon for Attach Files * refactor: add localizations to tools, plugins, add condition for adding/remove user plugins so tool selections don't affect this value * chore: disable `import from url` action for now * chore: remove textMimeTypes from default fileConfig for now * fix: catch tool errors and send as outputs with error messages * fix: React warning about button as descendant of button * style: retrieval and cancelled icon * WIP: pass isSubmitting to Parts, use InProgressCall to display cancelled tool calls correctly, show domain/function name * fix(meilisearch): fix `postSaveHook` issue where indexing expects a mongo document, and join all text content parts for meili indexing * ci: fix dall-e tests * ci: fix client tests * fix: button types in actions panel * fix: plugin auth form persisting across tool selections * fix(ci): update AppService spec with `loadAndFormatTools` * fix(clearConvos): add id check earlier on * refactor(AssistantAvatar): set previewURL dynamically when emtadata.avatar changes * feat(assistants): addTitle cache setting * fix(useSSE): resolve rebase conflicts * fix: delete mutation * style(SidePanel): make grip visible on active and hover, invisible otherwise * ci: add data-provider tests to workflow, also update eslint/tsconfig to recognize specs, and add `text/csv` to fileConfig * fix: handle edge case where auth object is undefined, and log errors * refactor(actions): resolve schemas, add tests for resolving refs, import specs from separate file for tests * chore: remove comment * fix(ActionsInput): re-render bug when initializing states with action fields * fix(patch/assistant): filter undefined tools * chore: add logging for errors in assistants routes * fix(updateAssistant): map actions to functions to avoid overwriting * fix(actions): properly handle GET paths * fix(convos): unhandled delete thread exception * refactor(AssistantService): pass both thread_id and conversationId when sending intermediate assistant messages, remove `mapMessagesToSteps` from AssistantService * refactor(useSSE): replace all messages with runMessages and pass latestMessageId to abortRun; fix(checkMessageGaps): include tool calls when syncing messages * refactor(assistants/chat): invoke `createOnTextProgress` after thread creation * chore: add typing * style: sidepanel styling * style: action tool call domain styling * feat(assistants): default models, limit retrieval to certain models, add env variables to to env.example * feat: assistants api key in EndpointService * refactor: set assistant model to conversation on assistant switch * refactor: set assistant model to conversation on assistant select from panel * fix(retrieveAndProcessFile): catch attempt to download file with `assistant` purpose which is not allowed; add logging * feat: retrieval styling, handling, and logging * chore: rename ASSISTANTS_REVERSE_PROXY to ASSISTANTS_BASE_URL * feat: FileContext for file metadata * feat: context file mgmt and filtering * style(Select): hover/rounded changes * refactor: explicit conversation switch, endpoint dependent, through `useSelectAssistant`, which does not create new chat if current endpoint is assistant endpoint * fix(AssistantAvatar): make empty previewURL if no avatar present * refactor: side panel mobile styling * style: merge tool and action section, optimize mobile styling for action/tool buttons * fix: localStorage issues * fix(useSelectAssistant): invoke react query hook directly in select hook as Map was not being updated in time * style: light mode fixes * fix: prevent sidepanel nav styling from shifting layout up * refactor: change default layout (collapsed by default) * style: mobile optimization of DataTable * style: datatable * feat: client-side hide right-side panel * chore(useNewConvo): add partial typing for preset * fix(useSelectAssistant): pass correct model name by using template as preset * WIP: assistant presets * refactor(ToolService): add native solution for `TavilySearchResults` and log tool output errors * refactor: organize imports and use native TavilySearchResults * fix(TavilySearchResults): stringify result * fix(ToolCall): show tool call outputs when not an action * chore: rename Prompt Prefix to custom instructions (in user facing text only) * refactor(EditPresetDialog): Optimize setting title by debouncing, reset preset on dialog close to avoid state mixture * feat: add `presetOverride` to overwrite active conversation settings when saving a Preset (relevant for client side updates only) * feat: Assistant preset settings (client-side) * fix(Switcher): only set assistant_id and model if current endpoint is Assistants * feat: use `useDebouncedInput` for updating conversation settings, starting with EditPresetDialog title setting and Assistant instructions setting * feat(Assistants): add instructions field to settings * feat(chat/assistants): pass conversation settings to run body * wip: begin localization and only allow actions if the assistant is created * refactor(AssistantsPanel): knowledge localization, allow tools on creation * feat: experimental: allow 'priming' values before assistant is created, that would normally require an assistant_id to be defined * chore: trim console logs and make more meaningful * chore: toast messages * fix(ci): date test * feat: create file when uploading Assistant Avatar * feat: file upload rate limiting from custom config with dynamic file route initialization * refactor: use file upload limiters on post routes only * refactor(fileConfig): add endpoints field for endpoint specific fileconfigs, add mergeConfig function, add tests * refactor: fileConfig route, dynamic multer instances used on all '/' and '/images' POST routes, data service and query hook * feat: supportedMimeTypesSchema, test for array of regex * feat: configurable file config limits * chore: clarify assistants file knowledge prereq. * chore(useTextarea): default to localized 'Assistant' if assistant name is empty * feat: configurable file limits and toggle file upload per endpoint * fix(useUploadFileMutation): prevent updating assistant.files cache if file upload is a message_file attachment * fix(AssistantSelect): set last selected assistant only when timeout successfully runs * refactor(queries): disable assistant queries if assistants endpoint is not enabled * chore(Switcher): add localization * chore: pluralize `assistant` for `EModelEndpoint key and value * feat: show/hide assistant UI components based on endpoint availability; librechat.yaml config for disabling builder section and setting polling/timeout intervals * fix(compactEndpointSchemas): use EModelEndpoint for schema access * feat(runAssistant): use configured values from `librechat.yaml` for `pollIntervalMs` and `timeout` * fix: naming issue * wip: revert landing * 🎉 happy birthday LibreChat (#1768) * happy birthday LibreChat * Refactor endpoint condition in Landing component * Update birthday message in Eng.tsx * fix(/config): avoid nesting ternaries * refactor(/config): check birthday --------- Co-authored-by: Danny Avila <messagedaniel@protonmail.com> * fix: landing * fix: landing * fix(useMessageHelpers): hardcoded check to use EModelEndpoint instead * fix(ci): convo test revert to main * fix(assistants/chat): fix issue where assistant_id was being saved as model for convo * chore: added logging, promises racing to prevent longer timeouts, explicit setting of maxRetries and timeouts, robust catching of invalid abortRun params * refactor: use recoil state for `showStopButton` and only show for assistants endpoint after syncing conversation data * refactor: optimize abortRun strategy using localStorage, refactor `abortConversation` to use async/await and await the result, refactor how the abortKey cache is set for runs * fix(checkMessageGaps): assign `assistant_id` to synced messages if defined; prevents UI from showing blank assistant for cancelled messages * refactor: re-order sequence of chat route, only allow aborting messages after run is created, cancel abortRun if there was a cancelling error (likely due already cancelled in chat route), and add extra logging * chore(typedefs): add httpAgent type to OpenAIClient * refactor: use custom implementation of retrieving run with axios to allow for timing out run query * fix(waitForRun): handle timed out run retrieval query * refactor: update preset conditions: - presets will retain settings when a different endpoint is selected; for existing convos, either when modular or is assistant switch - no longer use `navigateToConvo` on preset select * fix: temporary calculator hack as expects string input when invoked * fix: cancel abortRun only when cancelling error is a result of the run already being cancelled * chore: remove use of `fileMaxSizeMB` and total counterpart (redundant) * docs: custom config documentation update * docs: assistants api setup and dotenv, new custom config fields * refactor(Switcher): make Assistant switcher sticky in SidePanel * chore(useSSE): remove console log of data and message index * refactor(AssistantPanel): button styling and add secondary select button to bottom of panel * refactor(OpenAIClient): allow passing conversationId to RunManager through titleConvo and initializeLLM to properly record title context tokens used in cases where conversationId was not defined by the client * feat(assistants): token tracking for assistant runs * chore(spendTokens): improve logging * feat: support/exclude specific assistant Ids * chore: add update `librechat.example.yaml`, optimize `AppService` handling, new tests for `AppService`, optimize missing/outdate config logging * chore: mount docker logs to root of project * chore: condense axios errors * chore: bump vite * chore: vite hot reload fix using latest version * chore(getOpenAIModels): sort instruct models to the end of models list * fix(assistants): user provided key * fix(assistants): user provided key, invalidate more queries on revoke --------- Co-authored-by: Marco Beretta <81851188+Berry-13@users.noreply.github.com>
This commit is contained in:
parent
cd2786441a
commit
ecd63eb9f1
316 changed files with 21873 additions and 6315 deletions
2
client/src/hooks/Assistants/index.ts
Normal file
2
client/src/hooks/Assistants/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as useAssistantsMap } from './useAssistantsMap';
|
||||
export { default as useSelectAssistant } from './useSelectAssistant';
|
||||
12
client/src/hooks/Assistants/useAssistantsMap.ts
Normal file
12
client/src/hooks/Assistants/useAssistantsMap.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defaultOrderQuery } from 'librechat-data-provider';
|
||||
import { useListAssistantsQuery } from '~/data-provider';
|
||||
import { mapAssistants } from '~/utils';
|
||||
|
||||
export default function useAssistantsMap({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { data: assistantMap = {} } = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) => mapAssistants(res.data),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
return assistantMap;
|
||||
}
|
||||
51
client/src/hooks/Assistants/useSelectAssistant.ts
Normal file
51
client/src/hooks/Assistants/useSelectAssistant.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useCallback } from 'react';
|
||||
import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
|
||||
import type { TConversation, TPreset } from 'librechat-data-provider';
|
||||
import { useListAssistantsQuery } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import useDefaultConvo from '~/hooks/useDefaultConvo';
|
||||
import { mapAssistants } from '~/utils';
|
||||
|
||||
export default function useSelectAssistant() {
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const { data: assistantMap = {} } = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) => mapAssistants(res.data),
|
||||
});
|
||||
|
||||
const onSelect = useCallback(
|
||||
(value: string) => {
|
||||
const assistant = assistantMap?.[value];
|
||||
if (!assistant) {
|
||||
return;
|
||||
}
|
||||
const template: Partial<TPreset | TConversation> = {
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
assistant_id: assistant.id,
|
||||
model: assistant.model,
|
||||
conversationId: 'new',
|
||||
};
|
||||
|
||||
if (conversation?.endpoint === EModelEndpoint.assistants) {
|
||||
const currentConvo = getDefaultConversation({
|
||||
conversation: { ...(conversation ?? {}) },
|
||||
preset: template,
|
||||
});
|
||||
newConversation({
|
||||
template: currentConvo,
|
||||
preset: template as Partial<TPreset>,
|
||||
keepLatestMessage: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
newConversation({
|
||||
template: { ...(template as Partial<TConversation>) },
|
||||
preset: template as Partial<TPreset>,
|
||||
});
|
||||
},
|
||||
[assistantMap, conversation, getDefaultConversation, newConversation],
|
||||
);
|
||||
|
||||
return { onSelect };
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as usePresets } from './usePresets';
|
||||
export { default as useGetSender } from './useGetSender';
|
||||
export { default as useDebouncedInput } from './useDebouncedInput';
|
||||
|
|
|
|||
35
client/src/hooks/Conversations/useDebouncedInput.ts
Normal file
35
client/src/hooks/Conversations/useDebouncedInput.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { TSetOption } from '~/common';
|
||||
|
||||
/** A custom hook that accepts a setOption function and an option key (e.g., 'title').
|
||||
It manages a local state for the option value, a debounced setter function for that value,
|
||||
and returns the local state value, its setter, and an onChange handler suitable for inputs. */
|
||||
function useDebouncedInput(
|
||||
setOption: TSetOption,
|
||||
optionKey: string | number,
|
||||
initialValue: unknown,
|
||||
delay = 450,
|
||||
): [
|
||||
React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
|
||||
unknown,
|
||||
React.Dispatch<React.SetStateAction<unknown>>,
|
||||
] {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
/** A debounced function to call the passed setOption with the optionKey and new value.
|
||||
*
|
||||
Note: We use useCallback to ensure our debounced function is stable across renders. */
|
||||
const setDebouncedOption = useCallback(debounce(setOption(optionKey), delay), []);
|
||||
|
||||
/** An onChange handler that updates the local state and the debounced option */
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const newValue = e.target.value;
|
||||
setValue(newValue);
|
||||
setDebouncedOption(newValue);
|
||||
};
|
||||
|
||||
return [onChange, value, setValue];
|
||||
}
|
||||
|
||||
export default useDebouncedInput;
|
||||
|
|
@ -2,7 +2,7 @@ import filenamify from 'filenamify';
|
|||
import exportFromJSON from 'export-from-json';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, modularEndpoints } from 'librechat-data-provider';
|
||||
import { QueryKeys, modularEndpoints, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useCreatePresetMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TPreset, TEndpointsConfig } from 'librechat-data-provider';
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
useGetPresetsQuery,
|
||||
} from '~/data-provider';
|
||||
import { useChatContext, useToastContext } from '~/Providers';
|
||||
import useNavigateToConvo from '~/hooks/useNavigateToConvo';
|
||||
import { cleanupPreset, getEndpointField } from '~/utils';
|
||||
import useDefaultConvo from '~/hooks/useDefaultConvo';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
|
|
@ -114,7 +113,7 @@ export default function usePresets() {
|
|||
});
|
||||
},
|
||||
});
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
|
||||
const { endpoint } = conversation ?? {};
|
||||
|
|
@ -164,12 +163,19 @@ export default function usePresets() {
|
|||
|
||||
const currentEndpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const endpointType = getEndpointField(endpointsConfig, newPreset.endpoint, 'type');
|
||||
const isAssistantSwitch =
|
||||
newPreset.endpoint === EModelEndpoint.assistants &&
|
||||
conversation?.endpoint === EModelEndpoint.assistants &&
|
||||
conversation?.endpoint === newPreset.endpoint;
|
||||
|
||||
if (
|
||||
(modularEndpoints.has(endpoint ?? '') || modularEndpoints.has(currentEndpointType ?? '')) &&
|
||||
(modularEndpoints.has(endpoint ?? '') ||
|
||||
modularEndpoints.has(currentEndpointType ?? '') ||
|
||||
isAssistantSwitch) &&
|
||||
(modularEndpoints.has(newPreset?.endpoint ?? '') ||
|
||||
modularEndpoints.has(endpointType ?? '')) &&
|
||||
(endpoint === newPreset?.endpoint || modularChat)
|
||||
modularEndpoints.has(endpointType ?? '') ||
|
||||
isAssistantSwitch) &&
|
||||
(endpoint === newPreset?.endpoint || modularChat || isAssistantSwitch)
|
||||
) {
|
||||
const currentConvo = getDefaultConversation({
|
||||
/* target endpointType is necessary to avoid endpoint mixing */
|
||||
|
|
@ -178,7 +184,7 @@ export default function usePresets() {
|
|||
});
|
||||
|
||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
||||
navigateToConvo(currentConvo, false);
|
||||
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
7
client/src/hooks/Files/index.ts
Normal file
7
client/src/hooks/Files/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { default as useDeleteFilesFromTable } from './useDeleteFilesFromTable';
|
||||
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
|
||||
export { default as useFileHandling } from './useFileHandling';
|
||||
export { default as useFileDeletion } from './useFileDeletion';
|
||||
export { default as useUpdateFiles } from './useUpdateFiles';
|
||||
export { default as useDragHelpers } from './useDragHelpers';
|
||||
export { default as useFileMap } from './useFileMap';
|
||||
42
client/src/hooks/Files/useDeleteFilesFromTable.tsx
Normal file
42
client/src/hooks/Files/useDeleteFilesFromTable.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import type { BatchFile, TFile } from 'librechat-data-provider';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import useFileDeletion from './useFileDeletion';
|
||||
|
||||
export default function useDeleteFilesFromTable(callback?: () => void) {
|
||||
const queryClient = useQueryClient();
|
||||
const deletionMutation = useDeleteFilesMutation({
|
||||
onMutate: async (variables) => {
|
||||
const { files } = variables;
|
||||
if (!files?.length) {
|
||||
return new Map<string, BatchFile>();
|
||||
}
|
||||
|
||||
const filesToDeleteMap = files.reduce((map, file) => {
|
||||
map.set(file.file_id, file);
|
||||
return map;
|
||||
}, new Map<string, BatchFile>());
|
||||
|
||||
return { filesToDeleteMap };
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
console.log('Files deleted');
|
||||
const { filesToDeleteMap } = context as { filesToDeleteMap: Map<string, BatchFile> };
|
||||
|
||||
queryClient.setQueryData([QueryKeys.files], (oldFiles: TFile[] | undefined) => {
|
||||
const { files } = variables;
|
||||
return files?.length
|
||||
? oldFiles?.filter((file) => !filesToDeleteMap.has(file.file_id))
|
||||
: oldFiles;
|
||||
});
|
||||
callback?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Error deleting files:', error);
|
||||
callback?.();
|
||||
},
|
||||
});
|
||||
|
||||
return useFileDeletion({ mutateAsync: deletionMutation.mutateAsync });
|
||||
}
|
||||
125
client/src/hooks/Files/useFileDeletion.ts
Normal file
125
client/src/hooks/Files/useFileDeletion.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import type {
|
||||
BatchFile,
|
||||
TFile,
|
||||
DeleteFilesResponse,
|
||||
DeleteFilesBody,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseMutateAsyncFunction } from '@tanstack/react-query';
|
||||
import type { ExtendedFile, GenericSetter } from '~/common';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
|
||||
type FileMapSetter = GenericSetter<Map<string, ExtendedFile>>;
|
||||
|
||||
const useFileDeletion = ({
|
||||
mutateAsync,
|
||||
assistant_id,
|
||||
}: {
|
||||
mutateAsync: UseMutateAsyncFunction<DeleteFilesResponse, unknown, DeleteFilesBody, unknown>;
|
||||
assistant_id?: string;
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_batch, setFileDeleteBatch] = useState<BatchFile[]>([]);
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
|
||||
const executeBatchDelete = useCallback(
|
||||
(filesToDelete: BatchFile[], assistant_id?: string) => {
|
||||
console.log('Deleting files:', filesToDelete, assistant_id);
|
||||
mutateAsync({ files: filesToDelete, assistant_id });
|
||||
setFileDeleteBatch([]);
|
||||
},
|
||||
[mutateAsync],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedDelete = useCallback(debounce(executeBatchDelete, 1000), []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup function for debouncedDelete when component unmounts or before re-render
|
||||
return () => debouncedDelete.cancel();
|
||||
}, [debouncedDelete]);
|
||||
|
||||
const deleteFile = useCallback(
|
||||
({ file: _file, setFiles }: { file: ExtendedFile | TFile; setFiles?: FileMapSetter }) => {
|
||||
const {
|
||||
file_id,
|
||||
temp_file_id = '',
|
||||
filepath = '',
|
||||
source = FileSources.local,
|
||||
attached,
|
||||
} = _file as TFile & { attached?: boolean };
|
||||
|
||||
const progress = _file['progress'] ?? 1;
|
||||
|
||||
if (progress < 1) {
|
||||
return;
|
||||
}
|
||||
const file: BatchFile = {
|
||||
file_id,
|
||||
filepath,
|
||||
source,
|
||||
};
|
||||
|
||||
if (setFiles) {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.delete(file_id);
|
||||
updatedFiles.delete(temp_file_id);
|
||||
const files = Object.fromEntries(updatedFiles);
|
||||
setFilesToDelete(files);
|
||||
return updatedFiles;
|
||||
});
|
||||
}
|
||||
|
||||
if (attached) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFileDeleteBatch((prevBatch) => {
|
||||
const newBatch = [...prevBatch, file];
|
||||
debouncedDelete(newBatch, assistant_id);
|
||||
return newBatch;
|
||||
});
|
||||
},
|
||||
[debouncedDelete, setFilesToDelete, assistant_id],
|
||||
);
|
||||
|
||||
const deleteFiles = useCallback(
|
||||
({ files, setFiles }: { files: ExtendedFile[] | TFile[]; setFiles?: FileMapSetter }) => {
|
||||
const batchFiles = files.map((_file) => {
|
||||
const { file_id, filepath = '', source = FileSources.local } = _file;
|
||||
|
||||
return {
|
||||
file_id,
|
||||
filepath,
|
||||
source,
|
||||
};
|
||||
});
|
||||
|
||||
if (setFiles) {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
batchFiles.forEach((file) => {
|
||||
updatedFiles.delete(file.file_id);
|
||||
});
|
||||
const filesToUpdate = Object.fromEntries(updatedFiles);
|
||||
setFilesToDelete(filesToUpdate);
|
||||
return updatedFiles;
|
||||
});
|
||||
}
|
||||
|
||||
setFileDeleteBatch((prevBatch) => {
|
||||
const newBatch = [...prevBatch, ...batchFiles];
|
||||
debouncedDelete(newBatch, assistant_id);
|
||||
return newBatch;
|
||||
});
|
||||
},
|
||||
[debouncedDelete, setFilesToDelete, assistant_id],
|
||||
);
|
||||
|
||||
return { deleteFile, deleteFiles };
|
||||
};
|
||||
|
||||
export default useFileDeletion;
|
||||
278
client/src/hooks/Files/useFileHandling.ts
Normal file
278
client/src/hooks/Files/useFileHandling.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { v4 } from 'uuid';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
megabyte,
|
||||
EModelEndpoint,
|
||||
mergeFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import { useUploadFileMutation, useGetFileConfig } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
const { checkType } = defaultFileConfig;
|
||||
|
||||
type UseFileHandling = {
|
||||
overrideEndpoint?: EModelEndpoint;
|
||||
fileSetter?: FileSetter;
|
||||
additionalMetadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
const useFileHandling = (params?: UseFileHandling) => {
|
||||
const { showToast } = useToastContext();
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const { files, setFiles, setFilesLoading, conversation } = useChatContext();
|
||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
||||
const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles(
|
||||
params?.fileSetter ?? setFiles,
|
||||
);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
const endpoint =
|
||||
params?.overrideEndpoint ?? conversation?.endpointType ?? conversation?.endpoint ?? 'default';
|
||||
|
||||
const { fileLimit, fileSizeLimit, totalSizeLimit, supportedMimeTypes } =
|
||||
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
|
||||
|
||||
const displayToast = useCallback(() => {
|
||||
if (errors.length > 1) {
|
||||
const errorList = Array.from(new Set(errors))
|
||||
.map((e, i) => `${i > 0 ? '• ' : ''}${e}\n`)
|
||||
.join('');
|
||||
showToast({
|
||||
message: errorList,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (errors.length === 1) {
|
||||
showToast({
|
||||
message: errors[0],
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
setErrors([]);
|
||||
}, [errors, showToast]);
|
||||
|
||||
const debouncedDisplayToast = debounce(displayToast, 250);
|
||||
|
||||
useEffect(() => {
|
||||
if (errors.length > 0) {
|
||||
debouncedDisplayToast();
|
||||
}
|
||||
|
||||
return () => debouncedDisplayToast.cancel();
|
||||
}, [errors, debouncedDisplayToast]);
|
||||
|
||||
const uploadFile = useUploadFileMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('upload success', data);
|
||||
updateFileById(
|
||||
data.temp_file_id,
|
||||
{
|
||||
progress: 0.9,
|
||||
filepath: data.filepath,
|
||||
},
|
||||
params?.additionalMetadata?.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,
|
||||
},
|
||||
params?.additionalMetadata?.assistant_id ? true : false,
|
||||
);
|
||||
}, 300);
|
||||
},
|
||||
onError: (error, body) => {
|
||||
console.log('upload error', error);
|
||||
const file_id = body.get('file_id');
|
||||
deleteFileById(file_id as string);
|
||||
setError(
|
||||
(error as { response: { data: { message?: string } } })?.response?.data?.message ??
|
||||
'An error occurred while uploading the file.',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const startUpload = async (extendedFile: ExtendedFile) => {
|
||||
if (!endpoint) {
|
||||
setError('An error occurred while uploading the file: Endpoint is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', extendedFile.file as File);
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
if (extendedFile.width) {
|
||||
formData.append('width', extendedFile.width?.toString());
|
||||
}
|
||||
if (extendedFile.height) {
|
||||
formData.append('height', extendedFile.height?.toString());
|
||||
}
|
||||
|
||||
if (params?.additionalMetadata) {
|
||||
for (const [key, value] of Object.entries(params.additionalMetadata)) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
endpoint === EModelEndpoint.assistants &&
|
||||
!formData.get('assistant_id') &&
|
||||
conversation?.assistant_id
|
||||
) {
|
||||
formData.append('assistant_id', conversation.assistant_id);
|
||||
formData.append('message_file', 'true');
|
||||
}
|
||||
|
||||
formData.append('endpoint', endpoint);
|
||||
|
||||
uploadFile.mutate(formData);
|
||||
};
|
||||
|
||||
const validateFiles = (fileList: File[]) => {
|
||||
const existingFiles = Array.from(files.values());
|
||||
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
|
||||
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++) {
|
||||
const originalFile = fileList[i];
|
||||
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.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`),
|
||||
];
|
||||
|
||||
const uniqueFilesSet = new Set(combinedFilesInfo);
|
||||
|
||||
if (uniqueFilesSet.size !== combinedFilesInfo.length) {
|
||||
setError('Duplicate file detected.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
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[]) => {
|
||||
const fileList = Array.from(_files);
|
||||
/* Validate files */
|
||||
let filesAreValid: boolean;
|
||||
try {
|
||||
filesAreValid = validateFiles(fileList);
|
||||
} catch (error) {
|
||||
console.error('file validation error', error);
|
||||
setError('An error occurred while validating the file.');
|
||||
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('An error occurred while processing the file.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.target.files) {
|
||||
setFilesLoading(true);
|
||||
handleFiles(event.target.files);
|
||||
// reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleFileChange,
|
||||
handleFiles,
|
||||
files,
|
||||
setFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFileHandling;
|
||||
11
client/src/hooks/Files/useFileMap.ts
Normal file
11
client/src/hooks/Files/useFileMap.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useGetFiles } from '~/data-provider';
|
||||
import { mapFiles } from '~/utils';
|
||||
|
||||
export default function useFileMap({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { data: fileMap } = useGetFiles({
|
||||
select: mapFiles,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
return fileMap;
|
||||
}
|
||||
72
client/src/hooks/Files/useUpdateFiles.ts
Normal file
72
client/src/hooks/Files/useUpdateFiles.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
|
||||
export default function useUpdateFiles(setFiles: FileSetter) {
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
|
||||
const addFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const replaceFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileById = (
|
||||
fileId: string,
|
||||
updates: Partial<ExtendedFile>,
|
||||
isAssistantFile?: boolean,
|
||||
) => {
|
||||
setFiles((currentFiles) => {
|
||||
if (!currentFiles.has(fileId)) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
const currentFile = updatedFiles.get(fileId);
|
||||
if (!currentFile) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
updatedFiles.set(fileId, { ...currentFile, ...updates });
|
||||
|
||||
if (updates['filepath'] && updates['progress'] !== 1 && !isAssistantFile) {
|
||||
const files = Object.fromEntries(updatedFiles);
|
||||
setFilesToDelete(files);
|
||||
}
|
||||
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFileById = (fileId: string) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
if (updatedFiles.has(fileId)) {
|
||||
updatedFiles.delete(fileId);
|
||||
} else {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
}
|
||||
|
||||
const files = Object.fromEntries(updatedFiles);
|
||||
setFilesToDelete(files);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
addFile,
|
||||
replaceFile,
|
||||
updateFileById,
|
||||
deleteFileById,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,15 +1,32 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { TEndpointOption } from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointOption } from 'librechat-data-provider';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
import useFileHandling from '~/hooks/Files/useFileHandling';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import useFileHandling from '~/hooks/useFileHandling';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
||||
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
|
||||
|
||||
const getAssistantName = ({
|
||||
name,
|
||||
localize,
|
||||
}: {
|
||||
name?: string;
|
||||
localize: (phraseKey: string, ...values: string[]) => string;
|
||||
}) => {
|
||||
if (name && name.length > 0) {
|
||||
return name;
|
||||
} else {
|
||||
return localize('com_ui_assistant');
|
||||
}
|
||||
};
|
||||
|
||||
export default function useTextarea({ setText, submitMessage, disabled = false }) {
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { conversation, isSubmitting, latestMessage, setShowBingToneSetting, setFilesLoading } =
|
||||
useChatContext();
|
||||
const isComposing = useRef(false);
|
||||
|
|
@ -18,10 +35,13 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
|
|||
const getSender = useGetSender();
|
||||
const localize = useLocalize();
|
||||
|
||||
const { conversationId, jailbreak } = conversation || {};
|
||||
const { conversationId, jailbreak, endpoint = '', assistant_id } = conversation || {};
|
||||
const isNotAppendable = (latestMessage?.unfinished && !isSubmitting) || latestMessage?.error;
|
||||
// && (conversationId?.length ?? 0) > 6; // also ensures that we don't show the wrong placeholder
|
||||
|
||||
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
|
||||
const assistantName = (assistant && assistant?.name) || '';
|
||||
|
||||
// auto focus to input, when enter a conversation.
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
|
|
@ -61,7 +81,10 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
|
|||
return localize('com_endpoint_message_not_appendable');
|
||||
}
|
||||
|
||||
const sender = getSender(conversation as TEndpointOption);
|
||||
const sender =
|
||||
conversation?.endpoint === EModelEndpoint.assistants
|
||||
? getAssistantName({ name: assistantName, localize })
|
||||
: getSender(conversation as TEndpointOption);
|
||||
|
||||
return `${localize('com_endpoint_message')} ${sender ? sender : 'ChatGPT'}…`;
|
||||
};
|
||||
|
|
@ -84,7 +107,7 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
|
|||
debouncedSetPlaceholder();
|
||||
|
||||
return () => debouncedSetPlaceholder.cancel();
|
||||
}, [conversation, disabled, latestMessage, isNotAppendable, localize, getSender]);
|
||||
}, [conversation, disabled, latestMessage, isNotAppendable, localize, getSender, assistantName]);
|
||||
|
||||
const handleKeyDown = (e: KeyEvent) => {
|
||||
if (e.key === 'Enter' && isSubmitting) {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { default as useProgress } from './useProgress';
|
||||
export { default as useMessageHelpers } from './useMessageHelpers';
|
||||
export { default as useMessageScrolling } from './useMessageScrolling';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import copy from 'copy-to-clipboard';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||
import Icon from '~/components/Endpoints/Icon';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { getEndpointField } from '~/utils';
|
||||
|
||||
export default function useMessageHelpers(props: TMessageProps) {
|
||||
const latestText = useRef('');
|
||||
const latestText = useRef<string | number>('');
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
|
||||
|
|
@ -22,21 +23,26 @@ export default function useMessageHelpers(props: TMessageProps) {
|
|||
handleContinue,
|
||||
setLatestMessage,
|
||||
} = useChatContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const { text, children, messageId = null, isCreatedByUser } = message ?? {};
|
||||
const edit = messageId === currentEditId;
|
||||
const isLast = !children?.length;
|
||||
|
||||
useEffect(() => {
|
||||
let contentChanged = message?.content
|
||||
? message?.content?.length !== latestText.current
|
||||
: message?.text !== latestText.current;
|
||||
|
||||
if (!isLast) {
|
||||
contentChanged = false;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
} else if (
|
||||
isLast &&
|
||||
conversation?.conversationId !== 'new' &&
|
||||
latestText.current !== message.text
|
||||
) {
|
||||
} else if (isLast && conversation?.conversationId !== 'new' && contentChanged) {
|
||||
setLatestMessage({ ...message });
|
||||
latestText.current = message.text;
|
||||
latestText.current = message?.content ? message.content.length : message.text;
|
||||
}
|
||||
}, [isLast, message, setLatestMessage, conversation?.conversationId]);
|
||||
|
||||
|
|
@ -51,11 +57,17 @@ export default function useMessageHelpers(props: TMessageProps) {
|
|||
}
|
||||
}, [isSubmitting, setAbortScroll]);
|
||||
|
||||
const assistant =
|
||||
conversation?.endpoint === EModelEndpoint.assistants && assistantMap?.[message?.model ?? ''];
|
||||
|
||||
const icon = Icon({
|
||||
...conversation,
|
||||
...(message as TMessage),
|
||||
iconURL: getEndpointField(endpointsConfig, conversation?.endpoint, 'iconURL'),
|
||||
iconURL: !assistant
|
||||
? getEndpointField(endpointsConfig, conversation?.endpoint, 'iconURL')
|
||||
: (assistant?.metadata?.avatar as string | undefined) ?? '',
|
||||
model: message?.model ?? conversation?.model,
|
||||
assistantName: assistant ? (assistant.name as string | undefined) : '',
|
||||
size: 28.8,
|
||||
});
|
||||
|
||||
|
|
@ -81,6 +93,7 @@ export default function useMessageHelpers(props: TMessageProps) {
|
|||
icon,
|
||||
edit,
|
||||
isLast,
|
||||
assistant,
|
||||
enterEdit,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
|
|
|
|||
35
client/src/hooks/Messages/useProgress.ts
Normal file
35
client/src/hooks/Messages/useProgress.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function useProgress(initialProgress = 0.01) {
|
||||
const [progress, setProgress] = useState(initialProgress);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
if (initialProgress >= 1 && progress >= 1) {
|
||||
return;
|
||||
} else if (initialProgress >= 1 && progress < 1) {
|
||||
setProgress(0.99);
|
||||
timeout = setTimeout(() => {
|
||||
setProgress(1);
|
||||
}, 200);
|
||||
} else {
|
||||
timer = setInterval(() => {
|
||||
setProgress((prevProgress) => {
|
||||
if (prevProgress >= 1) {
|
||||
clearInterval(timer);
|
||||
return 1;
|
||||
}
|
||||
return Math.min(prevProgress + 0.007, 0.95);
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [progress, initialProgress]);
|
||||
|
||||
return progress;
|
||||
}
|
||||
1
client/src/hooks/Plugins/index.ts
Normal file
1
client/src/hooks/Plugins/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as usePluginDialogHelpers } from './usePluginDialogHelpers';
|
||||
79
client/src/hooks/Plugins/usePluginDialogHelpers.ts
Normal file
79
client/src/hooks/Plugins/usePluginDialogHelpers.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
|
||||
function usePluginDialogHelpers() {
|
||||
const [maxPage, setMaxPage] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(1);
|
||||
const [searchChanged, setSearchChanged] = useState(false);
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [showPluginAuthForm, setShowPluginAuthForm] = useState<boolean>(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<TPlugin | undefined>(undefined);
|
||||
|
||||
const calculateColumns = (node: HTMLElement) => {
|
||||
const width = node.offsetWidth;
|
||||
let columns: number;
|
||||
if (width < 501) {
|
||||
setItemsPerPage(8);
|
||||
return;
|
||||
} else if (width < 640) {
|
||||
columns = 2;
|
||||
} else if (width < 1024) {
|
||||
columns = 3;
|
||||
} else {
|
||||
columns = 4;
|
||||
}
|
||||
setItemsPerPage(columns * 2); // 2 rows
|
||||
};
|
||||
|
||||
const gridRef: (instance: HTMLDivElement | null) => void = useCallback(
|
||||
(node) => {
|
||||
if (node !== null) {
|
||||
if (itemsPerPage === 1) {
|
||||
calculateColumns(node);
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(() => calculateColumns(node));
|
||||
resizeObserver.observe(node);
|
||||
}
|
||||
},
|
||||
[itemsPerPage],
|
||||
);
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value);
|
||||
setSearchChanged(true);
|
||||
};
|
||||
|
||||
const handleChangePage = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return {
|
||||
maxPage,
|
||||
setMaxPage,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
searchChanged,
|
||||
setSearchChanged,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
gridRef,
|
||||
handleSearch,
|
||||
handleChangePage,
|
||||
error,
|
||||
setError,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
showPluginAuthForm,
|
||||
setShowPluginAuthForm,
|
||||
selectedPlugin,
|
||||
setSelectedPlugin,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePluginDialogHelpers;
|
||||
2
client/src/hooks/SSE/index.ts
Normal file
2
client/src/hooks/SSE/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as useSSE } from './useSSE';
|
||||
export { default as useContentHandler } from './useContentHandler';
|
||||
68
client/src/hooks/SSE/useContentHandler.ts
Normal file
68
client/src/hooks/SSE/useContentHandler.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TSubmission,
|
||||
TMessage,
|
||||
TContentData,
|
||||
ContentPart,
|
||||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
type TUseContentHandler = {
|
||||
setMessages: (messages: TMessage[]) => void;
|
||||
getMessages: () => TMessage[] | undefined;
|
||||
};
|
||||
|
||||
type TContentHandler = {
|
||||
data: TContentData;
|
||||
submission: TSubmission;
|
||||
};
|
||||
|
||||
export default function useContentHandler({ setMessages, getMessages }: TUseContentHandler) {
|
||||
const messageMap = new Map<string, TMessage>();
|
||||
return ({ data, submission }: TContentHandler) => {
|
||||
const { type, messageId, thread_id, conversationId, index, stream } = data;
|
||||
|
||||
const _messages = getMessages();
|
||||
const messages =
|
||||
_messages?.filter((m) => m.messageId !== messageId)?.map((msg) => ({ ...msg, thread_id })) ??
|
||||
[];
|
||||
const userMessage = messages[messages.length - 1];
|
||||
|
||||
const { initialResponse } = submission;
|
||||
|
||||
let response = messageMap.get(messageId);
|
||||
if (!response) {
|
||||
response = {
|
||||
...initialResponse,
|
||||
parentMessageId: userMessage?.messageId,
|
||||
conversationId,
|
||||
messageId,
|
||||
thread_id,
|
||||
};
|
||||
messageMap.set(messageId, response);
|
||||
}
|
||||
|
||||
// TODO: handle streaming for non-text
|
||||
const part: ContentPart =
|
||||
stream && data[ContentTypes.TEXT] ? { value: data[ContentTypes.TEXT] } : data[type];
|
||||
|
||||
/* spreading the content array to avoid mutation */
|
||||
response.content = [...(response.content ?? [])];
|
||||
|
||||
response.content[index] = { type, [type]: part } as TMessageContentParts;
|
||||
|
||||
if (
|
||||
type !== ContentTypes.TEXT &&
|
||||
initialResponse.content &&
|
||||
((response.content[response.content.length - 1].type === ContentTypes.TOOL_CALL &&
|
||||
response.content[response.content.length - 1][ContentTypes.TOOL_CALL].progress === 1) ||
|
||||
response.content[response.content.length - 1].type === ContentTypes.IMAGE_FILE)
|
||||
) {
|
||||
response.content.push(initialResponse.content[0]);
|
||||
}
|
||||
|
||||
response.content = response.content.filter((p) => p !== undefined);
|
||||
|
||||
setMessages([...messages, response]);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import { v4 } from 'uuid';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
/* @ts-ignore */
|
||||
SSE,
|
||||
QueryKeys,
|
||||
EndpointURLs,
|
||||
Constants,
|
||||
createPayload,
|
||||
tPresetSchema,
|
||||
tMessageSchema,
|
||||
|
|
@ -24,18 +26,31 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import { addConversation, deleteConversation, updateConversation } from '~/utils';
|
||||
import { useGenTitleMutation } from '~/data-provider';
|
||||
import { useAuthContext } from './AuthContext';
|
||||
import useChatHelpers from './useChatHelpers';
|
||||
import useSetStorage from './useSetStorage';
|
||||
import useContentHandler from './useContentHandler';
|
||||
import { useAuthContext } from '../AuthContext';
|
||||
import useChatHelpers from '../useChatHelpers';
|
||||
import useSetStorage from '../useSetStorage';
|
||||
import store from '~/store';
|
||||
|
||||
type TResData = {
|
||||
plugin?: TResPlugin;
|
||||
final?: boolean;
|
||||
initial?: boolean;
|
||||
previousMessages?: TMessage[];
|
||||
requestMessage: TMessage;
|
||||
responseMessage: TMessage;
|
||||
conversation: TConversation;
|
||||
conversationId?: string;
|
||||
runMessages?: TMessage[];
|
||||
};
|
||||
|
||||
type TSyncData = {
|
||||
sync: boolean;
|
||||
thread_id: string;
|
||||
messages?: TMessage[];
|
||||
requestMessage: TMessage;
|
||||
responseMessage: TMessage;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
export default function useSSE(submission: TSubmission | null, index = 0) {
|
||||
|
|
@ -46,8 +61,17 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
const { conversationId: paramId } = useParams();
|
||||
const { token, isAuthenticated } = useAuthContext();
|
||||
const [completed, setCompleted] = useState(new Set());
|
||||
const { setMessages, setConversation, setIsSubmitting, newConversation, resetLatestMessage } =
|
||||
useChatHelpers(index, paramId);
|
||||
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
|
||||
|
||||
const {
|
||||
setMessages,
|
||||
getMessages,
|
||||
setConversation,
|
||||
setIsSubmitting,
|
||||
newConversation,
|
||||
resetLatestMessage,
|
||||
} = useChatHelpers(index, paramId);
|
||||
const contentHandler = useContentHandler({ setMessages, getMessages });
|
||||
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const balanceQuery = useGetUserBalance({
|
||||
|
|
@ -120,7 +144,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
}
|
||||
|
||||
// refresh title
|
||||
if (isNewConvo && requestMessage?.parentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
if (isNewConvo && requestMessage?.parentMessageId === Constants.NO_PARENT) {
|
||||
setTimeout(() => {
|
||||
genTitle.mutate({ conversationId: convoUpdate.conversationId as string });
|
||||
}, 2500);
|
||||
|
|
@ -139,6 +163,50 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const syncHandler = (data: TSyncData, submission: TSubmission) => {
|
||||
const { conversationId, thread_id, responseMessage, requestMessage } = data;
|
||||
const { initialResponse, messages: _messages, message } = submission;
|
||||
|
||||
const messages = _messages.filter((msg) => msg.messageId !== message.messageId);
|
||||
|
||||
setMessages([
|
||||
...messages,
|
||||
requestMessage,
|
||||
{
|
||||
...initialResponse,
|
||||
...responseMessage,
|
||||
},
|
||||
]);
|
||||
|
||||
let update = {} as TConversation;
|
||||
setConversation((prevState) => {
|
||||
update = tConvoUpdateSchema.parse({
|
||||
...prevState,
|
||||
conversationId,
|
||||
thread_id,
|
||||
messages: [requestMessage.messageId, responseMessage.messageId],
|
||||
}) as TConversation;
|
||||
|
||||
setStorage(update);
|
||||
return update;
|
||||
});
|
||||
|
||||
queryClient.setQueryData<ConversationData>([QueryKeys.allConversations], (convoData) => {
|
||||
if (!convoData) {
|
||||
return convoData;
|
||||
}
|
||||
if (requestMessage.parentMessageId === Constants.NO_PARENT) {
|
||||
return addConversation(convoData, update);
|
||||
} else {
|
||||
return updateConversation(convoData, update);
|
||||
}
|
||||
});
|
||||
|
||||
setShowStopButton(true);
|
||||
|
||||
resetLatestMessage();
|
||||
};
|
||||
|
||||
const createdHandler = (data: TResData, submission: TSubmission) => {
|
||||
const { messages, message, initialResponse, isRegenerate = false } = submission;
|
||||
|
||||
|
|
@ -180,7 +248,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
if (!convoData) {
|
||||
return convoData;
|
||||
}
|
||||
if (message.parentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
if (message.parentMessageId === Constants.NO_PARENT) {
|
||||
return addConversation(convoData, update);
|
||||
} else {
|
||||
return updateConversation(convoData, update);
|
||||
|
|
@ -190,15 +258,18 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
};
|
||||
|
||||
const finalHandler = (data: TResData, submission: TSubmission) => {
|
||||
const { requestMessage, responseMessage, conversation } = data;
|
||||
const { requestMessage, responseMessage, conversation, runMessages } = data;
|
||||
const { messages, conversation: submissionConvo, isRegenerate = false } = submission;
|
||||
|
||||
setShowStopButton(false);
|
||||
setCompleted((prev) => new Set(prev.add(submission?.initialResponse?.messageId)));
|
||||
|
||||
// update the messages
|
||||
if (isRegenerate) {
|
||||
// update the messages; if assistants endpoint, client doesn't receive responseMessage
|
||||
if (runMessages) {
|
||||
setMessages([...runMessages]);
|
||||
} else if (isRegenerate && responseMessage) {
|
||||
setMessages([...messages, responseMessage]);
|
||||
} else {
|
||||
} else if (responseMessage) {
|
||||
setMessages([...messages, requestMessage, responseMessage]);
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +284,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
}
|
||||
|
||||
// refresh title
|
||||
if (isNewConvo && requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') {
|
||||
if (isNewConvo && requestMessage && requestMessage.parentMessageId === Constants.NO_PARENT) {
|
||||
setTimeout(() => {
|
||||
genTitle.mutate({ conversationId: conversation.conversationId as string });
|
||||
}, 2500);
|
||||
|
|
@ -307,76 +378,84 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
return;
|
||||
};
|
||||
|
||||
const abortConversation = (conversationId = '', submission: TSubmission) => {
|
||||
const abortConversation = async (conversationId = '', submission: TSubmission) => {
|
||||
console.log(submission);
|
||||
let runAbortKey = '';
|
||||
try {
|
||||
const conversation = (JSON.parse(localStorage.getItem('lastConversationSetup') ?? '') ??
|
||||
{}) as TConversation;
|
||||
const { conversationId, messages } = conversation;
|
||||
runAbortKey = `${conversationId}:${messages?.[messages.length - 1]}`;
|
||||
} catch (error) {
|
||||
console.error('Error getting last conversation setup');
|
||||
console.error(error);
|
||||
}
|
||||
const { endpoint: _endpoint, endpointType } = submission?.conversation || {};
|
||||
const endpoint = endpointType ?? _endpoint;
|
||||
let res: Response;
|
||||
try {
|
||||
const response = await fetch(`${EndpointURLs[endpoint ?? '']}/abort`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
abortKey: _endpoint === EModelEndpoint.assistants ? runAbortKey : conversationId,
|
||||
endpoint,
|
||||
}),
|
||||
});
|
||||
|
||||
fetch(`${EndpointURLs[endpoint ?? '']}/abort`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
abortKey: conversationId,
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
res = response;
|
||||
// Check if the response is JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
} else if (response.status === 204) {
|
||||
const responseMessage = {
|
||||
...submission.initialResponse,
|
||||
};
|
||||
|
||||
return {
|
||||
requestMessage: submission.message,
|
||||
responseMessage: responseMessage,
|
||||
conversation: submission.conversation,
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unexpected response from server; Status: ' + res.status + ' ' + res.statusText,
|
||||
);
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
// Check if the response is JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
console.log('aborted', data);
|
||||
if (res.status === 404) {
|
||||
return setIsSubmitting(false);
|
||||
if (response.status === 404) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
cancelHandler(data, submission);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error aborting request');
|
||||
console.error(error);
|
||||
const convoId = conversationId ?? v4();
|
||||
|
||||
const text =
|
||||
submission.initialResponse?.text?.length > 45 ? submission.initialResponse?.text : '';
|
||||
|
||||
const errorMessage = {
|
||||
...submission,
|
||||
if (data.final) {
|
||||
finalHandler(data, submission);
|
||||
} else {
|
||||
cancelHandler(data, submission);
|
||||
}
|
||||
} else if (response.status === 204) {
|
||||
const responseMessage = {
|
||||
...submission.initialResponse,
|
||||
text: text ?? error.message ?? 'Error cancelling request',
|
||||
unfinished: !!text.length,
|
||||
error: true,
|
||||
};
|
||||
|
||||
const errorResponse = tMessageSchema.parse(errorMessage);
|
||||
setMessages([...submission.messages, submission.message, errorResponse]);
|
||||
newConversation({
|
||||
template: { conversationId: convoId },
|
||||
preset: tPresetSchema.parse(submission?.conversation),
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
const data = {
|
||||
requestMessage: submission.message,
|
||||
responseMessage: responseMessage,
|
||||
conversation: submission.conversation,
|
||||
};
|
||||
console.log('aborted', data);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Unexpected response from server; Status: ' + response.status + ' ' + response.statusText,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling request');
|
||||
console.error(error);
|
||||
const convoId = conversationId ?? v4();
|
||||
const text =
|
||||
submission.initialResponse?.text?.length > 45 ? submission.initialResponse?.text : '';
|
||||
const errorMessage = {
|
||||
...submission,
|
||||
...submission.initialResponse,
|
||||
text: text ?? (error as Error).message ?? 'Error cancelling request',
|
||||
unfinished: !!text.length,
|
||||
error: true,
|
||||
};
|
||||
const errorResponse = tMessageSchema.parse(errorMessage);
|
||||
setMessages([...submission.messages, submission.message, errorResponse]);
|
||||
newConversation({
|
||||
template: { conversationId: convoId },
|
||||
preset: tPresetSchema.parse(submission?.conversation),
|
||||
});
|
||||
return;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -391,10 +470,12 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
|
||||
const payloadData = createPayload(submission);
|
||||
let { payload } = payloadData;
|
||||
if (payload.endpoint === EModelEndpoint.assistant) {
|
||||
if (payload.endpoint === EModelEndpoint.assistants) {
|
||||
payload = removeNullishValues(payload);
|
||||
}
|
||||
|
||||
let textIndex = null;
|
||||
|
||||
const events = new SSE(payloadData.server, {
|
||||
payload: JSON.stringify(payload),
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
|
|
@ -416,6 +497,16 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
overrideParentMessageId: message?.overrideParentMessageId,
|
||||
};
|
||||
createdHandler(data, { ...submission, message });
|
||||
} else if (data.sync) {
|
||||
/* synchronize messages to Assistants API as well as with real DB ID's */
|
||||
syncHandler(data, { ...submission, message });
|
||||
} else if (data.type) {
|
||||
const { text, index } = data;
|
||||
if (text && index !== textIndex) {
|
||||
textIndex = index;
|
||||
}
|
||||
|
||||
contentHandler({ data, submission });
|
||||
} else {
|
||||
const text = data.text || data.response;
|
||||
const { plugin, plugins } = data;
|
||||
|
|
@ -428,7 +519,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
|
||||
events.onopen = () => console.log('connection is opened');
|
||||
|
||||
events.oncancel = () => {
|
||||
events.oncancel = async () => {
|
||||
const streamKey = submission?.initialResponse?.messageId;
|
||||
if (completed.has(streamKey)) {
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -440,7 +531,10 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
|
|||
}
|
||||
|
||||
setCompleted((prev) => new Set(prev.add(streamKey)));
|
||||
return abortConversation(message?.conversationId ?? submission?.conversationId, submission);
|
||||
return await abortConversation(
|
||||
message?.conversationId ?? submission?.conversationId,
|
||||
submission,
|
||||
);
|
||||
};
|
||||
|
||||
events.onerror = function (e: MessageEvent) {
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
export * from './Messages';
|
||||
export * from './Assistants';
|
||||
export * from './Config';
|
||||
export * from './Input';
|
||||
export * from './Conversations';
|
||||
export * from './Nav';
|
||||
export * from './Files';
|
||||
export * from './Input';
|
||||
export * from './Messages';
|
||||
export * from './Plugins';
|
||||
export * from './SSE';
|
||||
|
||||
export * from './AuthContext';
|
||||
export * from './ThemeContext';
|
||||
export * from './ScreenshotContext';
|
||||
export * from './ApiErrorBoundaryContext';
|
||||
export { default as useSSE } from './useSSE';
|
||||
export { default as useToast } from './useToast';
|
||||
export { default as useTimeout } from './useTimeout';
|
||||
export { default as useNewConvo } from './useNewConvo';
|
||||
|
|
@ -18,13 +21,11 @@ export { default as useSetOptions } from './useSetOptions';
|
|||
export { default as useSetStorage } from './useSetStorage';
|
||||
export { default as useChatHelpers } from './useChatHelpers';
|
||||
export { default as useGenerations } from './useGenerations';
|
||||
export { default as useDragHelpers } from './useDragHelpers';
|
||||
export { default as useScrollToRef } from './useScrollToRef';
|
||||
export { default as useLocalStorage } from './useLocalStorage';
|
||||
export { default as useConversation } from './useConversation';
|
||||
export { default as useDefaultConvo } from './useDefaultConvo';
|
||||
export { default as useServerStream } from './useServerStream';
|
||||
export { default as useFileHandling } from './useFileHandling';
|
||||
export { default as useConversations } from './useConversations';
|
||||
export { default as useDelayedRender } from './useDelayedRender';
|
||||
export { default as useOnClickOutside } from './useOnClickOutside';
|
||||
|
|
@ -32,5 +33,4 @@ export { default as useMessageHandler } from './useMessageHandler';
|
|||
export { default as useOriginNavigate } from './useOriginNavigate';
|
||||
export { default as useNavigateToConvo } from './useNavigateToConvo';
|
||||
export { default as useSetIndexOptions } from './useSetIndexOptions';
|
||||
export { default as useSetFilesToDelete } from './useSetFilesToDelete';
|
||||
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { v4 } from 'uuid';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, parseCompactConvo } from 'librechat-data-provider';
|
||||
import {
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
QueryKeys,
|
||||
parseCompactConvo,
|
||||
ContentTypes,
|
||||
} from 'librechat-data-provider';
|
||||
import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useGetMessagesByConvoId, useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type {
|
||||
|
|
@ -11,7 +17,7 @@ import type {
|
|||
TEndpointsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TAskFunction } from '~/common';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
import useSetFilesToDelete from './Files/useSetFilesToDelete';
|
||||
import useGetSender from './Conversations/useGetSender';
|
||||
import { useAuthContext } from './AuthContext';
|
||||
import useUserKey from './Input/useUserKey';
|
||||
|
|
@ -21,8 +27,8 @@ import store from '~/store';
|
|||
// this to be set somewhere else
|
||||
export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
|
||||
const [files, setFiles] = useRecoilState(store.filesByIndex(index));
|
||||
const [showStopButton, setShowStopButton] = useState(true);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
const getSender = useGetSender();
|
||||
|
|
@ -93,7 +99,7 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
isEdited = false,
|
||||
} = {},
|
||||
) => {
|
||||
setShowStopButton(true);
|
||||
setShowStopButton(false);
|
||||
if (!!isSubmitting || text === '') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -116,6 +122,26 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
|
||||
const isEditOrContinue = isEdited || isContinued;
|
||||
|
||||
let currentMessages: TMessage[] | null = getMessages() ?? [];
|
||||
|
||||
// construct the query message
|
||||
// this is not a real messageId, it is used as placeholder before real messageId returned
|
||||
text = text.trim();
|
||||
const fakeMessageId = v4();
|
||||
parentMessageId = parentMessageId || latestMessage?.messageId || Constants.NO_PARENT;
|
||||
|
||||
if (conversationId == 'new') {
|
||||
parentMessageId = Constants.NO_PARENT;
|
||||
currentMessages = [];
|
||||
conversationId = null;
|
||||
}
|
||||
|
||||
const parentMessage = currentMessages?.find(
|
||||
(msg) => msg.messageId === latestMessage?.parentMessageId,
|
||||
);
|
||||
|
||||
const thread_id = parentMessage?.thread_id ?? latestMessage?.thread_id;
|
||||
|
||||
// set the endpoint option
|
||||
const convo = parseCompactConvo({
|
||||
endpoint,
|
||||
|
|
@ -130,23 +156,10 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
endpointType,
|
||||
modelDisplayLabel,
|
||||
key: getExpiry(),
|
||||
thread_id,
|
||||
} as TEndpointOption;
|
||||
const responseSender = getSender({ model: conversation?.model, ...endpointOption });
|
||||
|
||||
let currentMessages: TMessage[] | null = getMessages() ?? [];
|
||||
|
||||
// construct the query message
|
||||
// this is not a real messageId, it is used as placeholder before real messageId returned
|
||||
text = text.trim();
|
||||
const fakeMessageId = v4();
|
||||
parentMessageId =
|
||||
parentMessageId || latestMessage?.messageId || '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
if (conversationId == 'new') {
|
||||
parentMessageId = '00000000-0000-0000-0000-000000000000';
|
||||
currentMessages = [];
|
||||
conversationId = null;
|
||||
}
|
||||
const currentMsg: TMessage = {
|
||||
text,
|
||||
sender: 'User',
|
||||
|
|
@ -154,12 +167,10 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
parentMessageId,
|
||||
conversationId,
|
||||
messageId: isContinued && messageId ? messageId : fakeMessageId,
|
||||
thread_id,
|
||||
error: false,
|
||||
};
|
||||
|
||||
const parentMessage = currentMessages?.find(
|
||||
(msg) => msg.messageId === latestMessage?.parentMessageId,
|
||||
);
|
||||
const reuseFiles = isRegenerate && parentMessage?.files;
|
||||
if (reuseFiles && parentMessage.files?.length) {
|
||||
currentMsg.files = parentMessage.files;
|
||||
|
|
@ -188,6 +199,7 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
endpoint: endpoint ?? '',
|
||||
parentMessageId: isRegenerate ? messageId : fakeMessageId,
|
||||
messageId: responseMessageId ?? `${isRegenerate ? messageId : fakeMessageId}_`,
|
||||
thread_id,
|
||||
conversationId,
|
||||
unfinished: false,
|
||||
isCreatedByUser: false,
|
||||
|
|
@ -195,6 +207,21 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
error: false,
|
||||
};
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants) {
|
||||
initialResponse.model = conversation?.assistant_id ?? '';
|
||||
initialResponse.text = '';
|
||||
initialResponse.content = [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: {
|
||||
value: responseText,
|
||||
},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
setShowStopButton(true);
|
||||
}
|
||||
|
||||
if (isContinued) {
|
||||
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
|
||||
}
|
||||
|
|
@ -334,7 +361,5 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
|
|||
setFiles,
|
||||
filesLoading,
|
||||
setFilesLoading,
|
||||
showStopButton,
|
||||
setShowStopButton,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TConversation, TPreset, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery, useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import type {
|
||||
TConversation,
|
||||
TPreset,
|
||||
TEndpointsConfig,
|
||||
TModelsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { getDefaultEndpoint, buildDefaultConvo } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type TDefaultConvo = { conversation: Partial<TConversation>; preset?: Partial<TPreset> | null };
|
||||
|
||||
const useDefaultConvo = () => {
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
const modelsConfig = useRecoilValue(store.modelsConfig);
|
||||
const { data: modelsConfig = {} as TModelsConfig } = useGetModelsQuery();
|
||||
|
||||
const getDefaultConversation = ({ conversation, preset }: TDefaultConvo) => {
|
||||
const endpoint = getDefaultEndpoint({
|
||||
convoSetup: preset as TPreset,
|
||||
endpointsConfig,
|
||||
});
|
||||
const models = modelsConfig?.[endpoint] || [];
|
||||
|
||||
const models = modelsConfig[endpoint] || [];
|
||||
|
||||
return buildDefaultConvo({
|
||||
conversation: conversation as TConversation,
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
import { v4 } from 'uuid';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useToastContext } from '~/Providers/ToastContext';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { useUploadImageMutation } from '~/data-provider';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
||||
const sizeMB = 20;
|
||||
const maxSize = 25;
|
||||
const fileLimit = 10;
|
||||
const sizeLimit = sizeMB * 1024 * 1024; // 20 MB
|
||||
const totalSizeLimit = maxSize * 1024 * 1024; // 25 MB
|
||||
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
|
||||
const useFileHandling = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
||||
const { files, setFiles, setFilesLoading } = useChatContext();
|
||||
const setFilesToDelete = useSetFilesToDelete();
|
||||
|
||||
const displayToast = useCallback(() => {
|
||||
if (errors.length > 1) {
|
||||
const errorList = Array.from(new Set(errors))
|
||||
.map((e, i) => `${i > 0 ? '• ' : ''}${e}\n`)
|
||||
.join('');
|
||||
showToast({
|
||||
message: errorList,
|
||||
severity: NotificationSeverity.ERROR,
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (errors.length === 1) {
|
||||
showToast({
|
||||
message: errors[0],
|
||||
severity: NotificationSeverity.ERROR,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
setErrors([]);
|
||||
}, [errors, showToast]);
|
||||
|
||||
const debouncedDisplayToast = debounce(displayToast, 250);
|
||||
|
||||
useEffect(() => {
|
||||
if (errors.length > 0) {
|
||||
debouncedDisplayToast();
|
||||
}
|
||||
|
||||
return () => debouncedDisplayToast.cancel();
|
||||
}, [errors, debouncedDisplayToast]);
|
||||
|
||||
const addFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const replaceFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileById = (fileId: string, updates: Partial<ExtendedFile>) => {
|
||||
setFiles((currentFiles) => {
|
||||
if (!currentFiles.has(fileId)) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
const currentFile = updatedFiles.get(fileId);
|
||||
if (!currentFile) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
updatedFiles.set(fileId, { ...currentFile, ...updates });
|
||||
|
||||
if (updates['filepath'] && updates['progress'] !== 1) {
|
||||
const files = Object.fromEntries(updatedFiles);
|
||||
setFilesToDelete(files);
|
||||
}
|
||||
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFileById = (fileId: string) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
if (updatedFiles.has(fileId)) {
|
||||
updatedFiles.delete(fileId);
|
||||
} else {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
}
|
||||
|
||||
const files = Object.fromEntries(updatedFiles);
|
||||
setFilesToDelete(files);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadImage = useUploadImageMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('upload success', data);
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 0.9,
|
||||
filepath: data.filepath,
|
||||
});
|
||||
|
||||
const _files = queryClient.getQueryData<TFile[]>([QueryKeys.files]) ?? [];
|
||||
queryClient.setQueryData([QueryKeys.files], [..._files, data]);
|
||||
|
||||
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,
|
||||
});
|
||||
}, 300);
|
||||
},
|
||||
onError: (error, body) => {
|
||||
console.log('upload error', error);
|
||||
deleteFileById(body.file_id);
|
||||
setError('An error occurred while uploading the file.');
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (extendedFile: ExtendedFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', extendedFile.file);
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
if (extendedFile.width) {
|
||||
formData.append('width', extendedFile.width?.toString());
|
||||
}
|
||||
if (extendedFile.height) {
|
||||
formData.append('height', extendedFile.height?.toString());
|
||||
}
|
||||
|
||||
uploadImage.mutate({ formData, file_id: extendedFile.file_id });
|
||||
};
|
||||
|
||||
const validateFiles = (fileList: File[]) => {
|
||||
const existingFiles = Array.from(files.values());
|
||||
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
|
||||
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++) {
|
||||
const originalFile = fileList[i];
|
||||
if (!supportedTypes.includes(originalFile.type)) {
|
||||
setError('Currently, only JPEG, JPG, PNG, and WEBP files are supported.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (originalFile.size >= sizeLimit) {
|
||||
setError(`File size exceeds ${sizeMB} MB.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTotalSize + incomingTotalSize > totalSizeLimit) {
|
||||
setError(`The total size of the files cannot exceed ${maxSize} MB.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const combinedFilesInfo = [
|
||||
...existingFiles.map(
|
||||
(file) => `${file.file.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`,
|
||||
),
|
||||
...fileList.map((file) => `${file.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`),
|
||||
];
|
||||
|
||||
const uniqueFilesSet = new Set(combinedFilesInfo);
|
||||
|
||||
if (uniqueFilesSet.size !== combinedFilesInfo.length) {
|
||||
setError('Duplicate file detected.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFiles = async (_files: FileList | File[]) => {
|
||||
const fileList = Array.from(_files);
|
||||
/* Validate files */
|
||||
let filesAreValid: boolean;
|
||||
try {
|
||||
filesAreValid = validateFiles(fileList);
|
||||
} catch (error) {
|
||||
console.error('file validation error', error);
|
||||
setError('An error occurred while validating the file.');
|
||||
return;
|
||||
}
|
||||
if (!filesAreValid) {
|
||||
setFilesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Process files */
|
||||
fileList.forEach((originalFile) => {
|
||||
const file_id = v4();
|
||||
try {
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
let extendedFile: ExtendedFile = {
|
||||
file_id,
|
||||
file: originalFile,
|
||||
preview,
|
||||
progress: 0.2,
|
||||
size: originalFile.size,
|
||||
};
|
||||
|
||||
addFile(extendedFile);
|
||||
|
||||
// async processing
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
extendedFile.width = img.width;
|
||||
extendedFile.height = img.height;
|
||||
extendedFile = {
|
||||
...extendedFile,
|
||||
progress: 0.6,
|
||||
};
|
||||
replaceFile(extendedFile);
|
||||
|
||||
await uploadFile(extendedFile);
|
||||
URL.revokeObjectURL(preview);
|
||||
};
|
||||
img.src = preview;
|
||||
} catch (error) {
|
||||
deleteFileById(file_id);
|
||||
console.log('file handling error', error);
|
||||
setError('An error occurred while processing the file.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.target.files) {
|
||||
setFilesLoading(true);
|
||||
handleFiles(event.target.files);
|
||||
// reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleFileChange,
|
||||
handleFiles,
|
||||
files,
|
||||
setFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFileHandling;
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { v4 } from 'uuid';
|
||||
// import { useState } from 'react';
|
||||
import ImageBlobReduce from 'image-blob-reduce';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useUploadImageMutation } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
|
||||
const reducer = new ImageBlobReduce();
|
||||
const resolution = 'high';
|
||||
|
||||
const useFileHandling = () => {
|
||||
// const [errors, setErrors] = useState<unknown[]>([]);
|
||||
const { files, setFiles, setFilesLoading } = useChatContext();
|
||||
|
||||
const addFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const replaceFile = (newFile: ExtendedFile) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
updatedFiles.set(newFile.file_id, newFile);
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileById = (fileId: string, updates: Partial<ExtendedFile>) => {
|
||||
setFiles((currentFiles) => {
|
||||
if (!currentFiles.has(fileId)) {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
return currentFiles;
|
||||
}
|
||||
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
const currentFile = updatedFiles.get(fileId);
|
||||
updatedFiles.set(fileId, { ...currentFile, ...updates });
|
||||
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
// const deleteFile = (fileId: string) => {
|
||||
// setFiles((currentFiles) => {
|
||||
// const updatedFiles = new Map(currentFiles);
|
||||
// updatedFiles.delete(fileId);
|
||||
// return updatedFiles;
|
||||
// });
|
||||
// };
|
||||
|
||||
const deleteFileById = (fileId: string) => {
|
||||
setFiles((currentFiles) => {
|
||||
const updatedFiles = new Map(currentFiles);
|
||||
if (updatedFiles.has(fileId)) {
|
||||
updatedFiles.delete(fileId);
|
||||
} else {
|
||||
console.warn(`File with id ${fileId} not found.`);
|
||||
}
|
||||
return updatedFiles;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadImage = useUploadImageMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('upload success', data);
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 0.9,
|
||||
filepath: data.filepath,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
updateFileById(data.temp_file_id, {
|
||||
progress: 1,
|
||||
filepath: data.filepath,
|
||||
});
|
||||
}, 300);
|
||||
},
|
||||
onError: (error, body) => {
|
||||
console.log('upload error', error);
|
||||
deleteFileById(body.file_id);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (extendedFile: ExtendedFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', extendedFile.file);
|
||||
formData.append('file_id', extendedFile.file_id);
|
||||
if (extendedFile.width) {
|
||||
formData.append('width', extendedFile.width?.toString());
|
||||
}
|
||||
if (extendedFile.height) {
|
||||
formData.append('height', extendedFile.height?.toString());
|
||||
}
|
||||
|
||||
uploadImage.mutate({ formData, file_id: extendedFile.file_id });
|
||||
};
|
||||
|
||||
const handleFiles = async (files: FileList | File[]) => {
|
||||
Array.from(files).forEach((originalFile) => {
|
||||
if (!originalFile.type.startsWith('image/')) {
|
||||
// TODO: showToast('Only image files are supported');
|
||||
// TODO: handle other file types
|
||||
return;
|
||||
}
|
||||
|
||||
// todo: Set File is loading
|
||||
|
||||
try {
|
||||
const preview = URL.createObjectURL(originalFile);
|
||||
let extendedFile: ExtendedFile = {
|
||||
file_id: v4(),
|
||||
file: originalFile,
|
||||
preview,
|
||||
progress: 0.2,
|
||||
};
|
||||
|
||||
addFile(extendedFile);
|
||||
|
||||
// async processing
|
||||
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
extendedFile.width = img.width;
|
||||
extendedFile.height = img.height;
|
||||
|
||||
let max = 512;
|
||||
|
||||
if (resolution === 'high') {
|
||||
max = extendedFile.height > extendedFile.width ? 768 : 2000;
|
||||
}
|
||||
|
||||
const reducedBlob = await reducer.toBlob(originalFile, {
|
||||
max,
|
||||
});
|
||||
|
||||
const resizedFile = new File([reducedBlob], originalFile.name, {
|
||||
type: originalFile.type,
|
||||
});
|
||||
|
||||
const resizedPreview = URL.createObjectURL(resizedFile);
|
||||
extendedFile = {
|
||||
...extendedFile,
|
||||
file: resizedFile,
|
||||
};
|
||||
|
||||
const resizedImg = new Image();
|
||||
resizedImg.onload = async () => {
|
||||
extendedFile = {
|
||||
...extendedFile,
|
||||
file: resizedFile,
|
||||
width: resizedImg.width,
|
||||
height: resizedImg.height,
|
||||
progress: 0.6,
|
||||
};
|
||||
|
||||
replaceFile(extendedFile);
|
||||
URL.revokeObjectURL(resizedPreview); // Clean up the object URL
|
||||
await uploadFile(extendedFile);
|
||||
};
|
||||
resizedImg.src = resizedPreview;
|
||||
URL.revokeObjectURL(preview); // Clean up the original object URL
|
||||
|
||||
/* TODO: send to backend server /api/files
|
||||
use React Query Mutation to upload file (TypeScript), we need to make the CommonJS api endpoint (expressjs) to accept file upload
|
||||
server needs the image file, which the server will convert to base64 to send to external API
|
||||
server will then employ a 'saving' or 'caching' strategy based on admin configuration (can be local, CDN, etc.)
|
||||
the expressjs server needs the following:
|
||||
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
width,
|
||||
height,
|
||||
|
||||
use onSuccess, onMutate handlers to update the file progress
|
||||
|
||||
we need the full api handling for this, including the server-side
|
||||
|
||||
*/
|
||||
};
|
||||
img.src = preview;
|
||||
} catch (error) {
|
||||
console.log('file handling error', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.target.files) {
|
||||
setFilesLoading(true);
|
||||
handleFiles(event.target.files);
|
||||
// reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleFileChange,
|
||||
handleFiles,
|
||||
files,
|
||||
setFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFileHandling;
|
||||
|
|
@ -24,7 +24,7 @@ export default function useGenerations({
|
|||
const isEditableEndpoint = !![
|
||||
EModelEndpoint.openAI,
|
||||
EModelEndpoint.google,
|
||||
EModelEndpoint.assistant,
|
||||
EModelEndpoint.assistants,
|
||||
EModelEndpoint.anthropic,
|
||||
EModelEndpoint.gptPlugins,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function useGenerationsByLatest({
|
|||
EModelEndpoint.openAI,
|
||||
EModelEndpoint.custom,
|
||||
EModelEndpoint.google,
|
||||
EModelEndpoint.assistant,
|
||||
EModelEndpoint.assistants,
|
||||
EModelEndpoint.anthropic,
|
||||
EModelEndpoint.gptPlugins,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback } from 'react';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, FileSources, defaultOrderQuery } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
useSetRecoilState,
|
||||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
TEndpointsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { buildDefaultConvo, getDefaultEndpoint, getEndpointField } from '~/utils';
|
||||
import { useDeleteFilesMutation } from '~/data-provider';
|
||||
import { useDeleteFilesMutation, useListAssistantsQuery } from '~/data-provider';
|
||||
import useOriginNavigate from './useOriginNavigate';
|
||||
import useSetStorage from './useSetStorage';
|
||||
import store from '~/store';
|
||||
|
|
@ -31,6 +31,10 @@ const useNewConvo = (index = 0) => {
|
|||
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
|
||||
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const { data: assistants = [] } = useListAssistantsQuery(defaultOrderQuery, {
|
||||
select: (res) => res.data.map(({ id, name, metadata }) => ({ id, name, metadata })),
|
||||
});
|
||||
|
||||
const { mutateAsync } = useDeleteFilesMutation({
|
||||
onSuccess: () => {
|
||||
console.log('Files deleted');
|
||||
|
|
@ -44,7 +48,7 @@ const useNewConvo = (index = 0) => {
|
|||
({ snapshot }) =>
|
||||
async (
|
||||
conversation: TConversation,
|
||||
preset: TPreset | null = null,
|
||||
preset: Partial<TPreset> | null = null,
|
||||
modelsData?: TModelsConfig,
|
||||
buildDefault?: boolean,
|
||||
keepLatestMessage?: boolean,
|
||||
|
|
@ -75,6 +79,12 @@ const useNewConvo = (index = 0) => {
|
|||
conversation.endpointType = endpointType;
|
||||
}
|
||||
|
||||
if (!conversation.assistant_id && defaultEndpoint === EModelEndpoint.assistants) {
|
||||
const assistant_id =
|
||||
localStorage.getItem(`assistant_id__${index}`) ?? assistants[0]?.id;
|
||||
conversation.assistant_id = assistant_id;
|
||||
}
|
||||
|
||||
const models = modelsConfig?.[defaultEndpoint] ?? [];
|
||||
conversation = buildDefaultConvo({
|
||||
conversation,
|
||||
|
|
@ -99,7 +109,7 @@ const useNewConvo = (index = 0) => {
|
|||
navigate('new');
|
||||
}
|
||||
},
|
||||
[endpointsConfig, defaultPreset],
|
||||
[endpointsConfig, defaultPreset, assistants],
|
||||
);
|
||||
|
||||
const newConversation = useCallback(
|
||||
|
|
@ -111,7 +121,7 @@ const useNewConvo = (index = 0) => {
|
|||
keepLatestMessage = false,
|
||||
}: {
|
||||
template?: Partial<TConversation>;
|
||||
preset?: TPreset;
|
||||
preset?: Partial<TPreset>;
|
||||
modelsData?: TModelsConfig;
|
||||
buildDefault?: boolean;
|
||||
keepLatestMessage?: boolean;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import store from '~/store';
|
|||
|
||||
type TUseSetOptions = (preset?: TPreset | boolean | null) => TSetOptionsPayload;
|
||||
|
||||
const useSetOptions: TUseSetOptions = (preset = false) => {
|
||||
const useSetIndexOptions: TUseSetOptions = (preset = false) => {
|
||||
const setShowPluginStoreDialog = useSetRecoilState(store.showPluginStoreDialog);
|
||||
const availableTools = useRecoilValue(store.availableTools);
|
||||
const { conversation, setConversation } = useChatContext();
|
||||
|
|
@ -40,6 +40,12 @@ const useSetOptions: TUseSetOptions = (preset = false) => {
|
|||
setLastModel(lastModelUpdate);
|
||||
} else if (param === 'jailbreak' && endpoint) {
|
||||
setLastBingSettings({ ...lastBingSettings, jailbreak: newValue });
|
||||
} else if (param === 'presetOverride') {
|
||||
const currentOverride = conversation?.presetOverride || {};
|
||||
update['presetOverride'] = {
|
||||
...currentOverride,
|
||||
...(newValue as unknown as Partial<TPreset>),
|
||||
};
|
||||
}
|
||||
|
||||
setConversation(
|
||||
|
|
@ -133,7 +139,7 @@ const useSetOptions: TUseSetOptions = (preset = false) => {
|
|||
);
|
||||
};
|
||||
|
||||
const setTools: (newValue: string) => void = (newValue) => {
|
||||
const setTools: (newValue: string, remove?: boolean) => void = (newValue, remove) => {
|
||||
if (newValue === 'pluginStore') {
|
||||
setShowPluginStoreDialog(true);
|
||||
return;
|
||||
|
|
@ -144,7 +150,7 @@ const useSetOptions: TUseSetOptions = (preset = false) => {
|
|||
const isSelected = checkPluginSelection(newValue);
|
||||
const tool =
|
||||
availableTools[availableTools.findIndex((el: TPlugin) => el.pluginKey === newValue)];
|
||||
if (isSelected) {
|
||||
if (isSelected || remove) {
|
||||
update['tools'] = current.filter((el) => el.pluginKey !== newValue);
|
||||
} else {
|
||||
update['tools'] = [...current, tool];
|
||||
|
|
@ -171,4 +177,4 @@ const useSetOptions: TUseSetOptions = (preset = false) => {
|
|||
};
|
||||
};
|
||||
|
||||
export default useSetOptions;
|
||||
export default useSetIndexOptions;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue