🔖 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,10 +1,11 @@
const {
SystemRoles,
CacheKeys,
SystemRoles,
roleDefaults,
PermissionTypes,
Permissions,
removeNullishValues,
promptPermissionsSchema,
bookmarkPermissionsSchema,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const Role = require('~/models/schema/roleSchema');
@ -69,37 +70,52 @@ const updateRoleByName = async function (roleName, updates) {
}
};
const permissionSchemas = {
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
};
/**
* Updates the Prompt access for a specific role.
* @param {SystemRoles} roleName - The role to update the prompt access for.
* @param {boolean | undefined} [value] - The new value for the prompt access.
* Updates access permissions for a specific role and permission type.
* @param {SystemRoles} roleName - The role to update.
* @param {PermissionTypes} permissionType - The type of permission to update.
* @param {Object.<Permissions, boolean>} permissions - Permissions to update and their values.
*/
async function updatePromptsAccess(roleName, value) {
if (typeof value === 'undefined') {
async function updateAccessPermissions(roleName, permissionType, _permissions) {
const permissions = removeNullishValues(_permissions);
if (Object.keys(permissions).length === 0) {
return;
}
try {
const parsedUpdates = promptPermissionsSchema.partial().parse({ [Permissions.USE]: value });
const role = await getRoleByName(roleName);
if (!role) {
if (!role || !permissionSchemas[permissionType]) {
return;
}
const mergedUpdates = {
[PermissionTypes.PROMPTS]: {
...role[PermissionTypes.PROMPTS],
...parsedUpdates,
await updateRoleByName(roleName, {
[permissionType]: {
...role[permissionType],
...permissionSchemas[permissionType].partial().parse(permissions),
},
};
});
await updateRoleByName(roleName, mergedUpdates);
logger.info(`Updated '${roleName}' role prompts 'USE' permission to: ${value}`);
Object.entries(permissions).forEach(([permission, value]) =>
logger.info(
`Updated '${roleName}' role ${permissionType} '${permission}' permission to: ${value}`,
),
);
} catch (error) {
logger.error('Failed to update USER role prompts USE permission:', error);
logger.error(`Failed to update ${roleName} role ${permissionType} permissions:`, error);
}
}
const updatePromptsAccess = (roleName, permissions) =>
updateAccessPermissions(roleName, PermissionTypes.PROMPTS, permissions);
const updateBookmarksAccess = (roleName, permissions) =>
updateAccessPermissions(roleName, PermissionTypes.BOOKMARKS, permissions);
/**
* Initialize default roles in the system.
* Creates the default roles (ADMIN, USER) if they don't exist in the database.
@ -123,4 +139,5 @@ module.exports = {
initializeRoles,
updateRoleByName,
updatePromptsAccess,
updateBookmarksAccess,
};

View file

@ -8,6 +8,12 @@ const roleSchema = new mongoose.Schema({
unique: true,
index: true,
},
[PermissionTypes.BOOKMARKS]: {
[Permissions.USE]: {
type: Boolean,
default: true,
},
},
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: {
type: Boolean,

View file

@ -8,7 +8,6 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { forkConversation } = require('~/server/utils/import/fork');
const { importConversations } = require('~/server/utils/import');
const { createImportLimiters } = require('~/server/middleware');
const { updateTagsForConversation } = require('~/models/ConversationTag');
const getLogStores = require('~/cache/getLogStores');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
@ -174,18 +173,4 @@ router.post('/fork', async (req, res) => {
}
});
router.put('/tags/:conversationId', async (req, res) => {
try {
const conversationTags = await updateTagsForConversation(
req.user.id,
req.params.conversationId,
req.body.tags,
);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);
res.status(500).send('Error updating conversation tags');
}
});
module.exports = router;

View file

@ -1,13 +1,21 @@
const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
getConversationTags,
updateConversationTag,
createConversationTag,
deleteConversationTag,
updateTagsForConversation,
} = require('~/models/ConversationTag');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { logger } = require('~/config');
const router = express.Router();
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]);
router.use(requireJwtAuth);
router.use(checkBookmarkAccess);
/**
* GET /
@ -24,7 +32,7 @@ router.get('/', async (req, res) => {
res.status(404).end();
}
} catch (error) {
console.error('Error getting conversation tags:', error);
logger.error('Error getting conversation tags:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
@ -40,7 +48,7 @@ router.post('/', async (req, res) => {
const tag = await createConversationTag(req.user.id, req.body);
res.status(200).json(tag);
} catch (error) {
console.error('Error creating conversation tag:', error);
logger.error('Error creating conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
@ -60,7 +68,7 @@ router.put('/:tag', async (req, res) => {
res.status(404).json({ error: 'Tag not found' });
}
} catch (error) {
console.error('Error updating conversation tag:', error);
logger.error('Error updating conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
@ -80,9 +88,29 @@ router.delete('/:tag', async (req, res) => {
res.status(404).json({ error: 'Tag not found' });
}
} catch (error) {
console.error('Error deleting conversation tag:', error);
logger.error('Error deleting conversation tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* PUT /convo/:conversationId
* Updates the tags for a conversation.
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
router.put('/convo/:conversationId', async (req, res) => {
try {
const conversationTags = await updateTagsForConversation(
req.user.id,
req.params.conversationId,
req.body.tags,
);
res.status(200).json(conversationTags);
} catch (error) {
logger.error('Error updating conversation tags', error);
res.status(500).send('Error updating conversation tags');
}
});
module.exports = router;

View file

@ -24,6 +24,7 @@ jest.mock('./Files/Firebase/initialize', () => ({
jest.mock('~/models/Role', () => ({
initializeRoles: jest.fn(),
updatePromptsAccess: jest.fn(),
updateBookmarksAccess: jest.fn(),
}));
jest.mock('./ToolService', () => ({
loadAndFormatTools: jest.fn().mockReturnValue({

View file

@ -1,5 +1,5 @@
const { SystemRoles, removeNullishValues } = require('librechat-data-provider');
const { updatePromptsAccess } = require('~/models/Role');
const { SystemRoles, Permissions, removeNullishValues } = require('librechat-data-provider');
const { updatePromptsAccess, updateBookmarksAccess } = require('~/models/Role');
const { logger } = require('~/config');
/**
@ -24,10 +24,12 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks,
prompts: interfaceConfig?.prompts ?? defaults.prompts,
});
await updatePromptsAccess(roleName, loadedInterface.prompts);
await updatePromptsAccess(roleName, { [Permissions.USE]: loadedInterface.prompts });
await updateBookmarksAccess(roleName, { [Permissions.USE]: loadedInterface.bookmarks });
let i = 0;
const logSettings = () => {

View file

@ -1,9 +1,10 @@
const { SystemRoles } = require('librechat-data-provider');
const { SystemRoles, Permissions } = require('librechat-data-provider');
const { updatePromptsAccess } = require('~/models/Role');
const { loadDefaultInterface } = require('./interface');
jest.mock('~/models/Role', () => ({
updatePromptsAccess: jest.fn(),
updateBookmarksAccess: jest.fn(),
}));
describe('loadDefaultInterface', () => {
@ -13,7 +14,7 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, true);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, { [Permissions.USE]: true });
});
it('should call updatePromptsAccess with false when prompts is false', async () => {
@ -22,7 +23,9 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, false);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, {
[Permissions.USE]: false,
});
});
it('should call updatePromptsAccess with undefined when prompts is not specified in config', async () => {
@ -31,7 +34,9 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, undefined);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, {
[Permissions.USE]: undefined,
});
});
it('should call updatePromptsAccess with undefined when prompts is explicitly undefined', async () => {
@ -40,6 +45,8 @@ describe('loadDefaultInterface', () => {
await loadDefaultInterface(config, configDefaults);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, undefined);
expect(updatePromptsAccess).toHaveBeenCalledWith(SystemRoles.USER, {
[Permissions.USE]: undefined,
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -114,7 +114,7 @@ export default function NewChat({
</div>
</a>
</div>
{subHeaders ? subHeaders : null}
{subHeaders != null ? subHeaders : null}
</div>
</Tooltip>
</TooltipProvider>

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import {
useSharedLinksInfiniteQuery,
} from './queries';
import {
logger,
/* Shared Links */
addSharedLink,
deleteSharedLink,
@ -96,6 +97,7 @@ export const useUpdateConversationMutation = (
*/
export const useTagConversationMutation = (
conversationId: string,
options?: t.updateTagsInConvoOptions,
): UseMutationResult<t.TTagConversationResponse, unknown, t.TTagConversationRequest, unknown> => {
const query = useConversationTagsQuery();
const { updateTagsInConversation } = useUpdateTagsInConvo();
@ -103,13 +105,17 @@ export const useTagConversationMutation = (
(payload: t.TTagConversationRequest) =>
dataService.addTagToConversation(conversationId, payload),
{
onSuccess: (updatedTags) => {
onSuccess: (updatedTags, ...rest) => {
// Because the logic for calculating the bookmark count is complex,
// the client does not perform the calculation,
// but instead refetch the data from the API.
query.refetch();
updateTagsInConversation(conversationId, updatedTags);
options?.onSuccess?.(updatedTags, ...rest);
},
onError: options?.onError,
onMutate: options?.onMutate,
},
);
};
@ -296,62 +302,99 @@ export const useDeleteSharedLinkMutation = (
});
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
refetch({
refetchPage: (page, index) => index === (current?.pages.length || 1) - 1,
refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1,
});
onSuccess?.(_data, vars, context);
},
...(_options || {}),
..._options,
});
};
// Add a tag or update tag information (tag, description, position, etc.)
export const useConversationTagMutation = (
tag?: string,
options?: t.UpdateConversationTagOptions,
): UseMutationResult<t.TConversationTagResponse, unknown, t.TConversationTagRequest, unknown> => {
export const useConversationTagMutation = ({
context,
tag,
options,
}: {
context: string;
tag?: string;
options?: t.UpdateConversationTagOptions;
}): UseMutationResult<t.TConversationTagResponse, unknown, t.TConversationTagRequest, unknown> => {
const queryClient = useQueryClient();
const { ..._options } = options || {};
const { onSuccess, ..._options } = options || {};
const onMutationSuccess: typeof onSuccess = (_data, vars) => {
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (queryData) => {
if (!queryData) {
return [
{
count: 1,
position: 0,
tag: Constants.SAVED_TAG,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as t.TConversationTag[];
}
if (tag === undefined || !tag.length) {
// Check if the tag already exists
const existingTagIndex = queryData.findIndex((item) => item.tag === _data.tag);
if (existingTagIndex !== -1) {
logger.log(
'tag_mutation',
`"Created" tag exists, updating from ${context}`,
queryData,
_data,
);
// If the tag exists, update it
const updatedData = [...queryData];
updatedData[existingTagIndex] = { ...updatedData[existingTagIndex], ..._data };
return updatedData.sort((a, b) => a.position - b.position);
} else {
// If the tag doesn't exist, add it
logger.log(
'tag_mutation',
`"Created" tag is new, adding from ${context}`,
queryData,
_data,
);
return [...queryData, _data].sort((a, b) => a.position - b.position);
}
}
logger.log('tag_mutation', `Updating tag from ${context}`, queryData, _data);
return updateConversationTag(queryData, vars, _data, tag);
});
if (vars.addToConversation === true && vars.conversationId != null && _data.tag) {
const currentConvo = queryClient.getQueryData<t.TConversation>([
QueryKeys.conversation,
vars.conversationId,
]);
if (!currentConvo) {
return;
}
logger.log(
'tag_mutation',
`\`updateTagsInConversation\` Update from ${context}`,
currentConvo,
);
updateTagsInConversation(vars.conversationId, [...(currentConvo.tags || []), _data.tag]);
}
// Change the tag title to the new title
if (tag != null) {
replaceTagsInAllConversations(tag, _data.tag);
}
};
const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConvo();
return useMutation(
(payload: t.TConversationTagRequest) =>
tag
tag != null
? dataService.updateConversationTag(tag, payload)
: dataService.createConversationTag(payload),
{
onSuccess: (_data, vars) => {
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (data) => {
if (!data) {
return [
{
count: 1,
position: 0,
tag: Constants.SAVED_TAG,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as t.TConversationTag[];
}
if (!tag) {
return [...data, _data].sort((a, b) => a.position - b.position);
}
return updateConversationTag(data, vars, _data, tag);
});
if (vars.addToConversation && vars.conversationId && _data.tag) {
const currentConvo = queryClient.getQueryData<t.TConversation>([
QueryKeys.conversation,
vars.conversationId,
]);
if (!currentConvo) {
return;
}
updateTagsInConversation(vars.conversationId, [...(currentConvo.tags || []), _data.tag]);
}
// Change the tag title to the new title
if (tag) {
replaceTagsInAllConversations(tag, _data.tag);
}
onSuccess: (...args) => {
onMutationSuccess(...args);
onSuccess?.(...args);
},
...(_options || {}),
..._options,
},
);
};
@ -407,20 +450,22 @@ export const useDeleteConversationTagMutation = (
): UseMutationResult<t.TConversationTagResponse, unknown, string, void> => {
const queryClient = useQueryClient();
const deleteTagInAllConversations = useDeleteTagInConversations();
const { onSuccess, ..._options } = options || {};
return useMutation((tag: string) => dataService.deleteConversationTag(tag), {
onSuccess: (_data, vars, context) => {
onSuccess: (_data, tagToDelete, context) => {
queryClient.setQueryData<t.TConversationTag[]>([QueryKeys.conversationTags], (data) => {
if (!data) {
return data;
}
return data.filter((t) => t.tag !== vars);
return data.filter((t) => t.tag !== tagToDelete);
});
deleteTagInAllConversations(vars);
onSuccess?.(_data, vars, context);
deleteTagInAllConversations(tagToDelete);
onSuccess?.(_data, tagToDelete, context);
},
...(_options || {}),
..._options,
});
};
@ -817,7 +862,8 @@ export const useUpdateAssistantMutation = (
({ assistant_id, data }: { assistant_id: string; data: t.AssistantUpdateParams }) => {
const { endpoint } = data;
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const version = endpointsConfig?.[endpoint].version ?? defaultAssistantsVersion[endpoint];
const endpointConfig = endpointsConfig?.[endpoint];
const version = endpointConfig?.version ?? defaultAssistantsVersion[endpoint];
return dataService.updateAssistant({
data,
version,

View file

@ -3,7 +3,7 @@ import useUpdateTagsInConvo from './useUpdateTagsInConvo';
import store from '~/store';
const useBookmarkSuccess = (conversationId: string) => {
const setConversation = useSetRecoilState(store.conversationByIndex(0));
const updateConversation = useSetRecoilState(store.updateConversationSelector(conversationId));
const { updateTagsInConversation } = useUpdateTagsInConvo();
return (newTags: string[]) => {
@ -11,16 +11,7 @@ const useBookmarkSuccess = (conversationId: string) => {
return;
}
updateTagsInConversation(conversationId, newTags);
setConversation((prev) => {
if (prev) {
return {
...prev,
tags: newTags,
};
}
console.error('Conversation not found for bookmark/tags update');
return prev;
});
updateConversation({ tags: newTags });
};
};

View file

@ -38,6 +38,10 @@ export default function useSideNavLinks({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.USE,
});
const hasAccessToBookmarks = useHasAccess({
permissionType: PermissionTypes.BOOKMARKS,
permission: Permissions.USE,
});
const Links = useMemo(() => {
const links: NavLink[] = [];
@ -75,13 +79,15 @@ export default function useSideNavLinks({
Component: FilesPanel,
});
links.push({
title: 'com_sidepanel_conversation_tags',
label: '',
icon: Bookmark,
id: 'bookmarks',
Component: BookmarkPanel,
});
if (hasAccessToBookmarks) {
links.push({
title: 'com_sidepanel_conversation_tags',
label: '',
icon: Bookmark,
id: 'bookmarks',
Component: BookmarkPanel,
});
}
links.push({
title: 'com_sidepanel_hide_panel',

View file

@ -15,7 +15,7 @@ const useHasAccess = ({
({ user, permissionType, permission }) => {
if (isAuthenticated && user?.role === SystemRoles.ADMIN) {
return true;
} else if (isAuthenticated && user?.role && roles && roles[user.role]) {
} else if (isAuthenticated && user?.role != null && roles && roles[user.role]) {
return roles[user.role]?.[permissionType]?.[permission] === true;
}
return false;

View file

@ -311,6 +311,7 @@ export default {
com_ui_bookmarks_create_success: 'Bookmark created successfully',
com_ui_bookmarks_update_success: 'Bookmark updated successfully',
com_ui_bookmarks_delete_success: 'Bookmark deleted successfully',
com_ui_bookmarks_create_exists: 'This bookmark already exists',
com_ui_bookmarks_create_error: 'There was an error creating the bookmark',
com_ui_bookmarks_update_error: 'There was an error updating the bookmark',
com_ui_bookmarks_delete_error: 'There was an error deleting the bookmark',

View file

@ -2,6 +2,7 @@ import {
atom,
selector,
atomFamily,
DefaultValue,
selectorFamily,
useRecoilState,
useRecoilValue,
@ -325,6 +326,31 @@ function useClearLatestMessages(context?: string) {
return clearAllLatestMessages;
}
const updateConversationSelector = selectorFamily({
key: 'updateConversationSelector',
get: () => () => null as Partial<TConversation> | null,
set:
(conversationId: string) =>
({ set, get }, newPartialConversation) => {
if (newPartialConversation instanceof DefaultValue) {
return;
}
const keys = get(conversationKeysAtom);
keys.forEach((key) => {
set(conversationByIndex(key), (prevConversation) => {
if (prevConversation && prevConversation.conversationId === conversationId) {
return {
...prevConversation,
...newPartialConversation,
};
}
return prevConversation;
});
});
},
});
export default {
conversationByIndex,
filesByIndex,
@ -354,4 +380,5 @@ export default {
useClearSubmissionState,
useClearLatestMessages,
showPromptsPopoverFamily,
updateConversationSelector,
};

View file

@ -2,7 +2,7 @@ import { ContentTypes, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
export const getLengthAndLastTenChars = (str?: string): string => {
if (!str) {
if (typeof str !== 'string' || str.length === 0) {
return '0';
}
@ -18,12 +18,15 @@ export const getLatestText = (message?: TMessage | null, includeIndex?: boolean)
if (message.text) {
return message.text;
}
if (message.content?.length) {
if (message.content && message.content.length > 0) {
for (let i = message.content.length - 1; i >= 0; i--) {
const part = message.content[i];
if (part.type === ContentTypes.TEXT && part[ContentTypes.TEXT].value.length > 0) {
if (
part.type === ContentTypes.TEXT &&
((part[ContentTypes.TEXT].value as string | undefined)?.length ?? 0) > 0
) {
const text = part[ContentTypes.TEXT].value;
if (includeIndex) {
if (includeIndex === true) {
return `${text}-${i}`;
} else {
return text;
@ -39,9 +42,11 @@ export const getTextKey = (message?: TMessage | null, convoId?: string | null) =
return '';
}
const text = getLatestText(message, true);
return `${message.messageId ?? ''}${Constants.COMMON_DIVIDER}${getLengthAndLastTenChars(text)}${
return `${(message.messageId as string | null) ?? ''}${
Constants.COMMON_DIVIDER
}${message.conversationId ?? convoId}`;
}${getLengthAndLastTenChars(text)}${Constants.COMMON_DIVIDER}${
message.conversationId ?? convoId
}`;
};
export const scrollToEnd = (callback?: () => void) => {

2
package-lock.json generated
View file

@ -31493,7 +31493,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.415",
"version": "0.7.416",
"license": "ISC",
"dependencies": {
"@types/js-yaml": "^4.0.9",

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.415",
"version": "0.7.416",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

View file

@ -201,4 +201,4 @@ export const conversationTagsList = (pageNumber: string, sort?: string, order?:
}`;
export const addTagToConversation = (conversationId: string) =>
`${conversationsRoot}/tags/${conversationId}`;
`${conversationTags()}/convo/${conversationId}`;

View file

@ -413,6 +413,7 @@ export const configSchema = z.object({
modelSelect: z.boolean().optional(),
parameters: z.boolean().optional(),
sidePanel: z.boolean().optional(),
bookmarks: z.boolean().optional(),
presets: z.boolean().optional(),
prompts: z.boolean().optional(),
})

View file

@ -22,6 +22,10 @@ export enum PermissionTypes {
* Type for Prompt Permissions
*/
PROMPTS = 'PROMPTS',
/**
* Type for Bookmarks Permissions
*/
BOOKMARKS = 'BOOKMARKS',
}
/**
@ -41,13 +45,19 @@ export const promptPermissionsSchema = z.object({
[Permissions.SHARE]: z.boolean().default(false),
});
export const bookmarkPermissionsSchema = z.object({
[Permissions.USE]: z.boolean().default(true),
});
export const roleSchema = z.object({
name: z.string(),
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
});
export type TRole = z.infer<typeof roleSchema>;
export type TPromptPermissions = z.infer<typeof promptPermissionsSchema>;
export type TBookmarkPermissions = z.infer<typeof bookmarkPermissionsSchema>;
const defaultRolesSchema = z.object({
[SystemRoles.ADMIN]: roleSchema.extend({
@ -58,10 +68,14 @@ const defaultRolesSchema = z.object({
[Permissions.CREATE]: z.boolean().default(true),
[Permissions.SHARE]: z.boolean().default(true),
}),
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema.extend({
[Permissions.USE]: z.boolean().default(true),
}),
}),
[SystemRoles.USER]: roleSchema.extend({
name: z.literal(SystemRoles.USER),
[PermissionTypes.PROMPTS]: promptPermissionsSchema,
[PermissionTypes.BOOKMARKS]: bookmarkPermissionsSchema,
}),
});
@ -69,9 +83,11 @@ export const roleDefaults = defaultRolesSchema.parse({
[SystemRoles.ADMIN]: {
name: SystemRoles.ADMIN,
[PermissionTypes.PROMPTS]: {},
[PermissionTypes.BOOKMARKS]: {},
},
[SystemRoles.USER]: {
name: SystemRoles.USER,
[PermissionTypes.PROMPTS]: {},
[PermissionTypes.BOOKMARKS]: {},
},
});

View file

@ -481,6 +481,7 @@ export const tSharedLinkSchema = z.object({
export type TSharedLink = z.infer<typeof tSharedLinkSchema>;
export const tConversationTagSchema = z.object({
_id: z.string(),
user: z.string(),
tag: z.string(),
description: z.string().optional(),

View file

@ -105,6 +105,12 @@ export type CreateSharedLinkOptions = MutationOptions<
types.TSharedLink,
Partial<types.TSharedLink>
>;
export type updateTagsInConvoOptions = MutationOptions<
types.TTagConversationResponse,
types.TTagConversationRequest
>;
export type UpdateSharedLinkOptions = MutationOptions<
types.TSharedLink,
Partial<types.TSharedLink>