🔍 refactor: Search & Message Retrieval (#6903)

* refactor: conversation search fetch

* refactor: Message and Convo fetch with paramters and search

* refactor: update search states and cleanup old store states

* refactor: re-enable search API; fix: search conversation

* fix: message's convo fetch

* fix: redirect when searching

* chore: use logger instead of console

* fix: search message loading

* feat: small optimizations

* feat(Message): remove cache for search path

* fix: handle delete of all archivedConversation and sharedLinks

* chore: cleanup

* fix: search messages

* style: update ConvoOptions styles

* refactor(SearchButtons): streamline conversation fetching and remove unused state

* fix: ensure messages are invalidated after fetching conversation data

* fix: add iconURL to conversation query selection

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-04-17 03:07:43 +02:00 committed by GitHub
parent 851938e7a6
commit 88f4ad7c47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 489 additions and 576 deletions

View file

@ -1,11 +1,7 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type {
TConversation,
ConversationListResponse,
SearchConversationListResponse,
} from 'librechat-data-provider';
import type { TConversation, ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import {
useLocalize,
@ -17,8 +13,6 @@ import {
} from '~/hooks';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import { useSearchContext } from '~/Providers';
import { Spinner } from '~/components';
import NavToggle from './NavToggle';
import SearchBar from './SearchBar';
import NewChat from './NewChat';
@ -74,71 +68,48 @@ const Nav = memo(
permission: Permissions.USE,
});
const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const isSearchTyping = useRecoilValue(store.isSearchTyping);
const { searchQuery, searchQueryRes } = useSearchContext();
const search = useRecoilValue(store.search);
const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery(
{
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const { data, fetchNextPage, isFetchingNextPage, isLoading, isFetching, refetch } =
useConversationsInfiniteQuery(
{
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const computedHasNextPage = useMemo(() => {
if (searchQuery && searchQueryRes?.data) {
const pages = searchQueryRes.data.pages;
return pages[pages.length - 1]?.nextCursor !== null;
} else if (data?.pages && data.pages.length > 0) {
if (data?.pages && data.pages.length > 0) {
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
return lastPage.nextCursor !== null;
}
return false;
}, [searchQuery, searchQueryRes?.data, data?.pages]);
}, [data?.pages]);
const outerContainerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<any>(null);
const { moveToTop } = useNavScrolling<
ConversationListResponse | SearchConversationListResponse
>({
const { moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
fetchNextPage: async (options?) => {
if (computedHasNextPage) {
if (searchQuery && searchQueryRes) {
const pages = searchQueryRes.data?.pages;
if (pages && pages.length > 0 && pages[pages.length - 1]?.nextCursor !== null) {
return searchQueryRes.fetchNextPage(options);
}
} else {
return fetchNextPage(options);
}
return fetchNextPage(options);
}
return Promise.resolve(
{} as InfiniteQueryObserverResult<
SearchConversationListResponse | ConversationListResponse,
unknown
>,
{} as InfiniteQueryObserverResult<ConversationListResponse, unknown>,
);
},
isFetchingNext: searchQuery
? (searchQueryRes?.isFetchingNextPage ?? false)
: isFetchingNextPage,
isFetchingNext: isFetchingNextPage,
});
const conversations = useMemo(() => {
if (searchQuery && searchQueryRes?.data) {
return searchQueryRes.data.pages.flatMap(
(page) => page.conversations ?? [],
) as TConversation[];
}
return data ? data.pages.flatMap((page) => page.conversations) : [];
}, [data, searchQuery, searchQueryRes?.data]);
}, [data]);
const toggleNavVisible = useCallback(() => {
setNavVisible((prev: boolean) => {
@ -183,7 +154,7 @@ const Nav = memo(
const subHeaders = useMemo(
() => (
<>
{isSearchEnabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
{hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
@ -194,14 +165,22 @@ const Nav = memo(
)}
</>
),
[isSearchEnabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
[search.enabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
);
const isSearchLoading =
!!searchQuery &&
(isSearchTyping ||
(searchQueryRes?.isLoading ?? false) ||
(searchQueryRes?.isFetching ?? false));
const [isSearchLoading, setIsSearchLoading] = useState(
!!search.query && (search.isTyping || isLoading || isFetching),
);
useEffect(() => {
if (search.isTyping) {
setIsSearchLoading(true);
} else if (!isLoading && !isFetching) {
setIsSearchLoading(false);
} else if (!!search.query && (isLoading || isFetching)) {
setIsSearchLoading(true);
}
}, [search.query, search.isTyping, isLoading, isFetching]);
return (
<>
@ -243,7 +222,7 @@ const Nav = memo(
toggleNav={itemToggleNav}
containerRef={listRef}
loadMoreConversations={loadMoreConversations}
isFetchingNextPage={isFetchingNextPage || showLoading}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
/>
</div>

View file

@ -15,8 +15,9 @@ import { cn } from '~/utils';
import store from '~/store';
const NewChatButtonIcon = React.memo(({ conversation }: { conversation: TConversation | null }) => {
const searchQuery = useRecoilValue(store.searchQuery);
const { data: endpointsConfig } = useGetEndpointsQuery();
const search = useRecoilValue(store.search);
const searchQuery = search.debouncedQuery;
const computedIcon = useMemo(() => {
if (searchQuery) {

View file

@ -1,10 +1,10 @@
import { forwardRef, useState, useCallback, useMemo, useEffect, Ref } from 'react';
import debounce from 'lodash/debounce';
import { Search, X } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { forwardRef, useState, useCallback, useMemo, Ref } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocalize, useNewConvo } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -17,29 +17,34 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const localize = useLocalize();
const location = useLocation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { isSmallScreen } = props;
const [text, setText] = useState('');
const [showClearIcon, setShowClearIcon] = useState(false);
const { newConversation } = useNewConvo();
const clearConvoState = store.useClearConvoState();
const setSearchQuery = useSetRecoilState(store.searchQuery);
const setIsSearching = useSetRecoilState(store.isSearching);
const setIsSearchTyping = useSetRecoilState(store.isSearchTyping);
const setSearchState = useSetRecoilState(store.search);
const search = useRecoilValue(store.search);
const clearSearch = useCallback(() => {
if (location.pathname.includes('/search')) {
newConversation({ disableFocus: true });
navigate('/c/new', { replace: true });
}
}, [newConversation, location.pathname]);
}, [newConversation, location.pathname, navigate]);
const clearText = useCallback(() => {
setShowClearIcon(false);
setSearchQuery('');
clearSearch();
setText('');
}, [setSearchQuery, clearSearch]);
setSearchState((prev) => ({
...prev,
query: '',
debouncedQuery: '',
isTyping: false,
}));
clearSearch();
}, [setSearchState, clearSearch]);
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
const { value } = e.target as HTMLInputElement;
@ -50,34 +55,48 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const sendRequest = useCallback(
(value: string) => {
setSearchQuery(value);
if (!value) {
return;
}
queryClient.invalidateQueries([QueryKeys.messages]);
clearConvoState();
},
[queryClient, clearConvoState, setSearchQuery],
[queryClient],
);
const debouncedSendRequest = useMemo(
const debouncedSetDebouncedQuery = useMemo(
() =>
debounce((value: string) => {
setSearchState((prev) => ({ ...prev, debouncedQuery: value, isTyping: false }));
sendRequest(value);
}, 350),
[sendRequest, setIsSearchTyping],
}, 500),
[setSearchState, sendRequest],
);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setShowClearIcon(value.length > 0);
setText(value);
setSearchQuery(value);
setIsSearchTyping(true);
// debounce only the API call
debouncedSendRequest(value);
setSearchState((prev) => ({
...prev,
query: value,
isTyping: true,
}));
debouncedSetDebouncedQuery(value);
if (value.length > 0 && location.pathname !== '/search') {
navigate('/search', { replace: true });
} else if (value.length === 0 && location.pathname === '/search') {
navigate('/c/new', { replace: true });
}
};
// Automatically set isTyping to false when loading is done and debouncedQuery matches query
// (prevents stuck loading state if input is still focused)
useEffect(() => {
if (search.isTyping && !search.isSearching && search.debouncedQuery === search.query) {
setSearchState((prev) => ({ ...prev, isTyping: false }));
}
}, [search.isTyping, search.isSearching, search.debouncedQuery, search.query, setSearchState]);
return (
<div
ref={ref}
@ -98,8 +117,8 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
aria-label={localize('com_nav_search_placeholder')}
placeholder={localize('com_nav_search_placeholder')}
onKeyUp={handleKeyUp}
onFocus={() => setIsSearching(true)}
onBlur={() => setIsSearching(true)}
onFocus={() => setSearchState((prev) => ({ ...prev, isSearching: true }))}
onBlur={() => setSearchState((prev) => ({ ...prev, isSearching: false }))}
autoComplete="off"
dir="auto"
/>

View file

@ -272,7 +272,6 @@ export default function ArchivedChatsTable({
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
manualSorting={true} // Ensures server-side sorting
/>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>