refactor: Optimize Message Re-renders (#12097)

* 🔄 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.
This commit is contained in:
Danny Avila 2026-03-06 00:03:32 -05:00 committed by GitHub
parent c324a8d9e4
commit 5209f1dc9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1578 additions and 1085 deletions

View file

@ -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<ArtifactsContextValue>(
() => ({
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<ArtifactsContextValue>(
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
[defaultContextValue, value],

View file

@ -18,7 +18,8 @@ interface MessagesViewContextValue {
/** Message state management */
index: ReturnType<typeof useChatContext>['index'];
latestMessage: ReturnType<typeof useChatContext>['latestMessage'];
latestMessageId: ReturnType<typeof useChatContext>['latestMessageId'];
latestMessageDepth: ReturnType<typeof useChatContext>['latestMessageDepth'];
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
getMessages: ReturnType<typeof useChatContext>['getMessages'];
setMessages: ReturnType<typeof useChatContext>['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],
);
}

View file

@ -56,10 +56,13 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const announceAssertive = announcePolite;
const contextValue = {
announcePolite,
announceAssertive,
};
const contextValue = useMemo(
() => ({
announcePolite,
announceAssertive,
}),
[announcePolite, announceAssertive],
);
useEffect(() => {
return () => {

View file

@ -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;
}

View file

@ -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<ContextType>();
@ -94,3 +94,8 @@ export default function Header() {
</div>
);
}
const MemoizedHeader = memo(Header);
MemoizedHeader.displayName = 'Header';
export default MemoizedHeader;

View file

@ -219,7 +219,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
conversation={conversation}
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
@ -230,7 +229,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
)}
{showMentionPopover && (
<Mention
conversation={conversation}
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}

View file

@ -24,7 +24,7 @@ export default function FileRow({
files: Map<string, ExtendedFile> | undefined;
abortUpload?: () => void;
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
setFilesLoading?: React.Dispatch<React.SetStateAction<boolean>>;
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;

View file

@ -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<boolean>;
newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
@ -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,
});

View file

@ -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<typeof useChatContext>['newConversation'];
getConversation: () => TConversation | null;
newConversation: ConvoGenerator;
}
const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | undefined>(
@ -17,20 +20,34 @@ const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | u
);
export function ModelSelectorChatProvider({ children }: { children: React.ReactNode }) {
const { conversation, newConversation } = useChatContext();
const getConversation = useGetConversation(0);
const { newConversation: nextNewConversation } = useNewConvo();
const spec = useRecoilValue(store.conversationSpecByIndex(0));
const model = useRecoilValue(store.conversationModelByIndex(0));
const agent_id = useRecoilValue(store.conversationAgentIdByIndex(0));
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0));
const assistant_id = useRecoilValue(store.conversationAssistantIdByIndex(0));
const newConversationRef = useRef(nextNewConversation);
newConversationRef.current = nextNewConversation;
const newConversation = useCallback<ConvoGenerator>(
(params) => newConversationRef.current(params),
[],
);
/** Context value only created when relevant conversation properties change */
const contextValue = useMemo<ModelSelectorChatContextValue>(
() => ({
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 (

View file

@ -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 <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
}

View file

@ -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<HTMLButtonElement> = (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {

View file

@ -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);

View file

@ -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 (
<MessageContext.Provider value={contextValue}>
<Part
part={part}
attachments={partAttachments}
isSubmitting={isSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={isLastPart}
showCursor={isLastPart && isLast}
/>
</MessageContext.Provider>
);
});
type ContentPartsProps = {
content: Array<TMessageContentParts | undefined> | 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 (
<MessageContext.Provider
<PartWithContext
key={`provider-${messageId}-${idx}`}
value={{
messageId,
isExpanded: true,
conversationId,
partIndex: idx,
nextType: content?.[idx + 1]?.type,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<Part
part={part}
attachments={partAttachments}
isSubmitting={effectiveIsSubmitting}
key={`part-${messageId}-${idx}`}
isCreatedByUser={isCreatedByUser}
isLast={isLastPart}
showCursor={isLastPart && isLast}
/>
</MessageContext.Provider>
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]}
/>
);
},
[

View file

@ -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<boolean>(store.LaTeXParsing);
const isInitializing = content === '';
@ -106,5 +106,6 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
</MarkdownErrorBoundary>
);
});
Markdown.displayName = 'Markdown';
export default Markdown;

View file

@ -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 <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
}
});
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>
);
});
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 <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
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 <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
});
img.displayName = 'MarkdownImage';

View file

@ -185,4 +185,7 @@ const MessageContent = ({
);
};
export default memo(MessageContent);
const MemoizedMessageContent = memo(MessageContent);
MemoizedMessageContent.displayName = 'MessageContent';
export default MemoizedMessageContent;

View file

@ -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 (
<ErrorMessage
text={
part[ContentTypes.ERROR] ??
(typeof part[ContentTypes.TEXT] === 'string'
? part[ContentTypes.TEXT]
: part.text?.value) ??
''
}
className="my-2"
/>
);
} else if (part.type === ContentTypes.AGENT_UPDATE) {
return (
<>
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} />
{isLast && showCursor && (
<Container>
<EmptyText />
</Container>
)}
</>
);
} 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 (
<Container>
<EmptyText />
</Container>
);
}
/** Skip rendering non-last whitespace-only parts to avoid empty Container */
if (!isLast) {
return null;
}
}
return (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
} else if (part.type === ContentTypes.THINK) {
const reasoning = typeof part.think === 'string' ? part.think : part.think?.value;
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} 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 (
<ErrorMessage
text={
part[ContentTypes.ERROR] ??
(typeof part[ContentTypes.TEXT] === 'string'
? part[ContentTypes.TEXT]
: part.text?.value) ??
''
}
className="my-2"
<ExecuteCode
attachments={attachments}
isSubmitting={isSubmitting}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
/>
);
} 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 (
<>
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} />
{isLast && showCursor && (
<Container>
<EmptyText />
</Container>
)}
</>
<OpenAIImageGen
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
toolName={toolCall.name}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
attachments={attachments}
/>
);
} 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 (
<WebSearch
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
isLast={isLast}
/>
);
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
return (
<AgentHandoff
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
/>
);
} else if (isToolCall) {
return (
<ToolCall
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return (
<CodeAnalyze
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
/>
);
} else if (
toolCall.type === ToolCallTypes.RETRIEVAL ||
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
ToolCallTypes.FUNCTION in toolCall &&
imageGenTools.has(toolCall.function.name)
) {
return (
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
if (isImageVisionTool(toolCall)) {
if (isSubmitting && showCursor) {
return (
<Container>
<EmptyText />
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
}
/** Skip rendering non-last whitespace-only parts to avoid empty Container */
if (!isLast) {
return null;
}
}
return (
<Container>
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
} else if (part.type === ContentTypes.THINK) {
const reasoning = typeof part.think === 'string' ? part.think : part.think?.value;
if (typeof reasoning !== 'string') {
return null;
}
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
} 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 (
<ExecuteCode
attachments={attachments}
isSubmitting={isSubmitting}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
/>
);
} else if (
isToolCall &&
(toolCall.name === 'image_gen_oai' ||
toolCall.name === 'image_edit_oai' ||
toolCall.name === 'gemini_image_gen')
) {
return (
<OpenAIImageGen
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
toolName={toolCall.name}
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
output={toolCall.output ?? ''}
attachments={attachments}
/>
);
} else if (isToolCall && toolCall.name === Tools.web_search) {
return (
<WebSearch
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
isLast={isLast}
/>
);
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
return (
<AgentHandoff
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
/>
);
} else if (isToolCall) {
return (
<ToolCall
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
attachments={attachments}
auth={toolCall.auth}
expires_at={toolCall.expires_at}
isLast={isLast}
/>
);
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
return (
<CodeAnalyze
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
/>
);
} else if (
toolCall.type === ToolCallTypes.RETRIEVAL ||
toolCall.type === ToolCallTypes.FILE_SEARCH
) {
return (
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
);
} else if (
toolCall.type === ToolCallTypes.FUNCTION &&
ToolCallTypes.FUNCTION in toolCall &&
imageGenTools.has(toolCall.function.name)
) {
return (
<ImageGen
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.function.arguments as string}
/>
);
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
if (isImageVisionTool(toolCall)) {
if (isSubmitting && showCursor) {
return (
<Container>
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
</Container>
);
}
return null;
}
return (
<ToolCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
args={toolCall.function.arguments as string}
name={toolCall.function.name}
output={toolCall.function.output}
isLast={isLast}
/>
);
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
return (
<Image
imagePath={imageFile.filepath}
height={height}
width={width}
altText={imageFile.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: height + 'px',
width: width + 'px',
}}
<ToolCall
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
args={toolCall.function.arguments as string}
name={toolCall.function.name}
output={toolCall.function.output}
isLast={isLast}
/>
);
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
return (
<Image
imagePath={imageFile.filepath}
height={height}
width={width}
altText={imageFile.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: height + 'px',
width: width + 'px',
}}
/>
);
}
return null;
},
);
return null;
});
Part.displayName = 'Part';
export default Part;

