mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-04 09:38:50 +01:00
🔖 feat: Enhance Bookmarks UX, add RBAC, toggle via librechat.yaml (#3747)
* chore: update package version to 0.7.416 * chore: Update Role.js imports order * refactor: move updateTagsInConvo to tags route, add RBAC for tags * refactor: add updateTagsInConvoOptions * fix: loading state for bookmark form * refactor: update primaryText class in TitleButton component * refactor: remove duplicate bookmarks and theming * refactor: update EditIcon component to use React.forwardRef * refactor: add _id field to tConversationTagSchema * refactor: remove promises * refactor: move mutation logic from BookmarkForm -> BookmarkEditDialog * refactor: update button class in BookmarkForm component * fix: conversation mutations and add better logging to useConversationTagMutation * refactor: update logger message in BookmarkEditDialog component * refactor: improve UI consistency in BookmarkNav and NewChat components * refactor: update logger message in BookmarkEditDialog component * refactor: Add tags prop to BookmarkForm component * refactor: Update BookmarkForm to avoid tag mutation if the tag already exists; also close dialog on submission programmatically * refactor: general role helper function to support updating access permissions for different permission types * refactor: Update getLatestText function to handle undefined values in message.content * refactor: Update useHasAccess hook to handle null role values for authenticated users * feat: toggle bookmarks access * refactor: Update PromptsCommand to handle access permissions for prompts * feat: updateConversationSelector * refactor: rename `vars` to `tagToDelete` for clarity * fix: prevent recreation of deleted tags in BookmarkMenu on Item Click * ci: mock updateBookmarksAccess function * ci: mock updateBookmarksAccess function
This commit is contained in:
parent
366e4c5adb
commit
f86e9dd04c
39 changed files with 530 additions and 298 deletions
|
|
@ -1,12 +1,17 @@
|
|||
import React, { useRef, useState, Dispatch, SetStateAction } from 'react';
|
||||
import React, { useRef, Dispatch, SetStateAction } from 'react';
|
||||
import { TConversationTag, TConversation } from 'librechat-data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { OGDialog, OGDialogClose } from '~/components/ui/';
|
||||
import { useConversationTagMutation } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { OGDialog } from '~/components/ui';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import BookmarkForm from './BookmarkForm';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Spinner } from '../svg';
|
||||
import { logger } from '~/utils';
|
||||
|
||||
type BookmarkEditDialogProps = {
|
||||
context: string;
|
||||
bookmark?: TConversationTag;
|
||||
conversation?: TConversation;
|
||||
tags?: string[];
|
||||
|
|
@ -16,6 +21,7 @@ type BookmarkEditDialogProps = {
|
|||
};
|
||||
|
||||
const BookmarkEditDialog = ({
|
||||
context,
|
||||
bookmark,
|
||||
conversation,
|
||||
tags,
|
||||
|
|
@ -24,9 +30,40 @@ const BookmarkEditDialog = ({
|
|||
setOpen,
|
||||
}: BookmarkEditDialogProps) => {
|
||||
const localize = useLocalize();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const mutation = useConversationTagMutation({
|
||||
context,
|
||||
tag: bookmark?.tag,
|
||||
options: {
|
||||
onSuccess: (_data, vars) => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_success')
|
||||
: localize('com_ui_bookmarks_create_success'),
|
||||
});
|
||||
setOpen(false);
|
||||
logger.log('tag_mutation', 'tags before setting', tags);
|
||||
if (setTags && vars.addToConversation === true) {
|
||||
const newTags = [...(tags || []), vars.tag].filter(
|
||||
(tag) => tag !== undefined,
|
||||
) as string[];
|
||||
setTags(newTags);
|
||||
logger.log('tag_mutation', 'tags after', newTags);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_error')
|
||||
: localize('com_ui_bookmarks_create_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitForm = () => {
|
||||
if (formRef.current) {
|
||||
formRef.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
|
|
@ -40,26 +77,23 @@ const BookmarkEditDialog = ({
|
|||
showCloseButton={false}
|
||||
main={
|
||||
<BookmarkForm
|
||||
tags={tags}
|
||||
setOpen={setOpen}
|
||||
mutation={mutation}
|
||||
conversation={conversation}
|
||||
onOpenChange={setOpen}
|
||||
setIsLoading={setIsLoading}
|
||||
bookmark={bookmark}
|
||||
formRef={formRef}
|
||||
setTags={setTags}
|
||||
tags={tags}
|
||||
/>
|
||||
}
|
||||
buttons={
|
||||
<OGDialogClose asChild>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
onClick={handleSubmitForm}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{isLoading ? <Spinner /> : localize('com_ui_save')}
|
||||
</button>
|
||||
</OGDialogClose>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isLoading}
|
||||
onClick={handleSubmitForm}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{mutation.isLoading ? <Spinner /> : localize('com_ui_save')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</OGDialog>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,39 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
TConversationTag,
|
||||
TConversation,
|
||||
TConversationTag,
|
||||
TConversationTagRequest,
|
||||
} from 'librechat-data-provider';
|
||||
import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/';
|
||||
import { cn, removeFocusOutlines, defaultTextProps, logger } from '~/utils';
|
||||
import { Checkbox, Label, TextareaAutosize } from '~/components/ui';
|
||||
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 = {
|
||||
tags?: string[];
|
||||
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;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
mutation: ReturnType<typeof useConversationTagMutation>;
|
||||
};
|
||||
const BookmarkForm = ({
|
||||
bookmark,
|
||||
conversation,
|
||||
onOpenChange,
|
||||
formRef,
|
||||
setIsLoading,
|
||||
tags,
|
||||
setTags,
|
||||
bookmark,
|
||||
mutation,
|
||||
conversation,
|
||||
setOpen,
|
||||
formRef,
|
||||
}: TBookmarkFormProps) => {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
const mutation = useConversationTagMutation(bookmark?.tag);
|
||||
const onSuccess = useBookmarkSuccess(conversation?.conversationId || '');
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -46,56 +44,47 @@ const BookmarkForm = ({
|
|||
formState: { errors },
|
||||
} = useForm<TConversationTagRequest>({
|
||||
defaultValues: {
|
||||
tag: bookmark?.tag || '',
|
||||
description: bookmark?.description || '',
|
||||
conversationId: conversation?.conversationId || '',
|
||||
tag: bookmark?.tag ?? '',
|
||||
description: bookmark?.description ?? '',
|
||||
conversationId: conversation?.conversationId ?? '',
|
||||
addToConversation: conversation ? true : false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bookmark) {
|
||||
setValue('tag', bookmark.tag || '');
|
||||
setValue('description', bookmark.description || '');
|
||||
if (bookmark && bookmark.tag) {
|
||||
setValue('tag', bookmark.tag);
|
||||
setValue('description', bookmark.description ?? '');
|
||||
}
|
||||
}, [bookmark, setValue]);
|
||||
|
||||
const onSubmit = (data: TConversationTagRequest) => {
|
||||
logger.log('tag_mutation', 'BookmarkForm - onSubmit: data', data);
|
||||
if (mutation.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (data.tag === bookmark?.tag && data.description === bookmark?.description) {
|
||||
return;
|
||||
}
|
||||
if (data.tag != null && (tags ?? []).includes(data.tag)) {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_create_exists'),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allTags =
|
||||
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
|
||||
if (allTags.some((tag) => tag.tag === data.tag)) {
|
||||
showToast({
|
||||
message: localize('com_ui_bookmarks_create_exists'),
|
||||
status: 'warning',
|
||||
});
|
||||
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);
|
||||
onSuccess(newTags);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: bookmark
|
||||
? localize('com_ui_bookmarks_update_error')
|
||||
: localize('com_ui_bookmarks_create_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
mutation.mutate(data);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -175,10 +164,11 @@ const BookmarkForm = ({
|
|||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
className="form-check-label text-token-text-primary w-full cursor-pointer"
|
||||
className="form-check-label w-full cursor-pointer text-text-primary"
|
||||
onClick={() =>
|
||||
setValue('addToConversation', !getValues('addToConversation'), {
|
||||
setValue('addToConversation', !(getValues('addToConversation') ?? false), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ type MenuItemProps = {
|
|||
tag: string | React.ReactNode;
|
||||
selected: boolean;
|
||||
count?: number;
|
||||
handleSubmit: (tag?: string) => Promise<void>;
|
||||
handleSubmit: (tag?: string) => void;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
|
|
@ -17,12 +17,12 @@ const BookmarkItem: FC<MenuItemProps> = ({ tag, selected, handleSubmit, icon, ..
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const clickHandler = async () => {
|
||||
if (tag === 'New Bookmark') {
|
||||
await handleSubmit();
|
||||
handleSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await handleSubmit(tag as string);
|
||||
handleSubmit(tag as string);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ const BookmarkItem: FC<MenuItemProps> = ({ tag, selected, handleSubmit, icon, ..
|
|||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
if (icon) {
|
||||
if (icon != null) {
|
||||
return icon;
|
||||
}
|
||||
if (isLoading) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
|||
import BookmarkItem from './BookmarkItem';
|
||||
interface BookmarkItemsProps {
|
||||
tags: string[];
|
||||
handleSubmit: (tag?: string) => Promise<void>;
|
||||
handleSubmit: (tag?: string) => void;
|
||||
header: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -14,9 +14,9 @@ const BookmarkItems: FC<BookmarkItemsProps> = ({ tags, handleSubmit, header }) =
|
|||
<>
|
||||
{header}
|
||||
{bookmarks.length > 0 && <div className="my-1.5 h-px" role="none" />}
|
||||
{bookmarks.map((bookmark) => (
|
||||
{bookmarks.map((bookmark, i) => (
|
||||
<BookmarkItem
|
||||
key={bookmark.tag}
|
||||
key={`${bookmark._id ?? bookmark.tag}-${i}`}
|
||||
tag={bookmark.tag}
|
||||
selected={tags.includes(bookmark.tag)}
|
||||
handleSubmit={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ const EditBookmarkButton: FC<{
|
|||
|
||||
return (
|
||||
<>
|
||||
<BookmarkEditDialog bookmark={bookmark} open={open} setOpen={setOpen} />
|
||||
<BookmarkEditDialog
|
||||
context="EditBookmarkButton"
|
||||
bookmark={bookmark}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="transition-color flex size-7 items-center justify-center rounded-lg duration-200 hover:bg-surface-hover"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { getConfigDefaults } from 'librechat-data-provider';
|
||||
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import type { ContextType } from '~/common';
|
||||
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
|
||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
import { useMediaQuery, useHasAccess } from '~/hooks';
|
||||
import HeaderOptions from './Input/HeaderOptions';
|
||||
import BookmarkMenu from './Menus/BookmarkMenu';
|
||||
import AddMultiConvo from './AddMultiConvo';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
|
|
@ -21,6 +21,11 @@ export default function Header() {
|
|||
[startupConfig],
|
||||
);
|
||||
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
return (
|
||||
|
|
@ -28,11 +33,11 @@ export default function Header() {
|
|||
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
{!navVisible && <HeaderNewChat />}
|
||||
{interfaceConfig.endpointsMenu && <EndpointsMenu />}
|
||||
{interfaceConfig.endpointsMenu === true && <EndpointsMenu />}
|
||||
{modelSpecs.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
|
||||
{<HeaderOptions interfaceConfig={interfaceConfig} />}
|
||||
{interfaceConfig.presets && <PresetsMenu />}
|
||||
<BookmarkMenu />
|
||||
{interfaceConfig.presets === true && <PresetsMenu />}
|
||||
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
||||
<AddMultiConvo />
|
||||
{isSmallScreen && (
|
||||
<ExportAndShareMenu
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
|
||||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import type { PromptOption } from '~/common';
|
||||
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
|
||||
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import { useLocalize, useCombobox, useHasAccess } from '~/hooks';
|
||||
import { useGetAllPromptGroups } from '~/data-provider';
|
||||
import { useLocalize, useCombobox } from '~/hooks';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import MentionItem from './MentionItem';
|
||||
import store from '~/store';
|
||||
|
|
@ -51,8 +52,13 @@ function PromptsCommand({
|
|||
submitPrompt: (textPrompt: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const hasAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useGetAllPromptGroups(undefined, {
|
||||
enabled: hasAccess,
|
||||
select: (data) => {
|
||||
const mappedArray = data.map((group) => ({
|
||||
id: group._id,
|
||||
|
|
@ -144,6 +150,10 @@ function PromptsCommand({
|
|||
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverContainer
|
||||
index={index}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useState, type FC, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import type { TConversationTag } from 'librechat-data-provider';
|
||||
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
|
||||
import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
|
|
@ -11,19 +13,32 @@ import { NotificationSeverity } from '~/common';
|
|||
import { useToastContext } from '~/Providers';
|
||||
import { useBookmarkSuccess } from '~/hooks';
|
||||
import { Spinner } from '~/components';
|
||||
import { cn } from '~/utils';
|
||||
import { cn, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const BookmarkMenu: FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
|
||||
const conversationId = conversation?.conversationId ?? '';
|
||||
const onSuccess = useBookmarkSuccess(conversationId);
|
||||
const [tags, setTags] = useState<string[]>(conversation?.tags || []);
|
||||
const [open, setOpen] = useState(false);
|
||||
const updateConvoTags = useBookmarkSuccess(conversationId);
|
||||
|
||||
const { mutateAsync, isLoading } = useTagConversationMutation(conversationId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tags, setTags] = useState<string[]>(conversation?.tags || []);
|
||||
|
||||
const mutation = useTagConversationMutation(conversationId, {
|
||||
onSuccess: (newTags: string[]) => {
|
||||
setTags(newTags);
|
||||
updateConvoTags(newTags);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: 'Error adding bookmark',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = useConversationTagsQuery();
|
||||
|
||||
|
|
@ -35,7 +50,7 @@ const BookmarkMenu: FC = () => {
|
|||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (tag?: string): Promise<void> => {
|
||||
(tag?: string) => {
|
||||
if (tag === undefined || tag === '' || !conversationId) {
|
||||
showToast({
|
||||
message: 'Invalid tag or conversationId',
|
||||
|
|
@ -44,34 +59,29 @@ const BookmarkMenu: FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag];
|
||||
await mutateAsync(
|
||||
{
|
||||
tags: newTags,
|
||||
},
|
||||
{
|
||||
onSuccess: (newTags: string[]) => {
|
||||
setTags(newTags);
|
||||
onSuccess(newTags);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: 'Error adding bookmark',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags before setting', tags);
|
||||
const allTags =
|
||||
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
|
||||
const existingTags = allTags.map((t) => t.tag);
|
||||
const filteredTags = tags.filter((t) => existingTags.includes(t));
|
||||
logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags after filtering', filteredTags);
|
||||
const newTags = filteredTags.includes(tag)
|
||||
? filteredTags.filter((t) => t !== tag)
|
||||
: [...filteredTags, tag];
|
||||
logger.log('tag_mutation', 'BookmarkMenu - handleSubmit: tags after', newTags);
|
||||
mutation.mutate({
|
||||
tags: newTags,
|
||||
});
|
||||
},
|
||||
[tags, conversationId, mutateAsync, setTags, onSuccess, showToast],
|
||||
[tags, conversationId, mutation, queryClient, showToast],
|
||||
);
|
||||
|
||||
if (!isActiveConvo) {
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (isLoading) {
|
||||
if (mutation.isLoading) {
|
||||
return <Spinner aria-label="Spinner" />;
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
|
|
@ -80,10 +90,7 @@ const BookmarkMenu: FC = () => {
|
|||
return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />;
|
||||
};
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
setOpen(!open);
|
||||
return Promise.resolve();
|
||||
};
|
||||
const handleToggleOpen = () => setOpen(!open);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -116,6 +123,7 @@ const BookmarkMenu: FC = () => {
|
|||
)}
|
||||
</Menu>
|
||||
<BookmarkEditDialog
|
||||
context="BookmarkMenu - BookmarkEditDialog"
|
||||
conversation={conversation}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { useLocalize } from '~/hooks';
|
|||
|
||||
export const BookmarkMenuItems: FC<{
|
||||
tags: string[];
|
||||
handleToggleOpen?: () => Promise<void>;
|
||||
handleSubmit: (tag?: string) => Promise<void>;
|
||||
handleToggleOpen?: () => void;
|
||||
handleSubmit: (tag?: string) => void;
|
||||
}> = ({
|
||||
tags,
|
||||
handleSubmit,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function TitleButton({ primaryText = '', secondaryText = '' }) {
|
|||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div>
|
||||
<span className="text-token-text-secondary"> {primaryText} </span>
|
||||
<span className="text-text-primary"> {primaryText} </span>
|
||||
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
|
||||
</div>
|
||||
<ChevronDown className="text-token-text-secondary size-4" />
|
||||
|
|
|
|||
|
|
@ -43,14 +43,16 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
|
|||
)}
|
||||
data-testid="bookmark-menu"
|
||||
>
|
||||
<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 className="h-7 w-7 flex-shrink-0">
|
||||
<div className="relative flex h-full items-center justify-center rounded-full border border-border-medium bg-surface-primary-alt text-text-primary">
|
||||
{tags.length > 0 ? (
|
||||
<BookmarkFilledIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<BookmarkIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm text-text-primary">
|
||||
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm font-medium text-text-primary">
|
||||
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
|
||||
</div>
|
||||
</MenuButton>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const BookmarkNavItems: FC<{
|
|||
conversation: TConversation;
|
||||
tags: string[];
|
||||
setTags: (tags: string[]) => void;
|
||||
}> = ({ conversation, tags, setTags }) => {
|
||||
}> = ({ conversation, tags = [], setTags }) => {
|
||||
const [currentConversation, setCurrentConversation] = useState<TConversation>();
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
const localize = useLocalize();
|
||||
|
|
@ -24,19 +24,22 @@ const BookmarkNavItems: FC<{
|
|||
if (tags.some((selectedTag) => selectedTag === tag)) {
|
||||
return tags.filter((selectedTag) => selectedTag !== tag);
|
||||
} else {
|
||||
return [...(tags || []), tag];
|
||||
return [...tags, tag];
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (tag: string) => {
|
||||
const handleSubmit = (tag?: string) => {
|
||||
if (tag === undefined) {
|
||||
return;
|
||||
}
|
||||
const updatedSelected = getUpdatedSelected(tag);
|
||||
setTags(updatedSelected);
|
||||
return Promise.resolve();
|
||||
return;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setTags([]);
|
||||
return Promise.resolve();
|
||||
return;
|
||||
};
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useCallback, useEffect, useState, useMemo, memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import {
|
||||
useLocalize,
|
||||
useHasAccess,
|
||||
useMediaQuery,
|
||||
useAuthContext,
|
||||
useConversation,
|
||||
useLocalStorage,
|
||||
useNavScrolling,
|
||||
useConversations,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import { useConversationsInfiniteQuery } from '~/data-provider';
|
||||
import { TooltipProvider, Tooltip } from '~/components/ui';
|
||||
|
|
@ -41,6 +43,11 @@ const Nav = ({
|
|||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [isToggleHovering, setIsToggleHovering] = useState(false);
|
||||
|
||||
const hasAccessToBookmarks = useHasAccess({
|
||||
permissionType: PermissionTypes.BOOKMARKS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsHovering(true);
|
||||
}, []);
|
||||
|
|
@ -128,7 +135,7 @@ const Nav = ({
|
|||
<div
|
||||
data-testid="nav"
|
||||
className={
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-850 md:max-w-[260px]'
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt md:max-w-[260px]'
|
||||
}
|
||||
style={{
|
||||
width: navVisible ? navWidth : '0px',
|
||||
|
|
@ -168,7 +175,9 @@ const Nav = ({
|
|||
{isSearchEnabled === true && (
|
||||
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
|
||||
)}
|
||||
<BookmarkNav tags={tags} setTags={setTags} />
|
||||
{hasAccessToBookmarks === true && (
|
||||
<BookmarkNav tags={tags} setTags={setTags} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NewChat
|
||||
|
|
@ -181,7 +190,9 @@ const Nav = ({
|
|||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
)}
|
||||
<BookmarkNav tags={tags} setTags={setTags} />
|
||||
{hasAccessToBookmarks === true && (
|
||||
<BookmarkNav tags={tags} setTags={setTags} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export default function NewChat({
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{subHeaders ? subHeaders : null}
|
||||
{subHeaders != null ? subHeaders : null}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const BookmarkPanel = () => {
|
|||
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
|
||||
<BookmarkTable />
|
||||
<div className="flex justify-between gap-2">
|
||||
<BookmarkEditDialog open={open} setOpen={setOpen} />
|
||||
<BookmarkEditDialog context="BookmarkPanel" open={open} setOpen={setOpen} />
|
||||
<Button variant="outline" className="w-full text-sm" onClick={() => setOpen(!open)}>
|
||||
<BookmarkPlusIcon className="mr-1 size-4" />
|
||||
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@ import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext
|
|||
import BookmarkTableRow from './BookmarkTableRow';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const removeDuplicates = (bookmarks: TConversationTag[]) => {
|
||||
const seen = new Set();
|
||||
return bookmarks.filter((bookmark) => {
|
||||
const duplicate = seen.has(bookmark._id);
|
||||
seen.add(bookmark._id);
|
||||
return !duplicate;
|
||||
});
|
||||
};
|
||||
|
||||
const BookmarkTable = () => {
|
||||
const localize = useLocalize();
|
||||
const [rows, setRows] = useState<ConversationTagsResponse>([]);
|
||||
|
|
@ -12,13 +21,10 @@ const BookmarkTable = () => {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const pageSize = 10;
|
||||
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
const { bookmarks = [] } = useBookmarkContext();
|
||||
useEffect(() => {
|
||||
setRows(
|
||||
bookmarks
|
||||
.map((item) => ({ id: item.tag, ...item }))
|
||||
.sort((a, b) => a.position - b.position) || [],
|
||||
);
|
||||
const _bookmarks = removeDuplicates(bookmarks).sort((a, b) => a.position - b.position);
|
||||
setRows(_bookmarks);
|
||||
}, [bookmarks]);
|
||||
|
||||
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
|
||||
|
|
@ -32,17 +38,16 @@ const BookmarkTable = () => {
|
|||
|
||||
const renderRow = useCallback(
|
||||
(row: TConversationTag) => {
|
||||
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={row.position} />;
|
||||
return <BookmarkTableRow key={row._id} moveRow={moveRow} row={row} position={row.position} />;
|
||||
},
|
||||
[moveRow],
|
||||
);
|
||||
|
||||
const filteredRows = rows.filter((row) =>
|
||||
row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
const filteredRows = rows.filter(
|
||||
(row) => row.tag && row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
|
||||
|
||||
return (
|
||||
<BookmarkContext.Provider value={{ bookmarks }}>
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
|
|
@ -53,14 +58,14 @@ const BookmarkTable = () => {
|
|||
className="w-full border-border-light placeholder:text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
|
||||
<div className="overflow-y-auto rounded-md border border-border-light">
|
||||
<Table className="table-fixed border-separate border-spacing-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell className="w-full px-3 py-3.5 pl-6 dark:bg-gray-700">
|
||||
<TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
|
||||
<div>{localize('com_ui_bookmarks_title')}</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-full px-3 py-3.5 dark:bg-gray-700 sm:pl-6">
|
||||
<TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
|
||||
<div>{localize('com_ui_bookmarks_count')}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -69,7 +74,7 @@ const BookmarkTable = () => {
|
|||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="pl-1 text-gray-400">
|
||||
<div className="pl-1 text-text-secondary">
|
||||
{localize('com_ui_showing')} {pageIndex * pageSize + 1} -{' '}
|
||||
{Math.min((pageIndex + 1) * pageSize, filteredRows.length)} {localize('com_ui_of')}{' '}
|
||||
{filteredRows.length}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, posit
|
|||
const [isHovered, setIsHovered] = useState(false);
|
||||
const ref = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
const mutation = useConversationTagMutation(row.tag);
|
||||
const mutation = useConversationTagMutation({ context: 'BookmarkTableRow', tag: row.tag });
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, posit
|
|||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
className="cursor-move hover:bg-surface-secondary"
|
||||
className="cursor-move hover:bg-surface-tertiary"
|
||||
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,37 @@
|
|||
import React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function EditIcon({ className = 'icon-md', size = '1.2em' }) {
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
height={size}
|
||||
width={size}
|
||||
className={cn(className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.2929 4.29291C15.0641 2.52167 17.9359 2.52167 19.7071 4.2929C21.4783 6.06414 21.4783 8.93588 19.7071 10.7071L18.7073 11.7069L11.1603 19.2539C10.7182 19.696 10.1489 19.989 9.53219 20.0918L4.1644 20.9864C3.84584 21.0395 3.52125 20.9355 3.29289 20.7071C3.06453 20.4788 2.96051 20.1542 3.0136 19.8356L3.90824 14.4678C4.01103 13.8511 4.30396 13.2818 4.7461 12.8397L13.2929 4.29291ZM13 7.41422L6.16031 14.2539C6.01293 14.4013 5.91529 14.591 5.88102 14.7966L5.21655 18.7835L9.20339 18.119C9.40898 18.0847 9.59872 17.9871 9.7461 17.8397L16.5858 11L13 7.41422ZM18 9.5858L14.4142 6.00001L14.7071 5.70712C15.6973 4.71693 17.3027 4.71693 18.2929 5.70712C19.2831 6.69731 19.2831 8.30272 18.2929 9.29291L18 9.5858Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const EditIcon = React.forwardRef<SVGSVGElement>(
|
||||
(
|
||||
props: {
|
||||
className?: string;
|
||||
size?: string;
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { className = 'icon-md', size = '1.2em' } = props;
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
height={size}
|
||||
width={size}
|
||||
className={cn(className)}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.2929 4.29291C15.0641 2.52167 17.9359 2.52167 19.7071 4.2929C21.4783 6.06414 21.4783 8.93588 19.7071 10.7071L18.7073 11.7069L11.1603 19.2539C10.7182 19.696 10.1489 19.989 9.53219 20.0918L4.1644 20.9864C3.84584 21.0395 3.52125 20.9355 3.29289 20.7071C3.06453 20.4788 2.96051 20.1542 3.0136 19.8356L3.90824 14.4678C4.01103 13.8511 4.30396 13.2818 4.7461 12.8397L13.2929 4.29291ZM13 7.41422L6.16031 14.2539C6.01293 14.4013 5.91529 14.591 5.88102 14.7966L5.21655 18.7835L9.20339 18.119C9.40898 18.0847 9.59872 17.9871 9.7461 17.8397L16.5858 11L13 7.41422ZM18 9.5858L14.4142 6.00001L14.7071 5.70712C15.6973 4.71693 17.3027 4.71693 18.2929 5.70712C19.2831 6.69731 19.2831 8.30272 18.2929 9.29291L18 9.5858Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default EditIcon;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue