From 5209f1dc9e8ee208131cf7252854dc5784a49ae7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 6 Mar 2026 00:03:32 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20refactor:=20Optimize=20Message=20Re?= =?UTF-8?q?-renders=20(#12097)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔄 refactor: Update Artifacts and Messages Contexts to Use Latest Message ID and Depth - Modified ArtifactsContext to retrieve latestMessage using Recoil state management. - Updated MessagesViewContext to replace latestMessage with latestMessageId and latestMessageDepth for improved clarity and consistency. - Adjusted various components (HoverButtons, MessageParts, MessageRender, ContentRender) to utilize latestMessageId instead of the entire message object, enhancing performance and reducing unnecessary re-renders. - Refactored useChatHelpers to extract latestMessageId and latestMessageDepth, streamlining message handling across the application. * refactor: Introduce PartWithContext Component for Optimized Message Rendering - Added a new PartWithContext component to encapsulate message part rendering logic, improving context management and reducing redundancy in the ContentParts component. - Updated MessageRender to utilize the new PartWithContext, streamlining the context provider setup and enhancing code clarity. - Refactored related logic to ensure proper context values are passed, improving maintainability and performance in message rendering. * refactor: Update Components to Use Function Declarations and Improve Readability - Refactored several components (MessageContainer, Markdown, MarkdownCode, MarkdownCodeNoExecution, MarkdownAnchor, MarkdownParagraph, MarkdownImage, TextPart, PlaceholderRow) to use function declarations instead of arrow functions, enhancing readability and consistency across the codebase. - Added display names to memoized components for better debugging and profiling in React DevTools. - Improved overall code clarity and maintainability by standardizing component definitions. * refactor: Standardize MessageRender and ContentRender Components for Improved Clarity - Refactored MessageRender and ContentRender components to use function declarations, enhancing readability and consistency. - Streamlined props handling by removing unnecessary parameters and improving the use of hooks for state management. - Updated memoization and rendering logic to optimize performance and reduce unnecessary re-renders. - Enhanced overall code clarity and maintainability by standardizing component definitions and structure. * refactor: Enhance Header Component with Memoization for Performance - Refactored the Header component to utilize React's memoization by wrapping it with the memo function, improving rendering performance by preventing unnecessary re-renders. - Changed the export to a memoized version of the Header component, ensuring better debugging with a display name. - Maintained overall code clarity and consistency in component structure. * refactor: Transition Components to Use Recoil for State Management - Updated multiple components (AddMultiConvo, TemporaryChat, HeaderNewChat, PresetsMenu, ModelSelectorChatContext) to utilize Recoil for state management, enhancing consistency and performance. - Replaced useChatContext with Recoil selectors and atoms, improving data flow and reducing unnecessary re-renders. - Introduced new selectors for conversation ID and endpoint retrieval, streamlining component logic and enhancing maintainability. - Improved overall code clarity by standardizing state management practices across components. * refactor: Integrate getConversation Callback for Enhanced State Management - Updated multiple components (Mention, ModelSelectorChatContext, ModelSelectorContext, FavoritesList) to utilize a getConversation callback instead of directly accessing conversation state, improving encapsulation and maintainability. - Refactored useSelectMention hook to accept getConversation, streamlining conversation retrieval and enhancing code clarity. - Introduced new Recoil selectors for conversation properties, ensuring consistent state management across components. - Enhanced overall code structure by standardizing the approach to conversation handling, reducing redundancy and improving performance. * refactor: Optimize LiveAnnouncer Context Value with useMemo - Updated the LiveAnnouncer component to utilize useMemo for context value creation, enhancing performance by preventing unnecessary recalculations of the context object. - Improved overall code clarity and maintainability by ensuring that context values are only recomputed when their dependencies change. * refactor: Update AgentPanelSwitch to Use Recoil for Agent ID Management - Refactored AgentPanelSwitch component to utilize Recoil for retrieving the current agent ID, replacing the previous use of chat context. - Improved state management by ensuring the agent ID is derived from Recoil, enhancing code clarity and maintainability. - Adjusted useEffect dependencies to reflect the new state management approach, streamlining the component's logic. * refactor: Enhance useLocalize Hook with useCallback for Improved Performance - Updated the useLocalize hook to utilize useCallback for the translation function, optimizing performance by preventing unnecessary re-creations of the function on each render. - Improved code clarity by ensuring that the translation function is memoized, enhancing maintainability and efficiency in localization handling. * refactor: Rename useCreateConversationAtom to useSetConversationAtom for Clarity - Updated the hook name from useCreateConversationAtom to useSetConversationAtom to better reflect its functionality in managing conversation state. - Introduced a new implementation for setting conversation state, enhancing clarity and maintainability in the codebase. - Adjusted related references in the useNewConvo hook to align with the new naming convention. * refactor: Enhance useKeyDialog Hook with useMemo and useCallback for Improved Performance - Updated the useKeyDialog hook to utilize useMemo for returning the dialog state and handlers, optimizing performance by preventing unnecessary recalculations. - Refactored the onOpenChange function to use useCallback, ensuring it only changes when its dependencies do, enhancing maintainability and clarity in the code. - Improved overall code structure and readability by streamlining the hook's logic and dependencies. * feat: Add useRenderChangeLog Hook for Debugging Render Changes - Introduced a new hook, useRenderChangeLog, that logs changes in tracked values between renders when a debug flag is enabled. - Utilizes useEffect and useRef to track previous values and identify changes, enhancing debugging capabilities for component renders. - Provides detailed console output for initial renders and value changes, improving developer insights during the rendering process. * refactor: Update useSelectAgent Hook for Improved State Management and Performance - Refactored the useSelectAgent hook to utilize useRecoilCallback for fetching conversation data, enhancing state management and performance. - Replaced the use of useChatContext with a more efficient approach, streamlining the logic for selecting agents and updating conversations. - Improved error handling and ensured asynchronous operations are properly awaited, enhancing reliability in agent selection and data fetching processes. * refactor: Optimize useDefaultConvo Hook with useCallback for Improved Performance - Refactored the getDefaultConversation function within the useDefaultConvo hook to utilize useCallback, enhancing performance by memoizing the function and preventing unnecessary re-creations on re-renders. - Streamlined the logic for cleaning input and output in the conversation object, improving code clarity and maintainability. - Ensured that dependencies for useCallback are correctly set, enhancing the reliability of the hook's behavior. * refactor: Optimize Agent Components with Memoization for Improved Performance - Refactored multiple agent-related components (AgentAvatar, AgentCategorySelector, AgentSelect, DeleteButton, FileContext, FileSearch, Files) to utilize React.memo for memoization, enhancing rendering performance by preventing unnecessary re-renders. - Updated the FileRow component to make setFilesLoading optional, improving flexibility in file handling. - Streamlined component logic and improved maintainability by ensuring that props are compared efficiently in memoized components. * refactor: Enhance File Handling and Agent Components for Improved Performance - Refactored multiple components (DeleteButton, FileContext, FileSearch, Files) to utilize new file handling hooks that separate chat context from file operations, improving performance and maintainability. - Introduced useFileHandlingNoChatContext and useSharePointFileHandlingNoChatContext hooks to streamline file handling logic, enhancing flexibility in managing file states. - Updated DeleteButton to improve conversation state management and ensure proper handling of agent deletions, enhancing user experience. - Optimized imports and component structure for better clarity and organization across the affected files. * refactor: Enhance useRenderChangeLog Hook with Improved Type Safety and Documentation - Updated the useRenderChangeLog hook to improve type safety by specifying the value types as string, number, boolean, null, or undefined. - Enhanced documentation to clarify usage and enablement of the debug feature, ensuring better developer insights during rendering. - Added a production check to prevent logging in production builds, optimizing performance and maintaining clean console output. * chore: imports * refactor: Replace useRecoilCallback with useGetConversation Hook for Improved Clarity and Performance - Refactored multiple components (AddMultiConvo, ModelSelectorChatContext, FavoritesList, useSelectAgent, usePresets) to utilize the new useGetConversation hook, enhancing clarity and reducing complexity by eliminating the use of useRecoilCallback. - Streamlined conversation retrieval logic across components, improving maintainability and performance. - Updated imports and component structure for better organization and readability. * refactor: Enhance Memoization in DeleteButton Component for Improved Performance - Updated the memoization logic in the DeleteButton component to include a comparison for the setCurrentAgentId prop, ensuring more efficient re-renders. - This change improves performance by preventing unnecessary updates when the agent ID and current agent ID remain unchanged. * chore: fix test * refactor: Improve Memoization Logic in AgentSelect Component - Updated the memoization comparison in the AgentSelect component to directly compare agentQuery.data objects, enhancing performance by ensuring accurate re-renders. - Refactored the useCreateConversationAtom function to streamline the logic for updating conversation keys, improving clarity and maintainability. * refactor: Simplify State Management in DeleteButton Component - Removed unnecessary setConversationOption function, streamlining the logic for updating conversation state after agent deletion. - Updated the conversation state directly within the deleteAgent mutation, improving clarity and maintainability of the component. - Refactored conversationByKeySelector to directly reference conversationByIndex, enhancing performance and reducing complexity in state retrieval. * refactor: Remove Unused Conversation Prop from Mention Component - Eliminated the conversation prop from the Mention component, simplifying its interface and reducing unnecessary dependencies. - Updated the ChatForm component to reflect this change, enhancing clarity and maintainability of the codebase. - Introduced useGetConversation hook for improved conversation retrieval logic, streamlining the component's functionality. * refactor: Simplify File Handling State Management Across Components - Removed the unused setFilesLoading function from FileContext, FileSearch, and Files components, streamlining the file handling state management. - Updated the FileHandlingState type to make setFilesLoading optional, enhancing flexibility in file operations. - Improved memoization logic by directly referencing necessary state properties, ensuring better performance and maintainability. * refactor: Update ArtifactsContext for Improved State Management - Replaced the useChatContext hook with direct Recoil state retrieval for isSubmitting, latestMessage, and conversationId, simplifying the context provider's logic. - Enhanced memoization by ensuring relevant state properties are directly referenced, improving performance and maintainability. - Streamlined the context value creation to reflect the updated state management approach. * refactor: Adjust Memoization Logic in ArtifactsContext for Consistency - Updated the memoization logic in the ArtifactsProvider to ensure the messageId is consistently referenced, improving clarity and maintainability. - This change enhances the performance of the context provider by ensuring all relevant properties are included in the memoization dependencies. --- client/src/Providers/ArtifactsContext.tsx | 14 +- client/src/Providers/MessagesViewContext.tsx | 17 +- client/src/a11y/LiveAnnouncer.tsx | 11 +- client/src/components/Chat/AddMultiConvo.tsx | 20 +- client/src/components/Chat/Header.tsx | 9 +- client/src/components/Chat/Input/ChatForm.tsx | 2 - .../components/Chat/Input/Files/FileRow.tsx | 3 +- client/src/components/Chat/Input/Mention.tsx | 8 +- .../Endpoints/ModelSelectorChatContext.tsx | 41 +- .../Menus/Endpoints/ModelSelectorContext.tsx | 171 ++++---- .../components/Chat/Menus/HeaderNewChat.tsx | 8 +- .../src/components/Chat/Menus/PresetsMenu.tsx | 5 +- .../Chat/Messages/Content/ContentParts.tsx | 94 +++-- .../Chat/Messages/Content/Markdown.tsx | 3 +- .../Messages/Content/MarkdownComponents.tsx | 27 +- .../Chat/Messages/Content/MessageContent.tsx | 5 +- .../components/Chat/Messages/Content/Part.tsx | 378 +++++++++--------- .../Chat/Messages/Content/Parts/Text.tsx | 3 +- .../components/Chat/Messages/HoverButtons.tsx | 8 +- .../src/components/Chat/Messages/Message.tsx | 36 +- .../components/Chat/Messages/MessageParts.tsx | 6 +- .../Chat/Messages/ui/MessageRender.tsx | 311 +++++++------- .../Chat/Messages/ui/PlaceholderRow.tsx | 3 +- client/src/components/Chat/TemporaryChat.tsx | 11 +- .../components/Messages/Content/CodeBlock.tsx | 245 ++++++------ .../src/components/Messages/ContentRender.tsx | 297 +++++++------- .../components/Messages/MessageContent.tsx | 36 +- .../Nav/Favorites/FavoritesList.tsx | 16 +- .../Favorites/tests/FavoritesList.spec.tsx | 1 + .../Share/ShareMessagesProvider.tsx | 3 +- .../SidePanel/Agents/AgentAvatar.tsx | 10 +- .../Agents/AgentCategorySelector.tsx | 10 +- .../SidePanel/Agents/AgentPanelSwitch.tsx | 9 +- .../SidePanel/Agents/AgentSelect.tsx | 17 +- .../SidePanel/Agents/Code/Files.tsx | 29 +- .../SidePanel/Agents/DeleteButton.tsx | 35 +- .../SidePanel/Agents/FileContext.tsx | 44 +- .../SidePanel/Agents/FileSearch.tsx | 45 ++- client/src/hooks/Agents/useSelectAgent.ts | 46 +-- client/src/hooks/Chat/useChatHelpers.ts | 187 +++++---- client/src/hooks/Conversations/index.ts | 1 + .../hooks/Conversations/useDefaultConvo.ts | 83 ++-- .../hooks/Conversations/useGetConversation.ts | 14 + client/src/hooks/Conversations/usePresets.ts | 14 +- client/src/hooks/Endpoint/useKeyDialog.ts | 40 +- client/src/hooks/Files/useFileHandling.ts | 31 +- .../hooks/Files/useSharePointFileHandling.ts | 42 +- client/src/hooks/Input/useSelectMention.ts | 18 +- client/src/hooks/Input/useTextarea.ts | 4 +- .../src/hooks/Messages/useMessageActions.tsx | 15 +- .../src/hooks/Messages/useMessageHelpers.tsx | 4 +- client/src/hooks/Messages/useSubmitMessage.ts | 3 +- client/src/hooks/useLocalize.ts | 7 +- client/src/hooks/useNewConvo.ts | 2 +- client/src/hooks/useRenderChangeLog.ts | 67 ++++ client/src/store/families.ts | 94 ++++- 56 files changed, 1578 insertions(+), 1085 deletions(-) create mode 100644 client/src/hooks/Conversations/useGetConversation.ts create mode 100644 client/src/hooks/useRenderChangeLog.ts diff --git a/client/src/Providers/ArtifactsContext.tsx b/client/src/Providers/ArtifactsContext.tsx index 139f679003..fd67d5af94 100644 --- a/client/src/Providers/ArtifactsContext.tsx +++ b/client/src/Providers/ArtifactsContext.tsx @@ -1,7 +1,8 @@ import React, { createContext, useContext, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; -import { useChatContext } from './ChatContext'; import { getLatestText } from '~/utils'; +import store from '~/store'; export interface ArtifactsContextValue { isSubmitting: boolean; @@ -18,27 +19,28 @@ interface ArtifactsProviderProps { } export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) { - const { isSubmitting, latestMessage, conversation } = useChatContext(); + const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); + const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const conversationId = useRecoilValue(store.conversationIdByIndex(0)); const chatLatestMessageText = useMemo(() => { return getLatestText({ - messageId: latestMessage?.messageId ?? null, text: latestMessage?.text ?? null, content: latestMessage?.content ?? null, + messageId: latestMessage?.messageId ?? null, } as TMessage); }, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]); const defaultContextValue = useMemo( () => ({ isSubmitting, + conversationId: conversationId ?? null, latestMessageText: chatLatestMessageText, latestMessageId: latestMessage?.messageId ?? null, - conversationId: conversation?.conversationId ?? null, }), - [isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId], + [isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversationId], ); - /** Context value only created when relevant values change */ const contextValue = useMemo( () => (value ? { ...defaultContextValue, ...value } : defaultContextValue), [defaultContextValue, value], diff --git a/client/src/Providers/MessagesViewContext.tsx b/client/src/Providers/MessagesViewContext.tsx index f8f5eef12a..f1cae204a4 100644 --- a/client/src/Providers/MessagesViewContext.tsx +++ b/client/src/Providers/MessagesViewContext.tsx @@ -18,7 +18,8 @@ interface MessagesViewContextValue { /** Message state management */ index: ReturnType['index']; - latestMessage: ReturnType['latestMessage']; + latestMessageId: ReturnType['latestMessageId']; + latestMessageDepth: ReturnType['latestMessageDepth']; setLatestMessage: ReturnType['setLatestMessage']; getMessages: ReturnType['getMessages']; setMessages: ReturnType['setMessages']; @@ -39,7 +40,8 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } regenerate, isSubmitting, conversation, - latestMessage, + latestMessageId, + latestMessageDepth, setAbortScroll, handleContinue, setLatestMessage, @@ -83,10 +85,11 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } const messageState = useMemo( () => ({ index, - latestMessage, + latestMessageId, + latestMessageDepth, setLatestMessage, }), - [index, latestMessage, setLatestMessage], + [index, latestMessageId, latestMessageDepth, setLatestMessage], ); /** Combine all values into final context value */ @@ -139,9 +142,9 @@ export function useMessagesOperations() { /** Hook for components that only need message state */ export function useMessagesState() { - const { index, latestMessage, setLatestMessage } = useMessagesViewContext(); + const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext(); return useMemo( - () => ({ index, latestMessage, setLatestMessage }), - [index, latestMessage, setLatestMessage], + () => ({ index, latestMessageId, latestMessageDepth, setLatestMessage }), + [index, latestMessageId, latestMessageDepth, setLatestMessage], ); } diff --git a/client/src/a11y/LiveAnnouncer.tsx b/client/src/a11y/LiveAnnouncer.tsx index 9a02711556..0eac8089bc 100644 --- a/client/src/a11y/LiveAnnouncer.tsx +++ b/client/src/a11y/LiveAnnouncer.tsx @@ -56,10 +56,13 @@ const LiveAnnouncer: React.FC = ({ children }) => { const announceAssertive = announcePolite; - const contextValue = { - announcePolite, - announceAssertive, - }; + const contextValue = useMemo( + () => ({ + announcePolite, + announceAssertive, + }), + [announcePolite, announceAssertive], + ); useEffect(() => { return () => { diff --git a/client/src/components/Chat/AddMultiConvo.tsx b/client/src/components/Chat/AddMultiConvo.tsx index 7cabe0f336..48e9919092 100644 --- a/client/src/components/Chat/AddMultiConvo.tsx +++ b/client/src/components/Chat/AddMultiConvo.tsx @@ -1,17 +1,21 @@ +import { useCallback } from 'react'; +import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PlusCircle } from 'lucide-react'; import { TooltipAnchor } from '@librechat/client'; import { isAssistantsEndpoint } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider'; -import { useChatContext, useAddedChatContext } from '~/Providers'; +import { useGetConversation, useLocalize } from '~/hooks'; import { mainTextareaId } from '~/common'; -import { useLocalize } from '~/hooks'; +import store from '~/store'; function AddMultiConvo() { - const { conversation } = useChatContext(); - const { setConversation: setAddedConvo } = useAddedChatContext(); const localize = useLocalize(); + const getConversation = useGetConversation(0); + const endpoint = useRecoilValue(store.conversationEndpointByIndex(0)); + const setAddedConvo = useSetRecoilState(store.conversationByIndex(1)); - const clickHandler = () => { + const clickHandler = useCallback(() => { + const conversation = getConversation(); const { title: _t, ...convo } = conversation ?? ({} as TConversation); setAddedConvo({ ...convo, @@ -22,13 +26,13 @@ function AddMultiConvo() { if (textarea) { textarea.focus(); } - }; + }, [getConversation, setAddedConvo]); - if (!conversation) { + if (!endpoint) { return null; } - if (isAssistantsEndpoint(conversation.endpoint)) { + if (isAssistantsEndpoint(endpoint)) { return null; } diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 40e2c6b7ad..611e96fdcc 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useMediaQuery } from '@librechat/client'; import { useOutletContext } from 'react-router-dom'; import { AnimatePresence, motion } from 'framer-motion'; @@ -16,7 +16,7 @@ import { cn } from '~/utils'; const defaultInterface = getConfigDefaults().interface; -export default function Header() { +function Header() { const { data: startupConfig } = useGetStartupConfig(); const { navVisible, setNavVisible } = useOutletContext(); @@ -94,3 +94,8 @@ export default function Header() { ); } + +const MemoizedHeader = memo(Header); +MemoizedHeader.displayName = 'Header'; + +export default MemoizedHeader; diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index acce0d6f25..fed355dcb3 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -219,7 +219,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( { )} {showMentionPopover && ( | undefined; abortUpload?: () => void; setFiles: React.Dispatch>>; - setFilesLoading: React.Dispatch>; + setFilesLoading?: React.Dispatch>; fileFilter?: (file: ExtendedFile) => boolean; assistant_id?: string; agent_id?: string; @@ -58,6 +58,7 @@ export default function FileRow({ const { deleteFile } = useFileDeletion({ mutateAsync, agent_id, assistant_id, tool_resource }); useEffect(() => { + if (!setFilesLoading) return; if (files.length === 0) { setFilesLoading(false); return; diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx index 9e56068def..34bddba519 100644 --- a/client/src/components/Chat/Input/Mention.tsx +++ b/client/src/components/Chat/Input/Mention.tsx @@ -2,11 +2,10 @@ import { useState, useRef, useEffect } from 'react'; import { useCombobox } from '@librechat/client'; import { AutoSizer, List } from 'react-virtualized'; import { EModelEndpoint } from 'librechat-data-provider'; -import type { TConversation } from 'librechat-data-provider'; import type { MentionOption, ConvoGenerator } from '~/common'; import type { SetterOrUpdater } from 'recoil'; +import { useGetConversation, useLocalize, TranslationKeys } from '~/hooks'; import useSelectMention from '~/hooks/Input/useSelectMention'; -import { useLocalize, TranslationKeys } from '~/hooks'; import { useAssistantsMapContext } from '~/Providers'; import useMentions from '~/hooks/Input/useMentions'; import { removeCharIfLast } from '~/utils'; @@ -15,7 +14,6 @@ import MentionItem from './MentionItem'; const ROW_HEIGHT = 44; export default function Mention({ - conversation, setShowMentionPopover, newConversation, textAreaRef, @@ -23,7 +21,6 @@ export default function Mention({ placeholder = 'com_ui_mention', includeAssistants = true, }: { - conversation: TConversation | null; setShowMentionPopover: SetterOrUpdater; newConversation: ConvoGenerator; textAreaRef: React.MutableRefObject; @@ -32,6 +29,7 @@ export default function Mention({ includeAssistants?: boolean; }) { const localize = useLocalize(); + const getConversation = useGetConversation(0); const assistantsMap = useAssistantsMapContext(); const { options, @@ -45,9 +43,9 @@ export default function Mention({ const { onSelectMention } = useSelectMention({ presets, modelSpecs, - conversation, assistantsMap, endpointsConfig, + getConversation, newConversation, }); diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx index eac3bb200c..c6f2416d78 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx @@ -1,6 +1,9 @@ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react'; +import { useRecoilValue } from 'recoil'; import type { EModelEndpoint, TConversation } from 'librechat-data-provider'; -import { useChatContext } from '~/Providers/ChatContext'; +import type { ConvoGenerator } from '~/common'; +import { useGetConversation, useNewConvo } from '~/hooks'; +import store from '~/store'; interface ModelSelectorChatContextValue { endpoint?: EModelEndpoint | null; @@ -8,8 +11,8 @@ interface ModelSelectorChatContextValue { spec?: string | null; agent_id?: string | null; assistant_id?: string | null; - conversation: TConversation | null; - newConversation: ReturnType['newConversation']; + getConversation: () => TConversation | null; + newConversation: ConvoGenerator; } const ModelSelectorChatContext = createContext( @@ -17,20 +20,34 @@ const ModelSelectorChatContext = createContext( + (params) => newConversationRef.current(params), + [], + ); /** Context value only created when relevant conversation properties change */ const contextValue = useMemo( () => ({ - endpoint: conversation?.endpoint, - model: conversation?.model, - spec: conversation?.spec, - agent_id: conversation?.agent_id, - assistant_id: conversation?.assistant_id, - conversation, + model, + spec, + agent_id, + endpoint, + assistant_id, + getConversation, newConversation, }), - [conversation, newConversation], + [endpoint, model, spec, agent_id, assistant_id, getConversation, newConversation], ); return ( diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index dd08728560..5a51db6ce9 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -58,7 +58,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const agentsMap = useAgentsMapContext(); const assistantsMap = useAssistantsMapContext(); const { data: endpointsConfig } = useGetEndpointsQuery(); - const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } = + const { endpoint, model, spec, agent_id, assistant_id, getConversation, newConversation } = useModelSelectorChatContext(); const localize = useLocalize(); const { announcePolite } = useLiveAnnouncer(); @@ -114,7 +114,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const { onSelectEndpoint, onSelectSpec } = useSelectMention({ // presets, modelSpecs, - conversation, + getConversation, assistantsMap, endpointsConfig, newConversation, @@ -171,90 +171,115 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector }, 200), [], ); - const setEndpointSearchValue = (endpoint: string, value: string) => { + const setEndpointSearchValue = useCallback((endpoint: string, value: string) => { setEndpointSearchValues((prev) => ({ ...prev, [endpoint]: value, })); - }; + }, []); - const handleSelectSpec = (spec: t.TModelSpec) => { - let model = spec.preset.model ?? null; - onSelectSpec?.(spec); - if (isAgentsEndpoint(spec.preset.endpoint)) { - model = spec.preset.agent_id ?? ''; - } else if (isAssistantsEndpoint(spec.preset.endpoint)) { - model = spec.preset.assistant_id ?? ''; - } - setSelectedValues({ - endpoint: spec.preset.endpoint, - model, - modelSpec: spec.name, - }); - }; + const handleSelectSpec = useCallback( + (spec: t.TModelSpec) => { + let model = spec.preset.model ?? null; + onSelectSpec?.(spec); + if (isAgentsEndpoint(spec.preset.endpoint)) { + model = spec.preset.agent_id ?? ''; + } else if (isAssistantsEndpoint(spec.preset.endpoint)) { + model = spec.preset.assistant_id ?? ''; + } + setSelectedValues({ + endpoint: spec.preset.endpoint, + model, + modelSpec: spec.name, + }); + }, + [onSelectSpec], + ); - const handleSelectEndpoint = (endpoint: Endpoint) => { - if (!endpoint.hasModels) { - if (endpoint.value) { - onSelectEndpoint?.(endpoint.value); + const handleSelectEndpoint = useCallback( + (endpoint: Endpoint) => { + if (!endpoint.hasModels) { + if (endpoint.value) { + onSelectEndpoint?.(endpoint.value); + } + setSelectedValues({ + endpoint: endpoint.value, + model: '', + modelSpec: '', + }); + } + }, + [onSelectEndpoint], + ); + + const handleSelectModel = useCallback( + (endpoint: Endpoint, model: string) => { + if (isAgentsEndpoint(endpoint.value)) { + onSelectEndpoint?.(endpoint.value, { + agent_id: model, + model: agentsMap?.[model]?.model ?? '', + }); + } else if (isAssistantsEndpoint(endpoint.value)) { + onSelectEndpoint?.(endpoint.value, { + assistant_id: model, + model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '', + }); + } else if (endpoint.value) { + onSelectEndpoint?.(endpoint.value, { model }); } setSelectedValues({ endpoint: endpoint.value, - model: '', + model, modelSpec: '', }); - } - }; - const handleSelectModel = (endpoint: Endpoint, model: string) => { - if (isAgentsEndpoint(endpoint.value)) { - onSelectEndpoint?.(endpoint.value, { - agent_id: model, - model: agentsMap?.[model]?.model ?? '', - }); - } else if (isAssistantsEndpoint(endpoint.value)) { - onSelectEndpoint?.(endpoint.value, { - assistant_id: model, - model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '', - }); - } else if (endpoint.value) { - onSelectEndpoint?.(endpoint.value, { model }); - } - setSelectedValues({ - endpoint: endpoint.value, - model, - modelSpec: '', - }); + const modelDisplayName = getModelDisplayName(endpoint, model); + const announcement = localize('com_ui_model_selected', { 0: modelDisplayName }); + announcePolite({ message: announcement, isStatus: true }); + }, + [agentsMap, announcePolite, assistantsMap, getModelDisplayName, localize, onSelectEndpoint], + ); - const modelDisplayName = getModelDisplayName(endpoint, model); - const announcement = localize('com_ui_model_selected', { 0: modelDisplayName }); - announcePolite({ message: announcement, isStatus: true }); - }; - - const value = { - // State - searchValue, - searchResults, - selectedValues, - endpointSearchValues, - // LibreChat - agentsMap, - modelSpecs, - assistantsMap, - mappedEndpoints, - endpointsConfig, - - // Functions - handleSelectSpec, - handleSelectModel, - setSelectedValues, - handleSelectEndpoint, - setEndpointSearchValue, - endpointRequiresUserKey, - setSearchValue: setDebouncedSearchValue, - // Dialog - ...keyProps, - }; + const value = useMemo( + () => ({ + searchValue, + searchResults, + selectedValues, + endpointSearchValues, + agentsMap, + modelSpecs, + assistantsMap, + mappedEndpoints, + endpointsConfig, + handleSelectSpec, + handleSelectModel, + setSelectedValues, + handleSelectEndpoint, + setEndpointSearchValue, + endpointRequiresUserKey, + setSearchValue: setDebouncedSearchValue, + ...keyProps, + }), + [ + searchValue, + searchResults, + selectedValues, + endpointSearchValues, + agentsMap, + modelSpecs, + assistantsMap, + mappedEndpoints, + endpointsConfig, + handleSelectSpec, + handleSelectModel, + setSelectedValues, + handleSelectEndpoint, + setEndpointSearchValue, + endpointRequiresUserKey, + setDebouncedSearchValue, + keyProps, + ], + ); return {children}; } diff --git a/client/src/components/Chat/Menus/HeaderNewChat.tsx b/client/src/components/Chat/Menus/HeaderNewChat.tsx index 764397eddb..a50d42af85 100644 --- a/client/src/components/Chat/Menus/HeaderNewChat.tsx +++ b/client/src/components/Chat/Menus/HeaderNewChat.tsx @@ -1,14 +1,16 @@ import { QueryKeys } from 'librechat-data-provider'; +import { useRecoilValue } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; -import { useChatContext } from '~/Providers'; +import { useNewConvo, useLocalize } from '~/hooks'; import { clearMessagesCache } from '~/utils'; -import { useLocalize } from '~/hooks'; +import store from '~/store'; export default function HeaderNewChat() { const localize = useLocalize(); const queryClient = useQueryClient(); - const { conversation, newConversation } = useChatContext(); + const { newConversation } = useNewConvo(); + const conversation = useRecoilValue(store.conversationByIndex(0)); const clickHandler: React.MouseEventHandler = (e) => { if (e.button === 0 && (e.ctrlKey || e.metaKey)) { diff --git a/client/src/components/Chat/Menus/PresetsMenu.tsx b/client/src/components/Chat/Menus/PresetsMenu.tsx index 7ba0ae5c88..0edd1635bc 100644 --- a/client/src/components/Chat/Menus/PresetsMenu.tsx +++ b/client/src/components/Chat/Menus/PresetsMenu.tsx @@ -1,4 +1,5 @@ import { useRef } from 'react'; +import { useRecoilValue } from 'recoil'; import { Trans } from 'react-i18next'; import { BookCopy } from 'lucide-react'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; @@ -13,7 +14,7 @@ import { import type { FC } from 'react'; import { EditPresetDialog, PresetItems } from './Presets'; import { useLocalize, usePresets } from '~/hooks'; -import { useChatContext } from '~/Providers'; +import store from '~/store'; const PresetsMenu: FC = () => { const localize = useLocalize(); @@ -33,7 +34,7 @@ const PresetsMenu: FC = () => { presetToDelete, confirmDeletePreset, } = usePresets(); - const { preset } = useChatContext(); + const preset = useRecoilValue(store.presetByIndex(0)); const handleDeleteDialogChange = (open: boolean) => { setShowDeleteDialog(open); diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 42ce8b8f14..4b431d7a98 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -15,6 +15,61 @@ import Sources from '~/components/Web/Sources'; import Container from './Container'; import Part from './Part'; +type PartWithContextProps = { + part: TMessageContentParts; + idx: number; + isLastPart: boolean; + messageId: string; + conversationId?: string | null; + nextType?: string; + isSubmitting: boolean; + isLatestMessage?: boolean; + isCreatedByUser: boolean; + isLast: boolean; + partAttachments: TAttachment[] | undefined; +}; + +const PartWithContext = memo(function PartWithContext({ + part, + idx, + isLastPart, + messageId, + conversationId, + nextType, + isSubmitting, + isLatestMessage, + isCreatedByUser, + isLast, + partAttachments, +}: PartWithContextProps) { + const contextValue = useMemo( + () => ({ + messageId, + isExpanded: true as const, + conversationId, + partIndex: idx, + nextType, + isSubmitting, + isLatestMessage, + }), + [messageId, conversationId, idx, nextType, isSubmitting, isLatestMessage], + ); + + return ( + + + + ); +}); + type ContentPartsProps = { content: Array | undefined; messageId: string; @@ -58,37 +113,24 @@ const ContentParts = memo(function ContentParts({ const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; - /** - * Render a single content part with proper context. - */ const renderPart = useCallback( (part: TMessageContentParts, idx: number, isLastPart: boolean) => { const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; - const partAttachments = attachmentMap[toolCallId]; - return ( - - - + idx={idx} + part={part} + isLast={isLast} + messageId={messageId} + isLastPart={isLastPart} + conversationId={conversationId} + isLatestMessage={isLatestMessage} + isCreatedByUser={isCreatedByUser} + nextType={content?.[idx + 1]?.type} + isSubmitting={effectiveIsSubmitting} + partAttachments={attachmentMap[toolCallId]} + /> ); }, [ diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index a763885d2f..1217869a2c 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -27,7 +27,7 @@ type TContentProps = { isLatestMessage: boolean; }; -const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { +const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) { const LaTeXParsing = useRecoilValue(store.LaTeXParsing); const isInitializing = content === ''; @@ -106,5 +106,6 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { ); }); +Markdown.displayName = 'Markdown'; export default Markdown; diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index d647147151..1c5369955d 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -18,7 +18,10 @@ type TCodeProps = { children: React.ReactNode; }; -export const code: React.ElementType = memo(({ className, children }: TCodeProps) => { +export const code: React.ElementType = memo(function MarkdownCode({ + className, + children, +}: TCodeProps) { const canRunCode = useHasAccess({ permissionType: PermissionTypes.RUN_CODE, permission: Permissions.USE, @@ -62,8 +65,12 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps ); } }); +code.displayName = 'MarkdownCode'; -export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => { +export const codeNoExecution: React.ElementType = memo(function MarkdownCodeNoExecution({ + className, + children, +}: TCodeProps) { const match = /language-(\w+)/.exec(className ?? ''); const lang = match && match[1]; @@ -82,13 +89,14 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }: return ; } }); +codeNoExecution.displayName = 'MarkdownCodeNoExecution'; type TAnchorProps = { href: string; children: React.ReactNode; }; -export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { +export const a: React.ElementType = memo(function MarkdownAnchor({ href, children }: TAnchorProps) { const user = useRecoilValue(store.user); const { showToast } = useToastContext(); const localize = useLocalize(); @@ -163,14 +171,16 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { ); }); +a.displayName = 'MarkdownAnchor'; type TParagraphProps = { children: React.ReactNode; }; -export const p: React.ElementType = memo(({ children }: TParagraphProps) => { +export const p: React.ElementType = memo(function MarkdownParagraph({ children }: TParagraphProps) { return

{children}

; }); +p.displayName = 'MarkdownParagraph'; type TImageProps = { src?: string; @@ -180,7 +190,13 @@ type TImageProps = { style?: React.CSSProperties; }; -export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => { +export const img: React.ElementType = memo(function MarkdownImage({ + src, + alt, + title, + className, + style, +}: TImageProps) { // Get the base URL from the API endpoints const baseURL = apiBaseUrl(); @@ -199,3 +215,4 @@ export const img: React.ElementType = memo(({ src, alt, title, className, style return {alt}; }); +img.displayName = 'MarkdownImage'; diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 7a823a07e9..0e2e7faa2c 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -185,4 +185,7 @@ const MessageContent = ({ ); }; -export default memo(MessageContent); +const MemoizedMessageContent = memo(MessageContent); +MemoizedMessageContent.displayName = 'MessageContent'; + +export default MemoizedMessageContent; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index f97d1343b9..55574d576d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -28,212 +28,218 @@ type PartProps = { attachments?: TAttachment[]; }; -const Part = memo( - ({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser }: PartProps) => { - if (!part) { +const Part = memo(function Part({ + part, + isSubmitting, + attachments, + isLast, + showCursor, + isCreatedByUser, +}: PartProps) { + if (!part) { + return null; + } + + if (part.type === ContentTypes.ERROR) { + return ( + + ); + } else if (part.type === ContentTypes.AGENT_UPDATE) { + return ( + <> + + {isLast && showCursor && ( + + + + )} + + ); + } else if (part.type === ContentTypes.TEXT) { + const text = typeof part.text === 'string' ? part.text : part.text?.value; + + if (typeof text !== 'string') { + return null; + } + if (part.tool_call_ids != null && !text) { + return null; + } + /** Handle whitespace-only text to avoid layout shift */ + if (text.length > 0 && /^\s*$/.test(text)) { + /** Show placeholder for whitespace-only last part during streaming */ + if (isLast && showCursor) { + return ( + + + + ); + } + /** Skip rendering non-last whitespace-only parts to avoid empty Container */ + if (!isLast) { + return null; + } + } + return ( + + + + ); + } else if (part.type === ContentTypes.THINK) { + const reasoning = typeof part.think === 'string' ? part.think : part.think?.value; + if (typeof reasoning !== 'string') { + return null; + } + return ; + } else if (part.type === ContentTypes.TOOL_CALL) { + const toolCall = part[ContentTypes.TOOL_CALL]; + + if (!toolCall) { return null; } - if (part.type === ContentTypes.ERROR) { + const isToolCall = + 'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL); + if ( + isToolCall && + (toolCall.name === Tools.execute_code || + toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING) + ) { return ( - ); - } else if (part.type === ContentTypes.AGENT_UPDATE) { + } else if ( + isToolCall && + (toolCall.name === 'image_gen_oai' || + toolCall.name === 'image_edit_oai' || + toolCall.name === 'gemini_image_gen') + ) { return ( - <> - - {isLast && showCursor && ( - - - - )} - + ); - } else if (part.type === ContentTypes.TEXT) { - const text = typeof part.text === 'string' ? part.text : part.text?.value; - - if (typeof text !== 'string') { - return null; - } - if (part.tool_call_ids != null && !text) { - return null; - } - /** Handle whitespace-only text to avoid layout shift */ - if (text.length > 0 && /^\s*$/.test(text)) { - /** Show placeholder for whitespace-only last part during streaming */ - if (isLast && showCursor) { + } else if (isToolCall && toolCall.name === Tools.web_search) { + return ( + + ); + } else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) { + return ( + + ); + } else if (isToolCall) { + return ( + + ); + } else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { + const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER]; + return ( + + ); + } else if ( + toolCall.type === ToolCallTypes.RETRIEVAL || + toolCall.type === ToolCallTypes.FILE_SEARCH + ) { + return ( + + ); + } else if ( + toolCall.type === ToolCallTypes.FUNCTION && + ToolCallTypes.FUNCTION in toolCall && + imageGenTools.has(toolCall.function.name) + ) { + return ( + + ); + } else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) { + if (isImageVisionTool(toolCall)) { + if (isSubmitting && showCursor) { return ( - + ); } - /** Skip rendering non-last whitespace-only parts to avoid empty Container */ - if (!isLast) { - return null; - } - } - return ( - - - - ); - } else if (part.type === ContentTypes.THINK) { - const reasoning = typeof part.think === 'string' ? part.think : part.think?.value; - if (typeof reasoning !== 'string') { - return null; - } - return ; - } else if (part.type === ContentTypes.TOOL_CALL) { - const toolCall = part[ContentTypes.TOOL_CALL]; - - if (!toolCall) { return null; } - const isToolCall = - 'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL); - if ( - isToolCall && - (toolCall.name === Tools.execute_code || - toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING) - ) { - return ( - - ); - } else if ( - isToolCall && - (toolCall.name === 'image_gen_oai' || - toolCall.name === 'image_edit_oai' || - toolCall.name === 'gemini_image_gen') - ) { - return ( - - ); - } else if (isToolCall && toolCall.name === Tools.web_search) { - return ( - - ); - } else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) { - return ( - - ); - } else if (isToolCall) { - return ( - - ); - } else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { - const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER]; - return ( - - ); - } else if ( - toolCall.type === ToolCallTypes.RETRIEVAL || - toolCall.type === ToolCallTypes.FILE_SEARCH - ) { - return ( - - ); - } else if ( - toolCall.type === ToolCallTypes.FUNCTION && - ToolCallTypes.FUNCTION in toolCall && - imageGenTools.has(toolCall.function.name) - ) { - return ( - - ); - } else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) { - if (isImageVisionTool(toolCall)) { - if (isSubmitting && showCursor) { - return ( - - - - ); - } - return null; - } - - return ( - - ); - } - } else if (part.type === ContentTypes.IMAGE_FILE) { - const imageFile = part[ContentTypes.IMAGE_FILE]; - const height = imageFile.height ?? 1920; - const width = imageFile.width ?? 1080; return ( - ); } + } else if (part.type === ContentTypes.IMAGE_FILE) { + const imageFile = part[ContentTypes.IMAGE_FILE]; + const height = imageFile.height ?? 1920; + const width = imageFile.width ?? 1080; + return ( + + ); + } - return null; - }, -); + return null; +}); +Part.displayName = 'Part'; export default Part; diff --git a/client/src/components/Chat/Messages/Content/Parts/Text.tsx b/client/src/components/Chat/Messages/Content/Parts/Text.tsx index c926622c9d..aec8d949e0 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Text.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Text.tsx @@ -17,7 +17,7 @@ type ContentType = | ReactElement> | ReactElement; -const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => { +const TextPart = memo(function TextPart({ text, isCreatedByUser, showCursor }: TextPartProps) { const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]); @@ -46,5 +46,6 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
); }); +TextPart.displayName = 'TextPart'; export default TextPart; diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx index 5d60223d08..180e8b599e 100644 --- a/client/src/components/Chat/Messages/HoverButtons.tsx +++ b/client/src/components/Chat/Messages/HoverButtons.tsx @@ -18,7 +18,7 @@ type THoverButtons = { message: TMessage; regenerate: () => void; handleContinue: (e: React.MouseEvent) => void; - latestMessage: TMessage | null; + latestMessageId?: string; isLast: boolean; index: number; handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void; @@ -119,7 +119,7 @@ const HoverButtons = ({ message, regenerate, handleContinue, - latestMessage, + latestMessageId, isLast, handleFeedback, }: THoverButtons) => { @@ -143,7 +143,7 @@ const HoverButtons = ({ searchResult: message.searchResult, finish_reason: message.finish_reason, isCreatedByUser: message.isCreatedByUser, - latestMessageId: latestMessage?.messageId, + latestMessageId: latestMessageId, }); const { @@ -239,7 +239,7 @@ const HoverButtons = ({ messageId={message.messageId} conversationId={conversation.conversationId} forkingSupported={forkingSupported} - latestMessageId={latestMessage?.messageId} + latestMessageId={latestMessageId} isLast={isLast} /> diff --git a/client/src/components/Chat/Messages/Message.tsx b/client/src/components/Chat/Messages/Message.tsx index 78e08e3631..f9db38fdab 100644 --- a/client/src/components/Chat/Messages/Message.tsx +++ b/client/src/components/Chat/Messages/Message.tsx @@ -4,25 +4,23 @@ import type { TMessageProps } from '~/common'; import MessageRender from './ui/MessageRender'; import MultiMessage from './MultiMessage'; -const MessageContainer = React.memo( - ({ - handleScroll, - children, - }: { - handleScroll: (event?: unknown) => void; - children: React.ReactNode; - }) => { - return ( -
- {children} -
- ); - }, -); +const MessageContainer = React.memo(function MessageContainer({ + handleScroll, + children, +}: { + handleScroll: (event?: unknown) => void; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +}); export default function Message(props: TMessageProps) { const { conversation, handleScroll } = useMessageProcess({ diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 0005ee0499..88162f3287 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -32,7 +32,7 @@ export default function Message(props: TMessageProps) { handleScroll, conversation, isSubmitting, - latestMessage, + latestMessageId, handleContinue, copyToClipboard, regenerateMessage, @@ -142,7 +142,7 @@ export default function Message(props: TMessageProps) { setSiblingIdx={setSiblingIdx} isCreatedByUser={message.isCreatedByUser} conversationId={conversation?.conversationId} - isLatestMessage={messageId === latestMessage?.messageId} + isLatestMessage={messageId === latestMessageId} content={message.content as Array} /> @@ -165,7 +165,7 @@ export default function Message(props: TMessageProps) { regenerate={() => regenerateMessage()} copyToClipboard={copyToClipboard} handleContinue={handleContinue} - latestMessage={latestMessage} + latestMessageId={latestMessageId} isLast={isLast} /> diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 0d40b4a98f..2b4008ba12 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -4,11 +4,11 @@ import { useRecoilValue } from 'recoil'; import { type TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; +import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; -import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import SubRow from '~/components/Chat/Messages/SubRow'; import { cn, getMessageAriaLabel } from '~/utils'; import { fontSizeAtom } from '~/store/fontSize'; @@ -23,180 +23,183 @@ type MessageRenderProps = { 'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount' >; -const MessageRender = memo( - ({ +const MessageRender = memo(function MessageRender({ + message: msg, + siblingIdx, + siblingCount, + setSiblingIdx, + currentEditId, + setCurrentEditId, + isSubmitting = false, +}: MessageRenderProps) { + const localize = useLocalize(); + const { + ask, + edit, + index, + agent, + assistant, + enterEdit, + conversation, + messageLabel, + handleFeedback, + handleContinue, + latestMessageId, + copyToClipboard, + regenerateMessage, + latestMessageDepth, + } = useMessageActions({ message: msg, - siblingIdx, - siblingCount, - setSiblingIdx, currentEditId, setCurrentEditId, - isSubmitting = false, - }: MessageRenderProps) => { - const localize = useLocalize(); - const { - ask, - edit, - index, - agent, - assistant, - enterEdit, - conversation, + }); + const fontSize = useAtomValue(fontSizeAtom); + const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + + const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); + const hasNoChildren = !(msg?.children?.length ?? 0); + const isLast = useMemo( + () => hasNoChildren && (msg?.depth === latestMessageDepth || msg?.depth === -1), + [hasNoChildren, msg?.depth, latestMessageDepth], + ); + const isLatestMessage = msg?.messageId === latestMessageId; + /** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ + const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + + const iconData: TMessageIcon = useMemo( + () => ({ + endpoint: msg?.endpoint ?? conversation?.endpoint, + model: msg?.model ?? conversation?.model, + iconURL: msg?.iconURL, + modelLabel: messageLabel, + isCreatedByUser: msg?.isCreatedByUser, + }), + [ messageLabel, - latestMessage, - handleFeedback, - handleContinue, - copyToClipboard, - regenerateMessage, - } = useMessageActions({ - message: msg, - currentEditId, - setCurrentEditId, - }); - const fontSize = useAtomValue(fontSizeAtom); - const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + conversation?.endpoint, + conversation?.model, + msg?.model, + msg?.iconURL, + msg?.endpoint, + msg?.isCreatedByUser, + ], + ); - const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); - const hasNoChildren = !(msg?.children?.length ?? 0); - const isLast = useMemo( - () => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1), - [hasNoChildren, msg?.depth, latestMessage?.depth], - ); - const isLatestMessage = msg?.messageId === latestMessage?.messageId; - /** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ - const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + const { hasParallelContent } = useContentMetadata(msg); + const messageId = msg?.messageId ?? ''; + const messageContextValue = useMemo( + () => ({ + messageId, + isLatestMessage, + isExpanded: false as const, + isSubmitting: effectiveIsSubmitting, + conversationId: conversation?.conversationId, + }), + [messageId, conversation?.conversationId, effectiveIsSubmitting, isLatestMessage], + ); - const iconData: TMessageIcon = useMemo( - () => ({ - endpoint: msg?.endpoint ?? conversation?.endpoint, - model: msg?.model ?? conversation?.model, - iconURL: msg?.iconURL, - modelLabel: messageLabel, - isCreatedByUser: msg?.isCreatedByUser, - }), - [ - messageLabel, - conversation?.endpoint, - conversation?.model, - msg?.model, - msg?.iconURL, - msg?.endpoint, - msg?.isCreatedByUser, - ], - ); + if (!msg) { + return null; + } - const { hasParallelContent } = useContentMetadata(msg); - - if (!msg) { - return null; + const getChatWidthClass = () => { + if (maximizeChatSpace) { + return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'; } + if (hasParallelContent) { + return 'md:max-w-[58rem] xl:max-w-[70rem]'; + } + return 'md:max-w-[47rem] xl:max-w-[55rem]'; + }; - const getChatWidthClass = () => { - if (maximizeChatSpace) { - return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'; - } - if (hasParallelContent) { - return 'md:max-w-[58rem] xl:max-w-[70rem]'; - } - return 'md:max-w-[47rem] xl:max-w-[55rem]'; - }; + const baseClasses = { + common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ', + chat: getChatWidthClass(), + }; - const baseClasses = { - common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ', - chat: getChatWidthClass(), - }; + const conditionalClasses = { + focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy', + }; - const conditionalClasses = { - focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy', - }; + return ( +
+ {!hasParallelContent && ( +
+
+ +
+
+ )} - return (
{!hasParallelContent && ( -
-
- -
-
+

{messageLabel}

)} -
- {!hasParallelContent && ( -

{messageLabel}

- )} - -
-
- - ({}))} - /> - -
- {hasNoChildren && effectiveIsSubmitting ? ( - - ) : ( - - - - - )} +
+
+ + ({}))} + /> +
+ {hasNoChildren && effectiveIsSubmitting ? ( + + ) : ( + + + + + )}
- ); - }, -); +
+ ); +}); +MessageRender.displayName = 'MessageRender'; export default MessageRender; diff --git a/client/src/components/Chat/Messages/ui/PlaceholderRow.tsx b/client/src/components/Chat/Messages/ui/PlaceholderRow.tsx index d67424a46f..c3a4a68704 100644 --- a/client/src/components/Chat/Messages/ui/PlaceholderRow.tsx +++ b/client/src/components/Chat/Messages/ui/PlaceholderRow.tsx @@ -1,7 +1,8 @@ import { memo } from 'react'; -const PlaceholderRow = memo(() => { +const PlaceholderRow = memo(function PlaceholderRow() { return
; }); +PlaceholderRow.displayName = 'PlaceholderRow'; export default PlaceholderRow; diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index d09cc73289..a4d72d081e 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,8 +1,8 @@ import React from 'react'; +import { useRecoilValue } from 'recoil'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; import { useRecoilState, useRecoilCallback } from 'recoil'; -import { useChatContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -10,13 +10,8 @@ import store from '~/store'; export function TemporaryChat() { const localize = useLocalize(); const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); - const { conversation, isSubmitting } = useChatContext(); - - const temporaryBadge = { - id: 'temporary', - atom: store.isTemporary, - isAvailable: true, - }; + const conversation = useRecoilValue(store.conversationByIndex(0)); + const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); const handleBadgeToggle = useRecoilCallback( () => () => { diff --git a/client/src/components/Messages/Content/CodeBlock.tsx b/client/src/components/Messages/Content/CodeBlock.tsx index eae84e49a9..7407098c5e 100644 --- a/client/src/components/Messages/Content/CodeBlock.tsx +++ b/client/src/components/Messages/Content/CodeBlock.tsx @@ -23,125 +23,138 @@ interface FloatingCodeBarProps extends CodeBarProps { isVisible: boolean; } -const CodeBar: React.FC = React.memo( - ({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => { - const localize = useLocalize(); - const [isCopied, setIsCopied] = useState(false); - return ( -
- {lang} - {plugin === true ? ( - - ) : ( -
- {allowExecution === true && ( - +const CodeBar: React.FC = React.memo(function CodeBar({ + lang, + error, + codeRef, + blockIndex, + plugin = null, + allowExecution = true, +}) { + const localize = useLocalize(); + const [isCopied, setIsCopied] = useState(false); + return ( +
+ {lang} + {plugin === true ? ( + + ) : ( +
+ {allowExecution === true && ( + + )} + -
- )} -
- ); - }, -); - -const FloatingCodeBar: React.FC = React.memo( - ({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => { - const localize = useLocalize(); - const [isCopied, setIsCopied] = useState(false); - const copyButtonRef = useRef(null); - - const handleCopy = useCallback(() => { - const codeString = codeRef.current?.textContent; - if (codeString != null) { - const wasFocused = document.activeElement === copyButtonRef.current; - setIsCopied(true); - copy(codeString.trim(), { format: 'text/plain' }); - if (wasFocused) { - requestAnimationFrame(() => { - copyButtonRef.current?.focus(); - }); - } - - setTimeout(() => { - const focusedElement = document.activeElement as HTMLElement | null; - setIsCopied(false); - requestAnimationFrame(() => { - focusedElement?.focus(); - }); - }, 3000); - } - }, [codeRef]); - - return ( -
- {plugin === true ? ( - - ) : ( - <> - {allowExecution === true && ( - - )} - - {isCopied ? ( -
- ); - }, -); + }} + > + {isCopied ? : } + {error !== true && ( + + {localize('com_ui_copy_code')} + + {isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')} + + + )} + +
+ )} +
+ ); +}); +CodeBar.displayName = 'CodeBar'; + +const FloatingCodeBar: React.FC = React.memo(function FloatingCodeBar({ + lang, + error, + codeRef, + blockIndex, + plugin = null, + allowExecution = true, + isVisible, +}) { + const localize = useLocalize(); + const [isCopied, setIsCopied] = useState(false); + const copyButtonRef = useRef(null); + + const handleCopy = useCallback(() => { + const codeString = codeRef.current?.textContent; + if (codeString != null) { + const wasFocused = document.activeElement === copyButtonRef.current; + setIsCopied(true); + copy(codeString.trim(), { format: 'text/plain' }); + if (wasFocused) { + requestAnimationFrame(() => { + copyButtonRef.current?.focus(); + }); + } + + setTimeout(() => { + const focusedElement = document.activeElement as HTMLElement | null; + setIsCopied(false); + requestAnimationFrame(() => { + focusedElement?.focus(); + }); + }, 3000); + } + }, [codeRef]); + + return ( +
+ {plugin === true ? ( + + ) : ( + <> + {allowExecution === true && ( + + )} + + {isCopied ? ( +
+ ); +}); +FloatingCodeBar.displayName = 'FloatingCodeBar'; const CodeBlock: React.FC = ({ lang, diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 5724ff77c2..a50b91c071 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -22,176 +22,175 @@ type ContentRenderProps = { 'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount' >; -const ContentRender = memo( - ({ +const ContentRender = memo(function ContentRender({ + message: msg, + siblingIdx, + siblingCount, + setSiblingIdx, + currentEditId, + setCurrentEditId, + isSubmitting = false, +}: ContentRenderProps) { + const localize = useLocalize(); + const { attachments, searchResults } = useAttachments({ + messageId: msg?.messageId, + attachments: msg?.attachments, + }); + const { + edit, + index, + agent, + assistant, + enterEdit, + conversation, + messageLabel, + handleContinue, + handleFeedback, + latestMessageId, + copyToClipboard, + regenerateMessage, + latestMessageDepth, + } = useMessageActions({ message: msg, - siblingIdx, - siblingCount, - setSiblingIdx, + searchResults, currentEditId, setCurrentEditId, - isSubmitting = false, - }: ContentRenderProps) => { - const localize = useLocalize(); - const { attachments, searchResults } = useAttachments({ - messageId: msg?.messageId, - attachments: msg?.attachments, - }); - const { - edit, - index, - agent, - assistant, - enterEdit, - conversation, + }); + const fontSize = useAtomValue(fontSizeAtom); + const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + + const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); + const isLast = useMemo( + () => !(msg?.children?.length ?? 0) && (msg?.depth === latestMessageDepth || msg?.depth === -1), + [msg?.children, msg?.depth, latestMessageDepth], + ); + const hasNoChildren = !(msg?.children?.length ?? 0); + const isLatestMessage = msg?.messageId === latestMessageId; + /** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ + const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + + const iconData: TMessageIcon = useMemo( + () => ({ + endpoint: msg?.endpoint ?? conversation?.endpoint, + model: msg?.model ?? conversation?.model, + iconURL: msg?.iconURL, + modelLabel: messageLabel, + isCreatedByUser: msg?.isCreatedByUser, + }), + [ messageLabel, - latestMessage, - handleContinue, - handleFeedback, - copyToClipboard, - regenerateMessage, - } = useMessageActions({ - message: msg, - searchResults, - currentEditId, - setCurrentEditId, - }); - const fontSize = useAtomValue(fontSizeAtom); - const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); + conversation?.endpoint, + conversation?.model, + msg?.model, + msg?.iconURL, + msg?.endpoint, + msg?.isCreatedByUser, + ], + ); - const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); - const isLast = useMemo( - () => - !(msg?.children?.length ?? 0) && (msg?.depth === latestMessage?.depth || msg?.depth === -1), - [msg?.children, msg?.depth, latestMessage?.depth], - ); - const hasNoChildren = !(msg?.children?.length ?? 0); - const isLatestMessage = msg?.messageId === latestMessage?.messageId; - /** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */ - const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + const { hasParallelContent } = useContentMetadata(msg); - const iconData: TMessageIcon = useMemo( - () => ({ - endpoint: msg?.endpoint ?? conversation?.endpoint, - model: msg?.model ?? conversation?.model, - iconURL: msg?.iconURL, - modelLabel: messageLabel, - isCreatedByUser: msg?.isCreatedByUser, - }), - [ - messageLabel, - conversation?.endpoint, - conversation?.model, - msg?.model, - msg?.iconURL, - msg?.endpoint, - msg?.isCreatedByUser, - ], - ); + if (!msg) { + return null; + } - const { hasParallelContent } = useContentMetadata(msg); - - if (!msg) { - return null; + const getChatWidthClass = () => { + if (maximizeChatSpace) { + return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'; } + if (hasParallelContent) { + return 'md:max-w-[58rem] xl:max-w-[70rem]'; + } + return 'md:max-w-[47rem] xl:max-w-[55rem]'; + }; - const getChatWidthClass = () => { - if (maximizeChatSpace) { - return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'; - } - if (hasParallelContent) { - return 'md:max-w-[58rem] xl:max-w-[70rem]'; - } - return 'md:max-w-[47rem] xl:max-w-[55rem]'; - }; + const baseClasses = { + common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ', + chat: getChatWidthClass(), + }; - const baseClasses = { - common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ', - chat: getChatWidthClass(), - }; + const conditionalClasses = { + focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy', + }; - const conditionalClasses = { - focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy', - }; + return ( +
+ {!hasParallelContent && ( +
+
+ +
+
+ )} - return (
{!hasParallelContent && ( -
-
- -
-
+

{messageLabel}

)} -
- {!hasParallelContent && ( -

{messageLabel}

- )} - -
-
- } - /> -
- {hasNoChildren && effectiveIsSubmitting ? ( - - ) : ( - - - - - )} +
+
+ } + />
+ {hasNoChildren && effectiveIsSubmitting ? ( + + ) : ( + + + + + )}
- ); - }, -); +
+ ); +}); +ContentRender.displayName = 'ContentRender'; export default ContentRender; diff --git a/client/src/components/Messages/MessageContent.tsx b/client/src/components/Messages/MessageContent.tsx index 68fe2d8629..0e53b1c840 100644 --- a/client/src/components/Messages/MessageContent.tsx +++ b/client/src/components/Messages/MessageContent.tsx @@ -5,25 +5,23 @@ import type { TMessageProps } from '~/common'; import MultiMessage from '~/components/Chat/Messages/MultiMessage'; import ContentRender from './ContentRender'; -const MessageContainer = React.memo( - ({ - handleScroll, - children, - }: { - handleScroll: (event?: unknown) => void; - children: React.ReactNode; - }) => { - return ( -
- {children} -
- ); - }, -); +const MessageContainer = React.memo(function MessageContainer({ + handleScroll, + children, +}: { + handleScroll: (event?: unknown) => void; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +}); export default function MessageContent(props: TMessageProps) { const { conversation, handleScroll, isSubmitting } = useMessageProcess({ diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index 86fe4a793f..82225733fd 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -1,15 +1,21 @@ import React, { useRef, useCallback, useMemo, useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; import { LayoutGrid } from 'lucide-react'; import { useDrag, useDrop } from 'react-dnd'; import { Skeleton } from '@librechat/client'; import { useNavigate } from 'react-router-dom'; import { useQueries } from '@tanstack/react-query'; +import { useRecoilValue } from 'recoil'; import { QueryKeys, dataService } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; -import { useFavorites, useLocalize, useShowMarketplace, useNewConvo } from '~/hooks'; -import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import type { AgentQueryResult } from '~/common'; +import { + useGetConversation, + useShowMarketplace, + useFavorites, + useLocalize, + useNewConvo, +} from '~/hooks'; +import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import useSelectMention from '~/hooks/Input/useSelectMention'; import { useGetEndpointsQuery } from '~/data-provider'; import FavoriteItem from './FavoriteItem'; @@ -122,20 +128,20 @@ export default function FavoritesList({ const navigate = useNavigate(); const localize = useLocalize(); const search = useRecoilValue(store.search); + const getConversation = useGetConversation(0); const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites(); const showAgentMarketplace = useShowMarketplace(); const { newConversation } = useNewConvo(); const assistantsMap = useAssistantsMapContext(); const agentsMap = useAgentsMapContext(); - const conversation = useRecoilValue(store.conversationByIndex(0)); const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery(); const { onSelectEndpoint } = useSelectMention({ modelSpecs: [], - conversation, assistantsMap, endpointsConfig, + getConversation, newConversation, returnHandlers: true, }); diff --git a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx index 8318b94698..ed71221de3 100644 --- a/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx +++ b/client/src/components/Nav/Favorites/tests/FavoritesList.spec.tsx @@ -56,6 +56,7 @@ jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, useShowMarketplace: () => false, useNewConvo: () => ({ newConversation: jest.fn() }), + useGetConversation: () => () => null, })); jest.mock('~/Providers', () => ({ diff --git a/client/src/components/Share/ShareMessagesProvider.tsx b/client/src/components/Share/ShareMessagesProvider.tsx index e87591a082..e614aa891a 100644 --- a/client/src/components/Share/ShareMessagesProvider.tsx +++ b/client/src/components/Share/ShareMessagesProvider.tsx @@ -25,7 +25,8 @@ export function ShareMessagesProvider({ messages, children }: ShareMessagesProvi ask: () => Promise.resolve(), regenerate: () => {}, handleContinue: () => {}, - latestMessage: messages[messages.length - 1] ?? null, + latestMessageId: messages[messages.length - 1]?.messageId, + latestMessageDepth: messages[messages.length - 1]?.depth, isSubmitting: false, abortScroll: false, setAbortScroll: () => {}, diff --git a/client/src/components/SidePanel/Agents/AgentAvatar.tsx b/client/src/components/SidePanel/Agents/AgentAvatar.tsx index bb1d44dfdc..6b778f6515 100644 --- a/client/src/components/SidePanel/Agents/AgentAvatar.tsx +++ b/client/src/components/SidePanel/Agents/AgentAvatar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { useToastContext } from '@librechat/client'; import { useFormContext, useWatch } from 'react-hook-form'; import { mergeFileConfig, fileConfig as defaultFileConfig } from 'librechat-data-provider'; @@ -99,4 +99,10 @@ function Avatar({ avatar }: { avatar: AgentAvatar | null }) { ); } -export default Avatar; +const MemoizedAvatar = memo( + Avatar, + (prevProps, nextProps) => prevProps.avatar?.filepath === nextProps.avatar?.filepath, +); +MemoizedAvatar.displayName = 'Avatar'; + +export default MemoizedAvatar; diff --git a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx index 5840fe0f12..4485c0b08d 100644 --- a/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx +++ b/client/src/components/SidePanel/Agents/AgentCategorySelector.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { memo, useState } from 'react'; import { ControlCombobox } from '@librechat/client'; import { useWatch, @@ -95,4 +95,10 @@ const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) ); }; -export default AgentCategorySelector; +const MemoizedAgentCategorySelector = memo( + AgentCategorySelector, + (prevProps, nextProps) => prevProps.className === nextProps.className, +); +MemoizedAgentCategorySelector.displayName = 'AgentCategorySelector'; + +export default MemoizedAgentCategorySelector; diff --git a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx index a819de7deb..bfa148b3ab 100644 --- a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx @@ -1,10 +1,11 @@ import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext'; import { Panel, isEphemeralAgent } from '~/common'; import VersionPanel from './Version/VersionPanel'; -import { useChatContext } from '~/Providers'; import ActionsPanel from './ActionsPanel'; import AgentPanel from './AgentPanel'; +import store from '~/store'; export default function AgentPanelSwitch() { return ( @@ -15,15 +16,15 @@ export default function AgentPanelSwitch() { } function AgentPanelSwitchWithContext() { - const { conversation } = useChatContext(); const { activePanel, setCurrentAgentId } = useAgentPanelContext(); + const agentId = useRecoilValue(store.conversationAgentIdByIndex(0)); useEffect(() => { - const agent_id = conversation?.agent_id ?? ''; + const agent_id = agentId ?? ''; if (!isEphemeralAgent(agent_id)) { setCurrentAgentId(agent_id); } - }, [setCurrentAgentId, conversation?.agent_id]); + }, [setCurrentAgentId, agentId]); if (activePanel === Panel.actions) { return ; diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index a9e4ef7036..323136340e 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -1,6 +1,6 @@ import { EarthIcon } from 'lucide-react'; import { ControlCombobox } from '@librechat/client'; -import { useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider'; import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query'; @@ -12,7 +12,7 @@ import { useListAgentsQuery } from '~/data-provider'; const keys = new Set(Object.keys(defaultAgentFormValues)); -export default function AgentSelect({ +function AgentSelect({ agentQuery, selectedAgentId = null, setCurrentAgentId, @@ -225,3 +225,16 @@ export default function AgentSelect({ /> ); } + +const MemoizedAgentSelect = memo( + AgentSelect, + (prevProps, nextProps) => + prevProps.selectedAgentId === nextProps.selectedAgentId && + prevProps.agentQuery.data === nextProps.agentQuery.data && + prevProps.agentQuery.isSuccess === nextProps.agentQuery.isSuccess && + prevProps.createMutation.data?.id === nextProps.createMutation.data?.id && + prevProps.createMutation.isLoading === nextProps.createMutation.isLoading, +); +MemoizedAgentSelect.displayName = 'AgentSelect'; + +export default MemoizedAgentSelect; diff --git a/client/src/components/SidePanel/Agents/Code/Files.tsx b/client/src/components/SidePanel/Agents/Code/Files.tsx index 88fe710334..64524e66da 100644 --- a/client/src/components/SidePanel/Agents/Code/Files.tsx +++ b/client/src/components/SidePanel/Agents/Code/Files.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { memo, useMemo, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { AttachmentIcon } from '@librechat/client'; import { @@ -9,15 +9,15 @@ import { getEndpointFileConfig, } from 'librechat-data-provider'; import type { ExtendedFile, AgentForm } from '~/common'; -import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks'; +import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; import FileRow from '~/components/Chat/Input/Files/FileRow'; +import { useLocalize, useLazyEffect } from '~/hooks'; import { useGetFileConfig } from '~/data-provider'; -import { useChatContext } from '~/Providers'; import { isEphemeralAgent } from '~/common'; const tool_resource = EToolResources.execute_code; -export default function Files({ +function Files({ agent_id, files: _files, }: { @@ -25,18 +25,21 @@ export default function Files({ files?: [string, ExtendedFile][]; }) { const localize = useLocalize(); - const { setFilesLoading } = useChatContext(); const { watch } = useFormContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); + const fileHandlingState = useMemo(() => ({ files, setFiles, conversation: null }), [files]); const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), }); - const { abortUpload, handleFileChange } = useFileHandling({ - fileSetter: setFiles, - additionalMetadata: { agent_id, tool_resource }, - endpointOverride: EModelEndpoint.agents, - }); + const { abortUpload, handleFileChange } = useFileHandlingNoChatContext( + { + fileSetter: setFiles, + additionalMetadata: { agent_id, tool_resource }, + endpointOverride: EModelEndpoint.agents, + }, + fileHandlingState, + ); useLazyEffect( () => { @@ -81,7 +84,6 @@ export default function Files({ agent_id={agent_id} abortUpload={abortUpload} tool_resource={tool_resource} - setFilesLoading={setFilesLoading} Wrapper={({ children }) =>
{children}
} />
@@ -110,3 +112,8 @@ export default function Files({
); } + +const MemoizedFiles = memo(Files); +MemoizedFiles.displayName = 'Files'; + +export default MemoizedFiles; diff --git a/client/src/components/SidePanel/Agents/DeleteButton.tsx b/client/src/components/SidePanel/Agents/DeleteButton.tsx index b3c81debd4..a738e382b3 100644 --- a/client/src/components/SidePanel/Agents/DeleteButton.tsx +++ b/client/src/components/SidePanel/Agents/DeleteButton.tsx @@ -1,4 +1,6 @@ +import { memo } from 'react'; import { useFormContext } from 'react-hook-form'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Label, Button, @@ -11,12 +13,12 @@ import { import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; import { logger, getDefaultAgentFormValues } from '~/utils'; -import { useLocalize, useSetIndexOptions } from '~/hooks'; import { useDeleteAgentMutation } from '~/data-provider'; -import { useChatContext } from '~/Providers'; import { isEphemeralAgent } from '~/common'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; -export default function DeleteButton({ +function DeleteButton({ agent_id, setCurrentAgentId, createMutation, @@ -28,8 +30,8 @@ export default function DeleteButton({ const localize = useLocalize(); const { reset } = useFormContext(); const { showToast } = useToastContext(); - const { conversation } = useChatContext(); - const { setOption } = useSetIndexOptions(); + const setConversation = useSetRecoilState(store.conversationByIndex(0)); + const conversationAgentId = useRecoilValue(store.conversationAgentIdByIndex(0)); const deleteAgent = useDeleteAgentMutation({ onSuccess: (_, vars, context) => { @@ -52,15 +54,16 @@ export default function DeleteButton({ if (!firstAgent) { setCurrentAgentId(undefined); reset(getDefaultAgentFormValues()); - return setOption('agent_id')(''); + setConversation((prev) => (prev ? { ...prev, agent_id: '' } : prev)); + return; } - if (vars.agent_id === conversation?.agent_id) { - setOption('model')(''); - return setOption('agent_id')(firstAgent.id); + if (vars.agent_id === conversationAgentId) { + setConversation((prev) => (prev ? { ...prev, model: '', agent_id: firstAgent.id } : prev)); + return; } - const currentAgent = updatedList.find((agent) => agent.id === conversation?.agent_id); + const currentAgent = updatedList.find((agent) => agent.id === conversationAgentId); if (currentAgent) { setCurrentAgentId(currentAgent.id); @@ -119,3 +122,15 @@ export default function DeleteButton({ ); } + +const MemoizedDeleteButton = memo( + DeleteButton, + (prevProps, nextProps) => + prevProps.agent_id === nextProps.agent_id && + prevProps.setCurrentAgentId === nextProps.setCurrentAgentId && + prevProps.createMutation.data?.id === nextProps.createMutation.data?.id && + prevProps.createMutation.isLoading === nextProps.createMutation.isLoading, +); +MemoizedDeleteButton.displayName = 'DeleteButton'; + +export default MemoizedDeleteButton; diff --git a/client/src/components/SidePanel/Agents/FileContext.tsx b/client/src/components/SidePanel/Agents/FileContext.tsx index bad2c9bdee..433992b1d0 100644 --- a/client/src/components/SidePanel/Agents/FileContext.tsx +++ b/client/src/components/SidePanel/Agents/FileContext.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { memo, useMemo, useRef, useState } from 'react'; import { Folder } from 'lucide-react'; import * as Ariakit from '@ariakit/react'; import { @@ -18,14 +18,15 @@ import { HoverCardTrigger, } from '@librechat/client'; import type { ExtendedFile } from '~/common'; -import { useFileHandling, useLocalize, useLazyEffect, useSharePointFileHandling } from '~/hooks'; +import { useLocalize, useLazyEffect } from '~/hooks'; import { useGetFileConfig, useGetStartupConfig } from '~/data-provider'; import { SharePointPickerDialog } from '~/components/SharePoint'; import FileRow from '~/components/Chat/Input/Files/FileRow'; import { ESide, isEphemeralAgent } from '~/common'; -import { useChatContext } from '~/Providers'; +import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling'; +import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; -export default function FileContext({ +function FileContext({ agent_id, files: _files, }: { @@ -33,9 +34,9 @@ export default function FileContext({ files?: [string, ExtendedFile][]; }) { const localize = useLocalize(); - const { setFilesLoading } = useChatContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); + const fileHandlingState = useMemo(() => ({ files, setFiles, conversation: null }), [files]); const [isPopoverActive, setIsPopoverActive] = useState(false); const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false); const { data: startupConfig } = useGetStartupConfig(); @@ -45,16 +46,23 @@ export default function FileContext({ select: (data) => mergeFileConfig(data), }); - const { handleFileChange } = useFileHandling({ - additionalMetadata: { agent_id, tool_resource: EToolResources.context }, - endpointOverride: EModelEndpoint.agents, - fileSetter: setFiles, - }); - const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ - additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, - endpointOverride: EModelEndpoint.agents, - fileSetter: setFiles, - }); + const { handleFileChange } = useFileHandlingNoChatContext( + { + additionalMetadata: { agent_id, tool_resource: EToolResources.context }, + endpointOverride: EModelEndpoint.agents, + fileSetter: setFiles, + }, + fileHandlingState, + ); + const { handleSharePointFiles, isProcessing, downloadProgress } = + useSharePointFileHandlingNoChatContext( + { + additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, + fileSetter: setFiles, + }, + fileHandlingState, + ); useLazyEffect( () => { if (_files) { @@ -138,7 +146,6 @@ export default function FileContext({
{children}
} @@ -199,3 +206,8 @@ export default function FileContext({
); } + +const MemoizedFileContext = memo(FileContext); +MemoizedFileContext.displayName = 'FileContext'; + +export default MemoizedFileContext; diff --git a/client/src/components/SidePanel/Agents/FileSearch.tsx b/client/src/components/SidePanel/Agents/FileSearch.tsx index 6b3e813ef1..bb7d272d90 100644 --- a/client/src/components/SidePanel/Agents/FileSearch.tsx +++ b/client/src/components/SidePanel/Agents/FileSearch.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { memo, useMemo, useRef, useState } from 'react'; import { Folder } from 'lucide-react'; import * as Ariakit from '@ariakit/react'; import { useFormContext } from 'react-hook-form'; @@ -11,16 +11,16 @@ import { getEndpointFileConfig, } from 'librechat-data-provider'; import type { ExtendedFile, AgentForm } from '~/common'; -import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling'; import { useGetFileConfig, useGetStartupConfig } from '~/data-provider'; -import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks'; +import { useLocalize, useLazyEffect } from '~/hooks'; import { SharePointPickerDialog } from '~/components/SharePoint'; import FileRow from '~/components/Chat/Input/Files/FileRow'; import FileSearchCheckbox from './FileSearchCheckbox'; -import { useChatContext } from '~/Providers'; import { isEphemeralAgent } from '~/common'; +import { useFileHandlingNoChatContext } from '~/hooks/Files/useFileHandling'; +import { useSharePointFileHandlingNoChatContext } from '~/hooks/Files/useSharePointFileHandling'; -export default function FileSearch({ +function FileSearch({ agent_id, files: _files, }: { @@ -28,10 +28,10 @@ export default function FileSearch({ files?: [string, ExtendedFile][]; }) { const localize = useLocalize(); - const { setFilesLoading } = useChatContext(); const { watch } = useFormContext(); const fileInputRef = useRef(null); const [files, setFiles] = useState>(new Map()); + const fileHandlingState = useMemo(() => ({ files, setFiles, conversation: null }), [files]); const [isPopoverActive, setIsPopoverActive] = useState(false); const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false); @@ -42,17 +42,24 @@ export default function FileSearch({ select: (data) => mergeFileConfig(data), }); - const { handleFileChange } = useFileHandling({ - additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, - endpointOverride: EModelEndpoint.agents, - fileSetter: setFiles, - }); + const { handleFileChange } = useFileHandlingNoChatContext( + { + additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, + fileSetter: setFiles, + }, + fileHandlingState, + ); - const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ - additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, - endpointOverride: EModelEndpoint.agents, - fileSetter: setFiles, - }); + const { handleSharePointFiles, isProcessing, downloadProgress } = + useSharePointFileHandlingNoChatContext( + { + additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, + fileSetter: setFiles, + }, + fileHandlingState, + ); useLazyEffect( () => { @@ -143,7 +150,6 @@ export default function FileSearch({
{children}
} @@ -203,3 +209,8 @@ export default function FileSearch({
); } + +const MemoizedFileSearch = memo(FileSearch); +MemoizedFileSearch.displayName = 'FileSearch'; + +export default MemoizedFileSearch; diff --git a/client/src/hooks/Agents/useSelectAgent.ts b/client/src/hooks/Agents/useSelectAgent.ts index 00c2753d93..30024c8f63 100644 --- a/client/src/hooks/Agents/useSelectAgent.ts +++ b/client/src/hooks/Agents/useSelectAgent.ts @@ -1,31 +1,29 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Constants, QueryKeys, + dataService, EModelEndpoint, isAssistantsEndpoint, } from 'librechat-data-provider'; import type { TConversation, TPreset, Agent } from 'librechat-data-provider'; +import useGetConversation from '~/hooks/Conversations/useGetConversation'; import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo'; import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; -import { useChatContext } from '~/Providers/ChatContext'; -import { useGetAgentByIdQuery } from '~/data-provider'; +import useNewConvo from '~/hooks/useNewConvo'; import { logger } from '~/utils'; export default function useSelectAgent() { const queryClient = useQueryClient(); - const getDefaultConversation = useDefaultConvo(); - const { conversation, newConversation } = useChatContext(); const agentsMap = useAgentsMapContext(); - const [selectedAgentId, setSelectedAgentId] = useState( - conversation?.agent_id ?? null, - ); - - const agentQuery = useGetAgentByIdQuery(selectedAgentId); + const getDefaultConversation = useDefaultConvo(); + const { newConversation } = useNewConvo(); + const getConversation = useGetConversation(0); const updateConversation = useCallback( - (agent: Partial, template: Partial) => { + async (agent: Partial, template: Partial) => { + const conversation = await getConversation(); logger.log('conversation', 'Updating conversation with agent', agent); if (isAssistantsEndpoint(conversation?.endpoint)) { newConversation({ @@ -44,7 +42,7 @@ export default function useSelectAgent() { keepLatestMessage: true, }); }, - [conversation, getDefaultConversation, newConversation], + [getConversation, getDefaultConversation, newConversation], ); const onSelect = useCallback( @@ -54,30 +52,22 @@ export default function useSelectAgent() { return; } - setSelectedAgentId(agent.id); - const template: Partial = { endpoint: EModelEndpoint.agents, agent_id: agent.id, conversationId: Constants.NEW_CONVO as string, }; - updateConversation({ id: agent.id }, template); + await updateConversation({ id: agent.id }, template); - // Fetch full agent data in the background try { - await queryClient.invalidateQueries( - { - queryKey: [QueryKeys.agent, agent.id], - exact: true, - refetchType: 'active', - }, - { throwOnError: true }, + const fullAgent = await queryClient.fetchQuery([QueryKeys.agent, agent.id], () => + dataService.getAgentById({ + agent_id: agent.id, + }), ); - - const { data: fullAgent } = await agentQuery.refetch(); if (fullAgent) { - updateConversation(fullAgent, { ...template, agent_id: fullAgent.id }); + await updateConversation(fullAgent, { ...template, agent_id: fullAgent.id }); } } catch (error) { if ((error as { silent: boolean } | undefined)?.silent) { @@ -85,10 +75,10 @@ export default function useSelectAgent() { return; } console.error('Error fetching full agent data:', error); - updateConversation({}, { ...template, agent_id: undefined }); + await updateConversation({}, { ...template, agent_id: undefined }); } }, - [agentsMap, updateConversation, queryClient, agentQuery], + [agentsMap, updateConversation, queryClient], ); return { onSelect }; diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts index 46d38d3a4d..219c370418 100644 --- a/client/src/hooks/Chat/useChatHelpers.ts +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -1,12 +1,11 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import type { ActiveJobsResponse } from '~/data-provider'; -import { useGetMessagesByConvoId, useAbortStreamMutation } from '~/data-provider'; import useChatFunctions from '~/hooks/Chat/useChatFunctions'; -import { useAuthContext } from '~/hooks/AuthContext'; +import { useAbortStreamMutation } from '~/data-provider'; import useNewConvo from '~/hooks/useNewConvo'; import store from '~/store'; @@ -17,7 +16,6 @@ export default function useChatHelpers(index = 0, paramId?: string) { const [filesLoading, setFilesLoading] = useState(false); const queryClient = useQueryClient(); - const { isAuthenticated } = useAuthContext(); const abortMutation = useAbortStreamMutation(); const { newConversation } = useNewConvo(index); @@ -29,15 +27,15 @@ export default function useChatHelpers(index = 0, paramId?: string) { Falling back to conversationId (Recoil) only if paramId is not available */ const queryParam = paramId === 'new' ? paramId : (paramId ?? conversationId ?? ''); - /* Messages: here simply to fetch, don't export and use `getMessages()` instead */ - - const { data: _messages } = useGetMessagesByConvoId(queryParam, { - enabled: isAuthenticated, - }); - const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); const [isSubmitting, setIsSubmitting] = useRecoilState(store.isSubmittingFamily(index)); const [latestMessage, setLatestMessage] = useRecoilState(store.latestMessageFamily(index)); + + const latestMessageId = latestMessage?.messageId; + const latestMessageDepth = latestMessage?.depth; + const latestMessageRef = useRef(latestMessage); + latestMessageRef.current = latestMessage; + const setSiblingIdx = useSetRecoilState( store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null), ); @@ -77,7 +75,7 @@ export default function useChatHelpers(index = 0, paramId?: string) { const setSubmission = useSetRecoilState(store.submissionByIndex(index)); - const { ask, regenerate } = useChatFunctions({ + const { ask: _ask, regenerate: _regenerate } = useChatFunctions({ index, files, setFiles, @@ -90,8 +88,20 @@ export default function useChatHelpers(index = 0, paramId?: string) { setLatestMessage, }); - const continueGeneration = () => { - if (!latestMessage) { + const askRef = useRef(_ask); + askRef.current = _ask; + const ask: typeof _ask = useCallback((...args) => askRef.current(...args), []); + + const regenerateRef = useRef(_regenerate); + regenerateRef.current = _regenerate; + const regenerate: typeof _regenerate = useCallback( + (...args) => regenerateRef.current(...args), + [], + ); + + const continueGeneration = useCallback(() => { + const currentLatest = latestMessageRef.current; + if (!currentLatest) { console.error('Failed to regenerate the message: latestMessage not found.'); return; } @@ -99,7 +109,7 @@ export default function useChatHelpers(index = 0, paramId?: string) { const messages = getMessages(); const parentMessage = messages?.find( - (element) => element.messageId == latestMessage.parentMessageId, + (element) => element.messageId == currentLatest.parentMessageId, ); if (parentMessage && parentMessage.isCreatedByUser) { @@ -109,7 +119,7 @@ export default function useChatHelpers(index = 0, paramId?: string) { 'Failed to regenerate the message: parentMessage not found, or not created by user.', ); } - }; + }, [getMessages, ask]); /** * Stop generation - for non-assistants endpoints, calls abort endpoint first. @@ -153,64 +163,107 @@ export default function useChatHelpers(index = 0, paramId?: string) { } }, [conversationId, endpoint, endpointType, abortMutation, clearAllSubmissions, queryClient]); - const handleStopGenerating = (e: React.MouseEvent) => { - e.preventDefault(); - stopGenerating(); - }; + const handleStopGenerating = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + stopGenerating(); + }, + [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 handleRegenerate = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const parentMessageId = latestMessageRef.current?.parentMessageId ?? ''; + if (!parentMessageId) { + console.error('Failed to regenerate the message: parentMessageId not found.'); + return; + } + regenerate({ parentMessageId }); + }, + [regenerate], + ); - const handleContinue = (e: React.MouseEvent) => { - e.preventDefault(); - continueGeneration(); - setSiblingIdx(0); - }; + const handleContinue = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + continueGeneration(); + setSiblingIdx(0); + }, + [continueGeneration, setSiblingIdx], + ); const [preset, setPreset] = useRecoilState(store.presetByIndex(index)); const [showPopover, setShowPopover] = useRecoilState(store.showPopoverFamily(index)); const [abortScroll, setAbortScroll] = useRecoilState(store.abortScrollFamily(index)); const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettingsFamily(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, - preset, - setPreset, - optionSettings, - setOptionSettings, - files, - setFiles, - filesLoading, - setFilesLoading, - }; + return useMemo( + () => ({ + newConversation, + conversation, + setConversation, + isSubmitting, + setIsSubmitting, + getMessages, + setMessages, + setSiblingIdx, + latestMessageId, + latestMessageDepth, + setLatestMessage, + resetLatestMessage, + ask, + index, + regenerate, + stopGenerating, + handleStopGenerating, + handleRegenerate, + handleContinue, + showPopover, + setShowPopover, + abortScroll, + setAbortScroll, + preset, + setPreset, + optionSettings, + setOptionSettings, + files, + setFiles, + filesLoading, + setFilesLoading, + }), + [ + newConversation, + conversation, + setConversation, + isSubmitting, + setIsSubmitting, + getMessages, + setMessages, + setSiblingIdx, + latestMessageId, + latestMessageDepth, + setLatestMessage, + resetLatestMessage, + ask, + index, + regenerate, + stopGenerating, + handleStopGenerating, + handleRegenerate, + handleContinue, + showPopover, + setShowPopover, + abortScroll, + setAbortScroll, + preset, + setPreset, + optionSettings, + setOptionSettings, + files, + setFiles, + filesLoading, + setFilesLoading, + ], + ); } diff --git a/client/src/hooks/Conversations/index.ts b/client/src/hooks/Conversations/index.ts index 6c35ad5da9..2659ace457 100644 --- a/client/src/hooks/Conversations/index.ts +++ b/client/src/hooks/Conversations/index.ts @@ -4,6 +4,7 @@ export { default as useDefaultConvo } from './useDefaultConvo'; export { default as useSearchEnabled } from './useSearchEnabled'; export { default as useGenerateConvo } from './useGenerateConvo'; export { default as useDebouncedInput } from './useDebouncedInput'; +export { default as useGetConversation } from './useGetConversation'; export { default as useBookmarkSuccess } from './useBookmarkSuccess'; export { default as useNavigateToConvo } from './useNavigateToConvo'; export { default as useSetIndexOptions } from './useSetIndexOptions'; diff --git a/client/src/hooks/Conversations/useDefaultConvo.ts b/client/src/hooks/Conversations/useDefaultConvo.ts index 67a40ce64e..697854924b 100644 --- a/client/src/hooks/Conversations/useDefaultConvo.ts +++ b/client/src/hooks/Conversations/useDefaultConvo.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; import { excludedKeys, getDefaultParamsEndpoint } from 'librechat-data-provider'; import type { @@ -22,57 +23,55 @@ const useDefaultConvo = () => { const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); const { data: modelsConfig = {} as TModelsConfig } = useGetModelsQuery(); - const getDefaultConversation = ({ - conversation: _convo, - preset, - cleanInput, - cleanOutput, - }: TDefaultConvo) => { - const endpoint = getDefaultEndpoint({ - convoSetup: preset as TPreset, - endpointsConfig, - }); + const getDefaultConversation = useCallback( + ({ conversation: _convo, preset, cleanInput, cleanOutput }: TDefaultConvo) => { + const endpoint = getDefaultEndpoint({ + convoSetup: preset as TPreset, + endpointsConfig, + }); - const models = modelsConfig[endpoint ?? ''] || []; - const conversation = { ..._convo }; - if (cleanInput === true) { - for (const key in conversation) { + const models = modelsConfig[endpoint ?? ''] || []; + const conversation = { ..._convo }; + if (cleanInput === true) { + for (const key in conversation) { + if (excludedKeys.has(key) && !exceptions.has(key)) { + continue; + } + if (conversation[key] == null) { + continue; + } + conversation[key] = undefined; + } + } + + const defaultParamsEndpoint = getDefaultParamsEndpoint(endpointsConfig, endpoint); + + const defaultConvo = buildDefaultConvo({ + conversation: conversation as TConversation, + endpoint, + lastConversationSetup: preset as TConversation, + models, + defaultParamsEndpoint, + }); + + if (!cleanOutput) { + return defaultConvo; + } + + for (const key in defaultConvo) { if (excludedKeys.has(key) && !exceptions.has(key)) { continue; } - if (conversation[key] == null) { + if (defaultConvo[key] == null) { continue; } - conversation[key] = undefined; + defaultConvo[key] = undefined; } - } - const defaultParamsEndpoint = getDefaultParamsEndpoint(endpointsConfig, endpoint); - - const defaultConvo = buildDefaultConvo({ - conversation: conversation as TConversation, - endpoint, - lastConversationSetup: preset as TConversation, - models, - defaultParamsEndpoint, - }); - - if (!cleanOutput) { return defaultConvo; - } - - for (const key in defaultConvo) { - if (excludedKeys.has(key) && !exceptions.has(key)) { - continue; - } - if (defaultConvo[key] == null) { - continue; - } - defaultConvo[key] = undefined; - } - - return defaultConvo; - }; + }, + [endpointsConfig, modelsConfig], + ); return getDefaultConversation; }; diff --git a/client/src/hooks/Conversations/useGetConversation.ts b/client/src/hooks/Conversations/useGetConversation.ts new file mode 100644 index 0000000000..3b63e79f7f --- /dev/null +++ b/client/src/hooks/Conversations/useGetConversation.ts @@ -0,0 +1,14 @@ +import { useRecoilCallback } from 'recoil'; +import type { TConversation } from 'librechat-data-provider'; +import store from '~/store'; + +export default function useGetConversation(index: string | number = 0) { + return useRecoilCallback( + ({ snapshot }) => + () => + snapshot + .getLoadable(store.conversationByKeySelector(index)) + .getValue() as TConversation | null, + [index], + ); +} diff --git a/client/src/hooks/Conversations/usePresets.ts b/client/src/hooks/Conversations/usePresets.ts index 90ca5ab132..2165e1966e 100644 --- a/client/src/hooks/Conversations/usePresets.ts +++ b/client/src/hooks/Conversations/usePresets.ts @@ -13,19 +13,20 @@ import { useGetPresetsQuery, } from '~/data-provider'; import { cleanupPreset, removeUnavailableTools, getConvoSwitchLogic } from '~/utils'; +import useGetConversation from '~/hooks/Conversations/useGetConversation'; import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo'; import { useAuthContext } from '~/hooks/AuthContext'; import { NotificationSeverity } from '~/common'; import useNewConvo from '~/hooks/useNewConvo'; -import { useChatContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import store from '~/store'; -export default function usePresets() { +export default function usePresets(index = 0) { const localize = useLocalize(); const hasLoaded = useRef(false); const queryClient = useQueryClient(); const { showToast } = useToastContext(); + const getConversation = useGetConversation(index); const { user, isAuthenticated } = useAuthContext(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [presetToDelete, setPresetToDelete] = useState(null); @@ -35,7 +36,9 @@ export default function usePresets() { const setPresetModalVisible = useSetRecoilState(store.presetModalVisible); const [_defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset); const presetsQuery = useGetPresetsQuery({ enabled: !!user && isAuthenticated }); - const { preset, conversation, index, setPreset } = useChatContext(); + const preset = useRecoilValue(store.presetByIndex(index)); + const setPreset = useSetRecoilState(store.presetByIndex(index)); + const conversationId = useRecoilValue(store.conversationIdByIndex(index)); const { data: modelsData } = useGetModelsQuery(); const { newConversation } = useNewConvo(index); @@ -60,13 +63,13 @@ export default function usePresets() { return; } setDefaultPreset(defaultPreset); - if (!conversation?.conversationId || conversation.conversationId === 'new') { + if (!conversationId || conversationId === 'new') { newConversation({ preset: defaultPreset, modelsData, disableParams: true }); } hasLoaded.current = true; // dependencies are stable and only needed once // eslint-disable-next-line react-hooks/exhaustive-deps - }, [presetsQuery.data, user, modelsData]); + }, [presetsQuery.data, user, modelsData, conversationId]); const setPresets = useCallback( (presets: TPreset[]) => { @@ -164,6 +167,7 @@ export default function usePresets() { return; } + const conversation = getConversation(); const newPreset = removeUnavailableTools(_newPreset, availableTools); const toastTitle = newPreset.title diff --git a/client/src/hooks/Endpoint/useKeyDialog.ts b/client/src/hooks/Endpoint/useKeyDialog.ts index d783320fd6..89a156f57a 100644 --- a/client/src/hooks/Endpoint/useKeyDialog.ts +++ b/client/src/hooks/Endpoint/useKeyDialog.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { EModelEndpoint } from 'librechat-data-provider'; export const useKeyDialog = () => { @@ -15,24 +15,30 @@ export const useKeyDialog = () => { [], ); - const onOpenChange = (open: boolean) => { - if (!open && keyDialogEndpoint) { - const button = document.getElementById(`endpoint-${keyDialogEndpoint}-settings`); - if (button) { - setTimeout(() => { - button.focus(); - }, 5); + const onOpenChange = useCallback( + (open: boolean) => { + if (!open && keyDialogEndpoint) { + const button = document.getElementById(`endpoint-${keyDialogEndpoint}-settings`); + if (button) { + setTimeout(() => { + button.focus(); + }, 5); + } } - } - setKeyDialogOpen(open); - }; + setKeyDialogOpen(open); + }, + [keyDialogEndpoint], + ); - return { - keyDialogOpen, - keyDialogEndpoint, - onOpenChange, - handleOpenKeyDialog, - }; + return useMemo( + () => ({ + keyDialogOpen, + keyDialogEndpoint, + onOpenChange, + handleOpenKeyDialog, + }), + [keyDialogOpen, keyDialogEndpoint, onOpenChange, handleOpenKeyDialog], + ); }; export default useKeyDialog; diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 2d37dfd654..68d56e75ad 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -15,6 +15,7 @@ import { import debounce from 'lodash/debounce'; import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; +import type { TConversation } from 'librechat-data-provider'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; import { useDelayedUploadToast } from './useDelayedUploadToast'; @@ -33,14 +34,24 @@ type UseFileHandling = { endpointOverride?: EModelEndpoint; }; -const useFileHandling = (params?: UseFileHandling) => { +export type FileHandlingState = { + files: Map; + setFiles: FileSetter; + setFilesLoading?: React.Dispatch>; + conversation?: TConversation | null; +}; + +const noop = () => {}; + +const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: FileHandlingState) => { const localize = useLocalize(); const queryClient = useQueryClient(); const { showToast } = useToastContext(); const [errors, setErrors] = useState([]); const abortControllerRef = useRef(null); const { startUploadTimer, clearUploadTimer } = useDelayedUploadToast(); - const { files, setFiles, setFilesLoading, conversation } = useChatContext(); + const { files, setFiles, conversation } = fileState; + const setFilesLoading = fileState.setFilesLoading ?? noop; const setEphemeralAgent = useSetRecoilState( ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), ); @@ -443,4 +454,20 @@ const useFileHandling = (params?: UseFileHandling) => { }; }; +export const useFileHandlingNoChatContext = ( + params: UseFileHandling | undefined, + fileState: FileHandlingState, +) => useFileHandlingCore(params, fileState); + +const useFileHandling = (params?: UseFileHandling) => { + const { files, setFiles, setFilesLoading, conversation } = useChatContext(); + + return useFileHandlingCore(params, { + files, + setFiles, + conversation, + setFilesLoading, + }); +}; + export default useFileHandling; diff --git a/client/src/hooks/Files/useSharePointFileHandling.ts b/client/src/hooks/Files/useSharePointFileHandling.ts index 82ff7b555b..a398bd594b 100644 --- a/client/src/hooks/Files/useSharePointFileHandling.ts +++ b/client/src/hooks/Files/useSharePointFileHandling.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react'; -import useFileHandling from './useFileHandling'; -import useSharePointDownload from './useSharePointDownload'; import type { EModelEndpoint } from 'librechat-data-provider'; import type { SharePointFile } from '~/data-provider/Files/sharepoint'; +import type { FileHandlingState } from './useFileHandling'; +import useFileHandling, { useFileHandlingNoChatContext } from './useFileHandling'; +import useSharePointDownload from './useSharePointDownload'; interface UseSharePointFileHandlingProps { fileSetter?: any; @@ -23,6 +24,43 @@ export default function useSharePointFileHandling( props?: UseSharePointFileHandlingProps, ): UseSharePointFileHandlingReturn { const { handleFiles } = useFileHandling(props); + const { downloadSharePointFiles, isDownloading, downloadProgress, error } = useSharePointDownload( + { + onFilesDownloaded: async (downloadedFiles: File[]) => { + const fileArray = Array.from(downloadedFiles); + await handleFiles(fileArray, props?.toolResource); + }, + onError: (error) => { + console.error('SharePoint download failed:', error); + }, + }, + ); + + const handleSharePointFiles = useCallback( + async (sharePointFiles: SharePointFile[]) => { + try { + await downloadSharePointFiles(sharePointFiles); + } catch (error) { + console.error('SharePoint file handling error:', error); + throw error; + } + }, + [downloadSharePointFiles], + ); + + return { + handleSharePointFiles, + isProcessing: isDownloading, + downloadProgress, + error, + }; +} + +export function useSharePointFileHandlingNoChatContext( + props: UseSharePointFileHandlingProps | undefined, + fileState: FileHandlingState, +): UseSharePointFileHandlingReturn { + const { handleFiles } = useFileHandlingNoChatContext(props, fileState); const { downloadSharePointFiles, isDownloading, downloadProgress, error } = useSharePointDownload( { diff --git a/client/src/hooks/Input/useSelectMention.ts b/client/src/hooks/Input/useSelectMention.ts index 731302ff0a..00ba5095bb 100644 --- a/client/src/hooks/Input/useSelectMention.ts +++ b/client/src/hooks/Input/useSelectMention.ts @@ -22,19 +22,19 @@ import store from '~/store'; export default function useSelectMention({ presets, modelSpecs, - conversation, assistantsMap, returnHandlers, endpointsConfig, + getConversation, newConversation, }: { - conversation: TConversation | null; presets?: TPreset[]; modelSpecs: TModelSpec[]; + returnHandlers?: boolean; assistantsMap?: TAssistantsMap; newConversation: ConvoGenerator; endpointsConfig: TEndpointsConfig; - returnHandlers?: boolean; + getConversation: () => TConversation | null; }) { const getDefaultConversation = useDefaultConvo(); const modularChat = useRecoilValue(store.modularChat); @@ -45,6 +45,8 @@ export default function useSelectMention({ if (!spec) { return; } + + const conversation = getConversation(); const { preset } = spec; preset.iconURL = getModelSpecIconURL(spec); preset.spec = spec.name; @@ -110,7 +112,7 @@ export default function useSelectMention({ }); }, [ - conversation, + getConversation, getDefaultConversation, modularChat, newConversation, @@ -133,6 +135,8 @@ export default function useSelectMention({ return; } + const conversation = getConversation(); + const { shouldSwitch, isNewModular, @@ -202,7 +206,7 @@ export default function useSelectMention({ keepAddedConvos: isNewModular, }); }, - [conversation, getDefaultConversation, modularChat, newConversation, endpointsConfig], + [getConversation, getDefaultConversation, modularChat, newConversation, endpointsConfig], ); const onSelectPreset = useCallback( @@ -211,6 +215,8 @@ export default function useSelectMention({ return; } + const conversation = getConversation(); + const newPreset = removeUnavailableTools(_newPreset, availableTools); const newEndpoint = newPreset.endpoint ?? ''; @@ -266,7 +272,7 @@ export default function useSelectMention({ }, [ modularChat, - conversation, + getConversation, availableTools, newConversation, endpointsConfig, diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts index 4eae002430..15b415dabc 100644 --- a/client/src/hooks/Input/useTextarea.ts +++ b/client/src/hooks/Input/useTextarea.ts @@ -42,8 +42,8 @@ export default function useTextarea({ const checkHealth = useInteractionHealthCheck(); const enterToSend = useRecoilValue(store.enterToSend); - const { index, conversation, isSubmitting, filesLoading, latestMessage, setFilesLoading } = - useChatContext(); + const { index, conversation, isSubmitting, filesLoading, setFilesLoading } = useChatContext(); + const latestMessage = useRecoilValue(store.latestMessageFamily(index)); const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index)); const { endpoint = '' } = conversation || {}; diff --git a/client/src/hooks/Messages/useMessageActions.tsx b/client/src/hooks/Messages/useMessageActions.tsx index c168b16d6e..e8946b895b 100644 --- a/client/src/hooks/Messages/useMessageActions.tsx +++ b/client/src/hooks/Messages/useMessageActions.tsx @@ -31,8 +31,16 @@ export default function useMessageActions(props: TMessageActions) { const UsernameDisplay = useRecoilValue(store.UsernameDisplay); const { message, currentEditId, setCurrentEditId, searchResults } = props; - const { ask, index, regenerate, isSubmitting, conversation, latestMessage, handleContinue } = - useChatContext(); + const { + ask, + index, + regenerate, + isSubmitting, + conversation, + latestMessageId, + latestMessageDepth, + handleContinue, + } = useChatContext(); const getAddedConvo = useGetAddedConvo(); @@ -154,10 +162,11 @@ export default function useMessageActions(props: TMessageActions) { enterEdit, conversation, messageLabel, - latestMessage, handleFeedback, handleContinue, copyToClipboard, + latestMessageId, regenerateMessage, + latestMessageDepth, }; } diff --git a/client/src/hooks/Messages/useMessageHelpers.tsx b/client/src/hooks/Messages/useMessageHelpers.tsx index 0ecf5c684a..0453e4a49c 100644 --- a/client/src/hooks/Messages/useMessageHelpers.tsx +++ b/client/src/hooks/Messages/useMessageHelpers.tsx @@ -17,9 +17,9 @@ export default function useMessageHelpers(props: TMessageProps) { regenerate, isSubmitting, conversation, - latestMessage, setAbortScroll, handleContinue, + latestMessageId, setLatestMessage, } = useMessagesViewContext(); const agentsMap = useAgentsMapContext(); @@ -141,8 +141,8 @@ export default function useMessageHelpers(props: TMessageProps) { conversation, isSubmitting, handleScroll, - latestMessage, handleContinue, + latestMessageId, copyToClipboard, regenerateMessage, }; diff --git a/client/src/hooks/Messages/useSubmitMessage.ts b/client/src/hooks/Messages/useSubmitMessage.ts index fcf92d3eef..d924e3b987 100644 --- a/client/src/hooks/Messages/useSubmitMessage.ts +++ b/client/src/hooks/Messages/useSubmitMessage.ts @@ -9,7 +9,8 @@ export default function useSubmitMessage() { const { user } = useAuthContext(); const methods = useChatFormContext(); const { conversation: addedConvo } = useAddedChatContext(); - const { ask, index, getMessages, setMessages, latestMessage } = useChatContext(); + const { ask, index, getMessages, setMessages } = useChatContext(); + const latestMessage = useRecoilValue(store.latestMessageFamily(index)); const autoSendPrompts = useRecoilValue(store.autoSendPrompts); const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index)); diff --git a/client/src/hooks/useLocalize.ts b/client/src/hooks/useLocalize.ts index 6b574d25b1..f87ee5932b 100644 --- a/client/src/hooks/useLocalize.ts +++ b/client/src/hooks/useLocalize.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { TOptions } from 'i18next'; import { useRecoilValue } from 'recoil'; import { useTranslation } from 'react-i18next'; @@ -17,5 +17,8 @@ export default function useLocalize() { } }, [lang, i18n]); - return (phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options); + return useCallback( + (phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options), + [t], + ); } diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index 7fa499f40d..f2879cb092 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -48,7 +48,7 @@ const useNewConvo = (index = 0) => { const applyModelSpecEffects = useApplyModelSpecEffects(); const clearAllConversations = store.useClearConvoState(); const defaultPreset = useRecoilValue(store.defaultPreset); - const { setConversation } = store.useCreateConversationAtom(index); + const { setConversation } = store.useSetConversationAtom(index); const [files, setFiles] = useRecoilState(store.filesByIndex(index)); const saveBadgesState = useRecoilValue(store.saveBadgesState); const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`); diff --git a/client/src/hooks/useRenderChangeLog.ts b/client/src/hooks/useRenderChangeLog.ts new file mode 100644 index 0000000000..e20f04be05 --- /dev/null +++ b/client/src/hooks/useRenderChangeLog.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react'; + +type DebugWindow = Window & { + __LC_RENDER_DEBUG__?: boolean; +}; + +/** + * Development-only hook that logs which tracked values changed between renders. + * + * Enable by setting `window.__LC_RENDER_DEBUG__ = true` in the browser console. + * Automatically no-ops in production builds. + * + * @example + * ```ts + * useRenderChangeLog('MessageRender', { messageId, isLast, depth }); + * ``` + */ +export default function useRenderChangeLog( + name: string, + values: Record, +) { + const previousValuesRef = useRef | null>(null); + + useEffect(() => { + if (process.env.NODE_ENV === 'production') { + return; + } + + if (typeof window === 'undefined' || !(window as DebugWindow).__LC_RENDER_DEBUG__) { + previousValuesRef.current = values; + return; + } + + if (previousValuesRef.current == null) { + console.log(`[render-debug] ${name}: initial render`, values); + previousValuesRef.current = values; + return; + } + + const previousValues = previousValuesRef.current; + const changedEntries = Object.entries(values).filter( + ([key, value]) => !Object.is(previousValues[key], value), + ); + + if (changedEntries.length > 0) { + console.log( + `[render-debug] ${name}`, + Object.fromEntries( + changedEntries.map(([key, value]) => [ + key, + { + previous: previousValues[key], + next: value, + }, + ]), + ), + ); + } else { + console.log(`[render-debug] ${name}: parent-driven render`); + } + + previousValuesRef.current = values; + }); +} diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 7faec7aa9d..30b8211ab5 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -6,13 +6,18 @@ import { atomFamily, DefaultValue, selectorFamily, - useRecoilState, useRecoilValue, useSetRecoilState, useRecoilCallback, } from 'recoil'; import { LocalStorageKeys, isEphemeralAgentId, Constants } from 'librechat-data-provider'; -import type { TMessage, TPreset, TConversation, TSubmission } from 'librechat-data-provider'; +import type { + EModelEndpoint, + TConversation, + TSubmission, + TMessage, + TPreset, +} from 'librechat-data-provider'; import type { TOptionSettings, ExtendedFile } from '~/common'; import { clearModelForNonEphemeralAgent, @@ -151,6 +156,54 @@ const allConversationsSelector = selector({ }, }); +const conversationIdByIndex = selectorFamily({ + key: 'conversationIdByIndex', + get: + (index: string | number) => + ({ get }) => + get(conversationByIndex(index))?.conversationId ?? null, +}); + +const conversationEndpointByIndex = selectorFamily({ + key: 'conversationEndpointByIndex', + get: + (index: string | number) => + ({ get }) => + get(conversationByIndex(index))?.endpoint ?? null, +}); + +const conversationModelByIndex = selectorFamily({ + key: 'conversationModelByIndex', + get: + (index: string | number) => + ({ get }) => + get(conversationByIndex(index))?.model ?? null, +}); + +const conversationSpecByIndex = selectorFamily({ + key: 'conversationSpecByIndex', + get: + (index: string | number) => + ({ get }) => + get(conversationByIndex(index))?.spec ?? null, +}); + +const conversationAgentIdByIndex = selectorFamily({ + key: 'conversationAgentIdByIndex', + get: + (index: string | number) => + ({ get }) => + get(conversationByIndex(index))?.agent_id ?? null, +}); + +const conversationAssistantIdByIndex = selectorFamily({ + key: 'conversationAssistantIdByIndex', + get: + (index: string | number) => + ({ get }) => + get(conversationByIndex(index))?.assistant_id ?? null, +}); + const presetByIndex = atomFamily({ key: 'presetByIndex', default: null, @@ -268,19 +321,27 @@ const messagesSiblingIdxFamily = atomFamily({ function useCreateConversationAtom(key: string | number) { const hasSetConversation = useSetConvoContext(); - const [keys, setKeys] = useRecoilState(conversationKeysAtom); - const setConversation = useSetRecoilState(conversationByIndex(key)); + const setKeys = useSetRecoilState(conversationKeysAtom); const conversation = useRecoilValue(conversationByIndex(key)); + const setConversation = useSetRecoilState(conversationByIndex(key)); useEffect(() => { - if (!keys.includes(key)) { - setKeys([...keys, key]); - } - }, [key, keys, setKeys]); + setKeys((prevKeys) => { + if (prevKeys.includes(key)) { + return prevKeys; + } + return [...prevKeys, key]; + }); + }, [key, setKeys]); return { hasSetConversation, conversation, setConversation }; } +function useSetConversationAtom(key: string | number) { + const { setConversation } = useCreateConversationAtom(key); + return { setConversation }; +} + function useClearConvoState() { /** Clears all active conversations. Pass `true` to skip the first or root conversation */ const clearAllConversations = useRecoilCallback( @@ -309,15 +370,7 @@ function useClearConvoState() { return clearAllConversations; } -const conversationByKeySelector = selectorFamily({ - key: 'conversationByKeySelector', - get: - (index: string | number) => - ({ get }) => { - const conversation = get(conversationByIndex(index)); - return conversation; - }, -}); +const conversationByKeySelector = conversationByIndex; function useClearSubmissionState() { const clearAllSubmissions = useRecoilCallback( @@ -411,9 +464,16 @@ export default { messagesSiblingIdxFamily, anySubmittingSelector, allConversationsSelector, + conversationIdByIndex, + conversationEndpointByIndex, + conversationModelByIndex, + conversationSpecByIndex, + conversationAgentIdByIndex, + conversationAssistantIdByIndex, conversationByKeySelector, useClearConvoState, useCreateConversationAtom, + useSetConversationAtom, showMentionPopoverFamily, globalAudioURLFamily, activeRunFamily,