🏷️ fix: Address Statefulness Issues for Bookmarks (#3590)

* refactor: optimize tag methods, remove rebuild

* refactor(tags): add lean db operations, fix updateTagsForConversation, remove rebuild button, only send convoId once

* refactor: Update BookmarkMenu to use Constants.NEW_CONVO constant for comparison

* style: Update BookmarkMenu styles and constants, use theming

* refactor: move tags query from package to client workspace

* refactor: optimize ConversationTag document creation and update logic

* style: Update BookmarkMenuItems to use theming

* refactor: JSDocs + try/catch for conversation tags API routes

* refactor: Update BookmarkNav theming classes and new data provider location

* fix: statefulness of conversation bookmarks
- move non-mutation hook to hooks/Conversation
- remove use of deprecated global convo
- update convo infinite data as well as current convo state upon successful tag add

* refactor: Update BookmarkMenu styles and constants, use theming

* refactor: Add lean option to ConversationTag deletion query

* fix(BookmarkTable): position order rendering esp. when new tag is created

* refactor: Update useBookmarkSucess to useBookmarkSuccess for consistency

* refactor: Update ConversationTag creation logic to increment count only if addToConversation is true

* style: theming
This commit is contained in:
Danny Avila 2024-08-08 21:25:10 -04:00 committed by GitHub
parent 6ea2628b56
commit 016ed866a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 622 additions and 536 deletions

View file

@ -1,277 +1,257 @@
//const crypto = require('crypto');
const logger = require('~/config/winston');
const Conversation = require('./schema/convoSchema');
const ConversationTag = require('./schema/conversationTagSchema'); const ConversationTag = require('./schema/conversationTagSchema');
const Conversation = require('./schema/convoSchema');
const logger = require('~/config/winston');
const SAVED_TAG = 'Saved'; 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<Array>} An array of conversation tags.
*/
const getConversationTags = async (user) => {
try { try {
const conversation = await Conversation.findOne({ user, conversationId }); const cTags = await ConversationTag.find({ user }).sort({ position: 1 }).lean();
if (!conversation) {
return { message: 'Conversation not found' };
}
const addedTags = tags.tags.filter((tag) => !conversation.tags.includes(tag)); cTags.sort((a, b) => {
const removedTags = conversation.tags.filter((tag) => !tags.tags.includes(tag)); if (a.tag === SAVED_TAG) {
for (const tag of addedTags) { return -1;
await ConversationTag.updateOne({ tag, user }, { $inc: { count: 1 } }, { upsert: true }); }
} if (b.tag === SAVED_TAG) {
for (const tag of removedTags) { return 1;
await ConversationTag.updateOne({ tag, user }, { $inc: { count: -1 } }); }
} return 0;
conversation.tags = tags.tags; });
await conversation.save({ timestamps: { updatedAt: false } });
return conversation.tags; return cTags;
} catch (error) { } catch (error) {
logger.error('[updateTagsToConversation] Error updating tags', error); logger.error('[getConversationTags] Error getting conversation tags', error);
return { message: 'Error updating tags' }; 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<Object>} The created tag.
*/
const createConversationTag = async (user, data) => { const createConversationTag = async (user, data) => {
try { try {
const cTag = await ConversationTag.findOne({ user, tag: data.tag }); const { tag, description, addToConversation, conversationId } = data;
if (cTag) {
return cTag; const existingTag = await ConversationTag.findOne({ user, tag }).lean();
if (existingTag) {
return existingTag;
} }
const addToConversation = data.addToConversation && data.conversationId; const maxPosition = await ConversationTag.findOne({ user }).sort('-position').lean();
const newTag = await ConversationTag.create({ const position = (maxPosition?.position || 0) + 1;
user,
tag: data.tag,
count: 0,
description: data.description,
position: 1,
});
await ConversationTag.updateMany( const newTag = await ConversationTag.findOneAndUpdate(
{ user, position: { $gte: 1 }, _id: { $ne: newTag._id } }, { tag, user },
{ $inc: { position: 1 } }, {
tag,
user,
count: addToConversation ? 1 : 0,
position,
description,
$setOnInsert: { createdAt: new Date() },
},
{
new: true,
upsert: true,
lean: true,
},
); );
if (addToConversation) { if (addToConversation && conversationId) {
const conversation = await Conversation.findOne({ await Conversation.findOneAndUpdate(
user, { user, conversationId },
conversationId: data.conversationId, { $addToSet: { tags: tag } },
}); { new: true },
if (conversation) { );
const tags = [...(conversation.tags || []), data.tag];
await updateTagsForConversation(user, data.conversationId, { tags });
} else {
logger.warn('[updateTagsForConversation] Conversation not found', data.conversationId);
}
} }
return await ConversationTag.findOne({ user, tag: data.tag }); return newTag;
} catch (error) { } catch (error) {
logger.error('[createConversationTag] Error updating conversation tag', error); logger.error('[createConversationTag] Error creating conversation tag', error);
return { message: 'Error updating conversation tag' }; 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<Object>} The updated tag.
*/
const updateConversationTag = async (user, oldTag, data) => {
try { try {
const conversations = await Conversation.find({ user, tags: { $in: [oldtag] } }); const { tag: newTag, description, position } = data;
for (const conversation of conversations) {
if (newtag && newtag !== '') { const existingTag = await ConversationTag.findOne({ user, tag: oldTag }).lean();
conversation.tags = conversation.tags.map((tag) => (tag === oldtag ? newtag : tag)); if (!existingTag) {
} else { return null;
conversation.tags = conversation.tags.filter((tag) => tag !== oldtag); }
if (newTag && newTag !== oldTag) {
const tagAlreadyExists = await ConversationTag.findOne({ user, tag: newTag }).lean();
if (tagAlreadyExists) {
throw new Error('Tag already exists');
} }
await conversation.save({ timestamps: { updatedAt: false } });
}
} catch (error) {
logger.error('[replaceOrRemoveTagInConversations] Error updating conversation tags', error);
return { message: 'Error updating conversation tags' };
}
};
const updateTagPosition = async (user, tag, newPosition) => { await Conversation.updateMany({ user, tags: oldTag }, { $set: { 'tags.$': newTag } });
try {
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return { message: 'Tag not found' };
} }
const oldPosition = cTag.position; const updateData = {};
if (newTag) {
if (newPosition === oldPosition) { updateData.tag = newTag;
return cTag; }
if (description !== undefined) {
updateData.description = description;
}
if (position !== undefined) {
await adjustPositions(user, existingTag.position, position);
updateData.position = position;
} }
const updateOperations = []; return await ConversationTag.findOneAndUpdate({ user, tag: oldTag }, updateData, {
new: true,
if (newPosition > oldPosition) { lean: true,
// 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 } },
},
});
}
// Update the target tag's position
updateOperations.push({
updateOne: {
filter: { _id: cTag._id },
update: { $set: { position: newPosition } },
},
}); });
await ConversationTag.bulkWrite(updateOperations);
return await ConversationTag.findById(cTag._id);
} catch (error) { } catch (error) {
logger.error('[updateTagPosition] Error updating tag position', error); logger.error('[updateConversationTag] Error updating conversation tag', error);
return { message: 'Error updating tag position' }; throw new Error('Error updating conversation tag');
} }
}; };
/**
* 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<void>}
*/
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<Object>} The deleted tag.
*/
const deleteConversationTag = async (user, tag) => {
try {
const deletedTag = await ConversationTag.findOneAndDelete({ user, tag }).lean();
if (!deletedTag) {
return null;
}
await Conversation.updateMany({ user, tags: tag }, { $pull: { tags: tag } });
await ConversationTag.updateMany(
{ user, position: { $gt: deletedTag.position } },
{ $inc: { position: -1 } },
);
return deletedTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
throw new Error('Error deleting 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<string[]>} 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');
}
const oldTags = new Set(conversation.tags);
const newTags = new Set(tags);
const addedTags = [...newTags].filter((tag) => !oldTags.has(tag));
const removedTags = [...oldTags].filter((tag) => !newTags.has(tag));
const bulkOps = [];
for (const tag of addedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: 1 } },
upsert: true,
},
});
}
for (const tag of removedTags) {
bulkOps.push({
updateOne: {
filter: { user, tag },
update: { $inc: { count: -1 } },
},
});
}
if (bulkOps.length > 0) {
await ConversationTag.bulkWrite(bulkOps);
}
const updatedConversation = (
await Conversation.findOneAndUpdate(
{ user, conversationId },
{ $set: { tags: [...newTags] } },
{ new: true },
)
).toObject();
return updatedConversation.tags;
} catch (error) {
logger.error('[updateTagsForConversation] Error updating tags', error);
throw new Error('Error updating tags for conversation');
}
};
module.exports = { module.exports = {
SAVED_TAG, SAVED_TAG,
ConversationTag, getConversationTags,
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' };
}
},
createConversationTag, createConversationTag,
updateConversationTag: async (user, tag, data) => { updateConversationTag,
try { deleteConversationTag,
const cTag = await ConversationTag.findOne({ user, tag });
if (!cTag) {
return createConversationTag(user, data);
}
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();
}
if (data.position !== undefined && cTag.position !== data.position) {
await updateTagPosition(user, tag, data.position);
}
// 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' };
}
},
deleteConversationTag: async (user, tag) => {
try {
const currentTag = await ConversationTag.findOne({ user, tag });
if (!currentTag) {
return;
}
await currentTag.deleteOne({ user, tag });
await replaceOrRemoveTagInConversations(user, tag, null);
return currentTag;
} catch (error) {
logger.error('[deleteConversationTag] Error deleting conversation tag', error);
return { message: 'Error deleting conversation tag' };
}
},
updateTagsForConversation, 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;
}
});
});
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();
}
}
// 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();
}
}
// Sort tags by position in descending order
tags.sort((a, b) => a.position - b.position);
// 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);
}
// 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' };
}
},
}; };

