🔖 feat: Conversation Bookmarks (#3344)

* feat: add tags property in Conversation model

* feat: add ConversationTag model

* feat: add the tags parameter to getConvosByPage

* feat: add API route to ConversationTag

* feat: add types of ConversationTag

* feat: add data access functions for conversation tags

* feat: add Bookmark table component

* feat: Add an action to bookmark

* feat: add Bookmark nav component

* fix: failed test

* refactor: made 'Saved' tag a constant

* feat: add new bookmark to current conversation

* chore: Add comment

* fix: delete tag from conversations when it's deleted

* fix: Update the query cache when the tag title is changed.

* chore: fix typo

* refactor: add description of rebuilding bookmarks

* chore: remove unused variables

* fix: position when adding a new bookmark

* refactor: add comment, rename a function

* refactor: add a unique constraint in ConversationTag

* chore: add localizations
This commit is contained in:
Yuichi Oneda 2024-07-29 07:45:59 -07:00 committed by GitHub
parent d4d56281e3
commit e565e0faab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3751 additions and 36 deletions

View file

@ -0,0 +1,99 @@
import { useState, type FC } from 'react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useGetConversationTags } from 'librechat-data-provider/react-query';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
type BookmarkNavProps = {
tags: string[];
setTags: (tags: string[]) => void;
};
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps) => {
const localize = useLocalize();
const location = useLocation();
const { data } = useGetConversationTags();
const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [open, setIsOpen] = useState(false);
let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}
// Hide the button if there are no tags
if (!data || !data.some((tag) => tag.count > 0)) {
return null;
}
return (
<Root open={open} onOpenChange={setIsOpen}>
<Trigger asChild>
<button
className={cn(
'group-ui-open:bg-gray-100 dark:group-ui-open:bg-gray-700 duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800',
open ? 'bg-gray-100 dark:bg-gray-800' : '',
)}
id="presets-button"
data-testid="presets-button"
title={localize('com_endpoint_examples')}
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
<div className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 dark:text-white">
{tags.length > 0 ? (
<BookmarkFilledIcon className="h-6 w-6" />
) : (
<BookmarkIcon className="h-6 w-6" />
)}
</div>
</div>
</div>
<div
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-black dark:text-gray-100"
style={{ marginTop: '0', marginLeft: '0' }}
>
{tags.length > 0 ? tags.join(',') : localize('com_ui_bookmarks')}
</div>
</button>
</Trigger>
<Portal>
<div className="fixed left-0 top-0 z-auto translate-x-[268px] translate-y-[50px]">
<Content
side="bottom"
align="start"
className="mt-2 max-h-96 min-w-[240px] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-96"
>
{data && conversation && data.some((tag) => tag.count > 0) && (
// Display bookmarks and highlight the selected tag
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems
// Currently selected conversation
conversation={conversation}
// List of selected tags(string)
tags={tags}
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
setTags={setTags}
/>
</BookmarkContext.Provider>
)}
</Content>
</div>
</Portal>
</Root>
);
};
export default BookmarkNav;

View file

@ -0,0 +1,58 @@
import { useEffect, useState, type FC } from 'react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import type { TConversation } from 'librechat-data-provider';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
const BookmarkNavItems: FC<{
conversation: TConversation;
tags: string[];
setTags: (tags: string[]) => void;
}> = ({ conversation, tags, setTags }) => {
const [currentConversation, setCurrentConversation] = useState<TConversation>();
useEffect(() => {
if (!currentConversation) {
setCurrentConversation(conversation);
}
}, [conversation, currentConversation]);
const getUpdatedSelected = (tag: string) => {
if (tags.some((selectedTag) => selectedTag === tag)) {
return tags.filter((selectedTag) => selectedTag !== tag);
} else {
return [...(tags || []), tag];
}
};
const handleSubmit = (tag: string) => {
const updatedSelected = getUpdatedSelected(tag);
setTags(updatedSelected);
return Promise.resolve();
};
const clear = () => {
setTags([]);
return Promise.resolve();
};
return (
<>
<BookmarkItems
tags={tags}
handleSubmit={handleSubmit}
highlightSelected={true}
header={
<BookmarkItem
tag="Clear all"
data-testid="bookmark-item-clear"
handleSubmit={clear}
selected={false}
icon={<CrossCircledIcon className="h-4 w-4" />}
/>
}
/>
</>
);
};
export default BookmarkNavItems;

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import type { ConversationListResponse } from 'librechat-data-provider';
import {
useMediaQuery,
useAuthContext,
@ -12,6 +13,7 @@ import {
import { useConversationsInfiniteQuery } from '~/data-provider';
import { TooltipProvider, Tooltip } from '~/components/ui';
import { Conversations } from '~/components/Conversations';
import BookmarkNav from './Bookmarks/BookmarkNav';
import { useSearchContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import SearchBar from './SearchBar';
@ -19,7 +21,6 @@ import NavToggle from './NavToggle';
import NavLinks from './NavLinks';
import NewChat from './NewChat';
import { cn } from '~/utils';
import { ConversationListResponse } from 'librechat-data-provider';
import store from '~/store';
const Nav = ({ navVisible, setNavVisible }) => {
@ -58,12 +59,21 @@ const Nav = ({ navVisible, setNavVisible }) => {
const { refreshConversations } = useConversations();
const { pageNumber, searchQuery, setPageNumber, searchQueryRes } = useSearchContext();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery(
{ pageNumber: pageNumber.toString(), isArchived: false },
{ enabled: isAuthenticated },
);
const [tags, setTags] = useState<string[]>([]);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
useConversationsInfiniteQuery(
{
pageNumber: pageNumber.toString(),
isArchived: false,
tags: tags.length === 0 ? undefined : tags,
},
{ enabled: isAuthenticated },
);
useEffect(() => {
// When a tag is selected, refetch the list of conversations related to that tag
refetch();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
hasNextPage: searchQuery ? searchQueryRes.hasNextPage : hasNextPage,
@ -154,6 +164,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
/>
)}
</div>
<BookmarkNav tags={tags} setTags={setTags} />
<NavLinks />
</nav>
</div>
@ -168,7 +179,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
navVisible={navVisible}
className="fixed left-0 top-1/2 z-40 hidden md:flex"
/>
<div className={`nav-mask${navVisible ? ' active' : ''}`} onClick={toggleNavVisible} />
<div className={`nav-mask${navVisible ? 'active' : ''}`} onClick={toggleNavVisible} />
</Tooltip>
</TooltipProvider>
);