🧬 refactor: Derive Latest Message From Cache (#13294)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* fix: derive latest message from cache

* fix: clear changed-file lint warnings

* fix: address branch tail review feedback

* fix: keep latest message on new chat stream key
This commit is contained in:
Danny Avila 2026-05-24 16:20:27 -04:00 • committed by GitHub
parent 9cd2fc2ed6
commit a8c43a4126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 727 additions and 532 deletions

View file

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

View file

@ -20,7 +20,6 @@ interface MessagesViewContextValue {
index: ReturnType<typeof useChatContext>['index'];
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'];
}
@ -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],
);
}

View file

@ -604,7 +604,6 @@ export type NewConversationParams = {
preset?: Partial<t.TPreset>;
modelsData?: t.TModelsConfig;
buildDefault?: boolean;
keepLatestMessage?: boolean;
keepAddedConvos?: boolean;
disableParams?: boolean;
};

View file

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

View file

@ -45,7 +45,6 @@ export default function SearchButtons({ message }: { message: TMessage }) {
conversationId,
title,
} as TConversation),
{ resetLatestMessage: true },
);
};

View file

@ -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 <div data-testid="hover-buttons" />;
},
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
__esModule: true,
default: ({ text }: { text: string }) => <div data-testid="message-content">{text}</div>,
}));
jest.mock('~/components/Chat/Messages/MessageIcon', () => ({
__esModule: true,
default: () => <div data-testid="message-icon" />,
}));
jest.mock('~/components/Chat/Messages/ui/PlaceholderRow', () => ({
__esModule: true,
default: () => <div data-testid="placeholder-row" />,
}));
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<TMessage[]>([QueryKeys.messages, conversation.conversationId]),
setMessages: (messages: TMessage[]) => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
messages,
);
},
}) as unknown as ReturnType<typeof useChatContext>,
[latestMessageDepth, latestMessageId, queryClient],
);
if (!latestMessage) {
return null;
}
return (
<ChatContext.Provider value={chatContext}>
<MessagesViewProvider>
<Message message={latestMessage} currentEditId={null} setCurrentEditId={jest.fn()} />
</MessagesViewProvider>
</ChatContext.Provider>
);
}
function renderStreamingRow() {
const queryClient = createQueryClient();
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, optimisticAssistantMessage],
);
const initializeState = ({ set }: MutableSnapshot) => {
set(store.conversationByIndex(0), conversation);
set(store.isSubmittingFamily(0), true);
};
render(
<QueryClientProvider client={queryClient}>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter initialEntries={[`/c/${conversation.conversationId}`]}>
<DerivedStreamingRow />
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>,
);
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<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
[userMessage, canonicalAssistantMessage],
);
});
expect(screen.queryByTestId('hover-buttons')).toBeNull();
expect(mockHoverButtonsRenderCount).toBe(0);
expect(screen.getByTestId('placeholder-row')).toBeInTheDocument();
});
});

View file

@ -162,7 +162,6 @@ export default function Conversation({
navigateToConvo(conversation, {
currentConvoId,
resetLatestMessage: !(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
});
};

View file

@ -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 : (
<SharedLinkButton
share={share}
conversationId={conversationId}
targetMessageId={latestMessage?.messageId}
targetMessageId={latestMessage?.messageId ?? undefined}
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}
/>
);
const shareId = share?.shareId ?? '';
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}

View file

@ -31,7 +31,6 @@ export function ShareMessagesProvider({ messages, children }: ShareMessagesProvi
abortScroll: false,
setAbortScroll: () => {},
index: 0,
setLatestMessage: () => {},
getMessages: () => messages,
setMessages: () => {},
}),

View file

@ -39,7 +39,6 @@ export default function useSelectAgent() {
newConversation({
template: currentConvo,
preset: template as Partial<TPreset>,
keepLatestMessage: true,
});
},
[getConversation, getDefaultConversation, newConversation],

View file

@ -33,7 +33,6 @@ export default function useSelectAssistant(endpoint: AssistantsEndpoint) {
newConversation({
template: currentConvo,
preset: template as Partial<TPreset>,
keepLatestMessage: true,
});
return;
}

View file

@ -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<string, ExtendedFile>;
setFiles?: SetterOrUpdater<Map<string, ExtendedFile>>;
setSubmission: SetterOrUpdater<TSubmission | null>;
setLatestMessage?: SetterOrUpdater<TMessage | null>;
}) {
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 });

View file

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

View file

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

View file

@ -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<TEndpointsConfig>([QueryKeys.endpoints]);

View file

@ -218,7 +218,6 @@ export default function usePresets(index = 0) {
newConversation({
template: currentConvo,
preset: currentConvo,
keepLatestMessage: true,
keepAddedConvos: true,
disableParams,
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<QueryClientProvider client={queryClient}>
<RecoilRoot initializeState={initializeState}>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/c/:conversationId?" element={children} />
</Routes>
</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
);
};
}
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<TMessage[]>(
[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<TMessage[]>(
[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<TMessage[]>(
[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<TMessage[]>(
[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<TMessage[]>(
[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<TMessage[]>(
[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]);
});
});

View file

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

View file

@ -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<string, number>,
readonly (string | null)[]
>({
key: 'latestMessageSiblingIndexesByParent',
get:
(parentIds) =>
({ get }) => {
const indexes: Record<string, number> = {};
for (const parentId of parentIds) {
indexes[getParentLookupKey(parentId)] = get(store.messagesSiblingIdxFamily(parentId));
}
return indexes;
},
});
function useMessagesCacheSelect<TData>(
messagesQueryId: string | null | undefined,
select: (messages: TMessage[]) => TData,
): TData | null {
const queryClient = useQueryClient();
const queryKey = [QueryKeys.messages, messagesQueryId ?? ''];
const { data } = useQuery<TMessage[], unknown, TData>(
queryKey,
async () => queryClient.getQueryData<TMessage[]>(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);
}

View file

@ -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<string | number>('');
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],

View file

@ -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<string | number>('');
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);

View file

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

View file

@ -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 (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<MemoryRouter initialEntries={['/c/convo-id']}>{children}</MemoryRouter>
</RecoilRoot>
</QueryClientProvider>
);
}
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<EventHandlerSpies> = {}) {
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<typeof result.current.createdHandler>[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<typeof result.current.createdHandler>[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],
);
});
});

View file

@ -156,7 +156,6 @@ const buildChatHelpers = () => ({
setConversation: jest.fn(),
setIsSubmitting: mockSetIsSubmitting,
newConversation: jest.fn(),
setLatestMessage: jest.fn(),
});
const getLastSSE = (): MockSSEInstance => {

View file

@ -6,12 +6,7 @@ import useSSE from './useSSE';
type ChatHelpers = Pick<
EventHandlerParams,
| 'setMessages'
| 'getMessages'
| 'setConversation'
| 'setIsSubmitting'
| 'newConversation'
| 'setLatestMessage'
'setMessages' | 'getMessages' | 'setConversation' | 'setIsSubmitting' | 'newConversation'
>;
/**

View file

@ -62,7 +62,6 @@ export type EventHandlerParams = {
setConversation?: SetterOrUpdater<TConversation | null>;
newConversation?: ConvoGenerator;
setShowStopButton: SetterOrUpdater<boolean>;
setLatestMessage: SetterOrUpdater<TMessage | null>;
};
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,
],
);

View file

@ -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<NodeJS.Timeout | null>(null);
const submissionRef = useRef<TSubmission | null>(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();

View file

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

View file

@ -51,7 +51,6 @@ const useNewConvo = (index = 0) => {
const { setConversation } = store.useSetConversationAtom(index);
const [files, setFiles] = useRecoilState(store.filesByIndex(index));
const saveBadgesState = useRecoilValue<boolean>(store.saveBadgesState);
const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`);
const setSubmission = useSetRecoilState<TSubmission | null>(store.submissionByIndex(index));
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
@ -82,7 +81,6 @@ const useNewConvo = (index = 0) => {
preset: Partial<TPreset> | 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,

View file

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

View file

@ -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<TMessage | null, string | number | null>({
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<TSubmission | null, string | number>({
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<TConversation> | 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,

View file

@ -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<string | null>();
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 '';