mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📉 perf: start message queries before file map loads (#14188)
* feat(client): remove chat message waterfalls * fix(client): guard sends during conversation detail loads * fix(client): observe new chat message cache * fix(client): preserve streaming messages during prefetch * fix(client): keep pending regeneration during prefetch * fix(client): refresh stale pending chat tails * fix(client): skip message prefetch during active streams * fix(client): clear observed message caches * fix(client): support mocked message cache clients * fix(client): preserve replacement pending tails during message prefetch * perf(client): start message queries before file map loads
This commit is contained in:
parent
329ed48246
commit
55451ee75d
12 changed files with 470 additions and 35 deletions
|
|
@ -1,14 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type t from 'librechat-data-provider';
|
||||
import AgentDetail from '../AgentDetail';
|
||||
|
||||
// Mock dependencies
|
||||
|
|
@ -148,6 +146,7 @@ describe('AgentDetail', () => {
|
|||
(useQueryClient as jest.Mock).mockReturnValue({
|
||||
getQueryData: jest.fn(),
|
||||
setQueryData: jest.fn(),
|
||||
removeQueries: jest.fn(),
|
||||
invalidateQueries: jest.fn(),
|
||||
});
|
||||
|
||||
|
|
@ -232,6 +231,7 @@ describe('AgentDetail', () => {
|
|||
const mockQueryClient = {
|
||||
getQueryData: jest.fn().mockReturnValue(null),
|
||||
setQueryData: jest.fn(),
|
||||
removeQueries: jest.fn(),
|
||||
invalidateQueries: jest.fn(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
|
|||
},
|
||||
[fileMap],
|
||||
),
|
||||
enabled: !!fileMap,
|
||||
enabled: !!conversationId && conversationId !== Constants.SEARCH,
|
||||
},
|
||||
{ isStreaming: isSubmitting },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,24 @@ type MessageRenderProps = {
|
|||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
>;
|
||||
|
||||
export function areMessageFilesEqual(
|
||||
prevFiles: TMessage['files'],
|
||||
nextFiles: TMessage['files'],
|
||||
): boolean {
|
||||
if (prevFiles === nextFiles) {
|
||||
return true;
|
||||
}
|
||||
const prevLength = prevFiles?.length ?? 0;
|
||||
const nextLength = nextFiles?.length ?? 0;
|
||||
if (prevLength !== nextLength) {
|
||||
return false;
|
||||
}
|
||||
if (prevLength === 0) {
|
||||
return true;
|
||||
}
|
||||
return prevFiles?.every((file, index) => file === nextFiles?.[index]) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom comparator for React.memo: compares `message` by key fields instead of reference
|
||||
* because `buildTree` creates new message objects on every streaming update for ALL messages,
|
||||
|
|
@ -82,7 +100,7 @@ function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRende
|
|||
prevMsg.endpoint === nextMsg.endpoint &&
|
||||
prevMsg.iconURL === nextMsg.iconURL &&
|
||||
prevMsg.feedback?.rating === nextMsg.feedback?.rating &&
|
||||
(prevMsg.files?.length ?? 0) === (nextMsg.files?.length ?? 0) &&
|
||||
areMessageFilesEqual(prevMsg.files, nextMsg.files) &&
|
||||
(prevMsg.quotes?.length ?? 0) === (nextMsg.quotes?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import type { TFile } from 'librechat-data-provider';
|
||||
import { areMessageFilesEqual } from '../MessageRender';
|
||||
|
||||
const file = (overrides: Partial<TFile> = {}): TFile =>
|
||||
({
|
||||
file_id: 'file-1',
|
||||
filename: 'sample.pdf',
|
||||
filepath: '/uploads/sample.pdf',
|
||||
type: 'application/pdf',
|
||||
bytes: 100,
|
||||
embedded: false,
|
||||
object: 'file',
|
||||
usage: 1,
|
||||
user: 'user-1',
|
||||
...overrides,
|
||||
}) as TFile;
|
||||
|
||||
describe('areMessageFilesEqual', () => {
|
||||
it('detects when a raw message file is replaced by its hydrated file-map entry', () => {
|
||||
const rawFile = file({ filename: 'raw.pdf', preview: undefined });
|
||||
const hydratedFile = file({ filename: 'hydrated.pdf', preview: '/previews/sample.png' });
|
||||
|
||||
expect(areMessageFilesEqual([rawFile], [hydratedFile])).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps equivalent file entries memoized when buildTree creates a new array', () => {
|
||||
const hydratedFile = file({ preview: '/previews/sample.png' });
|
||||
|
||||
expect(areMessageFilesEqual([hydratedFile], [hydratedFile])).toBe(true);
|
||||
});
|
||||
|
||||
it('detects attachment additions and removals', () => {
|
||||
const hydratedFile = file();
|
||||
|
||||
expect(areMessageFilesEqual([], [hydratedFile])).toBe(false);
|
||||
expect(areMessageFilesEqual([hydratedFile], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('treats absent and empty file lists as equivalent', () => {
|
||||
expect(areMessageFilesEqual(undefined, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
import { createElement } from 'react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { logger } from '~/utils';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
getStableMessages,
|
||||
shouldPreserveMessagesOnNotFound,
|
||||
useGetMessagesByConvoId,
|
||||
} from '../queries';
|
||||
import { clearMessagesCache, logger } from '~/utils';
|
||||
|
||||
jest.mock('librechat-data-provider', () => {
|
||||
const actual = jest.requireActual('librechat-data-provider');
|
||||
|
|
@ -57,6 +57,16 @@ function createWrapper(queryClient: QueryClient, initialEntry: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
|
@ -307,6 +317,231 @@ describe('shouldPreserveMessagesOnNotFound', () => {
|
|||
});
|
||||
|
||||
describe('useGetMessagesByConvoId', () => {
|
||||
it('observes optimistic new-chat messages without requesting them from the API', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const optimisticMessages = [message({ conversationId: Constants.NEW_CONVO as string })];
|
||||
|
||||
const { result, unmount } = renderHook(() => useGetMessagesByConvoId(Constants.NEW_CONVO), {
|
||||
wrapper: createWrapper(queryClient, '/c/new'),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
expect(dataService.getMessagesByConvoId).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], optimisticMessages);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(optimisticMessages);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refetch();
|
||||
});
|
||||
expect(result.current.data).toEqual(optimisticMessages);
|
||||
expect(dataService.getMessagesByConvoId).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('loads server history after a cleared empty cache is removed', async () => {
|
||||
const conversationId = 'convo-id';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const serverMessages = [message({ messageId: 'persisted-1' })];
|
||||
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
|
||||
typeof dataService.getMessagesByConvoId
|
||||
>;
|
||||
mockGetMessagesByConvoId.mockResolvedValue(serverMessages);
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], []);
|
||||
|
||||
clearMessagesCache(queryClient, conversationId);
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toBeUndefined();
|
||||
|
||||
const { result, unmount } = renderHook(() => useGetMessagesByConvoId(conversationId), {
|
||||
wrapper: createWrapper(queryClient, `/c/${conversationId}`),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(serverMessages);
|
||||
});
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not discard server history when an empty cache changes during the initial request', async () => {
|
||||
const conversationId = 'convo-id';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const request = deferred<TMessage[]>();
|
||||
const serverMessages = [message({ messageId: 'persisted-1' })];
|
||||
const interveningMessages = [
|
||||
message({ messageId: 'intervening-user', createdAt: undefined, updatedAt: undefined }),
|
||||
];
|
||||
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
|
||||
typeof dataService.getMessagesByConvoId
|
||||
>;
|
||||
mockGetMessagesByConvoId.mockReturnValue(request.promise);
|
||||
|
||||
const { result, unmount } = renderHook(() => useGetMessagesByConvoId(conversationId), {
|
||||
wrapper: createWrapper(queryClient, `/c/${conversationId}`),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], interveningMessages);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
request.resolve(serverMessages);
|
||||
await request.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(serverMessages);
|
||||
});
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(serverMessages);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps a concurrent cache write during a warm refetch', async () => {
|
||||
const conversationId = 'convo-id';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const request = deferred<TMessage[]>();
|
||||
const cachedMessages = [message({ messageId: 'persisted-1' })];
|
||||
const staleServerMessages = [message({ messageId: 'persisted-1', text: 'stale' })];
|
||||
const concurrentMessages = [
|
||||
...cachedMessages,
|
||||
message({ messageId: 'new-user', createdAt: undefined, updatedAt: undefined }),
|
||||
];
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], cachedMessages);
|
||||
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
|
||||
typeof dataService.getMessagesByConvoId
|
||||
>;
|
||||
mockGetMessagesByConvoId.mockReturnValue(request.promise);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() => useGetMessagesByConvoId(conversationId, { enabled: false }),
|
||||
{ wrapper: createWrapper(queryClient, `/c/${conversationId}`) },
|
||||
);
|
||||
|
||||
const refetchPromise = result.current.refetch();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], concurrentMessages);
|
||||
});
|
||||
|
||||
let refetchData: TMessage[] | undefined;
|
||||
await act(async () => {
|
||||
request.resolve(staleServerMessages);
|
||||
refetchData = (await refetchPromise).data;
|
||||
});
|
||||
|
||||
expect(refetchData).toEqual(concurrentMessages);
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(
|
||||
concurrentMessages,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('applies fresh server data when a warm cache is unchanged during refetch', async () => {
|
||||
const conversationId = 'convo-id';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const cachedMessages = [message({ messageId: 'persisted-1', text: 'cached' })];
|
||||
const freshServerMessages = [message({ messageId: 'persisted-1', text: 'fresh' })];
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], cachedMessages);
|
||||
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
|
||||
typeof dataService.getMessagesByConvoId
|
||||
>;
|
||||
mockGetMessagesByConvoId.mockResolvedValue(freshServerMessages);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() => useGetMessagesByConvoId(conversationId, { enabled: false }),
|
||||
{ wrapper: createWrapper(queryClient, `/c/${conversationId}`) },
|
||||
);
|
||||
|
||||
let refetchData: TMessage[] | undefined;
|
||||
await act(async () => {
|
||||
refetchData = (await result.current.refetch()).data;
|
||||
});
|
||||
|
||||
expect(refetchData).toEqual(freshServerMessages);
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(
|
||||
freshServerMessages,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps a concurrent warm cache write when the refetch fails', async () => {
|
||||
const conversationId = 'convo-id';
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const request = deferred<TMessage[]>();
|
||||
const cachedMessages = [message({ messageId: 'persisted-1' })];
|
||||
const concurrentMessages = [
|
||||
...cachedMessages,
|
||||
message({ messageId: 'new-user', createdAt: undefined, updatedAt: undefined }),
|
||||
];
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], cachedMessages);
|
||||
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
|
||||
typeof dataService.getMessagesByConvoId
|
||||
>;
|
||||
mockGetMessagesByConvoId.mockReturnValue(request.promise);
|
||||
|
||||
const { result, unmount } = renderHook(
|
||||
() => useGetMessagesByConvoId(conversationId, { enabled: false }),
|
||||
{ wrapper: createWrapper(queryClient, `/c/${conversationId}`) },
|
||||
);
|
||||
|
||||
const refetchPromise = result.current.refetch();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], concurrentMessages);
|
||||
request.reject({ status: 500 });
|
||||
});
|
||||
|
||||
let refetchData: TMessage[] | undefined;
|
||||
await act(async () => {
|
||||
refetchData = (await refetchPromise).data;
|
||||
});
|
||||
|
||||
expect(refetchData).toEqual(concurrentMessages);
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(
|
||||
concurrentMessages,
|
||||
);
|
||||
expect(result.current.isError).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps cache during submitting cleanup when active job cache still marks the stream active', async () => {
|
||||
const conversationId = 'convo-id';
|
||||
const currentMessages = [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { UseQueryOptions, QueryObserverResult, QueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import type { UseQueryOptions, QueryObserverResult, QueryClient } from '@tanstack/react-query';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import { isNotFoundError, logger } from '~/utils';
|
||||
|
||||
|
|
@ -99,11 +99,26 @@ export const useGetMessagesByConvoId = <TData = t.TMessage[]>(
|
|||
return useQuery<t.TMessage[], unknown, TData>(
|
||||
[QueryKeys.messages, id],
|
||||
async () => {
|
||||
const queryKey = [QueryKeys.messages, id];
|
||||
const messagesAtRequestStart = queryClient.getQueryData<t.TMessage[]>(queryKey);
|
||||
|
||||
if (id === Constants.NEW_CONVO) {
|
||||
return messagesAtRequestStart ?? [];
|
||||
}
|
||||
|
||||
let result: t.TMessage[];
|
||||
try {
|
||||
result = await dataService.getMessagesByConvoId(id);
|
||||
} catch (error) {
|
||||
const currentMessages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, id]);
|
||||
const currentMessages = queryClient.getQueryData<t.TMessage[]>(queryKey);
|
||||
if (
|
||||
messagesAtRequestStart != null &&
|
||||
currentMessages != null &&
|
||||
currentMessages !== messagesAtRequestStart
|
||||
) {
|
||||
return currentMessages;
|
||||
}
|
||||
|
||||
const hasLiveStream = isStreamingRef.current || hasActiveJob(queryClient, id);
|
||||
if (
|
||||
currentMessages &&
|
||||
|
|
@ -125,7 +140,15 @@ export const useGetMessagesByConvoId = <TData = t.TMessage[]>(
|
|||
throw error;
|
||||
}
|
||||
|
||||
const currentMessages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, id]);
|
||||
const currentMessages = queryClient.getQueryData<t.TMessage[]>(queryKey);
|
||||
if (
|
||||
messagesAtRequestStart != null &&
|
||||
currentMessages != null &&
|
||||
currentMessages !== messagesAtRequestStart
|
||||
) {
|
||||
return currentMessages;
|
||||
}
|
||||
|
||||
const stableMessages = getStableMessages({
|
||||
pathname: location.pathname,
|
||||
result,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation, TMessage, TSubmission } from 'librechat-data-provider';
|
||||
import useChatFunctions from '../useChatFunctions';
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ const mockSetFilesToDelete = jest.fn();
|
|||
const mockGetSender = jest.fn(() => 'Assistant');
|
||||
const mockGetExpiry = jest.fn(() => 'expiry-key');
|
||||
const mockGetQueryData = jest.fn(() => ({}));
|
||||
const mockLoggerWarn = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
|
|
@ -49,6 +50,7 @@ jest.mock('~/store', () => ({
|
|||
isSubmittingFamily: () => 'isSubmitting',
|
||||
showStopButtonByIndex: () => 'showStopButton',
|
||||
pendingManualSkillsByConvoId: () => 'pendingManualSkills',
|
||||
pendingQuotesByConvoId: () => 'pendingQuotes',
|
||||
messagesSiblingIdxFamily: () => 'messagesSiblingIdx',
|
||||
},
|
||||
useGetEphemeralAgent: () => mockGetEphemeralAgent,
|
||||
|
|
@ -57,6 +59,7 @@ jest.mock('~/utils', () => ({
|
|||
logger: {
|
||||
log: jest.fn(),
|
||||
dir: jest.fn(),
|
||||
warn: (...args: unknown[]) => mockLoggerWarn(...args),
|
||||
},
|
||||
createDualMessageContent: jest.fn(() => []),
|
||||
getRouteChatProjectId: jest.fn(() => null),
|
||||
|
|
@ -82,6 +85,93 @@ const assistantMessage = (messageId: string, parentMessageId: string) =>
|
|||
text: messageId,
|
||||
}) as TMessage;
|
||||
|
||||
const conversation = (conversationId: string) =>
|
||||
({
|
||||
conversationId,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
model: 'gpt-4o',
|
||||
agent_id: 'agent-1',
|
||||
}) as TConversation;
|
||||
|
||||
function renderAsk(messages: TMessage[] | undefined, conversationId = 'conversation-1') {
|
||||
const setMessages = jest.fn();
|
||||
const setSubmission = jest.fn();
|
||||
const getMessages = jest.fn(() => messages);
|
||||
const hook = renderHook(() =>
|
||||
useChatFunctions({
|
||||
isSubmitting: false,
|
||||
latestMessage: messages?.at(-1) ?? null,
|
||||
conversation: conversation(conversationId),
|
||||
getMessages,
|
||||
setMessages,
|
||||
setSubmission,
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...hook, getMessages, setMessages, setSubmission };
|
||||
}
|
||||
|
||||
describe('useChatFunctions ask', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetQueryData.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('refuses to send to an existing conversation before its history loads', () => {
|
||||
const { result, getMessages, setMessages, setSubmission } = renderAsk(undefined);
|
||||
|
||||
let askResult: ReturnType<typeof result.current.ask>;
|
||||
act(() => {
|
||||
askResult = result.current.ask({ text: 'Hello', conversationId: 'conversation-1' });
|
||||
});
|
||||
|
||||
expect(askResult!).toBe(false);
|
||||
expect(getMessages).toHaveBeenCalledWith('conversation-1');
|
||||
expect(setMessages).not.toHaveBeenCalled();
|
||||
expect(setSubmission).not.toHaveBeenCalled();
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
'[useChatFunctions] Refusing to send before existing conversation history loads',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows an existing conversation whose loaded history is empty', () => {
|
||||
const { result, setMessages, setSubmission } = renderAsk([]);
|
||||
|
||||
act(() => {
|
||||
result.current.ask({ text: 'Hello', conversationId: 'conversation-1' });
|
||||
});
|
||||
|
||||
expect(setMessages).toHaveBeenCalled();
|
||||
expect(setSubmission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a new conversation before its message cache exists', () => {
|
||||
const newConversationId = Constants.NEW_CONVO as string;
|
||||
const { result, setMessages, setSubmission } = renderAsk(undefined, newConversationId);
|
||||
|
||||
act(() => {
|
||||
result.current.ask({ text: 'Hello', conversationId: newConversationId });
|
||||
});
|
||||
|
||||
expect(setMessages).toHaveBeenCalled();
|
||||
expect(setSubmission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows explicit override messages before the cache exists', () => {
|
||||
const { result, setMessages, setSubmission } = renderAsk(undefined);
|
||||
|
||||
act(() => {
|
||||
result.current.ask(
|
||||
{ text: 'Hello', conversationId: 'conversation-1' },
|
||||
{ overrideMessages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
expect(setMessages).toHaveBeenCalled();
|
||||
expect(setSubmission).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useChatFunctions regenerate', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export default function useChatFunctions({
|
|||
paramId?: string | undefined;
|
||||
conversation: TConversation | null;
|
||||
latestMessage: TMessage | null;
|
||||
getMessages: () => TMessage[] | undefined;
|
||||
getMessages: (conversationId?: string | null) => TMessage[] | undefined;
|
||||
setMessages: (messages: TMessage[]) => void;
|
||||
files?: Map<string, ExtendedFile>;
|
||||
setFiles?: SetterOrUpdater<Map<string, ExtendedFile>>;
|
||||
|
|
@ -302,6 +302,13 @@ export default function useChatFunctions({
|
|||
return;
|
||||
}
|
||||
|
||||
const cachedMessages = getMessages(conversationId);
|
||||
const isExistingConversation = conversationId != null && conversationId !== Constants.NEW_CONVO;
|
||||
if (isExistingConversation && overrideMessages == null && cachedMessages == null) {
|
||||
logger.warn('[useChatFunctions] Refusing to send before existing conversation history loads');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isContinued && !latestMessage) {
|
||||
console.error('cannot continue AI message without latestMessage!');
|
||||
return;
|
||||
|
|
@ -357,7 +364,7 @@ export default function useChatFunctions({
|
|||
}
|
||||
const isEditOrContinue = isEdited || isContinued;
|
||||
|
||||
let currentMessages: TMessage[] = overrideMessages ?? getMessages() ?? [];
|
||||
let currentMessages: TMessage[] = overrideMessages ?? cachedMessages ?? [];
|
||||
|
||||
if (conversation?.promptPrefix) {
|
||||
conversation.promptPrefix = replaceSpecialVars({
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { ActiveJobsResponse } from '~/data-provider';
|
||||
import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
|
||||
import useChatFunctions from '~/hooks/Chat/useChatFunctions';
|
||||
import { useAbortStreamMutation } from '~/data-provider';
|
||||
import useNewConvo from '~/hooks/useNewConvo';
|
||||
import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
|
||||
import { getMessageCacheIds } from './cache';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -51,9 +51,15 @@ export default function useChatHelpers(index = 0, paramId?: string) {
|
|||
[queryParam, queryClient, conversationId],
|
||||
);
|
||||
|
||||
const getMessages = useCallback(() => {
|
||||
return queryClient.getQueryData<TMessage[]>([QueryKeys.messages, queryParam]);
|
||||
}, [queryParam, queryClient]);
|
||||
const getMessages = useCallback(
|
||||
(targetConversationId?: string | null) => {
|
||||
return queryClient.getQueryData<TMessage[]>([
|
||||
QueryKeys.messages,
|
||||
targetConversationId ?? queryParam,
|
||||
]);
|
||||
},
|
||||
[queryParam, queryClient],
|
||||
);
|
||||
|
||||
/* Conversation */
|
||||
// const setActiveConvos = useSetRecoilState(store.activeConversations);
|
||||
|
|
|
|||
|
|
@ -121,10 +121,8 @@ const useNavigateToConvo = (index = 0) => {
|
|||
if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) {
|
||||
/**
|
||||
* Remove (not just invalidate) the target's messages so a freshly-mounted
|
||||
* ChatView refetches them. A prior `clearMessagesCache` can leave this
|
||||
* conversation cached as `[]`, which the messages query's `refetchOnMount: false`
|
||||
* would treat as valid — leaving the chat stuck on an empty cache with no
|
||||
* request when navigating in from a non-chat route (e.g. /projects).
|
||||
* ChatView refetches them even with `refetchOnMount: false`, including when
|
||||
* navigating in from a non-chat route (e.g. /projects).
|
||||
*/
|
||||
queryClient.removeQueries([QueryKeys.messages, convo.conversationId]);
|
||||
queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import {
|
||||
clearMessagesCache,
|
||||
isValidTimestamp,
|
||||
getMessageAriaLabel,
|
||||
getMessageTimestamp,
|
||||
|
|
@ -32,6 +35,21 @@ const makeMessage = (overrides: Partial<TMessage> = {}): TMessage =>
|
|||
...overrides,
|
||||
}) as TMessage;
|
||||
|
||||
describe('clearMessagesCache', () => {
|
||||
it('removes existing-conversation history while resetting the new-conversation cache', () => {
|
||||
const queryClient = new QueryClient();
|
||||
const conversationId = 'conversation-1';
|
||||
const messages = [makeMessage({ conversationId })];
|
||||
queryClient.setQueryData([QueryKeys.messages, conversationId], messages);
|
||||
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], messages);
|
||||
|
||||
clearMessagesCache(queryClient, conversationId);
|
||||
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toBeUndefined();
|
||||
expect(queryClient.getQueryData([QueryKeys.messages, Constants.NEW_CONVO])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageAriaLabel', () => {
|
||||
it('returns "Message N" when depth is present and valid', () => {
|
||||
const msg = makeMessage({ depth: 2 });
|
||||
|
|
|
|||
|
|
@ -292,9 +292,8 @@ export const scrollToEnd = (callback?: () => void) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Clears messages for both the specified conversation ID and the NEW_CONVO query key.
|
||||
* This ensures that messages are properly cleared in all contexts, preventing stale data
|
||||
* from persisting in the NEW_CONVO cache.
|
||||
* Removes an existing conversation's message query so reopening it starts cold, and resets the
|
||||
* NEW_CONVO query to an empty cache for immediate optimistic messages.
|
||||
*
|
||||
* @param queryClient - The React Query client instance
|
||||
* @param conversationId - The conversation ID to clear messages for
|
||||
|
|
@ -305,13 +304,12 @@ export const clearMessagesCache = (
|
|||
): void => {
|
||||
const convoId = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
// Clear messages for the current conversation
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, convoId], []);
|
||||
|
||||
// Also clear NEW_CONVO messages if we're not already on NEW_CONVO
|
||||
// An absent existing-conversation cache means its history must load before sending.
|
||||
if (convoId !== Constants.NEW_CONVO) {
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
|
||||
queryClient.removeQueries([QueryKeys.messages, convoId], { exact: true });
|
||||
}
|
||||
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
|
||||
};
|
||||
|
||||
/** Returns a 1-based message number, or null if depth is absent or invalid. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue