🔖 feat: Conversation Bookmarks (#3344)

* feat: add tags property in Conversation model

* feat: add ConversationTag model

* feat: add the tags parameter to getConvosByPage

* feat: add API route to ConversationTag

* feat: add types of ConversationTag

* feat: add data access functions for conversation tags

* feat: add Bookmark table component

* feat: Add an action to bookmark

* feat: add Bookmark nav component

* fix: failed test

* refactor: made 'Saved' tag a constant

* feat: add new bookmark to current conversation

* chore: Add comment

* fix: delete tag from conversations when it's deleted

* fix: Update the query cache when the tag title is changed.

* chore: fix typo

* refactor: add description of rebuilding bookmarks

* chore: remove unused variables

* fix: position when adding a new bookmark

* refactor: add comment, rename a function

* refactor: add a unique constraint in ConversationTag

* chore: add localizations
This commit is contained in:
Yuichi Oneda 2024-07-29 07:45:59 -07:00 committed by GitHub
parent d4d56281e3
commit e565e0faab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3751 additions and 36 deletions

View file

@ -0,0 +1,225 @@
import type { TConversationTagsResponse } from 'librechat-data-provider';
import { updateConversationTag } from './conversationTags';
describe('ConversationTag Utilities', () => {
let conversations: TConversationTagsResponse;
beforeEach(() => {
conversations = [
{
tag: 'saved',
count: 1,
position: 0,
description: 'description1',
updatedAt: '2023-04-01T12:00:00Z',
createdAt: '2023-04-01T12:00:00Z',
user: 'user1',
},
{
tag: 'tag1',
count: 1,
position: 1,
description: 'description1',
updatedAt: '2023-04-01T12:00:00Z',
createdAt: '2023-04-01T12:00:00Z',
user: 'user1',
},
{
tag: 'tag2',
count: 20,
position: 2,
description: 'description2',
updatedAt: new Date().toISOString(),
createdAt: '2023-04-01T12:00:00Z',
user: 'user1',
},
{
tag: 'tag3',
count: 30,
position: 3,
description: 'description3',
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
user: 'user1',
},
{
tag: 'tag4',
count: 40,
position: 4,
description: 'description4',
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
user: 'user1',
},
{
tag: 'tag5',
count: 50,
position: 5,
description: 'description5',
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
user: 'user1',
},
];
});
describe('updateConversationTag', () => {
it('updates the first tag correctly', () => {
const updated = updateConversationTag(
conversations,
{ tag: 'tag1-new', description: 'description1-new' },
{
...conversations[1],
tag: 'tag1-new',
description: 'description1-new',
},
'tag1',
);
expect(updated[0].tag).toBe('saved');
expect(updated[0].position).toBe(0);
expect(updated[1].tag).toBe('tag1-new');
expect(updated[1].description).toBe('description1-new');
expect(updated[1].position).toBe(1);
expect(updated[2].tag).toBe('tag2');
expect(updated[2].position).toBe(2);
expect(updated[3].tag).toBe('tag3');
expect(updated[3].position).toBe(3);
expect(updated[4].tag).toBe('tag4');
expect(updated[4].position).toBe(4);
expect(updated[5].tag).toBe('tag5');
expect(updated[5].position).toBe(5);
});
});
it('updates the third tag correctly', () => {
const updated = updateConversationTag(
conversations,
{ tag: 'tag3-new', description: 'description3-new' },
{
...conversations[3],
tag: 'tag3-new',
description: 'description3-new',
},
'tag3',
);
expect(updated[0].tag).toBe('saved');
expect(updated[0].position).toBe(0);
expect(updated[1].tag).toBe('tag1');
expect(updated[1].position).toBe(1);
expect(updated[2].tag).toBe('tag2');
expect(updated[2].position).toBe(2);
expect(updated[3].tag).toBe('tag3-new');
expect(updated[3].description).toBe('description3-new');
expect(updated[3].position).toBe(3);
expect(updated[4].tag).toBe('tag4');
expect(updated[4].position).toBe(4);
expect(updated[5].tag).toBe('tag5');
expect(updated[5].position).toBe(5);
});
it('updates the order of other tags if the order of the tags is moving up', () => {
const updated = updateConversationTag(
conversations,
// move tag3 to the second position
{ position: 2 },
{
...conversations[3],
position: 2,
},
'tag3',
);
expect(updated[0].tag).toBe('saved');
expect(updated[0].position).toBe(0);
expect(updated[1].tag).toBe('tag1');
expect(updated[1].position).toBe(1);
expect(updated[2].tag).toBe('tag3');
expect(updated[2].position).toBe(2);
expect(updated[3].tag).toBe('tag2');
expect(updated[3].position).toBe(3);
expect(updated[4].tag).toBe('tag4');
expect(updated[4].position).toBe(4);
expect(updated[5].tag).toBe('tag5');
expect(updated[5].position).toBe(5);
});
it('updates the order of other tags if the order of the tags is moving down', () => {
const updated = updateConversationTag(
conversations,
// move tag3 to the last position
{ position: 5 },
{
...conversations[3],
position: 5,
},
'tag3',
);
expect(updated[0].tag).toBe('saved');
expect(updated[0].position).toBe(0);
expect(updated[1].tag).toBe('tag1');
expect(updated[1].position).toBe(1);
expect(updated[2].tag).toBe('tag2');
expect(updated[2].position).toBe(2);
expect(updated[3].tag).toBe('tag4');
expect(updated[3].position).toBe(3);
expect(updated[4].tag).toBe('tag5');
expect(updated[4].position).toBe(4);
expect(updated[5].tag).toBe('tag3');
expect(updated[5].position).toBe(5);
});
it('updates the order of other tags if new tag is added', () => {
const updated = updateConversationTag(
conversations,
{ tag: 'newtag', description: 'newDescription' },
{
tag: 'newtag',
description: 'newDescription',
position: 1,
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
user: 'user1',
count: 30,
},
// no tag tag specified
);
expect(updated[0].tag).toBe('saved');
expect(updated[0].position).toBe(0);
expect(updated[1].tag).toBe('newtag');
expect(updated[1].description).toBe('newDescription');
expect(updated[1].position).toBe(1);
expect(updated[2].tag).toBe('tag1');
expect(updated[2].position).toBe(2);
expect(updated[3].tag).toBe('tag2');
expect(updated[3].position).toBe(3);
expect(updated[4].tag).toBe('tag3');
expect(updated[4].position).toBe(4);
expect(updated[5].tag).toBe('tag4');
expect(updated[5].position).toBe(5);
expect(updated[6].tag).toBe('tag5');
expect(updated[6].position).toBe(6);
});
it('returns a new array for new tag if no tags exist', () => {
const updated = updateConversationTag(
[],
{ tag: 'newtag', description: 'newDescription' },
{
tag: 'saved',
description: 'newDescription',
position: 0,
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
user: 'user1',
count: 30,
},
// no tag tag specified
);
expect(updated.length).toBe(1);
expect(updated[0].tag).toBe('saved');
expect(updated[0].position).toBe(0);
});
});

View file

@ -0,0 +1,55 @@
import {
TConversationTagRequest,
TConversationTagResponse,
TConversationTagsResponse,
} from 'librechat-data-provider';
export const updateConversationTag = (
queryCache: TConversationTagsResponse,
request: TConversationTagRequest,
response: TConversationTagResponse,
tag?: string,
): TConversationTagsResponse => {
if (queryCache.length === 0) {
return [response];
}
const oldData = queryCache.find((t) => t.tag === tag);
if (!oldData) {
// When a new tag is added, it is positioned at the top of the list.
return [queryCache[0], response, ...queryCache.slice(1)].map((t, index) => ({
...t,
position: index,
}));
}
const oldPosition = oldData.position;
const newPosition = response.position;
// Remove the updated data from the array
const filteredData = queryCache.filter((t) => t.tag !== tag);
if (newPosition === undefined || oldPosition === newPosition) {
// If the position hasn't changed, just replace the updated tag
return queryCache.map((t) => (t.tag === tag ? response : t));
}
// If the position has changed, update the position of the tag
const newData = [
...filteredData.slice(0, newPosition),
response,
...filteredData.slice(newPosition),
];
if (newPosition > oldPosition) {
// moving down
for (let i = oldPosition; i < newPosition; i++) {
newData[i].position = i;
}
} else {
// moving up
for (let i = newPosition + 1; i < newData.length; i++) {
newData[i].position = i;
}
}
return newData;
};

View file

@ -145,25 +145,37 @@ export const updateConversation = (
);
};
export const updateConvoFields: ConversationUpdater = (
export const updateConvoFields = (
data: ConversationData,
updatedConversation: Partial<TConversation> & Pick<TConversation, 'conversationId'>,
keepPosition = false,
): ConversationData => {
const newData = JSON.parse(JSON.stringify(data));
const { pageIndex, index } = findPageForConversation(
newData,
updatedConversation as { conversationId: string },
);
if (pageIndex !== -1 && index !== -1) {
const deleted = newData.pages[pageIndex].conversations.splice(index, 1);
const oldConversation = deleted[0] as TConversation;
const oldConversation = newData.pages[pageIndex].conversations[index] as TConversation;
newData.pages[0].conversations.unshift({
...oldConversation,
...updatedConversation,
updatedAt: new Date().toISOString(),
});
/**
* Do not change the position of the conversation if the tags are updated.
*/
if (keepPosition) {
const updatedConvo = {
...oldConversation,
...updatedConversation,
};
newData.pages[pageIndex].conversations[index] = updatedConvo;
} else {
const updatedConvo = {
...oldConversation,
...updatedConversation,
updatedAt: new Date().toISOString(),
};
newData.pages[pageIndex].conversations.splice(index, 1);
newData.pages[0].conversations.unshift(updatedConvo);
}
}
return newData;