mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-29 14:48:51 +01:00
🔖 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:
parent
d4d56281e3
commit
e565e0faab
65 changed files with 3751 additions and 36 deletions
69
client/src/components/Bookmarks/BookmarkEditDialog.tsx
Normal file
69
client/src/components/Bookmarks/BookmarkEditDialog.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||
import { TConversationTag, TConversation } from 'librechat-data-provider';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { Dialog, DialogButton } from '~/components/ui/';
|
||||
import BookmarkForm from './BookmarkForm';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Spinner } from '../svg';
|
||||
|
||||
type BookmarkEditDialogProps = {
|
||||
bookmark?: TConversationTag;
|
||||
conversation?: TConversation;
|
||||
tags?: string[];
|
||||
setTags?: (tags: string[]) => void;
|
||||
trigger: React.ReactNode;
|
||||
};
|
||||
const BookmarkEditDialog = ({
|
||||
bookmark,
|
||||
conversation,
|
||||
tags,
|
||||
setTags,
|
||||
trigger,
|
||||
}: BookmarkEditDialogProps) => {
|
||||
const localize = useLocalize();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleSubmitForm = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title="Bookmark"
|
||||
className="w-11/12 sm:w-1/4"
|
||||
showCloseButton={false}
|
||||
main={
|
||||
<BookmarkForm
|
||||
conversation={conversation}
|
||||
onOpenChange={setOpen}
|
||||
setIsLoading={setIsLoading}
|
||||
bookmark={bookmark}
|
||||
formRef={formRef}
|
||||
setTags={setTags}
|
||||
tags={tags}
|
||||
/>
|
||||
}
|
||||
buttons={
|
||||
<div className="mb-6 md:mb-2">
|
||||
<DialogButton
|
||||
disabled={isLoading}
|
||||
onClick={handleSubmitForm}
|
||||
className="bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600"
|
||||
>
|
||||
{isLoading ? <Spinner /> : localize('com_ui_save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkEditDialog;
|
||||
195
client/src/components/Bookmarks/BookmarkForm.tsx
Normal file
195
client/src/components/Bookmarks/BookmarkForm.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import type {
|
||||
TConversationTag,
|
||||
TConversation,
|
||||
TConversationTagRequest,
|
||||
} from 'librechat-data-provider';
|
||||
import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/';
|
||||
import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { Checkbox, Label, TextareaAutosize } from '~/components/ui/';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TBookmarkFormProps = {
|
||||
bookmark?: TConversationTag;
|
||||
conversation?: TConversation;
|
||||
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
formRef: React.RefObject<HTMLFormElement>;
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
tags?: string[];
|
||||
setTags?: (tags: string[]) => void;
|
||||
};
|
||||
const BookmarkForm = ({
|
||||
bookmark,
|
||||
conversation,
|
||||
onOpenChange,
|
||||
formRef,
|
||||
setIsLoading,
|
||||
tags,
|
||||
setTags,
|
||||
}: TBookmarkFormProps) => {
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
const mutation = useConversationTagMutation(bookmark?.tag);
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
getValues,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<TConversationTagRequest>({
|
||||
defaultValues: {
|
||||
tag: bookmark?.tag || '',
|
||||
description: bookmark?.description || '',
|
||||
conversationId: conversation?.conversationId || '',
|
||||
addToConversation: conversation ? true : false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bookmark) {
|
||||
setValue('tag', bookmark.tag || '');
|
||||
setValue('description', bookmark.description || '');
|
||||
}
|
||||
}, [bookmark, setValue]);
|
||||
|
||||
const onSubmit = (data: TConversationTagRequest) => {
|
||||
if (mutation.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (data.tag === bookmark?.tag && data.description === bookmark?.description) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
mutation.mutate(data, {
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_success')
|
||||
: localize('com_ui_bookmarks_create_success'),
|
||||
});
|
||||
setIsLoading(false);
|
||||
onOpenChange(false);
|
||||
if (setTags && data.addToConversation) {
|
||||
const newTags = [...(tags || []), data.tag].filter(
|
||||
(tag) => tag !== undefined,
|
||||
) as string[];
|
||||
setTags(newTags);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_error')
|
||||
: localize('com_ui_bookmarks_create_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
className="mt-6"
|
||||
aria-label="Bookmark form"
|
||||
method="POST"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_bookmarks_title')}
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="bookmark-tag"
|
||||
aria-label="Bookmark"
|
||||
{...register('tag', {
|
||||
required: 'tag is required',
|
||||
maxLength: {
|
||||
value: 128,
|
||||
message: localize('com_auth_password_max_length'),
|
||||
},
|
||||
validate: (value) => {
|
||||
return (
|
||||
value === bookmark?.tag ||
|
||||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
|
||||
'tag must be unique'
|
||||
);
|
||||
},
|
||||
})}
|
||||
aria-invalid={!!errors.tag}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex h-10 max-h-10 w-full resize-none border-gray-100 px-3 py-2 dark:border-gray-600',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
placeholder=" "
|
||||
/>
|
||||
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="bookmark-description" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_bookmarks_description')}
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
{...register('description', {
|
||||
maxLength: {
|
||||
value: 1048,
|
||||
message: 'Maximum 1048 characters',
|
||||
},
|
||||
})}
|
||||
id="bookmark-description"
|
||||
disabled={false}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{conversation && (
|
||||
<div className="flex w-full items-center">
|
||||
<Controller
|
||||
name="addToConversation"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field?.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label text-token-text-primary w-full cursor-pointer"
|
||||
htmlFor="addToConversation"
|
||||
onClick={() =>
|
||||
setValue('addToConversation', !getValues('addToConversation'), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex select-none items-center">
|
||||
{localize('com_ui_bookmarks_add_to_conversation')}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkForm;
|
||||
74
client/src/components/Bookmarks/BookmarkItem.tsx
Normal file
74
client/src/components/Bookmarks/BookmarkItem.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState } from 'react';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import type { FC } from 'react';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type MenuItemProps = {
|
||||
tag: string;
|
||||
selected: boolean;
|
||||
count?: number;
|
||||
handleSubmit: (tag: string) => Promise<void>;
|
||||
icon?: React.ReactNode;
|
||||
highlightSelected?: boolean;
|
||||
};
|
||||
|
||||
const BookmarkItem: FC<MenuItemProps> = ({
|
||||
tag,
|
||||
selected,
|
||||
count,
|
||||
handleSubmit,
|
||||
icon,
|
||||
highlightSelected,
|
||||
...rest
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const clickHandler = async () => {
|
||||
setIsLoading(true);
|
||||
await handleSubmit(tag);
|
||||
setIsLoading(false);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
className={cn(
|
||||
'group m-1.5 flex cursor-pointer gap-2 rounded px-1 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',
|
||||
)}
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
<div className="break-all">{tag}</div>
|
||||
</div>
|
||||
|
||||
{count !== undefined && (
|
||||
<div className="flex items-center justify-end">
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto w-9 min-w-max whitespace-nowrap rounded-full bg-white px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-gray-600 ring-1 ring-inset ring-gray-200',
|
||||
'dark:bg-gray-800 dark:text-white dark:ring-gray-100/50',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default BookmarkItem;
|
||||
30
client/src/components/Bookmarks/BookmarkItems.tsx
Normal file
30
client/src/components/Bookmarks/BookmarkItems.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { FC } from 'react';
|
||||
import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import BookmarkItem from './BookmarkItem';
|
||||
|
||||
const BookmarkItems: FC<{
|
||||
tags: string[];
|
||||
handleSubmit: (tag: string) => Promise<void>;
|
||||
header: React.ReactNode;
|
||||
highlightSelected?: boolean;
|
||||
}> = ({ tags, handleSubmit, header, highlightSelected }) => {
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
|
||||
{bookmarks.length > 0 &&
|
||||
bookmarks.map((bookmark) => (
|
||||
<BookmarkItem
|
||||
key={bookmark.tag}
|
||||
tag={bookmark.tag}
|
||||
selected={tags.includes(bookmark.tag)}
|
||||
count={bookmark.count}
|
||||
handleSubmit={handleSubmit}
|
||||
highlightSelected={highlightSelected}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default BookmarkItems;
|
||||
49
client/src/components/Bookmarks/DeleteBookmarkButton.tsx
Normal file
49
client/src/components/Bookmarks/DeleteBookmarkButton.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { useDeleteConversationTagMutation } from '~/data-provider';
|
||||
import TooltipIcon from '~/components/ui/TooltipIcon';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import { Label } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DeleteBookmarkButton: FC<{ bookmark: string }> = ({ bookmark }) => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const deleteBookmarkMutation = useDeleteConversationTagMutation({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_delete_success'),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
await deleteBookmarkMutation.mutateAsync(bookmark);
|
||||
}, [bookmark, deleteBookmarkMutation]);
|
||||
|
||||
return (
|
||||
<TooltipIcon
|
||||
disabled={false}
|
||||
appendLabel={false}
|
||||
title="Delete Bookmark"
|
||||
confirmMessage={
|
||||
<Label htmlFor="bookmark" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_bookmark_delete_confirm')} : {bookmark}
|
||||
</Label>
|
||||
}
|
||||
confirm={confirmDelete}
|
||||
className="hover:text-gray-300 focus-visible:bg-gray-100 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default DeleteBookmarkButton;
|
||||
33
client/src/components/Bookmarks/EditBookmarkButton.tsx
Normal file
33
client/src/components/Bookmarks/EditBookmarkButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { FC } from 'react';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import BookmarkEditDialog from './BookmarkEditDialog';
|
||||
import { EditIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '~/components/ui';
|
||||
const EditBookmarkButton: FC<{ bookmark: TConversationTag }> = ({ bookmark }) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<BookmarkEditDialog
|
||||
bookmark={bookmark}
|
||||
trigger={
|
||||
<button className="size-4 hover:text-gray-300 focus-visible:bg-gray-100 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600">
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<EditIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={0}>
|
||||
{localize('com_ui_edit')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditBookmarkButton;
|
||||
6
client/src/components/Bookmarks/index.ts
Normal file
6
client/src/components/Bookmarks/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { default as DeleteBookmarkButton } from './DeleteBookmarkButton';
|
||||
export { default as EditBookmarkButton } from './EditBookmarkButton';
|
||||
export { default as BookmarkEditDialog } from './BookmarkEditDialog';
|
||||
export { default as BookmarkItems } from './BookmarkItems';
|
||||
export { default as BookmarkItem } from './BookmarkItem';
|
||||
export { default as BookmarkForm } from './BookmarkForm';
|
||||
|
|
@ -6,6 +6,7 @@ import type { ContextType } from '~/common';
|
|||
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
|
||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
import HeaderOptions from './Input/HeaderOptions';
|
||||
import BookmarkMenu from './Menus/BookmarkMenu';
|
||||
import AddMultiConvo from './AddMultiConvo';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export default function Header() {
|
|||
className="pl-0"
|
||||
/>
|
||||
)}
|
||||
<BookmarkMenu />
|
||||
<AddMultiConvo />
|
||||
</div>
|
||||
{!isSmallScreen && (
|
||||
|
|
|
|||
134
client/src/components/Chat/Menus/BookmarkMenu.tsx
Normal file
134
client/src/components/Chat/Menus/BookmarkMenu.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useEffect, 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 { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
|
||||
import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
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 [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 { 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';
|
||||
|
||||
if (!isActiveConvo) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const onOpenChange = async (open: boolean) => {
|
||||
if (!open) {
|
||||
setIsOpen(open);
|
||||
return;
|
||||
}
|
||||
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] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Root open={open} onOpenChange={onOpenChange}>
|
||||
<Trigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'pointer-cursor relative flex flex-col rounded-md border border-gray-100 bg-white text-left focus:outline-none focus:ring-0 focus:ring-offset-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:ring-0 focus:ring-offset-0',
|
||||
)}
|
||||
title={localize('com_ui_bookmarks')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : tags && tags.length > 0 ? (
|
||||
<BookmarkFilledIcon className="icon-sm" />
|
||||
) : (
|
||||
<BookmarkIcon className="icon-sm" />
|
||||
)}
|
||||
</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]',
|
||||
)}
|
||||
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}
|
||||
/>
|
||||
</BookmarkContext.Provider>
|
||||
)}
|
||||
</Content>
|
||||
</Portal>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkMenu;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { 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 { 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 }) => {
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const { mutateAsync } = useTagConversationMutation(conversation?.conversationId ?? '');
|
||||
const handleSubmit = useCallback(
|
||||
async (tag: string): Promise<void> => {
|
||||
if (tags !== undefined && conversation?.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 });
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: 'Error adding bookmark',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[tags, conversation],
|
||||
);
|
||||
|
||||
return (
|
||||
<BookmarkItems
|
||||
tags={tags}
|
||||
handleSubmit={handleSubmit}
|
||||
header={
|
||||
<div>
|
||||
<BookmarkEditDialog
|
||||
conversation={conversation}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
trigger={
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded px-1 !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"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookmarkPlusIcon className="size-4" />
|
||||
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
99
client/src/components/Nav/Bookmarks/BookmarkNav.tsx
Normal file
99
client/src/components/Nav/Bookmarks/BookmarkNav.tsx
Normal 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;
|
||||
58
client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx
Normal file
58
client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
72
client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx
Normal file
72
client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { BookmarkPlusIcon } from 'lucide-react';
|
||||
import type { FC } from 'react';
|
||||
import { useConversationTagsQuery, useRebuildConversationTagsMutation } from '~/data-provider';
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } 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 { cn } from '~/utils/';
|
||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
||||
|
||||
const BookmarkPanel: FC<{ open: boolean; onOpenChange: (open: boolean) => void }> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { mutate, isLoading } = useRebuildConversationTagsMutation();
|
||||
const { data } = useConversationTagsQuery();
|
||||
const rebuildTags = () => {
|
||||
mutate({});
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={true}
|
||||
className={cn(
|
||||
'overflow-x-auto shadow-2xl dark:bg-gray-700 dark:text-white md:max-h-[600px] md:min-h-[373px] md:w-[680px]',
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
{localize('com_ui_bookmarks')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
|
||||
<div className="p-0 sm:p-6 sm:pt-4">
|
||||
<BookmarkTable />
|
||||
<div className="mt-5 sm:mt-4" />
|
||||
<div className="flex justify-between gap-2 pr-2 sm:pr-0">
|
||||
<Button variant="outline" onClick={rebuildTags} className="text-sm">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{localize('com_ui_bookmarks_rebuild')}
|
||||
<HoverCardSettings side="bottom" text="com_nav_info_bookmarks_rebuild" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<BookmarkEditDialog
|
||||
trigger={
|
||||
<Button variant="outline" onClick={rebuildTags} className="text-sm">
|
||||
<BookmarkPlusIcon className="mr-1 size-4" />
|
||||
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button variant="subtle" onClick={() => onOpenChange(!open)} className="text-sm">
|
||||
{localize('com_ui_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BookmarkContext.Provider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default BookmarkPanel;
|
||||
59
client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Normal file
59
client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider';
|
||||
import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import BookmarkTableRow from './BookmarkTableRow';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const BookmarkTable = () => {
|
||||
const localize = useLocalize();
|
||||
const [rows, setRows] = useState<ConversationTagsResponse>([]);
|
||||
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
useEffect(() => {
|
||||
setRows(bookmarks?.map((item) => ({ id: item.tag, ...item })) || []);
|
||||
}, [bookmarks]);
|
||||
|
||||
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
|
||||
setRows((prevTags: TConversationTag[]) => {
|
||||
const updatedRows = [...prevTags];
|
||||
const [movedRow] = updatedRows.splice(dragIndex, 1);
|
||||
updatedRows.splice(hoverIndex, 0, movedRow);
|
||||
return updatedRows;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderRow = useCallback((row: TConversationTag, position: number) => {
|
||||
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={position} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks }}>
|
||||
<div
|
||||
className={cn(
|
||||
'container',
|
||||
'relative h-[300px] overflow-auto',
|
||||
'-mx-4 w-auto ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg',
|
||||
)}
|
||||
>
|
||||
<table className="min-w-full divide-gray-300">
|
||||
<thead className="sticky top-0 z-10 border-b bg-white">
|
||||
<tr className="text-left text-sm font-semibold text-gray-900">
|
||||
<th className="w-96 px-3 py-3.5 pl-6">
|
||||
<div>{localize('com_ui_bookmarks_title')}</div>
|
||||
</th>
|
||||
<th className="w-28 px-3 py-3.5 sm:pl-6">
|
||||
<div>{localize('com_ui_bookmarks_count')}</div>
|
||||
</th>
|
||||
|
||||
<th className="flex-grow px-3 py-3.5 sm:pl-6"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">{rows.map((row, i) => renderRow(row, i))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</BookmarkContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkTable;
|
||||
135
client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx
Normal file
135
client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useRef } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import type { FC } from 'react';
|
||||
import type { Identifier, XYCoord } from 'dnd-core';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const ItemTypes = {
|
||||
CARD: 'card',
|
||||
};
|
||||
|
||||
export interface BookmarkItemProps {
|
||||
position: number;
|
||||
moveRow: (dragIndex: number, hoverIndex: number) => void;
|
||||
row: TConversationTag;
|
||||
}
|
||||
|
||||
interface DragItem {
|
||||
index: number;
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const BookmarkTableRow: FC<BookmarkItemProps> = ({ position, moveRow, row, ...rest }) => {
|
||||
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_endpoint_preset_save_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const [{ handlerId }, drop] = useDrop<DragItem, void, { handlerId: Identifier | null }>({
|
||||
accept: ItemTypes.CARD,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
drop(item: DragItem, monitor) {
|
||||
handleDrop(item);
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = position;
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveRow(dragIndex, hoverIndex);
|
||||
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.CARD,
|
||||
item: () => {
|
||||
return { id: row.tag, index: position };
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (position > 0) {
|
||||
drag(drop(ref));
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
'group cursor-pointer gap-2 rounded text-sm hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5',
|
||||
isDragging ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
key={row.tag}
|
||||
ref={ref}
|
||||
data-handler-id={handlerId}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
>
|
||||
<td className="w-96 py-2 pl-6 pr-3">{row.tag}</td>
|
||||
<td className={cn('w-28 py-2 pl-4 pr-3 sm:pl-6')}>
|
||||
<span className="py-1">{row.count}</span>
|
||||
</td>
|
||||
<td className="flex-grow py-2 pl-4 pr-4 sm:pl-6">
|
||||
{position > 0 && (
|
||||
<div className="flex w-full items-center justify-end gap-2 py-1 text-gray-400">
|
||||
<EditBookmarkButton bookmark={row} />
|
||||
<DeleteBookmarkButton bookmark={row.tag} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkTableRow;
|
||||
|
|
@ -41,9 +41,9 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
|||
? 'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
: '',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (link.onClick) {
|
||||
link.onClick();
|
||||
link.onClick(e);
|
||||
setActive('');
|
||||
return;
|
||||
}
|
||||
|
|
@ -87,9 +87,9 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
|||
'hover:bg-gray-50 data-[state=open]:bg-gray-50 data-[state=open]:text-black dark:hover:bg-gray-700 dark:data-[state=open]:bg-gray-700 dark:data-[state=open]:text-white',
|
||||
'w-full justify-start rounded-md border dark:border-gray-700',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (link.onClick) {
|
||||
link.onClick();
|
||||
link.onClick(e);
|
||||
setActive('');
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/compo
|
|||
import { TooltipProvider, Tooltip } from '~/components/ui/Tooltip';
|
||||
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
|
||||
import { useMediaQuery, useLocalStorage } from '~/hooks';
|
||||
import BookmarkPanel from './Bookmarks/BookmarkPanel';
|
||||
import NavToggle from '~/components/Nav/NavToggle';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import Switcher from './Switcher';
|
||||
|
|
@ -79,8 +80,20 @@ const SidePanel = ({
|
|||
localStorage.setItem('fullPanelCollapse', 'true');
|
||||
panelRef.current?.collapse();
|
||||
}, []);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const manageBookmarks = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setShowBookmarks((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const Links = useSideNavLinks({ hidePanel, assistants, keyProvided, endpoint, interfaceConfig });
|
||||
const Links = useSideNavLinks({
|
||||
hidePanel,
|
||||
assistants,
|
||||
keyProvided,
|
||||
endpoint,
|
||||
interfaceConfig,
|
||||
manageBookmarks,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const throttledSaveLayout = useCallback(
|
||||
|
|
@ -128,6 +141,7 @@ const SidePanel = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{showBookmarks && <BookmarkPanel open={showBookmarks} onOpenChange={setShowBookmarks} />}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
|
|
@ -216,7 +230,7 @@ const SidePanel = ({
|
|||
</ResizablePanelGroup>
|
||||
</TooltipProvider>
|
||||
<div
|
||||
className={`nav-mask${!isCollapsed ? ' active' : ''}`}
|
||||
className={`nav-mask${!isCollapsed ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setIsCollapsed(() => {
|
||||
localStorage.setItem('fullPanelCollapse', 'true');
|
||||
|
|
|
|||
88
client/src/components/ui/TooltipIcon.tsx
Normal file
88
client/src/components/ui/TooltipIcon.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { ReactElement } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Label,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { CrossIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function TooltipIcon({
|
||||
disabled,
|
||||
appendLabel = false,
|
||||
title,
|
||||
className = '',
|
||||
confirm,
|
||||
confirmMessage,
|
||||
icon,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
appendLabel?: boolean;
|
||||
className?: string;
|
||||
confirm?: () => void;
|
||||
confirmMessage?: ReactElement;
|
||||
icon?: ReactElement;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const renderDeleteButton = () => {
|
||||
if (appendLabel) {
|
||||
return (
|
||||
<>
|
||||
{icon} {localize('com_ui_delete')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{icon}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={0}>
|
||||
{localize('com_ui_delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
if (!confirmMessage) {
|
||||
return (
|
||||
<button className={className} onClick={confirm}>
|
||||
{disabled ? <CrossIcon /> : renderDeleteButton()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className={className}>{disabled ? <CrossIcon /> : renderDeleteButton()}</button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
showCloseButton={false}
|
||||
title={title}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">{confirmMessage}</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirm,
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue