From 3fe48b2da919b34ddba84d9bf7858567660c5f74 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Feb 2025 19:29:17 +0100 Subject: [PATCH] feat: enhance conversation listing with pagination, sorting, and search capabilities --- api/models/Conversation.js | 85 +++--- api/server/routes/convos.js | 20 +- client/src/components/Nav/Nav.tsx | 2 +- .../Nav/SettingsTabs/Data/SharedLinks.tsx | 10 +- .../SettingsTabs/General/ArchivedChats.tsx | 10 +- .../General/ArchivedChatsTable.tsx | 243 +++++++++--------- client/src/components/ui/DataTable.tsx | 46 +++- client/src/data-provider/mutations.ts | 150 ++++------- client/src/data-provider/queries.ts | 38 +-- .../hooks/Conversations/useArchiveHandler.ts | 4 +- client/src/locales/en/translation.json | 3 +- packages/data-provider/src/api-endpoints.ts | 13 +- packages/data-provider/src/data-service.ts | 13 +- packages/data-provider/src/types/queries.ts | 7 +- 14 files changed, 333 insertions(+), 311 deletions(-) diff --git a/api/models/Conversation.js b/api/models/Conversation.js index a9f2e02eb80..c5234c35783 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -87,11 +87,13 @@ module.exports = { */ saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => { try { - if (metadata && metadata?.context) { + if (metadata?.context) { logger.debug(`[saveConvo] ${metadata.context}`); } + const messages = await getMessages({ conversationId }, '_id'); const update = { ...convo, messages, user: req.user.id }; + if (newConversationId) { update.conversationId = newConversationId; } @@ -143,26 +145,44 @@ module.exports = { }, getConvosByCursor: async ( user, - { cursor, limit = 25, isArchived = false, tags, order = 'desc' } = {}, + { cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {}, ) => { - const query = { user }; + const filters = [{ user }]; if (isArchived) { - query.isArchived = true; + filters.push({ isArchived: true }); } else { - query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }]; + filters.push({ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] }); } if (Array.isArray(tags) && tags.length > 0) { - query.tags = { $in: tags }; + filters.push({ tags: { $in: tags } }); } - query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }]; + filters.push({ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }); + + if (search) { + try { + const meiliResults = await Conversation.meiliSearch(search); + const matchingIds = Array.isArray(meiliResults.hits) + ? meiliResults.hits.map((result) => result.conversationId) + : []; + if (!matchingIds.length) { + return { conversations: [], nextCursor: null }; + } + filters.push({ conversationId: { $in: matchingIds } }); + } catch (error) { + logger.error('[getConvosByCursor] Error during meiliSearch', error); + return { message: 'Error during meiliSearch' }; + } + } if (cursor) { - query.updatedAt = { $lt: new Date(cursor) }; + filters.push({ updatedAt: { $lt: new Date(cursor) } }); } + const query = filters.length === 1 ? filters[0] : { $and: filters }; + try { const convos = await Conversation.find(query) .select('conversationId endpoint title createdAt updatedAt user') @@ -184,37 +204,26 @@ module.exports = { }, getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => { try { - if (!convoIds || convoIds.length === 0) { + if (!convoIds?.length) { return { conversations: [], nextCursor: null, convoMap: {} }; } - const convoMap = {}; - const promises = []; - - convoIds.forEach((convo) => - promises.push( - Conversation.findOne({ - user, - conversationId: convo.conversationId, - $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], - }).lean(), - ), - ); + const conversationIds = convoIds.map((convo) => convo.conversationId); - // Fetch all matching conversations and filter out any falsy results - const results = (await Promise.all(promises)).filter(Boolean); + const results = await Conversation.find({ + user, + conversationId: { $in: conversationIds }, + $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], + }).lean(); - // Sort conversations by updatedAt descending (most recent first) results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); - // If a cursor is provided and not "start", filter out recrods newer or equal to the cursor date let filtered = results; if (cursor && cursor !== 'start') { const cursorDate = new Date(cursor); filtered = results.filter((convo) => new Date(convo.updatedAt) < cursorDate); } - // Retrieve limit + 1 results to determine if there's a next page. const limited = filtered.slice(0, limit + 1); let nextCursor = null; if (limited.length > limit) { @@ -222,7 +231,7 @@ module.exports = { nextCursor = lastConvo.updatedAt.toISOString(); } - // Build convoMap for ease of access if required by caller + const convoMap = {}; limited.forEach((convo) => { convoMap[convo.conversationId] = convo; }); @@ -268,10 +277,22 @@ module.exports = { * logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } } */ deleteConvos: async (user, filter) => { - let toRemove = await Conversation.find({ ...filter, user }).select('conversationId'); - const ids = toRemove.map((instance) => instance.conversationId); - let deleteCount = await Conversation.deleteMany({ ...filter, user }); - deleteCount.messages = await deleteMessages({ conversationId: { $in: ids } }); - return deleteCount; + try { + const userFilter = { ...filter, user }; + + const conversations = await Conversation.find(userFilter).select('conversationId'); + const conversationIds = conversations.map((c) => c.conversationId); + + const deleteConvoResult = await Conversation.deleteMany(userFilter); + + const deleteMessagesResult = await deleteMessages({ + conversationId: { $in: conversationIds }, + }); + + return { ...deleteConvoResult, messages: deleteMessagesResult }; + } catch (error) { + logger.error('[deleteConvos] Error deleting conversations and messages', error); + throw error; + } }, }; diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 9fb28b71743..919a1157a21 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -8,9 +8,10 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const { importConversations } = require('~/server/utils/import'); const { createImportLimiters } = require('~/server/middleware'); const { deleteToolCalls } = require('~/models/ToolCall'); +const { isEnabled, sleep } = require('~/server/utils'); const getLogStores = require('~/cache/getLogStores'); -const { sleep } = require('~/server/utils'); const { logger } = require('~/config'); + const assistantClients = { [EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'), [EModelEndpoint.assistants]: require('~/server/services/Endpoints/assistants'), @@ -20,21 +21,26 @@ const router = express.Router(); router.use(requireJwtAuth); router.get('/', async (req, res) => { - // Limiting pagination as cursor may be undefined if not provided const limit = parseInt(req.query.limit, 10) || 25; const cursor = req.query.cursor; - const isArchived = req.query.isArchived === 'true'; + const isArchived = isEnabled(req.query.isArchived); + const search = req.query.search; + const order = req.query.order || 'desc'; let tags; if (req.query.tags) { tags = Array.isArray(req.query.tags) ? req.query.tags : [req.query.tags]; } - // Support for ordering; expects "asc" or "desc", defaults to descending order. - const order = req.query.order || 'desc'; - try { - const result = await getConvosByCursor(req.user.id, { cursor, limit, isArchived, tags, order }); + const result = await getConvosByCursor(req.user.id, { + cursor, + limit, + isArchived, + tags, + search, + order, + }); res.status(200).json(result); } catch (error) { res.status(500).json({ error: 'Error fetching conversations' }); diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index fbed32aea26..d671eb80f3a 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -78,7 +78,7 @@ const Nav = memo( const { data, fetchNextPage, isFetchingNextPage, refetch } = useConversationsInfiniteQuery( { - cursor: null, + pageSize: 25, isArchived: false, tags: tags.length === 0 ? undefined : tags, }, diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 093135ae3c2..bc3c8afc729 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -9,10 +9,11 @@ import { OGDialogContent, OGDialogHeader, OGDialogTitle, - Button, TooltipAnchor, + Button, Label, -} from '~/components/ui'; + Spinner, +} from '~/components'; import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useLocalize, useMediaQuery } from '~/hooks'; @@ -20,7 +21,6 @@ import DataTable from '~/components/ui/DataTable'; import { NotificationSeverity } from '~/common'; import { useToastContext } from '~/Providers'; import { formatDate } from '~/utils'; -import { Spinner } from '~/components/svg'; const PAGE_SIZE = 25; @@ -37,6 +37,7 @@ export default function SharedLinks() { const { showToast } = useToastContext(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const [queryParams, setQueryParams] = useState(DEFAULT_PARAMS); + const [deleteRow, setDeleteRow] = useState(null); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isOpen, setIsOpen] = useState(false); @@ -144,8 +145,6 @@ export default function SharedLinks() { await fetchNextPage(); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); - const [deleteRow, setDeleteRow] = useState(null); - const confirmDelete = useCallback(() => { if (deleteRow) { handleDelete([deleteRow]); @@ -293,6 +292,7 @@ export default function SharedLinks() { showCheckboxes={false} onFilterChange={debouncedFilterChange} filterValue={queryParams.search} + isLoading={isLoading} /> diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx index 7820e784986..123b0764745 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx @@ -1,15 +1,17 @@ -import { useLocalize } from '~/hooks'; -import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; +import { useState } from 'react'; import { OGDialog, OGDialogTrigger, Button } from '~/components'; +import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import ArchivedChatsTable from './ArchivedChatsTable'; +import { useLocalize } from '~/hooks'; export default function ArchivedChats() { const localize = useLocalize(); + const [isOpen, setIsOpen] = useState(false); return (
{localize('com_nav_archived_chats')}
- +
diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx index 82faba8a473..6c97cebe991 100644 --- a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx @@ -1,8 +1,8 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +// If TypeScript complains about lodash/debounce, consider installing @types/lodash or add a declaration file. import debounce from 'lodash/debounce'; -import { Search, TrashIcon, MessageCircle, ArchiveRestore } from 'lucide-react'; -import type { TConversation } from 'librechat-data-provider'; +import { TrashIcon, ArchiveRestore } from 'lucide-react'; +import type { ConversationListParams, TConversation } from 'librechat-data-provider'; import { Button, OGDialog, @@ -10,94 +10,68 @@ import { OGDialogHeader, OGDialogTitle, Label, - Separator, - Skeleton, + TooltipAnchor, Spinner, } from '~/components'; import { - useConversationsInfiniteQuery, useArchiveConvoMutation, + useConversationsInfiniteQuery, useDeleteConversationMutation, } from '~/data-provider'; -import { useAuthContext, useLocalize, useMediaQuery } from '~/hooks'; +import { useLocalize, useMediaQuery } from '~/hooks'; +import { MinimalIcon } from '~/components/Endpoints'; import DataTable from '~/components/ui/DataTable'; import { NotificationSeverity } from '~/common'; import { useToastContext } from '~/Providers'; -import { cn, formatDate } from '~/utils'; +import { formatDate } from '~/utils'; -const DEFAULT_PARAMS = { +const DEFAULT_PARAMS: ConversationListParams = { + pageSize: 25, isArchived: true, - search: '', sortBy: 'createdAt', sortDirection: 'desc', - // use nextCursor for pagination + search: '', }; -export default function ArchivedChatsTable() { +export default function ArchivedChatsTable({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}) { const localize = useLocalize(); - const { isAuthenticated } = useAuthContext(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const { showToast } = useToastContext(); - const [queryParams, setQueryParams] = useState(DEFAULT_PARAMS); - const [deleteConversation, setDeleteConversation] = useState(null); const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [queryParams, setQueryParams] = useState(DEFAULT_PARAMS); + const [deleteConversation, setDeleteConversation] = useState(null); - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } = useConversationsInfiniteQuery(queryParams, { - enabled: isAuthenticated, + enabled: isOpen, staleTime: 0, cacheTime: 5 * 60 * 1000, refetchOnWindowFocus: false, refetchOnMount: false, }); - const unarchiveMutation = useArchiveConvoMutation(); - const deleteMutation = useDeleteConversationMutation({ - onSuccess: async () => { - showToast({ - message: localize('com_ui_delete_success'), - severity: NotificationSeverity.SUCCESS, - }); - setIsDeleteOpen(false); - setDeleteConversation(null); - await refetch(); - }, - onError: (error) => { - console.error('Delete error:', error); - showToast({ - message: localize('com_ui_delete_error'), - severity: NotificationSeverity.ERROR, - }); - }, - }); - - const handleUnarchive = useCallback( - (conversationId: string) => { - unarchiveMutation.mutate({ conversationId, isArchived: false }); - }, - [unarchiveMutation], - ); - - const allConversations: TConversation[] = useMemo(() => { - if (!data?.pages) { - return []; - } - return data.pages.flatMap((page) => page.conversations ?? []); - }, [data]); + const unarchiveMutation = useArchiveConvoMutation(''); const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => { setQueryParams((prev) => ({ ...prev, - sortBy: sortField, + sortBy: sortField as 'title' | 'createdAt', sortDirection: sortOrder, })); }, []); const handleFilterChange = useCallback((value: string) => { + const encodedValue = encodeURIComponent(value.trim()); setQueryParams((prev) => ({ ...prev, - search: encodeURIComponent(value.trim()), + search: encodedValue, })); }, []); @@ -105,12 +79,48 @@ export default function ArchivedChatsTable() { () => debounce(handleFilterChange, 300), [handleFilterChange], ); + useEffect(() => { return () => { debouncedFilterChange.cancel(); }; }, [debouncedFilterChange]); + const allConversations = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.conversations.filter(Boolean)); + }, [data?.pages]); + + const deleteMutation = useDeleteConversationMutation({ + onSuccess: async () => { + setIsDeleteOpen(false); + await refetch(); + }, + onError: (error) => { + console.error('Delete error:', error); + showToast({ + message: localize('com_ui_archive_delete_error') as string, + severity: NotificationSeverity.ERROR, + }); + }, + }); + + const handleUnarchive = useCallback( + (conversationId: string) => { + unarchiveMutation.mutate( + { conversationId, isArchived: false }, + { + onSuccess: () => { + refetch(); + }, + }, + ); + }, + [unarchiveMutation, refetch], + ); + const handleFetchNextPage = useCallback(async () => { if (!hasNextPage || isFetchingNextPage) { return; @@ -122,26 +132,29 @@ export default function ArchivedChatsTable() { () => [ { accessorKey: 'title', - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { + header: ({ column }: { column: any }) => ( + + ), + cell: ({ row }: { row: any }) => { const { conversationId, title } = row.original; return ( ); @@ -153,20 +166,17 @@ export default function ArchivedChatsTable() { }, { accessorKey: 'createdAt', - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen), + header: ({ column }: { column: any }) => ( + + ), + cell: ({ row }: { row: any }) => + formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen), meta: { size: isSmallScreen ? '30%' : '35%', mobileSize: '30%', @@ -179,29 +189,39 @@ export default function ArchivedChatsTable() { {localize('com_assistants_actions')} ), - cell: ({ row }) => { + cell: ({ row }: { row: any }) => { const conversation = row.original; return (
- - + handleUnarchive(conversation.conversationId)} + title={localize('com_ui_unarchive')} + > + + + } + /> + { + setDeleteConversation(row.original); + setIsDeleteOpen(true); + }} + title={localize('com_ui_delete')} + > + + + } + />
); }, @@ -214,18 +234,6 @@ export default function ArchivedChatsTable() { [handleSort, handleUnarchive, isSmallScreen, localize], ); - if (isLoading) { - return ( -
- {Array.from({ length: 10 }, (_, index) => ( -
- -
- ))} -
- ); - } - return ( <> - + @@ -257,7 +266,9 @@ export default function ArchivedChatsTable() {