import { format, isToday, subDays, getYear, parseISO, startOfDay, startOfYear, isWithinInterval, } from 'date-fns'; import { QueryClient } from '@tanstack/react-query'; import { EModelEndpoint, LocalStorageKeys, QueryKeys } from 'librechat-data-provider'; import type { TConversation, GroupedConversations } from 'librechat-data-provider'; import type { InfiniteData } from '@tanstack/react-query'; // Date group helpers export const dateKeys = { today: 'com_ui_date_today', yesterday: 'com_ui_date_yesterday', previous7Days: 'com_ui_date_previous_7_days', previous30Days: 'com_ui_date_previous_30_days', january: 'com_ui_date_january', february: 'com_ui_date_february', march: 'com_ui_date_march', april: 'com_ui_date_april', may: 'com_ui_date_may', june: 'com_ui_date_june', july: 'com_ui_date_july', august: 'com_ui_date_august', september: 'com_ui_date_september', october: 'com_ui_date_october', november: 'com_ui_date_november', december: 'com_ui_date_december', }; const getGroupName = (date: Date) => { const now = new Date(Date.now()); if (isToday(date)) { return dateKeys.today; } if (isWithinInterval(date, { start: startOfDay(subDays(now, 1)), end: now })) { return dateKeys.yesterday; } if (isWithinInterval(date, { start: subDays(now, 7), end: now })) { return dateKeys.previous7Days; } if (isWithinInterval(date, { start: subDays(now, 30), end: now })) { return dateKeys.previous30Days; } if (isWithinInterval(date, { start: startOfYear(now), end: now })) { const month = format(date, 'MMMM').toLowerCase(); return dateKeys[month]; } 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(([k, v]) => [v, k])); const dateGroupsSet = new Set([ dateKeys.today, dateKeys.yesterday, dateKeys.previous7Days, dateKeys.previous30Days, ]); export const groupConversationsByDate = ( conversations: Array, ): GroupedConversations => { if (!Array.isArray(conversations)) { return []; } const seenConversationIds = new Set(); const groups = new Map(); const now = new Date(Date.now()); conversations.forEach((conversation) => { if (!conversation || seenConversationIds.has(conversation.conversationId)) { return; } seenConversationIds.add(conversation.conversationId); let date: Date; if (conversation.updatedAt) { date = parseISO(conversation.updatedAt); } else { date = now; } const groupName = getGroupName(date); if (!groups.has(groupName)) { groups.set(groupName, []); } groups.get(groupName).push(conversation); }); const sortedGroups = new Map(); dateGroupsSet.forEach((group) => { if (groups.has(group)) { sortedGroups.set(group, groups.get(group)); } }); const yearMonthGroups = Array.from(groups.keys()) .filter((group) => !dateGroupsSet.has(group)) .sort((a, b) => { const [yearA, yearB] = [parseInt(a.trim()), parseInt(b.trim())]; if (yearA !== yearB) { return yearB - yearA; } const [monthA, monthB] = [dateKeysReverse[a], dateKeysReverse[b]]; const bOrder = monthOrderMap.get(monthB) ?? -1, aOrder = monthOrderMap.get(monthA) ?? -1; return bOrder - aOrder; }); yearMonthGroups.forEach((group) => { sortedGroups.set(group, groups.get(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 type ConversationCursorData = { conversations: TConversation[]; nextCursor?: string | null; }; // === InfiniteData helpers for cursor-based convo queries === export function findConversationInInfinite( data: InfiniteData | undefined, conversationId: string, ): TConversation | undefined { if (!data) { return undefined; } for (const page of data.pages) { const found = page.conversations.find((c) => c.conversationId === conversationId); if (found) { return found; } } return undefined; } export function updateInfiniteConvoPage( data: InfiniteData | undefined, conversationId: string, updater: (c: TConversation) => TConversation, ): InfiniteData | undefined { if (!data) { return data; } return { ...data, pages: data.pages.map((page) => ({ ...page, conversations: page.conversations.map((c) => c.conversationId === conversationId ? updater(c) : c, ), })), }; } export function addConversationToInfinitePages( data: InfiniteData | undefined, newConversation: TConversation, ): InfiniteData { if (!data) { return { pageParams: [undefined], pages: [{ conversations: [newConversation], nextCursor: null }], }; } return { ...data, pages: [ { ...data.pages[0], conversations: [newConversation, ...data.pages[0].conversations] }, ...data.pages.slice(1), ], }; } export function addConversationToAllConversationsQueries( queryClient: QueryClient, newConversation: TConversation, ) { // Find all keys that start with QueryKeys.allConversations const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); for (const query of queries) { queryClient.setQueryData>(query.queryKey, (old) => { if ( !old || old.pages[0].conversations.some((c) => c.conversationId === newConversation.conversationId) ) { return old; } return { ...old, pages: [ { ...old.pages[0], conversations: [newConversation, ...old.pages[0].conversations], }, ...old.pages.slice(1), ], }; }); } } export function removeConvoFromInfinitePages( data: InfiniteData | undefined, conversationId: string, ): InfiniteData | undefined { if (!data) { return data; } return { ...data, pages: data.pages .map((page) => ({ ...page, conversations: page.conversations.filter((c) => c.conversationId !== conversationId), })) .filter((page) => page.conversations.length > 0), }; } // Used for partial update (e.g., title, etc.), updating AND possibly bumping to front of visible convos export function updateConvoFieldsInfinite( data: InfiniteData | undefined, updatedConversation: Partial & { conversationId: string }, keepPosition = false, ): InfiniteData | undefined { if (!data) { return data; } let found: TConversation | undefined; let pageIdx = -1, convoIdx = -1; for (let i = 0; i < data.pages.length; ++i) { const idx = data.pages[i].conversations.findIndex( (c) => c.conversationId === updatedConversation.conversationId, ); if (idx !== -1) { pageIdx = i; convoIdx = idx; found = data.pages[i].conversations[idx]; break; } } if (!found) { return data; } if (keepPosition) { return { ...data, pages: data.pages.map((page, pi) => pi === pageIdx ? { ...page, conversations: page.conversations.map((c, ci) => ci === convoIdx ? { ...c, ...updatedConversation } : c, ), } : page, ), }; } else { const patched = { ...found, ...updatedConversation, updatedAt: new Date().toISOString() }; const pages = data.pages.map((page) => ({ ...page, conversations: page.conversations.filter((c) => c.conversationId !== patched.conversationId), })); pages[0].conversations = [patched, ...pages[0].conversations]; const finalPages = pages.filter((page) => page.conversations.length > 0); return { ...data, pages: finalPages }; } } export function storeEndpointSettings(conversation: TConversation | null) { if (!conversation) { return; } const { endpoint, model, agentOptions } = conversation; if (!endpoint) { return; } const lastModel = JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_MODEL) ?? '{}'); lastModel[endpoint] = model; if (endpoint === EModelEndpoint.gptPlugins) { lastModel.secondaryModel = agentOptions?.model ?? model ?? ''; } localStorage.setItem(LocalStorageKeys.LAST_MODEL, JSON.stringify(lastModel)); } // Add export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConversation) { const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); for (const query of queries) { queryClient.setQueryData>(query.queryKey, (oldData) => { if (!oldData) { return oldData; } if ( oldData.pages.some((p) => p.conversations.some((c) => c.conversationId === newConvo.conversationId), ) ) { return oldData; } return { ...oldData, pages: [ { ...oldData.pages[0], conversations: [newConvo, ...oldData.pages[0].conversations], }, ...oldData.pages.slice(1), ], }; }); } } // Update export function updateConvoInAllQueries( queryClient: QueryClient, conversationId: string, updater: (c: TConversation) => TConversation, ) { const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); for (const query of queries) { queryClient.setQueryData>(query.queryKey, (oldData) => { if (!oldData) { return oldData; } return { ...oldData, pages: oldData.pages.map((page) => ({ ...page, conversations: page.conversations.map((c) => c.conversationId === conversationId ? updater(c) : c, ), })), }; }); } } // Remove export function removeConvoFromAllQueries(queryClient: QueryClient, conversationId: string) { const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); for (const query of queries) { queryClient.setQueryData>(query.queryKey, (oldData) => { if (!oldData) { return oldData; } return { ...oldData, pages: oldData.pages .map((page) => ({ ...page, conversations: page.conversations.filter((c) => c.conversationId !== conversationId), })) .filter((page) => page.conversations.length > 0), }; }); } }