diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 03aaf847031..7f759e5f33b 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -27,6 +27,10 @@ const tokenConfig = isEnabled(USE_REDIS) // ttl: 30 minutes ? new Keyv({ store: keyvRedis, ttl: 1800000 }) : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: 1800000 }); +const genTitle = isEnabled(USE_REDIS) // ttl: 2 minutes + ? new Keyv({ store: keyvRedis, ttl: 120000 }) + : new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: 120000 }); + const namespaces = { [CacheKeys.CONFIG_STORE]: config, pending_req, @@ -39,6 +43,7 @@ const namespaces = { registrations: createViolationInstance('registrations'), logins: createViolationInstance('logins'), [CacheKeys.TOKEN_CONFIG]: tokenConfig, + [CacheKeys.GEN_TITLE]: genTitle, }; /** diff --git a/api/models/Conversation.js b/api/models/Conversation.js index f1aa7bfe718..1ef47241cac 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -30,12 +30,12 @@ module.exports = { return { message: 'Error saving conversation' }; } }, - getConvosByPage: async (user, pageNumber = 1, pageSize = 14) => { + getConvosByPage: async (user, pageNumber = 1, pageSize = 25) => { try { const totalConvos = (await Conversation.countDocuments({ user })) || 1; const totalPages = Math.ceil(totalConvos / pageSize); const convos = await Conversation.find({ user }) - .sort({ createdAt: -1 }) + .sort({ updatedAt: -1 }) .skip((pageNumber - 1) * pageSize) .limit(pageSize) .lean(); @@ -45,7 +45,7 @@ module.exports = { return { message: 'Error getting conversations' }; } }, - getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 14) => { + getConvosQueried: async (user, convoIds, pageNumber = 1, pageSize = 25) => { try { if (!convoIds || convoIds.length === 0) { return { conversations: [], pages: 1, pageNumber, pageSize }; diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index a410b6fd584..6213cfd2c78 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -1,6 +1,9 @@ const express = require('express'); +const { CacheKeys } = require('librechat-data-provider'); const { getConvosByPage, deleteConvos } = require('~/models/Conversation'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const { sleep } = require('~/server/services/AssistantService'); +const getLogStores = require('~/cache/getLogStores'); const { getConvo, saveConvo } = require('~/models'); const { logger } = require('~/config'); @@ -29,6 +32,29 @@ router.get('/:conversationId', async (req, res) => { } }); +router.post('/gen_title', async (req, res) => { + const { conversationId } = req.body; + const titleCache = getLogStores(CacheKeys.GEN_TITLE); + const key = `${req.user.id}-${conversationId}`; + let title = await titleCache.get(key); + + if (!title) { + await sleep(2500); + title = await titleCache.get(key); + } + + if (title) { + await titleCache.delete(key); + res.status(200).json({ title }); + } else { + res + .status(404) + .json({ + message: 'Title not found or method not implemented for the conversation\'s endpoint', + }); + } +}); + router.post('/clear', async (req, res) => { let filter = {}; const { conversationId, source } = req.body.arg; diff --git a/api/server/services/AssistantService.js b/api/server/services/AssistantService.js index 4b929193481..b6eabb75392 100644 --- a/api/server/services/AssistantService.js +++ b/api/server/services/AssistantService.js @@ -357,5 +357,6 @@ module.exports = { waitForRun, getResponse, handleRun, + sleep, mapMessagesToSteps, }; diff --git a/api/server/services/Endpoints/openAI/addTitle.js b/api/server/services/Endpoints/openAI/addTitle.js index ab15443f942..9bb0ec3487e 100644 --- a/api/server/services/Endpoints/openAI/addTitle.js +++ b/api/server/services/Endpoints/openAI/addTitle.js @@ -1,5 +1,7 @@ -const { saveConvo } = require('~/models'); +const { CacheKeys } = require('librechat-data-provider'); +const getLogStores = require('~/cache/getLogStores'); const { isEnabled } = require('~/server/utils'); +const { saveConvo } = require('~/models'); const addTitle = async (req, { text, response, client }) => { const { TITLE_CONVO = 'true' } = process.env ?? {}; @@ -16,7 +18,11 @@ const addTitle = async (req, { text, response, client }) => { return; } + const titleCache = getLogStores(CacheKeys.GEN_TITLE); + const key = `${req.user.id}-${response.conversationId}`; + const title = await client.titleConvo({ text, responseText: response?.text }); + await titleCache.set(key, title); await saveConvo(req.user.id, { conversationId: response.conversationId, title, diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 0f19db956de..a6e790558be 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -1,6 +1,6 @@ import { useOutletContext } from 'react-router-dom'; import type { ContextType } from '~/common'; -import { EndpointsMenu, PresetsMenu, NewChat } from './Menus'; +import { EndpointsMenu, PresetsMenu, HeaderNewChat } from './Menus'; import HeaderOptions from './Input/HeaderOptions'; export default function Header() { @@ -8,7 +8,7 @@ export default function Header() { return (
- {!navVisible && } + {!navVisible && } diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index 1cc5df08e26..d495ce3058b 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -7,8 +7,9 @@ import { getEndpointField } from '~/utils'; import { useLocalize } from '~/hooks'; export default function Landing({ Header }: { Header?: ReactNode }) { - const { data: endpointsConfig } = useGetEndpointsQuery(); const { conversation } = useChatContext(); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const localize = useLocalize(); let { endpoint } = conversation ?? {}; if ( diff --git a/client/src/components/Chat/Menus/HeaderNewChat.tsx b/client/src/components/Chat/Menus/HeaderNewChat.tsx new file mode 100644 index 00000000000..5ccf4f35bc5 --- /dev/null +++ b/client/src/components/Chat/Menus/HeaderNewChat.tsx @@ -0,0 +1,23 @@ +import { NewChatIcon } from '~/components/svg'; +import { useChatContext } from '~/Providers'; +import { useMediaQuery } from '~/hooks'; + +export default function HeaderNewChat() { + const { newConversation } = useChatContext(); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); + if (isSmallScreen) { + return null; + } + return ( + + ); +} diff --git a/client/src/components/Chat/Menus/NewChat.tsx b/client/src/components/Chat/Menus/NewChat.tsx deleted file mode 100644 index 4035714f9cd..00000000000 --- a/client/src/components/Chat/Menus/NewChat.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useChatContext } from '~/Providers'; -import { useMediaQuery } from '~/hooks'; - -export default function NewChat() { - const { newConversation } = useChatContext(); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); - if (isSmallScreen) { - return null; - } - return ( - - ); -} diff --git a/client/src/components/Chat/Menus/index.ts b/client/src/components/Chat/Menus/index.ts index 26913eb9644..d505f765101 100644 --- a/client/src/components/Chat/Menus/index.ts +++ b/client/src/components/Chat/Menus/index.ts @@ -1,3 +1,3 @@ export { default as EndpointsMenu } from './EndpointsMenu'; export { default as PresetsMenu } from './PresetsMenu'; -export { default as NewChat } from './NewChat'; +export { default as HeaderNewChat } from './HeaderNewChat'; diff --git a/client/src/components/Chat/SearchView.tsx b/client/src/components/Chat/SearchView.tsx new file mode 100644 index 00000000000..5feed132ccd --- /dev/null +++ b/client/src/components/Chat/SearchView.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react'; +import { useRecoilValue } from 'recoil'; +import MessagesView from './Messages/MessagesView'; +import store from '~/store'; + +import Header from './Header'; + +function SearchView() { + const searchResultMessagesTree = useRecoilValue(store.searchResultMessagesTree); + + return ( +
+
+
+ } /> +
+
+
+ ); +} + +export default memo(SearchView); diff --git a/client/src/components/Conversations/Conversation.jsx b/client/src/components/Conversations/Conversation.jsx index 375edddd32b..1dc9bf4e1a5 100644 --- a/client/src/components/Conversations/Conversation.jsx +++ b/client/src/components/Conversations/Conversation.jsx @@ -1,6 +1,6 @@ import { useState, useRef } from 'react'; import { useRecoilState, useSetRecoilState } from 'recoil'; -import { useUpdateConversationMutation } from 'librechat-data-provider/react-query'; +import { useUpdateConversationMutation } from '~/data-provider'; import { useConversations, useConversation } from '~/hooks'; import { MinimalIcon } from '~/components/Endpoints'; import { NotificationSeverity } from '~/common'; diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 5ce4f1f8b9e..4c46a646872 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,7 +1,10 @@ -import Convo from './Convo'; -import Conversation from './Conversation'; +import { useMemo } from 'react'; +import { parseISO, isToday } from 'date-fns'; import { useLocation } from 'react-router-dom'; import { TConversation } from 'librechat-data-provider'; +import { groupConversationsByDate } from '~/utils'; +import Conversation from './Conversation'; +import Convo from './Convo'; export default function Conversations({ conversations, @@ -15,22 +18,50 @@ export default function Conversations({ const location = useLocation(); const { pathname } = location; const ConvoItem = pathname.includes('chat') ? Conversation : Convo; + const groupedConversations = useMemo( + () => groupConversationsByDate(conversations), + [conversations], + ); + const firstTodayConvoId = conversations.find((convo) => + isToday(parseISO(convo.updatedAt)), + )?.conversationId; return ( - <> - {conversations && - conversations.length > 0 && - conversations.map((convo: TConversation, i) => { - return ( - - ); - })} - +
+
+ + {groupedConversations.map(([groupName, convos]) => ( +
+
+ {groupName} +
+ {convos.map((convo, i) => ( + + ))} +
+
+ ))} + +
+
); } diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index adf30e2851d..4bf23fa9013 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -1,13 +1,11 @@ import { useRecoilValue } from 'recoil'; import { useState, useRef } from 'react'; import { useParams } from 'react-router-dom'; -import { - useGetEndpointsQuery, - useUpdateConversationMutation, -} from 'librechat-data-provider/react-query'; import { EModelEndpoint } from 'librechat-data-provider'; +import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react'; import { useConversations, useNavigateToConvo } from '~/hooks'; +import { useUpdateConversationMutation } from '~/data-provider'; import { MinimalIcon } from '~/components/Endpoints'; import { NotificationSeverity } from '~/common'; import { useToastContext } from '~/Providers'; @@ -18,7 +16,7 @@ import store from '~/store'; type KeyEvent = KeyboardEvent; -export default function Conversation({ conversation, retainView, toggleNav, i }) { +export default function Conversation({ conversation, retainView, toggleNav, isLatestConvo }) { const { conversationId: currentConvoId } = useParams(); const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); @@ -32,7 +30,13 @@ export default function Conversation({ conversation, retainView, toggleNav, i }) const [titleInput, setTitleInput] = useState(title); const [renaming, setRenaming] = useState(false); - const clickHandler = async () => { + const clickHandler = async (event: React.MouseEvent) => { + if (event.button === 0 && event.ctrlKey) { + toggleNav(); + return; + } + + event.preventDefault(); if (currentConvoId === conversationId) { return; } @@ -109,22 +113,28 @@ export default function Conversation({ conversation, retainView, toggleNav, i }) const aProps = { className: - 'animate-flash group relative flex cursor-pointer items-center gap-3 break-all rounded-md bg-gray-900 py-3 px-3 pr-14 hover:bg-gray-900', + 'group relative rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-3 break-all rounded-lg bg-gray-800 py-2 px-2', }; const activeConvo = currentConvoId === conversationId || - (i === 0 && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new'); + (isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new'); if (!activeConvo) { aProps.className = - 'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-900 hover:pr-4'; + 'group relative rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-3 break-all rounded-lg py-2 px-2 hover:bg-gray-900'; } return ( - clickHandler()} {...aProps} title={title}> + {icon} -
+
{renaming === true ? ( + {activeConvo ? ( +
+ ) : ( +
+ )} {activeConvo ? (
@@ -150,7 +165,7 @@ export default function Conversation({ conversation, retainView, toggleNav, i }) />
) : ( -
+
)} ); diff --git a/client/src/components/Conversations/DeleteButton.tsx b/client/src/components/Conversations/DeleteButton.tsx index 75e2bfc5bda..7b54e587bcd 100644 --- a/client/src/components/Conversations/DeleteButton.tsx +++ b/client/src/components/Conversations/DeleteButton.tsx @@ -1,6 +1,6 @@ import { useParams } from 'react-router-dom'; -import { useDeleteConversationMutation } from 'librechat-data-provider/react-query'; import { useLocalize, useConversations, useConversation } from '~/hooks'; +import { useDeleteConversationMutation } from '~/data-provider'; import { Dialog, DialogTrigger, Label } from '~/components/ui'; import DialogTemplate from '~/components/ui/DialogTemplate'; import { TrashIcon, CrossIcon } from '~/components/svg'; diff --git a/client/src/components/Conversations/NewDeleteButton.tsx b/client/src/components/Conversations/NewDeleteButton.tsx index 337bc8fb615..7710a77b931 100644 --- a/client/src/components/Conversations/NewDeleteButton.tsx +++ b/client/src/components/Conversations/NewDeleteButton.tsx @@ -1,6 +1,6 @@ import { useParams } from 'react-router-dom'; -import { useDeleteConversationMutation } from 'librechat-data-provider/react-query'; import { useLocalize, useConversations, useNewConvo } from '~/hooks'; +import { useDeleteConversationMutation } from '~/data-provider'; import { Dialog, DialogTrigger, Label } from '~/components/ui'; import DialogTemplate from '~/components/ui/DialogTemplate'; import { TrashIcon, CrossIcon } from '~/components/svg'; diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index 86a3e335971..dae0f54667c 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -1,16 +1,18 @@ -import { useSearchQuery, useGetConversationsQuery } from 'librechat-data-provider/react-query'; +import { useParams } from 'react-router-dom'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { TConversation, TSearchResults } from 'librechat-data-provider'; +import { useCallback, useEffect, useState, useMemo } from 'react'; +import type { ConversationListResponse } from 'librechat-data-provider'; import { - useAuthContext, useMediaQuery, + useAuthContext, useConversation, - useConversations, useLocalStorage, + useNavScrolling, + useConversations, } from '~/hooks'; +import { useSearchInfiniteQuery, useConversationsInfiniteQuery } from '~/data-provider'; import { TooltipProvider, Tooltip } from '~/components/ui'; -import { Conversations, Pages } from '../Conversations'; +import { Conversations } from '~/components/Conversations'; import { Spinner } from '~/components/svg'; import SearchBar from './SearchBar'; import NavToggle from './NavToggle'; @@ -20,14 +22,14 @@ import { cn } from '~/utils'; import store from '~/store'; export default function Nav({ navVisible, setNavVisible }) { - const [isToggleHovering, setIsToggleHovering] = useState(false); - const [isHovering, setIsHovering] = useState(false); - const [navWidth, setNavWidth] = useState('260px'); + const { conversationId } = useParams(); const { isAuthenticated } = useAuthContext(); - const containerRef = useRef(null); - const scrollPositionRef = useRef(null); + + const [navWidth, setNavWidth] = useState('260px'); + const [isHovering, setIsHovering] = useState(false); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const [newUser, setNewUser] = useLocalStorage('newUser', true); + const [isToggleHovering, setIsToggleHovering] = useState(false); useEffect(() => { if (isSmallScreen) { @@ -37,44 +39,42 @@ export default function Nav({ navVisible, setNavVisible }) { } }, [isSmallScreen]); - const [conversations, setConversations] = useState([]); - // current page const [pageNumber, setPageNumber] = useState(1); - // total pages - const [pages, setPages] = useState(1); - - // data provider - const getConversationsQuery = useGetConversationsQuery(pageNumber + '', { - enabled: isAuthenticated, - }); + const [showLoading, setShowLoading] = useState(false); - // search const searchQuery = useRecoilValue(store.searchQuery); const isSearchEnabled = useRecoilValue(store.isSearchEnabled); - const isSearching = useRecoilValue(store.isSearching); const { newConversation, searchPlaceholderConversation } = useConversation(); - // current conversation - const conversation = useRecoilValue(store.conversation); - const { conversationId } = conversation || {}; - const setSearchResultMessages = useSetRecoilState(store.searchResultMessages); - const refreshConversationsHint = useRecoilValue(store.refreshConversationsHint); const { refreshConversations } = useConversations(); + const setSearchResultMessages = useSetRecoilState(store.searchResultMessages); - const [isFetching, setIsFetching] = useState(false); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery( + { pageNumber: pageNumber.toString() }, + { enabled: isAuthenticated }, + ); + + const searchQueryRes = useSearchInfiniteQuery( + { pageNumber: pageNumber.toString(), searchQuery: searchQuery }, + { enabled: isAuthenticated && !!searchQuery.length }, + ); - const searchQueryFn = useSearchQuery(searchQuery, pageNumber + '', { - enabled: !!(!!searchQuery && searchQuery.length > 0 && isSearchEnabled && isSearching), + const { containerRef, moveToTop } = useNavScrolling({ + setShowLoading, + hasNextPage: searchQuery ? searchQueryRes.hasNextPage : hasNextPage, + fetchNextPage: searchQuery ? searchQueryRes.fetchNextPage : fetchNextPage, + isFetchingNextPage: searchQuery ? searchQueryRes.isFetchingNextPage : isFetchingNextPage, }); - const onSearchSuccess = useCallback((data: TSearchResults, expectedPage?: number) => { + const conversations = useMemo( + () => + (searchQuery ? searchQueryRes?.data : data)?.pages.flatMap((page) => page.conversations) || + [], + [data, searchQuery, searchQueryRes?.data], + ); + + const onSearchSuccess = useCallback(({ data }: { data: ConversationListResponse }) => { const res = data; - setConversations(res.conversations); - if (expectedPage) { - setPageNumber(expectedPage); - } - setPages(Number(res.pages)); - setIsFetching(false); searchPlaceholderConversation(); setSearchResultMessages(res.messages); /* disabled due recoil methods not recognized as state setters */ @@ -83,12 +83,10 @@ export default function Nav({ navVisible, setNavVisible }) { useEffect(() => { //we use isInitialLoading here instead of isLoading because query is disabled by default - if (searchQueryFn.isInitialLoading) { - setIsFetching(true); - } else if (searchQueryFn.data) { - onSearchSuccess(searchQueryFn.data); + if (searchQueryRes.data) { + onSearchSuccess({ data: searchQueryRes.data.pages[0] }); } - }, [searchQueryFn.data, searchQueryFn.isInitialLoading, onSearchSuccess]); + }, [searchQueryRes.data, searchQueryRes.isInitialLoading, onSearchSuccess]); const clearSearch = () => { setPageNumber(1); @@ -98,51 +96,6 @@ export default function Nav({ navVisible, setNavVisible }) { } }; - const moveToTop = useCallback(() => { - const container = containerRef.current; - if (container) { - scrollPositionRef.current = container.scrollTop; - } - }, [containerRef, scrollPositionRef]); - - const nextPage = async () => { - moveToTop(); - setPageNumber(pageNumber + 1); - }; - - const previousPage = async () => { - moveToTop(); - setPageNumber(pageNumber - 1); - }; - - useEffect(() => { - if (getConversationsQuery.data) { - if (isSearching) { - return; - } - let { conversations, pages } = getConversationsQuery.data; - pages = Number(pages); - if (pageNumber > pages) { - setPageNumber(pages); - } else { - if (!isSearching) { - conversations = conversations.sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - } - setConversations(conversations); - setPages(pages); - } - } - }, [getConversationsQuery.isSuccess, getConversationsQuery.data, isSearching, pageNumber]); - - useEffect(() => { - if (!isSearching) { - getConversationsQuery.refetch(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageNumber, conversationId, refreshConversationsHint]); - const toggleNavVisible = () => { setNavVisible((prev: boolean) => !prev); if (newUser) { @@ -156,11 +109,6 @@ export default function Nav({ navVisible, setNavVisible }) { } }; - const containerClasses = - getConversationsQuery.isLoading && pageNumber === 1 - ? 'flex flex-col gap-2 text-gray-100 text-sm h-full justify-center items-center' - : 'flex flex-col gap-2 text-gray-100 text-sm'; - return ( @@ -178,44 +126,44 @@ export default function Nav({ navVisible, setNavVisible }) {
- + + +
diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index b0ebb195dce..75a5facf08d 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -1,11 +1,36 @@ -import { useLocalize, useConversation, useNewConvo, useOriginNavigate } from '~/hooks'; +import { EModelEndpoint } from 'librechat-data-provider'; +import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; +import { + useLocalize, + useConversation, + useNewConvo, + useOriginNavigate, + useLocalStorage, +} from '~/hooks'; +import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; +import { NewChatIcon } from '~/components/svg'; +import { getEndpointField } from '~/utils'; -export default function NewChat({ toggleNav }: { toggleNav: () => void }) { - const { newConversation } = useConversation(); +export default function NewChat({ + toggleNav, + subHeaders, +}: { + toggleNav: () => void; + subHeaders?: React.ReactNode; +}) { const { newConversation: newConvo } = useNewConvo(); + const { newConversation } = useConversation(); const navigate = useOriginNavigate(); const localize = useLocalize(); + const { data: endpointsConfig } = useGetEndpointsQuery(); + const [convo] = useLocalStorage('lastConversationSetup', { endpoint: EModelEndpoint.openAI }); + const { endpoint } = convo; + const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); + const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + const iconKey = endpointType ? 'unknown' : endpoint ?? 'unknown'; + const Icon = icons[iconKey]; + const clickHandler = (event: React.MouseEvent) => { if (event.button === 0 && !event.ctrlKey) { event.preventDefault(); @@ -17,28 +42,40 @@ export default function NewChat({ toggleNav }: { toggleNav: () => void }) { }; return ( - - - - - - {localize('com_ui_new_chat')} - + ); } diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index 6f6fe6c21d5..713a41cc841 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -3,6 +3,7 @@ import { Search, X } from 'lucide-react'; import { useSetRecoilState } from 'recoil'; import debounce from 'lodash/debounce'; import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; import store from '~/store'; type SearchBarProps = { @@ -43,7 +44,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = return (
{} ) = onKeyUp={handleKeyUp} />
diff --git a/client/src/components/svg/GoogleIconChat.tsx b/client/src/components/svg/GoogleIconChat.tsx new file mode 100644 index 00000000000..4c2f4eb8fca --- /dev/null +++ b/client/src/components/svg/GoogleIconChat.tsx @@ -0,0 +1,26 @@ +import { cn } from '~/utils/'; + +export default function Google({ + size = 25, + className = '', +}: { + size?: number; + className?: string; +}) { + const unit = '41'; + const height = size; + const width = size; + return ( + + + + ); +} diff --git a/client/src/components/svg/NewChatIcon.tsx b/client/src/components/svg/NewChatIcon.tsx new file mode 100644 index 00000000000..59bc7ac1ece --- /dev/null +++ b/client/src/components/svg/NewChatIcon.tsx @@ -0,0 +1,20 @@ +import { cn } from '~/utils'; +export default function NewChatIcon({ className = '' }: { className?: string }) { + return ( + + + + ); +} diff --git a/client/src/components/svg/Spinner.tsx b/client/src/components/svg/Spinner.tsx index 3e60397cd60..eb473fe3e79 100644 --- a/client/src/components/svg/Spinner.tsx +++ b/client/src/components/svg/Spinner.tsx @@ -4,7 +4,7 @@ import { cn } from '~/utils/'; export default function Spinner({ className = 'm-auto' }) { return ( => { + const queryClient = useQueryClient(); + return useMutation((payload: t.TGenTitleRequest) => dataService.genTitle(payload), { + onSuccess: (response, vars) => { + queryClient.setQueryData( + [QueryKeys.conversation, vars.conversationId], + (convo: TConversation | undefined) => { + if (!convo) { + return convo; + } + return { ...convo, title: response.title }; + }, + ); + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return updateConvoFields(convoData, { + conversationId: vars.conversationId, + title: response.title, + } as TConversation); + }); + }, + }); +}; + +export const useUpdateConversationMutation = ( + id: string, +): UseMutationResult< + t.TUpdateConversationResponse, + unknown, + t.TUpdateConversationRequest, + unknown +> => { + const queryClient = useQueryClient(); + return useMutation( + (payload: t.TUpdateConversationRequest) => dataService.updateConversation(payload), + { + onSuccess: (updatedConvo) => { + queryClient.setQueryData([QueryKeys.conversation, id], updatedConvo); + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return updateConversation(convoData, updatedConvo); + }); + }, + }, + ); +}; + +export const useDeleteConversationMutation = ( + id?: string, +): UseMutationResult< + t.TDeleteConversationResponse, + unknown, + t.TDeleteConversationRequest, + unknown +> => { + const queryClient = useQueryClient(); + return useMutation( + (payload: t.TDeleteConversationRequest) => dataService.deleteConversation(payload), + { + onSuccess: () => { + if (!id) { + return; + } + queryClient.setQueryData([QueryKeys.conversation, id], null); + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + const update = deleteConversation(convoData, id); + return update; + }); + }, + }, + ); +}; + export const useUploadImageMutation = ( options?: UploadMutationOptions, ): UseMutationResult< diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index 2ae6aaf81b2..431b4f813bc 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -1,6 +1,18 @@ import { QueryKeys, dataService } from 'librechat-data-provider'; -import { UseQueryOptions, useQuery, QueryObserverResult } from '@tanstack/react-query'; -import type { TPreset, TFile } from 'librechat-data-provider'; +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import type { + UseInfiniteQueryOptions, + QueryObserverResult, + UseQueryOptions, +} from '@tanstack/react-query'; +import type t from 'librechat-data-provider'; +import type { + TPreset, + TFile, + ConversationListResponse, + ConversationListParams, +} from 'librechat-data-provider'; +import { findPageForConversation } from '~/utils'; export const useGetFiles = ( config?: UseQueryOptions, @@ -38,3 +50,82 @@ export const useGetEndpointsConfigOverride = ( }, ); }; + +export const useGetConvoIdQuery = ( + id: string, + config?: UseQueryOptions, +): QueryObserverResult => { + const queryClient = useQueryClient(); + return useQuery( + [QueryKeys.conversation, id], + () => { + const defaultQuery = () => dataService.getConversationById(id); + const convosQuery = queryClient.getQueryData([ + QueryKeys.allConversations, + ]); + + if (!convosQuery) { + return defaultQuery(); + } + + const { pageIndex, convIndex } = findPageForConversation(convosQuery, { conversationId: id }); + + if (pageIndex > -1 && convIndex > -1) { + return convosQuery.pages[pageIndex].conversations[convIndex]; + } + + return defaultQuery(); + }, + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }, + ); +}; + +export const useSearchInfiniteQuery = ( + params?: ConversationListParams & { searchQuery?: string }, + config?: UseInfiniteQueryOptions, +) => { + return useInfiniteQuery( + [QueryKeys.searchConversations, params], // Include the searchQuery in the query key + ({ pageParam = '1' }) => + dataService.listConversationsByQuery({ ...params, pageNumber: pageParam }), + { + getNextPageParam: (lastPage) => { + const currentPageNumber = Number(lastPage.pageNumber); + const totalPages = Number(lastPage.pages); + return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }, + ); +}; + +export const useConversationsInfiniteQuery = ( + params?: ConversationListParams, + config?: UseInfiniteQueryOptions, +) => { + return useInfiniteQuery( + [QueryKeys.allConversations], + ({ pageParam = '' }) => + dataService.listConversations({ ...params, pageNumber: pageParam?.toString() }), + { + getNextPageParam: (lastPage) => { + const currentPageNumber = Number(lastPage.pageNumber); + const totalPages = Number(lastPage.pages); // Convert totalPages to a number + // If the current page number is less than total pages, return the next page number + return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined; + }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }, + ); +}; diff --git a/client/src/hooks/Nav/index.ts b/client/src/hooks/Nav/index.ts new file mode 100644 index 00000000000..ff2140639e2 --- /dev/null +++ b/client/src/hooks/Nav/index.ts @@ -0,0 +1 @@ +export { default as useNavScrolling } from './useNavScrolling'; diff --git a/client/src/hooks/Nav/useNavScrolling.ts b/client/src/hooks/Nav/useNavScrolling.ts new file mode 100644 index 00000000000..ba45d3eabf4 --- /dev/null +++ b/client/src/hooks/Nav/useNavScrolling.ts @@ -0,0 +1,64 @@ +import throttle from 'lodash/throttle'; +import React, { useCallback, useEffect, useRef } from 'react'; +import type { FetchNextPageOptions, InfiniteQueryObserverResult } from '@tanstack/react-query'; +import type { ConversationListResponse } from 'librechat-data-provider'; + +export default function useNavScrolling({ + hasNextPage, + isFetchingNextPage, + setShowLoading, + fetchNextPage, +}: { + hasNextPage?: boolean; + isFetchingNextPage: boolean; + setShowLoading: React.Dispatch>; + fetchNextPage: ( + options?: FetchNextPageOptions | undefined, + ) => Promise>; +}) { + const scrollPositionRef = useRef(null); + const containerRef = useRef(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const fetchNext = useCallback( + throttle(() => fetchNextPage(), 750, { leading: true }), + [fetchNextPage], + ); + + const handleScroll = useCallback(() => { + if (containerRef.current) { + const { scrollTop, clientHeight, scrollHeight } = containerRef.current; + const nearBottomOfList = scrollTop + clientHeight >= scrollHeight * 0.97; + + if (nearBottomOfList && hasNextPage && !isFetchingNextPage) { + setShowLoading(true); + fetchNext(); + } else { + setShowLoading(false); + } + } + }, [hasNextPage, isFetchingNextPage, fetchNext, setShowLoading]); + + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener('scroll', handleScroll); + } + + return () => { + container?.removeEventListener('scroll', handleScroll); + }; + }, [handleScroll, fetchNext]); + + const moveToTop = useCallback(() => { + const container = containerRef.current; + if (container) { + scrollPositionRef.current = container.scrollTop; + } + }, [containerRef, scrollPositionRef]); + + return { + containerRef, + moveToTop, + }; +} diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 649ea9c0bdc..e4a60931be2 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -2,6 +2,7 @@ export * from './Messages'; export * from './Config'; export * from './Input'; export * from './Conversations'; +export * from './Nav'; export * from './AuthContext'; export * from './ThemeContext'; diff --git a/client/src/hooks/useChatHelpers.ts b/client/src/hooks/useChatHelpers.ts index 3d196859255..fd48524631b 100644 --- a/client/src/hooks/useChatHelpers.ts +++ b/client/src/hooks/useChatHelpers.ts @@ -8,9 +8,7 @@ import type { TMessage, TSubmission, TEndpointOption, - TConversation, TEndpointsConfig, - TGetConversationsResponse, } from 'librechat-data-provider'; import type { TAskFunction } from '~/common'; import useSetFilesToDelete from './useSetFilesToDelete'; @@ -60,42 +58,6 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) { [queryParam, queryClient], ); - const addConvo = useCallback( - (convo: TConversation) => { - const convoData = queryClient.getQueryData([ - QueryKeys.allConversations, - { pageNumber: '1', active: true }, - ]) ?? { conversations: [] as TConversation[], pageNumber: '1', pages: 1, pageSize: 14 }; - - let { conversations: convos, pageSize = 14 } = convoData; - pageSize = Number(pageSize); - convos = convos.filter((c) => c.conversationId !== convo.conversationId); - convos = convos.length < pageSize ? convos : convos.slice(0, -1); - - const conversations = [ - { - ...convo, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ...convos, - ]; - - queryClient.setQueryData( - [QueryKeys.allConversations, { pageNumber: '1', active: true }], - { - ...convoData, - conversations, - }, - ); - }, - [queryClient], - ); - - const invalidateConvos = useCallback(() => { - queryClient.invalidateQueries([QueryKeys.allConversations, { active: true }]); - }, [queryClient]); - const getMessages = useCallback(() => { return queryClient.getQueryData([QueryKeys.messages, queryParam]); }, [queryParam, queryClient]); @@ -341,7 +303,6 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) { newConversation, conversation, setConversation, - addConvo, // getConvos, // setConvos, isSubmitting, @@ -373,7 +334,6 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) { setShowAgentSettings, files, setFiles, - invalidateConvos, filesLoading, setFilesLoading, showStopButton, diff --git a/client/src/hooks/useSSE.ts b/client/src/hooks/useSSE.ts index 94629b2d7a6..13e04935b1a 100644 --- a/client/src/hooks/useSSE.ts +++ b/client/src/hooks/useSSE.ts @@ -1,9 +1,11 @@ import { v4 } from 'uuid'; -import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { /* @ts-ignore */ SSE, + QueryKeys, EndpointURLs, createPayload, tPresetSchema, @@ -13,7 +15,15 @@ import { removeNullishValues, } from 'librechat-data-provider'; import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query'; -import type { TResPlugin, TMessage, TConversation, TSubmission } from 'librechat-data-provider'; +import type { + TResPlugin, + TMessage, + TConversation, + TSubmission, + ConversationData, +} from 'librechat-data-provider'; +import { addConversation, deleteConversation, updateConversation } from '~/utils'; +import { useGenTitleMutation } from '~/data-provider'; import { useAuthContext } from './AuthContext'; import useChatHelpers from './useChatHelpers'; import useSetStorage from './useSetStorage'; @@ -30,18 +40,14 @@ type TResData = { export default function useSSE(submission: TSubmission | null, index = 0) { const setStorage = useSetStorage(); + const queryClient = useQueryClient(); + const genTitle = useGenTitleMutation(); + const { conversationId: paramId } = useParams(); const { token, isAuthenticated } = useAuthContext(); const [completed, setCompleted] = useState(new Set()); - const { - addConvo, - setMessages, - setConversation, - setIsSubmitting, - resetLatestMessage, - invalidateConvos, - newConversation, - } = useChatHelpers(index, paramId); + const { setMessages, setConversation, setIsSubmitting, newConversation, resetLatestMessage } = + useChatHelpers(index, paramId); const { data: startupConfig } = useGetStartupConfig(); const balanceQuery = useGetUserBalance({ @@ -103,16 +109,21 @@ export default function useSSE(submission: TSubmission | null, index = 0) { setMessages(messagesUpdate); } - // refresh title - if (requestMessage?.parentMessageId == '00000000-0000-0000-0000-000000000000') { - setTimeout(() => { - invalidateConvos(); - }, 2000); + const isNewConvo = conversation.conversationId !== submission.conversation.conversationId; + if (isNewConvo) { + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return deleteConversation(convoData, submission.conversation.conversationId as string); + }); + } - // in case it takes too long. + // refresh title + if (isNewConvo && requestMessage?.parentMessageId == '00000000-0000-0000-0000-000000000000') { setTimeout(() => { - invalidateConvos(); - }, 5000); + genTitle.mutate({ conversationId: convoUpdate.conversationId as string }); + }, 2500); } setConversation((prevState) => { @@ -164,9 +175,17 @@ export default function useSSE(submission: TSubmission | null, index = 0) { setStorage(update); return update; }); - if (message.parentMessageId == '00000000-0000-0000-0000-000000000000') { - addConvo(update); - } + + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + if (message.parentMessageId == '00000000-0000-0000-0000-000000000000') { + return addConversation(convoData, update); + } else { + return updateConversation(convoData, update); + } + }); resetLatestMessage(); }; @@ -183,16 +202,21 @@ export default function useSSE(submission: TSubmission | null, index = 0) { setMessages([...messages, requestMessage, responseMessage]); } - // refresh title - if (requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') { - setTimeout(() => { - invalidateConvos(); - }, 1500); + const isNewConvo = conversation.conversationId !== submissionConvo.conversationId; + if (isNewConvo) { + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return deleteConversation(convoData, submissionConvo.conversationId as string); + }); + } - // in case it takes too long. + // refresh title + if (isNewConvo && requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') { setTimeout(() => { - invalidateConvos(); - }, 5000); + genTitle.mutate({ conversationId: conversation.conversationId as string }); + }, 2500); } setConversation((prevState) => { diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index 36807d560f4..39baa8170fe 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -2,13 +2,13 @@ import { useRecoilValue } from 'recoil'; import { useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; import { - useGetConvoIdQuery, useGetModelsQuery, useGetStartupConfig, useGetEndpointsQuery, } from 'librechat-data-provider/react-query'; import type { TPreset } from 'librechat-data-provider'; import { useNewConvo, useConfigOverride } from '~/hooks'; +import { useGetConvoIdQuery } from '~/data-provider'; import ChatView from '~/components/Chat/ChatView'; import useAuthRedirect from './useAuthRedirect'; import { Spinner } from '~/components/svg'; diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 7e4a3750ed1..59847b7c777 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -60,8 +60,8 @@ export default function Root() { return ( <>
-