View file

@ -175,8 +175,17 @@ router.post('/fork', async (req, res) => {
}); });
router.put('/tags/:conversationId', async (req, res) => { router.put('/tags/:conversationId', async (req, res) => {
const tag = await updateTagsForConversation(req.user.id, req.params.conversationId, req.body); try {
res.status(200).json(tag); 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; module.exports = router;

View file

@ -1,44 +1,88 @@
const express = require('express'); const express = require('express');
const { const {
getConversationTags, getConversationTags,
updateConversationTag, updateConversationTag,
createConversationTag, createConversationTag,
deleteConversationTag, deleteConversationTag,
rebuildConversationTags,
} = require('~/models/ConversationTag'); } = require('~/models/ConversationTag');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router(); const router = express.Router();
router.use(requireJwtAuth); 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) => { router.get('/', async (req, res) => {
const tags = await getConversationTags(req.user.id); try {
const tags = await getConversationTags(req.user.id);
if (tags) { if (tags) {
res.status(200).json(tags); res.status(200).json(tags);
} else { } else {
res.status(404).end(); 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) => { router.post('/', async (req, res) => {
const tag = await createConversationTag(req.user.id, req.body); try {
res.status(200).json(tag); const tag = await createConversationTag(req.user.id, req.body);
}); res.status(200).json(tag);
} catch (error) {
router.post('/rebuild', async (req, res) => { console.error('Error creating conversation tag:', error);
const tag = await rebuildConversationTags(req.user.id); res.status(500).json({ error: 'Internal server error' });
res.status(200).json(tag); }
}); });
/**
* 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) => { router.put('/:tag', async (req, res) => {
const tag = await updateConversationTag(req.user.id, req.params.tag, req.body); try {
res.status(200).json(tag); 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) => { router.delete('/:tag', async (req, res) => {
const tag = await deleteConversationTag(req.user.id, req.params.tag); try {
res.status(200).json(tag); 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; module.exports = router;

View file

@ -9,9 +9,9 @@ import { cn, removeFocusOutlines, defaultTextProps } from '~/utils/';
import { useBookmarkContext } from '~/Providers/BookmarkContext'; import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { useConversationTagMutation } from '~/data-provider'; import { useConversationTagMutation } from '~/data-provider';
import { Checkbox, Label, TextareaAutosize } from '~/components/ui/'; import { Checkbox, Label, TextareaAutosize } from '~/components/ui/';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
type TBookmarkFormProps = { type TBookmarkFormProps = {
bookmark?: TConversationTag; bookmark?: TConversationTag;
@ -31,10 +31,11 @@ const BookmarkForm = ({
tags, tags,
setTags, setTags,
}: TBookmarkFormProps) => { }: TBookmarkFormProps) => {
const { showToast } = useToastContext();
const localize = useLocalize(); const localize = useLocalize();
const mutation = useConversationTagMutation(bookmark?.tag); const { showToast } = useToastContext();
const { bookmarks } = useBookmarkContext(); const { bookmarks } = useBookmarkContext();
const mutation = useConversationTagMutation(bookmark?.tag);
const onSuccess = useBookmarkSuccess(conversation?.conversationId || '');
const { const {
register, register,
@ -82,6 +83,7 @@ const BookmarkForm = ({
(tag) => tag !== undefined, (tag) => tag !== undefined,
) as string[]; ) as string[];
setTags(newTags); setTags(newTags);
onSuccess(newTags);
} }
}, },
onError: () => { onError: () => {
@ -172,9 +174,9 @@ const BookmarkForm = ({
/> />
)} )}
/> />
<label <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 text-token-text-primary w-full cursor-pointer"
htmlFor="addToConversation"
onClick={() => onClick={() =>
setValue('addToConversation', !getValues('addToConversation'), { setValue('addToConversation', !getValues('addToConversation'), {
shouldDirty: true, shouldDirty: true,
@ -184,7 +186,7 @@ const BookmarkForm = ({
<div className="flex select-none items-center"> <div className="flex select-none items-center">
{localize('com_ui_bookmarks_add_to_conversation')} {localize('com_ui_bookmarks_add_to_conversation')}
</div> </div>
</label> </button>
</div> </div>
)} )}
</div> </div>

View file

@ -7,6 +7,7 @@ import { cn } from '~/utils';
type MenuItemProps = { type MenuItemProps = {
tag: string | React.ReactNode; tag: string | React.ReactNode;
selected: boolean; selected: boolean;
ctx: 'header' | 'nav';
count?: number; count?: number;
handleSubmit: (tag: string) => Promise<void>; handleSubmit: (tag: string) => Promise<void>;
icon?: React.ReactNode; icon?: React.ReactNode;
@ -15,6 +16,7 @@ type MenuItemProps = {
const BookmarkItem: FC<MenuItemProps> = ({ const BookmarkItem: FC<MenuItemProps> = ({
tag, tag,
ctx,
selected, selected,
count, count,
handleSubmit, handleSubmit,
@ -34,13 +36,30 @@ const BookmarkItem: FC<MenuItemProps> = ({
overflowWrap: 'anywhere', overflowWrap: 'anywhere',
}; };
const renderIcon = () => {
if (icon) {
return icon;
}
if (isLoading) {
return <Spinner className="size-4" />;
}
if (selected) {
return <BookmarkFilledIcon className="size-4" />;
}
return <BookmarkIcon className="size-4" />;
};
const ariaLabel =
ctx === 'header' ? `${selected ? 'Remove' : 'Add'} bookmark for ${tag}` : (tag as string);
return ( return (
<div <button
aria-label={ariaLabel}
role="menuitem" role="menuitem"
className={cn( className={cn(
'group m-1.5 flex w-[225px] cursor-pointer gap-2 rounded px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50', 'group m-1.5 flex w-[225px] cursor-pointer gap-2 rounded bg-transparent px-2 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50',
'hover:bg-black/5 dark:hover:bg-white/5', highlightSelected && selected ? 'bg-surface-secondary' : '',
highlightSelected && selected && 'bg-black/5 dark:bg-white/5', ctx === 'header' ? 'hover:bg-header-hover' : 'hover:bg-surface-hover',
)} )}
tabIndex={-1} tabIndex={-1}
{...rest} {...rest}
@ -48,25 +67,14 @@ const BookmarkItem: FC<MenuItemProps> = ({
> >
<div className="flex grow items-center justify-between gap-2"> <div className="flex grow items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{icon ? ( {renderIcon()}
icon
) : isLoading ? (
<Spinner className="size-4" />
) : selected ? (
<BookmarkFilledIcon className="size-4" />
) : (
<BookmarkIcon className="size-4" />
)}
<div style={breakWordStyle}>{tag}</div> <div style={breakWordStyle}>{tag}</div>
</div> </div>
{count !== undefined && ( {count !== undefined && (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<span <span
className={cn( className="ml-auto w-7 min-w-max whitespace-nowrap rounded-md bg-surface-secondary px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-text-secondary"
'ml-auto w-7 min-w-max whitespace-nowrap rounded-md bg-white px-2.5 py-0.5 text-center text-xs font-medium leading-5 text-gray-600',
'dark:bg-gray-800 dark:text-white',
)}
aria-hidden="true" aria-hidden="true"
> >
{count} {count}
@ -74,7 +82,8 @@ const BookmarkItem: FC<MenuItemProps> = ({
</div> </div>
)} )}
</div> </div>
</div> </button>
); );
}; };
export default BookmarkItem; export default BookmarkItem;

View file

@ -2,6 +2,7 @@ import type { FC } from 'react';
import { useBookmarkContext } from '~/Providers/BookmarkContext'; import { useBookmarkContext } from '~/Providers/BookmarkContext';
import BookmarkItem from './BookmarkItem'; import BookmarkItem from './BookmarkItem';
interface BookmarkItemsProps { interface BookmarkItemsProps {
ctx: 'header' | 'nav';
tags: string[]; tags: string[];
handleSubmit: (tag: string) => Promise<void>; handleSubmit: (tag: string) => Promise<void>;
header: React.ReactNode; header: React.ReactNode;
@ -9,6 +10,7 @@ interface BookmarkItemsProps {
} }
const BookmarkItems: FC<BookmarkItemsProps> = ({ const BookmarkItems: FC<BookmarkItemsProps> = ({
ctx,
tags, tags,
handleSubmit, handleSubmit,
header, header,
@ -19,9 +21,10 @@ const BookmarkItems: FC<BookmarkItemsProps> = ({
return ( return (
<> <>
{header} {header}
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" /> <div className="my-1.5 h-px" role="none" />
{bookmarks.map((bookmark) => ( {bookmarks.map((bookmark) => (
<BookmarkItem <BookmarkItem
ctx={ctx}
key={bookmark.tag} key={bookmark.tag}
tag={bookmark.tag} tag={bookmark.tag}
selected={tags.includes(bookmark.tag)} selected={tags.includes(bookmark.tag)}

View file

@ -14,14 +14,14 @@ const FileContainer = ({
const fileType = getFileType(file.type); const fileType = getFileType(file.type);
return ( return (
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90"> <div className="group relative inline-block text-sm text-text-primary">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600"> <div className="relative overflow-hidden rounded-xl border border-border-medium">
<div className="w-60 p-2 dark:bg-gray-600"> <div className="w-60 bg-surface-active p-2">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<FilePreview file={file} fileType={fileType} className="relative" /> <FilePreview file={file} fileType={fileType} className="relative" />
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="truncate font-medium">{file.filename}</div> <div className="truncate font-medium">{file.filename}</div>
<div className="truncate text-gray-300">{fileType.title}</div> <div className="truncate text-text-secondary">{fileType.title}</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -66,7 +66,7 @@ export default function OptionsPopover({
{presetsDisabled ? null : ( {presetsDisabled ? null : (
<Button <Button
type="button" type="button"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white" className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-ring-primary dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white"
onClick={saveAsPreset} onClick={saveAsPreset}
> >
<Save className="mr-1 w-[14px]" /> <Save className="mr-1 w-[14px]" />
@ -77,7 +77,7 @@ export default function OptionsPopover({
<Button <Button
type="button" type="button"
className={cn( className={cn(
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white', 'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
removeFocusOutlines, removeFocusOutlines,
)} )}
onClick={closePopover} onClick={closePopover}

View file

@ -1,62 +1,36 @@
import { useEffect, useState, type FC } from 'react'; import { useState, type FC } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom'; import { Constants } from 'librechat-data-provider';
import { TConversation } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider'; import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems'; import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems';
import { BookmarkContext } from '~/Providers/BookmarkContext'; import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { Spinner } from '~/components'; import { Spinner } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
const SAVED_TAG = 'Saved'; const SAVED_TAG = 'Saved';
const BookmarkMenu: FC = () => { const BookmarkMenu: FC = () => {
const localize = useLocalize(); const localize = useLocalize();
const location = useLocation();
const activeConvo = useRecoilValue(store.conversationByIndex(0)); const conversation = useRecoilValue(store.conversationByIndex(0));
const conversationId = conversation?.conversationId ?? '';
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation); const onSuccess = useBookmarkSuccess(conversationId);
const [tags, setTags] = useState<string[]>(); const [tags, setTags] = useState<string[]>(conversation?.tags || []);
const [open, setIsOpen] = useState(false); const [open, setIsOpen] = useState(false);
const [conversation, setConversation] = useState<TConversation>();
let thisConversation: TConversation | null | undefined; const { mutateAsync, isLoading } = useTagConversationMutation(conversationId);
if (location.state?.from?.pathname.includes('/chat')) {
thisConversation = globalConvo;
} else {
thisConversation = activeConvo;
}
const { mutateAsync, isLoading } = useTagConversationMutation(
thisConversation?.conversationId ?? '',
);
const { data } = useConversationTagsQuery(); const { data } = useConversationTagsQuery();
useEffect(() => {
if (
(!conversation && thisConversation) ||
(conversation &&
thisConversation &&
conversation.conversationId !== thisConversation.conversationId)
) {
setConversation(thisConversation);
setTags(thisConversation.tags ?? []);
}
if (tags === undefined && conversation) {
setTags(conversation.tags ?? []);
}
}, [thisConversation, conversation, tags]);
const isActiveConvo = const isActiveConvo =
thisConversation && conversation &&
thisConversation.conversationId && conversationId &&
thisConversation.conversationId !== 'new' && conversationId !== Constants.NEW_CONVO &&
thisConversation.conversationId !== 'search'; conversationId !== 'search';
if (!isActiveConvo) { if (!isActiveConvo) {
return <></>; return <></>;
@ -70,59 +44,59 @@ const BookmarkMenu: FC = () => {
if (open && tags && tags.length > 0) { if (open && tags && tags.length > 0) {
setIsOpen(open); setIsOpen(open);
} else { } else {
if (thisConversation && thisConversation.conversationId) { if (conversation && conversationId) {
await mutateAsync({ await mutateAsync(
conversationId: thisConversation.conversationId, {
tags: [SAVED_TAG], tags: [SAVED_TAG],
}); },
setTags([SAVED_TAG]); {
setConversation({ ...thisConversation, tags: [SAVED_TAG] }); onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
},
onError: () => {
console.error('Error adding bookmark');
},
},
);
} }
} }
}; };
const renderButtonContent = () => {
if (isLoading) {
return <Spinner />;
}
if (tags && tags.length > 0) {
return <BookmarkFilledIcon className="icon-sm" />;
}
return <BookmarkIcon className="icon-sm" />;
};
return ( return (
<Root open={open} onOpenChange={onOpenChange}> <Root open={open} onOpenChange={onOpenChange}>
<Trigger asChild> <Trigger asChild>
<button <button
id="header-bookmarks-menu"
className={cn( className={cn(
'pointer-cursor relative flex flex-col rounded-md border border-gray-100 bg-white text-left focus:outline-none focus:ring-0 dark:border-gray-700 dark:bg-gray-800 sm:text-sm', 'pointer-cursor relative flex flex-col rounded-md border border-border-light bg-transparent text-left focus:outline-none focus:ring-0 sm:text-sm',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700', 'hover:bg-header-button-hover radix-state-open:bg-header-button-hover',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:outline-offset-2 focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-500', 'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:outline-offset-2 focus:ring-0 focus-visible:ring-2 focus-visible:ring-ring-primary ',
)} )}
title={localize('com_ui_bookmarks')} title={localize('com_ui_bookmarks')}
> >
{isLoading ? ( {renderButtonContent()}
<Spinner />
) : tags && tags.length > 0 ? (
<BookmarkFilledIcon className="icon-sm" />
) : (
<BookmarkIcon className="icon-sm" />
)}
</button> </button>
</Trigger> </Trigger>
<Portal> <Portal>
<Content <Content
className={cn( className="mt-2 grid max-h-[500px] w-full min-w-[240px] overflow-y-auto rounded-lg border border-border-medium bg-header-primary text-text-primary shadow-lg"
'grid w-full',
'mt-2 min-w-[240px] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white',
'max-h-[500px]',
)}
side="bottom" side="bottom"
align="start" align="start"
> >
{data && conversation && ( {data && conversation && (
// Display all bookmarks registered by the user and highlight the tags of the currently selected conversation
<BookmarkContext.Provider value={{ bookmarks: data }}> <BookmarkContext.Provider value={{ bookmarks: data }}>
<BookmarkMenuItems <BookmarkMenuItems conversation={conversation} tags={tags ?? []} setTags={setTags} />
// Currently selected conversation
conversation={conversation}
setConversation={setConversation}
// Tags in the conversation
tags={tags ?? []}
// Update tags in the conversation
setTags={setTags}
/>
</BookmarkContext.Provider> </BookmarkContext.Provider>
)} )}
</Content> </Content>

View file

@ -1,36 +1,37 @@
import { useCallback } from 'react'; import React, { useCallback } from 'react';
import { BookmarkPlusIcon } from 'lucide-react'; import { BookmarkPlusIcon } from 'lucide-react';
import type { FC } from 'react'; import type { FC } from 'react';
import type { TConversation } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider';
import { BookmarkItems, BookmarkEditDialog } from '~/components/Bookmarks'; import { BookmarkItems, BookmarkEditDialog } from '~/components/Bookmarks';
import { useTagConversationMutation } from '~/data-provider'; import { useTagConversationMutation } from '~/data-provider';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
export const BookmarkMenuItems: FC<{ export const BookmarkMenuItems: FC<{
conversation: TConversation; conversation: TConversation;
tags: string[]; tags: string[];
setTags: (tags: string[]) => void; setTags: React.Dispatch<React.SetStateAction<string[]>>;
setConversation: (conversation: TConversation) => void; }> = ({ conversation, tags, setTags }) => {
}> = ({ conversation, tags, setTags, setConversation }) => {
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const localize = useLocalize(); const localize = useLocalize();
const { mutateAsync } = useTagConversationMutation(conversation?.conversationId ?? ''); const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const { mutateAsync } = useTagConversationMutation(conversationId);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (tag: string): Promise<void> => { async (tag: string): Promise<void> => {
if (tags !== undefined && conversation?.conversationId) { if (tags !== undefined && conversationId) {
const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag]; const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag];
await mutateAsync( await mutateAsync(
{ {
conversationId: conversation.conversationId,
tags: newTags, tags: newTags,
}, },
{ {
onSuccess: (newTags: string[]) => { onSuccess: (newTags: string[]) => {
setTags(newTags); setTags(newTags);
setConversation({ ...conversation, tags: newTags }); onSuccess(newTags);
}, },
onError: () => { onError: () => {
showToast({ showToast({
@ -42,11 +43,12 @@ export const BookmarkMenuItems: FC<{
); );
} }
}, },
[tags, conversation], [tags, conversationId, mutateAsync, setTags, onSuccess, showToast],
); );
return ( return (
<BookmarkItems <BookmarkItems
ctx="header"
tags={tags} tags={tags}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
header={ header={
@ -58,7 +60,7 @@ export const BookmarkMenuItems: FC<{
trigger={ trigger={
<div <div
role="menuitem" role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5" className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-header-hover focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50"
tabIndex={-1} tabIndex={-1}
> >
<div className="flex grow items-center justify-between gap-2"> <div className="flex grow items-center justify-between gap-2">

View file

@ -4,8 +4,8 @@ import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider'; import { TConversation } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover'; import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useGetConversationTags } from 'librechat-data-provider/react-query';
import { BookmarkContext } from '~/Providers/BookmarkContext'; import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems'; import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -39,22 +39,22 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
<Trigger asChild> <Trigger asChild>
<button <button
className={cn( className={cn(
'relative mt-1 flex h-10 w-full cursor-pointer items-center gap-1 rounded-lg border-white bg-gray-50 px-1 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800', 'relative mt-1 flex h-10 w-full cursor-pointer items-center gap-1 rounded-lg border-border-light bg-transparent px-1 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
open ? 'bg-gray-200 dark:bg-gray-800' : '', open ? 'bg-surface-hover' : '',
)} )}
id="presets-button" id="show-bookmarks"
data-testid="presets-button" data-testid="show-bookmarks"
title={localize('com_endpoint_examples')} title={localize('com_ui_bookmarks')}
> >
<div className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 dark:text-white"> <div className="relative flex h-8 w-8 items-center justify-center rounded-full p-1 text-text-primary">
{tags.length > 0 ? ( {tags.length > 0 ? (
<BookmarkFilledIcon className="h-5 w-5" /> <BookmarkFilledIcon className="h-5 w-5" />
) : ( ) : (
<BookmarkIcon className="h-5 w-5" /> <BookmarkIcon className="h-5 w-5" />
)} )}
</div> </div>
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm text-black dark:text-gray-100"> <div className="grow overflow-hidden whitespace-nowrap text-left text-sm text-text-primary">
{tags.length > 0 ? tags.join(',') : localize('com_ui_bookmarks')} {tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
</div> </div>
</button> </button>
</Trigger> </Trigger>
@ -63,7 +63,7 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
<Content <Content
side="bottom" side="bottom"
align="start" align="start"
className="mt-2 max-h-96 min-w-[240px] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-96" className="mt-2 max-h-96 min-w-[240px] overflow-y-auto rounded-lg border border-border-medium bg-surface-primary-alt text-text-primary shadow-lg lg:max-h-96"
> >
{data && conversation && ( {data && conversation && (
// Display bookmarks and highlight the selected tag // Display bookmarks and highlight the selected tag

View file

@ -39,12 +39,11 @@ const BookmarkNavItems: FC<{
return Promise.resolve(); return Promise.resolve();
}; };
console.log('bookmarks', bookmarks);
if (bookmarks.length === 0) { if (bookmarks.length === 0) {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<BookmarkItem <BookmarkItem
ctx="nav"
tag={localize('com_ui_no_bookmarks')} tag={localize('com_ui_no_bookmarks')}
data-testid="bookmark-item-clear" data-testid="bookmark-item-clear"
handleSubmit={() => Promise.resolve()} handleSubmit={() => Promise.resolve()}
@ -58,11 +57,13 @@ const BookmarkNavItems: FC<{
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<BookmarkItems <BookmarkItems
ctx="nav"
tags={tags} tags={tags}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
highlightSelected={true} highlightSelected={true}
header={ header={
<BookmarkItem <BookmarkItem
ctx="nav"
tag="Clear all" tag="Clear all"
data-testid="bookmark-item-clear" data-testid="bookmark-item-clear"
handleSubmit={clear} handleSubmit={clear}

View file

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* Reason: SearchContext is not specifying potential undefined type */
import { useCallback, useEffect, useState, useMemo, memo } from 'react'; import { useCallback, useEffect, useState, useMemo, memo } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import type { ConversationListResponse } from 'librechat-data-provider'; import type { ConversationListResponse } from 'librechat-data-provider';
import { import {
useMediaQuery, useMediaQuery,
@ -159,7 +157,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
toggleNav={itemToggleNav} toggleNav={itemToggleNav}
subHeaders={ subHeaders={
<> <>
{isSearchEnabled && <SearchBar clearSearch={clearSearch} />} {isSearchEnabled && <SearchBar clearSearch={clearSearch} />}
<BookmarkNav tags={tags} setTags={setTags} /> <BookmarkNav tags={tags} setTags={setTags} />
</> </>
} }

View file

@ -58,12 +58,12 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
return ( return (
<div <div
ref={ref} ref={ref}
className="relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800" className="relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover dark:focus-within:bg-surface-hover"
> >
{<Search className="absolute left-3 h-4 w-4" />} {<Search className="absolute left-3 h-4 w-4" />}
<input <input
type="text" type="text"
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-gray-500 placeholder-opacity-100 outline-none dark:placeholder-white dark:placeholder-opacity-100" className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 outline-none dark:placeholder-opacity-100"
value={text} value={text}
onChange={onChange} onChange={onChange}
onKeyDown={(e) => { onKeyDown={(e) => {

View file

@ -1,38 +1,23 @@
import { BookmarkPlusIcon } from 'lucide-react'; import { BookmarkPlusIcon } from 'lucide-react';
import { useConversationTagsQuery, useRebuildConversationTagsMutation } from '~/data-provider'; import { useConversationTagsQuery } from '~/data-provider';
import { Button } from '~/components/ui'; import { Button } from '~/components/ui';
import { BookmarkContext } from '~/Providers/BookmarkContext'; import { BookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkEditDialog } from '~/components/Bookmarks'; import { BookmarkEditDialog } from '~/components/Bookmarks';
import BookmarkTable from './BookmarkTable'; import BookmarkTable from './BookmarkTable';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
const BookmarkPanel = () => { const BookmarkPanel = () => {
const localize = useLocalize(); const localize = useLocalize();
const { mutate, isLoading } = useRebuildConversationTagsMutation();
const { data } = useConversationTagsQuery(); const { data } = useConversationTagsQuery();
const rebuildTags = () => {
mutate({});
};
return ( return (
<div className="h-auto max-w-full overflow-x-hidden"> <div className="h-auto max-w-full overflow-x-hidden">
<BookmarkContext.Provider value={{ bookmarks: data || [] }}> <BookmarkContext.Provider value={{ bookmarks: data || [] }}>
<BookmarkTable /> <BookmarkTable />
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<Button variant="outline" onClick={rebuildTags} className="w-50 text-sm">
{isLoading ? (
<Spinner />
) : (
<div className="flex gap-2">
{localize('com_ui_bookmarks_rebuild')}
<HoverCardSettings side="top" text="com_nav_info_bookmarks_rebuild" />
</div>
)}
</Button>
<BookmarkEditDialog <BookmarkEditDialog
trigger={ trigger={
<Button variant="outline" onClick={rebuildTags} className="w-full text-sm"> <Button variant="outline" className="w-full text-sm">
<BookmarkPlusIcon className="mr-1 size-4" /> <BookmarkPlusIcon className="mr-1 size-4" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div> <div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</Button> </Button>

View file

@ -14,7 +14,11 @@ const BookmarkTable = () => {
const { bookmarks } = useBookmarkContext(); const { bookmarks } = useBookmarkContext();
useEffect(() => { 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]); }, [bookmarks]);
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => { const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
@ -22,13 +26,16 @@ const BookmarkTable = () => {
const updatedRows = [...prevTags]; const updatedRows = [...prevTags];
const [movedRow] = updatedRows.splice(dragIndex, 1); const [movedRow] = updatedRows.splice(dragIndex, 1);
updatedRows.splice(hoverIndex, 0, movedRow); updatedRows.splice(hoverIndex, 0, movedRow);
return updatedRows; return updatedRows.map((row, index) => ({ ...row, position: index }));
}); });
}, []); }, []);
const renderRow = useCallback((row: TConversationTag, position: number) => { const renderRow = useCallback(
return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={position} />; (row: TConversationTag) => {
}, []); return <BookmarkTableRow key={row.tag} moveRow={moveRow} row={row} position={row.position} />;
},
[moveRow],
);
const filteredRows = rows.filter((row) => const filteredRows = rows.filter((row) =>
row.tag.toLowerCase().includes(searchQuery.toLowerCase()), row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
@ -58,7 +65,7 @@ const BookmarkTable = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody>{currentRows.map((row, i) => renderRow(row, i))}</TableBody> <TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
</Table> </Table>
</div> </div>
<div className="flex items-center justify-between py-4"> <div className="flex items-center justify-between py-4">

View file

@ -1,8 +1,12 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import type { TConversationTag } from 'librechat-data-provider'; import type { TConversationTag } from 'librechat-data-provider';
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks'; import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
import { useConversationTagMutation } from '~/data-provider';
import { TableRow, TableCell } from '~/components/ui'; import { TableRow, TableCell } from '~/components/ui';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
interface BookmarkTableRowProps { interface BookmarkTableRowProps {
row: TConversationTag; row: TConversationTag;
@ -10,13 +14,39 @@ interface BookmarkTableRowProps {
position: number; position: number;
} }
interface DragItem {
index: number;
id: string;
type: string;
}
const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => { const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const ref = React.useRef<HTMLTableRowElement>(null); const ref = useRef<HTMLTableRowElement>(null);
const mutation = useConversationTagMutation(row.tag);
const localize = useLocalize();
const { showToast } = useToastContext();
const handleDrop = (item: DragItem) => {
const data = {
...row,
position: item.index,
};
mutation.mutate(data, {
onError: () => {
showToast({
message: localize('com_ui_bookmarks_update_error'),
severity: NotificationSeverity.ERROR,
});
},
});
};
const [, drop] = useDrop({ const [, drop] = useDrop({
accept: 'bookmark', accept: 'bookmark',
hover(item: { index: number }) { drop: (item: DragItem) => handleDrop(item),
hover(item: DragItem) {
if (!ref.current) { if (!ref.current) {
return; return;
} }
@ -43,7 +73,7 @@ const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, posit
return ( return (
<TableRow <TableRow
ref={ref} ref={ref}
className="cursor-move hover:bg-gray-100 dark:hover:bg-gray-800" className="cursor-move hover:bg-surface-secondary"
style={{ opacity: isDragging ? 0.5 : 1 }} style={{ opacity: isDragging ? 0.5 : 1 }}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}

View file

@ -3,3 +3,4 @@ export * from './mutations';
export * from './prompts'; export * from './prompts';
export * from './queries'; export * from './queries';
export * from './roles'; export * from './roles';
export * from './tags';

View file

@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider'; import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import type { InfiniteData, UseMutationResult } from '@tanstack/react-query'; import type { InfiniteData, UseMutationResult } from '@tanstack/react-query';
import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
import { updateConversationTag } from '~/utils/conversationTags'; import { updateConversationTag } from '~/utils/conversationTags';
import { normalizeData } from '~/utils/collection'; import { normalizeData } from '~/utils/collection';
import store from '~/store'; 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<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (!currentConvo) {
return;
}
const updatedConvo = {
...currentConvo,
tags,
} as t.TConversation;
queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo);
queryClient.setQueryData<t.ConversationData>([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<InfiniteData<ConversationListResponse>>([
QueryKeys.allConversations,
]);
const conversationIdsWithTag = [] as string[];
// update tag to newTag in all conversations
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
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<InfiniteData<ConversationListResponse>>(
[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<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (conversation && conversation.tags) {
const updatedConvo = {
...conversation,
tags: conversation.tags.map((t) => (t === tag ? newTag : t)),
} as t.TConversation;
queryClient.setQueryData<t.TConversation>(
[QueryKeys.conversation, conversationId],
updatedConvo,
);
}
}
};
return { updateTagsInConversation, replaceTagsInAllConversations };
};
/** /**
* Add or remove tags for a conversation * Add or remove tags for a conversation
*/ */
@ -179,7 +97,7 @@ export const useTagConversationMutation = (
conversationId: string, conversationId: string,
): UseMutationResult<t.TTagConversationResponse, unknown, t.TTagConversationRequest, unknown> => { ): UseMutationResult<t.TTagConversationResponse, unknown, t.TTagConversationRequest, unknown> => {
const query = useConversationTagsQuery(); const query = useConversationTagsQuery();
const { updateTagsInConversation } = useUpdateTagsInConversation(); const { updateTagsInConversation } = useUpdateTagsInConvo();
return useMutation( return useMutation(
(payload: t.TTagConversationRequest) => (payload: t.TTagConversationRequest) =>
dataService.addTagToConversation(conversationId, payload), 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<t.TConversationTag[]>([QueryKeys.conversationTags], _data);
},
});
};
// Add a tag or update tag information (tag, description, position, etc.) // Add a tag or update tag information (tag, description, position, etc.)
export const useConversationTagMutation = ( export const useConversationTagMutation = (
tag?: string, tag?: string,
@ -407,7 +310,7 @@ export const useConversationTagMutation = (
): UseMutationResult<t.TConversationTagResponse, unknown, t.TConversationTagRequest, unknown> => { ): UseMutationResult<t.TConversationTagResponse, unknown, t.TConversationTagRequest, unknown> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { ..._options } = options || {}; const { ..._options } = options || {};
const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConversation(); const { updateTagsInConversation, replaceTagsInAllConversations } = useUpdateTagsInConvo();
return useMutation( return useMutation(
(payload: t.TConversationTagRequest) => (payload: t.TConversationTagRequest) =>
tag tag
@ -427,6 +330,9 @@ export const useConversationTagMutation = (
}, },
] as t.TConversationTag[]; ] as t.TConversationTag[];
} }
if (!tag) {
return [...data, _data].sort((a, b) => a.position - b.position);
}
return updateConversationTag(data, vars, _data, tag); return updateConversationTag(data, vars, _data, tag);
}); });
if (vars.addToConversation && vars.conversationId && _data.tag) { if (vars.addToConversation && vars.conversationId && _data.tag) {

View file

@ -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<TConversationTagsResponse>,
): QueryObserverResult<TConversationTagsResponse> => {
return useQuery<TConversationTagsResponse>(
[QueryKeys.conversationTags],
() => dataService.getConversationTags(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};

View file

@ -6,7 +6,9 @@ export { default as useConversation } from './useConversation';
export { default as useGenerateConvo } from './useGenerateConvo'; export { default as useGenerateConvo } from './useGenerateConvo';
export { default as useConversations } from './useConversations'; export { default as useConversations } from './useConversations';
export { default as useDebouncedInput } from './useDebouncedInput'; export { default as useDebouncedInput } from './useDebouncedInput';
export { default as useBookmarkSuccess } from './useBookmarkSuccess';
export { default as useNavigateToConvo } from './useNavigateToConvo'; export { default as useNavigateToConvo } from './useNavigateToConvo';
export { default as useSetIndexOptions } from './useSetIndexOptions'; export { default as useSetIndexOptions } from './useSetIndexOptions';
export { default as useParameterEffects } from './useParameterEffects'; export { default as useParameterEffects } from './useParameterEffects';
export { default as useUpdateTagsInConvo } from './useUpdateTagsInConvo';
export { default as useExportConversation } from './useExportConversation'; export { default as useExportConversation } from './useExportConversation';

View file

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

View file

@ -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<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (!currentConvo) {
return;
}
const updatedConvo = {
...currentConvo,
tags,
} as t.TConversation;
queryClient.setQueryData([QueryKeys.conversation, conversationId], updatedConvo);
queryClient.setQueryData<t.ConversationData>([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<InfiniteData<ConversationListResponse>>([
QueryKeys.allConversations,
]);
const conversationIdsWithTag = [] as string[];
// update tag to newTag in all conversations
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
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<InfiniteData<ConversationListResponse>>(
[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<t.TConversation>([
QueryKeys.conversation,
conversationId,
]);
if (conversation && conversation.tags) {
const updatedConvo = {
...conversation,
tags: conversation.tags.map((t) => (t === tag ? newTag : t)),
} as t.TConversation;
queryClient.setQueryData<t.TConversation>(
[QueryKeys.conversation, conversationId],
updatedConvo,
);
}
}
};
return { updateTagsInConversation, replaceTagsInAllConversations };
};
export default useUpdateTagsInConvo;

View file

@ -294,7 +294,6 @@ export default {
com_ui_min_tags: 'Cannot remove more values, a minimum of {0} are required.', 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_max_tags: 'Maximum number allowed is {0}, using latest values.',
com_ui_bookmarks: 'Bookmarks', com_ui_bookmarks: 'Bookmarks',
com_ui_bookmarks_rebuild: 'Rebuild',
com_ui_bookmarks_new: 'New Bookmark', com_ui_bookmarks_new: 'New Bookmark',
com_ui_bookmark_delete_confirm: 'Are you sure you want to delete this bookmark?', com_ui_bookmark_delete_confirm: 'Are you sure you want to delete this bookmark?',
com_ui_bookmarks_title: 'Title', 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.', '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: 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.', '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 // Command Settings Tab
com_nav_commands: 'Commands', com_nav_commands: 'Commands',
com_nav_commands_tab: 'Command Settings', com_nav_commands_tab: 'Command Settings',

View file

@ -32,7 +32,12 @@ html {
--text-secondary:var(--gray-600); --text-secondary:var(--gray-600);
--text-secondary-alt:var(--gray-500); --text-secondary-alt:var(--gray-500);
--text-tertiary: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-active:var(--gray-100);
--surface-hover:var(--gray-200);
--surface-primary:var(--white); --surface-primary:var(--white);
--surface-primary-alt:var(--white); --surface-primary-alt:var(--white);
--surface-primary-contrast:var(--gray-100); --surface-primary-contrast:var(--gray-100);
@ -50,7 +55,11 @@ html {
--text-secondary:var(--gray-300); --text-secondary:var(--gray-300);
--text-secondary-alt:var(--gray-400); --text-secondary-alt:var(--gray-400);
--text-tertiary:var(--gray-500); --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-active:var(--gray-600);
--surface-hover:var(--gray-700);
--surface-primary:var(--gray-900); --surface-primary:var(--gray-900);
--surface-primary-alt:var(--gray-850); --surface-primary-alt:var(--gray-850);
--surface-primary-contrast:var(--gray-850); --surface-primary-contrast:var(--gray-850);

View file

@ -65,7 +65,12 @@ module.exports = {
'text-secondary': 'var(--text-secondary)', 'text-secondary': 'var(--text-secondary)',
'text-secondary-alt': 'var(--text-secondary-alt)', 'text-secondary-alt': 'var(--text-secondary-alt)',
'text-tertiary': 'var(--text-tertiary)', '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-active': 'var(--surface-active)',
'surface-hover': 'var(--surface-hover)',
'surface-primary': 'var(--surface-primary)', 'surface-primary': 'var(--surface-primary)',
'surface-primary-alt': 'var(--surface-primary-alt)', 'surface-primary-alt': 'var(--surface-primary-alt)',
'surface-primary-contrast': 'var(--surface-primary-contrast)', 'surface-primary-contrast': 'var(--surface-primary-contrast)',

View file

@ -74,21 +74,6 @@ export const useGetSharedMessages = (
); );
}; };
export const useGetConversationTags = (
config?: UseQueryOptions<t.TConversationTagsResponse>,
): QueryObserverResult<t.TConversationTagsResponse> => {
return useQuery<t.TConversationTagsResponse>(
[QueryKeys.conversationTags],
() => dataService.getConversationTags(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
},
);
};
export const useGetUserBalance = ( export const useGetUserBalance = (
config?: UseQueryOptions<string>, config?: UseQueryOptions<string>,
): QueryObserverResult<string> => { ): QueryObserverResult<string> => {

View file

@ -185,7 +185,6 @@ export type TConversationTagResponse = TConversationTag;
// type for tagging conversation // type for tagging conversation
export type TTagConversationRequest = { export type TTagConversationRequest = {
conversationId: string;
tags: string[]; tags: string[];
}; };
export type TTagConversationResponse = string[]; export type TTagConversationResponse = string[];