🏷️ fix: Address Statefulness Issues for Bookmarks (#3590)

* refactor: optimize tag methods, remove rebuild

* refactor(tags): add lean db operations, fix updateTagsForConversation, remove rebuild button, only send convoId once

* refactor: Update BookmarkMenu to use Constants.NEW_CONVO constant for comparison

* style: Update BookmarkMenu styles and constants, use theming

* refactor: move tags query from package to client workspace

* refactor: optimize ConversationTag document creation and update logic

* style: Update BookmarkMenuItems to use theming

* refactor: JSDocs + try/catch for conversation tags API routes

* refactor: Update BookmarkNav theming classes and new data provider location

* fix: statefulness of conversation bookmarks
- move non-mutation hook to hooks/Conversation
- remove use of deprecated global convo
- update convo infinite data as well as current convo state upon successful tag add

* refactor: Update BookmarkMenu styles and constants, use theming

* refactor: Add lean option to ConversationTag deletion query

* fix(BookmarkTable): position order rendering esp. when new tag is created

* refactor: Update useBookmarkSucess to useBookmarkSuccess for consistency

* refactor: Update ConversationTag creation logic to increment count only if addToConversation is true

* style: theming
This commit is contained in:
Danny Avila 2024-08-08 21:25:10 -04:00 committed by GitHub
parent 6ea2628b56
commit 016ed866a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 622 additions and 536 deletions

View file

@ -9,9 +9,9 @@ import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { useConversationTagMutation } from '~/data-provider';
import { Checkbox, Label, TextareaAutosize } from '~/components/ui/';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
type TBookmarkFormProps = {
bookmark?: TConversationTag;
@ -31,10 +31,11 @@ const BookmarkForm = ({
tags,
setTags,
}: TBookmarkFormProps) => {
const { showToast } = useToastContext();
const localize = useLocalize();
const mutation = useConversationTagMutation(bookmark?.tag);
const { showToast } = useToastContext();
const { bookmarks } = useBookmarkContext();
const mutation = useConversationTagMutation(bookmark?.tag);
const onSuccess = useBookmarkSuccess(conversation?.conversationId || '');
const {
register,
@ -82,6 +83,7 @@ const BookmarkForm = ({
(tag) => tag !== undefined,
) as string[];
setTags(newTags);
onSuccess(newTags);
}
},
onError: () => {
@ -172,9 +174,9 @@ const BookmarkForm = ({
/>
)}
/>
<label
<button
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="addToConversation"
onClick={() =>
setValue('addToConversation', !getValues('addToConversation'), {
shouldDirty: true,
@ -184,7 +186,7 @@ const BookmarkForm = ({
<div className="flex select-none items-center">
{localize('com_ui_bookmarks_add_to_conversation')}
</div>
</label>
</button>
</div>
)}
</div>

View file

@ -7,6 +7,7 @@ import { cn } from '~/utils';
type MenuItemProps = {
tag: string | React.ReactNode;
selected: boolean;
ctx: 'header' | 'nav';
count?: number;
handleSubmit: (tag: string) => Promise<void>;
icon?: React.ReactNode;
@ -15,6 +16,7 @@ type MenuItemProps = {
const BookmarkItem: FC<MenuItemProps> = ({
tag,
ctx,
selected,
count,
handleSubmit,
@ -34,13 +36,30 @@ const BookmarkItem: FC<MenuItemProps> = ({
overflowWrap: 'anywhere',
};
const renderIcon = () => {
if (icon) {
return icon;
}
if (isLoading) {
return <Spinner className="size-4" />;
}
if (selected) {
return <BookmarkFilledIcon className="size-4" />;
}
return <BookmarkIcon className="size-4" />;
};
const ariaLabel =
ctx === 'header' ? `${selected ? 'Remove' : 'Add'} bookmark for ${tag}` : (tag as string);
return (
<div
<button
aria-label={ariaLabel}
role="menuitem"
className={cn(
'group m-1.5 flex w-[225px] cursor-pointer gap-2 rounded px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50',
'hover:bg-black/5 dark:hover:bg-white/5',
highlightSelected && selected && 'bg-black/5 dark:bg-white/5',
'group m-1.5 flex w-[225px] cursor-pointer gap-2 rounded bg-transparent px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50',
highlightSelected && selected ? 'bg-surface-secondary' : '',
ctx === 'header' ? 'hover:bg-header-hover' : 'hover:bg-surface-hover',
)}
tabIndex={-1}
{...rest}
@ -48,25 +67,14 @@ const BookmarkItem: FC<MenuItemProps> = ({
>
<div className="flex grow items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon ? (
icon
) : isLoading ? (
<Spinner className="size-4" />
) : selected ? (
<BookmarkFilledIcon className="size-4" />
) : (
<BookmarkIcon className="size-4" />
)}
{renderIcon()}
<div style={breakWordStyle}>{tag}</div>
</div>
{count !== undefined && (
<div className="flex items-center justify-end">
<span
className={cn(
'ml-auto w-7 min-w-max whitespace-nowrap rounded-md bg-white px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-gray-600',
'dark:bg-gray-800 dark:text-white',
)}
className="ml-auto w-7 min-w-max whitespace-nowrap rounded-md bg-surface-secondary px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-text-secondary"
aria-hidden="true"
>
{count}
@ -74,7 +82,8 @@ const BookmarkItem: FC<MenuItemProps> = ({
</div>
)}
</div>
</div>
</button>
);
};
export default BookmarkItem;

View file

@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import BookmarkItem from './BookmarkItem';
interface BookmarkItemsProps {
ctx: 'header' | 'nav';
tags: string[];
handleSubmit: (tag: string) => Promise<void>;
header: React.ReactNode;
@ -9,6 +10,7 @@ interface BookmarkItemsProps {
}
const BookmarkItems: FC<BookmarkItemsProps> = ({
ctx,
tags,
handleSubmit,
header,
@ -19,9 +21,10 @@ const BookmarkItems: FC<BookmarkItemsProps> = ({
return (
<>
{header}
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
<div className="my-1.5 h-px" role="none" />
{bookmarks.map((bookmark) => (
<BookmarkItem
ctx={ctx}
key={bookmark.tag}
tag={bookmark.tag}
selected={tags.includes(bookmark.tag)}

View file

@ -14,14 +14,14 @@ const FileContainer = ({
const fileType = getFileType(file.type);
return (
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
<div className="w-60 p-2 dark:bg-gray-600">
<div className="group relative inline-block text-sm text-text-primary">
<div className="relative overflow-hidden rounded-xl border border-border-medium">
<div className="w-60 bg-surface-active p-2">
<div className="flex flex-row items-center gap-2">
<FilePreview file={file} fileType={fileType} className="relative" />
<div className="overflow-hidden">
<div className="truncate font-medium">{file.filename}</div>
<div className="truncate text-gray-300">{fileType.title}</div>
<div className="truncate text-text-secondary">{fileType.title}</div>
</div>
</div>
</div>

View file

@ -66,7 +66,7 @@ export default function OptionsPopover({
{presetsDisabled ? null : (
<Button
type="button"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-ring-primary dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />
@ -77,7 +77,7 @@ export default function OptionsPopover({
<Button
type="button"
className={cn(
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
removeFocusOutlines,
)}
onClick={closePopover}

View file

@ -1,62 +1,36 @@
import { useEffect, useState, type FC } from 'react';
import { useState, type FC } from 'react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
import { Constants } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { Spinner } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const SAVED_TAG = 'Saved';
const BookmarkMenu: FC = () => {
const localize = useLocalize();
const location = useLocation();
const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [tags, setTags] = useState<string[]>();
const conversation = useRecoilValue(store.conversationByIndex(0));
const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const [tags, setTags] = useState<string[]>(conversation?.tags || []);
const [open, setIsOpen] = useState(false);
const [conversation, setConversation] = useState<TConversation>();
let thisConversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
thisConversation = globalConvo;
} else {
thisConversation = activeConvo;
}
const { mutateAsync, isLoading } = useTagConversationMutation(
thisConversation?.conversationId ?? '',
);
const { mutateAsync, isLoading } = useTagConversationMutation(conversationId);
const { data } = useConversationTagsQuery();
useEffect(() => {
if (
(!conversation && thisConversation) ||
(conversation &&
thisConversation &&
conversation.conversationId !== thisConversation.conversationId)
) {
setConversation(thisConversation);
setTags(thisConversation.tags ?? []);
}
if (tags === undefined && conversation) {
setTags(conversation.tags ?? []);
}
}, [thisConversation, conversation, tags]);
const isActiveConvo =
thisConversation &&
thisConversation.conversationId &&
thisConversation.conversationId !== 'new' &&
thisConversation.conversationId !== 'search';
conversation &&
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search';
if (!isActiveConvo) {
return <></>;
@ -70,59 +44,59 @@ const BookmarkMenu: FC = () => {
if (open && tags && tags.length > 0) {
setIsOpen(open);
} else {
if (thisConversation && thisConversation.conversationId) {
await mutateAsync({
conversationId: thisConversation.conversationId,
tags: [SAVED_TAG],
});
setTags([SAVED_TAG]);
setConversation({ ...thisConversation, tags: [SAVED_TAG] });
if (conversation && conversationId) {
await mutateAsync(
{
tags: [SAVED_TAG],
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
},
onError: () => {
console.error('Error adding bookmark');
},
},
);
}
}
};
const renderButtonContent = () => {
if (isLoading) {
return <Spinner />;
}
if (tags && tags.length > 0) {
return <BookmarkFilledIcon className="icon-sm" />;
}
return <BookmarkIcon className="icon-sm" />;
};
return (
<Root open={open} onOpenChange={onOpenChange}>
<Trigger asChild>
<button
id="header-bookmarks-menu"
className={cn(
'pointer-cursor relative flex flex-col rounded-md border border-gray-100 bg-white text-left focus:outline-none focus:ring-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:outline-offset-2 focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-500',
'pointer-cursor relative flex flex-col rounded-md border border-border-light bg-transparent text-left focus:outline-none focus:ring-0 sm:text-sm',
'hover:bg-header-button-hover radix-state-open:bg-header-button-hover',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:outline-offset-2 focus:ring-0 focus-visible:ring-2 focus-visible:ring-ring-primary ',
)}
title={localize('com_ui_bookmarks')}
>
{isLoading ? (
<Spinner />
) : tags && tags.length > 0 ? (
<BookmarkFilledIcon className="icon-sm" />
) : (
<BookmarkIcon className="icon-sm" />
)}
{renderButtonContent()}
</button>
</Trigger>
<Portal>
<Content
className={cn(
'grid w-full',
'mt-2 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',
'max-h-[500px]',
)}
className="mt-2 grid max-h-[500px] w-full min-w-[240px] overflow-y-auto rounded-lg border border-border-medium bg-header-primary text-text-primary shadow-lg"
side="bottom"
align="start"
>
{data && conversation && (
// Display all bookmarks registered by the user and highlight the tags of the currently selected conversation
<BookmarkContext.Provider value={{ bookmarks: data }}>
<BookmarkMenuItems
// Currently selected conversation
conversation={conversation}
setConversation={setConversation}
// Tags in the conversation
tags={tags ?? []}
// Update tags in the conversation
setTags={setTags}
/>
<BookmarkMenuItems conversation={conversation} tags={tags ?? []} setTags={setTags} />
</BookmarkContext.Provider>
)}
</Content>

View file

@ -1,36 +1,37 @@
import { useCallback } from 'react';
import React, { useCallback } from 'react';
import { BookmarkPlusIcon } from 'lucide-react';
import type { FC } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { BookmarkItems, BookmarkEditDialog } from '~/components/Bookmarks';
import { useTagConversationMutation } from '~/data-provider';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
export const BookmarkMenuItems: FC<{
conversation: TConversation;
tags: string[];
setTags: (tags: string[]) => void;
setConversation: (conversation: TConversation) => void;
}> = ({ conversation, tags, setTags, setConversation }) => {
setTags: React.Dispatch<React.SetStateAction<string[]>>;
}> = ({ conversation, tags, setTags }) => {
const { showToast } = useToastContext();
const localize = useLocalize();
const { mutateAsync } = useTagConversationMutation(conversation?.conversationId ?? '');
const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const { mutateAsync } = useTagConversationMutation(conversationId);
const handleSubmit = useCallback(
async (tag: string): Promise<void> => {
if (tags !== undefined && conversation?.conversationId) {
if (tags !== undefined && conversationId) {
const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag];
await mutateAsync(
{
conversationId: conversation.conversationId,
tags: newTags,
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
setConversation({ ...conversation, tags: newTags });
onSuccess(newTags);
},
onError: () => {
showToast({
@ -42,11 +43,12 @@ export const BookmarkMenuItems: FC<{
);
}
},
[tags, conversation],
[tags, conversationId, mutateAsync, setTags, onSuccess, showToast],
);
return (
<BookmarkItems
ctx="header"
tags={tags}
handleSubmit={handleSubmit}
header={
@ -58,7 +60,7 @@ export const BookmarkMenuItems: FC<{
trigger={
<div
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-header-hover focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50"
tabIndex={-1}
>
<div className="flex grow items-center justify-between gap-2">

View file

@ -4,8 +4,8 @@ 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 { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -39,22 +39,22 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
<Trigger asChild>
<button
className={cn(
'relative mt-1 flex h-10 w-full cursor-pointer items-center gap-1 rounded-lg border-white bg-gray-50 px-1 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800',
open ? 'bg-gray-200 dark:bg-gray-800' : '',
'relative mt-1 flex h-10 w-full cursor-pointer items-center gap-1 rounded-lg border-border-light bg-transparent px-1 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
open ? 'bg-surface-hover' : '',
)}
id="presets-button"
data-testid="presets-button"
title={localize('com_endpoint_examples')}
id="show-bookmarks"
data-testid="show-bookmarks"
title={localize('com_ui_bookmarks')}
>
<div className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 dark:text-white">
<div className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 text-text-primary">
{tags.length > 0 ? (
<BookmarkFilledIcon className="h-5 w-5" />
) : (
<BookmarkIcon className="h-5 w-5" />
)}
</div>
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm text-black dark:text-gray-100">
{tags.length > 0 ? tags.join(',') : localize('com_ui_bookmarks')}
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm text-text-primary">
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
</div>
</button>
</Trigger>
@ -63,7 +63,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
<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"
className="mt-2 max-h-96 min-w-[240px] overflow-y-auto rounded-lg border border-border-medium bg-surface-primary-alt text-text-primary shadow-lg lg:max-h-96"
>
{data && conversation && (
// Display bookmarks and highlight the selected tag

View file

@ -39,12 +39,11 @@ const BookmarkNavItems: FC<{
return Promise.resolve();
};
console.log('bookmarks', bookmarks);
if (bookmarks.length === 0) {
return (
<div className="flex flex-col">
<BookmarkItem
ctx="nav"
tag={localize('com_ui_no_bookmarks')}
data-testid="bookmark-item-clear"
handleSubmit={() => Promise.resolve()}
@ -58,11 +57,13 @@ const BookmarkNavItems: FC<{
return (
<div className="flex flex-col">
<BookmarkItems
ctx="nav"
tags={tags}
handleSubmit={handleSubmit}
highlightSelected={true}
header={
<BookmarkItem
ctx="nav"
tag="Clear all"
data-testid="bookmark-item-clear"
handleSubmit={clear}

View file

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* Reason: SearchContext is not specifying potential undefined type */
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import type { ConversationListResponse } from 'librechat-data-provider';
import {
useMediaQuery,
@ -159,7 +157,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
toggleNav={itemToggleNav}
subHeaders={
<>
{isSearchEnabled && <SearchBar clearSearch={clearSearch} />}
{isSearchEnabled && <SearchBar clearSearch={clearSearch} />}
<BookmarkNav tags={tags} setTags={setTags} />
</>
}

View file

@ -58,12 +58,12 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
return (
<div
ref={ref}
className="relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
className="relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover dark:focus-within:bg-surface-hover"
>
{<Search className="absolute left-3 h-4 w-4" />}
<input
type="text"
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-gray-500 placeholder-opacity-100 outline-none dark:placeholder-white dark:placeholder-opacity-100"
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 outline-none dark:placeholder-opacity-100"
value={text}
onChange={onChange}
onKeyDown={(e) => {

View file

@ -1,38 +1,23 @@
import { BookmarkPlusIcon } from 'lucide-react';
import { useConversationTagsQuery, useRebuildConversationTagsMutation } from '~/data-provider';
import { useConversationTagsQuery } from '~/data-provider';
import { Button } from '~/components/ui';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import BookmarkTable from './BookmarkTable';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
const BookmarkPanel = () => {
const localize = useLocalize();
const { mutate, isLoading } = useRebuildConversationTagsMutation();
const { data } = useConversationTagsQuery();
const rebuildTags = () => {
mutate({});
};
return (
<div className="h-auto max-w-full overflow-x-hidden">
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
<BookmarkTable />
<div className="flex justify-between gap-2">
<Button variant="outline" onClick={rebuildTags} className="w-50 text-sm">
{isLoading ? (
<Spinner />
) : (
<div className="flex gap-2">
{localize('com_ui_bookmarks_rebuild')}
<HoverCardSettings side="top" text="com_nav_info_bookmarks_rebuild" />
</div>
)}
</Button>
<BookmarkEditDialog
trigger={
<Button variant="outline" onClick={rebuildTags} className="w-full text-sm">
<Button variant="outline" className="w-full text-sm">
<BookmarkPlusIcon className="mr-1 size-4" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</Button>

View file

@ -14,7 +14,11 @@ const BookmarkTable = () => {
const { bookmarks } = useBookmarkContext();
useEffect(() => {
setRows(bookmarks?.map((item) => ({ id: item.tag, ...item })) || []);
setRows(
bookmarks
?.map((item) => ({ id: item.tag, ...item }))
.sort((a, b) => a.position - b.position) || [],
);
}, [bookmarks]);
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
@ -22,13 +26,16 @@ const BookmarkTable = () => {
const updatedRows = [...prevTags];
const [movedRow] = updatedRows.splice(dragIndex, 1);
updatedRows.splice(hoverIndex, 0, movedRow);
return updatedRows;
return updatedRows.map((row, index) => ({ ...row, position: index }));
});
}, []);
const renderRow = useCallback((row: TConversationTag, position: number) => {
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={position} />;
}, []);
const renderRow = useCallback(
(row: TConversationTag) => {
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={row.position} />;
},
[moveRow],
);
const filteredRows = rows.filter((row) =>
row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
@ -58,7 +65,7 @@ const BookmarkTable = () => {
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{currentRows.map((row, i) => renderRow(row, i))}</TableBody>
<TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4">

View file

@ -1,8 +1,12 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import type { TConversationTag } from 'librechat-data-provider';
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
import { useConversationTagMutation } from '~/data-provider';
import { TableRow, TableCell } from '~/components/ui';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
interface BookmarkTableRowProps {
row: TConversationTag;
@ -10,13 +14,39 @@ interface BookmarkTableRowProps {
position: number;
}
interface DragItem {
index: number;
id: string;
type: string;
}
const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => {
const [isHovered, setIsHovered] = useState(false);
const ref = React.useRef<HTMLTableRowElement>(null);
const ref = useRef<HTMLTableRowElement>(null);
const mutation = useConversationTagMutation(row.tag);
const localize = useLocalize();
const { showToast } = useToastContext();
const handleDrop = (item: DragItem) => {
const data = {
...row,
position: item.index,
};
mutation.mutate(data, {
onError: () => {
showToast({
message: localize('com_ui_bookmarks_update_error'),
severity: NotificationSeverity.ERROR,
});
},
});
};
const [, drop] = useDrop({
accept: 'bookmark',
hover(item: { index: number }) {
drop: (item: DragItem) => handleDrop(item),
hover(item: DragItem) {
if (!ref.current) {
return;
}
@ -43,7 +73,7 @@ const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, posit
return (
<TableRow
ref={ref}
className="cursor-move hover:bg-gray-100 dark:hover:bg-gray-800"
className="cursor-move hover:bg-surface-secondary"
style={{ opacity: isDragging ? 0.5 : 1 }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}

View file

@ -3,3 +3,4 @@ export * from './mutations';
export * from './prompts';
export * from './queries';
export * from './roles';
export * from './tags';

View file

@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { InfiniteData, UseMutationResult } from '@tanstack/react-query';
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
import { updateConversationTag } from '~/utils/conversationTags';
import { normalizeData } from '~/utils/collection';
import store from '~/store';
@ -89,89 +90,6 @@ export const useUpdateConversationMutation = (
);
};
const useUpdateTagsInConversation = () => {
const queryClient = useQueryClient();
// Update the queryClient cache with the new tag when a new tag is added/removed to a conversation
const updateTagsInConversation = (conversationId: string, tags: string[]) => {
// Update the tags for the current conversation
const currentConvo = queryClient.getQueryData<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (!currentConvo) {
return;
}
const updatedConvo = {
...currentConvo,
tags,
} as t.TConversation;
queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo);
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
}
return updateConvoFields(
convoData,
{
conversationId: currentConvo.conversationId,
tags: updatedConvo.tags,
} as t.TConversation,
true,
);
});
};
// update the tag to newTag in all conversations when a tag is updated to a newTag
// The difference with updateTagsInConversation is that it adds or removes tags for a specific conversation,
// whereas this function is for changing the title of a specific tag.
const replaceTagsInAllConversations = (tag: string, newTag: string) => {
const data = queryClient.getQueryData<InfiniteData<ConversationListResponse>>([
QueryKeys.allConversations,
]);
const conversationIdsWithTag = [] as string[];
// update tag to newTag in all conversations
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
const page = newData.pages[pageIndex];
page.conversations = page.conversations.map((conversation) => {
if (conversation.conversationId && conversation.tags?.includes(tag)) {
conversationIdsWithTag.push(conversation.conversationId);
conversation.tags = conversation.tags.map((t) => (t === tag ? newTag : t));
}
return conversation;
});
}
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
[QueryKeys.allConversations],
newData,
);
// update the tag to newTag from the cache of each conversation
for (let i = 0; i < conversationIdsWithTag.length; i++) {
const conversationId = conversationIdsWithTag[i];
const conversation = queryClient.getQueryData<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (conversation && conversation.tags) {
const updatedConvo = {
...conversation,
tags: conversation.tags.map((t) => (t === tag ? newTag : t)),
} as t.TConversation;
queryClient.setQueryData<t.TConversation>(
[QueryKeys.conversation, conversationId],
updatedConvo,
);
}
}
};
return { updateTagsInConversation, replaceTagsInAllConversations };
};
/**
* Add or remove tags for a conversation
*/
@ -179,7 +97,7 @@ export const useTagConversationMutation = (
conversationId: string,
): UseMutationResult<t.TTagConversationResponse, unknown, t.TTagConversationRequest, unknown> => {
const query = useConversationTagsQuery();
const { updateTagsInConversation } = useUpdateTagsInConversation();
const { updateTagsInConversation } = useUpdateTagsInConvo();
return useMutation(
(payload: t.TTagConversationRequest) =>
dataService.addTagToConversation(conversationId, payload),
@ -385,21 +303,6 @@ export const useDeleteSharedLinkMutation = (
});
};
// If the number of conversations tagged is incorrect, recalculate the tag information.
export const useRebuildConversationTagsMutation = (): UseMutationResult<
t.TConversationTagsResponse,
unknown,
unknown,
unknown
> => {
const queryClient = useQueryClient();
return useMutation(() => dataService.rebuildConversationTags(), {
onSuccess: (_data) => {
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], _data);
},
});
};
// Add a tag or update tag information (tag, description, position, etc.)
export const useConversationTagMutation = (
tag?: string,
@ -407,7 +310,7 @@ export const useConversationTagMutation = (
): UseMutationResult<t.TConversationTagResponse, unknown, t.TConversationTagRequest, unknown> => {
const queryClient = useQueryClient();
const { ..._options } = options || {};
const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConversation();
const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConvo();
return useMutation(
(payload: t.TConversationTagRequest) =>
tag
@ -427,6 +330,9 @@ export const useConversationTagMutation = (
},
] as t.TConversationTag[];
}
if (!tag) {
return [...data, _data].sort((a, b) => a.position - b.position);
}
return updateConversationTag(data, vars, _data, tag);
});
if (vars.addToConversation && vars.conversationId && _data.tag) {

View file

@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
import type { TConversationTagsResponse } from 'librechat-data-provider';
import { QueryKeys, dataService } from 'librechat-data-provider';
export const useGetConversationTags = (
config?: UseQueryOptions<TConversationTagsResponse>,
): QueryObserverResult<TConversationTagsResponse> => {
return useQuery<TConversationTagsResponse>(
[QueryKeys.conversationTags],
() => dataService.getConversationTags(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};

View file

@ -6,7 +6,9 @@ export { default as useConversation } from './useConversation';
export { default as useGenerateConvo } from './useGenerateConvo';
export { default as useConversations } from './useConversations';
export { default as useDebouncedInput } from './useDebouncedInput';
export { default as useBookmarkSuccess } from './useBookmarkSuccess';
export { default as useNavigateToConvo } from './useNavigateToConvo';
export { default as useSetIndexOptions } from './useSetIndexOptions';
export { default as useParameterEffects } from './useParameterEffects';
export { default as useUpdateTagsInConvo } from './useUpdateTagsInConvo';
export { default as useExportConversation } from './useExportConversation';

View file

@ -0,0 +1,27 @@
import { useSetRecoilState } from 'recoil';
import useUpdateTagsInConvo from './useUpdateTagsInConvo';
import store from '~/store';
const useBookmarkSuccess = (conversationId: string) => {
const setConversation = useSetRecoilState(store.conversationByIndex(0));
const { updateTagsInConversation } = useUpdateTagsInConvo();
return (newTags: string[]) => {
if (!conversationId) {
return;
}
updateTagsInConversation(conversationId, newTags);
setConversation((prev) => {
if (prev) {
return {
...prev,
tags: newTags,
};
}
console.error('Conversation not found for bookmark/tags update');
return prev;
});
};
};
export default useBookmarkSuccess;

View file

@ -0,0 +1,92 @@
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { updateConvoFields } from '~/utils/convos';
const useUpdateTagsInConvo = () => {
const queryClient = useQueryClient();
// Update the queryClient cache with the new tag when a new tag is added/removed to a conversation
const updateTagsInConversation = (conversationId: string, tags: string[]) => {
// Update the tags for the current conversation
const currentConvo = queryClient.getQueryData<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (!currentConvo) {
return;
}
const updatedConvo = {
...currentConvo,
tags,
} as t.TConversation;
queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo);
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
}
return updateConvoFields(
convoData,
{
conversationId: currentConvo.conversationId,
tags: updatedConvo.tags,
} as t.TConversation,
true,
);
});
};
// update the tag to newTag in all conversations when a tag is updated to a newTag
// The difference with updateTagsInConversation is that it adds or removes tags for a specific conversation,
// whereas this function is for changing the title of a specific tag.
const replaceTagsInAllConversations = (tag: string, newTag: string) => {
const data = queryClient.getQueryData<InfiniteData<ConversationListResponse>>([
QueryKeys.allConversations,
]);
const conversationIdsWithTag = [] as string[];
// update tag to newTag in all conversations
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
const page = newData.pages[pageIndex];
page.conversations = page.conversations.map((conversation) => {
if (conversation.conversationId && conversation.tags?.includes(tag)) {
conversationIdsWithTag.push(conversation.conversationId);
conversation.tags = conversation.tags.map((t) => (t === tag ? newTag : t));
}
return conversation;
});
}
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
[QueryKeys.allConversations],
newData,
);
// update the tag to newTag from the cache of each conversation
for (let i = 0; i < conversationIdsWithTag.length; i++) {
const conversationId = conversationIdsWithTag[i];
const conversation = queryClient.getQueryData<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (conversation && conversation.tags) {
const updatedConvo = {
...conversation,
tags: conversation.tags.map((t) => (t === tag ? newTag : t)),
} as t.TConversation;
queryClient.setQueryData<t.TConversation>(
[QueryKeys.conversation, conversationId],
updatedConvo,
);
}
}
};
return { updateTagsInConversation, replaceTagsInAllConversations };
};
export default useUpdateTagsInConvo;

View file

@ -294,7 +294,6 @@ export default {
com_ui_min_tags: 'Cannot remove more values, a minimum of {0} are required.',
com_ui_max_tags: 'Maximum number allowed is {0}, using latest values.',
com_ui_bookmarks: 'Bookmarks',
com_ui_bookmarks_rebuild: 'Rebuild',
com_ui_bookmarks_new: 'New Bookmark',
com_ui_bookmark_delete_confirm: 'Are you sure you want to delete this bookmark?',
com_ui_bookmarks_title: 'Title',
@ -696,8 +695,6 @@ export default {
'This action will revoke and remove all the API keys that you have provided. You will need to re-enter these credentials to continue using those endpoints.',
com_nav_info_delete_cache_storage:
'This action will delete all cached TTS (Text-to-Speech) audio files stored on your device. Cached audio files are used to speed up playback of previously generated TTS audio, but they can consume storage space on your device.',
com_nav_info_bookmarks_rebuild:
'If the bookmark count is incorrect, please rebuild the bookmark information. The bookmark count will be recalculated and the data will be restored to its correct state.',
// Command Settings Tab
com_nav_commands: 'Commands',
com_nav_commands_tab: 'Command Settings',

View file

@ -32,7 +32,12 @@ html {
--text-secondary:var(--gray-600);
--text-secondary-alt:var(--gray-500);
--text-tertiary:var(--gray-500);
--ring-primary:var(--gray-500);
--header-primary:var(--white);
--header-hover:var(--gray-50);
--header-button-hover:var(--gray-50);
--surface-active:var(--gray-100);
--surface-hover:var(--gray-200);
--surface-primary:var(--white);
--surface-primary-alt:var(--white);
--surface-primary-contrast:var(--gray-100);
@ -50,7 +55,11 @@ html {
--text-secondary:var(--gray-300);
--text-secondary-alt:var(--gray-400);
--text-tertiary:var(--gray-500);
--header-primary:var(--gray-700);
--header-hover:var(--gray-600);
--header-button-hover:var(--gray-700);
--surface-active:var(--gray-600);
--surface-hover:var(--gray-700);
--surface-primary:var(--gray-900);
--surface-primary-alt:var(--gray-850);
--surface-primary-contrast:var(--gray-850);