Skip to content

Commit

Permalink
✨ fix: search functionality and bugs with loadMoreConversations
Browse files Browse the repository at this point in the history
  • Loading branch information
berry-13 committed Feb 10, 2025
1 parent d356f2d commit faecfc5
Show file tree
Hide file tree
Showing 12 changed files with 227 additions and 159 deletions.
44 changes: 24 additions & 20 deletions api/models/Conversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,12 @@ module.exports = {
return { message: 'Error getting conversations' };
}
},
getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => {
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
try {
if (!convoIds || convoIds.length === 0) {
return { conversations: [], pages: 1, pageNumber, pageSize };
return { conversations: [], nextCursor: null, convoMap: {} };
}

const cache = {};
const convoMap = {};
const promises = [];

Expand All @@ -202,28 +201,33 @@ module.exports = {
),
);

// Fetch all matching conversations and filter out any falsy results
const results = (await Promise.all(promises)).filter(Boolean);

results.forEach((convo, i) => {
const page = Math.floor(i / pageSize) + 1;
if (!cache[page]) {
cache[page] = [];
}
cache[page].push(convo);
// Sort conversations by updatedAt descending (most recent first)
results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));

// If a cursor is provided and not "start", filter out recrods newer or equal to the cursor date
let filtered = results;
if (cursor && cursor !== 'start') {
const cursorDate = new Date(cursor);
filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate);
}

// Retrieve limit + 1 results to determine if there's a next page.
const limited = filtered.slice(0, limit + 1);
let nextCursor = null;
if (limited.length > limit) {
const lastConvo = limited.pop();
nextCursor = lastConvo.updatedAt.toISOString();
}

// Build convoMap for ease of access if required by caller
limited.forEach((convo) => {
convoMap[convo.conversationId] = convo;
});