View file

@ -17,7 +17,7 @@ type ContentType =
| ReactElement<React.ComponentProps<typeof MarkdownLite>>
| 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) =>
</div>
);
});
TextPart.displayName = 'TextPart';
export default TextPart;

View file

@ -18,7 +18,7 @@ type THoverButtons = {
message: TMessage;
regenerate: () => void;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => 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}
/>

View file

@ -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 (
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
{children}
</div>
);
},
);
const MessageContainer = React.memo(function MessageContainer({
handleScroll,
children,
}: {
handleScroll: (event?: unknown) => void;
children: React.ReactNode;
}) {
return (
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
{children}
</div>
);
});
export default function Message(props: TMessageProps) {
const { conversation, handleScroll } = useMessageProcess({

View file

@ -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<TMessageContentParts | undefined>}
/>
</div>
@ -165,7 +165,7 @@ export default function Message(props: TMessageProps) {
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
latestMessageId={latestMessageId}
isLast={isLast}
/>
</SubRow>

View file

@ -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 (
<div
id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
)}
>
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
)}
return (
<div
id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div
className={cn(
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<MessageContext.Provider
value={{
messageId: msg.messageId,
conversationId: conversation?.conversationId,
isExpanded: false,
isSubmitting: effectiveIsSubmitting,
isLatestMessage,
}}
>
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={effectiveIsSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</MessageContext.Provider>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<MessageContext.Provider value={messageContextValue}>
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(msg.error ?? false)}
isSubmitting={effectiveIsSubmitting}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</MessageContext.Provider>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessageId={latestMessageId}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
);
},
);
</div>
);
});
MessageRender.displayName = 'MessageRender';
export default MessageRender;

View file

@ -1,7 +1,8 @@
import { memo } from 'react';
const PlaceholderRow = memo(() => {
const PlaceholderRow = memo(function PlaceholderRow() {
return <div className="mt-1 h-[27px] bg-transparent" />;
});
PlaceholderRow.displayName = 'PlaceholderRow';
export default PlaceholderRow;

View file

@ -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(
() => () => {

View file

@ -23,125 +23,138 @@ interface FloatingCodeBarProps extends CodeBarProps {
isVisible: boolean;
}
const CodeBar: React.FC<CodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
) : (
<div className="flex items-center justify-center gap-4">
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
const CodeBar: React.FC<CodeBarProps> = React.memo(function CodeBar({
lang,
error,
codeRef,
blockIndex,
plugin = null,
allowExecution = true,
}) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
<span className="">{lang}</span>
{plugin === true ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />
) : (
<div className="flex items-center justify-center gap-4">
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
)}
<button
type="button"
className={cn(
'ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
<button
type="button"
className={cn(
'ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white',
error === true ? 'h-4 w-4 items-start text-white/50' : '',
)}
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString != null) {
setIsCopied(true);
copy(codeString.trim(), { format: 'text/plain' });
setTimeout(() => {
setIsCopied(false);
}, 3000);
}
}}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</div>
)}
</div>
);
},
);
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(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 (
<div
className={cn(
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
{plugin === true ? (
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
) : (
<>
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} iconOnly />
)}
<TooltipAnchor
description={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
render={
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
className={cn(
'flex items-center justify-center rounded p-1.5 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard aria-hidden="true" />
)}
</button>
setTimeout(() => {
setIsCopied(false);
}, 3000);
}
/>
</>
)}
</div>
);
},
);
}}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{error !== true && (
<span className="relative">
<span className="invisible">{localize('com_ui_copy_code')}</span>
<span className="absolute inset-0 flex items-center">
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
</span>
</span>
)}
</button>
</div>
)}
</div>
);
});
CodeBar.displayName = 'CodeBar';
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(function FloatingCodeBar({
lang,
error,
codeRef,
blockIndex,
plugin = null,
allowExecution = true,
isVisible,
}) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(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 (
<div
className={cn(
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
{plugin === true ? (
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
) : (
<>
{allowExecution === true && (
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} iconOnly />
)}
<TooltipAnchor
description={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
render={
<button
ref={copyButtonRef}
type="button"
tabIndex={isVisible ? 0 : -1}
aria-label={isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
className={cn(
'flex items-center justify-center rounded p-1.5 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
error === true ? 'h-4 w-4 text-white/50' : '',
)}
onClick={handleCopy}
>
{isCopied ? (
<CheckMark className="h-[18px] w-[18px]" aria-hidden="true" />
) : (
<Clipboard aria-hidden="true" />
)}
</button>
}
/>
</>
)}
</div>
);
});
FloatingCodeBar.displayName = 'FloatingCodeBar';
const CodeBlock: React.FC<CodeBlockProps> = ({
lang,

View file

@ -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 (
<div
id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
)}
>
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
)}
return (
<div
id={msg.messageId}
aria-label={getMessageAriaLabel(msg, localize)}
className={cn(
baseClasses.common,
baseClasses.chat,
conditionalClasses.focus,
'message-render',
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
{!hasParallelContent && (
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div
className={cn(
'relative flex flex-col',
hasParallelContent ? 'w-full' : 'w-11/12',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
{!hasParallelContent && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts
edit={edit}
isLast={isLast}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
messageId={msg.messageId}
attachments={attachments}
searchResults={searchResults}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isSubmitting={effectiveIsSubmitting}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
message={msg}
isEditing={edit}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts
edit={edit}
isLast={isLast}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
messageId={msg.messageId}
attachments={attachments}
searchResults={searchResults}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isSubmitting={effectiveIsSubmitting}
isCreatedByUser={msg.isCreatedByUser}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
{hasNoChildren && effectiveIsSubmitting ? (
<PlaceholderRow />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
message={msg}
isEditing={edit}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessageId={latestMessageId}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
);
},
);
</div>
);
});
ContentRender.displayName = 'ContentRender';
export default ContentRender;

View file

@ -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 (
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
{children}
</div>
);
},
);
const MessageContainer = React.memo(function MessageContainer({
handleScroll,
children,
}: {
handleScroll: (event?: unknown) => void;
children: React.ReactNode;
}) {
return (
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
{children}
</div>
);
});
export default function MessageContent(props: TMessageProps) {
const { conversation, handleScroll, isSubmitting } = useMessageProcess({

View file

@ -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,
});

View file

@ -56,6 +56,7 @@ jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useShowMarketplace: () => false,
useNewConvo: () => ({ newConversation: jest.fn() }),
useGetConversation: () => () => null,
}));
jest.mock('~/Providers', () => ({

View file

@ -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: () => {},

View file

@ -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;

View file

@ -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;

View file

@ -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 <ActionsPanel />;

View file

@ -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;

View file

@ -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<AgentForm>();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(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 }) => <div className="flex flex-wrap gap-2">{children}</div>}
/>
<div>
@ -110,3 +112,8 @@ export default function Files({
</div>
);
}
const MemoizedFiles = memo(Files);
MemoizedFiles.displayName = 'Files';
export default MemoizedFiles;

View file

@ -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({
</OGDialog>
);
}
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;

View file

@ -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<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(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({
<FileRow
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
agent_id={agent_id}
tool_resource={EToolResources.context}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
@ -199,3 +206,8 @@ export default function FileContext({
</div>
);
}
const MemoizedFileContext = memo(FileContext);
MemoizedFileContext.displayName = 'FileContext';
export default MemoizedFileContext;

View file

@ -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<AgentForm>();
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<Map<string, ExtendedFile>>(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({
<FileRow
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
agent_id={agent_id}
tool_resource={EToolResources.file_search}
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
@ -203,3 +209,8 @@ export default function FileSearch({
</div>
);
}
const MemoizedFileSearch = memo(FileSearch);
MemoizedFileSearch.displayName = 'FileSearch';
export default MemoizedFileSearch;

View file

@ -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<string | null>(
conversation?.agent_id ?? null,
);
const agentQuery = useGetAgentByIdQuery(selectedAgentId);
const getDefaultConversation = useDefaultConvo();
const { newConversation } = useNewConvo();
const getConversation = useGetConversation(0);
const updateConversation = useCallback(
(agent: Partial<Agent>, template: Partial<TPreset | TConversation>) => {
async (agent: Partial<Agent>, template: Partial<TPreset | TConversation>) => {
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<TPreset | TConversation> = {
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 };

View file

@ -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<HTMLButtonElement>) => {
e.preventDefault();
stopGenerating();
};
const handleStopGenerating = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
stopGenerating();
},
[stopGenerating],
);
const handleRegenerate = (e: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault();
continueGeneration();
setSiblingIdx(0);
};
const handleContinue = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
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,
],
);
}

View file

@ -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';

View file

@ -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;
};

View file

@ -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],
);
}

View file

@ -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<TPreset | null>(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

View file

@ -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;

View file

@ -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<string, ExtendedFile>;
setFiles: FileSetter;
setFilesLoading?: React.Dispatch<React.SetStateAction<boolean>>;
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<string[]>([]);
const abortControllerRef = useRef<AbortController | null>(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;

View file

@ -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(
{

View file

@ -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,

View file

@ -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 || {};

View file

@ -31,8 +31,16 @@ export default function useMessageActions(props: TMessageActions) {
const UsernameDisplay = useRecoilValue<boolean>(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,
};
}

View file

@ -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,
};

View file

@ -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));

View file

@ -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],
);
}

View file

@ -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<boolean>(store.saveBadgesState);
const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`);

View file

@ -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<string, string | number | boolean | null | undefined>,
) {
const previousValuesRef = useRef<Record<
string,
string | number | boolean | null | undefined
> | 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;
});
}

View file

@ -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<string | null, string | number>({
key: 'conversationIdByIndex',
get:
(index: string | number) =>
({ get }) =>
get(conversationByIndex(index))?.conversationId ?? null,
});
const conversationEndpointByIndex = selectorFamily<EModelEndpoint | null, string | number>({
key: 'conversationEndpointByIndex',
get:
(index: string | number) =>
({ get }) =>
get(conversationByIndex(index))?.endpoint ?? null,
});
const conversationModelByIndex = selectorFamily<string | null, string | number>({
key: 'conversationModelByIndex',
get:
(index: string | number) =>
({ get }) =>
get(conversationByIndex(index))?.model ?? null,
});
const conversationSpecByIndex = selectorFamily<string | null, string | number>({
key: 'conversationSpecByIndex',
get:
(index: string | number) =>
({ get }) =>
get(conversationByIndex(index))?.spec ?? null,
});
const conversationAgentIdByIndex = selectorFamily<string | null, string | number>({
key: 'conversationAgentIdByIndex',
get:
(index: string | number) =>
({ get }) =>
get(conversationByIndex(index))?.agent_id ?? null,
});
const conversationAssistantIdByIndex = selectorFamily<string | null, string | number>({
key: 'conversationAssistantIdByIndex',
get:
(index: string | number) =>
({ get }) =>
get(conversationByIndex(index))?.assistant_id ?? null,
});
const presetByIndex = atomFamily<TPreset | null, string | number>({
key: 'presetByIndex',
default: null,
@ -268,19 +321,27 @@ const messagesSiblingIdxFamily = atomFamily<number, string | null | undefined>({
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,