From 88f4ad7c4799ab5313e48925fbf56a6559479ee4 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 17 Apr 2025 03:07:43 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8D=20refactor:=20Search=20&=20Message?= =?UTF-8?q?=20Retrieval=20(#6903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/lib/utils/reduceHits.js | 59 ---------- api/models/Conversation.js | 2 +- api/server/index.js | 2 +- api/server/routes/index.js | 8 +- api/server/routes/messages.js | 78 ++++++++++++++ api/server/routes/search.js | 89 +-------------- client/src/Providers/SearchContext.tsx | 5 - client/src/Providers/index.ts | 1 - client/src/components/Chat/Input/ChatForm.tsx | 6 +- .../Chat/Messages/SearchButtons.tsx | 59 +++++++--- .../Conversations/Conversations.tsx | 59 +++++++--- .../ConvoOptions/DeleteButton.tsx | 1 + client/src/components/Nav/Nav.tsx | 95 +++++++--------- client/src/components/Nav/NewChat.tsx | 3 +- client/src/components/Nav/SearchBar.tsx | 65 +++++++---- .../General/ArchivedChatsTable.tsx | 1 - client/src/components/ui/DataTable.tsx | 5 +- client/src/data-provider/queries.ts | 69 ++++++------ client/src/hooks/Conversations/index.ts | 2 +- .../Conversations/useNavigateToConvo.tsx | 2 + client/src/hooks/Conversations/useSearch.ts | 102 ------------------ .../hooks/Conversations/useSearchEnabled.ts | 20 ++++ client/src/routes/Root.tsx | 63 +++++------ client/src/routes/Search.tsx | 69 +++++++----- client/src/store/search.ts | 38 +++---- client/src/style.css | 1 - client/src/utils/convos.spec.ts | 5 +- packages/data-provider/src/api-endpoints.ts | 33 +++--- packages/data-provider/src/data-service.ts | 91 +++++++--------- packages/data-provider/src/types/queries.ts | 32 +++--- 30 files changed, 489 insertions(+), 576 deletions(-) delete mode 100644 api/lib/utils/reduceHits.js delete mode 100644 client/src/Providers/SearchContext.tsx delete mode 100644 client/src/hooks/Conversations/useSearch.ts create mode 100644 client/src/hooks/Conversations/useSearchEnabled.ts diff --git a/api/lib/utils/reduceHits.js b/api/lib/utils/reduceHits.js deleted file mode 100644 index 77b2f9d57d..0000000000 --- a/api/lib/utils/reduceHits.js +++ /dev/null @@ -1,59 +0,0 @@ -const mergeSort = require('./mergeSort'); -const { cleanUpPrimaryKeyValue } = require('./misc'); - -function reduceMessages(hits) { - const counts = {}; - - for (const hit of hits) { - if (!counts[hit.conversationId]) { - counts[hit.conversationId] = 1; - } else { - counts[hit.conversationId]++; - } - } - - const result = []; - - for (const [conversationId, count] of Object.entries(counts)) { - result.push({ - conversationId, - count, - }); - } - - return mergeSort(result, (a, b) => b.count - a.count); -} - -function reduceHits(hits, titles = []) { - const counts = {}; - const titleMap = {}; - const convos = [...hits, ...titles]; - - for (const convo of convos) { - const currentId = cleanUpPrimaryKeyValue(convo.conversationId); - if (!counts[currentId]) { - counts[currentId] = 1; - } else { - counts[currentId]++; - } - - if (convo.title) { - // titleMap[currentId] = convo._formatted.title; - titleMap[currentId] = convo.title; - } - } - - const result = []; - - for (const [conversationId, count] of Object.entries(counts)) { - result.push({ - conversationId, - count, - title: titleMap[conversationId] ? titleMap[conversationId] : null, - }); - } - - return mergeSort(result, (a, b) => b.count - a.count); -} - -module.exports = { reduceMessages, reduceHits }; diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 50b826c0dc..51081a6491 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -193,7 +193,7 @@ module.exports = { try { const convos = await Conversation.find(query) .select( - 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec', + 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL', ) .sort({ updatedAt: order === 'asc' ? 1 : -1 }) .limit(limit + 1) diff --git a/api/server/index.js b/api/server/index.js index 4a428789dd..cd0bdd3f88 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -88,8 +88,8 @@ const startServer = async () => { app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/user', routes.user); - app.use('/api/search', routes.search); app.use('/api/ask', routes.ask); + app.use('/api/search', routes.search); app.use('/api/edit', routes.edit); app.use('/api/messages', routes.messages); app.use('/api/convos', routes.convos); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 4b34029c7b..449759383d 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -10,6 +10,7 @@ const balance = require('./balance'); const plugins = require('./plugins'); const bedrock = require('./bedrock'); const actions = require('./actions'); +const banner = require('./banner'); const search = require('./search'); const models = require('./models'); const convos = require('./convos'); @@ -25,7 +26,6 @@ const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); const ask = require('./ask'); -const banner = require('./banner'); module.exports = { ask, @@ -38,13 +38,14 @@ module.exports = { oauth, files, share, + banner, agents, - bedrock, convos, search, - prompts, config, models, + bedrock, + prompts, plugins, actions, presets, @@ -55,5 +56,4 @@ module.exports = { assistants, categories, staticRoute, - banner, }; diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 54c4aab1c2..d5980ae55b 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -10,12 +10,90 @@ const { } = require('~/models'); const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); +const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); +const { getConvosQueried } = require('~/models/Conversation'); const { countTokens } = require('~/server/utils'); +const { Message } = require('~/models/Message'); const { logger } = require('~/config'); const router = express.Router(); router.use(requireJwtAuth); +router.get('/', async (req, res) => { + try { + const user = req.user.id ?? ''; + const { + cursor = null, + sortBy = 'createdAt', + sortDirection = 'desc', + pageSize: pageSizeRaw, + conversationId, + messageId, + search, + } = req.query; + const pageSize = parseInt(pageSizeRaw, 10) || 25; + + let response; + const sortField = ['endpoint', 'createdAt', 'updatedAt'].includes(sortBy) + ? sortBy + : 'createdAt'; + const sortOrder = sortDirection === 'asc' ? 1 : -1; + + if (conversationId && messageId) { + const message = await Message.findOne({ conversationId, messageId, user: user }).lean(); + response = { messages: message ? [message] : [], nextCursor: null }; + } else if (conversationId) { + const filter = { conversationId, user: user }; + if (cursor) { + filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor }; + } + const messages = await Message.find(filter) + .sort({ [sortField]: sortOrder }) + .limit(pageSize + 1) + .lean(); + const nextCursor = messages.length > pageSize ? messages.pop()[sortField] : null; + response = { messages, nextCursor }; + } else if (search) { + const searchResults = await Message.meiliSearch(search, undefined, true); + + const messages = searchResults.hits || []; + + const result = await getConvosQueried(req.user.id, messages, 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 dbMessage = await getMessage({ user, messageId: message.messageId }); + activeMessages.push({ + ...message, + title: convo.title, + conversationId: message.conversationId, + model: convo.model, + isCreatedByUser: dbMessage?.isCreatedByUser, + endpoint: dbMessage?.endpoint, + iconURL: dbMessage?.iconURL, + }); + } + } + + response = { messages: activeMessages, nextCursor: null }; + } else { + response = { messages: [], nextCursor: null }; + } + + res.status(200).json(response); + } catch (error) { + logger.error('Error fetching messages:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + router.post('/artifact/:messageId', async (req, res) => { try { const { messageId } = req.params; diff --git a/api/server/routes/search.js b/api/server/routes/search.js index 109fc7996a..36c68cc2b0 100644 --- a/api/server/routes/search.js +++ b/api/server/routes/search.js @@ -1,14 +1,11 @@ const { Keyv } = require('keyv'); const express = require('express'); const { MeiliSearch } = require('meilisearch'); -const { Conversation, getConvosQueried } = require('~/models/Conversation'); +const { Conversation } = require('~/models/Conversation'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); -const { Message, getMessage } = require('~/models/Message'); -const { reduceHits } = require('~/lib/utils/reduceHits'); +const { Message } = require('~/models/Message'); const { isEnabled } = require('~/server/utils'); const keyvRedis = require('~/cache/keyvRedis'); -const { logger } = require('~/config'); const router = express.Router(); @@ -25,88 +22,6 @@ router.get('/sync', async function (req, res) { res.send('synced'); }); -router.get('/', async function (req, res) { - try { - 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); - return res.status(200).send(cached); - } - - 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, 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 dbMessage = await getMessage({ user, messageId: message.messageId }); - activeMessages.push({ - ...message, - title: convo.title, - conversationId: message.conversationId, - model: convo.model, - isCreatedByUser: dbMessage?.isCreatedByUser, - endpoint: dbMessage?.endpoint, - iconURL: dbMessage?.iconURL, - }); - } - } - - const activeConversations = []; - for (const convId in result.convoMap) { - const convo = result.convoMap[convId]; - - if (convo.isArchived) { - continue; - } - - activeConversations.push({ - title: convo.title, - user: convo.user, - conversationId: convo.conversationId, - endpoint: convo.endpoint, - endpointType: convo.endpointType, - model: convo.model, - createdAt: convo.createdAt, - updatedAt: convo.updatedAt, - }); - } - - if (result.cache) { - result.cache.messages = activeMessages; - result.cache.conversations = activeConversations; - cache.set(key, result.cache, expiration); - } - - 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' }); - } -}); - router.get('/test', async function (req, res) { const { q } = req.query; const messages = ( diff --git a/client/src/Providers/SearchContext.tsx b/client/src/Providers/SearchContext.tsx deleted file mode 100644 index b767be2ce1..0000000000 --- a/client/src/Providers/SearchContext.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext, useContext } from 'react'; -import { UseSearchMessagesResult } from '~/hooks/Conversations/useSearch'; - -export const SearchContext = createContext({} as UseSearchMessagesResult); -export const useSearchContext = () => useContext(SearchContext); diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 7363c97d41..43da0d346b 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -4,7 +4,6 @@ export { default as AgentsProvider } from './AgentsContext'; export * from './ChatContext'; export * from './ShareContext'; export * from './ToastContext'; -export * from './SearchContext'; export * from './FileMapContext'; export * from './AddedChatContext'; export * from './EditorContext'; diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index e2adbbd0fa..1aa72a0dbd 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -43,7 +43,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const [isTextAreaFocused, setIsTextAreaFocused] = useState(false); const [backupBadges, setBackupBadges] = useState[]>([]); - const isSearching = useRecoilValue(store.isSearching); + const search = useRecoilValue(store.search); const SpeechToText = useRecoilValue(store.speechToText); const TextToSpeech = useRecoilValue(store.textToSpeech); const chatDirection = useRecoilValue(store.chatDirection); @@ -151,10 +151,10 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const textValue = useWatch({ control: methods.control, name: 'text' }); useEffect(() => { - if (!isSearching && textAreaRef.current && !disableInputs) { + if (!search.isSearching && textAreaRef.current && !disableInputs) { textAreaRef.current.focus(); } - }, [isSearching, disableInputs]); + }, [search.isSearching, disableInputs]); useEffect(() => { if (textAreaRef.current) { diff --git a/client/src/components/Chat/Messages/SearchButtons.tsx b/client/src/components/Chat/Messages/SearchButtons.tsx index 9707767ad6..a8abb3afc7 100644 --- a/client/src/components/Chat/Messages/SearchButtons.tsx +++ b/client/src/components/Chat/Messages/SearchButtons.tsx @@ -1,34 +1,63 @@ import { Link } from 'lucide-react'; -import type { TMessage } from 'librechat-data-provider'; +import { useRecoilValue } from 'recoil'; +import { QueryKeys } from 'librechat-data-provider'; +import { useQueryClient } from '@tanstack/react-query'; +import type { TMessage, TConversation } from 'librechat-data-provider'; +import type { InfiniteData } from '@tanstack/react-query'; +import type { ConversationCursorData } from '~/utils'; import { useLocalize, useNavigateToConvo } from '~/hooks'; import { findConversationInInfinite } from '~/utils'; -import { useSearchContext } from '~/Providers'; +import store from '~/store'; export default function SearchButtons({ message }: { message: TMessage }) { const localize = useLocalize(); - const { searchQueryRes } = useSearchContext(); + const queryClient = useQueryClient(); + const search = useRecoilValue(store.search); const { navigateWithLastTools } = useNavigateToConvo(); const conversationId = message.conversationId ?? ''; + const clickHandler = async (event: React.MouseEvent) => { + event.preventDefault(); + if (!conversationId) { + return; + } + + let title = message.title ?? ''; + let cachedConvo = queryClient.getQueryData([ + QueryKeys.conversation, + conversationId, + ]); + const convos = queryClient.getQueryData>([ + QueryKeys.allConversations, + { search: search.debouncedQuery }, + ]); + if (!cachedConvo && convos) { + cachedConvo = findConversationInInfinite(convos, conversationId); + } + if (!title) { + title = cachedConvo?.title ?? ''; + } + + document.title = title; + navigateWithLastTools( + cachedConvo ?? + ({ + conversationId, + title, + } as TConversation), + true, + true, + ); + }; + if (!conversationId) { return null; } - const clickHandler = (event: React.MouseEvent) => { - event.preventDefault(); - - const conversation = findConversationInInfinite(searchQueryRes?.data, conversationId); - if (!conversation) { - return; - } - - document.title = message.title ?? ''; - navigateWithLastTools(conversation, true, true); - }; - return (
)} - {isFetchingNextPage && !isSearchLoading && ( -
- -
- )} ); }; diff --git a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx index ec1a387d8d..26810f40b0 100644 --- a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx @@ -74,6 +74,7 @@ export function DeleteConversationDialog({ {localize('com_ui_delete_conversation')} diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index 33d8d55065..89fa3de027 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -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(null); const listRef = useRef(null); - const { moveToTop } = useNavScrolling< - ConversationListResponse | SearchConversationListResponse - >({ + const { moveToTop } = useNavScrolling({ 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, ); }, - 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 && } + {search.enabled === true && } {hasAccessToBookmarks && ( <>
@@ -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} />
diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index 6cf88605bb..a891fe9386 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -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) { diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index 018d3d1ea1..9e21b52663 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -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) = 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) => { const { value } = e.target as HTMLInputElement; @@ -50,34 +55,48 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = 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) => { 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 (
) = 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" /> diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx index 535548ea5e..9071593b9a 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx @@ -272,7 +272,6 @@ export default function ArchivedChatsTable({ isFetchingNextPage={isFetchingNextPage} isLoading={isLoading} showCheckboxes={false} - manualSorting={true} // Ensures server-side sorting /> diff --git a/client/src/components/ui/DataTable.tsx b/client/src/components/ui/DataTable.tsx index 7fda04d1d5..a69f3d253f 100644 --- a/client/src/components/ui/DataTable.tsx +++ b/client/src/components/ui/DataTable.tsx @@ -216,8 +216,8 @@ export default function DataTable({ const isSmallScreen = useMediaQuery('(max-width: 768px)'); const tableContainerRef = useRef(null); + const search = useRecoilValue(store.search); const [isDeleting, setIsDeleting] = useState(false); - const isSearchEnabled = useRecoilValue(store.isSearchEnabled); const [rowSelection, setRowSelection] = useState>({}); const [sorting, setSorting] = useState(defaultSort); const [columnFilters, setColumnFilters] = useState([]); @@ -331,7 +331,6 @@ export default function DataTable({ const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original); await onDelete(itemsToDelete); setRowSelection({}); - // await fetchNextPage?.({ pageParam: lastPage?.nextCursor }); } finally { setIsDeleting(false); } @@ -375,7 +374,7 @@ export default function DataTable({ localize={localize} /> )} - {filterColumn !== undefined && table.getColumn(filterColumn) && isSearchEnabled && ( + {filterColumn !== undefined && table.getColumn(filterColumn) && search.enabled && (
, @@ -68,9 +68,9 @@ export const useGetConvoIdQuery = ( [QueryKeys.conversation, id], () => { // Try to find in all fetched infinite pages - const convosQuery = queryClient.getQueryData< - InfiniteData - >([QueryKeys.allConversations]); + const convosQuery = queryClient.getQueryData>([ + QueryKeys.allConversations, + ]); const found = convosQuery?.pages .flatMap((page) => page.conversations) .find((c) => c.conversationId === id); @@ -90,30 +90,6 @@ export const useGetConvoIdQuery = ( ); }; -export const useSearchInfiniteQuery = ( - params?: SearchConversationListParams, - config?: UseInfiniteQueryOptions, -) => { - return useInfiniteQuery( - [QueryKeys.searchConversations, params], - ({ pageParam = null }) => - dataService - .listConversations({ - ...params, - search: params?.search ?? '', - cursor: pageParam?.toString(), - }) - .then((res) => ({ ...res })) as Promise, - { - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - ...config, - }, - ); -}; - export const useConversationsInfiniteQuery = ( params: ConversationListParams, config?: UseInfiniteQueryOptions, @@ -134,7 +110,36 @@ export const useConversationsInfiniteQuery = ( search, cursor: pageParam?.toString(), }), - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined, + keepPreviousData: true, + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 30 * 60 * 1000, // 30 minutes + ...config, + }); +}; + +export const useMessagesInfiniteQuery = ( + params: MessagesListParams, + config?: UseInfiniteQueryOptions, +) => { + const { sortBy, sortDirection, pageSize, conversationId, messageId, search } = params; + + return useInfiniteQuery({ + queryKey: [ + QueryKeys.messages, + { sortBy, sortDirection, pageSize, conversationId, messageId, search }, + ], + queryFn: ({ pageParam }) => + dataService.listMessages({ + sortBy, + sortDirection, + pageSize, + conversationId, + messageId, + search, + cursor: pageParam?.toString(), + }), + getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined, keepPreviousData: true, staleTime: 5 * 60 * 1000, // 5 minutes cacheTime: 30 * 60 * 1000, // 30 minutes @@ -159,7 +164,7 @@ export const useSharedLinksQuery = ( sortBy, sortDirection, }), - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined, keepPreviousData: true, staleTime: 5 * 60 * 1000, // 5 minutes cacheTime: 30 * 60 * 1000, // 30 minutes diff --git a/client/src/hooks/Conversations/index.ts b/client/src/hooks/Conversations/index.ts index 8eb0709997..6c35ad5da9 100644 --- a/client/src/hooks/Conversations/index.ts +++ b/client/src/hooks/Conversations/index.ts @@ -1,7 +1,7 @@ -export { default as useSearch } from './useSearch'; export { default as usePresets } from './usePresets'; export { default as useGetSender } from './useGetSender'; export { default as useDefaultConvo } from './useDefaultConvo'; +export { default as useSearchEnabled } from './useSearchEnabled'; export { default as useGenerateConvo } from './useGenerateConvo'; export { default as useDebouncedInput } from './useDebouncedInput'; export { default as useBookmarkSuccess } from './useBookmarkSuccess'; diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index 88c6eaeda7..1a990e642f 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -29,6 +29,7 @@ const useNavigateToConvo = (index = 0) => { dataService.getConversationById(conversationId), ); logger.log('conversation', 'Fetched fresh conversation data', data); + await queryClient.invalidateQueries([QueryKeys.messages, conversationId]); setConversation(data); } catch (error) { console.error('Error fetching conversation data on navigation', error); @@ -38,6 +39,7 @@ const useNavigateToConvo = (index = 0) => { const navigateToConvo = ( conversation?: TConversation | null, _resetLatestMessage = true, + /** Likely need to remove this since it happens after fetching conversation data */ invalidateMessages = false, ) => { if (!conversation) { diff --git a/client/src/hooks/Conversations/useSearch.ts b/client/src/hooks/Conversations/useSearch.ts deleted file mode 100644 index 0d74e5dfd0..0000000000 --- a/client/src/hooks/Conversations/useSearch.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect, useCallback, useState } from 'react'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { useNavigate, useLocation } from 'react-router-dom'; -import type { UseInfiniteQueryResult } from '@tanstack/react-query'; -import type { SearchConversationListResponse } from 'librechat-data-provider'; -import { useSearchInfiniteQuery, useGetSearchEnabledQuery } from '~/data-provider'; -import useNewConvo from '~/hooks/useNewConvo'; -import store from '~/store'; - -export interface UseSearchMessagesResult { - searchQuery: string; - searchQueryRes: UseInfiniteQueryResult | undefined; -} - -export default function useSearchMessages({ - isAuthenticated, -}: { - isAuthenticated: boolean; -}): UseSearchMessagesResult { - const navigate = useNavigate(); - const location = useLocation(); - const { switchToConversation } = useNewConvo(); - const searchPlaceholderConversation = useCallback(() => { - switchToConversation({ - conversationId: 'search', - title: 'Search', - endpoint: null, - createdAt: '', - updatedAt: '', - }); - }, [switchToConversation]); - - const searchQuery = useRecoilValue(store.searchQuery); - const setIsSearchEnabled = useSetRecoilState(store.isSearchEnabled); - - const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedSearchQuery(searchQuery); - }, 350); // 350ms debounce - return () => clearTimeout(handler); - }, [searchQuery]); - - const searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated }); - const searchQueryRes = useSearchInfiniteQuery( - { nextCursor: null, search: debouncedSearchQuery, pageSize: 20 }, - { enabled: isAuthenticated && !!debouncedSearchQuery }, - ) as UseInfiniteQueryResult | undefined; - - useEffect(() => { - if (searchQuery && searchQuery.length > 0) { - navigate('/search', { replace: true }); - return; - } - - if (location.pathname && location.pathname.includes('/c/')) { - return; - } - navigate('/c/new', { replace: true }); - /* Disabled eslint rule because we don't want to run this effect when location changes */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [navigate, searchQuery]); - - useEffect(() => { - if (searchEnabledQuery.data === true) { - setIsSearchEnabled(searchEnabledQuery.data); - } else if (searchEnabledQuery.isError) { - console.error('Failed to get search enabled', searchEnabledQuery.error); - } - }, [ - searchEnabledQuery.data, - searchEnabledQuery.error, - searchEnabledQuery.isError, - setIsSearchEnabled, - ]); - - const onSearchSuccess = useCallback( - () => searchPlaceholderConversation(), - [searchPlaceholderConversation], - ); - - useEffect(() => { - // we use isInitialLoading here instead of isLoading because query is disabled by default - if (searchQueryRes?.data) { - onSearchSuccess(); - } - }, [searchQueryRes?.data, searchQueryRes?.isInitialLoading, onSearchSuccess]); - - const setIsSearchTyping = useSetRecoilState(store.isSearchTyping); - - useEffect(() => { - if (!searchQueryRes?.isLoading && !searchQueryRes?.isFetching) { - setIsSearchTyping(false); - } - }, [searchQueryRes?.isLoading, searchQueryRes?.isFetching, setIsSearchTyping]); - - return { - searchQuery, - searchQueryRes, - }; -} diff --git a/client/src/hooks/Conversations/useSearchEnabled.ts b/client/src/hooks/Conversations/useSearchEnabled.ts new file mode 100644 index 0000000000..d643833426 --- /dev/null +++ b/client/src/hooks/Conversations/useSearchEnabled.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { useGetSearchEnabledQuery } from '~/data-provider'; +import { logger } from '~/utils'; +import store from '~/store'; + +export default function useSearchEnabled(isAuthenticated: boolean) { + const setSearch = useSetRecoilState(store.search); + const searchEnabledQuery = useGetSearchEnabledQuery({ enabled: isAuthenticated }); + + useEffect(() => { + if (searchEnabledQuery.data === true) { + setSearch((prev) => ({ ...prev, enabled: searchEnabledQuery.data })); + } else if (searchEnabledQuery.isError) { + logger.error('Failed to get search enabled: ', searchEnabledQuery.error); + } + }, [searchEnabledQuery.data, searchEnabledQuery.error, searchEnabledQuery.isError, setSearch]); + + return searchEnabledQuery; +} diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index da02b7c4c2..94e46a4704 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -1,14 +1,19 @@ import React, { useState, useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import type { ContextType } from '~/common'; +import { + useAuthContext, + useAssistantsMap, + useAgentsMap, + useFileMap, + useSearchEnabled, +} from '~/hooks'; import { AgentsMapContext, AssistantsMapContext, FileMapContext, - SearchContext, SetConvoProvider, } from '~/Providers'; -import { useAuthContext, useAssistantsMap, useAgentsMap, useFileMap, useSearch } from '~/hooks'; import TermsAndConditionsModal from '~/components/ui/TermsAndConditionsModal'; import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider'; import { Nav, MobileNav } from '~/components/Nav'; @@ -26,13 +31,14 @@ export default function Root() { const assistantsMap = useAssistantsMap({ isAuthenticated }); const agentsMap = useAgentsMap({ isAuthenticated }); const fileMap = useFileMap({ isAuthenticated }); - const search = useSearch({ isAuthenticated }); const { data: config } = useGetStartupConfig(); const { data: termsData } = useUserTermsQuery({ enabled: isAuthenticated && config?.interface?.termsOfService?.modalAcceptance === true, }); + useSearchEnabled(isAuthenticated); + useEffect(() => { if (termsData) { setShowTerms(!termsData.termsAccepted); @@ -43,7 +49,6 @@ export default function Root() { setShowTerms(false); }; - // Pass the desired redirect parameter to logout const handleDeclineTerms = () => { setShowTerms(false); logout('/login?redirect=false'); @@ -55,34 +60,32 @@ export default function Root() { return ( - - - - - -
-
-