const totalPages = Math.ceil(results.length / pageSize);
cache.pages = totalPages;
cache.pageSize = pageSize;
return {
cache,
conversations: cache[pageNumber] || [],
pages: totalPages || 1,
pageNumber,
pageSize,
convoMap,
};
return { conversations: limited, nextCursor, convoMap };
} catch (error) {
logger.error('[getConvosQueried] Error getting conversations', error);
return { message: 'Error fetching conversations' };
Expand Down
62 changes: 43 additions & 19 deletions api/server/routes/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,48 +27,72 @@ router.get('/sync', async function (req, res) {

router.get('/', async function (req, res) {
try {
let user = req.user.id ?? '';
const { q } = req.query;
const pageNumber = req.query.pageNumber || 1;
const key = `${user}:search:${q}`;
const user = req.user.id ?? '';
const { q, cursor = 'start' } = req.query;
const key = `${user}:search:${q}:${cursor}`;
const cached = await cache.get(key);
if (cached) {
logger.debug('[/search] cache hit: ' + key);
const { pages, pageSize, messages } = cached;
res
.status(200)
.send({ conversations: cached[pageNumber], pages, pageNumber, pageSize, messages });
return;
return res.status(200).send(cached);
}

const messages = (await Message.meiliSearch(q, undefined, true)).hits;
const titles = (await Conversation.meiliSearch(q)).hits;
const [messageResults, titleResults] = await Promise.all([
Message.meiliSearch(q, undefined, true),
Conversation.meiliSearch(q),
]);
const messages = messageResults.hits;
const titles = titleResults.hits;

const sortedHits = reduceHits(messages, titles);
const result = await getConvosQueried(user, sortedHits, pageNumber);
const result = await getConvosQueried(user, sortedHits, cursor);

const activeMessages = [];
for (let i = 0; i < messages.length; i++) {
let message = messages[i];

if (message.conversationId.includes('--')) {
message.conversationId = cleanUpPrimaryKeyValue(message.conversationId);
}

if (result.convoMap[message.conversationId]) {
const convo = result.convoMap[message.conversationId];
const { title, chatGptLabel, model } = convo;
message = { ...message, ...{ title, chatGptLabel, model } };
activeMessages.push(message);
activeMessages.push({
...message,
title: convo.title,
conversationId: message.conversationId,
cleanedConversationId: cleanUpPrimaryKeyValue(message.conversationId),
searchMetadata: {
matchScore: message._score,
highlightedContent: message._formatted?.text,
matchedTerms: message._matchesInfo,
},
});
}
}
result.messages = activeMessages;

const activeConversations = [];
for (const convId in result.convoMap) {
const convo = result.convoMap[convId];
activeConversations.push({
title: convo.title,
user: convo.user,
conversationId: convo.conversationId,
});
}

if (result.cache) {
result.cache.messages = activeMessages;
result.cache.conversations = activeConversations;
cache.set(key, result.cache, expiration);
delete result.cache;
}
delete result.convoMap;

res.status(200).send(result);
const response = {
nextCursor: result.nextCursor ?? null,
messages: activeMessages,
conversations: activeConversations,
};

res.status(200).send(response);
} catch (error) {
logger.error('[/search] Error while searching messages & conversations', error);
res.status(500).send({ message: 'Error searching' });
Expand Down
105 changes: 67 additions & 38 deletions client/src/components/Nav/Nav.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type {
TConversation,
ConversationListResponse,
SearchConversationListResponse,
} from 'librechat-data-provider';
import {
useLocalize,
useHasAccess,
Expand Down Expand Up @@ -69,37 +73,67 @@ const Nav = memo(
});

const isSearchEnabled = useRecoilValue(store.isSearchEnabled);
const { searchQuery, setPageNumber, searchQueryRes } = useSearchContext();

const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
useConversationsInfiniteQuery(
{
cursor: null,
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);

const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
const { searchQuery, searchQueryRes } = useSearchContext();

const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery(
{
cursor: null,
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{
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) {
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
return lastPage.nextCursor !== null;
}
return false;
}, [searchQuery, searchQueryRes?.data, data?.pages]);

const { containerRef, moveToTop } = useNavScrolling<
ConversationListResponse | SearchConversationListResponse
>({
setShowLoading,
hasNextPage: searchQuery ? searchQueryRes?.hasNextPage : hasNextPage,
fetchNextPage: searchQuery ? searchQueryRes?.fetchNextPage : fetchNextPage,
isFetchingNextPage: searchQuery
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 Promise.resolve(
{} as InfiniteQueryObserverResult<
SearchConversationListResponse | ConversationListResponse,
unknown
>,
);
},
isFetchingNext: searchQuery
? searchQueryRes?.isFetchingNextPage ?? false
: isFetchingNextPage,
});

const conversations = useMemo(
() =>
(searchQuery ? searchQueryRes?.data : data)?.pages.flatMap((page) => page.conversations) ||
[],
[data, searchQuery, searchQueryRes?.data],
);
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]);

const toggleNavVisible = useCallback(() => {
setNavVisible((prev: boolean) => {
Expand Down Expand Up @@ -134,22 +168,17 @@ const Nav = memo(
}, [tags, refetch]);

const loadMoreConversations = useCallback(() => {
if (isFetchingNextPage) {
if (isFetchingNextPage || !computedHasNextPage) {
return;
}
if (searchQuery && searchQueryRes?.hasNextPage) {
searchQueryRes.fetchNextPage();
} else if (hasNextPage) {
fetchNextPage();
}
}, [isFetchingNextPage, searchQuery, searchQueryRes, hasNextPage, fetchNextPage]);

fetchNextPage();
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);

const subHeaders = useMemo(
() => (
<>
{isSearchEnabled && (
<SearchBar setPageNumber={setPageNumber} isSmallScreen={isSmallScreen} />
)}
{isSearchEnabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
{hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
Expand All @@ -160,7 +189,7 @@ const Nav = memo(
)}
</>
),
[isSearchEnabled, hasAccessToBookmarks, setPageNumber, isSmallScreen, tags, setTags],
[isSearchEnabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
);

return (
Expand Down
10 changes: 3 additions & 7 deletions client/src/components/Nav/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@ import store from '~/store';

type SearchBarProps = {
isSmallScreen?: boolean;
setPageNumber: React.Dispatch<React.SetStateAction<number>>;
};

const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => {
const localize = useLocalize();
const location = useLocation();
const queryClient = useQueryClient();
const { setPageNumber, isSmallScreen } = props;
const { isSmallScreen } = props;

const [text, setText] = useState('');
const [showClearIcon, setShowClearIcon] = useState(false);
Expand All @@ -29,11 +28,10 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
const setIsSearching = useSetRecoilState(store.isSearching);

const clearSearch = useCallback(() => {
setPageNumber(1);
if (location.pathname.includes('/search')) {
newConversation({ disableFocus: true });
}
}, [newConversation, setPageNumber, location.pathname]);
}, [newConversation, location.pathname]);

const clearText = useCallback(() => {
setShowClearIcon(false);
Expand Down Expand Up @@ -80,9 +78,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
)}
>
{
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
}
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
<input
type="text"
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
Expand Down
27 changes: 16 additions & 11 deletions client/src/data-provider/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import type {
TPlugin,
ConversationListResponse,
ConversationListParams,
SearchConversationListResponse,
SearchConversationListParams,
Assistant,
AssistantListParams,
AssistantListResponse,
Expand Down Expand Up @@ -92,19 +94,22 @@ export const useGetConvoIdQuery = (
};

export const useSearchInfiniteQuery = (
params?: ConversationListParams & { searchQuery?: string },
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
params?: SearchConversationListParams,
config?: UseInfiniteQueryOptions<SearchConversationListResponse, unknown>,
) => {
return useInfiniteQuery<ConversationListResponse, unknown>(
[QueryKeys.searchConversations, params], // Include the searchQuery in the query key
({ pageParam = '1' }) =>
dataService.listConversationsByQuery({ ...params, pageNumber: pageParam }),
return useInfiniteQuery<SearchConversationListResponse, unknown>(
[QueryKeys.searchConversations, params],
({ pageParam = null }) =>
dataService
.listConversationsByQuery({
...params,
nextCursor: pageParam,
pageSize: params?.pageSize ?? 20,
search: params?.search ?? '',
})
.then((res) => ({ ...res })) as Promise<SearchConversationListResponse>,
{
getNextPageParam: (lastPage) => {
const currentPageNumber = Number(lastPage.pageNumber);
const totalPages = Number(lastPage.pages);
return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined;
},
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
Expand Down
Loading

0 comments on commit faecfc5

Please sign in to comment.