🔃 refactor: Streamline Navigation, Message Loading UX (#7118)

* chore: fix logging for illegal target endpoints in getEndpointFromSetup

* fix: prevent querying agent by ID for ephemeral agents

* refactor: reorder variable declarations in MessagesView for clarity

* fix: localize 'nothing found' message in MessagesView

* refactor: streamline navigation logic and enhance loading spinner component in ChatView

* refactor: simplify loading spinner logic in ChatView component

* fix: ensure message queries are invalidated after new conversation creation in HeaderNewChat, MobileNav, and NewChat components

* 🐛 First run dev mode will have error occur.

🐛 First run dev mode will have error occur.

* fix font-size localstorage presist bug

* Don't ping meilisearch if the search is disabled via env var

* simplify logic in search/enable endpoint

* refactor: simplify enable endpoint condition check

* feat: add useIdChangeEffect hook and integrate it into ChatRoute

---------

Co-authored-by: Ne0 <20765145+zeeklog@users.noreply.github.com>
Co-authored-by: TinyTin <garychangcn@hotmail.com>
Co-authored-by: Denis Palnitsky <denis.palnitsky@zendesk.com>
This commit is contained in:
Danny Avila 2025-04-28 18:18:13 -04:00 committed by GitHub
parent fc30482f65
commit 0e8041bcac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 80 additions and 131 deletions

View file

@ -1,40 +1,17 @@
const { Keyv } = require('keyv');
const express = require('express');
const { MeiliSearch } = require('meilisearch');
const { Conversation } = require('~/models/Conversation');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { Message } = require('~/models/Message');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const router = express.Router();
const expiration = 60 * 1000;
const cache = isEnabled(process.env.USE_REDIS)
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: 'search', ttl: expiration });
router.use(requireJwtAuth);
router.get('/sync', async function (req, res) {
await Message.syncWithMeili();
await Conversation.syncWithMeili();
res.send('synced');
});
router.get('/test', async function (req, res) {
const { q } = req.query;
const messages = (
await Message.meiliSearch(q, { attributesToHighlight: ['text'] }, true)
).hits.map((message) => {
const { _formatted, ...rest } = message;
return { ...rest, searchResult: true, text: _formatted.text };
});
res.send(messages);
});
router.get('/enable', async function (req, res) {
let result = false;
if (!isEnabled(process.env.SEARCH)) {
return res.send(false);
}
try {
const client = new MeiliSearch({
host: process.env.MEILI_HOST,
@ -42,8 +19,7 @@ router.get('/enable', async function (req, res) {
});
const { status } = await client.health();
result = status === 'available' && !!process.env.SEARCH;
return res.send(result);
return res.send(status === 'available');
} catch (error) {
return res.send(false);
}

View file

@ -1,4 +1,4 @@
import { memo, useCallback } from 'react';
import { memo, useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
@ -19,6 +19,16 @@ import Header from './Header';
import Footer from './Footer';
import store from '~/store';
function LoadingSpinner() {
return (
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
}
function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
@ -52,15 +62,12 @@ function ChatView({ index = 0 }: { index?: number }) {
const isLandingPage =
(!messagesTree || messagesTree.length === 0) &&
(conversationId === Constants.NEW_CONVO || !conversationId);
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
if (isLoading && conversationId !== Constants.NEW_CONVO) {
content = (
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
content = <LoadingSpinner />;
} else if ((isLoading || isNavigating) && !isLandingPage) {
content = <LoadingSpinner />;
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
} else {

View file

@ -20,6 +20,7 @@ export default function HeaderNewChat() {
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};

View file

@ -14,10 +14,9 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
}) {
const localize = useLocalize();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const { screenshotTargetRef } = useScreenshot();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const {

View file

@ -13,7 +13,7 @@ export default function SearchButtons({ message }: { message: TMessage }) {
const localize = useLocalize();
const queryClient = useQueryClient();
const search = useRecoilValue(store.search);
const { navigateWithLastTools } = useNavigateToConvo();
const { navigateToConvo } = useNavigateToConvo();
const conversationId = message.conversationId ?? '';
const clickHandler = async (event: React.MouseEvent<HTMLButtonElement>) => {
@ -39,14 +39,13 @@ export default function SearchButtons({ message }: { message: TMessage }) {
}
document.title = title;
navigateWithLastTools(
navigateToConvo(
cachedConvo ??
({
conversationId,
title,
} as TConversation),
true,
true,
{ resetLatestMessage: true },
);
};

View file

@ -31,11 +31,11 @@ export default function Conversation({
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
const { navigateToConvo } = useNavigateToConvo();
const { data: endpointsConfig } = useGetEndpointsQuery();
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
const { data: endpointsConfig } = useGetEndpointsQuery();
const { navigateWithLastTools } = useNavigateToConvo();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { conversationId, title = '' } = conversation;
@ -118,10 +118,10 @@ export default function Conversation({
document.title = title;
}
navigateWithLastTools(
conversation,
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
);
navigateToConvo(conversation, {
currentConvoId,
resetLatestMessage: !(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
});
};
const convoOptionsProps = {

View file

@ -61,6 +61,7 @@ export default function MobileNav({
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}}
>

View file

@ -40,6 +40,7 @@ export default function NewChat({
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConvo();
navigate('/c/new', { state: { focusChat: true } });
if (isSmallScreen) {

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import MultiMessage from './MultiMessage';
import { useLocalize } from '~/hooks';
export default function MessagesView({
messagesTree: _messagesTree,
@ -9,6 +10,7 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
conversationId: string;
}) {
const localize = useLocalize();
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
return (
<div className="flex-1 pb-[50px]">
@ -23,7 +25,7 @@ export default function MessagesView({
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
Nothing found
{localize('com_ui_nothing_found')}
</div>
) : (
<>

View file

@ -4,6 +4,7 @@ import { useWatch, useForm, FormProvider } from 'react-hook-form';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import {
Tools,
Constants,
SystemRoles,
EModelEndpoint,
isAssistantsEndpoint,
@ -45,7 +46,7 @@ export default function AgentPanel({
const modelsQuery = useGetModelsQuery();
const agentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
enabled: !!(current_agent_id ?? ''),
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
});
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);

View file

@ -18,7 +18,7 @@ export default function useSelectAgent() {
);
const agentQuery = useGetAgentByIdQuery(selectedAgentId ?? '', {
enabled: !!(selectedAgentId ?? ''),
enabled: !!(selectedAgentId ?? '') && selectedAgentId !== Constants.EPHEMERAL_AGENT_ID,
});
const updateConversation = useCallback(

View file

@ -2,4 +2,5 @@ export { default as useChatHelpers } from './useChatHelpers';
export { default as useAddedHelpers } from './useAddedHelpers';
export { default as useAddedResponse } from './useAddedResponse';
export { default as useChatFunctions } from './useChatFunctions';
export { default as useIdChangeEffect } from './useIdChangeEffect';
export { default as useFocusChatEffect } from './useFocusChatEffect';

View file

@ -0,0 +1,21 @@
import { useEffect, useRef } from 'react';
import { useResetRecoilState } from 'recoil';
import { logger } from '~/utils';
import store from '~/store';
/**
* Hook to reset artifacts when the conversation ID changes
* @param conversationId - The current conversation ID
*/
export default function useIdChangeEffect(conversationId: string) {
const lastConvoId = useRef<string | null>(null);
const resetArtifacts = useResetRecoilState(store.artifactsState);
useEffect(() => {
if (conversationId !== lastConvoId.current) {
logger.log('conversation', 'Conversation ID change');
resetArtifacts();
}
lastConvoId.current = conversationId;
}, [conversationId, resetArtifacts]);
}

View file

@ -1,13 +1,7 @@
import { useSetRecoilState } from 'recoil';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import {
QueryKeys,
Constants,
dataService,
EModelEndpoint,
LocalStorageKeys,
} from 'librechat-data-provider';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider';
import { buildDefaultConvo, getDefaultEndpoint, getEndpointField, logger } from '~/utils';
import store from '~/store';
@ -16,48 +10,28 @@ const useNavigateToConvo = (index = 0) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const clearAllConversations = store.useClearConvoState();
const resetArtifacts = useResetRecoilState(store.artifactsState);
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
const clearAllLatestMessages = store.useClearLatestMessages(`useNavigateToConvo ${index}`);
const { hasSetConversation, setConversation } = store.useCreateConversationAtom(index);
const fetchFreshData = async (conversationId?: string | null) => {
if (!conversationId) {
return;
}
try {
const data = await queryClient.fetchQuery([QueryKeys.conversation, conversationId], () =>
dataService.getConversationById(conversationId),
);
logger.log('conversation', 'Fetched fresh conversation data', data);
await queryClient.invalidateQueries([QueryKeys.messages, conversationId]);
setConversation(data);
navigate(`/c/${conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } });
} catch (error) {
console.error('Error fetching conversation data on navigation', error);
}
};
const navigateToConvo = (
conversation?: TConversation | null,
_resetLatestMessage = true,
/** Likely need to remove this since it happens after fetching conversation data */
invalidateMessages = false,
options?: {
resetLatestMessage?: boolean;
currentConvoId?: string;
},
) => {
if (!conversation) {
logger.warn('conversation', 'Conversation not provided to `navigateToConvo`');
return;
}
const { resetLatestMessage = true, currentConvoId } = options || {};
logger.log('conversation', 'Navigating to conversation', conversation);
hasSetConversation.current = true;
setSubmission(null);
if (_resetLatestMessage) {
if (resetLatestMessage) {
clearAllLatestMessages();
}
if (invalidateMessages && conversation.conversationId != null && conversation.conversationId) {
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], []);
queryClient.invalidateQueries([QueryKeys.messages, conversation.conversationId]);
}
let convo = { ...conversation };
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
@ -84,51 +58,13 @@ const useNavigateToConvo = (index = 0) => {
});
}
clearAllConversations(true);
resetArtifacts();
setConversation(convo);
if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) {
queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]);
fetchFreshData(convo.conversationId);
} else {
navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } });
}
};
const navigateWithLastTools = (
conversation?: TConversation | null,
_resetLatestMessage?: boolean,
invalidateMessages?: boolean,
) => {
if (!conversation) {
logger.warn('conversation', 'Conversation not provided to `navigateToConvo`');
return;
}
// set conversation to the new conversation
if (conversation.endpoint === EModelEndpoint.gptPlugins) {
let lastSelectedTools = [];
try {
lastSelectedTools =
JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_TOOLS) ?? '') ?? [];
} catch (e) {
logger.error('conversation', 'Error parsing last selected tools', e);
}
const hasTools = (conversation.tools?.length ?? 0) > 0;
navigateToConvo(
{
...conversation,
tools: hasTools ? conversation.tools : lastSelectedTools,
},
_resetLatestMessage,
invalidateMessages,
);
} else {
navigateToConvo(conversation, _resetLatestMessage, invalidateMessages);
}
queryClient.setQueryData([QueryKeys.messages, currentConvoId], []);
navigate(`/c/${convo.conversationId ?? Constants.NEW_CONVO}`, { state: { focusChat: true } });
};
return {
navigateToConvo,
navigateWithLastTools,
};
};

View file

@ -58,10 +58,14 @@ export const ThemeProvider = ({ initialTheme, children }) => {
if (fontSize == null) {
setFontSize('text-base');
applyFontSize('text-base');
localStorage.setItem('fontSize', 'text-base');
localStorage.setItem('fontSize', JSON.stringify('text-base'));
return;
}
applyFontSize(JSON.parse(fontSize));
try {
applyFontSize(JSON.parse(fontSize));
} catch (error) {
console.log(error);
}
// Reason: This effect should only run once, and `setFontSize` is a stable function
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View file

@ -9,8 +9,8 @@ import {
useGetStartupConfig,
useGetEndpointsQuery,
} from '~/data-provider';
import { useNewConvo, useAppStartup, useAssistantListMap, useIdChangeEffect } from '~/hooks';
import { getDefaultModelSpec, getModelSpecPreset, logger } from '~/utils';
import { useNewConvo, useAppStartup, useAssistantListMap } from '~/hooks';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
import useAuthRedirect from './useAuthRedirect';
@ -34,7 +34,7 @@ export default function ChatRoute() {
const index = 0;
const { conversationId = '' } = useParams();
useIdChangeEffect(conversationId);
const { hasSetConversation, conversation } = store.useCreateConversationAtom(index);
const { newConversation } = useNewConvo();

View file

@ -20,7 +20,7 @@ const getEndpointFromSetup = (
if (targetEndpoint && endpointsConfig?.[targetEndpoint]) {
return targetEndpoint as EModelEndpoint;
} else if (targetEndpoint) {
console.warn(`Illegal target endpoint ${targetEndpoint} ${endpointsConfig}`);
console.warn(`Illegal target endpoint ${targetEndpoint}`, endpointsConfig);
}
return null;
};