🔖 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:
Danny Avila 2024-08-22 17:09:05 -04:00 committed by GitHub
parent 366e4c5adb
commit f86e9dd04c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 530 additions and 298 deletions

View file

@ -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

View file

@ -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}

View file

@ -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}

View file

@ -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,

View file

@ -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" />