mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-28 22:28:51 +01:00
🔖 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:
parent
d4d56281e3
commit
e565e0faab
65 changed files with 3751 additions and 36 deletions
225
client/src/utils/conversationTags.spec.ts
Normal file
225
client/src/utils/conversationTags.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
55
client/src/utils/conversationTags.ts
Normal file
55
client/src/utils/conversationTags.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue