mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🪡 fix: Prevent Hover Actions Flash While Streaming (#13285)
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
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
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
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
This commit is contained in:
parent
385c7abcbf
commit
9c4049b1c9
7 changed files with 303 additions and 29 deletions
|
|
@ -0,0 +1,64 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import type { TConversation, TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import useMemoizedChatContext from '../useMemoizedChatContext';
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useChatContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
|
||||
|
||||
const conversation = {
|
||||
conversationId: 'convo-id',
|
||||
endpoint: 'openAI',
|
||||
model: 'gpt-4',
|
||||
} as TConversation;
|
||||
|
||||
const message = (overrides: Partial<TMessage> = {}) =>
|
||||
({
|
||||
messageId: 'assistant-response_',
|
||||
parentMessageId: 'user-message',
|
||||
conversationId: 'convo-id',
|
||||
sender: 'Assistant',
|
||||
text: '',
|
||||
isCreatedByUser: false,
|
||||
children: [],
|
||||
...overrides,
|
||||
}) as TMessage;
|
||||
|
||||
function mockChatContext(latestMessageId: string | undefined) {
|
||||
mockUseChatContext.mockReturnValue({
|
||||
ask: jest.fn(),
|
||||
index: 0,
|
||||
regenerate: jest.fn(),
|
||||
conversation,
|
||||
latestMessageId,
|
||||
latestMessageDepth: -1,
|
||||
handleContinue: jest.fn(),
|
||||
isSubmitting: true,
|
||||
} as unknown as ReturnType<typeof useChatContext>);
|
||||
}
|
||||
|
||||
describe('useMemoizedChatContext', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('treats the latest message as submitting while streaming', () => {
|
||||
mockChatContext('assistant-response_');
|
||||
|
||||
const { result } = renderHook(() => useMemoizedChatContext(message(), true));
|
||||
|
||||
expect(result.current.effectiveIsSubmitting).toBe(true);
|
||||
});
|
||||
|
||||
it('requires latestMessageId alignment before marking a row as submitting', () => {
|
||||
mockChatContext(undefined);
|
||||
|
||||
const { result } = renderHook(() => useMemoizedChatContext(message(), true));
|
||||
|
||||
expect(result.current.effectiveIsSubmitting).toBe(false);
|
||||
expect(result.current.chatContext.isSubmitting).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
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],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -156,7 +156,7 @@ const buildChatHelpers = () => ({
|
|||
setConversation: jest.fn(),
|
||||
setIsSubmitting: mockSetIsSubmitting,
|
||||
newConversation: jest.fn(),
|
||||
resetLatestMessage: jest.fn(),
|
||||
setLatestMessage: jest.fn(),
|
||||
});
|
||||
|
||||
const getLastSSE = (): MockSSEInstance => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ type ChatHelpers = Pick<
|
|||
| 'setConversation'
|
||||
| 'setIsSubmitting'
|
||||
| 'newConversation'
|
||||
| 'resetLatestMessage'
|
||||
| 'setLatestMessage'
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { TResData, TFinalResData, ConvoGenerator } from '~/common';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type { SetterOrUpdater, Resetter } from 'recoil';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { ConversationCursorData } from '~/utils';
|
||||
import {
|
||||
logger,
|
||||
|
|
@ -62,7 +62,7 @@ export type EventHandlerParams = {
|
|||
setConversation?: SetterOrUpdater<TConversation | null>;
|
||||
newConversation?: ConvoGenerator;
|
||||
setShowStopButton: SetterOrUpdater<boolean>;
|
||||
resetLatestMessage?: Resetter;
|
||||
setLatestMessage: SetterOrUpdater<TMessage | null>;
|
||||
};
|
||||
|
||||
const createErrorMessage = ({
|
||||
|
|
@ -175,7 +175,7 @@ export default function useEventHandlers({
|
|||
setIsSubmitting,
|
||||
newConversation,
|
||||
setShowStopButton,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
}: EventHandlerParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const { announcePolite } = useLiveAnnouncer();
|
||||
|
|
@ -323,14 +323,14 @@ export default function useEventHandlers({
|
|||
const { initialResponse, messages: _messages, userMessage } = submission;
|
||||
const messages = _messages.filter((msg) => msg.messageId !== userMessage.messageId);
|
||||
|
||||
setMessages([
|
||||
...messages,
|
||||
requestMessage,
|
||||
{
|
||||
...initialResponse,
|
||||
...responseMessage,
|
||||
},
|
||||
]);
|
||||
const nextResponseMessage = {
|
||||
...initialResponse,
|
||||
...responseMessage,
|
||||
};
|
||||
|
||||
logger.log('latest_message', 'syncHandler: setting latest message');
|
||||
setLatestMessage(nextResponseMessage);
|
||||
setMessages([...messages, requestMessage, nextResponseMessage]);
|
||||
|
||||
announcePolite({
|
||||
message: 'start',
|
||||
|
|
@ -375,10 +375,6 @@ export default function useEventHandlers({
|
|||
}
|
||||
|
||||
setShowStopButton(true);
|
||||
if (resetLatestMessage) {
|
||||
logger.log('latest_message', 'syncHandler: resetting latest message');
|
||||
resetLatestMessage();
|
||||
}
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
|
|
@ -387,7 +383,7 @@ export default function useEventHandlers({
|
|||
announcePolite,
|
||||
setConversation,
|
||||
setShowStopButton,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -410,7 +406,10 @@ export default function useEventHandlers({
|
|||
...submission.initialResponse,
|
||||
parentMessageId: userMessage.messageId,
|
||||
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 {
|
||||
|
|
@ -469,10 +468,6 @@ export default function useEventHandlers({
|
|||
});
|
||||
}
|
||||
|
||||
if (resetLatestMessage) {
|
||||
logger.log('latest_message', 'createdHandler: resetting latest message');
|
||||
resetLatestMessage();
|
||||
}
|
||||
scrollToEnd(() => setAbortScroll(false));
|
||||
},
|
||||
[
|
||||
|
|
@ -482,7 +477,7 @@ export default function useEventHandlers({
|
|||
isAddedRequest,
|
||||
announcePolite,
|
||||
setConversation,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
applyAgentTemplate,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ type ChatHelpers = Pick<
|
|||
| 'setConversation'
|
||||
| 'setIsSubmitting'
|
||||
| 'newConversation'
|
||||
| 'resetLatestMessage'
|
||||
| 'setLatestMessage'
|
||||
>;
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
|
|
@ -102,7 +102,7 @@ export default function useResumableSSE(
|
|||
setConversation,
|
||||
setIsSubmitting,
|
||||
newConversation,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
} = chatHelpers;
|
||||
|
||||
const {
|
||||
|
|
@ -125,7 +125,7 @@ export default function useResumableSSE(
|
|||
setIsSubmitting,
|
||||
newConversation,
|
||||
setShowStopButton,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
});
|
||||
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ type ChatHelpers = Pick<
|
|||
| 'setConversation'
|
||||
| 'setIsSubmitting'
|
||||
| 'newConversation'
|
||||
| 'resetLatestMessage'
|
||||
| 'setLatestMessage'
|
||||
>;
|
||||
|
||||
export default function useSSE(
|
||||
|
|
@ -41,7 +41,7 @@ export default function useSSE(
|
|||
setConversation,
|
||||
setIsSubmitting,
|
||||
newConversation,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
} = chatHelpers;
|
||||
|
||||
const {
|
||||
|
|
@ -64,7 +64,7 @@ export default function useSSE(
|
|||
setIsSubmitting,
|
||||
newConversation,
|
||||
setShowStopButton,
|
||||
resetLatestMessage,
|
||||
setLatestMessage,
|
||||
});
|
||||
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue