diff --git a/client/src/Providers/ArtifactsContext.tsx b/client/src/Providers/ArtifactsContext.tsx index fd67d5af94..2eb26e04b0 100644 --- a/client/src/Providers/ArtifactsContext.tsx +++ b/client/src/Providers/ArtifactsContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import type { TMessage } from 'librechat-data-provider'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { getLatestText } from '~/utils'; import store from '~/store'; @@ -20,16 +20,12 @@ interface ArtifactsProviderProps { export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) { const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); - const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const latestMessage = useLatestMessage(0); const conversationId = useRecoilValue(store.conversationIdByIndex(0)); const chatLatestMessageText = useMemo(() => { - return getLatestText({ - text: latestMessage?.text ?? null, - content: latestMessage?.content ?? null, - messageId: latestMessage?.messageId ?? null, - } as TMessage); - }, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]); + return getLatestText(latestMessage); + }, [latestMessage]); const defaultContextValue = useMemo( () => ({ diff --git a/client/src/Providers/MessagesViewContext.tsx b/client/src/Providers/MessagesViewContext.tsx index c44972918c..1d9172c95b 100644 --- a/client/src/Providers/MessagesViewContext.tsx +++ b/client/src/Providers/MessagesViewContext.tsx @@ -20,7 +20,6 @@ interface MessagesViewContextValue { index: ReturnType['index']; latestMessageId: ReturnType['latestMessageId']; latestMessageDepth: ReturnType['latestMessageDepth']; - setLatestMessage: ReturnType['setLatestMessage']; getMessages: ReturnType['getMessages']; setMessages: ReturnType['setMessages']; } @@ -44,7 +43,6 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } latestMessageDepth, setAbortScroll, handleContinue, - setLatestMessage, abortScroll, getMessages, setMessages, @@ -87,9 +85,8 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } index, latestMessageId, latestMessageDepth, - setLatestMessage, }), - [index, latestMessageId, latestMessageDepth, setLatestMessage], + [index, latestMessageId, latestMessageDepth], ); /** Combine all values into final context value */ @@ -191,9 +188,9 @@ export function useOptionalMessagesConversation() { /** Hook for components that only need message state */ export function useMessagesState() { - const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext(); + const { index, latestMessageId, latestMessageDepth } = useMessagesViewContext(); return useMemo( - () => ({ index, latestMessageId, latestMessageDepth, setLatestMessage }), - [index, latestMessageId, latestMessageDepth, setLatestMessage], + () => ({ index, latestMessageId, latestMessageDepth }), + [index, latestMessageId, latestMessageDepth], ); } diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 1db8ba3b3e..d8f86f23f4 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -604,7 +604,6 @@ export type NewConversationParams = { preset?: Partial; modelsData?: t.TModelsConfig; buildDefault?: boolean; - keepLatestMessage?: boolean; keepAddedConvos?: boolean; disableParams?: boolean; }; diff --git a/client/src/components/Chat/Input/StreamAudio.tsx b/client/src/components/Chat/Input/StreamAudio.tsx index 221acaaa2c..d199962eff 100644 --- a/client/src/components/Chat/Input/StreamAudio.tsx +++ b/client/src/components/Chat/Input/StreamAudio.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import { useCustomAudioRef, MediaSourceAppender, usePauseGlobalAudio } from '~/hooks/Audio'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { getLatestText, logger } from '~/utils'; import { useAuthContext } from '~/hooks'; import { globalAudioId } from '~/common'; @@ -29,7 +30,7 @@ export default function StreamAudio({ index = 0 }) { const activeRunId = useRecoilValue(store.activeRunFamily(index)); const automaticPlayback = useRecoilValue(store.automaticPlayback); const isSubmitting = useRecoilValue(store.isSubmittingFamily(index)); - const latestMessage = useRecoilValue(store.latestMessageFamily(index)); + const latestMessage = useLatestMessage(index); const setIsPlaying = useSetRecoilState(store.globalAudioPlayingFamily(index)); const [audioRunId, setAudioRunId] = useRecoilState(store.audioRunFamily(index)); const [isFetching, setIsFetching] = useRecoilState(store.globalAudioFetchingFamily(index)); diff --git a/client/src/components/Chat/Messages/SearchButtons.tsx b/client/src/components/Chat/Messages/SearchButtons.tsx index 0b2a5d8895..03574946c7 100644 --- a/client/src/components/Chat/Messages/SearchButtons.tsx +++ b/client/src/components/Chat/Messages/SearchButtons.tsx @@ -45,7 +45,6 @@ export default function SearchButtons({ message }: { message: TMessage }) { conversationId, title, } as TConversation), - { resetLatestMessage: true }, ); }; diff --git a/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx new file mode 100644 index 0000000000..efebd8a82d --- /dev/null +++ b/client/src/components/Chat/Messages/__tests__/HoverActions.streaming.spec.tsx @@ -0,0 +1,213 @@ +import React, { useMemo } from 'react'; +import { RecoilRoot, type MutableSnapshot } from 'recoil'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, type TConversation, type TMessage } from 'librechat-data-provider'; +import { ChatContext, MessagesViewProvider, useChatContext } from '~/Providers'; +import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; +import Message from '~/components/Chat/Messages/Message'; +import store from '~/store'; + +let mockHoverButtonsRenderCount = 0; + +jest.mock('~/components/Chat/Messages/HoverButtons', () => ({ + __esModule: true, + default: () => { + mockHoverButtonsRenderCount += 1; + return
; + }, +})); + +jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({ + __esModule: true, + default: ({ text }: { text: string }) =>
{text}
, +})); + +jest.mock('~/components/Chat/Messages/MessageIcon', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/components/Chat/Messages/ui/PlaceholderRow', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('~/hooks', () => { + const useMessageProcess = jest.requireActual('~/hooks/Messages/useMessageProcess').default; + const useMemoizedChatContext = jest.requireActual( + '~/hooks/Messages/useMemoizedChatContext', + ).default; + return { + useMessageProcess, + useMemoizedChatContext, + useContentMetadata: () => ({ hasParallelContent: false }), + useLocalize: () => (key: string) => key, + useMessageActions: ({ + chatContext, + message, + }: { + chatContext: { + ask: jest.Mock; + index: number; + regenerate: jest.Mock; + conversation: TConversation | null; + latestMessageId: string | undefined; + latestMessageDepth: number | undefined; + handleContinue: jest.Mock; + }; + message?: TMessage; + }) => ({ + ask: chatContext.ask, + edit: false, + index: chatContext.index, + agent: undefined, + assistant: undefined, + conversation: chatContext.conversation, + messageLabel: message?.sender ?? 'Assistant', + handleFeedback: jest.fn(), + handleContinue: chatContext.handleContinue, + copyToClipboard: jest.fn(), + latestMessageId: chatContext.latestMessageId, + regenerateMessage: jest.fn(), + latestMessageDepth: chatContext.latestMessageDepth, + }), + }; +}); + +const conversation = { + conversationId: 'conversation-streaming', + endpoint: 'openAI', + model: 'gpt-4', +} as TConversation; + +const userMessage = { + messageId: 'user-message', + parentMessageId: '00000000-0000-0000-0000-000000000000', + conversationId: conversation.conversationId, + text: 'Tell me something', + isCreatedByUser: true, +} as TMessage; + +const optimisticAssistantMessage = { + messageId: 'assistant-message_', + parentMessageId: userMessage.messageId, + conversationId: conversation.conversationId, + sender: 'Assistant', + text: '', + isCreatedByUser: false, +} as TMessage; + +const canonicalAssistantMessage = { + ...optimisticAssistantMessage, + messageId: 'assistant-message', + text: 'Streaming canonical response', +} as TMessage; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function DerivedStreamingRow() { + const queryClient = useQueryClient(); + const latestMessage = useLatestMessage(0); + const latestMessageId = useLatestMessageId(0); + const latestMessageDepth = latestMessage?.depth; + + const chatContext = useMemo( + () => + ({ + ask: jest.fn(), + index: 0, + regenerate: jest.fn(), + conversation, + latestMessageId: latestMessageId ?? undefined, + latestMessageDepth, + handleContinue: jest.fn(), + isSubmitting: true, + abortScroll: false, + setAbortScroll: jest.fn(), + getMessages: () => + queryClient.getQueryData([QueryKeys.messages, conversation.conversationId]), + setMessages: (messages: TMessage[]) => { + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + messages, + ); + }, + }) as unknown as ReturnType, + [latestMessageDepth, latestMessageId, queryClient], + ); + + if (!latestMessage) { + return null; + } + + return ( + + + + + + ); +} + +function renderStreamingRow() { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, optimisticAssistantMessage], + ); + + const initializeState = ({ set }: MutableSnapshot) => { + set(store.conversationByIndex(0), conversation); + set(store.isSubmittingFamily(0), true); + }; + + render( + + + + + + + , + ); + + return queryClient; +} + +describe('streaming hover actions', () => { + beforeEach(() => { + mockHoverButtonsRenderCount = 0; + }); + + it('does not mount hover actions while an optimistic assistant row is replaced', async () => { + const user = userEvent.setup(); + const queryClient = renderStreamingRow(); + + await user.hover(screen.getByTestId('message-content')); + + expect(screen.queryByTestId('hover-buttons')).toBeNull(); + expect(mockHoverButtonsRenderCount).toBe(0); + + act(() => { + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, canonicalAssistantMessage], + ); + }); + + expect(screen.queryByTestId('hover-buttons')).toBeNull(); + expect(mockHoverButtonsRenderCount).toBe(0); + expect(screen.getByTestId('placeholder-row')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 108755a291..ba6ce2121b 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -162,7 +162,6 @@ export default function Conversation({ navigateToConvo(conversation, { currentConvoId, - resetLatestMessage: !(conversationId ?? '') || conversationId === Constants.NEW_CONVO, }); }; diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 0bf2cb093b..6f18a48ba8 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -1,13 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; import { QRCodeSVG } from 'qrcode.react'; import { Copy, CopyCheck } from 'lucide-react'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client'; import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import SharedLinkButton from './SharedLinkButton'; import { buildShareLinkUrl, cn } from '~/utils'; -import store from '~/store'; export default function ShareButton({ conversationId, @@ -35,29 +34,28 @@ export default function ShareButton({ setAnnouncement(''); }, 1000); }; - const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const latestMessage = useLatestMessage(0); const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); + const shareId = share?.shareId ?? ''; useEffect(() => { - if (share?.shareId !== undefined) { - setSharedLink(buildShareLinkUrl(share.shareId)); + if (shareId) { + setSharedLink(buildShareLinkUrl(shareId)); } - }, [share]); + }, [shareId]); const button = isLoading === true ? null : ( ); - const shareId = share?.shareId ?? ''; - return ( {children} diff --git a/client/src/components/Share/ShareMessagesProvider.tsx b/client/src/components/Share/ShareMessagesProvider.tsx index e614aa891a..496b21940e 100644 --- a/client/src/components/Share/ShareMessagesProvider.tsx +++ b/client/src/components/Share/ShareMessagesProvider.tsx @@ -31,7 +31,6 @@ export function ShareMessagesProvider({ messages, children }: ShareMessagesProvi abortScroll: false, setAbortScroll: () => {}, index: 0, - setLatestMessage: () => {}, getMessages: () => messages, setMessages: () => {}, }), diff --git a/client/src/hooks/Agents/useSelectAgent.ts b/client/src/hooks/Agents/useSelectAgent.ts index 30024c8f63..756f67ff2b 100644 --- a/client/src/hooks/Agents/useSelectAgent.ts +++ b/client/src/hooks/Agents/useSelectAgent.ts @@ -39,7 +39,6 @@ export default function useSelectAgent() { newConversation({ template: currentConvo, preset: template as Partial, - keepLatestMessage: true, }); }, [getConversation, getDefaultConversation, newConversation], diff --git a/client/src/hooks/Assistants/useSelectAssistant.ts b/client/src/hooks/Assistants/useSelectAssistant.ts index c3710503e8..09240e2018 100644 --- a/client/src/hooks/Assistants/useSelectAssistant.ts +++ b/client/src/hooks/Assistants/useSelectAssistant.ts @@ -33,7 +33,6 @@ export default function useSelectAssistant(endpoint: AssistantsEndpoint) { newConversation({ template: currentConvo, preset: template as Partial, - keepLatestMessage: true, }); return; } diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 36647908d2..0d4c5e1fd5 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -2,7 +2,7 @@ import { v4 } from 'uuid'; import { cloneDeep } from 'lodash'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { useSetRecoilState, useResetRecoilState, useRecoilValue, useRecoilCallback } from 'recoil'; +import { useSetRecoilState, useRecoilValue, useRecoilCallback } from 'recoil'; import { Constants, QueryKeys, @@ -49,7 +49,6 @@ export default function useChatFunctions({ isSubmitting, latestMessage, setSubmission, - setLatestMessage, conversation: immutableConversation, }: { index?: number; @@ -62,7 +61,6 @@ export default function useChatFunctions({ files?: Map; setFiles?: SetterOrUpdater>; setSubmission: SetterOrUpdater; - setLatestMessage?: SetterOrUpdater; }) { const navigate = useNavigate(); const getSender = useGetSender(); @@ -74,7 +72,6 @@ export default function useChatFunctions({ const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? ''); const setIsSubmitting = useSetRecoilState(store.isSubmittingFamily(index)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); - const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1)); /** * Atomically read + reset the per-conversation queue of manually-invoked @@ -122,7 +119,6 @@ export default function useChatFunctions({ } = {}, ) => { setShowStopButton(false); - resetLatestMultiMessage(); text = text.trim(); if (!!isSubmitting || text === '') { @@ -160,12 +156,13 @@ export default function useChatFunctions({ * a prior turn, not compose a new one). * - Fresh submit → drain the per-convo atom into the message. */ - const manualSkills = - overrideManualSkills != null - ? overrideManualSkills - : isRegenerate || isContinued || isEdited + let manualSkills = overrideManualSkills; + if (manualSkills == null) { + manualSkills = + isRegenerate || isContinued || isEdited ? [] : drainPendingManualSkills(conversationId ?? Constants.NEW_CONVO); + } const isEditOrContinue = isEdited || isContinued; let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? []; @@ -399,9 +396,6 @@ export default function useChatFunctions({ } else { setMessages([...submission.messages, currentMsg, initialResponse]); } - if (index === 0 && setLatestMessage) { - setLatestMessage(initialResponse); - } setSubmission(submission); logger.dir('message_stream', submission, { depth: null }); diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts index 219c370418..6bb66274c1 100644 --- a/client/src/hooks/Chat/useChatHelpers.ts +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -1,12 +1,13 @@ 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 { useRecoilState, useSetRecoilState } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import type { ActiveJobsResponse } from '~/data-provider'; import useChatFunctions from '~/hooks/Chat/useChatFunctions'; import { useAbortStreamMutation } from '~/data-provider'; import useNewConvo from '~/hooks/useNewConvo'; +import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; import store from '~/store'; // this to be set somewhere else @@ -27,11 +28,10 @@ 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 ?? ''); - const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index)); const [isSubmitting, setIsSubmitting] = useRecoilState(store.isSubmittingFamily(index)); - const [latestMessage, setLatestMessage] = useRecoilState(store.latestMessageFamily(index)); + const latestMessage = useLatestMessage(index, queryParam); - const latestMessageId = latestMessage?.messageId; + const latestMessageId = useLatestMessageId(index, queryParam) ?? undefined; const latestMessageDepth = latestMessage?.depth; const latestMessageRef = useRef(latestMessage); latestMessageRef.current = latestMessage; @@ -85,7 +85,6 @@ export default function useChatHelpers(index = 0, paramId?: string) { conversation, latestMessage, setSubmission, - setLatestMessage, }); const askRef = useRef(_ask); @@ -210,8 +209,6 @@ export default function useChatHelpers(index = 0, paramId?: string) { setSiblingIdx, latestMessageId, latestMessageDepth, - setLatestMessage, - resetLatestMessage, ask, index, regenerate, @@ -243,8 +240,6 @@ export default function useChatHelpers(index = 0, paramId?: string) { setSiblingIdx, latestMessageId, latestMessageDepth, - setLatestMessage, - resetLatestMessage, ask, index, regenerate, diff --git a/client/src/hooks/Config/useClearStates.ts b/client/src/hooks/Config/useClearStates.ts index 46ec9476c7..2958107194 100644 --- a/client/src/hooks/Config/useClearStates.ts +++ b/client/src/hooks/Config/useClearStates.ts @@ -5,14 +5,12 @@ import store from '~/store'; export default function useClearStates() { const clearConversations = store.useClearConvoState(); const clearSubmissions = store.useClearSubmissionState(); - const clearLatestMessages = store.useClearLatestMessages(); const clearStates = useRecoilCallback( ({ reset, snapshot }) => async (skipFirst?: boolean) => { await clearSubmissions(skipFirst); await clearConversations(skipFirst); - await clearLatestMessages(skipFirst); const keys = await snapshot.getPromise(store.conversationKeysAtom); diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index ddb2bac6c9..c8e7aa0b05 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -32,7 +32,6 @@ const useNavigateToConvo = (index = 0) => { const clearAllConversations = store.useClearConvoState(); const applyModelSpecEffects = useApplyModelSpecEffects(); const setSubmission = useSetRecoilState(store.submissionByIndex(index)); - const clearAllLatestMessages = store.useClearLatestMessages(`useNavigateToConvo ${index}`); const { hasSetConversation, setConversation: setConvo } = store.useCreateConversationAtom(index); const setConversation = useCallback( @@ -79,7 +78,6 @@ const useNavigateToConvo = (index = 0) => { const navigateToConvo = ( conversation?: TConversation | null, options?: { - resetLatestMessage?: boolean; currentConvoId?: string; }, ) => { @@ -87,14 +85,10 @@ const useNavigateToConvo = (index = 0) => { logger.warn('conversation', 'Conversation not provided to `navigateToConvo`'); return; } - const { resetLatestMessage = true, currentConvoId } = options || {}; + const { currentConvoId } = options || {}; logger.log('conversation', 'Navigating to conversation', conversation); hasSetConversation.current = true; setSubmission(null); - if (resetLatestMessage) { - logger.log('latest_message', 'Clearing all latest messages'); - clearAllLatestMessages(); - } let convo = { ...conversation }; const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); diff --git a/client/src/hooks/Conversations/usePresets.ts b/client/src/hooks/Conversations/usePresets.ts index 2165e1966e..5ead899004 100644 --- a/client/src/hooks/Conversations/usePresets.ts +++ b/client/src/hooks/Conversations/usePresets.ts @@ -218,7 +218,6 @@ export default function usePresets(index = 0) { newConversation({ template: currentConvo, preset: currentConvo, - keepLatestMessage: true, keepAddedConvos: true, disableParams, }); diff --git a/client/src/hooks/Input/useHandleKeyUp.spec.ts b/client/src/hooks/Input/useHandleKeyUp.spec.ts index 8148340e08..51b6cf8554 100644 --- a/client/src/hooks/Input/useHandleKeyUp.spec.ts +++ b/client/src/hooks/Input/useHandleKeyUp.spec.ts @@ -12,9 +12,6 @@ const mockCommandToggles = { at: true, plus: true, slash: true, dollar: true }; jest.mock('recoil', () => ({ ...jest.requireActual('recoil'), useRecoilValue: jest.fn((atom) => { - if (atom === 'latestMessageFamily-0') { - return null; - } if (atom === 'effectiveEndpointByIndex-0') { return mockEndpoint.current; } @@ -55,7 +52,6 @@ jest.mock('~/store', () => ({ showPlusPopoverFamily: (idx: number) => `showPlusPopoverFamily-${idx}`, showSkillsPopoverFamily: (idx: number) => `showSkillsPopoverFamily-${idx}`, effectiveEndpointByIndex: (idx: number) => `effectiveEndpointByIndex-${idx}`, - latestMessageFamily: (idx: number) => `latestMessageFamily-${idx}`, atCommand: 'atCommand', plusCommand: 'plusCommand', slashCommand: 'slashCommand', @@ -87,6 +83,10 @@ jest.mock('~/hooks/Agents/useAgentCapabilities', () => })), ); +jest.mock('~/hooks/Messages/useLatestMessage', () => ({ + useLatestMessage: jest.fn(() => null), +})); + import React from 'react'; import { renderHook, act } from '@testing-library/react'; import useHandleKeyUp from './useHandleKeyUp'; diff --git a/client/src/hooks/Input/useHandleKeyUp.ts b/client/src/hooks/Input/useHandleKeyUp.ts index e9af8b4a95..fa94e68f07 100644 --- a/client/src/hooks/Input/useHandleKeyUp.ts +++ b/client/src/hooks/Input/useHandleKeyUp.ts @@ -4,6 +4,7 @@ import { PermissionTypes, Permissions, isAssistantsEndpoint } from 'librechat-da import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities'; import useGetAgentsConfig from '~/hooks/Agents/useGetAgentsConfig'; import useHasAccess from '~/hooks/Roles/useHasAccess'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import store from '~/store'; /** Event keys that shouldn't trigger a command */ @@ -69,7 +70,7 @@ const useHandleKeyUp = ({ }); const { agentsConfig } = useGetAgentsConfig(); const { skillsEnabled } = useAgentCapabilities(agentsConfig?.capabilities); - const latestMessage = useRecoilValue(store.latestMessageFamily(index)); + const latestMessage = useLatestMessage(index); const endpoint = useRecoilValue(store.effectiveEndpointByIndex(index)); const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index)); const setShowPlusPopover = useSetRecoilState(store.showPlusPopoverFamily(index)); diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts index 48d3da90c8..e37e899188 100644 --- a/client/src/hooks/Input/useQueryParams.ts +++ b/client/src/hooks/Input/useQueryParams.ts @@ -90,10 +90,11 @@ export default function useQueryParams({ if (!spec) { return; } - const { preset } = spec; - preset.iconURL = getModelSpecIconURL(spec); - preset.spec = spec.name; - newPreset = preset; + newPreset = { + ...spec.preset, + iconURL: getModelSpecIconURL(spec), + spec: spec.name, + } as TPreset; } let newEndpoint = newPreset.endpoint ?? ''; @@ -161,7 +162,6 @@ export default function useQueryParams({ newConversation({ template: currentConvo, preset: newPreset, - keepLatestMessage: true, keepAddedConvos: true, }); return; diff --git a/client/src/hooks/Input/useSelectMention.ts b/client/src/hooks/Input/useSelectMention.ts index 00ba5095bb..c0996b29d2 100644 --- a/client/src/hooks/Input/useSelectMention.ts +++ b/client/src/hooks/Input/useSelectMention.ts @@ -47,9 +47,11 @@ export default function useSelectMention({ } const conversation = getConversation(); - const { preset } = spec; - preset.iconURL = getModelSpecIconURL(spec); - preset.spec = spec.name; + const preset = { + ...spec.preset, + iconURL: getModelSpecIconURL(spec), + spec: spec.name, + } as TPreset; const { endpoint } = preset; const newEndpoint = endpoint ?? ''; if (!newEndpoint) { @@ -98,7 +100,6 @@ export default function useSelectMention({ newConversation({ template: currentConvo, preset, - keepLatestMessage: true, keepAddedConvos: true, }); return; @@ -193,7 +194,6 @@ export default function useSelectMention({ newConversation({ template: currentConvo, preset: currentConvo, - keepLatestMessage: true, keepAddedConvos: true, }); return; @@ -256,7 +256,6 @@ export default function useSelectMention({ newConversation({ template: currentConvo, preset: newPreset, - keepLatestMessage: true, keepAddedConvos: true, disableParams, }); diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts index 15b415dabc..d132b2299d 100644 --- a/client/src/hooks/Input/useTextarea.ts +++ b/client/src/hooks/Input/useTextarea.ts @@ -16,6 +16,7 @@ import useGetSender from '~/hooks/Conversations/useGetSender'; import useFileHandling from '~/hooks/Files/useFileHandling'; import { useInteractionHealthCheck } from '~/data-provider'; import { useChatContext } from '~/Providers/ChatContext'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { globalAudioId } from '~/common'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -43,7 +44,7 @@ export default function useTextarea({ const enterToSend = useRecoilValue(store.enterToSend); const { index, conversation, isSubmitting, filesLoading, setFilesLoading } = useChatContext(); - const latestMessage = useRecoilValue(store.latestMessageFamily(index)); + const latestMessage = useLatestMessage(index); const [activePrompt, setActivePrompt] = useRecoilState(store.activePromptByIndex(index)); const { endpoint = '' } = conversation || {}; diff --git a/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx b/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx new file mode 100644 index 0000000000..2e5de871dd --- /dev/null +++ b/client/src/hooks/Messages/__tests__/useLatestMessage.spec.tsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { RecoilRoot, type MutableSnapshot } from 'recoil'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryKeys, type TConversation, type TMessage } from 'librechat-data-provider'; +import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; +import { getMessageBranchSiblingParentIds } from '~/utils'; +import store from '~/store'; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +const conversation = { + conversationId: 'conversation-1', + endpoint: 'openAI', + model: 'gpt-4', +} as TConversation; + +const userMessage = { + messageId: 'user-message', + parentMessageId: '00000000-0000-0000-0000-000000000000', + conversationId: conversation.conversationId, + text: 'Hello', + isCreatedByUser: true, +} as TMessage; + +const assistantMessage = { + messageId: 'assistant-message', + parentMessageId: userMessage.messageId, + conversationId: conversation.conversationId, + text: 'Hi there', + isCreatedByUser: false, +} as TMessage; + +const olderAssistantMessage = { + ...assistantMessage, + messageId: 'assistant-older', + text: 'Older branch', +} as TMessage; + +const olderFollowUpUserMessage = { + ...userMessage, + messageId: 'user-older-follow-up', + parentMessageId: olderAssistantMessage.messageId, + text: 'Follow up on older branch', +} as TMessage; + +const olderFollowUpAssistantMessage = { + ...assistantMessage, + messageId: 'assistant-older-follow-up', + parentMessageId: olderFollowUpUserMessage.messageId, + text: 'Older branch tail', +} as TMessage; + +function createWrapper( + queryClient = createQueryClient(), + initializeState?: (snapshot: MutableSnapshot) => void, + initialEntry = '/c/conversation-1', +) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + ); + }; +} + +describe('useLatestMessage', () => { + it('returns null when there is no active conversation', () => { + const { result } = renderHook(() => useLatestMessage(0), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeNull(); + }); + + it('returns the tail message from the active conversation cache', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, assistantMessage], + ); + + const { result } = renderHook(() => useLatestMessage(0), { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + }), + }); + + expect(result.current).toEqual( + expect.objectContaining({ + messageId: assistantMessage.messageId, + depth: 1, + }), + ); + }); + + it('uses the new-conversation route cache while the conversation atom has the server id', () => { + const queryClient = createQueryClient(); + const serverConversation = { + ...conversation, + conversationId: 'server-conversation', + } as TConversation; + queryClient.setQueryData( + [QueryKeys.messages, 'new'], + [ + { + ...userMessage, + conversationId: serverConversation.conversationId, + }, + { + ...assistantMessage, + conversationId: serverConversation.conversationId, + }, + ], + ); + + const { result } = renderHook(() => useLatestMessage(0), { + wrapper: createWrapper( + queryClient, + ({ set }) => { + set(store.conversationByIndex(0), serverConversation); + }, + '/c/new', + ), + }); + + expect(result.current).toEqual( + expect.objectContaining({ + messageId: assistantMessage.messageId, + conversationId: serverConversation.conversationId, + }), + ); + }); + + it('returns the active branch tail when sibling selection points at an older branch', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [ + userMessage, + olderAssistantMessage, + olderFollowUpUserMessage, + olderFollowUpAssistantMessage, + assistantMessage, + ], + ); + + const { result } = renderHook(() => useLatestMessage(0), { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + set(store.messagesSiblingIdxFamily(userMessage.messageId), 1); + }), + }); + + expect(result.current).toEqual( + expect.objectContaining({ + messageId: olderFollowUpAssistantMessage.messageId, + depth: 3, + }), + ); + }); + + it('falls back to the latest sibling when a stored sibling index is out of range', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [ + userMessage, + olderAssistantMessage, + olderFollowUpUserMessage, + olderFollowUpAssistantMessage, + assistantMessage, + ], + ); + + const { result } = renderHook(() => useLatestMessage(0), { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + set(store.messagesSiblingIdxFamily(userMessage.messageId), 99); + }), + }); + + expect(result.current).toEqual( + expect.objectContaining({ + messageId: assistantMessage.messageId, + depth: 1, + }), + ); + }); + + it('keeps latestMessageId stable across token-only cache writes', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, assistantMessage], + ); + let renderCount = 0; + + const { result } = renderHook( + () => { + renderCount += 1; + return useLatestMessageId(0); + }, + { + wrapper: createWrapper(queryClient, ({ set }) => { + set(store.conversationByIndex(0), conversation); + }), + }, + ); + + const firstId = result.current; + + act(() => { + queryClient.setQueryData( + [QueryKeys.messages, conversation.conversationId], + [userMessage, { ...assistantMessage, text: 'Hi there, still streaming' }], + ); + }); + + expect(result.current).toBe(firstId); + expect(renderCount).toBe(1); + }); +}); + +describe('getMessageBranchSiblingParentIds', () => { + it('returns only parent keys that have branch choices', () => { + expect( + getMessageBranchSiblingParentIds( + [ + userMessage, + olderAssistantMessage, + olderFollowUpUserMessage, + olderFollowUpAssistantMessage, + assistantMessage, + ], + conversation.conversationId, + ), + ).toEqual([userMessage.messageId]); + }); +}); diff --git a/client/src/hooks/Messages/index.ts b/client/src/hooks/Messages/index.ts index 439b7e152e..f9c5ab834f 100644 --- a/client/src/hooks/Messages/index.ts +++ b/client/src/hooks/Messages/index.ts @@ -5,6 +5,7 @@ export { default as useSubmitMessage } from './useSubmitMessage'; export type { ContentMetadataResult } from './useContentMetadata'; export { default as useExpandCollapse } from './useExpandCollapse'; export { default as useMessageActions } from './useMessageActions'; +export { useLatestMessage, useLatestMessageId } from './useLatestMessage'; export { default as useMemoizedChatContext } from './useMemoizedChatContext'; export { default as useMessageProcess } from './useMessageProcess'; export { default as useMessageHelpers } from './useMessageHelpers'; diff --git a/client/src/hooks/Messages/useLatestMessage.ts b/client/src/hooks/Messages/useLatestMessage.ts new file mode 100644 index 0000000000..ef8fce1362 --- /dev/null +++ b/client/src/hooks/Messages/useLatestMessage.ts @@ -0,0 +1,129 @@ +import { useCallback } from 'react'; +import { selectorFamily, useRecoilValue } from 'recoil'; +import { useParams } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Constants, QueryKeys } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; +import { getMessageBranchSiblingParentIds, selectActiveBranchTail } from '~/utils'; +import store from '~/store'; + +const NULL_PARENT_KEY = '__null_parent__'; +const EMPTY_PARENT_IDS: (string | null)[] = []; + +const getParentLookupKey = (parentMessageId: string | null | undefined) => + parentMessageId ?? NULL_PARENT_KEY; + +const siblingIndexesByParentSelector = selectorFamily< + Record, + readonly (string | null)[] +>({ + key: 'latestMessageSiblingIndexesByParent', + get: + (parentIds) => + ({ get }) => { + const indexes: Record = {}; + + for (const parentId of parentIds) { + indexes[getParentLookupKey(parentId)] = get(store.messagesSiblingIdxFamily(parentId)); + } + + return indexes; + }, +}); + +function useMessagesCacheSelect( + messagesQueryId: string | null | undefined, + select: (messages: TMessage[]) => TData, +): TData | null { + const queryClient = useQueryClient(); + const queryKey = [QueryKeys.messages, messagesQueryId ?? '']; + + const { data } = useQuery( + queryKey, + async () => queryClient.getQueryData(queryKey) ?? [], + { + enabled: false, + select, + }, + ); + + if (!messagesQueryId) { + return null; + } + + return data ?? null; +} + +function useLatestMessagesQueryId( + index: string | number, + conversationId: string | null, + messagesQueryId?: string | null, +) { + const { conversationId: routeConversationId } = useParams(); + + if (!conversationId) { + return null; + } + + if (messagesQueryId != null) { + return messagesQueryId; + } + + if (index === 0 && routeConversationId) { + return routeConversationId === Constants.NEW_CONVO ? Constants.NEW_CONVO : routeConversationId; + } + + return conversationId; +} + +function useLatestMessageSiblingIndexes( + messagesQueryId: string | null | undefined, + rootSiblingKey: string | null, +) { + const selectParentIds = useCallback( + (messages: TMessage[]) => getMessageBranchSiblingParentIds(messages, rootSiblingKey), + [rootSiblingKey], + ); + const parentIds = useMessagesCacheSelect(messagesQueryId, selectParentIds) ?? EMPTY_PARENT_IDS; + return useRecoilValue(siblingIndexesByParentSelector(parentIds)); +} + +export function useLatestMessage( + index: string | number, + messagesQueryIdOverride?: string | null, +): TMessage | null { + const conversationId = useRecoilValue(store.conversationIdByIndex(index)); + const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); + const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId); + const select = useCallback( + (messages: TMessage[]) => + selectActiveBranchTail( + messages, + conversationId, + (parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0, + ), + [conversationId, siblingIndexes], + ); + + return useMessagesCacheSelect(messagesQueryId, select); +} + +export function useLatestMessageId( + index: string | number, + messagesQueryIdOverride?: string | null, +): string | null { + const conversationId = useRecoilValue(store.conversationIdByIndex(index)); + const messagesQueryId = useLatestMessagesQueryId(index, conversationId, messagesQueryIdOverride); + const siblingIndexes = useLatestMessageSiblingIndexes(messagesQueryId, conversationId); + const select = useCallback( + (messages: TMessage[]) => + selectActiveBranchTail( + messages, + conversationId, + (parentId) => siblingIndexes[getParentLookupKey(parentId)] ?? 0, + )?.messageId ?? null, + [conversationId, siblingIndexes], + ); + + return useMessagesCacheSelect(messagesQueryId, select); +} diff --git a/client/src/hooks/Messages/useMessageHelpers.tsx b/client/src/hooks/Messages/useMessageHelpers.tsx index 0453e4a49c..fb92c17e8e 100644 --- a/client/src/hooks/Messages/useMessageHelpers.tsx +++ b/client/src/hooks/Messages/useMessageHelpers.tsx @@ -1,14 +1,13 @@ -import { useEffect, useRef, useCallback, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import throttle from 'lodash/throttle'; -import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; +import { isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import type { TMessageProps } from '~/common'; import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; -import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import useCopyToClipboard from './useCopyToClipboard'; import { useGetAddedConvo } from '~/hooks/Chat'; +import { logger } from '~/utils'; export default function useMessageHelpers(props: TMessageProps) { - const latestText = useRef(''); const { message, currentEditId, setCurrentEditId } = props; const { @@ -20,7 +19,6 @@ export default function useMessageHelpers(props: TMessageProps) { setAbortScroll, handleContinue, latestMessageId, - setLatestMessage, } = useMessagesViewContext(); const agentsMap = useAgentsMapContext(); const assistantMap = useAssistantsMapContext(); @@ -31,51 +29,6 @@ export default function useMessageHelpers(props: TMessageProps) { const edit = messageId === currentEditId; const isLast = children?.length === 0 || children?.length === undefined; - useEffect(() => { - const convoId = conversation?.conversationId; - if (convoId === Constants.NEW_CONVO) { - return; - } - if (!message) { - return; - } - if (!isLast) { - return; - } - - const textKey = getTextKey(message, convoId); - - // Check for text/conversation change - const logInfo = { - textKey, - 'latestText.current': latestText.current, - messageId: message.messageId, - convoId, - }; - - /* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */ - let previousConvoId: string | null = null; - if ( - latestText.current && - typeof latestText.current === 'string' && - latestText.current.length > 0 - ) { - const parts = latestText.current.split(TEXT_KEY_DIVIDER); - previousConvoId = parts[parts.length - 1] || null; - } - - if ( - textKey !== latestText.current || - (convoId != null && previousConvoId != null && convoId !== previousConvoId) - ) { - logger.log('latest_message', '[useMessageHelpers] Setting latest message: ', logInfo); - latestText.current = textKey; - setLatestMessage({ ...message }); - } else { - logger.log('latest_message', 'No change in latest message', logInfo); - } - }, [isLast, message, setLatestMessage, conversation?.conversationId]); - const enterEdit = useCallback( (cancel?: boolean) => setCurrentEditId && setCurrentEditId(cancel === true ? -1 : messageId), [messageId, setCurrentEditId], diff --git a/client/src/hooks/Messages/useMessageProcess.tsx b/client/src/hooks/Messages/useMessageProcess.tsx index bb49670a2f..283926a423 100644 --- a/client/src/hooks/Messages/useMessageProcess.tsx +++ b/client/src/hooks/Messages/useMessageProcess.tsx @@ -1,60 +1,11 @@ import throttle from 'lodash/throttle'; -import { Constants } from 'librechat-data-provider'; import { useEffect, useRef, useMemo } from 'react'; import type { TMessage } from 'librechat-data-provider'; -import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import { useMessagesViewContext } from '~/Providers'; +import { logger } from '~/utils'; -export default function useMessageProcess({ message }: { message?: TMessage | null }) { - const latestText = useRef(''); - const hasNoChildren = useMemo(() => (message?.children?.length ?? 0) === 0, [message]); - - const { conversation, setAbortScroll, setLatestMessage, isSubmitting } = useMessagesViewContext(); - - useEffect(() => { - const convoId = conversation?.conversationId; - if (convoId === Constants.NEW_CONVO) { - return; - } - if (!message) { - return; - } - if (!hasNoChildren) { - return; - } - - const textKey = getTextKey(message, convoId); - - // Check for text/conversation change - const logInfo = { - textKey, - 'latestText.current': latestText.current, - messageId: message.messageId, - convoId, - }; - - /* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */ - let previousConvoId: string | null = null; - if ( - latestText.current && - typeof latestText.current === 'string' && - latestText.current.length > 0 - ) { - const parts = latestText.current.split(TEXT_KEY_DIVIDER); - previousConvoId = parts[parts.length - 1] || null; - } - - if ( - textKey !== latestText.current || - (convoId != null && previousConvoId != null && convoId !== previousConvoId) - ) { - logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo); - latestText.current = textKey; - setLatestMessage({ ...message }); - } else { - logger.log('latest_message', 'No change in latest message; logInfo', logInfo); - } - }, [hasNoChildren, message, setLatestMessage, conversation?.conversationId]); +export default function useMessageProcess({ message: _message }: { message?: TMessage | null }) { + const { conversation, setAbortScroll, isSubmitting } = useMessagesViewContext(); /** Use ref for isSubmitting to stabilize handleScroll across isSubmitting changes */ const isSubmittingRef = useRef(isSubmitting); diff --git a/client/src/hooks/Messages/useSubmitMessage.ts b/client/src/hooks/Messages/useSubmitMessage.ts index 8fd06e6003..ebdb85f32d 100644 --- a/client/src/hooks/Messages/useSubmitMessage.ts +++ b/client/src/hooks/Messages/useSubmitMessage.ts @@ -3,6 +3,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { replaceSpecialVars } from 'librechat-data-provider'; import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers'; import { useAuthContext } from '~/hooks/AuthContext'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { mainTextareaId } from '~/common'; import store from '~/store'; @@ -11,7 +12,7 @@ export default function useSubmitMessage() { const methods = useChatFormContext(); const { conversation: addedConvo } = useAddedChatContext(); const { ask, index, getMessages, setMessages } = useChatContext(); - const latestMessage = useRecoilValue(store.latestMessageFamily(index)); + const latestMessage = useLatestMessage(index); const autoSendPrompts = useRecoilValue(store.autoSendPrompts); const setActivePrompt = useSetRecoilState(store.activePromptByIndex(index)); diff --git a/client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx b/client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx deleted file mode 100644 index 46d982428d..0000000000 --- a/client/src/hooks/SSE/__tests__/useEventHandlers.latestMessage.spec.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import React from 'react'; -import { RecoilRoot } from 'recoil'; -import { MemoryRouter } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Constants } from 'librechat-data-provider'; -import type { TConversation, TMessage, EventSubmission } from 'librechat-data-provider'; -import useEventHandlers, { type EventHandlerParams } from '../useEventHandlers'; -import { renderHook, act } from '@testing-library/react'; - -const mockAnnouncePolite = jest.fn(); -const mockApplyAgentTemplate = jest.fn(); -const mockContentHandler = jest.fn(); -const mockResetContentHandler = jest.fn(); -const mockStepHandler = jest.fn(); -const mockClearStepMaps = jest.fn(); -const mockResetSubagentAtoms = jest.fn(); -const mockSyncStepMessage = jest.fn(); -const mockAttachmentHandler = jest.fn(); - -jest.mock('~/Providers', () => ({ - useLiveAnnouncer: () => ({ announcePolite: mockAnnouncePolite }), -})); - -jest.mock('~/hooks/AuthContext', () => ({ - useAuthContext: () => ({ token: 'token' }), -})); - -jest.mock('~/hooks/Agents', () => ({ - useApplyAgentTemplate: () => mockApplyAgentTemplate, -})); - -jest.mock('~/hooks/SSE/useContentHandler', () => () => ({ - contentHandler: mockContentHandler, - resetContentHandler: mockResetContentHandler, -})); - -jest.mock('~/hooks/SSE/useStepHandler', () => () => ({ - stepHandler: mockStepHandler, - clearStepMaps: mockClearStepMaps, - resetSubagentAtoms: mockResetSubagentAtoms, - syncStepMessage: mockSyncStepMessage, -})); - -jest.mock('~/hooks/SSE/useAttachmentHandler', () => () => mockAttachmentHandler); - -const conversation = { - conversationId: 'convo-id', - endpoint: 'openAI', - model: 'gpt-4', - title: 'New Chat', -} as TConversation; - -const userMessage = { - messageId: 'user-message', - parentMessageId: Constants.NO_PARENT, - conversationId: 'convo-id', - sender: 'User', - text: 'hello', - isCreatedByUser: true, -} as TMessage; - -const initialResponse = { - messageId: 'user-message_', - parentMessageId: 'user-message', - conversationId: 'convo-id', - sender: 'Assistant', - text: '', - isCreatedByUser: false, -} as TMessage; - -const submission = { - conversation, - messages: [] as TMessage[], - userMessage, - initialResponse, -} as unknown as EventSubmission; - -function wrapper({ children }: { children: React.ReactNode }) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - - return ( - - - {children} - - - ); -} - -const expectedCreatedAssistantResponse = () => - expect.objectContaining({ - messageId: 'user-message_', - parentMessageId: 'user-message', - isCreatedByUser: false, - }); - -type EventHandlerSpies = Omit< - EventHandlerParams, - | 'setMessages' - | 'setLatestMessage' - | 'getMessages' - | 'setCompleted' - | 'setIsSubmitting' - | 'setShowStopButton' -> & { - setMessages: jest.Mock; - setLatestMessage: jest.Mock; - getMessages: jest.Mock; - setCompleted: jest.Mock; - setIsSubmitting: jest.Mock; - setShowStopButton: jest.Mock; -}; - -function renderEventHandlersWithSpies(overrides: Partial = {}) { - const defaultSpies: EventHandlerSpies = { - setMessages: jest.fn(), - setLatestMessage: jest.fn(), - getMessages: jest.fn(() => []), - setCompleted: jest.fn(), - setIsSubmitting: jest.fn(), - setShowStopButton: jest.fn(), - }; - const spies: EventHandlerSpies = { ...defaultSpies, ...overrides }; - - const { result } = renderHook(() => useEventHandlers(spies), { wrapper }); - - return { result, ...spies }; -} - -describe('useEventHandlers latest-message transitions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('keeps latestMessage aligned after replacing optimistic messages in createdHandler', () => { - const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies(); - - act(() => { - result.current.createdHandler( - { created: true } as unknown as Parameters[0], - submission, - ); - }); - - expect(setMessages).toHaveBeenCalledWith([userMessage, expectedCreatedAssistantResponse()]); - expect(setLatestMessage).toHaveBeenCalledWith(expectedCreatedAssistantResponse()); - expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan( - setMessages.mock.invocationCallOrder[0], - ); - }); - - it('keeps latestMessage aligned when createdHandler starts a regeneration', () => { - const previousMessage = { - ...userMessage, - messageId: 'previous-message', - } as TMessage; - const regenerateSubmission = { - ...submission, - messages: [previousMessage], - isRegenerate: true, - } as unknown as EventSubmission; - const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies({ - getMessages: jest.fn(() => [previousMessage]), - }); - - act(() => { - result.current.createdHandler( - { created: true } as unknown as Parameters[0], - regenerateSubmission, - ); - }); - - expect(setMessages).toHaveBeenCalledWith([previousMessage, expectedCreatedAssistantResponse()]); - expect(setLatestMessage).toHaveBeenCalledWith(expectedCreatedAssistantResponse()); - expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan( - setMessages.mock.invocationCallOrder[0], - ); - }); - - it('keeps latestMessage aligned after replacing optimistic messages in syncHandler', () => { - const requestMessage = { ...userMessage, messageId: 'server-user-message' } as TMessage; - const responseMessage = { - ...initialResponse, - messageId: 'server-assistant-response', - parentMessageId: 'server-user-message', - } as TMessage; - const setShowStopButton = jest.fn(); - const { result, setMessages, setLatestMessage } = renderEventHandlersWithSpies({ - getMessages: jest.fn(() => [userMessage, initialResponse]), - setShowStopButton, - }); - - act(() => { - result.current.syncHandler( - { - sync: true, - conversationId: 'convo-id', - thread_id: 'thread-id', - requestMessage, - responseMessage, - }, - submission, - ); - }); - - expect(setMessages).toHaveBeenCalledWith([requestMessage, responseMessage]); - expect(setShowStopButton).toHaveBeenCalledWith(true); - expect(setLatestMessage).toHaveBeenCalledWith(responseMessage); - expect(setLatestMessage.mock.invocationCallOrder[0]).toBeLessThan( - setMessages.mock.invocationCallOrder[0], - ); - }); -}); diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index 7d2942448f..2cedcf5ce5 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -156,7 +156,6 @@ const buildChatHelpers = () => ({ setConversation: jest.fn(), setIsSubmitting: mockSetIsSubmitting, newConversation: jest.fn(), - setLatestMessage: jest.fn(), }); const getLastSSE = (): MockSSEInstance => { diff --git a/client/src/hooks/SSE/useAdaptiveSSE.ts b/client/src/hooks/SSE/useAdaptiveSSE.ts index d3acf67e3c..b1d6862718 100644 --- a/client/src/hooks/SSE/useAdaptiveSSE.ts +++ b/client/src/hooks/SSE/useAdaptiveSSE.ts @@ -6,12 +6,7 @@ import useSSE from './useSSE'; type ChatHelpers = Pick< EventHandlerParams, - | 'setMessages' - | 'getMessages' - | 'setConversation' - | 'setIsSubmitting' - | 'newConversation' - | 'setLatestMessage' + 'setMessages' | 'getMessages' | 'setConversation' | 'setIsSubmitting' | 'newConversation' >; /** diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 902cfeffcd..32a530c0f8 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -62,7 +62,6 @@ export type EventHandlerParams = { setConversation?: SetterOrUpdater; newConversation?: ConvoGenerator; setShowStopButton: SetterOrUpdater; - setLatestMessage: SetterOrUpdater; }; const createErrorMessage = ({ @@ -175,7 +174,6 @@ export default function useEventHandlers({ setIsSubmitting, newConversation, setShowStopButton, - setLatestMessage, }: EventHandlerParams) { const queryClient = useQueryClient(); const { announcePolite } = useLiveAnnouncer(); @@ -328,8 +326,6 @@ export default function useEventHandlers({ ...responseMessage, }; - logger.log('latest_message', 'syncHandler: setting latest message'); - setLatestMessage(nextResponseMessage); setMessages([...messages, requestMessage, nextResponseMessage]); announcePolite({ @@ -376,15 +372,7 @@ export default function useEventHandlers({ setShowStopButton(true); }, - [ - queryClient, - setMessages, - isAddedRequest, - announcePolite, - setConversation, - setShowStopButton, - setLatestMessage, - ], + [queryClient, setMessages, isAddedRequest, announcePolite, setConversation, setShowStopButton], ); const createdHandler = useCallback( @@ -408,8 +396,6 @@ export default function useEventHandlers({ messageId: userMessage.messageId + '_', conversationId: userMessage.conversationId ?? submission.initialResponse.conversationId, }; - logger.log('latest_message', 'createdHandler: setting latest message'); - setLatestMessage(initialResponse); if (isRegenerate) { setMessages([...messages, initialResponse]); } else { @@ -477,7 +463,6 @@ export default function useEventHandlers({ isAddedRequest, announcePolite, setConversation, - setLatestMessage, applyAgentTemplate, ], ); diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 3729f4dbcc..88a5391cce 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -30,12 +30,7 @@ import store from '~/store'; type ChatHelpers = Pick< EventHandlerParams, - | 'setMessages' - | 'getMessages' - | 'setConversation' - | 'setIsSubmitting' - | 'newConversation' - | 'setLatestMessage' + 'setMessages' | 'getMessages' | 'setConversation' | 'setIsSubmitting' | 'newConversation' >; const MAX_RETRIES = 5; @@ -96,14 +91,8 @@ export default function useResumableSSE( const reconnectTimeoutRef = useRef(null); const submissionRef = useRef(null); - const { - setMessages, - getMessages, - setConversation, - setIsSubmitting, - newConversation, - setLatestMessage, - } = chatHelpers; + const { setMessages, getMessages, setConversation, setIsSubmitting, newConversation } = + chatHelpers; const { stepHandler, @@ -125,7 +114,6 @@ export default function useResumableSSE( setIsSubmitting, newConversation, setShowStopButton, - setLatestMessage, }); const { data: startupConfig } = useGetStartupConfig(); diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 13a2963d58..a9b9928df4 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -14,12 +14,7 @@ import store from '~/store'; type ChatHelpers = Pick< EventHandlerParams, - | 'setMessages' - | 'getMessages' - | 'setConversation' - | 'setIsSubmitting' - | 'newConversation' - | 'setLatestMessage' + 'setMessages' | 'getMessages' | 'setConversation' | 'setIsSubmitting' | 'newConversation' >; export default function useSSE( @@ -35,14 +30,8 @@ export default function useSSE( const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(runIndex)); - const { - setMessages, - getMessages, - setConversation, - setIsSubmitting, - newConversation, - setLatestMessage, - } = chatHelpers; + const { setMessages, getMessages, setConversation, setIsSubmitting, newConversation } = + chatHelpers; const { clearStepMaps, @@ -64,7 +53,6 @@ export default function useSSE( setIsSubmitting, newConversation, setShowStopButton, - setLatestMessage, }); const { data: startupConfig } = useGetStartupConfig(); diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index f2879cb092..0c5d6dd6db 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -51,7 +51,6 @@ const useNewConvo = (index = 0) => { const { setConversation } = store.useSetConversationAtom(index); const [files, setFiles] = useRecoilState(store.filesByIndex(index)); const saveBadgesState = useRecoilValue(store.saveBadgesState); - const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`); const setSubmission = useSetRecoilState(store.submissionByIndex(index)); const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); @@ -82,7 +81,6 @@ const useNewConvo = (index = 0) => { preset: Partial | null = null, modelsData?: TModelsConfig, buildDefault?: boolean, - keepLatestMessage?: boolean, keepAddedConvos?: boolean, disableFocus?: boolean, _disableParams?: boolean, @@ -225,10 +223,6 @@ const useNewConvo = (index = 0) => { setConversation(conversation); } setSubmission({} as TSubmission); - if (!(keepLatestMessage ?? false)) { - logger.log('latest_message', 'Clearing all latest messages'); - clearAllLatestMessages(); - } if (isCancelled) { return; } @@ -262,7 +256,6 @@ const useNewConvo = (index = 0) => { modelsData, disableFocus, buildDefault = true, - keepLatestMessage = false, keepAddedConvos = false, disableParams, }: { @@ -271,7 +264,6 @@ const useNewConvo = (index = 0) => { modelsData?: TModelsConfig; buildDefault?: boolean; disableFocus?: boolean; - keepLatestMessage?: boolean; keepAddedConvos?: boolean; disableParams?: boolean; } = {}) { @@ -348,7 +340,6 @@ const useNewConvo = (index = 0) => { preset, modelsData, buildDefault, - keepLatestMessage, keepAddedConvos, disableFocus, disableParams, diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index 374e2330cd..e7118fe45d 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -128,7 +128,6 @@ export default function ChatRoute() { /* this is necessary to load all existing settings */ preset: initialConvoQuery.data as TPreset, modelsData: modelsQuery.data, - keepLatestMessage: true, }); hasSetConversation.current = true; } else if ( @@ -177,7 +176,6 @@ export default function ChatRoute() { template: initialConvoQuery.data, preset: initialConvoQuery.data as TPreset, modelsData: modelsQuery.data, - keepLatestMessage: true, }); hasSetConversation.current = true; } diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 7e754e05ce..5d19331f59 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -11,13 +11,7 @@ import { useRecoilCallback, } from 'recoil'; import { LocalStorageKeys, isEphemeralAgentId, Constants } from 'librechat-data-provider'; -import type { - EModelEndpoint, - TConversation, - TSubmission, - TMessage, - TPreset, -} from 'librechat-data-provider'; +import type { EModelEndpoint, TConversation, TSubmission, TPreset } from 'librechat-data-provider'; import type { TOptionSettings, ExtendedFile } from '~/common'; import { clearModelForNonEphemeralAgent, @@ -27,46 +21,16 @@ import { } from '~/utils'; import { useSetConvoContext } from '~/Providers/SetConvoContext'; -const latestMessageKeysAtom = atom<(string | number)[]>({ - key: 'latestMessageKeys', - default: [], -}); - const submissionKeysAtom = atom<(string | number)[]>({ key: 'submissionKeys', default: [], }); -const latestMessageFamily = atomFamily({ - key: 'latestMessageByIndex', - default: null, - effects: [ - ({ onSet, node }) => { - onSet(async (newValue) => { - const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]); - logger.log('Recoil Effect: Setting latestMessage', { key, newValue }); - }); - }, - ] as const, -}); - const submissionByIndex = atomFamily({ key: 'submissionByIndex', default: null, }); -const latestMessageKeysSelector = selector<(string | number)[]>({ - key: 'latestMessageKeysSelector', - get: ({ get }) => { - const keys = get(conversationKeysAtom); - return keys.filter((key) => get(latestMessageFamily(key)) !== null); - }, - set: ({ set }, newKeys) => { - logger.log('setting latestMessageKeys', { newKeys }); - set(latestMessageKeysAtom, newKeys); - }, -}); - const submissionKeysSelector = selector<(string | number)[]>({ key: 'submissionKeysSelector', get: ({ get }) => { @@ -385,11 +349,6 @@ function useClearConvoState() { } reset(conversationByIndex(conversationKey)); - - const conversation = await snapshot.getPromise(conversationByIndex(conversationKey)); - if (conversation) { - reset(latestMessageFamily(conversationKey)); - } } reset(conversationKeysAtom); @@ -426,33 +385,6 @@ function useClearSubmissionState() { return clearAllSubmissions; } -function useClearLatestMessages(context?: string) { - const clearAllLatestMessages = useRecoilCallback( - ({ reset, set, snapshot }) => - async (skipFirst?: boolean) => { - const latestMessageKeys = await snapshot.getPromise(latestMessageKeysSelector); - logger.log('[clearAllLatestMessages] latestMessageKeys', latestMessageKeys); - if (context != null && context) { - logger.log(`[clearAllLatestMessages] context: ${context}`); - } - - for (const key of latestMessageKeys) { - if (skipFirst === true && key == 0) { - continue; - } - - logger.log(`[clearAllLatestMessages] resetting latest message; key: ${key}`); - reset(latestMessageFamily(key)); - } - - set(latestMessageKeysSelector, []); - }, - [], - ); - - return clearAllLatestMessages; -} - const updateConversationSelector = selectorFamily({ key: 'updateConversationSelector', get: () => () => null as Partial | null, @@ -490,7 +422,6 @@ export default { isSubmittingFamily, optionSettingsFamily, showPopoverFamily, - latestMessageFamily, messagesSiblingIdxFamily, anySubmittingSelector, allConversationsSelector, @@ -514,7 +445,6 @@ export default { showPlusPopoverFamily, activePromptByIndex, useClearSubmissionState, - useClearLatestMessages, showPromptsPopoverFamily, showSkillsPopoverFamily, pendingManualSkillsByConvoId, diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 27dc063481..d906c33ddc 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -1,6 +1,7 @@ import { QueryKeys, Constants, + buildTree, ContentTypes, isEphemeralAgentId, appendAgentIdSuffix, @@ -17,6 +18,70 @@ import type { LocalizeFunction } from '~/common'; export const TEXT_KEY_DIVIDER = '|||'; +type SiblingIndexLookup = (parentMessageId: string | null | undefined) => number; + +export const selectActiveBranchTail = ( + messages: TMessage[] | null | undefined, + rootSiblingKey: string | null | undefined, + getSiblingIndex: SiblingIndexLookup = () => 0, +): TMessage | null => { + const messagesTree = buildTree({ messages: messages ?? null }); + if (!messagesTree?.length) { + return null; + } + + let siblings = messagesTree; + let parentMessageId = rootSiblingKey; + let tail: TMessage | null = null; + + while (siblings.length > 0) { + const siblingIdx = getSiblingIndex(parentMessageId); + const normalizedSiblingIdx = siblingIdx >= 0 && siblingIdx < siblings.length ? siblingIdx : 0; + const activeSiblingIndex = siblings.length - normalizedSiblingIdx - 1; + const message = siblings[activeSiblingIndex] ?? siblings[siblings.length - 1]; + if (!message) { + return tail; + } + + tail = message; + parentMessageId = message.messageId; + siblings = message.children ?? []; + } + + return tail; +}; + +export const getMessageBranchSiblingParentIds = ( + messages: TMessage[] | null | undefined, + rootSiblingKey: string | null | undefined, +): (string | null)[] => { + const messagesTree = buildTree({ messages: messages ?? null }); + if (!messagesTree?.length) { + return []; + } + + const parentIds = new Set(); + const collectBranchParents = ( + siblings: TMessage[] | undefined, + parentMessageId: string | null | undefined, + ) => { + if (!siblings?.length) { + return; + } + + if (siblings.length > 1) { + parentIds.add(parentMessageId ?? null); + } + + for (const message of siblings) { + collectBranchParents(message.children, message.messageId); + } + }; + + collectBranchParents(messagesTree, rootSiblingKey); + return Array.from(parentIds); +}; + export const getLatestText = (message?: TMessage | null, includeIndex?: boolean): string => { if (!message) { return '';