diff --git a/api/models/ConversationTag.js b/api/models/ConversationTag.js index 9745dce70bd..144aeb18dc6 100644 --- a/api/models/ConversationTag.js +++ b/api/models/ConversationTag.js @@ -1,277 +1,257 @@ -//const crypto = require('crypto'); - -const logger = require('~/config/winston'); -const Conversation = require('./schema/convoSchema'); const ConversationTag = require('./schema/conversationTagSchema'); +const Conversation = require('./schema/convoSchema'); +const logger = require('~/config/winston'); const SAVED_TAG = 'Saved'; -const updateTagsForConversation = async (user, conversationId, tags) => { +/** + * Retrieves all conversation tags for a user. + * @param {string} user - The user ID. + * @returns {Promise} An array of conversation tags. + */ +const getConversationTags = async (user) => { try { - const conversation = await Conversation.findOne({ user, conversationId }); - if (!conversation) { - return { message: 'Conversation not found' }; - } + const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean(); - const addedTags = tags.tags.filter((tag) => !conversation.tags.includes(tag)); - const removedTags = conversation.tags.filter((tag) => !tags.tags.includes(tag)); - for (const tag of addedTags) { - await ConversationTag.updateOne({ tag, user }, { $inc: { count: 1 } }, { upsert: true }); - } - for (const tag of removedTags) { - await ConversationTag.updateOne({ tag, user }, { $inc: { count: -1 } }); - } - conversation.tags = tags.tags; - await conversation.save({ timestamps: { updatedAt: false } }); - return conversation.tags; + cTags.sort((a, b) => { + if (a.tag === SAVED_TAG) { + return -1; + } + if (b.tag === SAVED_TAG) { + return 1; + } + return 0; + }); + + return cTags; } catch (error) { - logger.error('[updateTagsToConversation] Error updating tags', error); - return { message: 'Error updating tags' }; + logger.error('[getConversationTags] Error getting conversation tags', error); + throw new Error('Error getting conversation tags'); } }; +/** + * Creates a new conversation tag. + * @param {string} user - The user ID. + * @param {Object} data - The tag data. + * @param {string} data.tag - The tag name. + * @param {string} [data.description] - The tag description. + * @param {boolean} [data.addToConversation] - Whether to add the tag to a conversation. + * @param {string} [data.conversationId] - The conversation ID to add the tag to. + * @returns {Promise} The created tag. + */ const createConversationTag = async (user, data) => { try { - const cTag = await ConversationTag.findOne({ user, tag: data.tag }); - if (cTag) { - return cTag; + const { tag, description, addToConversation, conversationId } = data; + + const existingTag = await ConversationTag.findOne({ user, tag }).lean(); + if (existingTag) { + return existingTag; } - const addToConversation = data.addToConversation && data.conversationId; - const newTag = await ConversationTag.create({ - user, - tag: data.tag, - count: 0, - description: data.description, - position: 1, - }); + const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean(); + const position = (maxPosition?.position || 0) + 1; - await ConversationTag.updateMany( - { user, position: { $gte: 1 }, _id: { $ne: newTag._id } }, - { $inc: { position: 1 } }, + const newTag = await ConversationTag.findOneAndUpdate( + { tag, user }, + { + tag, + user, + count: addToConversation ? 1 : 0, + position, + description, + $setOnInsert: { createdAt: new Date() }, + }, + { + new: true, + upsert: true, + lean: true, + }, ); - if (addToConversation) { - const conversation = await Conversation.findOne({ - user, - conversationId: data.conversationId, - }); - if (conversation) { - const tags = [...(conversation.tags || []), data.tag]; - await updateTagsForConversation(user, data.conversationId, { tags }); - } else { - logger.warn('[updateTagsForConversation] Conversation not found', data.conversationId); - } + if (addToConversation && conversationId) { + await Conversation.findOneAndUpdate( + { user, conversationId }, + { $addToSet: { tags: tag } }, + { new: true }, + ); } - return await ConversationTag.findOne({ user, tag: data.tag }); + return newTag; } catch (error) { - logger.error('[createConversationTag] Error updating conversation tag', error); - return { message: 'Error updating conversation tag' }; + logger.error('[createConversationTag] Error creating conversation tag', error); + throw new Error('Error creating conversation tag'); } }; -const replaceOrRemoveTagInConversations = async (user, oldtag, newtag) => { +/** + * Updates an existing conversation tag. + * @param {string} user - The user ID. + * @param {string} oldTag - The current tag name. + * @param {Object} data - The updated tag data. + * @param {string} [data.tag] - The new tag name. + * @param {string} [data.description] - The updated description. + * @param {number} [data.position] - The new position. + * @returns {Promise} The updated tag. + */ +const updateConversationTag = async (user, oldTag, data) => { try { - const conversations = await Conversation.find({ user, tags: { $in: [oldtag] } }); - for (const conversation of conversations) { - if (newtag && newtag !== '') { - conversation.tags = conversation.tags.map((tag) => (tag === oldtag ? newtag : tag)); - } else { - conversation.tags = conversation.tags.filter((tag) => tag !== oldtag); - } - await conversation.save({ timestamps: { updatedAt: false } }); - } - } catch (error) { - logger.error('[replaceOrRemoveTagInConversations] Error updating conversation tags', error); - return { message: 'Error updating conversation tags' }; - } -}; + const { tag: newTag, description, position } = data; -const updateTagPosition = async (user, tag, newPosition) => { - try { - const cTag = await ConversationTag.findOne({ user, tag }); - if (!cTag) { - return { message: 'Tag not found' }; + const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean(); + if (!existingTag) { + return null; } - const oldPosition = cTag.position; + if (newTag && newTag !== oldTag) { + const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean(); + if (tagAlreadyExists) { + throw new Error('Tag already exists'); + } - if (newPosition === oldPosition) { - return cTag; + await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } }); } - const updateOperations = []; - - if (newPosition > oldPosition) { - // Move other tags up - updateOperations.push({ - updateMany: { - filter: { - user, - position: { $gt: oldPosition, $lte: newPosition }, - tag: { $ne: SAVED_TAG }, - }, - update: { $inc: { position: -1 } }, - }, - }); - } else { - // Move other tags down - updateOperations.push({ - updateMany: { - filter: { - user, - position: { $gte: newPosition, $lt: oldPosition }, - tag: { $ne: SAVED_TAG }, - }, - update: { $inc: { position: 1 } }, - }, - }); + const updateData = {}; + if (newTag) { + updateData.tag = newTag; + } + if (description !== undefined) { + updateData.description = description; + } + if (position !== undefined) { + await adjustPositions(user, existingTag.position, position); + updateData.position = position; } - // Update the target tag's position - updateOperations.push({ - updateOne: { - filter: { _id: cTag._id }, - update: { $set: { position: newPosition } }, - }, + return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, { + new: true, + lean: true, }); - - await ConversationTag.bulkWrite(updateOperations); - - return await ConversationTag.findById(cTag._id); } catch (error) { - logger.error('[updateTagPosition] Error updating tag position', error); - return { message: 'Error updating tag position' }; + logger.error('[updateConversationTag] Error updating conversation tag', error); + throw new Error('Error updating conversation tag'); } }; -module.exports = { - SAVED_TAG, - ConversationTag, - getConversationTags: async (user) => { - try { - const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean(); - - cTags.sort((a, b) => { - if (a.tag === SAVED_TAG) { - return -1; - } - if (b.tag === SAVED_TAG) { - return 1; - } - return 0; - }); - return cTags; - } catch (error) { - logger.error('[getShare] Error getting share link', error); - return { message: 'Error getting share link' }; +/** + * Adjusts positions of tags when a tag's position is changed. + * @param {string} user - The user ID. + * @param {number} oldPosition - The old position of the tag. + * @param {number} newPosition - The new position of the tag. + * @returns {Promise} + */ +const adjustPositions = async (user, oldPosition, newPosition) => { + if (oldPosition === newPosition) { + return; + } + + const update = oldPosition < newPosition ? { $inc: { position: -1 } } : { $inc: { position: 1 } }; + + await ConversationTag.updateMany( + { + user, + position: { + $gt: Math.min(oldPosition, newPosition), + $lte: Math.max(oldPosition, newPosition), + }, + }, + update, + ); +}; + +/** + * Deletes a conversation tag. + * @param {string} user - The user ID. + * @param {string} tag - The tag to delete. + * @returns {Promise} The deleted tag. + */ +const deleteConversationTag = async (user, tag) => { + try { + const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean(); + if (!deletedTag) { + return null; } - }, - createConversationTag, - updateConversationTag: async (user, tag, data) => { - try { - const cTag = await ConversationTag.findOne({ user, tag }); - if (!cTag) { - return createConversationTag(user, data); - } + await Conversation.updateMany({ user, tags: tag }, { $pull: { tags: tag } }); - if (cTag.tag !== data.tag || cTag.description !== data.description) { - cTag.tag = data.tag; - cTag.description = data.description === undefined ? cTag.description : data.description; - await cTag.save(); - } + await ConversationTag.updateMany( + { user, position: { $gt: deletedTag.position } }, + { $inc: { position: -1 } }, + ); - if (data.position !== undefined && cTag.position !== data.position) { - await updateTagPosition(user, tag, data.position); - } + return deletedTag; + } catch (error) { + logger.error('[deleteConversationTag] Error deleting conversation tag', error); + throw new Error('Error deleting conversation tag'); + } +}; - // update conversation tags properties - replaceOrRemoveTagInConversations(user, tag, data.tag); - return await ConversationTag.findOne({ user, tag: data.tag }); - } catch (error) { - logger.error('[updateConversationTag] Error updating conversation tag', error); - return { message: 'Error updating conversation tag' }; +/** + * Updates tags for a specific conversation. + * @param {string} user - The user ID. + * @param {string} conversationId - The conversation ID. + * @param {string[]} tags - The new set of tags for the conversation. + * @returns {Promise} The updated list of tags for the conversation. + */ +const updateTagsForConversation = async (user, conversationId, tags) => { + try { + const conversation = await Conversation.findOne({ user, conversationId }).lean(); + if (!conversation) { + throw new Error('Conversation not found'); } - }, - deleteConversationTag: async (user, tag) => { - try { - const currentTag = await ConversationTag.findOne({ user, tag }); - if (!currentTag) { - return; - } + const oldTags = new Set(conversation.tags); + const newTags = new Set(tags); - await currentTag.deleteOne({ user, tag }); + const addedTags = [...newTags].filter((tag) => !oldTags.has(tag)); + const removedTags = [...oldTags].filter((tag) => !newTags.has(tag)); - await replaceOrRemoveTagInConversations(user, tag, null); - return currentTag; - } catch (error) { - logger.error('[deleteConversationTag] Error deleting conversation tag', error); - return { message: 'Error deleting conversation tag' }; - } - }, + const bulkOps = []; - updateTagsForConversation, - rebuildConversationTags: async (user) => { - try { - const conversations = await Conversation.find({ user }).select('tags'); - const tagCountMap = {}; - - // Count the occurrences of each tag - conversations.forEach((conversation) => { - conversation.tags.forEach((tag) => { - if (tagCountMap[tag]) { - tagCountMap[tag]++; - } else { - tagCountMap[tag] = 1; - } - }); + for (const tag of addedTags) { + bulkOps.push({ + updateOne: { + filter: { user, tag }, + update: { $inc: { count: 1 } }, + upsert: true, + }, }); + } - const tags = await ConversationTag.find({ user }).sort({ position: -1 }); - - // Update existing tags and add new tags - for (const [tag, count] of Object.entries(tagCountMap)) { - const existingTag = tags.find((t) => t.tag === tag); - if (existingTag) { - existingTag.count = count; - await existingTag.save(); - } else { - const newTag = new ConversationTag({ user, tag, count }); - tags.push(newTag); - await newTag.save(); - } - } + for (const tag of removedTags) { + bulkOps.push({ + updateOne: { + filter: { user, tag }, + update: { $inc: { count: -1 } }, + }, + }); + } - // Set count to 0 for tags that are not in the grouped tags - for (const tag of tags) { - if (!tagCountMap[tag.tag]) { - tag.count = 0; - await tag.save(); - } - } + if (bulkOps.length > 0) { + await ConversationTag.bulkWrite(bulkOps); + } - // Sort tags by position in descending order - tags.sort((a, b) => a.position - b.position); + const updatedConversation = ( + await Conversation.findOneAndUpdate( + { user, conversationId }, + { $set: { tags: [...newTags] } }, + { new: true }, + ) + ).toObject(); - // Move the tag with name "saved" to the first position - const savedTagIndex = tags.findIndex((tag) => tag.tag === SAVED_TAG); - if (savedTagIndex !== -1) { - const [savedTag] = tags.splice(savedTagIndex, 1); - tags.unshift(savedTag); - } + return updatedConversation.tags; + } catch (error) { + logger.error('[updateTagsForConversation] Error updating tags', error); + throw new Error('Error updating tags for conversation'); + } +}; - // Reassign positions starting from 0 - tags.forEach((tag, index) => { - tag.position = index; - tag.save(); - }); - return tags; - } catch (error) { - logger.error('[rearrangeTags] Error rearranging tags', error); - return { message: 'Error rearranging tags' }; - } - }, +module.exports = { + SAVED_TAG, + getConversationTags, + createConversationTag, + updateConversationTag, + deleteConversationTag, + updateTagsForConversation, }; diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 8757ce76eae..14db4755685 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -175,8 +175,17 @@ router.post('/fork', async (req, res) => { }); router.put('/tags/:conversationId', async (req, res) => { - const tag = await updateTagsForConversation(req.user.id, req.params.conversationId, req.body); - res.status(200).json(tag); + 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; diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index 0d4d85e3eef..289ee5c8f8a 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -1,44 +1,88 @@ const express = require('express'); - const { getConversationTags, updateConversationTag, createConversationTag, deleteConversationTag, - rebuildConversationTags, } = require('~/models/ConversationTag'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const router = express.Router(); router.use(requireJwtAuth); +/** + * GET / + * Retrieves all conversation tags for the authenticated user. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ router.get('/', async (req, res) => { - const tags = await getConversationTags(req.user.id); - - if (tags) { - res.status(200).json(tags); - } else { - res.status(404).end(); + try { + const tags = await getConversationTags(req.user.id); + if (tags) { + res.status(200).json(tags); + } else { + res.status(404).end(); + } + } catch (error) { + console.error('Error getting conversation tags:', error); + res.status(500).json({ error: 'Internal server error' }); } }); +/** + * POST / + * Creates a new conversation tag for the authenticated user. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ router.post('/', async (req, res) => { - const tag = await createConversationTag(req.user.id, req.body); - res.status(200).json(tag); -}); - -router.post('/rebuild', async (req, res) => { - const tag = await rebuildConversationTags(req.user.id); - res.status(200).json(tag); + try { + const tag = await createConversationTag(req.user.id, req.body); + res.status(200).json(tag); + } catch (error) { + console.error('Error creating conversation tag:', error); + res.status(500).json({ error: 'Internal server error' }); + } }); +/** + * PUT /:tag + * Updates an existing conversation tag for the authenticated user. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ router.put('/:tag', async (req, res) => { - const tag = await updateConversationTag(req.user.id, req.params.tag, req.body); - res.status(200).json(tag); + try { + const tag = await updateConversationTag(req.user.id, req.params.tag, req.body); + if (tag) { + res.status(200).json(tag); + } else { + res.status(404).json({ error: 'Tag not found' }); + } + } catch (error) { + console.error('Error updating conversation tag:', error); + res.status(500).json({ error: 'Internal server error' }); + } }); +/** + * DELETE /:tag + * Deletes a conversation tag for the authenticated user. + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ router.delete('/:tag', async (req, res) => { - const tag = await deleteConversationTag(req.user.id, req.params.tag); - res.status(200).json(tag); + try { + const tag = await deleteConversationTag(req.user.id, req.params.tag); + if (tag) { + res.status(200).json(tag); + } else { + res.status(404).json({ error: 'Tag not found' }); + } + } catch (error) { + console.error('Error deleting conversation tag:', error); + res.status(500).json({ error: 'Internal server error' }); + } }); module.exports = router; diff --git a/client/src/components/Bookmarks/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm.tsx index 98b84362dec..38ebfb2b851 100644 --- a/client/src/components/Bookmarks/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm.tsx @@ -9,9 +9,9 @@ import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/'; import { useBookmarkContext } from '~/Providers/BookmarkContext'; import { useConversationTagMutation } from '~/data-provider'; import { Checkbox, Label, TextareaAutosize } from '~/components/ui/'; +import { useLocalize, useBookmarkSuccess } from '~/hooks'; import { NotificationSeverity } from '~/common'; import { useToastContext } from '~/Providers'; -import { useLocalize } from '~/hooks'; type TBookmarkFormProps = { bookmark?: TConversationTag; @@ -31,10 +31,11 @@ const BookmarkForm = ({ tags, setTags, }: TBookmarkFormProps) => { - const { showToast } = useToastContext(); const localize = useLocalize(); - const mutation = useConversationTagMutation(bookmark?.tag); + const { showToast } = useToastContext(); const { bookmarks } = useBookmarkContext(); + const mutation = useConversationTagMutation(bookmark?.tag); + const onSuccess = useBookmarkSuccess(conversation?.conversationId || ''); const { register, @@ -82,6 +83,7 @@ const BookmarkForm = ({ (tag) => tag !== undefined, ) as string[]; setTags(newTags); + onSuccess(newTags); } }, onError: () => { @@ -172,9 +174,9 @@ const BookmarkForm = ({ /> )} /> - + )} diff --git a/client/src/components/Bookmarks/BookmarkItem.tsx b/client/src/components/Bookmarks/BookmarkItem.tsx index ebf7b298f6c..62f8f79a73a 100644 --- a/client/src/components/Bookmarks/BookmarkItem.tsx +++ b/client/src/components/Bookmarks/BookmarkItem.tsx @@ -7,6 +7,7 @@ import { cn } from '~/utils'; type MenuItemProps = { tag: string | React.ReactNode; selected: boolean; + ctx: 'header' | 'nav'; count?: number; handleSubmit: (tag: string) => Promise; icon?: React.ReactNode; @@ -15,6 +16,7 @@ type MenuItemProps = { const BookmarkItem: FC = ({ tag, + ctx, selected, count, handleSubmit, @@ -34,13 +36,30 @@ const BookmarkItem: FC = ({ overflowWrap: 'anywhere', }; + const renderIcon = () => { + if (icon) { + return icon; + } + if (isLoading) { + return ; + } + if (selected) { + return ; + } + return ; + }; + + const ariaLabel = + ctx === 'header' ? `${selected ? 'Remove' : 'Add'} bookmark for ${tag}` : (tag as string); + return ( -
= ({ >
- {icon ? ( - icon - ) : isLoading ? ( - - ) : selected ? ( - - ) : ( - - )} + {renderIcon()}
{tag}
{count !== undefined && (
)}
-
+ ); }; + export default BookmarkItem; diff --git a/client/src/components/Bookmarks/BookmarkItems.tsx b/client/src/components/Bookmarks/BookmarkItems.tsx index a0c3787be2b..9b4da3b5655 100644 --- a/client/src/components/Bookmarks/BookmarkItems.tsx +++ b/client/src/components/Bookmarks/BookmarkItems.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { useBookmarkContext } from '~/Providers/BookmarkContext'; import BookmarkItem from './BookmarkItem'; interface BookmarkItemsProps { + ctx: 'header' | 'nav'; tags: string[]; handleSubmit: (tag: string) => Promise; header: React.ReactNode; @@ -9,6 +10,7 @@ interface BookmarkItemsProps { } const BookmarkItems: FC = ({ + ctx, tags, handleSubmit, header, @@ -19,9 +21,10 @@ const BookmarkItems: FC = ({ return ( <> {header} -
+
{bookmarks.map((bookmark) => ( -
-
+
+
+
{file.filename}
-
{fileType.title}
+
{fileType.title}
diff --git a/client/src/components/Chat/Input/OptionsPopover.tsx b/client/src/components/Chat/Input/OptionsPopover.tsx index ce20f84cd8c..7c68bc4daf6 100644 --- a/client/src/components/Chat/Input/OptionsPopover.tsx +++ b/client/src/components/Chat/Input/OptionsPopover.tsx @@ -66,7 +66,7 @@ export default function OptionsPopover({ {presetsDisabled ? null : ( {data && conversation && ( - // Display all bookmarks registered by the user and highlight the tags of the currently selected conversation - + )} diff --git a/client/src/components/Chat/Menus/Bookmarks/BookmarkMenuItems.tsx b/client/src/components/Chat/Menus/Bookmarks/BookmarkMenuItems.tsx index 7d7d526aa6f..0a25350757b 100644 --- a/client/src/components/Chat/Menus/Bookmarks/BookmarkMenuItems.tsx +++ b/client/src/components/Chat/Menus/Bookmarks/BookmarkMenuItems.tsx @@ -1,36 +1,37 @@ -import { useCallback } from 'react'; +import React, { useCallback } from 'react'; import { BookmarkPlusIcon } from 'lucide-react'; import type { FC } from 'react'; import type { TConversation } from 'librechat-data-provider'; import { BookmarkItems, BookmarkEditDialog } from '~/components/Bookmarks'; import { useTagConversationMutation } from '~/data-provider'; +import { useLocalize, useBookmarkSuccess } from '~/hooks'; import { NotificationSeverity } from '~/common'; import { useToastContext } from '~/Providers'; -import { useLocalize } from '~/hooks'; export const BookmarkMenuItems: FC<{ conversation: TConversation; tags: string[]; - setTags: (tags: string[]) => void; - setConversation: (conversation: TConversation) => void; -}> = ({ conversation, tags, setTags, setConversation }) => { + setTags: React.Dispatch>; +}> = ({ conversation, tags, setTags }) => { const { showToast } = useToastContext(); const localize = useLocalize(); - const { mutateAsync } = useTagConversationMutation(conversation?.conversationId ?? ''); + const conversationId = conversation?.conversationId ?? ''; + const onSuccess = useBookmarkSuccess(conversationId); + + const { mutateAsync } = useTagConversationMutation(conversationId); const handleSubmit = useCallback( async (tag: string): Promise => { - if (tags !== undefined && conversation?.conversationId) { + if (tags !== undefined && conversationId) { const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag]; await mutateAsync( { - conversationId: conversation.conversationId, tags: newTags, }, { onSuccess: (newTags: string[]) => { setTags(newTags); - setConversation({ ...conversation, tags: newTags }); + onSuccess(newTags); }, onError: () => { showToast({ @@ -42,11 +43,12 @@ export const BookmarkMenuItems: FC<{ ); } }, - [tags, conversation], + [tags, conversationId, mutateAsync, setTags, onSuccess, showToast], ); return (
diff --git a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx index c7597303e5a..d82affc61fe 100644 --- a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx +++ b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx @@ -4,8 +4,8 @@ import { useLocation } from 'react-router-dom'; import { TConversation } from 'librechat-data-provider'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; -import { useGetConversationTags } from 'librechat-data-provider/react-query'; import { BookmarkContext } from '~/Providers/BookmarkContext'; +import { useGetConversationTags } from '~/data-provider'; import BookmarkNavItems from './BookmarkNavItems'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -39,22 +39,22 @@ const BookmarkNav: FC = ({ tags, setTags }: BookmarkNavProps) @@ -63,7 +63,7 @@ const BookmarkNav: FC = ({ tags, setTags }: BookmarkNavProps) {data && conversation && ( // Display bookmarks and highlight the selected tag diff --git a/client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx b/client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx index 7b7a3eb66f6..4d0c6485b22 100644 --- a/client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx +++ b/client/src/components/Nav/Bookmarks/BookmarkNavItems.tsx @@ -39,12 +39,11 @@ const BookmarkNavItems: FC<{ return Promise.resolve(); }; - console.log('bookmarks', bookmarks); - if (bookmarks.length === 0) { return (
Promise.resolve()} @@ -58,11 +57,13 @@ const BookmarkNavItems: FC<{ return (
{ toggleNav={itemToggleNav} subHeaders={ <> - {isSearchEnabled && } + {isSearchEnabled && } } diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index b26c8c81fcb..27ff8609366 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -58,12 +58,12 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = return (
{} { diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx index 97d0c021732..0eedf04e79b 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkPanel.tsx @@ -1,38 +1,23 @@ import { BookmarkPlusIcon } from 'lucide-react'; -import { useConversationTagsQuery, useRebuildConversationTagsMutation } from '~/data-provider'; +import { useConversationTagsQuery } from '~/data-provider'; import { Button } from '~/components/ui'; import { BookmarkContext } from '~/Providers/BookmarkContext'; import { BookmarkEditDialog } from '~/components/Bookmarks'; import BookmarkTable from './BookmarkTable'; -import { Spinner } from '~/components/svg'; import { useLocalize } from '~/hooks'; -import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings'; const BookmarkPanel = () => { const localize = useLocalize(); - const { mutate, isLoading } = useRebuildConversationTagsMutation(); const { data } = useConversationTagsQuery(); - const rebuildTags = () => { - mutate({}); - }; + return (
- + diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx index 3ca213a8449..d85d64829ba 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkTable.tsx @@ -14,7 +14,11 @@ const BookmarkTable = () => { const { bookmarks } = useBookmarkContext(); useEffect(() => { - setRows(bookmarks?.map((item) => ({ id: item.tag, ...item })) || []); + setRows( + bookmarks + ?.map((item) => ({ id: item.tag, ...item })) + .sort((a, b) => a.position - b.position) || [], + ); }, [bookmarks]); const moveRow = useCallback((dragIndex: number, hoverIndex: number) => { @@ -22,13 +26,16 @@ const BookmarkTable = () => { const updatedRows = [...prevTags]; const [movedRow] = updatedRows.splice(dragIndex, 1); updatedRows.splice(hoverIndex, 0, movedRow); - return updatedRows; + return updatedRows.map((row, index) => ({ ...row, position: index })); }); }, []); - const renderRow = useCallback((row: TConversationTag, position: number) => { - return ; - }, []); + const renderRow = useCallback( + (row: TConversationTag) => { + return ; + }, + [moveRow], + ); const filteredRows = rows.filter((row) => row.tag.toLowerCase().includes(searchQuery.toLowerCase()), @@ -58,7 +65,7 @@ const BookmarkTable = () => { - {currentRows.map((row, i) => renderRow(row, i))} + {currentRows.map((row) => renderRow(row))}
diff --git a/client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx b/client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx index 9923fe73982..bcc269fa9eb 100644 --- a/client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx +++ b/client/src/components/SidePanel/Bookmarks/BookmarkTableRow.tsx @@ -1,8 +1,12 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import type { TConversationTag } from 'librechat-data-provider'; import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks'; +import { useConversationTagMutation } from '~/data-provider'; import { TableRow, TableCell } from '~/components/ui'; +import { NotificationSeverity } from '~/common'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; interface BookmarkTableRowProps { row: TConversationTag; @@ -10,13 +14,39 @@ interface BookmarkTableRowProps { position: number; } +interface DragItem { + index: number; + id: string; + type: string; +} + const BookmarkTableRow: React.FC = ({ row, moveRow, position }) => { const [isHovered, setIsHovered] = useState(false); - const ref = React.useRef(null); + const ref = useRef(null); + + const mutation = useConversationTagMutation(row.tag); + const localize = useLocalize(); + const { showToast } = useToastContext(); + + const handleDrop = (item: DragItem) => { + const data = { + ...row, + position: item.index, + }; + mutation.mutate(data, { + onError: () => { + showToast({ + message: localize('com_ui_bookmarks_update_error'), + severity: NotificationSeverity.ERROR, + }); + }, + }); + }; const [, drop] = useDrop({ accept: 'bookmark', - hover(item: { index: number }) { + drop: (item: DragItem) => handleDrop(item), + hover(item: DragItem) { if (!ref.current) { return; } @@ -43,7 +73,7 @@ const BookmarkTableRow: React.FC = ({ row, moveRow, posit return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index 14f82f312e4..2cfe6520db4 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -3,3 +3,4 @@ export * from './mutations'; export * from './prompts'; export * from './queries'; export * from './roles'; +export * from './tags'; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index e4e80235bfe..9e036914b7f 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import type { InfiniteData, UseMutationResult } from '@tanstack/react-query'; +import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo'; import { updateConversationTag } from '~/utils/conversationTags'; import { normalizeData } from '~/utils/collection'; import store from '~/store'; @@ -89,89 +90,6 @@ export const useUpdateConversationMutation = ( ); }; -const useUpdateTagsInConversation = () => { - const queryClient = useQueryClient(); - - // Update the queryClient cache with the new tag when a new tag is added/removed to a conversation - const updateTagsInConversation = (conversationId: string, tags: string[]) => { - // Update the tags for the current conversation - const currentConvo = queryClient.getQueryData([ - QueryKeys.conversation, - conversationId, - ]); - if (!currentConvo) { - return; - } - - const updatedConvo = { - ...currentConvo, - tags, - } as t.TConversation; - queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo); - queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { - if (!convoData) { - return convoData; - } - return updateConvoFields( - convoData, - { - conversationId: currentConvo.conversationId, - tags: updatedConvo.tags, - } as t.TConversation, - true, - ); - }); - }; - - // update the tag to newTag in all conversations when a tag is updated to a newTag - // The difference with updateTagsInConversation is that it adds or removes tags for a specific conversation, - // whereas this function is for changing the title of a specific tag. - const replaceTagsInAllConversations = (tag: string, newTag: string) => { - const data = queryClient.getQueryData>([ - QueryKeys.allConversations, - ]); - - const conversationIdsWithTag = [] as string[]; - - // update tag to newTag in all conversations - const newData = JSON.parse(JSON.stringify(data)) as InfiniteData; - for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) { - const page = newData.pages[pageIndex]; - page.conversations = page.conversations.map((conversation) => { - if (conversation.conversationId && conversation.tags?.includes(tag)) { - conversationIdsWithTag.push(conversation.conversationId); - conversation.tags = conversation.tags.map((t) => (t === tag ? newTag : t)); - } - return conversation; - }); - } - queryClient.setQueryData>( - [QueryKeys.allConversations], - newData, - ); - - // update the tag to newTag from the cache of each conversation - for (let i = 0; i < conversationIdsWithTag.length; i++) { - const conversationId = conversationIdsWithTag[i]; - const conversation = queryClient.getQueryData([ - QueryKeys.conversation, - conversationId, - ]); - if (conversation && conversation.tags) { - const updatedConvo = { - ...conversation, - tags: conversation.tags.map((t) => (t === tag ? newTag : t)), - } as t.TConversation; - queryClient.setQueryData( - [QueryKeys.conversation, conversationId], - updatedConvo, - ); - } - } - }; - - return { updateTagsInConversation, replaceTagsInAllConversations }; -}; /** * Add or remove tags for a conversation */ @@ -179,7 +97,7 @@ export const useTagConversationMutation = ( conversationId: string, ): UseMutationResult => { const query = useConversationTagsQuery(); - const { updateTagsInConversation } = useUpdateTagsInConversation(); + const { updateTagsInConversation } = useUpdateTagsInConvo(); return useMutation( (payload: t.TTagConversationRequest) => dataService.addTagToConversation(conversationId, payload), @@ -385,21 +303,6 @@ export const useDeleteSharedLinkMutation = ( }); }; -// If the number of conversations tagged is incorrect, recalculate the tag information. -export const useRebuildConversationTagsMutation = (): UseMutationResult< - t.TConversationTagsResponse, - unknown, - unknown, - unknown -> => { - const queryClient = useQueryClient(); - return useMutation(() => dataService.rebuildConversationTags(), { - onSuccess: (_data) => { - queryClient.setQueryData([QueryKeys.conversationTags], _data); - }, - }); -}; - // Add a tag or update tag information (tag, description, position, etc.) export const useConversationTagMutation = ( tag?: string, @@ -407,7 +310,7 @@ export const useConversationTagMutation = ( ): UseMutationResult => { const queryClient = useQueryClient(); const { ..._options } = options || {}; - const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConversation(); + const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConvo(); return useMutation( (payload: t.TConversationTagRequest) => tag @@ -427,6 +330,9 @@ export const useConversationTagMutation = ( }, ] as t.TConversationTag[]; } + if (!tag) { + return [...data, _data].sort((a, b) => a.position - b.position); + } return updateConversationTag(data, vars, _data, tag); }); if (vars.addToConversation && vars.conversationId && _data.tag) { diff --git a/client/src/data-provider/tags.ts b/client/src/data-provider/tags.ts new file mode 100644 index 00000000000..8651a781c29 --- /dev/null +++ b/client/src/data-provider/tags.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query'; +import type { TConversationTagsResponse } from 'librechat-data-provider'; +import { QueryKeys, dataService } from 'librechat-data-provider'; + +export const useGetConversationTags = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.conversationTags], + () => dataService.getConversationTags(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }, + ); +}; diff --git a/client/src/hooks/Conversations/index.ts b/client/src/hooks/Conversations/index.ts index 105ea2330e7..42f278f9af2 100644 --- a/client/src/hooks/Conversations/index.ts +++ b/client/src/hooks/Conversations/index.ts @@ -6,7 +6,9 @@ export { default as useConversation } from './useConversation'; export { default as useGenerateConvo } from './useGenerateConvo'; export { default as useConversations } from './useConversations'; export { default as useDebouncedInput } from './useDebouncedInput'; +export { default as useBookmarkSuccess } from './useBookmarkSuccess'; export { default as useNavigateToConvo } from './useNavigateToConvo'; export { default as useSetIndexOptions } from './useSetIndexOptions'; export { default as useParameterEffects } from './useParameterEffects'; +export { default as useUpdateTagsInConvo } from './useUpdateTagsInConvo'; export { default as useExportConversation } from './useExportConversation'; diff --git a/client/src/hooks/Conversations/useBookmarkSuccess.ts b/client/src/hooks/Conversations/useBookmarkSuccess.ts new file mode 100644 index 00000000000..2aeb664d709 --- /dev/null +++ b/client/src/hooks/Conversations/useBookmarkSuccess.ts @@ -0,0 +1,27 @@ +import { useSetRecoilState } from 'recoil'; +import useUpdateTagsInConvo from './useUpdateTagsInConvo'; +import store from '~/store'; + +const useBookmarkSuccess = (conversationId: string) => { + const setConversation = useSetRecoilState(store.conversationByIndex(0)); + const { updateTagsInConversation } = useUpdateTagsInConvo(); + + return (newTags: string[]) => { + if (!conversationId) { + return; + } + updateTagsInConversation(conversationId, newTags); + setConversation((prev) => { + if (prev) { + return { + ...prev, + tags: newTags, + }; + } + console.error('Conversation not found for bookmark/tags update'); + return prev; + }); + }; +}; + +export default useBookmarkSuccess; diff --git a/client/src/hooks/Conversations/useUpdateTagsInConvo.ts b/client/src/hooks/Conversations/useUpdateTagsInConvo.ts new file mode 100644 index 00000000000..ebbefe955fe --- /dev/null +++ b/client/src/hooks/Conversations/useUpdateTagsInConvo.ts @@ -0,0 +1,92 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from 'librechat-data-provider'; +import type { ConversationListResponse } from 'librechat-data-provider'; +import type { InfiniteData } from '@tanstack/react-query'; +import type t from 'librechat-data-provider'; +import { updateConvoFields } from '~/utils/convos'; + +const useUpdateTagsInConvo = () => { + const queryClient = useQueryClient(); + + // Update the queryClient cache with the new tag when a new tag is added/removed to a conversation + const updateTagsInConversation = (conversationId: string, tags: string[]) => { + // Update the tags for the current conversation + const currentConvo = queryClient.getQueryData([ + QueryKeys.conversation, + conversationId, + ]); + if (!currentConvo) { + return; + } + + const updatedConvo = { + ...currentConvo, + tags, + } as t.TConversation; + queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo); + queryClient.setQueryData([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; + } + return updateConvoFields( + convoData, + { + conversationId: currentConvo.conversationId, + tags: updatedConvo.tags, + } as t.TConversation, + true, + ); + }); + }; + + // update the tag to newTag in all conversations when a tag is updated to a newTag + // The difference with updateTagsInConversation is that it adds or removes tags for a specific conversation, + // whereas this function is for changing the title of a specific tag. + const replaceTagsInAllConversations = (tag: string, newTag: string) => { + const data = queryClient.getQueryData>([ + QueryKeys.allConversations, + ]); + + const conversationIdsWithTag = [] as string[]; + + // update tag to newTag in all conversations + const newData = JSON.parse(JSON.stringify(data)) as InfiniteData; + for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) { + const page = newData.pages[pageIndex]; + page.conversations = page.conversations.map((conversation) => { + if (conversation.conversationId && conversation.tags?.includes(tag)) { + conversationIdsWithTag.push(conversation.conversationId); + conversation.tags = conversation.tags.map((t) => (t === tag ? newTag : t)); + } + return conversation; + }); + } + queryClient.setQueryData>( + [QueryKeys.allConversations], + newData, + ); + + // update the tag to newTag from the cache of each conversation + for (let i = 0; i < conversationIdsWithTag.length; i++) { + const conversationId = conversationIdsWithTag[i]; + const conversation = queryClient.getQueryData([ + QueryKeys.conversation, + conversationId, + ]); + if (conversation && conversation.tags) { + const updatedConvo = { + ...conversation, + tags: conversation.tags.map((t) => (t === tag ? newTag : t)), + } as t.TConversation; + queryClient.setQueryData( + [QueryKeys.conversation, conversationId], + updatedConvo, + ); + } + } + }; + + return { updateTagsInConversation, replaceTagsInAllConversations }; +}; + +export default useUpdateTagsInConvo; diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 57ef7d6d82f..8c39d5db4d7 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -294,7 +294,6 @@ export default { com_ui_min_tags: 'Cannot remove more values, a minimum of {0} are required.', com_ui_max_tags: 'Maximum number allowed is {0}, using latest values.', com_ui_bookmarks: 'Bookmarks', - com_ui_bookmarks_rebuild: 'Rebuild', com_ui_bookmarks_new: 'New Bookmark', com_ui_bookmark_delete_confirm: 'Are you sure you want to delete this bookmark?', com_ui_bookmarks_title: 'Title', @@ -696,8 +695,6 @@ export default { 'This action will revoke and remove all the API keys that you have provided. You will need to re-enter these credentials to continue using those endpoints.', com_nav_info_delete_cache_storage: 'This action will delete all cached TTS (Text-to-Speech) audio files stored on your device. Cached audio files are used to speed up playback of previously generated TTS audio, but they can consume storage space on your device.', - com_nav_info_bookmarks_rebuild: - 'If the bookmark count is incorrect, please rebuild the bookmark information. The bookmark count will be recalculated and the data will be restored to its correct state.', // Command Settings Tab com_nav_commands: 'Commands', com_nav_commands_tab: 'Command Settings', diff --git a/client/src/style.css b/client/src/style.css index 43f86e9ef80..a2b6afdac2f 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -32,7 +32,12 @@ html { --text-secondary:var(--gray-600); --text-secondary-alt:var(--gray-500); --text-tertiary:var(--gray-500); + --ring-primary:var(--gray-500); + --header-primary:var(--white); + --header-hover:var(--gray-50); + --header-button-hover:var(--gray-50); --surface-active:var(--gray-100); + --surface-hover:var(--gray-200); --surface-primary:var(--white); --surface-primary-alt:var(--white); --surface-primary-contrast:var(--gray-100); @@ -50,7 +55,11 @@ html { --text-secondary:var(--gray-300); --text-secondary-alt:var(--gray-400); --text-tertiary:var(--gray-500); + --header-primary:var(--gray-700); + --header-hover:var(--gray-600); + --header-button-hover:var(--gray-700); --surface-active:var(--gray-600); + --surface-hover:var(--gray-700); --surface-primary:var(--gray-900); --surface-primary-alt:var(--gray-850); --surface-primary-contrast:var(--gray-850); diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index f28699919f2..ab36de54ea6 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -65,7 +65,12 @@ module.exports = { 'text-secondary': 'var(--text-secondary)', 'text-secondary-alt': 'var(--text-secondary-alt)', 'text-tertiary': 'var(--text-tertiary)', + 'ring-primary': 'var(--ring-primary)', + 'header-primary': 'var(--header-primary)', + 'header-hover': 'var(--header-hover)', + 'header-button-hover': 'var(--header-button-hover)', 'surface-active': 'var(--surface-active)', + 'surface-hover': 'var(--surface-hover)', 'surface-primary': 'var(--surface-primary)', 'surface-primary-alt': 'var(--surface-primary-alt)', 'surface-primary-contrast': 'var(--surface-primary-contrast)', diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 37af1258f16..76f2c11c9f4 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -74,21 +74,6 @@ export const useGetSharedMessages = ( ); }; -export const useGetConversationTags = ( - config?: UseQueryOptions, -): QueryObserverResult => { - return useQuery( - [QueryKeys.conversationTags], - () => dataService.getConversationTags(), - { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - ...config, - }, - ); -}; - export const useGetUserBalance = ( config?: UseQueryOptions, ): QueryObserverResult => { diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index fce9d0278b7..003f2e3d021 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -185,7 +185,6 @@ export type TConversationTagResponse = TConversationTag; // type for tagging conversation export type TTagConversationRequest = { - conversationId: string; tags: string[]; }; export type TTagConversationResponse = string[];