From 156c52e293573b7e3990ab2137f14ab5a857e95b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 25 Jun 2024 03:02:38 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=BF=20feat:=20Multi-response=20Streami?= =?UTF-8?q?ng=20(#3191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: comment back handlePlusCommand * chore: ignore .git dir * refactor: pass newConversation to `useSelectMention` refactor: pass newConversation to Mention component refactor: useChatFunctions for modular use of `ask` and `regenerate` refactor: set latest message only for the first index in useChatFunctions refactor: pass setLatestMessage to useChatFunctions refactor: Pass setSubmission to useChatFunctions for submission handling refactor: consolidate event handlers to separate hook from useSSE WIP: additional response handlers feat: responsive added convo, clears on new chat/navigating to chat, assistants excluded feat: Add conversationByKeySelector to select any conversation by index WIP: handle second submission with messages paired to root * style: surface-primary-contrast * refactor: remove unnecessary console.log statement in useChatFunctions * refactor: Consolidate imports in ChatForm and Input hooks * refactor: compositional usage of useSSE for multiple streams * WIP: set latest 'multi' message * WIP: first pass, added response streaming * pass: performant multi-message stream * fix: styling and message render * second pass: modular, performant multi-stream * fix: align parentMessageId of multiMessage * refactor: move resetting latestMultiMessage * chore: update footer text in Chat component * fix: stop button styling * fix: handle abortMessage request for multi-response * clear messages but bug with latest message reset present * fix: add delay for additional message generation * fix: access LAST_CONVO_SETUP by index * style: add div to prevent layout shift before hover buttons render * chore: Update Message component styling for card messages * chore: move hook use order * fix: abort middleware using unsent field from req.body * feat: support multi-response stream from initial message * refactor: buildTree function to improve readability and remove unused code * feat: add logger for frontend dev * refactor: use depth to track if message is really last in its branch * fix(buildTree): default export * fix: share parent message Id and avoid duplication error for multi-response streams * fix: prevent addedConvo reset to response convo * feat: allow setting multi message as latest message to control which to respond to * chore: wrap setSiblingIdxRev with useCallback * chore: styling and allow editing messages * style: styling fixes * feat: Add "AddMultiConvo" component to Chat Header * feat: prevent clearing added convos on endpoint, preset, mention, or modelSpec switch * fix: message styling fixes, mainly related to code blocks * fix: stop button visibility logic * fix: Handle edge case in abortMiddleware for non-existant `abortControllers` * refactor: optimize/memoize icons * chore(GoogleClient): change info to debug logs * style: active message styling * style: prevent layout shift due to placeholder row * chore: remove unused code * fix: Update BaseClient to handle optional request body properties * fix(ci): `onStart` now accepts 2 args, the 2nd being responseMessageId * chore: bump data-provider --- .gitignore | 2 + api/app/clients/BaseClient.js | 42 +- api/app/clients/GoogleClient.js | 8 +- api/app/clients/PluginsClient.js | 5 +- api/app/clients/specs/BaseClient.test.js | 6 +- api/server/controllers/AskController.js | 6 +- api/server/controllers/EditController.js | 2 +- api/server/middleware/abortMiddleware.js | 43 +- api/server/routes/ask/gptPlugins.js | 22 +- api/server/routes/edit/gptPlugins.js | 32 +- client/src/Providers/AddedChatContext.tsx | 6 + client/src/Providers/ChatContext.tsx | 2 +- client/src/Providers/index.ts | 1 + client/src/common/types.ts | 30 +- client/src/components/Chat/AddMultiConvo.tsx | 47 ++ client/src/components/Chat/ChatView.tsx | 52 +- client/src/components/Chat/Footer.tsx | 2 +- client/src/components/Chat/Header.tsx | 2 + .../src/components/Chat/Input/AddedConvo.tsx | 66 +++ client/src/components/Chat/Input/ChatForm.tsx | 52 +- client/src/components/Chat/Input/Mention.tsx | 19 +- .../src/components/Chat/Input/StopButton.tsx | 52 +- .../components/Chat/Input/TextareaHeader.tsx | 20 + .../Chat/Menus/Endpoints/MenuItem.tsx | 19 +- .../Chat/Menus/Models/ModelSpecsMenu.tsx | 20 +- .../Chat/Messages/Content/EditMessage.tsx | 41 +- .../src/components/Chat/Messages/Message.tsx | 296 ++++++---- .../components/Chat/Messages/MessageIcon.tsx | 6 +- .../components/Chat/Messages/MessageParts.tsx | 4 +- .../components/Chat/Messages/MultiMessage.tsx | 11 +- client/src/components/Endpoints/Icon.tsx | 9 +- client/src/data-provider/mutations.ts | 12 +- client/src/hooks/Chat/index.ts | 4 + client/src/hooks/Chat/useAddedHelpers.ts | 128 +++++ client/src/hooks/Chat/useAddedResponse.ts | 39 ++ client/src/hooks/Chat/useChatFunctions.ts | 264 +++++++++ client/src/hooks/Chat/useChatHelpers.ts | 182 ++++++ client/src/hooks/Conversations/index.ts | 1 + .../hooks/Conversations/useGenerateConvo.ts | 147 +++++ .../Conversations/useNavigateToConvo.tsx | 10 +- client/src/hooks/Conversations/usePresets.ts | 50 +- client/src/hooks/Input/index.ts | 1 + client/src/hooks/Input/useHandleKeyUp.ts | 39 +- client/src/hooks/Input/useMentions.ts | 26 +- client/src/hooks/Input/useSelectMention.ts | 53 +- client/src/hooks/Messages/index.ts | 2 + client/src/hooks/Messages/useAvatar.ts | 63 +- .../src/hooks/Messages/useMessageActions.tsx | 95 +++ .../src/hooks/Messages/useMessageProcess.tsx | 88 +++ client/src/hooks/Messages/useSubmitMessage.ts | 61 +- client/src/hooks/SSE/useEventHandlers.ts | 544 ++++++++++++++++++ client/src/hooks/SSE/useSSE.ts | 536 ++--------------- client/src/hooks/index.ts | 4 +- client/src/hooks/useChatHelpers.ts | 373 ------------ client/src/hooks/useNewConvo.ts | 29 +- client/src/localization/languages/Eng.ts | 2 + client/src/store/families.ts | 138 ++++- client/src/style.css | 4 +- client/src/utils/buildTree.ts | 96 ++-- client/src/utils/endpoints.ts | 8 +- client/src/utils/getLocalStorageItems.ts | 2 +- client/src/utils/index.ts | 1 + client/src/utils/logger.ts | 37 ++ client/src/utils/textarea.ts | 9 +- client/src/vite-env.d.ts | 10 + client/tailwind.config.cjs | 1 + package-lock.json | 2 +- packages/data-provider/package.json | 2 +- packages/data-provider/src/config.ts | 4 + .../src/react-query/react-query-service.ts | 2 + packages/data-provider/src/schemas.ts | 2 + packages/data-provider/src/types.ts | 3 + 72 files changed, 2685 insertions(+), 1314 deletions(-) create mode 100644 client/src/Providers/AddedChatContext.tsx create mode 100644 client/src/components/Chat/AddMultiConvo.tsx create mode 100644 client/src/components/Chat/Input/AddedConvo.tsx create mode 100644 client/src/components/Chat/Input/TextareaHeader.tsx create mode 100644 client/src/hooks/Chat/index.ts create mode 100644 client/src/hooks/Chat/useAddedHelpers.ts create mode 100644 client/src/hooks/Chat/useAddedResponse.ts create mode 100644 client/src/hooks/Chat/useChatFunctions.ts create mode 100644 client/src/hooks/Chat/useChatHelpers.ts create mode 100644 client/src/hooks/Conversations/useGenerateConvo.ts create mode 100644 client/src/hooks/Messages/useMessageActions.tsx create mode 100644 client/src/hooks/Messages/useMessageProcess.tsx create mode 100644 client/src/hooks/SSE/useEventHandlers.ts delete mode 100644 client/src/hooks/useChatHelpers.ts create mode 100644 client/src/utils/logger.ts create mode 100644 client/src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index 3a01fc80176..a80c13a745a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ logs pids *.pid *.seed +.git # Directory for instrumented libs generated by jscoverage/JSCover lib-cov @@ -45,6 +46,7 @@ api/node_modules/ client/node_modules/ bower_components/ *.d.ts +!vite-env.d.ts # Floobits .floo diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 949c9bb68cf..d335272e950 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -19,6 +19,10 @@ class BaseClient { day: 'numeric', }); this.fetch = this.fetch.bind(this); + /** @type {boolean} */ + this.skipSaveConvo = false; + /** @type {boolean} */ + this.skipSaveUserMessage = false; } setOptions() { @@ -84,19 +88,45 @@ class BaseClient { await stream.processTextStream(onProgress); } + /** + * @returns {[string|undefined, string|undefined]} + */ + processOverideIds() { + /** @type {Record} */ + let { overrideConvoId, overrideUserMessageId } = this.options?.req?.body ?? {}; + if (overrideConvoId) { + const [conversationId, index] = overrideConvoId.split(Constants.COMMON_DIVIDER); + overrideConvoId = conversationId; + if (index !== '0') { + this.skipSaveConvo = true; + } + } + if (overrideUserMessageId) { + const [userMessageId, index] = overrideUserMessageId.split(Constants.COMMON_DIVIDER); + overrideUserMessageId = userMessageId; + if (index !== '0') { + this.skipSaveUserMessage = true; + } + } + + return [overrideConvoId, overrideUserMessageId]; + } + async setMessageOptions(opts = {}) { if (opts && opts.replaceOptions) { this.setOptions(opts); } + const [overrideConvoId, overrideUserMessageId] = this.processOverideIds(); const { isEdited, isContinued } = opts; const user = opts.user ?? null; this.user = user; const saveOptions = this.getSaveOptions(); this.abortController = opts.abortController ?? new AbortController(); - const conversationId = opts.conversationId ?? crypto.randomUUID(); + const conversationId = overrideConvoId ?? opts.conversationId ?? crypto.randomUUID(); const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT; - const userMessageId = opts.overrideParentMessageId ?? crypto.randomUUID(); + const userMessageId = + overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID(); let responseMessageId = opts.responseMessageId ?? crypto.randomUUID(); let head = isEdited ? responseMessageId : parentMessageId; this.currentMessages = (await this.loadHistory(conversationId, head)) ?? []; @@ -160,7 +190,7 @@ class BaseClient { } if (typeof opts?.onStart === 'function') { - opts.onStart(userMessage); + opts.onStart(userMessage, responseMessageId); } return { @@ -450,7 +480,7 @@ class BaseClient { this.handleTokenCountMap(tokenCountMap); } - if (!isEdited) { + if (!isEdited && !this.skipSaveUserMessage) { await this.saveMessageToDatabase(userMessage, saveOptions, user); } @@ -569,6 +599,10 @@ class BaseClient { unfinished: false, user, }); + + if (this.skipSaveConvo) { + return; + } await saveConvo(user, { conversationId: message.conversationId, endpoint: this.options.endpoint, diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 8e42d59e926..a01df718416 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -737,7 +737,7 @@ class GoogleClient extends BaseClient { let clientOptions = { ...parameters, maxRetries: 2 }; - logger.info('Initialized title client options'); + logger.debug('Initialized title client options'); if (this.project_id) { clientOptions['authOptions'] = { @@ -764,7 +764,7 @@ class GoogleClient extends BaseClient { const modelName = clientOptions.modelName ?? clientOptions.model ?? ''; if (modelName?.includes('1.5') && !this.project_id) { - logger.info('Identified titling model as 1.5 version'); + logger.debug('Identified titling model as 1.5 version'); /** @type {GenerativeModel} */ const client = model; const requestOptions = { @@ -790,7 +790,7 @@ class GoogleClient extends BaseClient { return reply; } else { - logger.info('Beginning titling'); + logger.debug('Beginning titling'); const safetySettings = _payload.safetySettings; const titleResponse = await model.invoke(messages, { @@ -840,7 +840,7 @@ class GoogleClient extends BaseClient { } catch (e) { logger.error('[GoogleClient] There was an issue generating the title', e); } - logger.info(`Title response: ${title}`); + logger.debug(`Title response: ${title}`); return title; } diff --git a/api/app/clients/PluginsClient.js b/api/app/clients/PluginsClient.js index e321cb351e5..123890dfb8a 100644 --- a/api/app/clients/PluginsClient.js +++ b/api/app/clients/PluginsClient.js @@ -301,7 +301,10 @@ class PluginsClient extends OpenAIClient { if (payload) { this.currentMessages = payload; } - await this.saveMessageToDatabase(userMessage, saveOptions, user); + + if (!this.skipSaveUserMessage) { + await this.saveMessageToDatabase(userMessage, saveOptions, user); + } if (isEnabled(process.env.CHECK_BALANCE)) { await checkBalance({ diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 9ffa7e04f1b..41138cdb1e6 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -576,7 +576,11 @@ describe('BaseClient', () => { const onStart = jest.fn(); const opts = { onStart }; await TestClient.sendMessage('Hello, world!', opts); - expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ text: 'Hello, world!' })); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ text: 'Hello, world!' }), + expect.any(String), + ); }); test('saveMessageToDatabase is called with the correct arguments', async () => { diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index 48e79cf0da4..f6da236929b 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -81,7 +81,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { promptTokens, }); - const { abortController, onStart } = createAbortController(req, res, getAbortData); + const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); res.on('close', () => { logger.debug('[AskController] Request closed'); @@ -144,7 +144,9 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { await saveMessage({ ...response, user }); } - await saveMessage(userMessage); + if (!client.skipSaveUserMessage) { + await saveMessage(userMessage); + } if (addTitle && parentMessageId === Constants.NO_PARENT && newConvo) { addTitle(req, { diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index 165245f7b0c..5a2d71d1b7f 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -81,7 +81,7 @@ const EditController = async (req, res, next, initializeClient) => { promptTokens, }); - const { abortController, onStart } = createAbortController(req, res, getAbortData); + const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); res.on('close', () => { logger.debug('[EditController] Request closed'); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index 69df9619ccd..f0eabddd75f 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -9,23 +9,28 @@ const { abortRun } = require('./abortRun'); const { logger } = require('~/config'); async function abortMessage(req, res) { - let { abortKey, conversationId, endpoint } = req.body; - - if (!abortKey && conversationId) { - abortKey = conversationId; - } + let { abortKey, endpoint } = req.body; if (isAssistantsEndpoint(endpoint)) { return await abortRun(req, res); } + const conversationId = abortKey?.split(':')?.[0] ?? req.user.id; + + if (!abortControllers.has(abortKey) && abortControllers.has(conversationId)) { + abortKey = conversationId; + } + if (!abortControllers.has(abortKey) && !res.headersSent) { return res.status(204).send({ message: 'Request not found' }); } - const { abortController } = abortControllers.get(abortKey); + const { abortController } = abortControllers.get(abortKey) ?? {}; + if (!abortController) { + return res.status(204).send({ message: 'Request not found' }); + } const finalEvent = await abortController.abortCompletion(); - logger.debug('[abortMessage] Aborted request', { abortKey }); + logger.info('[abortMessage] Aborted request', { abortKey }); abortControllers.delete(abortKey); if (res.headersSent && finalEvent) { @@ -50,12 +55,32 @@ const handleAbort = () => { }; }; -const createAbortController = (req, res, getAbortData) => { +const createAbortController = (req, res, getAbortData, getReqData) => { const abortController = new AbortController(); const { endpointOption } = req.body; - const onStart = (userMessage) => { + + abortController.getAbortData = function () { + return getAbortData(); + }; + + /** + * @param {TMessage} userMessage + * @param {string} responseMessageId + */ + const onStart = (userMessage, responseMessageId) => { sendMessage(res, { message: userMessage, created: true }); const abortKey = userMessage?.conversationId ?? req.user.id; + const prevRequest = abortControllers.get(abortKey); + if (prevRequest && prevRequest?.abortController) { + const data = prevRequest.abortController.getAbortData(); + getReqData({ userMessage: data?.userMessage }); + const addedAbortKey = `${abortKey}:${responseMessageId}`; + abortControllers.set(addedAbortKey, { abortController, ...endpointOption }); + res.on('finish', function () { + abortControllers.delete(addedAbortKey); + }); + return; + } abortControllers.set(abortKey, { abortController, ...endpointOption }); res.on('finish', function () { diff --git a/api/server/routes/ask/gptPlugins.js b/api/server/routes/ask/gptPlugins.js index 2acf4ce592e..66f15da0f89 100644 --- a/api/server/routes/ask/gptPlugins.js +++ b/api/server/routes/ask/gptPlugins.js @@ -148,15 +148,6 @@ router.post( } }; - const onChainEnd = () => { - saveMessage({ ...userMessage, user }); - sendIntermediateMessage(res, { - plugins, - parentMessageId: userMessage.messageId, - messageId: responseMessageId, - }); - }; - const getAbortData = () => ({ sender, conversationId, @@ -167,12 +158,23 @@ router.post( userMessage, promptTokens, }); - const { abortController, onStart } = createAbortController(req, res, getAbortData); + const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); try { endpointOption.tools = await validateTools(user, endpointOption.tools); const { client } = await initializeClient({ req, res, endpointOption }); + const onChainEnd = () => { + if (!client.skipSaveUserMessage) { + saveMessage({ ...userMessage, user }); + } + sendIntermediateMessage(res, { + plugins, + parentMessageId: userMessage.messageId, + messageId: responseMessageId, + }); + }; + let response = await client.sendMessage(text, { user, conversationId, diff --git a/api/server/routes/edit/gptPlugins.js b/api/server/routes/edit/gptPlugins.js index cf71d487dc8..6fc2e4b1f07 100644 --- a/api/server/routes/edit/gptPlugins.js +++ b/api/server/routes/edit/gptPlugins.js @@ -103,21 +103,6 @@ router.post( }, }); - const onAgentAction = (action, start = false) => { - const formattedAction = formatAction(action); - plugin.inputs.push(formattedAction); - plugin.latest = formattedAction.plugin; - if (!start) { - saveMessage({ ...userMessage, user }); - } - sendIntermediateMessage(res, { - plugin, - parentMessageId: userMessage.messageId, - messageId: responseMessageId, - }); - // logger.debug('PLUGIN ACTION', formattedAction); - }; - const onChainEnd = (data) => { let { intermediateSteps: steps } = data; plugin.outputs = steps && steps[0].action ? formatSteps(steps) : 'An error occurred.'; @@ -141,12 +126,27 @@ router.post( userMessage, promptTokens, }); - const { abortController, onStart } = createAbortController(req, res, getAbortData); + const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); try { endpointOption.tools = await validateTools(user, endpointOption.tools); const { client } = await initializeClient({ req, res, endpointOption }); + const onAgentAction = (action, start = false) => { + const formattedAction = formatAction(action); + plugin.inputs.push(formattedAction); + plugin.latest = formattedAction.plugin; + if (!start && !client.skipSaveUserMessage) { + saveMessage({ ...userMessage, user }); + } + sendIntermediateMessage(res, { + plugin, + parentMessageId: userMessage.messageId, + messageId: responseMessageId, + }); + // logger.debug('PLUGIN ACTION', formattedAction); + }; + let response = await client.sendMessage(text, { user, generation, diff --git a/client/src/Providers/AddedChatContext.tsx b/client/src/Providers/AddedChatContext.tsx new file mode 100644 index 00000000000..9f656debe1f --- /dev/null +++ b/client/src/Providers/AddedChatContext.tsx @@ -0,0 +1,6 @@ +import { createContext, useContext } from 'react'; +import useAddedResponse from '~/hooks/Chat/useAddedResponse'; +type TAddedChatContext = ReturnType; + +export const AddedChatContext = createContext({} as TAddedChatContext); +export const useAddedChatContext = () => useContext(AddedChatContext); diff --git a/client/src/Providers/ChatContext.tsx b/client/src/Providers/ChatContext.tsx index 0c688011161..3d3acbcc42c 100644 --- a/client/src/Providers/ChatContext.tsx +++ b/client/src/Providers/ChatContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import useChatHelpers from '~/hooks/useChatHelpers'; +import useChatHelpers from '~/hooks/Chat/useChatHelpers'; type TChatContext = ReturnType; export const ChatContext = createContext({} as TChatContext); diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 836bbde90b4..81de0f129d0 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -5,6 +5,7 @@ export * from './ShareContext'; export * from './ToastContext'; export * from './SearchContext'; export * from './FileMapContext'; +export * from './AddedChatContext'; export * from './ChatFormContext'; export * from './DashboardContext'; export * from './AssistantsContext'; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index c7afb6872ce..8ae86a9c7e6 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { FileSources, SystemRoles } from 'librechat-data-provider'; +import { FileSources } from 'librechat-data-provider'; import type * as InputNumberPrimitive from 'rc-input-number'; import type { ColumnDef } from '@tanstack/react-table'; import type { SetterOrUpdater } from 'recoil'; @@ -11,8 +11,10 @@ import type { TPlugin, TMessage, Assistant, + TResPlugin, TLoginUser, AuthTypeEnum, + TModelsConfig, TConversation, TStartupConfig, EModelEndpoint, @@ -229,6 +231,8 @@ export type TGenButtonProps = { export type TAskProps = { text: string; + overrideConvoId?: string; + overrideUserMessageId?: string; parentMessageId?: string | null; conversationId?: string | null; messageId?: string | null; @@ -241,6 +245,7 @@ export type TOptions = { isRegenerate?: boolean; isContinued?: boolean; isEdited?: boolean; + overrideMessages?: TMessage[]; }; export type TAskFunction = (props: TAskProps, options?: TOptions) => void; @@ -409,6 +414,29 @@ export type TLoginLayoutContext = { headerText: string; setHeaderText: React.Dispatch>; }; + +export type NewConversationParams = { + template?: Partial; + preset?: Partial; + modelsData?: TModelsConfig; + buildDefault?: boolean; + keepLatestMessage?: boolean; + keepAddedConvos?: boolean; +}; + +export type ConvoGenerator = (params: NewConversationParams) => void | TConversation; + +export type TResData = { + plugin?: TResPlugin; + final?: boolean; + initial?: boolean; + previousMessages?: TMessage[]; + requestMessage: TMessage; + responseMessage: TMessage; + conversation: TConversation; + conversationId?: string; + runMessages?: TMessage[]; +}; export type TVectorStore = { _id: string; object: 'vector_store'; diff --git a/client/src/components/Chat/AddMultiConvo.tsx b/client/src/components/Chat/AddMultiConvo.tsx new file mode 100644 index 00000000000..78f9f27a6ad --- /dev/null +++ b/client/src/components/Chat/AddMultiConvo.tsx @@ -0,0 +1,47 @@ +import { PlusCircle } from 'lucide-react'; +import { isAssistantsEndpoint } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; +import { useChatContext, useAddedChatContext } from '~/Providers'; +import { mainTextareaId } from '~/common'; +import { cn } from '~/utils'; + +function AddMultiConvo({ className = '' }: { className?: string }) { + const { conversation } = useChatContext(); + const { setConversation: setAddedConvo } = useAddedChatContext(); + + const clickHandler = () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { title: _t, ...convo } = conversation ?? ({} as TConversation); + setAddedConvo({ + ...convo, + title: '', + }); + + const textarea = document.getElementById(mainTextareaId); + if (textarea) { + textarea.focus(); + } + }; + + if (!conversation) { + return null; + } + + if (isAssistantsEndpoint(conversation.endpoint)) { + return null; + } + + return ( + + ); +} + +export default AddMultiConvo; diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index dfae014ea02..01082c2b386 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form'; import { useParams } from 'react-router-dom'; import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query'; import type { ChatFormValues } from '~/common'; -import { ChatContext, useFileMapContext, ChatFormProvider } from '~/Providers'; +import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers'; +import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks'; import MessagesView from './Messages/MessagesView'; -import { useChatHelpers, useSSE } from '~/hooks'; import { Spinner } from '~/components/svg'; import Presentation from './Presentation'; import ChatForm from './Input/ChatForm'; @@ -18,8 +18,8 @@ import store from '~/store'; function ChatView({ index = 0 }: { index?: number }) { const { conversationId } = useParams(); - const submissionAtIndex = useRecoilValue(store.submissionByIndex(0)); - useSSE(submissionAtIndex); + const rootSubmission = useRecoilValue(store.submissionByIndex(index)); + const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1)); const fileMap = useFileMapContext(); @@ -32,35 +32,35 @@ function ChatView({ index = 0 }: { index?: number }) { }); const chatHelpers = useChatHelpers(index, conversationId); + const addedChatHelpers = useAddedResponse({ rootIndex: index }); + + useSSE(rootSubmission, chatHelpers, false); + useSSE(addedSubmission, addedChatHelpers, true); + const methods = useForm({ defaultValues: { text: '' }, }); return ( - + - - {isLoading && conversationId !== 'new' ? ( -
- + + + {isLoading && conversationId !== 'new' ? ( +
+ +
+ ) : messagesTree && messagesTree.length !== 0 ? ( + } /> + ) : ( + } /> + )} +
+ +
- ) : messagesTree && messagesTree.length !== 0 ? ( - } /> - ) : ( - } /> - )} -
- -
-
-
+ +
); diff --git a/client/src/components/Chat/Footer.tsx b/client/src/components/Chat/Footer.tsx index 5f07bef375d..af170a96fc6 100644 --- a/client/src/components/Chat/Footer.tsx +++ b/client/src/components/Chat/Footer.tsx @@ -47,7 +47,7 @@ export default function Footer({ className }: { className?: string }) { : '[LibreChat ' + Constants.VERSION + '](https://librechat.ai) - ' + - localize('com_ui_pay_per_call') + localize('com_ui_latest_footer') ).split('|'); const mainContentRender = mainContentParts.map((text, index) => ( diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 0797cf5212e..d7c323de144 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -6,6 +6,7 @@ import type { ContextType } from '~/common'; import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus'; import ExportAndShareMenu from './ExportAndShareMenu'; import HeaderOptions from './Input/HeaderOptions'; +import AddMultiConvo from './AddMultiConvo'; import { useMediaQuery } from '~/hooks'; const defaultInterface = getConfigDefaults().interface; @@ -36,6 +37,7 @@ export default function Header() { className="pl-0" /> )} +
{!isSmallScreen && ( diff --git a/client/src/components/Chat/Input/AddedConvo.tsx b/client/src/components/Chat/Input/AddedConvo.tsx new file mode 100644 index 00000000000..e3cacb9f4bf --- /dev/null +++ b/client/src/components/Chat/Input/AddedConvo.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; +import type { TConversation, TEndpointOption, TPreset } from 'librechat-data-provider'; +import type { SetterOrUpdater } from 'recoil'; +import useGetSender from '~/hooks/Conversations/useGetSender'; +import { EndpointIcon } from '~/components/Endpoints'; +import { getPresetTitle } from '~/utils'; + +export default function AddedConvo({ + addedConvo, + setAddedConvo, +}: { + addedConvo: TConversation | null; + setAddedConvo: SetterOrUpdater; +}) { + const getSender = useGetSender(); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const title = useMemo(() => { + const sender = getSender(addedConvo as TEndpointOption); + const title = getPresetTitle(addedConvo as TPreset); + return `+ ${sender}: ${title}`; + }, [addedConvo, getSender]); + + if (!addedConvo) { + return null; + } + return ( +
+ +
+ +
+
+ + {title} + + +
+ ); +} diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 9f1fd47788e..e86221d5e1a 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -6,12 +6,23 @@ import { isAssistantsEndpoint, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; -import { useChatContext, useAssistantsMapContext, useChatFormContext } from '~/Providers'; -import { useRequiresKey, useTextarea, useSubmitMessage, useHandleKeyUp } from '~/hooks'; -import { useAutoSave } from '~/hooks/Input/useAutoSave'; +import { + useChatContext, + useAddedChatContext, + useAssistantsMapContext, + useChatFormContext, +} from '~/Providers'; +import { + useTextarea, + useAutoSave, + useRequiresKey, + useHandleKeyUp, + useSubmitMessage, +} from '~/hooks'; import { TextareaAutosize } from '~/components/ui'; import { useGetFileConfig } from '~/data-provider'; import { cn, removeFocusRings } from '~/utils'; +import TextareaHeader from './TextareaHeader'; import AttachFile from './Files/AttachFile'; import AudioRecorder from './AudioRecorder'; import { mainTextareaId } from '~/common'; @@ -25,16 +36,19 @@ import store from '~/store'; const ChatForm = ({ index = 0 }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); + const SpeechToText = useRecoilValue(store.SpeechToText); const TextToSpeech = useRecoilValue(store.TextToSpeech); const automaticPlayback = useRecoilValue(store.automaticPlayback); + const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index)); + const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index)); const [showMentionPopover, setShowMentionPopover] = useRecoilState( store.showMentionPopoverFamily(index), ); - const { requiresKey } = useRequiresKey(); - const handleKeyUp = useHandleKeyUp({ index, textAreaRef }); + const { requiresKey } = useRequiresKey(); + const handleKeyUp = useHandleKeyUp({ textAreaRef, setShowPlusPopover, setShowMentionPopover }); const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({ textAreaRef, submitButtonRef, @@ -48,9 +62,18 @@ const ChatForm = ({ index = 0 }) => { isSubmitting, filesLoading, setFilesLoading, + newConversation, handleStopGenerating, } = useChatContext(); const methods = useChatFormContext(); + const { + addedIndex, + generateConversation, + conversation: addedConvo, + setConversation: setAddedConvo, + isSubmitting: isSubmittingAdded, + } = useAddedChatContext(); + const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex)); const { clearDraft } = useAutoSave({ conversationId: useMemo(() => conversation?.conversationId, [conversation]), @@ -96,10 +119,25 @@ const ChatForm = ({ index = 0 }) => { >
+ {showPlusPopover && !isAssistantsEndpoint(endpoint) && ( + + )} {showMentionPopover && ( - + )}
+ { endpointType={endpointType} disabled={disableInputs} /> - {isSubmitting && showStopButton ? ( + {(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? ( ) : ( endpoint && ( diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx index 31604ba1943..ba2821a7f02 100644 --- a/client/src/components/Chat/Input/Mention.tsx +++ b/client/src/components/Chat/Input/Mention.tsx @@ -1,30 +1,39 @@ import { useState, useRef, useEffect } from 'react'; import { EModelEndpoint } from 'librechat-data-provider'; import type { SetterOrUpdater } from 'recoil'; -import type { MentionOption } from '~/common'; +import type { MentionOption, ConvoGenerator } from '~/common'; import useSelectMention from '~/hooks/Input/useSelectMention'; import { useAssistantsMapContext } from '~/Providers'; import useMentions from '~/hooks/Input/useMentions'; import { useLocalize, useCombobox } from '~/hooks'; -import { removeAtSymbolIfLast } from '~/utils'; +import { removeCharIfLast } from '~/utils'; import MentionItem from './MentionItem'; export default function Mention({ setShowMentionPopover, + newConversation, textAreaRef, + commandChar = '@', + placeholder = 'com_ui_mention', + includeAssistants = true, }: { setShowMentionPopover: SetterOrUpdater; + newConversation: ConvoGenerator; textAreaRef: React.MutableRefObject; + commandChar?: string; + placeholder?: string; + includeAssistants?: boolean; }) { const localize = useLocalize(); const assistantMap = useAssistantsMapContext(); const { options, presets, modelSpecs, modelsConfig, endpointsConfig, assistantListMap } = - useMentions({ assistantMap }); + useMentions({ assistantMap, includeAssistants }); const { onSelectMention } = useSelectMention({ presets, modelSpecs, assistantMap, endpointsConfig, + newConversation, }); const [activeIndex, setActiveIndex] = useState(0); @@ -49,7 +58,7 @@ export default function Mention({ onSelectMention(mention); if (textAreaRef.current) { - removeAtSymbolIfLast(textAreaRef.current); + removeCharIfLast(textAreaRef.current, commandChar); } }; @@ -105,7 +114,7 @@ export default function Mention({ -
-
- -
-
+
+
); } diff --git a/client/src/components/Chat/Input/TextareaHeader.tsx b/client/src/components/Chat/Input/TextareaHeader.tsx new file mode 100644 index 00000000000..e4df93c9937 --- /dev/null +++ b/client/src/components/Chat/Input/TextareaHeader.tsx @@ -0,0 +1,20 @@ +import AddedConvo from './AddedConvo'; +import type { TConversation } from 'librechat-data-provider'; +import type { SetterOrUpdater } from 'recoil'; + +export default function TextareaHeader({ + addedConvo, + setAddedConvo, +}: { + addedConvo: TConversation | null; + setAddedConvo: SetterOrUpdater; +}) { + if (!addedConvo) { + return null; + } + return ( +
+ +
+ ); +} diff --git a/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx b/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx index 0fe08b5c79e..718af823fbf 100644 --- a/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/MenuItem.tsx @@ -50,12 +50,12 @@ const MenuItem: FC = ({ } const { + template, shouldSwitch, isNewModular, + newEndpointType, isCurrentModular, isExistingConversation, - newEndpointType, - template, } = getConvoSwitchLogic({ newEndpoint, modularChat, @@ -63,7 +63,8 @@ const MenuItem: FC = ({ endpointsConfig, }); - if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) { + const isModular = isCurrentModular && isNewModular && shouldSwitch; + if (isExistingConversation && isModular) { template.endpointType = newEndpointType; const currentConvo = getDefaultConversation({ @@ -73,10 +74,18 @@ const MenuItem: FC = ({ }); /* We don't reset the latest message, only when changing settings mid-converstion */ - newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true }); + newConversation({ + template: currentConvo, + preset: currentConvo, + keepLatestMessage: true, + keepAddedConvos: true, + }); return; } - newConversation({ template: { ...(template as Partial) } }); + newConversation({ + template: { ...(template as Partial) }, + keepAddedConvos: isModular, + }); }; const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); diff --git a/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx b/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx index cc1fb2d1d99..53d5aeaeaa9 100644 --- a/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx +++ b/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx @@ -29,12 +29,12 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[ } const { + template, shouldSwitch, isNewModular, + newEndpointType, isCurrentModular, isExistingConversation, - newEndpointType, - template, } = getConvoSwitchLogic({ newEndpoint, modularChat, @@ -42,7 +42,8 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[ endpointsConfig, }); - if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) { + const isModular = isCurrentModular && isNewModular && shouldSwitch; + if (isExistingConversation && isModular) { template.endpointType = newEndpointType as EModelEndpoint | undefined; const currentConvo = getDefaultConversation({ @@ -52,11 +53,20 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[ }); /* We don't reset the latest message, only when changing settings mid-converstion */ - newConversation({ template: currentConvo, preset, keepLatestMessage: true }); + newConversation({ + template: currentConvo, + preset, + keepLatestMessage: true, + keepAddedConvos: true, + }); return; } - newConversation({ template: { ...(template as Partial) }, preset }); + newConversation({ + template: { ...(template as Partial) }, + preset, + keepAddedConvos: isModular, + }); }; const selected = useMemo(() => { diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx index a439eab5612..64e3bead670 100644 --- a/client/src/components/Chat/Messages/Content/EditMessage.tsx +++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx @@ -1,12 +1,14 @@ +import { useRecoilState } from 'recoil'; import TextareaAutosize from 'react-textarea-autosize'; import { EModelEndpoint } from 'librechat-data-provider'; import { useState, useRef, useEffect, useCallback } from 'react'; import { useUpdateMessageMutation } from 'librechat-data-provider/react-query'; import type { TEditProps } from '~/common'; +import { useChatContext, useAddedChatContext } from '~/Providers'; import { cn, removeFocusRings } from '~/utils'; -import { useChatContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import Container from './Container'; +import store from '~/store'; const EditMessage = ({ text, @@ -17,7 +19,11 @@ const EditMessage = ({ siblingIdx, setSiblingIdx, }: TEditProps) => { + const { addedIndex } = useAddedChatContext(); const { getMessages, setMessages, conversation } = useChatContext(); + const [latestMultiMessage, setLatestMultiMessage] = useRecoilState( + store.latestMessageFamily(addedIndex), + ); const [editedText, setEditedText] = useState(text ?? ''); const textAreaRef = useRef(null); @@ -85,17 +91,28 @@ const EditMessage = ({ text: editedText, messageId, }); - setMessages( - messages.map((msg) => - msg.messageId === messageId - ? { - ...msg, - text: editedText, - isEdited: true, - } - : msg, - ), - ); + + if (message.messageId === latestMultiMessage?.messageId) { + setLatestMultiMessage({ ...latestMultiMessage, text: editedText }); + } + + const isInMessages = messages?.some((message) => message?.messageId === messageId); + if (!isInMessages) { + message.text = editedText; + } else { + setMessages( + messages.map((msg) => + msg.messageId === messageId + ? { + ...msg, + text: editedText, + isEdited: true, + } + : msg, + ), + ); + } + enterEdit(true); }; diff --git a/client/src/components/Chat/Messages/Message.tsx b/client/src/components/Chat/Messages/Message.tsx index 98fa5bdced6..e4a4e9a85f9 100644 --- a/client/src/components/Chat/Messages/Message.tsx +++ b/client/src/components/Chat/Messages/Message.tsx @@ -1,5 +1,6 @@ -import { useRecoilValue } from 'recoil'; -import { useAuthContext, useMessageHelpers, useLocalize } from '~/hooks'; +import React, { useCallback, useMemo } from 'react'; +import { useMessageProcess, useMessageActions } from '~/hooks'; +import type { TMessage } from 'librechat-data-provider'; import type { TMessageProps } from '~/common'; import Icon from '~/components/Chat/Messages/MessageIcon'; import { Plugin } from '~/components/Messages/Content'; @@ -10,120 +11,213 @@ import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; import SubRow from './SubRow'; import { cn } from '~/utils'; -import store from '~/store'; -export default function Message(props: TMessageProps) { - const UsernameDisplay = useRecoilValue(store.UsernameDisplay); - const { user } = useAuthContext(); - const localize = useLocalize(); - - const { - ask, - edit, - index, - isLast, - assistant, - enterEdit, - handleScroll, - conversation, - isSubmitting, - latestMessage, - handleContinue, - copyToClipboard, - regenerateMessage, - } = useMessageHelpers(props); - - const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } = - props; +const MessageContainer = React.memo( + ({ handleScroll, children }: { handleScroll: () => void; children: React.ReactNode }) => { + return ( +
+ {children} +
+ ); + }, +); - if (!message) { +const PlaceholderRow = React.memo(({ isLast, isCard }: { isLast: boolean; isCard?: boolean }) => { + if (!isLast && !isCard) { return null; } + return
; +}); - const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {}; +type MessageRenderProps = { + message?: TMessage; + isCard?: boolean; + isMultiMessage?: boolean; + isSubmittingFamily?: boolean; +} & Pick< + TMessageProps, + 'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount' +>; - let messageLabel = ''; - if (isCreatedByUser) { - messageLabel = UsernameDisplay ? user?.name || user?.username : localize('com_user_message'); - } else if (assistant) { - messageLabel = assistant.name ?? 'Assistant'; - } else { - messageLabel = message.sender; - } +const MessageRender = React.memo( + ({ + isCard, + siblingIdx, + siblingCount, + message: msg, + setSiblingIdx, + currentEditId, + isMultiMessage, + setCurrentEditId, + isSubmittingFamily, + }: MessageRenderProps) => { + const { + ask, + edit, + index, + assistant, + enterEdit, + conversation, + messageLabel, + isSubmitting, + latestMessage, + handleContinue, + copyToClipboard, + setLatestMessage, + regenerateMessage, + } = useMessageActions({ + message: msg, + currentEditId, + isMultiMessage, + setCurrentEditId, + }); - return ( - <> + const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); + const { isCreatedByUser, error, unfinished } = msg ?? {}; + const isLast = useMemo( + () => !msg?.children?.length && (msg?.depth === latestMessage?.depth || msg?.depth === -1), + [msg?.children, msg?.depth, latestMessage?.depth], + ); + + if (!msg) { + return null; + } + + const isLatest = isCard && !isSubmittingFamily && msg.messageId === latestMessage?.messageId; + const clickHandler = + isLast && isCard && !isSubmittingFamily && msg.messageId !== latestMessage?.messageId + ? () => setLatestMessage(msg) + : undefined; + + return (
-
-
-
-
-
-
- -
-
+ {isLatest && ( +
+ )} +
+
+
+
+
-
-
{messageLabel}
-
-
- {/* Legacy Plugins */} - {message?.plugin && } - { - return; - }) - } - /> -
-
- {isLast && isSubmitting ? null : ( - - - regenerateMessage()} - copyToClipboard={copyToClipboard} - handleContinue={handleContinue} - latestMessage={latestMessage} - isLast={isLast} - /> - - )} +
+
+
+
{messageLabel}
+
+
+ {msg?.plugin && } + ({}))} + />
+ {!msg?.children?.length && (isSubmittingFamily || isSubmitting) ? ( + + ) : ( + + + + + )}
+ ); + }, +); + +export default function Message(props: TMessageProps) { + const { + showSibling, + conversation, + handleScroll, + siblingMessage, + latestMultiMessage, + isSubmittingFamily, + } = useMessageProcess({ message: props.message }); + const { message, currentEditId, setCurrentEditId } = props; + + if (!message) { + return null; + } + + const { children, messageId = null } = message ?? {}; + + return ( + <> + + {showSibling ? ( +
+
+ + +
+
+ ) : ( +
+
+ +
+
+ )} +
& { assistant?: false | Assistant; }, @@ -56,3 +56,5 @@ export default function MessageIcon( /> ); } + +export default memo(MessageIcon); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index a51a973a257..d3853419695 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -83,7 +83,9 @@ export default function Message(props: TMessageProps) { />
- {isLast && isSubmitting ? null : ( + {isLast && isSubmitting ? ( +
+ ) : ( { - setSiblingIdx((messagesTree?.length ?? 0) - value - 1); - }; + const setSiblingIdxRev = useCallback( + (value: number) => { + setSiblingIdx((messagesTree?.length ?? 0) - value - 1); + }, + [messagesTree?.length, setSiblingIdx], + ); useEffect(() => { // reset siblingIdx when the tree changes, mostly when a new message is submitting. diff --git a/client/src/components/Endpoints/Icon.tsx b/client/src/components/Endpoints/Icon.tsx index 1f5bd17d7f6..0059725a1ef 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -1,10 +1,11 @@ -import { UserIcon } from '~/components/svg'; +import { memo } from 'react'; +import type { IconProps } from '~/common'; +import MessageEndpointIcon from './MessageEndpointIcon'; import { useAuthContext } from '~/hooks/AuthContext'; import useAvatar from '~/hooks/Messages/useAvatar'; import useLocalize from '~/hooks/useLocalize'; -import { IconProps } from '~/common'; +import { UserIcon } from '~/components/svg'; import { cn } from '~/utils'; -import MessageEndpointIcon from './MessageEndpointIcon'; const Icon: React.FC = (props) => { const { user } = useAuthContext(); @@ -46,4 +47,4 @@ const Icon: React.FC = (props) => { return ; }; -export default Icon; +export default memo(Icon); diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index fe6a5f1ec7b..e76b90d861c 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -23,13 +23,15 @@ import { useConversationsInfiniteQuery, useSharedLinksInfiniteQuery } from './qu import { normalizeData } from '~/utils/collection'; import store from '~/store'; -/** Conversations */ -export const useGenTitleMutation = (): UseMutationResult< +export type TGenTitleMutation = UseMutationResult< t.TGenTitleResponse, unknown, t.TGenTitleRequest, unknown -> => { +>; + +/** Conversations */ +export const useGenTitleMutation = (): TGenTitleMutation => { const queryClient = useQueryClient(); return useMutation((payload: t.TGenTitleRequest) => dataService.genTitle(payload), { onSuccess: (response, vars) => { @@ -525,6 +527,8 @@ export const useLogoutUserMutation = ( setDefaultPreset(null); queryClient.removeQueries(); localStorage.removeItem(LocalStorageKeys.LAST_CONVO_SETUP); + localStorage.removeItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_0`); + localStorage.removeItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_1`); localStorage.removeItem(LocalStorageKeys.LAST_MODEL); localStorage.removeItem(LocalStorageKeys.LAST_TOOLS); localStorage.removeItem(LocalStorageKeys.FILES_TO_DELETE); @@ -565,6 +569,8 @@ export const useDeleteUserMutation = ( setDefaultPreset(null); queryClient.removeQueries(); localStorage.removeItem(LocalStorageKeys.LAST_CONVO_SETUP); + localStorage.removeItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_0`); + localStorage.removeItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_1`); localStorage.removeItem(LocalStorageKeys.LAST_MODEL); localStorage.removeItem(LocalStorageKeys.LAST_TOOLS); localStorage.removeItem(LocalStorageKeys.FILES_TO_DELETE); diff --git a/client/src/hooks/Chat/index.ts b/client/src/hooks/Chat/index.ts new file mode 100644 index 00000000000..54396042760 --- /dev/null +++ b/client/src/hooks/Chat/index.ts @@ -0,0 +1,4 @@ +export { default as useChatHelpers } from './useChatHelpers'; +export { default as useAddedHelpers } from './useAddedHelpers'; +export { default as useAddedResponse } from './useAddedResponse'; +export { default as useChatFunctions } from './useChatFunctions'; diff --git a/client/src/hooks/Chat/useAddedHelpers.ts b/client/src/hooks/Chat/useAddedHelpers.ts new file mode 100644 index 00000000000..b6edf3338ff --- /dev/null +++ b/client/src/hooks/Chat/useAddedHelpers.ts @@ -0,0 +1,128 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from 'librechat-data-provider'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import type { TMessage } from 'librechat-data-provider'; +import useChatFunctions from '~/hooks/Chat/useChatFunctions'; +import store from '~/store'; + +// this to be set somewhere else +export default function useAddedHelpers({ + rootIndex = 0, + currentIndex, + paramId, +}: { + rootIndex?: number; + currentIndex: number; + paramId?: string; +}) { + const queryClient = useQueryClient(); + + const clearAllSubmissions = store.useClearSubmissionState(); + const [files, setFiles] = useRecoilState(store.filesByIndex(rootIndex)); + const latestMessage = useRecoilValue(store.latestMessageFamily(rootIndex)); + const setLatestMultiMessage = useSetRecoilState(store.latestMessageFamily(currentIndex)); + + const { useCreateConversationAtom } = store; + const { conversation, setConversation } = useCreateConversationAtom(currentIndex); + const [isSubmitting, setIsSubmitting] = useRecoilState(store.isSubmittingFamily(currentIndex)); + + const setSiblingIdx = useSetRecoilState( + store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null), + ); + + const queryParam = paramId === 'new' ? paramId : conversation?.conversationId ?? paramId ?? ''; + + const setMessages = useCallback( + (messages: TMessage[]) => { + queryClient.setQueryData( + [QueryKeys.messages, queryParam, currentIndex], + messages, + ); + const latestMultiMessage = messages[messages.length - 1]; + if (latestMultiMessage) { + setLatestMultiMessage({ ...latestMultiMessage, depth: -1 }); + } + }, + [queryParam, queryClient, currentIndex, setLatestMultiMessage], + ); + + const getMessages = useCallback(() => { + return queryClient.getQueryData([QueryKeys.messages, queryParam, currentIndex]); + }, [queryParam, queryClient, currentIndex]); + + const setSubmission = useSetRecoilState(store.submissionByIndex(currentIndex)); + + const { ask, regenerate } = useChatFunctions({ + index: currentIndex, + files, + setFiles, + getMessages, + setMessages, + isSubmitting, + conversation, + setSubmission, + latestMessage, + }); + + const continueGeneration = () => { + if (!latestMessage) { + console.error('Failed to regenerate the message: latestMessage not found.'); + return; + } + + const messages = getMessages(); + + const parentMessage = messages?.find( + (element) => element.messageId == latestMessage.parentMessageId, + ); + + if (parentMessage && parentMessage.isCreatedByUser) { + ask({ ...parentMessage }, { isContinued: true, isRegenerate: true, isEdited: true }); + } else { + console.error( + 'Failed to regenerate the message: parentMessage not found, or not created by user.', + ); + } + }; + + const stopGenerating = () => clearAllSubmissions(); + + const handleStopGenerating = (e: React.MouseEvent) => { + e.preventDefault(); + stopGenerating(); + }; + + const handleRegenerate = (e: React.MouseEvent) => { + e.preventDefault(); + const parentMessageId = latestMessage?.parentMessageId; + if (!parentMessageId) { + console.error('Failed to regenerate the message: parentMessageId not found.'); + return; + } + regenerate({ parentMessageId }); + }; + + const handleContinue = (e: React.MouseEvent) => { + e.preventDefault(); + continueGeneration(); + setSiblingIdx(0); + }; + + return { + ask, + regenerate, + getMessages, + setMessages, + conversation, + isSubmitting, + setSiblingIdx, + latestMessage, + stopGenerating, + handleContinue, + setConversation, + setIsSubmitting, + handleRegenerate, + handleStopGenerating, + }; +} diff --git a/client/src/hooks/Chat/useAddedResponse.ts b/client/src/hooks/Chat/useAddedResponse.ts new file mode 100644 index 00000000000..a5d463de388 --- /dev/null +++ b/client/src/hooks/Chat/useAddedResponse.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import useGenerateConvo from '~/hooks/Conversations/useGenerateConvo'; +import useAddedHelpers from '~/hooks/Chat/useAddedHelpers'; + +export default function useAddedResponse({ rootIndex }: { rootIndex: number }) { + const currentIndex = useMemo(() => rootIndex + 1, [rootIndex]); + const { + ask, + regenerate, + setMessages, + getMessages, + conversation, + isSubmitting, + setConversation, + setIsSubmitting, + } = useAddedHelpers({ + rootIndex, + currentIndex, + }); + + const { generateConversation } = useGenerateConvo({ + index: currentIndex, + rootIndex, + setConversation, + }); + + return { + ask, + regenerate, + getMessages, + setMessages, + conversation, + isSubmitting, + setConversation, + setIsSubmitting, + generateConversation, + addedIndex: currentIndex, + }; +} diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts new file mode 100644 index 00000000000..3a5e84beb63 --- /dev/null +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -0,0 +1,264 @@ +import { v4 } from 'uuid'; +import { useQueryClient } from '@tanstack/react-query'; +import { + Constants, + QueryKeys, + ContentTypes, + parseCompactConvo, + isAssistantsEndpoint, +} from 'librechat-data-provider'; +import { useSetRecoilState, useResetRecoilState } from 'recoil'; +import type { + TMessage, + TSubmission, + TConversation, + TEndpointOption, + TEndpointsConfig, +} from 'librechat-data-provider'; +import type { SetterOrUpdater } from 'recoil'; +import type { TAskFunction, ExtendedFile } from '~/common'; +import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; +import useGetSender from '~/hooks/Conversations/useGetSender'; +import { getEndpointField, logger } from '~/utils'; +import useUserKey from '~/hooks/Input/useUserKey'; +import store from '~/store'; + +export default function useChatFunctions({ + index = 0, + files, + setFiles, + getMessages, + setMessages, + isSubmitting, + conversation, + latestMessage, + setSubmission, + setLatestMessage, +}: { + index?: number; + isSubmitting: boolean; + paramId?: string | undefined; + conversation: TConversation | null; + latestMessage: TMessage | null; + getMessages: () => TMessage[] | undefined; + setMessages: (messages: TMessage[]) => void; + files?: Map; + setFiles?: SetterOrUpdater>; + setSubmission: SetterOrUpdater; + setLatestMessage?: SetterOrUpdater; +}) { + const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1)); + const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); + const setFilesToDelete = useSetFilesToDelete(); + const getSender = useGetSender(); + + const queryClient = useQueryClient(); + const { getExpiry } = useUserKey(conversation?.endpoint ?? ''); + + const ask: TAskFunction = ( + { + text, + overrideConvoId, + overrideUserMessageId, + parentMessageId = null, + conversationId = null, + messageId = null, + }, + { + editedText = null, + editedMessageId = null, + resubmitFiles = false, + isRegenerate = false, + isContinued = false, + isEdited = false, + overrideMessages, + } = {}, + ) => { + setShowStopButton(false); + resetLatestMultiMessage(); + if (!!isSubmitting || text === '') { + return; + } + + const endpoint = conversation?.endpoint; + if (endpoint === null) { + console.error('No endpoint available'); + return; + } + + conversationId = conversationId ?? conversation?.conversationId ?? null; + if (conversationId == 'search') { + console.error('cannot send any message under search view!'); + return; + } + + if (isContinued && !latestMessage) { + console.error('cannot continue AI message without latestMessage!'); + return; + } + + const isEditOrContinue = isEdited || isContinued; + + let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? []; + + // construct the query message + // this is not a real messageId, it is used as placeholder before real messageId returned + text = text.trim(); + const intermediateId = overrideUserMessageId ?? 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, + ); + + let thread_id = parentMessage?.thread_id ?? latestMessage?.thread_id; + if (!thread_id) { + thread_id = currentMessages.find((message) => message.thread_id)?.thread_id; + } + + const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); + const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); + + // set the endpoint option + const convo = parseCompactConvo({ + endpoint, + endpointType, + conversation: conversation ?? {}, + }); + + const { modelDisplayLabel } = endpointsConfig?.[endpoint ?? ''] ?? {}; + const endpointOption = { + ...convo, + endpoint, + thread_id, + endpointType, + overrideConvoId, + key: getExpiry(), + modelDisplayLabel, + overrideUserMessageId, + } as TEndpointOption; + const responseSender = getSender({ model: conversation?.model, ...endpointOption }); + + const currentMsg: TMessage = { + text, + sender: 'User', + isCreatedByUser: true, + parentMessageId, + conversationId, + messageId: isContinued && messageId ? messageId : intermediateId, + thread_id, + error: false, + }; + + const reuseFiles = (isRegenerate || resubmitFiles) && parentMessage?.files; + if (setFiles && reuseFiles && parentMessage.files?.length) { + currentMsg.files = parentMessage.files; + setFiles(new Map()); + setFilesToDelete({}); + } else if (setFiles && files && files.size > 0) { + currentMsg.files = Array.from(files.values()).map((file) => ({ + file_id: file.file_id, + filepath: file.filepath, + type: file.type || '', // Ensure type is not undefined + height: file.height, + width: file.width, + })); + setFiles(new Map()); + setFilesToDelete({}); + } + + // construct the placeholder response message + const generation = editedText ?? latestMessage?.text ?? ''; + const responseText = isEditOrContinue ? generation : ''; + + const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null; + const initialResponse: TMessage = { + sender: responseSender, + text: responseText, + endpoint: endpoint ?? '', + parentMessageId: isRegenerate ? messageId : intermediateId, + messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`, + thread_id, + conversationId, + unfinished: false, + isCreatedByUser: false, + isEdited: isEditOrContinue, + iconURL: convo.iconURL, + error: false, + }; + + if (isAssistantsEndpoint(endpoint)) { + 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); + } + + const submission: TSubmission = { + conversation: { + ...conversation, + conversationId, + }, + endpointOption, + userMessage: { + ...currentMsg, + generation, + responseMessageId, + overrideParentMessageId: isRegenerate ? messageId : null, + }, + messages: currentMessages, + isEdited: isEditOrContinue, + isContinued, + isRegenerate, + initialResponse, + }; + + if (isRegenerate) { + setMessages([...submission.messages, initialResponse]); + } else { + setMessages([...submission.messages, currentMsg, initialResponse]); + } + if (index === 0 && setLatestMessage) { + setLatestMessage(initialResponse); + } + setSubmission(submission); + logger.log('Submission:'); + logger.dir(submission, { depth: null }); + }; + + const regenerate = ({ parentMessageId }) => { + const messages = getMessages(); + const parentMessage = messages?.find((element) => element.messageId == parentMessageId); + + if (parentMessage && parentMessage.isCreatedByUser) { + ask({ ...parentMessage }, { isRegenerate: true }); + } else { + console.error( + 'Failed to regenerate the message: parentMessage not found or not created by user.', + ); + } + }; + + return { + ask, + regenerate, + }; +} diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts new file mode 100644 index 00000000000..2b03848bbd2 --- /dev/null +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -0,0 +1,182 @@ +import { useCallback, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from 'librechat-data-provider'; +import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'; +import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query'; +import type { TMessage } from 'librechat-data-provider'; +import useChatFunctions from '~/hooks/Chat/useChatFunctions'; +import { useAuthContext } from '~/hooks/AuthContext'; +import useNewConvo from '~/hooks/useNewConvo'; +import store from '~/store'; + +// this to be set somewhere else +export default function useChatHelpers(index = 0, paramId?: string) { + const clearAllSubmissions = store.useClearSubmissionState(); + const [files, setFiles] = useRecoilState(store.filesByIndex(index)); + const [filesLoading, setFilesLoading] = useState(false); + + const queryClient = useQueryClient(); + const { isAuthenticated } = useAuthContext(); + + const { newConversation } = useNewConvo(index); + const { useCreateConversationAtom } = store; + const { conversation, setConversation } = useCreateConversationAtom(index); + const { conversationId } = conversation ?? {}; + + const queryParam = paramId === 'new' ? paramId : conversationId ?? paramId ?? ''; + + /* Messages: here simply to fetch, don't export and use `getMessages()` instead */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { data: _messages } = useGetMessagesByConvoId(conversationId ?? '', { + enabled: isAuthenticated, + }); + + const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); + const [isSubmitting, setIsSubmitting] = useRecoilState(store.isSubmittingFamily(index)); + const [latestMessage, setLatestMessage] = useRecoilState(store.latestMessageFamily(index)); + const setSiblingIdx = useSetRecoilState( + store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null), + ); + + const setMessages = useCallback( + (messages: TMessage[]) => { + queryClient.setQueryData([QueryKeys.messages, queryParam], messages); + if (queryParam === 'new') { + queryClient.setQueryData([QueryKeys.messages, conversationId], messages); + } + }, + [queryParam, queryClient, conversationId], + ); + + const getMessages = useCallback(() => { + return queryClient.getQueryData([QueryKeys.messages, queryParam]); + }, [queryParam, queryClient]); + + /* Conversation */ + // const setActiveConvos = useSetRecoilState(store.activeConversations); + + // const setConversation = useCallback( + // (convoUpdate: TConversation) => { + // _setConversation(prev => { + // const { conversationId: convoId } = prev ?? { conversationId: null }; + // const { conversationId: currentId } = convoUpdate; + // if (currentId && convoId && convoId !== 'new' && convoId !== currentId) { + // // for now, we delete the prev convoId from activeConversations + // const newActiveConvos = { [currentId]: true }; + // setActiveConvos(newActiveConvos); + // } + // return convoUpdate; + // }); + // }, + // [_setConversation, setActiveConvos], + // ); + + const setSubmission = useSetRecoilState(store.submissionByIndex(index)); + + const { ask, regenerate } = useChatFunctions({ + index, + files, + setFiles, + getMessages, + setMessages, + isSubmitting, + conversation, + latestMessage, + setSubmission, + setLatestMessage, + }); + + const continueGeneration = () => { + if (!latestMessage) { + console.error('Failed to regenerate the message: latestMessage not found.'); + return; + } + + const messages = getMessages(); + + const parentMessage = messages?.find( + (element) => element.messageId == latestMessage.parentMessageId, + ); + + if (parentMessage && parentMessage.isCreatedByUser) { + ask({ ...parentMessage }, { isContinued: true, isRegenerate: true, isEdited: true }); + } else { + console.error( + 'Failed to regenerate the message: parentMessage not found, or not created by user.', + ); + } + }; + + const stopGenerating = () => clearAllSubmissions(); + + const handleStopGenerating = (e: React.MouseEvent) => { + e.preventDefault(); + stopGenerating(); + }; + + const handleRegenerate = (e: React.MouseEvent) => { + e.preventDefault(); + const parentMessageId = latestMessage?.parentMessageId; + if (!parentMessageId) { + console.error('Failed to regenerate the message: parentMessageId not found.'); + return; + } + regenerate({ parentMessageId }); + }; + + const handleContinue = (e: React.MouseEvent) => { + e.preventDefault(); + continueGeneration(); + setSiblingIdx(0); + }; + + const [showBingToneSetting, setShowBingToneSetting] = useRecoilState( + store.showBingToneSettingFamily(index), + ); + const [showPopover, setShowPopover] = useRecoilState(store.showPopoverFamily(index)); + const [abortScroll, setAbortScroll] = useRecoilState(store.abortScrollFamily(index)); + const [preset, setPreset] = useRecoilState(store.presetByIndex(index)); + const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettingsFamily(index)); + const [showAgentSettings, setShowAgentSettings] = useRecoilState( + store.showAgentSettingsFamily(index), + ); + + return { + newConversation, + conversation, + setConversation, + // getConvos, + // setConvos, + isSubmitting, + setIsSubmitting, + getMessages, + setMessages, + setSiblingIdx, + latestMessage, + setLatestMessage, + resetLatestMessage, + ask, + index, + regenerate, + stopGenerating, + handleStopGenerating, + handleRegenerate, + handleContinue, + showPopover, + setShowPopover, + abortScroll, + setAbortScroll, + showBingToneSetting, + setShowBingToneSetting, + preset, + setPreset, + optionSettings, + setOptionSettings, + showAgentSettings, + setShowAgentSettings, + files, + setFiles, + filesLoading, + setFilesLoading, + }; +} diff --git a/client/src/hooks/Conversations/index.ts b/client/src/hooks/Conversations/index.ts index 5e5cabb81c0..105ea2330e7 100644 --- a/client/src/hooks/Conversations/index.ts +++ b/client/src/hooks/Conversations/index.ts @@ -3,6 +3,7 @@ export { default as usePresets } from './usePresets'; export { default as useGetSender } from './useGetSender'; export { default as useDefaultConvo } from './useDefaultConvo'; export { default as useConversation } from './useConversation'; +export { default as useGenerateConvo } from './useGenerateConvo'; export { default as useConversations } from './useConversations'; export { default as useDebouncedInput } from './useDebouncedInput'; export { default as useNavigateToConvo } from './useNavigateToConvo'; diff --git a/client/src/hooks/Conversations/useGenerateConvo.ts b/client/src/hooks/Conversations/useGenerateConvo.ts new file mode 100644 index 00000000000..a5f2c933bd8 --- /dev/null +++ b/client/src/hooks/Conversations/useGenerateConvo.ts @@ -0,0 +1,147 @@ +import { useRecoilValue } from 'recoil'; +import { useCallback, useRef, useEffect } from 'react'; +import { LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider'; +import { useGetModelsQuery, useGetEndpointsQuery } from 'librechat-data-provider/react-query'; +import type { + TPreset, + TModelsConfig, + TConversation, + TEndpointsConfig, +} from 'librechat-data-provider'; +import type { SetterOrUpdater } from 'recoil'; +import type { AssistantListItem } from '~/common'; +import { getEndpointField, buildDefaultConvo, getDefaultEndpoint } from '~/utils'; +import useAssistantListMap from '~/hooks/Assistants/useAssistantListMap'; +import { mainTextareaId } from '~/common'; +import store from '~/store'; + +const useGenerateConvo = ({ + index = 0, + rootIndex, + setConversation, +}: { + index?: number; + rootIndex: number; + setConversation?: SetterOrUpdater; +}) => { + const modelsQuery = useGetModelsQuery(); + const assistantsListMap = useAssistantListMap(); + const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); + + const timeoutIdRef = useRef(); + const rootConvo = useRecoilValue(store.conversationByKeySelector(rootIndex)); + + useEffect(() => { + if (rootConvo?.conversationId && setConversation) { + setConversation((prevState) => { + if (!prevState) { + return prevState; + } + const update = { + ...prevState, + conversationId: rootConvo.conversationId, + } as TConversation; + + return update; + }); + } + }, [rootConvo?.conversationId, setConversation]); + + const generateConversation = useCallback( + ({ + template = {}, + preset, + modelsData, + }: { + template?: Partial; + preset?: Partial; + modelsData?: TModelsConfig; + } = {}) => { + let conversation = { + conversationId: 'new', + title: 'New Chat', + endpoint: null, + ...template, + createdAt: '', + updatedAt: '', + }; + + if (rootConvo?.conversationId) { + conversation.conversationId = rootConvo.conversationId; + } + + const modelsConfig = modelsData ?? modelsQuery.data; + + const defaultEndpoint = getDefaultEndpoint({ + convoSetup: preset ?? conversation, + endpointsConfig, + }); + + const endpointType = getEndpointField(endpointsConfig, defaultEndpoint, 'type'); + if (!conversation.endpointType && endpointType) { + conversation.endpointType = endpointType; + } else if (conversation.endpointType && !endpointType) { + conversation.endpointType = undefined; + } + + const isAssistantEndpoint = isAssistantsEndpoint(defaultEndpoint); + const assistants: AssistantListItem[] = assistantsListMap[defaultEndpoint] ?? []; + + if ( + conversation.assistant_id && + !assistantsListMap[defaultEndpoint]?.[conversation.assistant_id] + ) { + conversation.assistant_id = undefined; + } + + if (!conversation.assistant_id && isAssistantEndpoint) { + conversation.assistant_id = + localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}${defaultEndpoint}`) ?? + assistants[0]?.id; + } + + if ( + conversation.assistant_id && + isAssistantEndpoint && + conversation.conversationId === 'new' + ) { + const assistant = assistants.find((asst) => asst.id === conversation.assistant_id); + conversation.model = assistant?.model; + } + + if (conversation.assistant_id && !isAssistantEndpoint) { + conversation.assistant_id = undefined; + } + + const models = modelsConfig?.[defaultEndpoint] ?? []; + conversation = buildDefaultConvo({ + conversation, + lastConversationSetup: preset as TConversation, + endpoint: defaultEndpoint, + models, + }); + + if (preset?.title) { + conversation.title = preset.title; + } + + if (setConversation) { + setConversation(conversation); + } + + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = setTimeout(() => { + const textarea = document.getElementById(mainTextareaId); + if (textarea) { + textarea.focus(); + } + }, 150); + return conversation; + }, + [assistantsListMap, endpointsConfig, index, modelsQuery.data, rootConvo, setConversation], + ); + + return { generateConversation }; +}; + +export default useGenerateConvo; diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index 17f2563ab43..63af9fcf960 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -1,6 +1,6 @@ +import { useSetRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { QueryKeys, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider'; import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider'; import { buildDefaultConvo, getDefaultEndpoint, getEndpointField } from '~/utils'; @@ -9,9 +9,10 @@ import store from '~/store'; const useNavigateToConvo = (index = 0) => { const navigate = useNavigate(); const queryClient = useQueryClient(); - const { setConversation } = store.useCreateConversationAtom(index); + const clearAllConversations = store.useClearConvoState(); + const clearAllLatestMessages = store.useClearLatestMessages(); const setSubmission = useSetRecoilState(store.submissionByIndex(index)); - const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); + const { setConversation } = store.useCreateConversationAtom(index); const navigateToConvo = (conversation: TConversation, _resetLatestMessage = true) => { if (!conversation) { @@ -20,7 +21,7 @@ const useNavigateToConvo = (index = 0) => { } setSubmission(null); if (_resetLatestMessage) { - resetLatestMessage(); + clearAllLatestMessages(); } let convo = { ...conversation }; @@ -47,6 +48,7 @@ const useNavigateToConvo = (index = 0) => { models, }); } + clearAllConversations(true); setConversation(convo); navigate(`/c/${convo.conversationId ?? 'new'}`); }; diff --git a/client/src/hooks/Conversations/usePresets.ts b/client/src/hooks/Conversations/usePresets.ts index be7ea69f37c..76b98dbb675 100644 --- a/client/src/hooks/Conversations/usePresets.ts +++ b/client/src/hooks/Conversations/usePresets.ts @@ -1,9 +1,9 @@ import filenamify from 'filenamify'; import exportFromJSON from 'export-from-json'; +import { QueryKeys } from 'librechat-data-provider'; import { useCallback, useEffect, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useRecoilState, useSetRecoilState, useRecoilValue } from 'recoil'; -import { QueryKeys, modularEndpoints, isAssistantsEndpoint } from 'librechat-data-provider'; import { useCreatePresetMutation, useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TPreset, TEndpointsConfig } from 'librechat-data-provider'; import { @@ -11,7 +11,7 @@ import { useDeletePresetMutation, useGetPresetsQuery, } from '~/data-provider'; -import { cleanupPreset, getEndpointField, removeUnavailableTools } from '~/utils'; +import { cleanupPreset, removeUnavailableTools, getConvoSwitchLogic } from '~/utils'; import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo'; import { useChatContext, useToastContext } from '~/Providers'; import { useAuthContext } from '~/hooks/AuthContext'; @@ -124,8 +124,6 @@ export default function usePresets() { const getDefaultConversation = useDefaultConvo(); - const { endpoint } = conversation ?? {}; - const importPreset = (jsonPreset: TPreset) => { createPresetMutation.mutate( { ...jsonPreset }, @@ -171,34 +169,38 @@ export default function usePresets() { const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - const currentEndpointType = getEndpointField(endpointsConfig, endpoint, 'type'); - const endpointType = getEndpointField(endpointsConfig, newPreset.endpoint, 'type'); - const isAssistantSwitch = - isAssistantsEndpoint(newPreset.endpoint) && - isAssistantsEndpoint(conversation?.endpoint) && - conversation?.endpoint === newPreset.endpoint; - - if ( - (modularEndpoints.has(endpoint ?? '') || - modularEndpoints.has(currentEndpointType ?? '') || - isAssistantSwitch) && - (modularEndpoints.has(newPreset?.endpoint ?? '') || - modularEndpoints.has(endpointType ?? '') || - isAssistantSwitch) && - (endpoint === newPreset?.endpoint || modularChat || isAssistantSwitch) - ) { + const { + shouldSwitch, + isNewModular, + newEndpointType, + isCurrentModular, + isExistingConversation, + } = getConvoSwitchLogic({ + newEndpoint: newPreset.endpoint ?? '', + modularChat, + conversation, + endpointsConfig, + }); + + const isModular = isCurrentModular && isNewModular && shouldSwitch; + if (isExistingConversation && isModular) { const currentConvo = getDefaultConversation({ /* target endpointType is necessary to avoid endpoint mixing */ - conversation: { ...(conversation ?? {}), endpointType }, - preset: { ...newPreset, endpointType }, + conversation: { ...(conversation ?? {}), endpointType: newEndpointType }, + preset: { ...newPreset, endpointType: newEndpointType }, }); /* We don't reset the latest message, only when changing settings mid-converstion */ - newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true }); + newConversation({ + template: currentConvo, + preset: currentConvo, + keepLatestMessage: true, + keepAddedConvos: true, + }); return; } - newConversation({ preset: newPreset }); + newConversation({ preset: newPreset, keepAddedConvos: isModular }); }; const onChangePreset = (preset: TPreset) => { diff --git a/client/src/hooks/Input/index.ts b/client/src/hooks/Input/index.ts index e740b94df47..672c98e1be6 100644 --- a/client/src/hooks/Input/index.ts +++ b/client/src/hooks/Input/index.ts @@ -1,3 +1,4 @@ +export * from './useAutoSave'; export { default as useUserKey } from './useUserKey'; export { default as useDebounce } from './useDebounce'; export { default as useTextarea } from './useTextarea'; diff --git a/client/src/hooks/Input/useHandleKeyUp.ts b/client/src/hooks/Input/useHandleKeyUp.ts index 6fdcc1d1fb0..726924edd5a 100644 --- a/client/src/hooks/Input/useHandleKeyUp.ts +++ b/client/src/hooks/Input/useHandleKeyUp.ts @@ -1,6 +1,12 @@ import { useCallback, useMemo } from 'react'; -import { useSetRecoilState } from 'recoil'; -import store from '~/store'; +import type { SetterOrUpdater } from 'recoil'; + +/** Event Keys that shouldn't trigger a command */ +const invalidKeys = { + Escape: true, + Backspace: true, + Enter: true, +}; /** * Utility function to determine if a command should trigger. @@ -23,10 +29,6 @@ const shouldTriggerCommand = ( const isPrecededBySpace = textAreaRef.current?.value.charAt(startPos - 2) === ' '; const shouldTrigger = isAtStart || isPrecededBySpace; - if (shouldTrigger) { - // Blurring helps prevent the command from firing twice. - textAreaRef.current.blur(); - } return shouldTrigger; }; @@ -34,33 +36,32 @@ const shouldTriggerCommand = ( * Custom hook for handling key up events with command triggers. */ const useHandleKeyUp = ({ - index, textAreaRef, + setShowPlusPopover, + setShowMentionPopover, }: { - index: number; textAreaRef: React.RefObject; + setShowPlusPopover: SetterOrUpdater; + setShowMentionPopover: SetterOrUpdater; }) => { - const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index)); - const handleAtCommand = useCallback(() => { if (shouldTriggerCommand(textAreaRef, '@')) { setShowMentionPopover(true); } }, [textAreaRef, setShowMentionPopover]); - // const handlePlusCommand = useCallback(() => { - // if (shouldTriggerCommand(textAreaRef, '+')) { - // console.log('+ command triggered'); - // } - // }, [textAreaRef]); + const handlePlusCommand = useCallback(() => { + if (shouldTriggerCommand(textAreaRef, '+')) { + setShowPlusPopover(true); + } + }, [textAreaRef, setShowPlusPopover]); const commandHandlers = useMemo( () => ({ '@': handleAtCommand, - // '+': handlePlusCommand, + '+': handlePlusCommand, }), - [handleAtCommand], - // [handleAtCommand, handlePlusCommand], + [handleAtCommand, handlePlusCommand], ); /** @@ -73,7 +74,7 @@ const useHandleKeyUp = ({ return; } - if (event.key === 'Escape') { + if (invalidKeys[event.key]) { return; } diff --git a/client/src/hooks/Input/useMentions.ts b/client/src/hooks/Input/useMentions.ts index eada4d32d94..8e81f2dae14 100644 --- a/client/src/hooks/Input/useMentions.ts +++ b/client/src/hooks/Input/useMentions.ts @@ -4,7 +4,12 @@ import { useGetStartupConfig, useGetEndpointsQuery, } from 'librechat-data-provider/react-query'; -import { getConfigDefaults, EModelEndpoint, alternateName } from 'librechat-data-provider'; +import { + getConfigDefaults, + EModelEndpoint, + alternateName, + isAssistantsEndpoint, +} from 'librechat-data-provider'; import type { AssistantsEndpoint, TAssistantsMap, TEndpointsConfig } from 'librechat-data-provider'; import type { MentionOption } from '~/common'; import useAssistantListMap from '~/hooks/Assistants/useAssistantListMap'; @@ -39,7 +44,13 @@ const assistantMapFn = }), }); -export default function useMentions({ assistantMap }: { assistantMap: TAssistantsMap }) { +export default function useMentions({ + assistantMap, + includeAssistants, +}: { + assistantMap: TAssistantsMap; + includeAssistants: boolean; +}) { const { data: presets } = useGetPresetsQuery(); const { data: modelsConfig } = useGetModelsQuery(); const { data: startupConfig } = useGetStartupConfig(); @@ -85,6 +96,10 @@ export default function useMentions({ assistantMap }: { assistantMap: TAssistant ); const options: MentionOption[] = useMemo(() => { + let validEndpoints = endpoints; + if (!includeAssistants) { + validEndpoints = endpoints.filter((endpoint) => !isAssistantsEndpoint(endpoint)); + } const mentions = [ ...(modelSpecs?.length > 0 ? modelSpecs : []).map((modelSpec) => ({ value: modelSpec.name, @@ -101,7 +116,7 @@ export default function useMentions({ assistantMap }: { assistantMap: TAssistant }), type: 'modelSpec' as const, })), - ...(interfaceConfig.endpointsMenu ? endpoints : []).map((endpoint) => ({ + ...(interfaceConfig.endpointsMenu ? validEndpoints : []).map((endpoint) => ({ value: endpoint, label: alternateName[endpoint] ?? endpoint ?? '', type: 'endpoint' as const, @@ -112,10 +127,10 @@ export default function useMentions({ assistantMap }: { assistantMap: TAssistant size: 20, }), })), - ...(endpointsConfig?.[EModelEndpoint.assistants] + ...(endpointsConfig?.[EModelEndpoint.assistants] && includeAssistants ? assistantListMap[EModelEndpoint.assistants] || [] : []), - ...(endpointsConfig?.[EModelEndpoint.azureAssistants] + ...(endpointsConfig?.[EModelEndpoint.azureAssistants] && includeAssistants ? assistantListMap[EModelEndpoint.azureAssistants] || [] : []), ...((interfaceConfig.presets ? presets : [])?.map((preset, index) => ({ @@ -142,6 +157,7 @@ export default function useMentions({ assistantMap }: { assistantMap: TAssistant assistantMap, endpointsConfig, assistantListMap, + includeAssistants, interfaceConfig.presets, interfaceConfig.endpointsMenu, ]); diff --git a/client/src/hooks/Input/useSelectMention.ts b/client/src/hooks/Input/useSelectMention.ts index b8df5e92e26..001f9defb12 100644 --- a/client/src/hooks/Input/useSelectMention.ts +++ b/client/src/hooks/Input/useSelectMention.ts @@ -8,25 +8,26 @@ import type { TAssistantsMap, TEndpointsConfig, } from 'librechat-data-provider'; -import type { MentionOption } from '~/common'; +import type { MentionOption, ConvoGenerator } from '~/common'; import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools } from '~/utils'; -import { useDefaultConvo, useNewConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; +import { useDefaultConvo } from '~/hooks'; import store from '~/store'; export default function useSelectMention({ presets, modelSpecs, - endpointsConfig, assistantMap, + endpointsConfig, + newConversation, }: { presets?: TPreset[]; modelSpecs: TModelSpec[]; - endpointsConfig: TEndpointsConfig; assistantMap: TAssistantsMap; + newConversation: ConvoGenerator; + endpointsConfig: TEndpointsConfig; }) { const { conversation } = useChatContext(); - const { newConversation } = useNewConvo(); const getDefaultConversation = useDefaultConvo(); const modularChat = useRecoilValue(store.modularChat); const availableTools = useRecoilValue(store.availableTools); @@ -45,12 +46,12 @@ export default function useSelectMention({ } const { + template, shouldSwitch, isNewModular, + newEndpointType, isCurrentModular, isExistingConversation, - newEndpointType, - template, } = getConvoSwitchLogic({ newEndpoint, modularChat, @@ -58,7 +59,8 @@ export default function useSelectMention({ endpointsConfig, }); - if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) { + const isModular = isCurrentModular && isNewModular && shouldSwitch; + if (isExistingConversation && isModular) { template.endpointType = newEndpointType as EModelEndpoint | undefined; const currentConvo = getDefaultConversation({ @@ -68,11 +70,20 @@ export default function useSelectMention({ }); /* We don't reset the latest message, only when changing settings mid-converstion */ - newConversation({ template: currentConvo, preset, keepLatestMessage: true }); + newConversation({ + template: currentConvo, + preset, + keepLatestMessage: true, + keepAddedConvos: true, + }); return; } - newConversation({ template: { ...(template as Partial) }, preset }); + newConversation({ + template: { ...(template as Partial) }, + preset, + keepAddedConvos: isModular, + }); }, [conversation, getDefaultConversation, modularChat, newConversation, endpointsConfig], ); @@ -142,12 +153,12 @@ export default function useSelectMention({ const newEndpoint = newPreset.endpoint ?? ''; const { + template, shouldSwitch, isNewModular, + newEndpointType, isCurrentModular, isExistingConversation, - newEndpointType, - template, } = getConvoSwitchLogic({ newEndpoint, modularChat, @@ -155,7 +166,8 @@ export default function useSelectMention({ endpointsConfig, }); - if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) { + const isModular = isCurrentModular && isNewModular && shouldSwitch; + if (isExistingConversation && isModular) { template.endpointType = newEndpointType as EModelEndpoint | undefined; const currentConvo = getDefaultConversation({ @@ -165,19 +177,24 @@ export default function useSelectMention({ }); /* We don't reset the latest message, only when changing settings mid-converstion */ - newConversation({ template: currentConvo, preset: newPreset, keepLatestMessage: true }); + newConversation({ + template: currentConvo, + preset: newPreset, + keepLatestMessage: true, + keepAddedConvos: true, + }); return; } - newConversation({ preset: newPreset }); + newConversation({ preset: newPreset, keepAddedConvos: true }); }, [ - availableTools, - conversation, - getDefaultConversation, modularChat, + conversation, + availableTools, newConversation, endpointsConfig, + getDefaultConversation, ], ); diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts index 51b4c16ed24..db39db533b1 100644 --- a/client/src/hooks/Messages/index.ts +++ b/client/src/hooks/Messages/index.ts @@ -1,6 +1,8 @@ export { default as useAvatar } from './useAvatar'; export { default as useProgress } from './useProgress'; export { default as useSubmitMessage } from './useSubmitMessage'; +export { default as useMessageActions } from './useMessageActions'; +export { default as useMessageProcess } from './useMessageProcess'; export { default as useMessageHelpers } from './useMessageHelpers'; export { default as useCopyToClipboard } from './useCopyToClipboard'; export { default as useMessageScrolling } from './useMessageScrolling'; diff --git a/client/src/hooks/Messages/useAvatar.ts b/client/src/hooks/Messages/useAvatar.ts index 785b22b41a8..c7f0cada881 100644 --- a/client/src/hooks/Messages/useAvatar.ts +++ b/client/src/hooks/Messages/useAvatar.ts @@ -1,50 +1,45 @@ -import { useState, useEffect } from 'react'; +import { useMemo } from 'react'; import { createAvatar } from '@dicebear/core'; import { initials } from '@dicebear/collection'; import type { TUser } from 'librechat-data-provider'; -const useAvatar = (user: TUser | undefined) => { - const [avatarSrc, setAvatarSrc] = useState(''); - - useEffect(() => { - if (avatarSrc.length) { - return; - } - - if (user?.avatar) { - return; - } +const avatarCache: Record = {}; +const useAvatar = (user: TUser | undefined) => { + return useMemo(() => { if (!user?.username) { - return; + return ''; } - const generateAvatar = async () => { - if (!user) { - return; - } + if (user.avatar) { + return user.avatar; + } - const { username } = user; + const { username } = user; - const avatar = createAvatar(initials, { - seed: username, - fontFamily: ['Verdana'], - fontSize: 36, - }); + if (avatarCache[username]) { + return avatarCache[username]; + } - try { - const avatarDataUri = await avatar.toDataUri(); - setAvatarSrc(avatarDataUri); - } catch (error) { + const avatar = createAvatar(initials, { + seed: username, + fontFamily: ['Verdana'], + fontSize: 36, + }); + + let avatarDataUri = ''; + avatar + .toDataUri() + .then((dataUri) => { + avatarDataUri = dataUri; + avatarCache[username] = dataUri; // Store in cache + }) + .catch((error) => { console.error('Failed to generate avatar:', error); - setAvatarSrc(''); - } - }; - - generateAvatar(); - }, [user, avatarSrc.length]); + }); - return avatarSrc; + return avatarDataUri; + }, [user]); }; export default useAvatar; diff --git a/client/src/hooks/Messages/useMessageActions.tsx b/client/src/hooks/Messages/useMessageActions.tsx new file mode 100644 index 00000000000..051454c7209 --- /dev/null +++ b/client/src/hooks/Messages/useMessageActions.tsx @@ -0,0 +1,95 @@ +import { useRecoilValue } from 'recoil'; +import { useCallback, useMemo } from 'react'; +import { isAssistantsEndpoint } from 'librechat-data-provider'; +import type { TMessageProps } from '~/common'; +import { useChatContext, useAddedChatContext, useAssistantsMapContext } from '~/Providers'; +import useCopyToClipboard from './useCopyToClipboard'; +import { useAuthContext } from '~/hooks/AuthContext'; +import useLocalize from '~/hooks/useLocalize'; +import store from '~/store'; + +export type TMessageActions = Pick< + TMessageProps, + 'message' | 'currentEditId' | 'setCurrentEditId' +> & { + isMultiMessage?: boolean; +}; +export default function useMessageActions(props: TMessageActions) { + const localize = useLocalize(); + const { user } = useAuthContext(); + const UsernameDisplay = useRecoilValue(store.UsernameDisplay); + const { message, currentEditId, setCurrentEditId, isMultiMessage } = props; + + const { + ask, + index, + regenerate, + latestMessage, + handleContinue, + setLatestMessage, + conversation: rootConvo, + isSubmitting: isSubmittingRoot, + } = useChatContext(); + const { conversation: addedConvo, isSubmitting: isSubmittingAdditional } = useAddedChatContext(); + const conversation = useMemo( + () => (isMultiMessage ? addedConvo : rootConvo), + [isMultiMessage, addedConvo, rootConvo], + ); + const assistantMap = useAssistantsMapContext(); + + const { text, content, messageId = null, isCreatedByUser } = message ?? {}; + const edit = useMemo(() => messageId === currentEditId, [messageId, currentEditId]); + + const enterEdit = useCallback( + (cancel?: boolean) => setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId), + [messageId, setCurrentEditId], + ); + + const assistant = useMemo( + () => + isAssistantsEndpoint(conversation?.endpoint) && + assistantMap?.[conversation?.endpoint ?? '']?.[message?.model ?? ''], + [assistantMap, conversation?.endpoint, message?.model], + ); + + const isSubmitting = useMemo( + () => (isMultiMessage ? isSubmittingAdditional : isSubmittingRoot), + [isMultiMessage, isSubmittingAdditional, isSubmittingRoot], + ); + + const regenerateMessage = useCallback(() => { + if ((isSubmitting && isCreatedByUser) || !message) { + return; + } + + regenerate(message); + }, [isSubmitting, isCreatedByUser, message, regenerate]); + + const copyToClipboard = useCopyToClipboard({ text, content }); + + const messageLabel = useMemo(() => { + if (message?.isCreatedByUser) { + return UsernameDisplay ? user?.name || user?.username : localize('com_user_message'); + } else if (assistant) { + return assistant.name ?? 'Assistant'; + } else { + return message?.sender; + } + }, [message, assistant, UsernameDisplay, user, localize]); + + return { + ask, + edit, + index, + assistant, + enterEdit, + conversation, + messageLabel, + isSubmitting, + latestMessage, + handleContinue, + copyToClipboard, + setLatestMessage, + regenerateMessage, + }; +} diff --git a/client/src/hooks/Messages/useMessageProcess.tsx b/client/src/hooks/Messages/useMessageProcess.tsx new file mode 100644 index 00000000000..b7a3e3ce1d1 --- /dev/null +++ b/client/src/hooks/Messages/useMessageProcess.tsx @@ -0,0 +1,88 @@ +import { useRecoilValue } from 'recoil'; +import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; +import type { TMessage } from 'librechat-data-provider'; +import { useChatContext, useAddedChatContext } from '~/Providers'; +import { getLatestText, getLengthAndFirstFiveChars } from '~/utils'; +import store from '~/store'; + +export default function useMessageProcess({ message }: { message?: TMessage | null }) { + const latestText = useRef(''); + const hasNoChildren = useMemo(() => !message?.children?.length, [message]); + const [siblingMessage, setSiblingMessage] = useState(null); + + const { + index, + conversation, + latestMessage, + setAbortScroll, + setLatestMessage, + isSubmitting: isSubmittingRoot, + } = useChatContext(); + const { isSubmitting: isSubmittingAdditional } = useAddedChatContext(); + const latestMultiMessage = useRecoilValue(store.latestMessageFamily(index + 1)); + const isSubmittingFamily = useMemo( + () => isSubmittingRoot || isSubmittingAdditional, + [isSubmittingRoot, isSubmittingAdditional], + ); + + useEffect(() => { + if (conversation?.conversationId === 'new') { + return; + } + if (!message) { + return; + } + if (!hasNoChildren) { + return; + } + + const text = getLatestText(message); + const textKey = `${message?.messageId ?? ''}${getLengthAndFirstFiveChars(text)}`; + + if (textKey === latestText.current) { + return; + } + + latestText.current = textKey; + setLatestMessage({ ...message }); + }, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]); + + const handleScroll = useCallback(() => { + if (isSubmittingFamily) { + setAbortScroll(true); + } else { + setAbortScroll(false); + } + }, [isSubmittingFamily, setAbortScroll]); + + const showSibling = useMemo( + () => + (hasNoChildren && latestMultiMessage && !latestMultiMessage?.children?.length) || + siblingMessage, + [hasNoChildren, latestMultiMessage, siblingMessage], + ); + + useEffect(() => { + if ( + hasNoChildren && + latestMultiMessage && + latestMultiMessage.conversationId === message?.conversationId + ) { + const newSibling = Object.assign({}, latestMultiMessage, { + parentMessageId: message?.parentMessageId, + depth: message?.depth, + }); + setSiblingMessage(newSibling); + } + }, [hasNoChildren, latestMultiMessage, message, setSiblingMessage, latestMessage]); + + return { + showSibling, + handleScroll, + conversation, + siblingMessage, + setSiblingMessage, + isSubmittingFamily, + latestMultiMessage, + }; +} diff --git a/client/src/hooks/Messages/useSubmitMessage.ts b/client/src/hooks/Messages/useSubmitMessage.ts index 0dd420ce2c6..f04ea8a37cb 100644 --- a/client/src/hooks/Messages/useSubmitMessage.ts +++ b/client/src/hooks/Messages/useSubmitMessage.ts @@ -1,15 +1,27 @@ +import { v4 } from 'uuid'; import { useCallback } from 'react'; +import { Constants } from 'librechat-data-provider'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { useChatContext, useChatFormContext } from '~/Providers'; +import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers'; import { useAuthContext } from '~/hooks/AuthContext'; import { replaceSpecialVars } from '~/utils'; import store from '~/store'; +const appendIndex = (index: number, value?: string) => { + if (!value) { + return value; + } + return `${value}${Constants.COMMON_DIVIDER}${index}`; +}; + export default function useSubmitMessage(helpers?: { clearDraft?: () => void }) { const { user } = useAuthContext(); const methods = useChatFormContext(); - const { ask, index } = useChatContext(); + const { ask, index, getMessages, setMessages, latestMessage } = useChatContext(); + const { addedIndex, ask: askAdditional, conversation: addedConvo } = useAddedChatContext(); + const autoSendPrompts = useRecoilValue(store.autoSendPrompts); + const activeConvos = useRecoilValue(store.allConversationsSelector); const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index)); const submitMessage = useCallback( @@ -17,11 +29,52 @@ export default function useSubmitMessage(helpers?: { clearDraft?: () => void }) if (!data) { return console.warn('No data provided to submitMessage'); } - ask({ text: data.text }); + const rootMessages = getMessages(); + const isLatestInRootMessages = rootMessages?.some( + (message) => message?.messageId === latestMessage?.messageId, + ); + if (!isLatestInRootMessages && latestMessage) { + setMessages([...(rootMessages || []), latestMessage]); + } + + const hasAdded = addedIndex && activeConvos[addedIndex] && addedConvo; + const isNewMultiConvo = + hasAdded && + activeConvos.every((convoId) => convoId === Constants.NEW_CONVO) && + !rootMessages?.length; + const overrideConvoId = isNewMultiConvo ? v4() : undefined; + const overrideUserMessageId = hasAdded ? v4() : undefined; + const rootIndex = addedIndex - 1; + ask({ + text: data.text, + overrideConvoId: appendIndex(rootIndex, overrideConvoId), + overrideUserMessageId: appendIndex(rootIndex, overrideUserMessageId), + }); + if (hasAdded) { + askAdditional( + { + text: data.text, + overrideConvoId: appendIndex(addedIndex, overrideConvoId), + overrideUserMessageId: appendIndex(addedIndex, overrideUserMessageId), + }, + { overrideMessages: rootMessages }, + ); + } methods.reset(); helpers?.clearDraft && helpers.clearDraft(); }, - [ask, methods, helpers], + [ + ask, + methods, + helpers, + addedIndex, + addedConvo, + setMessages, + getMessages, + activeConvos, + askAdditional, + latestMessage, + ], ); const submitPrompt = useCallback( diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts new file mode 100644 index 00000000000..cf30667bba8 --- /dev/null +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -0,0 +1,544 @@ +import { v4 } from 'uuid'; +import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { + QueryKeys, + Constants, + EndpointURLs, + tPresetSchema, + tMessageSchema, + tConvoUpdateSchema, +} from 'librechat-data-provider'; +import type { + TMessage, + TConversation, + TSubmission, + ConversationData, +} from 'librechat-data-provider'; +import type { SetterOrUpdater, Resetter } from 'recoil'; +import type { TResData, ConvoGenerator } from '~/common'; +import { + addConversation, + deleteConversation, + updateConversation, + getConversationById, +} from '~/utils'; +import useContentHandler from '~/hooks/SSE/useContentHandler'; +import type { TGenTitleMutation } from '~/data-provider'; +import { useAuthContext } from '~/hooks/AuthContext'; + +type TSyncData = { + sync: boolean; + thread_id: string; + messages?: TMessage[]; + requestMessage: TMessage; + responseMessage: TMessage; + conversationId: string; +}; + +export type EventHandlerParams = { + isAddedRequest?: boolean; + genTitle?: TGenTitleMutation; + setCompleted: React.Dispatch>>; + setMessages: (messages: TMessage[]) => void; + getMessages: () => TMessage[] | undefined; + setIsSubmitting: SetterOrUpdater; + setConversation?: SetterOrUpdater; + newConversation?: ConvoGenerator; + setShowStopButton: SetterOrUpdater; + resetLatestMessage?: Resetter; +}; + +export default function useEventHandlers({ + genTitle, + setMessages, + getMessages, + setCompleted, + isAddedRequest, + setConversation, + setIsSubmitting, + newConversation, + setShowStopButton, + resetLatestMessage, +}: EventHandlerParams) { + const queryClient = useQueryClient(); + + const { conversationId: paramId } = useParams(); + const { token } = useAuthContext(); + + const contentHandler = useContentHandler({ setMessages, getMessages }); + + const messageHandler = useCallback( + (data: string, submission: TSubmission) => { + const { + messages, + userMessage, + plugin, + plugins, + initialResponse, + isRegenerate = false, + } = submission; + + if (isRegenerate) { + setMessages([ + ...messages, + { + ...initialResponse, + text: data, + plugin: plugin ?? null, + plugins: plugins ?? [], + // unfinished: true + }, + ]); + } else { + setMessages([ + ...messages, + userMessage, + { + ...initialResponse, + text: data, + plugin: plugin ?? null, + plugins: plugins ?? [], + // unfinished: true + }, + ]); + } + }, + [setMessages], + ); + + const cancelHandler = useCallback( + (data: TResData, submission: TSubmission) => { + const { requestMessage, responseMessage, conversation } = data; + const { messages, isRegenerate = false } = submission; + + const convoUpdate = conversation ?? submission.conversation; + + // update the messages + if (isRegenerate) { + const messagesUpdate = [...messages, responseMessage].filter((msg) => msg); + setMessages(messagesUpdate); + } else { + const messagesUpdate = [...messages, requestMessage, responseMessage].filter((msg) => msg); + setMessages(messagesUpdate); + } + + const isNewConvo = conversation.conversationId !== submission.conversation.conversationId; + if (isNewConvo) { + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return deleteConversation(convoData, submission.conversation.conversationId as string); + }); + } + + // refresh title + if (genTitle && isNewConvo && requestMessage?.parentMessageId === Constants.NO_PARENT) { + setTimeout(() => { + genTitle.mutate({ conversationId: convoUpdate.conversationId as string }); + }, 2500); + } + + if (setConversation && !isAddedRequest) { + setConversation((prevState) => { + const update = { + ...prevState, + ...convoUpdate, + }; + + return update; + }); + } + + setIsSubmitting(false); + }, + [setMessages, setConversation, genTitle, isAddedRequest, queryClient, setIsSubmitting], + ); + + const syncHandler = useCallback( + (data: TSyncData, submission: TSubmission) => { + const { conversationId, thread_id, responseMessage, requestMessage } = data; + const { initialResponse, messages: _messages, userMessage } = submission; + + const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId); + + setMessages([ + ...messages, + requestMessage, + { + ...initialResponse, + ...responseMessage, + }, + ]); + + let update = {} as TConversation; + if (setConversation && !isAddedRequest) { + setConversation((prevState) => { + let title = prevState?.title; + const parentId = requestMessage.parentMessageId; + if (parentId !== Constants.NO_PARENT && title?.toLowerCase()?.includes('new chat')) { + const convos = queryClient.getQueryData([QueryKeys.allConversations]); + const cachedConvo = getConversationById(convos, conversationId); + title = cachedConvo?.title; + } + + update = tConvoUpdateSchema.parse({ + ...prevState, + conversationId, + thread_id, + title, + messages: [requestMessage.messageId, responseMessage.messageId], + }) as TConversation; + + return update; + }); + + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + if (requestMessage.parentMessageId === Constants.NO_PARENT) { + return addConversation(convoData, update); + } else { + return updateConversation(convoData, update); + } + }); + } else if (setConversation) { + setConversation((prevState) => { + update = tConvoUpdateSchema.parse({ + ...prevState, + conversationId, + thread_id, + messages: [requestMessage.messageId, responseMessage.messageId], + }) as TConversation; + return update; + }); + } + + setShowStopButton(true); + if (resetLatestMessage) { + resetLatestMessage(); + } + }, + [ + setMessages, + setConversation, + queryClient, + isAddedRequest, + setShowStopButton, + resetLatestMessage, + ], + ); + + const createdHandler = useCallback( + (data: TResData, submission: TSubmission) => { + const { messages, userMessage, isRegenerate = false } = submission; + const initialResponse = { + ...submission.initialResponse, + parentMessageId: userMessage?.messageId, + messageId: userMessage?.messageId + '_', + }; + if (isRegenerate) { + setMessages([...messages, initialResponse]); + } else { + setMessages([...messages, userMessage, initialResponse]); + } + + const { conversationId, parentMessageId } = userMessage; + + let update = {} as TConversation; + if (setConversation && !isAddedRequest) { + setConversation((prevState) => { + let title = prevState?.title; + const parentId = isRegenerate ? userMessage?.overrideParentMessageId : parentMessageId; + if (parentId !== Constants.NO_PARENT && title?.toLowerCase()?.includes('new chat')) { + const convos = queryClient.getQueryData([QueryKeys.allConversations]); + const cachedConvo = getConversationById(convos, conversationId); + title = cachedConvo?.title; + } + + update = tConvoUpdateSchema.parse({ + ...prevState, + conversationId, + title, + }) as TConversation; + + return update; + }); + + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + if (parentMessageId === Constants.NO_PARENT) { + return addConversation(convoData, update); + } else { + return updateConversation(convoData, update); + } + }); + } else if (setConversation) { + setConversation((prevState) => { + update = tConvoUpdateSchema.parse({ + ...prevState, + conversationId, + }) as TConversation; + return update; + }); + } + + if (resetLatestMessage) { + resetLatestMessage(); + } + }, + [setMessages, setConversation, queryClient, isAddedRequest, resetLatestMessage], + ); + + const finalHandler = useCallback( + (data: TResData, submission: TSubmission) => { + const { requestMessage, responseMessage, conversation, runMessages } = data; + const { messages, conversation: submissionConvo, isRegenerate = false } = submission; + + setShowStopButton(false); + setCompleted((prev) => new Set(prev.add(submission?.initialResponse?.messageId))); + + const currentMessages = getMessages(); + // Early return if messages are empty; i.e., the user navigated away + if (!currentMessages?.length) { + return setIsSubmitting(false); + } + + // update the messages; if assistants endpoint, client doesn't receive responseMessage + if (runMessages) { + setMessages([...runMessages]); + } else if (isRegenerate && responseMessage) { + setMessages([...messages, responseMessage]); + } else if (responseMessage) { + setMessages([...messages, requestMessage, responseMessage]); + } + + const isNewConvo = conversation.conversationId !== submissionConvo.conversationId; + if (isNewConvo) { + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return deleteConversation(convoData, submissionConvo.conversationId as string); + }); + } + + // refresh title + if ( + genTitle && + isNewConvo && + requestMessage && + requestMessage.parentMessageId === Constants.NO_PARENT + ) { + setTimeout(() => { + genTitle.mutate({ conversationId: conversation.conversationId as string }); + }, 2500); + } + + if (setConversation && !isAddedRequest) { + setConversation((prevState) => { + const update = { + ...prevState, + ...conversation, + }; + + if (prevState?.model && prevState.model !== submissionConvo.model) { + update.model = prevState.model; + } + + return update; + }); + } + + setIsSubmitting(false); + }, + [ + genTitle, + queryClient, + getMessages, + setMessages, + setCompleted, + isAddedRequest, + setConversation, + setIsSubmitting, + setShowStopButton, + ], + ); + + const errorHandler = useCallback( + ({ data, submission }: { data?: TResData; submission: TSubmission }) => { + const { messages, userMessage, initialResponse } = submission; + + setCompleted((prev) => new Set(prev.add(initialResponse.messageId))); + + const conversationId = userMessage?.conversationId ?? submission?.conversationId; + + const parseErrorResponse = (data: TResData | Partial) => { + const metadata = data['responseMessage'] ?? data; + const errorMessage = { + ...initialResponse, + ...metadata, + error: true, + parentMessageId: userMessage?.messageId, + }; + + if (!errorMessage.messageId) { + errorMessage.messageId = v4(); + } + + return tMessageSchema.parse(errorMessage); + }; + + if (!data) { + const convoId = conversationId ?? v4(); + const errorResponse = parseErrorResponse({ + text: 'Error connecting to server, try refreshing the page.', + ...submission, + conversationId: convoId, + }); + setMessages([...messages, userMessage, errorResponse]); + if (newConversation) { + newConversation({ + template: { conversationId: convoId }, + preset: tPresetSchema.parse(submission?.conversation), + }); + } + setIsSubmitting(false); + return; + } + + if (!conversationId && !data.conversationId) { + const convoId = v4(); + const errorResponse = parseErrorResponse(data); + setMessages([...messages, userMessage, errorResponse]); + if (newConversation) { + newConversation({ + template: { conversationId: convoId }, + preset: tPresetSchema.parse(submission?.conversation), + }); + } + setIsSubmitting(false); + return; + } else if (!data.conversationId) { + const errorResponse = parseErrorResponse(data); + setMessages([...messages, userMessage, errorResponse]); + setIsSubmitting(false); + return; + } + + console.log('Error:', data); + const errorResponse = tMessageSchema.parse({ + ...data, + error: true, + parentMessageId: userMessage?.messageId, + }); + + setMessages([...messages, userMessage, errorResponse]); + if (data.conversationId && paramId === 'new' && newConversation) { + newConversation({ + template: { conversationId: data.conversationId }, + preset: tPresetSchema.parse(submission?.conversation), + }); + } + + setIsSubmitting(false); + return; + }, + [setMessages, paramId, setIsSubmitting, setCompleted, newConversation], + ); + + const abortConversation = useCallback( + async (conversationId = '', submission: TSubmission, messages?: TMessage[]) => { + const runAbortKey = `${conversationId}:${messages?.[messages.length - 1]?.messageId ?? ''}`; + console.log({ conversationId, submission, messages, runAbortKey }); + const { endpoint: _endpoint, endpointType } = submission?.conversation || {}; + const endpoint = endpointType ?? _endpoint; + try { + const response = await fetch(`${EndpointURLs[endpoint ?? '']}/abort`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + abortKey: runAbortKey, + endpoint, + }), + }); + + // 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] RESPONSE STATUS: ${response.status}`, data); + if (response.status === 404) { + setIsSubmitting(false); + return; + } + if (data.final) { + finalHandler(data, submission); + } else { + cancelHandler(data, submission); + } + } else if (response.status === 204) { + const responseMessage = { + ...submission.initialResponse, + }; + + const data = { + requestMessage: submission.userMessage, + responseMessage: responseMessage, + conversation: submission.conversation, + }; + console.log(`[aborted] RESPONSE STATUS: ${response.status}`, data); + setIsSubmitting(false); + } 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.userMessage, errorResponse]); + if (newConversation) { + newConversation({ + template: { conversationId: convoId }, + preset: tPresetSchema.parse(submission?.conversation), + }); + } + setIsSubmitting(false); + } + }, + [token, setIsSubmitting, finalHandler, cancelHandler, setMessages, newConversation], + ); + + return { + syncHandler, + finalHandler, + errorHandler, + messageHandler, + contentHandler, + createdHandler, + abortConversation, + }; +} diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 8c9eae93cd1..7fb61d111f1 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -1,72 +1,44 @@ import { v4 } from 'uuid'; import { useSetRecoilState } from 'recoil'; -import { useParams } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { /* @ts-ignore */ SSE, - QueryKeys, - Constants, - EndpointURLs, createPayload, - tPresetSchema, - tMessageSchema, - LocalStorageKeys, - tConvoUpdateSchema, removeNullishValues, isAssistantsEndpoint, } from 'librechat-data-provider'; import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query'; -import type { - TResPlugin, - TMessage, - TConversation, - TSubmission, - ConversationData, -} from 'librechat-data-provider'; -import { - addConversation, - deleteConversation, - updateConversation, - getConversationById, -} from '~/utils'; +import type { TSubmission } from 'librechat-data-provider'; +import type { EventHandlerParams } from './useEventHandlers'; +import type { TResData } from '~/common'; import { useGenTitleMutation } from '~/data-provider'; -import useContentHandler from './useContentHandler'; -import { useAuthContext } from '../AuthContext'; -import useChatHelpers from '../useChatHelpers'; +import { useAuthContext } from '~/hooks/AuthContext'; +import useEventHandlers from './useEventHandlers'; 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) { - const queryClient = useQueryClient(); +type ChatHelpers = Pick< + EventHandlerParams, + | 'setMessages' + | 'getMessages' + | 'setConversation' + | 'setIsSubmitting' + | 'newConversation' + | 'resetLatestMessage' +>; + +export default function useSSE( + submission: TSubmission | null, + chatHelpers: ChatHelpers, + isAddedRequest = false, + runIndex = 0, +) { const genTitle = useGenTitleMutation(); - const setActiveRunId = useSetRecoilState(store.activeRunFamily(index)); + const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex)); - const { conversationId: paramId } = useParams(); const { token, isAuthenticated } = useAuthContext(); const [completed, setCompleted] = useState(new Set()); - const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); + const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(runIndex)); const { setMessages, @@ -75,435 +47,34 @@ export default function useSSE(submission: TSubmission | null, index = 0) { setIsSubmitting, newConversation, resetLatestMessage, - } = useChatHelpers(index, paramId); - const contentHandler = useContentHandler({ setMessages, getMessages }); + } = chatHelpers; + + const { + syncHandler, + finalHandler, + errorHandler, + messageHandler, + contentHandler, + createdHandler, + abortConversation, + } = useEventHandlers({ + genTitle, + setMessages, + getMessages, + setCompleted, + isAddedRequest, + setConversation, + setIsSubmitting, + newConversation, + setShowStopButton, + resetLatestMessage, + }); const { data: startupConfig } = useGetStartupConfig(); const balanceQuery = useGetUserBalance({ enabled: !!isAuthenticated && startupConfig?.checkBalance, }); - const messageHandler = useCallback( - (data: string, submission: TSubmission) => { - const { - messages, - userMessage, - plugin, - plugins, - initialResponse, - isRegenerate = false, - } = submission; - - if (isRegenerate) { - setMessages([ - ...messages, - { - ...initialResponse, - text: data, - plugin: plugin ?? null, - plugins: plugins ?? [], - // unfinished: true - }, - ]); - } else { - setMessages([ - ...messages, - userMessage, - { - ...initialResponse, - text: data, - plugin: plugin ?? null, - plugins: plugins ?? [], - // unfinished: true - }, - ]); - } - }, - [setMessages], - ); - - const cancelHandler = useCallback( - (data: TResData, submission: TSubmission) => { - const { requestMessage, responseMessage, conversation } = data; - const { messages, isRegenerate = false } = submission; - - const convoUpdate = conversation ?? submission.conversation; - - // update the messages - if (isRegenerate) { - const messagesUpdate = [...messages, responseMessage].filter((msg) => msg); - setMessages(messagesUpdate); - } else { - const messagesUpdate = [...messages, requestMessage, responseMessage].filter((msg) => msg); - setMessages(messagesUpdate); - } - - const isNewConvo = conversation.conversationId !== submission.conversation.conversationId; - if (isNewConvo) { - queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { - if (!convoData) { - return convoData; - } - return deleteConversation(convoData, submission.conversation.conversationId as string); - }); - } - - // refresh title - if (isNewConvo && requestMessage?.parentMessageId === Constants.NO_PARENT) { - setTimeout(() => { - genTitle.mutate({ conversationId: convoUpdate.conversationId as string }); - }, 2500); - } - - setConversation((prevState) => { - const update = { - ...prevState, - ...convoUpdate, - }; - - return update; - }); - - setIsSubmitting(false); - }, - [setMessages, setConversation, genTitle, queryClient, setIsSubmitting], - ); - - const syncHandler = useCallback( - (data: TSyncData, submission: TSubmission) => { - const { conversationId, thread_id, responseMessage, requestMessage } = data; - const { initialResponse, messages: _messages, userMessage } = submission; - - const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId); - - setMessages([ - ...messages, - requestMessage, - { - ...initialResponse, - ...responseMessage, - }, - ]); - - let update = {} as TConversation; - setConversation((prevState) => { - let title = prevState?.title; - const parentId = requestMessage.parentMessageId; - if (parentId !== Constants.NO_PARENT && title?.toLowerCase()?.includes('new chat')) { - const convos = queryClient.getQueryData([QueryKeys.allConversations]); - const cachedConvo = getConversationById(convos, conversationId); - title = cachedConvo?.title; - } - - update = tConvoUpdateSchema.parse({ - ...prevState, - conversationId, - thread_id, - title, - messages: [requestMessage.messageId, responseMessage.messageId], - }) as TConversation; - - return update; - }); - - queryClient.setQueryData([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(); - }, - [setMessages, setConversation, queryClient, setShowStopButton, resetLatestMessage], - ); - - const createdHandler = useCallback( - (data: TResData, submission: TSubmission) => { - const { messages, userMessage, isRegenerate = false } = submission; - const initialResponse = { - ...submission.initialResponse, - parentMessageId: userMessage?.messageId, - messageId: userMessage?.messageId + '_', - }; - if (isRegenerate) { - setMessages([...messages, initialResponse]); - } else { - setMessages([...messages, userMessage, initialResponse]); - } - - const { conversationId, parentMessageId } = userMessage; - - let update = {} as TConversation; - setConversation((prevState) => { - let title = prevState?.title; - const parentId = isRegenerate ? userMessage?.overrideParentMessageId : parentMessageId; - if (parentId !== Constants.NO_PARENT && title?.toLowerCase()?.includes('new chat')) { - const convos = queryClient.getQueryData([QueryKeys.allConversations]); - const cachedConvo = getConversationById(convos, conversationId); - title = cachedConvo?.title; - } - - update = tConvoUpdateSchema.parse({ - ...prevState, - conversationId, - title, - }) as TConversation; - - return update; - }); - - queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { - if (!convoData) { - return convoData; - } - if (parentMessageId === Constants.NO_PARENT) { - return addConversation(convoData, update); - } else { - return updateConversation(convoData, update); - } - }); - resetLatestMessage(); - }, - [setMessages, setConversation, queryClient, resetLatestMessage], - ); - - const finalHandler = useCallback( - (data: TResData, submission: TSubmission) => { - const { requestMessage, responseMessage, conversation, runMessages } = data; - const { messages, conversation: submissionConvo, isRegenerate = false } = submission; - - setShowStopButton(false); - setCompleted((prev) => new Set(prev.add(submission?.initialResponse?.messageId))); - - const currentMessages = getMessages(); - // Early return if messages are empty; i.e., the user navigated away - if (!currentMessages?.length) { - return setIsSubmitting(false); - } - - // update the messages; if assistants endpoint, client doesn't receive responseMessage - if (runMessages) { - setMessages([...runMessages]); - } else if (isRegenerate && responseMessage) { - setMessages([...messages, responseMessage]); - } else if (responseMessage) { - setMessages([...messages, requestMessage, responseMessage]); - } - - const isNewConvo = conversation.conversationId !== submissionConvo.conversationId; - if (isNewConvo) { - queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { - if (!convoData) { - return convoData; - } - return deleteConversation(convoData, submissionConvo.conversationId as string); - }); - } - - // refresh title - if (isNewConvo && requestMessage && requestMessage.parentMessageId === Constants.NO_PARENT) { - setTimeout(() => { - genTitle.mutate({ conversationId: conversation.conversationId as string }); - }, 2500); - } - - setConversation((prevState) => { - const update = { - ...prevState, - ...conversation, - }; - - if (prevState?.model && prevState.model !== submissionConvo.model) { - update.model = prevState.model; - } - - return update; - }); - - setIsSubmitting(false); - }, - [ - genTitle, - queryClient, - getMessages, - setMessages, - setConversation, - setIsSubmitting, - setShowStopButton, - ], - ); - - const errorHandler = useCallback( - ({ data, submission }: { data?: TResData; submission: TSubmission }) => { - const { messages, userMessage, initialResponse } = submission; - - setCompleted((prev) => new Set(prev.add(initialResponse.messageId))); - - const conversationId = userMessage?.conversationId ?? submission?.conversationId; - - const parseErrorResponse = (data: TResData | Partial) => { - const metadata = data['responseMessage'] ?? data; - const errorMessage = { - ...initialResponse, - ...metadata, - error: true, - parentMessageId: userMessage?.messageId, - }; - - if (!errorMessage.messageId) { - errorMessage.messageId = v4(); - } - - return tMessageSchema.parse(errorMessage); - }; - - if (!data) { - const convoId = conversationId ?? v4(); - const errorResponse = parseErrorResponse({ - text: 'Error connecting to server, try refreshing the page.', - ...submission, - conversationId: convoId, - }); - setMessages([...messages, userMessage, errorResponse]); - newConversation({ - template: { conversationId: convoId }, - preset: tPresetSchema.parse(submission?.conversation), - }); - setIsSubmitting(false); - return; - } - - if (!conversationId && !data.conversationId) { - const convoId = v4(); - const errorResponse = parseErrorResponse(data); - setMessages([...messages, userMessage, errorResponse]); - newConversation({ - template: { conversationId: convoId }, - preset: tPresetSchema.parse(submission?.conversation), - }); - setIsSubmitting(false); - return; - } else if (!data.conversationId) { - const errorResponse = parseErrorResponse(data); - setMessages([...messages, userMessage, errorResponse]); - setIsSubmitting(false); - return; - } - - console.log('Error:', data); - const errorResponse = tMessageSchema.parse({ - ...data, - error: true, - parentMessageId: userMessage?.messageId, - }); - - setMessages([...messages, userMessage, errorResponse]); - if (data.conversationId && paramId === 'new') { - newConversation({ - template: { conversationId: data.conversationId }, - preset: tPresetSchema.parse(submission?.conversation), - }); - } - - setIsSubmitting(false); - return; - }, - [setMessages, paramId, setIsSubmitting, newConversation], - ); - - const abortConversation = useCallback( - async (conversationId = '', submission: TSubmission) => { - let runAbortKey = ''; - try { - const conversation = (JSON.parse( - localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP) ?? '', - ) ?? {}) 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; - try { - const response = await fetch(`${EndpointURLs[endpoint ?? '']}/abort`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - abortKey: isAssistantsEndpoint(_endpoint) ? runAbortKey : conversationId, - endpoint, - }), - }); - - // 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 (response.status === 404) { - setIsSubmitting(false); - return; - } - if (data.final) { - finalHandler(data, submission); - } else { - cancelHandler(data, submission); - } - } else if (response.status === 204) { - const responseMessage = { - ...submission.initialResponse, - }; - - const data = { - requestMessage: submission.userMessage, - 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.userMessage, errorResponse]); - newConversation({ - template: { conversationId: convoId }, - preset: tPresetSchema.parse(submission?.conversation), - }); - setIsSubmitting(false); - } - }, - [token, setIsSubmitting, finalHandler, cancelHandler, setMessages, newConversation], - ); - useEffect(() => { if (submission === null || Object.keys(submission).length === 0) { return; @@ -571,16 +142,6 @@ export default function useSSE(submission: TSubmission | null, index = 0) { } }; - // events.onaudio = (e: MessageEvent) => { - // const data = JSON.parse(e.data); - // console.log('audio', data); - // if (data.audio) { - // audioSource.addBase64Data(data.audio); - // } - // }; - - // events.onend = () => audioSource.close(); - events.onopen = () => console.log('connection is opened'); events.oncancel = async () => { @@ -595,9 +156,12 @@ export default function useSSE(submission: TSubmission | null, index = 0) { } setCompleted((prev) => new Set(prev.add(streamKey))); + const latestMessages = getMessages(); + const conversationId = latestMessages?.[latestMessages?.length - 1]?.conversationId; return await abortConversation( - userMessage?.conversationId ?? submission?.conversationId, + conversationId ?? userMessage?.conversationId ?? submission?.conversationId, submission, + latestMessages, ); }; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index f974f9722a0..78a3db5141f 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './Assistants'; +export * from './Chat'; export * from './Config'; export * from './Conversations'; export * from './Nav'; @@ -19,11 +20,10 @@ export { default as useTimeout } from './useTimeout'; export { default as useNewConvo } from './useNewConvo'; export { default as useLocalize } from './useLocalize'; export { default as useMediaQuery } from './useMediaQuery'; -export { default as useChatHelpers } from './useChatHelpers'; export { default as useScrollToRef } from './useScrollToRef'; export { default as useLocalStorage } from './useLocalStorage'; export { default as useDelayedRender } from './useDelayedRender'; export { default as useOnClickOutside } from './useOnClickOutside'; -export { default as useGenerationsByLatest } from './useGenerationsByLatest'; export { default as useSpeechToText } from './Input/useSpeechToText'; export { default as useTextToSpeech } from './Input/useTextToSpeech'; +export { default as useGenerationsByLatest } from './useGenerationsByLatest'; diff --git a/client/src/hooks/useChatHelpers.ts b/client/src/hooks/useChatHelpers.ts deleted file mode 100644 index cb4d72a9996..00000000000 --- a/client/src/hooks/useChatHelpers.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { v4 } from 'uuid'; -import { useCallback, useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; -import { - Constants, - QueryKeys, - ContentTypes, - parseCompactConvo, - isAssistantsEndpoint, -} from 'librechat-data-provider'; -import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'; -import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query'; -import type { - TMessage, - TSubmission, - TEndpointOption, - TEndpointsConfig, -} from 'librechat-data-provider'; -import type { TAskFunction } from '~/common'; -import useSetFilesToDelete from './Files/useSetFilesToDelete'; -import useGetSender from './Conversations/useGetSender'; -import { useAuthContext } from './AuthContext'; -import useUserKey from './Input/useUserKey'; -import { getEndpointField } from '~/utils'; -import useNewConvo from './useNewConvo'; -import store from '~/store'; - -// this to be set somewhere else -export default function useChatHelpers(index = 0, paramId: string | undefined) { - const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); - const [files, setFiles] = useRecoilState(store.filesByIndex(index)); - const [filesLoading, setFilesLoading] = useState(false); - const setFilesToDelete = useSetFilesToDelete(); - const getSender = useGetSender(); - - const queryClient = useQueryClient(); - const { isAuthenticated } = useAuthContext(); - - const { newConversation } = useNewConvo(index); - const { useCreateConversationAtom } = store; - const { conversation, setConversation } = useCreateConversationAtom(index); - const { conversationId, endpoint } = conversation ?? {}; - - const queryParam = paramId === 'new' ? paramId : conversationId ?? paramId ?? ''; - - /* Messages: here simply to fetch, don't export and use `getMessages()` instead */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { data: _messages } = useGetMessagesByConvoId(conversationId ?? '', { - enabled: isAuthenticated, - }); - - const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); - const [isSubmitting, setIsSubmitting] = useRecoilState(store.isSubmittingFamily(index)); - const [latestMessage, setLatestMessage] = useRecoilState(store.latestMessageFamily(index)); - const setSiblingIdx = useSetRecoilState( - store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null), - ); - - const setMessages = useCallback( - (messages: TMessage[]) => { - queryClient.setQueryData([QueryKeys.messages, queryParam], messages); - }, - // [conversationId, queryClient], - [queryParam, queryClient], - ); - - const getMessages = useCallback(() => { - return queryClient.getQueryData([QueryKeys.messages, queryParam]); - }, [queryParam, queryClient]); - - /* Conversation */ - // const setActiveConvos = useSetRecoilState(store.activeConversations); - - // const setConversation = useCallback( - // (convoUpdate: TConversation) => { - // _setConversation(prev => { - // const { conversationId: convoId } = prev ?? { conversationId: null }; - // const { conversationId: currentId } = convoUpdate; - // if (currentId && convoId && convoId !== 'new' && convoId !== currentId) { - // // for now, we delete the prev convoId from activeConversations - // const newActiveConvos = { [currentId]: true }; - // setActiveConvos(newActiveConvos); - // } - // return convoUpdate; - // }); - // }, - // [_setConversation, setActiveConvos], - // ); - const { getExpiry } = useUserKey(endpoint ?? ''); - const setSubmission = useSetRecoilState(store.submissionByIndex(index)); - - const ask: TAskFunction = ( - { text, parentMessageId = null, conversationId = null, messageId = null }, - { - editedText = null, - editedMessageId = null, - resubmitFiles = false, - isRegenerate = false, - isContinued = false, - isEdited = false, - } = {}, - ) => { - setShowStopButton(false); - if (!!isSubmitting || text === '') { - return; - } - - if (endpoint === null) { - console.error('No endpoint available'); - return; - } - - conversationId = conversationId ?? conversation?.conversationId ?? null; - if (conversationId == 'search') { - console.error('cannot send any message under search view!'); - return; - } - - if (isContinued && !latestMessage) { - console.error('cannot continue AI message without latestMessage!'); - return; - } - - 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, - ); - - let thread_id = parentMessage?.thread_id ?? latestMessage?.thread_id; - if (!thread_id) { - thread_id = currentMessages.find((message) => message.thread_id)?.thread_id; - } - - const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); - - // set the endpoint option - const convo = parseCompactConvo({ - endpoint, - endpointType, - conversation: conversation ?? {}, - }); - - const { modelDisplayLabel } = endpointsConfig?.[endpoint ?? ''] ?? {}; - const endpointOption = { - ...convo, - endpoint, - endpointType, - modelDisplayLabel, - key: getExpiry(), - thread_id, - } as TEndpointOption; - const responseSender = getSender({ model: conversation?.model, ...endpointOption }); - - const currentMsg: TMessage = { - text, - sender: 'User', - isCreatedByUser: true, - parentMessageId, - conversationId, - messageId: isContinued && messageId ? messageId : fakeMessageId, - thread_id, - error: false, - }; - - const reuseFiles = (isRegenerate || resubmitFiles) && parentMessage?.files; - if (reuseFiles && parentMessage.files?.length) { - currentMsg.files = parentMessage.files; - setFiles(new Map()); - setFilesToDelete({}); - } else if (files.size > 0) { - currentMsg.files = Array.from(files.values()).map((file) => ({ - file_id: file.file_id, - filepath: file.filepath, - type: file.type || '', // Ensure type is not undefined - height: file.height, - width: file.width, - })); - setFiles(new Map()); - setFilesToDelete({}); - } - - // construct the placeholder response message - const generation = editedText ?? latestMessage?.text ?? ''; - const responseText = isEditOrContinue ? generation : ''; - - const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null; - const initialResponse: TMessage = { - sender: responseSender, - text: responseText, - endpoint: endpoint ?? '', - parentMessageId: isRegenerate ? messageId : fakeMessageId, - messageId: responseMessageId ?? `${isRegenerate ? messageId : fakeMessageId}_`, - thread_id, - conversationId, - unfinished: false, - isCreatedByUser: false, - isEdited: isEditOrContinue, - iconURL: convo.iconURL, - error: false, - }; - - if (isAssistantsEndpoint(endpoint)) { - 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); - } - - const submission: TSubmission = { - conversation: { - ...conversation, - conversationId, - }, - endpointOption, - userMessage: { - ...currentMsg, - generation, - responseMessageId, - overrideParentMessageId: isRegenerate ? messageId : null, - }, - messages: currentMessages, - isEdited: isEditOrContinue, - isContinued, - isRegenerate, - initialResponse, - }; - - if (isRegenerate) { - setMessages([...submission.messages, initialResponse]); - } else { - setMessages([...submission.messages, currentMsg, initialResponse]); - } - setLatestMessage(initialResponse); - setSubmission(submission); - }; - - const regenerate = ({ parentMessageId }) => { - const messages = getMessages(); - const parentMessage = messages?.find((element) => element.messageId == parentMessageId); - - if (parentMessage && parentMessage.isCreatedByUser) { - ask({ ...parentMessage }, { isRegenerate: true }); - } else { - console.error( - 'Failed to regenerate the message: parentMessage not found or not created by user.', - ); - } - }; - - const continueGeneration = () => { - if (!latestMessage) { - console.error('Failed to regenerate the message: latestMessage not found.'); - return; - } - - const messages = getMessages(); - - const parentMessage = messages?.find( - (element) => element.messageId == latestMessage.parentMessageId, - ); - - if (parentMessage && parentMessage.isCreatedByUser) { - ask({ ...parentMessage }, { isContinued: true, isRegenerate: true, isEdited: true }); - } else { - console.error( - 'Failed to regenerate the message: parentMessage not found, or not created by user.', - ); - } - }; - - const stopGenerating = () => { - setSubmission(null); - }; - - const handleStopGenerating = (e: React.MouseEvent) => { - e.preventDefault(); - stopGenerating(); - }; - - const handleRegenerate = (e: React.MouseEvent) => { - e.preventDefault(); - const parentMessageId = latestMessage?.parentMessageId; - if (!parentMessageId) { - console.error('Failed to regenerate the message: parentMessageId not found.'); - return; - } - regenerate({ parentMessageId }); - }; - - const handleContinue = (e: React.MouseEvent) => { - e.preventDefault(); - continueGeneration(); - setSiblingIdx(0); - }; - - const [showBingToneSetting, setShowBingToneSetting] = useRecoilState( - store.showBingToneSettingFamily(index), - ); - const [showPopover, setShowPopover] = useRecoilState(store.showPopoverFamily(index)); - const [abortScroll, setAbortScroll] = useRecoilState(store.abortScrollFamily(index)); - const [preset, setPreset] = useRecoilState(store.presetByIndex(index)); - const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettingsFamily(index)); - const [showAgentSettings, setShowAgentSettings] = useRecoilState( - store.showAgentSettingsFamily(index), - ); - - return { - newConversation, - conversation, - setConversation, - // getConvos, - // setConvos, - isSubmitting, - setIsSubmitting, - getMessages, - setMessages, - setSiblingIdx, - latestMessage, - setLatestMessage, - resetLatestMessage, - ask, - index, - regenerate, - stopGenerating, - handleStopGenerating, - handleRegenerate, - handleContinue, - showPopover, - setShowPopover, - abortScroll, - setAbortScroll, - showBingToneSetting, - setShowBingToneSetting, - preset, - setPreset, - optionSettings, - setOptionSettings, - showAgentSettings, - setShowAgentSettings, - files, - setFiles, - filesLoading, - setFilesLoading, - }; -} diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index cca7700e9c2..5b3aea95ff3 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -6,13 +6,7 @@ import { } from 'librechat-data-provider/react-query'; import { useNavigate } from 'react-router-dom'; import { FileSources, LocalStorageKeys, isAssistantsEndpoint } from 'librechat-data-provider'; -import { - useRecoilState, - useRecoilValue, - useSetRecoilState, - useRecoilCallback, - useResetRecoilState, -} from 'recoil'; +import { useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback } from 'recoil'; import type { TPreset, TSubmission, @@ -38,12 +32,14 @@ import store from '~/store'; const useNewConvo = (index = 0) => { const navigate = useNavigate(); const { data: startupConfig } = useGetStartupConfig(); + const clearAllConversations = store.useClearConvoState(); const defaultPreset = useRecoilValue(store.defaultPreset); + const clearAllLatestMessages = store.useClearLatestMessages(); const { setConversation } = store.useCreateConversationAtom(index); const [files, setFiles] = useRecoilState(store.filesByIndex(index)); const setSubmission = useSetRecoilState(store.submissionByIndex(index)); - const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); + const modelsQuery = useGetModelsQuery(); const timeoutIdRef = useRef(); const assistantsListMap = useAssistantListMap(); @@ -67,6 +63,7 @@ const useNewConvo = (index = 0) => { modelsData?: TModelsConfig, buildDefault?: boolean, keepLatestMessage?: boolean, + keepAddedConvos?: boolean, ) => { const modelsConfig = modelsData ?? modelsQuery.data; const { endpoint = null } = conversation; @@ -139,10 +136,13 @@ const useNewConvo = (index = 0) => { }); } + if (!keepAddedConvos) { + clearAllConversations(true); + } setConversation(conversation); setSubmission({} as TSubmission); if (!keepLatestMessage) { - resetLatestMessage(); + clearAllLatestMessages(); } if (conversation.conversationId === 'new' && !modelsData) { @@ -171,12 +171,14 @@ const useNewConvo = (index = 0) => { modelsData, buildDefault = true, keepLatestMessage = false, + keepAddedConvos = false, }: { template?: Partial; preset?: Partial; modelsData?: TModelsConfig; buildDefault?: boolean; keepLatestMessage?: boolean; + keepAddedConvos?: boolean; } = {}) => { pauseGlobalAudio(); @@ -217,7 +219,14 @@ const useNewConvo = (index = 0) => { } } - switchToConversation(conversation, preset, modelsData, buildDefault, keepLatestMessage); + switchToConversation( + conversation, + preset, + modelsData, + buildDefault, + keepLatestMessage, + keepAddedConvos, + ); }, [ pauseGlobalAudio, diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 662b725d945..64774ee73a3 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -150,6 +150,7 @@ export default { com_ui_entries: 'Entries', com_ui_pay_per_call: 'All AI conversations in one place. Pay per call and not per month', com_ui_new_footer: 'All AI conversations in one place.', + com_ui_latest_footer: 'Every AI for Everyone.', com_ui_enter: 'Enter', com_ui_submit: 'Submit', com_ui_none_selected: 'None selected', @@ -198,6 +199,7 @@ export default { com_ui_fork_visible: 'Visible messages only', com_ui_fork_from_message: 'Select a fork option', com_ui_mention: 'Mention an endpoint, assistant, or preset to quickly switch to it', + com_ui_add: 'Add a model or preset for an additional response', com_ui_regenerate: 'Regenerate', com_ui_continue: 'Continue', com_ui_edit: 'Edit', diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 3cc8019cd6b..5080dc95ae8 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -1,7 +1,8 @@ import { atom, - atomFamily, selector, + atomFamily, + selectorFamily, useRecoilState, useRecoilValue, useSetRecoilState, @@ -10,9 +11,53 @@ import { import { LocalStorageKeys } from 'librechat-data-provider'; import type { TMessage, TPreset, TConversation, TSubmission } from 'librechat-data-provider'; import type { TOptionSettings, ExtendedFile } from '~/common'; -import { storeEndpointSettings } from '~/utils'; +import { storeEndpointSettings, logger } from '~/utils'; import { useEffect } from 'react'; +const latestMessageKeysAtom = atom<(string | number)[]>({ + key: 'latestMessageKeys', + default: [], +}); + +const submissionKeysAtom = atom<(string | number)[]>({ + key: 'submissionKeys', + default: [], +}); + +const latestMessageFamily = atomFamily({ + key: 'latestMessageByIndex', + default: null, +}); + +const submissionByIndex = atomFamily({ + key: 'submissionByIndex', + default: null, +}); + +const latestMessageKeysSelector = selector<(string | number)[]>({ + key: 'latestMessageKeysSelector', + get: ({ get }) => { + const keys = get(conversationKeysAtom); + return keys.filter((key) => get(latestMessageFamily(key)) !== null); + }, + set: ({ set }, newKeys) => { + logger.log('setting latestMessageKeys', newKeys); + set(latestMessageKeysAtom, newKeys); + }, +}); + +const submissionKeysSelector = selector<(string | number)[]>({ + key: 'submissionKeysSelector', + get: ({ get }) => { + const keys = get(conversationKeysAtom); + return keys.filter((key) => get(submissionByIndex(key)) !== null); + }, + set: ({ set }, newKeys) => { + logger.log('setting submissionKeysAtom', newKeys); + set(submissionKeysAtom, newKeys); + }, +}); + const conversationByIndex = atomFamily({ key: 'conversationByIndex', default: null, @@ -41,7 +86,10 @@ const conversationByIndex = atomFamily({ } storeEndpointSettings(newValue); - localStorage.setItem(LocalStorageKeys.LAST_CONVO_SETUP, JSON.stringify(newValue)); + localStorage.setItem( + `${LocalStorageKeys.LAST_CONVO_SETUP}_${index}`, + JSON.stringify(newValue), + ); }); }, ] as const, @@ -70,11 +118,6 @@ const presetByIndex = atomFamily({ default: null, }); -const submissionByIndex = atomFamily({ - key: 'submissionByIndex', - default: null, -}); - const textByIndex = atomFamily({ key: 'textByIndex', default: '', @@ -125,6 +168,11 @@ const showMentionPopoverFamily = atomFamily({ default: false, }); +const showPlusPopoverFamily = atomFamily({ + key: 'showPlusPopoverByIndex', + default: false, +}); + const globalAudioURLFamily = atomFamily({ key: 'globalAudioURLByIndex', default: null, @@ -150,11 +198,6 @@ const audioRunFamily = atomFamily({ default: null, }); -const latestMessageFamily = atomFamily({ - key: 'latestMessageByIndex', - default: null, -}); - function useCreateConversationAtom(key: string | number) { const [keys, setKeys] = useRecoilState(conversationKeysAtom); const setConversation = useSetRecoilState(conversationByIndex(key)); @@ -170,12 +213,17 @@ function useCreateConversationAtom(key: string | number) { } function useClearConvoState() { + /** Clears all active conversations. Pass `true` to skip the first or root conversation */ const clearAllConversations = useRecoilCallback( ({ reset, snapshot }) => - async () => { + async (skipFirst?: boolean) => { const conversationKeys = await snapshot.getPromise(conversationKeysAtom); for (const conversationKey of conversationKeys) { + if (skipFirst && conversationKey == 0) { + continue; + } + reset(conversationByIndex(conversationKey)); const conversation = await snapshot.getPromise(conversationByIndex(conversationKey)); @@ -192,6 +240,64 @@ function useClearConvoState() { return clearAllConversations; } +const conversationByKeySelector = selectorFamily({ + key: 'conversationByKeySelector', + get: + (index: string | number) => + ({ get }) => { + const conversation = get(conversationByIndex(index)); + return conversation; + }, +}); + +function useClearSubmissionState() { + const clearAllSubmissions = useRecoilCallback( + ({ reset, set, snapshot }) => + async (skipFirst?: boolean) => { + const submissionKeys = await snapshot.getPromise(submissionKeysSelector); + logger.log('submissionKeys', submissionKeys); + + for (const key of submissionKeys) { + if (skipFirst && key == 0) { + continue; + } + + logger.log('resetting submission', key); + reset(submissionByIndex(key)); + } + + set(submissionKeysSelector, []); + }, + [], + ); + + return clearAllSubmissions; +} + +function useClearLatestMessages() { + const clearAllLatestMessages = useRecoilCallback( + ({ reset, set, snapshot }) => + async (skipFirst?: boolean) => { + const latestMessageKeys = await snapshot.getPromise(latestMessageKeysSelector); + logger.log('latestMessageKeys', latestMessageKeys); + + for (const key of latestMessageKeys) { + if (skipFirst && key == 0) { + continue; + } + + logger.log('resetting latest message', key); + reset(latestMessageFamily(key)); + } + + set(latestMessageKeysSelector, []); + }, + [], + ); + + return clearAllLatestMessages; +} + export default { conversationByIndex, filesByIndex, @@ -207,6 +313,7 @@ export default { showPopoverFamily, latestMessageFamily, allConversationsSelector, + conversationByKeySelector, useClearConvoState, useCreateConversationAtom, showMentionPopoverFamily, @@ -215,5 +322,8 @@ export default { audioRunFamily, globalAudioPlayingFamily, globalAudioFetchingFamily, + showPlusPopoverFamily, activePromptByIndex, + useClearSubmissionState, + useClearLatestMessages, }; diff --git a/client/src/style.css b/client/src/style.css index 06ab02068c7..0703e375c27 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -28,6 +28,7 @@ html { --text-tertiary:var(--gray-500); --surface-primary:var(--white); --surface-primary-alt:var(--white); + --surface-primary-contrast:var(--gray-100); --surface-secondary:var(--gray-50); --surface-tertiary:var(--gray-100); --border-light:var(--gray-100); @@ -41,8 +42,9 @@ html { --text-secondary:var(--gray-300); --text-tertiary:var(--gray-500); --surface-primary:var(--gray-900); - --surface-secondary:var(--gray-800); --surface-primary-alt:var(--gray-850); + --surface-primary-contrast:var(--gray-850); + --surface-secondary:var(--gray-800); --surface-tertiary:var(--gray-700); --border-light:var(--gray-700); --border-medium-alt:var(--gray-600); diff --git a/client/src/utils/buildTree.ts b/client/src/utils/buildTree.ts index d75b29f8cba..e6d0b28e209 100644 --- a/client/src/utils/buildTree.ts +++ b/client/src/utils/buildTree.ts @@ -1,78 +1,64 @@ import { TFile, TMessage } from 'librechat-data-provider'; -const even = - 'w-full border-b border-black/10 dark:border-gray-800/50 text-gray-800 bg-white dark:text-gray-200 group dark:bg-gray-800 hover:bg-gray-200/25 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200'; -const odd = - 'w-full border-b border-black/10 bg-gray-50 dark:border-gray-800/50 text-gray-800 dark:text-gray-200 group bg-gray-200 dark:bg-gray-700 hover:bg-gray-200/40 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200'; - +type ParentMessage = TMessage & { children: TMessage[]; depth: number }; export default function buildTree({ messages, fileMap, - groupAll = false, }: { messages: TMessage[] | null; fileMap?: Record; - groupAll?: boolean; }) { if (messages === null) { return null; } - const messageMap: Record = {}; + const messageMap: Record = {}; const rootMessages: TMessage[] = []; + const childrenCount: Record = {}; - if (groupAll) { - return messages.map((m, idx) => ({ ...m, bg: idx % 2 === 0 ? even : odd })); - } - if (!groupAll) { - // Traverse the messages array and store each element in messageMap. - messages.forEach((message) => { - messageMap[message.messageId] = { ...message, children: [] }; + messages.forEach((message) => { + const parentId = message.parentMessageId ?? ''; + childrenCount[parentId] = (childrenCount[parentId] || 0) + 1; - if (message.files && fileMap) { - messageMap[message.messageId].files = message.files.map( - (file) => fileMap[file.file_id ?? ''] ?? file, - ); - } + const extendedMessage: ParentMessage = { + ...message, + children: [], + depth: 0, + siblingIndex: childrenCount[parentId] - 1, + }; - const parentMessage = messageMap[message.parentMessageId ?? '']; - if (parentMessage) { - parentMessage.children.push(messageMap[message.messageId]); - } else { - rootMessages.push(messageMap[message.messageId]); - } - }); + if (message.files && fileMap) { + extendedMessage.files = message.files.map((file) => fileMap[file.file_id ?? ''] ?? file); + } - return rootMessages; - } + messageMap[message.messageId] = extendedMessage; - // // Group all messages into one tree - // let parentId = null; - // messages.forEach((message, i) => { - // messageMap[message.messageId] = { ...message, bg: i % 2 === 0 ? even : odd, children: [] }; - // const currentMessage = messageMap[message.messageId]; - // const parentMessage = messageMap[parentId]; - // if (parentMessage) parentMessage.children.push(currentMessage); - // else rootMessages.push(currentMessage); - // parentId = message.messageId; - // }); + const parentMessage = messageMap[parentId]; + if (parentMessage) { + parentMessage.children.push(extendedMessage); + extendedMessage.depth = parentMessage.depth + 1; + } else { + rootMessages.push(extendedMessage); + } + }); - // return rootMessages; + return rootMessages; +} - // Group all messages by conversation, doesn't look great - // Traverse the messages array and store each element in messageMap. - // rootMessages = {}; - // let parents = 0; - // messages.forEach(message => { - // if (message.conversationId in messageMap) { - // messageMap[message.conversationId].children.push(message); - // } else { - // messageMap[message.conversationId] = { ...message, bg: parents % 2 === 0 ? even : odd, children: [] }; - // rootMessages.push(messageMap[message.conversationId]); - // parents++; - // } - // }); +const even = + 'w-full border-b border-black/10 dark:border-gray-800/50 text-gray-800 bg-white dark:text-gray-200 group dark:bg-gray-800 hover:bg-gray-200/25 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200'; +const odd = + 'w-full border-b border-black/10 bg-gray-50 dark:border-gray-800/50 text-gray-800 dark:text-gray-200 group bg-gray-200 dark:bg-gray-700 hover:bg-gray-200/40 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-200'; - // // return Object.values(rootMessages); - // return rootMessages; +export function groupIntoList({ + messages, +}: // fileMap, +{ + messages: TMessage[] | null; + // fileMap?: Record; +}) { + if (messages === null) { + return null; + } + return messages.map((m, idx) => ({ ...m, bg: idx % 2 === 0 ? even : odd })); } diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index fd557295c1c..06f822b1163 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -85,6 +85,8 @@ export function mapEndpoints(endpointsConfig: TEndpointsConfig) { ); } +const firstLocalConvoKey = LocalStorageKeys.LAST_CONVO_SETUP + '_0'; + /** * Ensures the last selected model stays up to date, as conversation may * update without updating last convo setup when same endpoint */ @@ -98,13 +100,11 @@ export function updateLastSelectedModel({ if (!model) { return; } - const lastConversationSetup = JSON.parse( - localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP) || '{}', - ); + const lastConversationSetup = JSON.parse(localStorage.getItem(firstLocalConvoKey) || '{}'); if (lastConversationSetup.endpoint === endpoint) { lastConversationSetup.model = model; - localStorage.setItem(LocalStorageKeys.LAST_CONVO_SETUP, JSON.stringify(lastConversationSetup)); + localStorage.setItem(firstLocalConvoKey, JSON.stringify(lastConversationSetup)); } const lastSelectedModels = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) || '{}'); diff --git a/client/src/utils/getLocalStorageItems.ts b/client/src/utils/getLocalStorageItems.ts index 2c1a4d4964d..f189d0d9c92 100644 --- a/client/src/utils/getLocalStorageItems.ts +++ b/client/src/utils/getLocalStorageItems.ts @@ -5,7 +5,7 @@ export default function getLocalStorageItems() { lastSelectedModel: localStorage.getItem(LocalStorageKeys.LAST_MODEL), lastSelectedTools: localStorage.getItem(LocalStorageKeys.LAST_TOOLS), lastBingSettings: localStorage.getItem(LocalStorageKeys.LAST_BING), - lastConversationSetup: localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP), + lastConversationSetup: localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP + '_0'), }; const lastSelectedModel = items.lastSelectedModel ? JSON.parse(items.lastSelectedModel) : {}; diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index dce24cebe35..9aafc5fa228 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -12,6 +12,7 @@ export * from './endpoints'; export * from './sharedLink'; export * from './promptGroups'; export { default as cn } from './cn'; +export { default as logger } from './logger'; export { default as buildTree } from './buildTree'; export { default as getLoginError } from './getLoginError'; export { default as cleanupPreset } from './cleanupPreset'; diff --git a/client/src/utils/logger.ts b/client/src/utils/logger.ts new file mode 100644 index 00000000000..1e92efe2823 --- /dev/null +++ b/client/src/utils/logger.ts @@ -0,0 +1,37 @@ +const isDevelopment = import.meta.env.MODE === 'development'; +const isLoggerEnabled = import.meta.env.VITE_ENABLE_LOGGER === 'true'; + +const logger = { + log: (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + console.log(...args); + } + }, + warn: (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + console.warn(...args); + } + }, + error: (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + console.error(...args); + } + }, + info: (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + console.info(...args); + } + }, + debug: (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + console.debug(...args); + } + }, + dir: (...args: unknown[]) => { + if (isDevelopment || isLoggerEnabled) { + console.dir(...args); + } + }, +}; + +export default logger; diff --git a/client/src/utils/textarea.ts b/client/src/utils/textarea.ts index aa742c9ae3f..8081bf5e1ed 100644 --- a/client/src/utils/textarea.ts +++ b/client/src/utils/textarea.ts @@ -58,13 +58,14 @@ export const trimUndoneRange = (textAreaRef: React.RefObject + +interface ImportMetaEnv { + readonly VITE_ENABLE_LOGGER: string; + // Add other env variables here +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index d4583684cc6..1809a2ef48b 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -69,6 +69,7 @@ module.exports = { 'text-tertiary': 'var(--text-tertiary)', 'surface-primary': 'var(--surface-primary)', 'surface-primary-alt': 'var(--surface-primary-alt)', + 'surface-primary-contrast': 'var(--surface-primary-contrast)', 'surface-secondary': 'var(--surface-secondary)', 'surface-tertiary': 'var(--surface-tertiary)', 'border-light': 'var(--border-light)', diff --git a/package-lock.json b/package-lock.json index 5bb82fbae7b..d018ec0eadc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29348,7 +29348,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.0", + "version": "0.7.1", "license": "ISC", "dependencies": { "@types/js-yaml": "^4.0.9", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 97635f9aeee..c3622a3c324 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.0", + "version": "0.7.1", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 0f1665f1b51..0735bd6a5e3 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -814,10 +814,14 @@ export enum Constants { CONFIG_VERSION = '1.1.4', /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ NO_PARENT = '00000000-0000-0000-0000-000000000000', + /** Standard value for the initial conversationId before a request is sent */ + NEW_CONVO = 'new', /** Fixed, encoded domain length for Azure OpenAI Assistants Function name parsing. */ ENCODED_DOMAIN_LENGTH = 10, /** Identifier for using current_model in multi-model requests. */ CURRENT_MODEL = 'current_model', + /** Common divider for text values */ + COMMON_DIVIDER = '__', } export enum LocalStorageKeys { diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index feee35d5f15..50632a3f1f1 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -298,6 +298,8 @@ export const useLoginUserMutation = (): UseMutationResult< onMutate: () => { queryClient.removeQueries(); localStorage.removeItem(LocalStorageKeys.LAST_CONVO_SETUP); + localStorage.removeItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_0`); + localStorage.removeItem(`${LocalStorageKeys.LAST_CONVO_SETUP}_1`); localStorage.removeItem(LocalStorageKeys.LAST_MODEL); localStorage.removeItem(LocalStorageKeys.LAST_TOOLS); localStorage.removeItem(LocalStorageKeys.FILES_TO_DELETE); diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 98b4c70d3f7..ea4624295c7 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -286,6 +286,8 @@ export type TMessage = z.input & { plugins?: TResPlugin[]; content?: TMessageContentParts[]; files?: Partial[]; + depth?: number; + siblingIndex?: number; }; export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => { diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index f43ad2a2c16..04d6ff0e059 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -36,6 +36,9 @@ export type TEndpointOption = { key?: string | null; /* assistant */ thread_id?: string; + /* multi-response stream */ + overrideConvoId?: string; + overrideUserMessageId?: string; }; export type TPayload = Partial &