🗂️ fix: Optimize Conversation Grouping and Sorting (#4173)

* chore: remove double import of TrashIcon

* fix(convos): eslint warnings

* ci(convos): add test for month sorting

* fix(convos): grouping by month in chronological order instead of alphabetical, optimize sort

* ci: additional tests for conversation sorting

* chore: fix eslint disable rule

* chore: imports, use constant enum for 'new' value

* fix: test dependent on current date
This commit is contained in:
Danny Avila 2024-09-21 10:20:30 -04:00 committed by GitHub
parent 44458d3832
commit c1c13a69dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 243 additions and 39 deletions

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { TVectorStore } from '~/common'; import { TVectorStore } from '~/common';
import { DotsIcon, TrashIcon, TrashIcon } from '~/components/svg'; import { DotsIcon, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui'; import { Button } from '~/components/ui';
type VectorStoreListItemProps = { type VectorStoreListItemProps = {

View file

@ -1,4 +1,5 @@
import { convoData } from './convos.fakeData'; import { Constants } from 'librechat-data-provider';
import type { TConversation, ConversationData } from 'librechat-data-provider';
import { import {
dateKeys, dateKeys,
addConversation, addConversation,
@ -8,7 +9,7 @@ import {
findPageForConversation, findPageForConversation,
groupConversationsByDate, groupConversationsByDate,
} from './convos'; } from './convos';
import type { TConversation, ConversationData } from 'librechat-data-provider'; import { convoData } from './convos.fakeData';
import { normalizeData } from './collection'; import { normalizeData } from './collection';
describe('Conversation Utilities', () => { describe('Conversation Utilities', () => {
@ -60,18 +61,182 @@ describe('Conversation Utilities', () => {
const uniqueIds = [...new Set(allGroupedIds)]; const uniqueIds = [...new Set(allGroupedIds)];
expect(allGroupedIds.length).toBe(uniqueIds.length); expect(allGroupedIds.length).toBe(uniqueIds.length);
}); });
it('sorts conversations by month correctly', () => {
const conversations = [
{ conversationId: '1', updatedAt: '2023-01-01T12:00:00Z' }, // January 2023
{ conversationId: '2', updatedAt: '2023-12-01T12:00:00Z' }, // December 2023
{ conversationId: '3', updatedAt: '2023-02-01T12:00:00Z' }, // February 2023
{ conversationId: '4', updatedAt: '2023-11-01T12:00:00Z' }, // November 2023
{ conversationId: '5', updatedAt: '2022-12-01T12:00:00Z' }, // December 2022
];
const grouped = groupConversationsByDate(conversations as TConversation[]);
// Check if the years are in the correct order (most recent first)
expect(grouped.map(([key]) => key)).toEqual([' 2023', ' 2022']);
// Check if conversations within 2023 are sorted correctly by month
const conversationsIn2023 = grouped[0][1];
const monthsIn2023 = conversationsIn2023.map((c) => new Date(c.updatedAt).getMonth());
expect(monthsIn2023).toEqual([11, 10, 1, 0]); // December (11), November (10), February (1), January (0)
// Check if the conversation from 2022 is in its own group
expect(grouped[1][1].length).toBe(1);
expect(new Date(grouped[1][1][0].updatedAt).getFullYear()).toBe(2022);
});
it('handles conversations from multiple years correctly', () => {
const conversations = [
{ conversationId: '1', updatedAt: '2023-01-01T12:00:00Z' }, // January 2023
{ conversationId: '2', updatedAt: '2022-12-01T12:00:00Z' }, // December 2022
{ conversationId: '3', updatedAt: '2021-06-01T12:00:00Z' }, // June 2021
{ conversationId: '4', updatedAt: '2023-06-01T12:00:00Z' }, // June 2023
{ conversationId: '5', updatedAt: '2021-12-01T12:00:00Z' }, // December 2021
];
const grouped = groupConversationsByDate(conversations as TConversation[]);
expect(grouped.map(([key]) => key)).toEqual([' 2023', ' 2022', ' 2021']);
expect(grouped[0][1].map((c) => new Date(c.updatedAt).getMonth())).toEqual([5, 0]); // June, January
expect(grouped[1][1].map((c) => new Date(c.updatedAt).getMonth())).toEqual([11]); // December
expect(grouped[2][1].map((c) => new Date(c.updatedAt).getMonth())).toEqual([11, 5]); // December, June
});
it('handles conversations from the same month correctly', () => {
const conversations = [
{ conversationId: '1', updatedAt: '2023-06-01T12:00:00Z' },
{ conversationId: '2', updatedAt: '2023-06-15T12:00:00Z' },
{ conversationId: '3', updatedAt: '2023-06-30T12:00:00Z' },
];
const grouped = groupConversationsByDate(conversations as TConversation[]);
expect(grouped.length).toBe(1);
expect(grouped[0][0]).toBe(' 2023');
expect(grouped[0][1].map((c) => c.conversationId)).toEqual(['3', '2', '1']);
});
it('handles conversations from today, yesterday, and previous days correctly', () => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const twoDaysAgo = new Date(today);
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const conversations = [
{ conversationId: '1', updatedAt: today.toISOString() },
{ conversationId: '2', updatedAt: yesterday.toISOString() },
{ conversationId: '3', updatedAt: twoDaysAgo.toISOString() },
];
const grouped = groupConversationsByDate(conversations as TConversation[]);
expect(grouped.map(([key]) => key)).toEqual([
dateKeys.today,
dateKeys.yesterday,
dateKeys.previous7Days,
]);
});
it('handles conversations with null or undefined updatedAt correctly', () => {
const conversations = [
{ conversationId: '1', updatedAt: '2023-06-01T12:00:00Z' },
{ conversationId: '2', updatedAt: null },
{ conversationId: '3', updatedAt: undefined },
];
const grouped = groupConversationsByDate(conversations as TConversation[]);
expect(grouped.length).toBe(2); // One group for 2023 and one for today (null/undefined dates)
expect(grouped[0][0]).toBe(dateKeys.today);
expect(grouped[0][1].length).toBe(2); // Two conversations with null/undefined dates
expect(grouped[1][0]).toBe(' 2023');
expect(grouped[1][1].length).toBe(1); // One conversation from 2023
});
it('handles an empty array of conversations', () => {
const grouped = groupConversationsByDate([]);
expect(grouped).toEqual([]);
});
it('correctly groups and sorts conversations for every month of the year', () => {
const months = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
// Create conversations for each month in both 2023 and 2022
const conversations = months.flatMap((month, index) => [
{
conversationId: `2023-${month}`,
updatedAt: `2023-${String(index + 1).padStart(2, '0')}-15T12:00:00Z`,
},
{
conversationId: `2022-${month}`,
updatedAt: `2022-${String(index + 1).padStart(2, '0')}-15T12:00:00Z`,
},
]);
const grouped = groupConversationsByDate(conversations as TConversation[]);
// Check that we have two year groups
expect(grouped.length).toBe(2);
// Check 2023 months
const group2023 = grouped.find(([key]) => key === ' 2023') ?? [];
expect(group2023).toBeDefined();
const grouped2023 = group2023[1];
expect(grouped2023?.length).toBe(12);
expect(grouped2023?.map((c) => new Date(c.updatedAt).getMonth())).toEqual([
11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
]);
// Check 2022 months
const group2022 = grouped.find(([key]) => key === ' 2022') ?? [];
expect(group2022).toBeDefined();
const grouped2022 = group2022[1];
expect(grouped2022?.length).toBe(12);
expect(grouped2022?.map((c) => new Date(c.updatedAt).getMonth())).toEqual([
11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
]);
// Check that all conversations are accounted for
const totalGroupedConversations =
// eslint-disable-next-line @typescript-eslint/no-unused-vars
grouped.reduce((total, [_, convos]) => total + convos.length, 0);
expect(totalGroupedConversations).toBe(conversations.length);
// Check that the years are in the correct order
const yearOrder = grouped.map(([key]) => key);
expect(yearOrder).toEqual([' 2023', ' 2022']);
});
}); });
describe('addConversation', () => { describe('addConversation', () => {
it('adds a new conversation to the top of the list', () => { it('adds a new conversation to the top of the list', () => {
const data = { pages: [{ conversations: [] }] }; const data = { pages: [{ conversations: [] }] };
const newConversation = { conversationId: 'new', updatedAt: '2023-04-02T12:00:00Z' }; const newConversation = {
conversationId: Constants.NEW_CONVO,
updatedAt: '2023-04-02T12:00:00Z',
};
const newData = addConversation( const newData = addConversation(
data as unknown as ConversationData, data as unknown as ConversationData,
newConversation as TConversation, newConversation as TConversation,
); );
expect(newData.pages[0].conversations).toHaveLength(1); expect(newData.pages[0].conversations).toHaveLength(1);
expect(newData.pages[0].conversations[0].conversationId).toBe('new'); expect(newData.pages[0].conversations[0].conversationId).toBe(Constants.NEW_CONVO);
}); });
}); });
@ -171,13 +336,13 @@ describe('Conversation Utilities with Fake Data', () => {
describe('addConversation', () => { describe('addConversation', () => {
it('adds a new conversation to the existing fake data', () => { it('adds a new conversation to the existing fake data', () => {
const newConversation = { const newConversation = {
conversationId: 'new', conversationId: Constants.NEW_CONVO,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} as TConversation; } as TConversation;
const initialLength = convoData.pages[0].conversations.length; const initialLength = convoData.pages[0].conversations.length;
const newData = addConversation(convoData, newConversation); const newData = addConversation(convoData, newConversation);
expect(newData.pages[0].conversations.length).toBe(initialLength + 1); expect(newData.pages[0].conversations.length).toBe(initialLength + 1);
expect(newData.pages[0].conversations[0].conversationId).toBe('new'); expect(newData.pages[0].conversations[0].conversationId).toBe(Constants.NEW_CONVO);
}); });
}); });

View file

@ -60,6 +60,32 @@ const getGroupName = (date: Date) => {
return ' ' + getYear(date).toString(); return ' ' + getYear(date).toString();
}; };
const monthOrderMap = new Map([
['december', 11],
['november', 10],
['october', 9],
['september', 8],
['august', 7],
['july', 6],
['june', 5],
['may', 4],
['april', 3],
['march', 2],
['february', 1],
['january', 0],
]);
const dateKeysReverse = Object.fromEntries(
Object.entries(dateKeys).map(([key, value]) => [value, key]),
);
const dateGroupsSet = new Set([
dateKeys.today,
dateKeys.yesterday,
dateKeys.previous7Days,
dateKeys.previous30Days,
]);
export const groupConversationsByDate = ( export const groupConversationsByDate = (
conversations: Array<TConversation | null>, conversations: Array<TConversation | null>,
): GroupedConversations => { ): GroupedConversations => {
@ -68,47 +94,60 @@ export const groupConversationsByDate = (
} }
const seenConversationIds = new Set(); const seenConversationIds = new Set();
const groups = conversations.reduce((acc, conversation) => { const groups = new Map();
if (!conversation) { const today = startOfToday();
return acc;
}
if (seenConversationIds.has(conversation.conversationId)) { conversations.forEach((conversation) => {
return acc; if (!conversation || seenConversationIds.has(conversation.conversationId)) {
return;
} }
seenConversationIds.add(conversation.conversationId); seenConversationIds.add(conversation.conversationId);
const date = conversation.updatedAt ? parseISO(conversation.updatedAt) : startOfToday(); const date = conversation.updatedAt ? parseISO(conversation.updatedAt) : today;
const groupName = getGroupName(date); const groupName = getGroupName(date);
if (!acc[groupName]) { if (!groups.has(groupName)) {
acc[groupName] = []; groups.set(groupName, []);
} }
acc[groupName].push(conversation); groups.get(groupName).push(conversation);
return acc; });
}, {});
const sortedGroups = {}; const sortedGroups = new Map();
const dateGroups = [
dateKeys.today, // Add date groups first
dateKeys.yesterday, dateGroupsSet.forEach((group) => {
dateKeys.previous7Days, if (groups.has(group)) {
dateKeys.previous30Days, sortedGroups.set(group, groups.get(group));
];
dateGroups.forEach((group) => {
if (groups[group]) {
sortedGroups[group] = groups[group];
} }
}); });
Object.keys(groups) // Sort and add year/month groups
.filter((group) => !dateGroups.includes(group)) const yearMonthGroups = Array.from(groups.keys())
.sort() .filter((group) => !dateGroupsSet.has(group))
.reverse() .sort((a, b) => {
.forEach((year) => { const [yearA, yearB] = [parseInt(a.trim()), parseInt(b.trim())];
sortedGroups[year] = groups[year]; if (yearA !== yearB) {
return yearB - yearA;
}
const [monthA, monthB] = [dateKeysReverse[a], dateKeysReverse[b]];
const bOrder = monthOrderMap.get(monthB) ?? -1;
const aOrder = monthOrderMap.get(monthA) ?? -1;
return bOrder - aOrder;
}); });
return Object.entries(sortedGroups); yearMonthGroups.forEach((group) => {
sortedGroups.set(group, groups.get(group));
});
// Sort conversations within each group
sortedGroups.forEach((conversations) => {
conversations.sort(
(a: TConversation, b: TConversation) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
});
return Array.from(sortedGroups, ([key, value]) => [key, value]);
}; };
export const addConversation = ( export const addConversation = (
@ -195,7 +234,7 @@ export const getConversationById = (
data: ConversationData | undefined, data: ConversationData | undefined,
conversationId: string | null, conversationId: string | null,
): TConversation | undefined => { ): TConversation | undefined => {
if (!data || !conversationId) { if (!data || !(conversationId ?? '')) {
return undefined; return undefined;
} }
@ -224,11 +263,11 @@ export function storeEndpointSettings(conversation: TConversation | null) {
return; return;
} }
const lastModel = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) || '{}'); const lastModel = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '{}');
lastModel[endpoint] = model; lastModel[endpoint] = model;
if (endpoint === EModelEndpoint.gptPlugins) { if (endpoint === EModelEndpoint.gptPlugins) {
lastModel.secondaryModel = agentOptions?.model || model || ''; lastModel.secondaryModel = agentOptions?.model ?? model ?? '';
} }
localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify(lastModel)); localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify(lastModel));