🧹 fix: Resolve Unarchive Conversation Bug, Archive Pagination (#4189)

* feat: add cleanup service for 'bugged' conversations (empty/nullish conversationIds)

* fix(ArchivedChatsTable): typing and minor styling issues

* fix: properly archive conversations

* fix: archive convo application crash

* chore: remove unused `useEffect`

* fix: add basic navigation

* chore: typing
This commit is contained in:
Danny Avila 2024-09-22 17:21:50 -04:00 committed by GitHub
parent 2d62eca612
commit 4328a25b6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 202 additions and 69 deletions

View file

@ -31,9 +31,39 @@ const getConvo = async (user, conversationId) => {
} }
}; };
const deleteNullOrEmptyConversations = async () => {
try {
const filter = {
$or: [
{ conversationId: null },
{ conversationId: '' },
{ conversationId: { $exists: false } },
],
};
const result = await Conversation.deleteMany(filter);
// Delete associated messages
const messageDeleteResult = await deleteMessages(filter);
logger.info(
`[deleteNullOrEmptyConversations] Deleted ${result.deletedCount} conversations and ${messageDeleteResult.deletedCount} messages`,
);
return {
conversations: result,
messages: messageDeleteResult,
};
} catch (error) {
logger.error('[deleteNullOrEmptyConversations] Error deleting conversations', error);
throw new Error('Error deleting conversations with null or empty conversationId');
}
};
module.exports = { module.exports = {
Conversation, Conversation,
searchConversation, searchConversation,
deleteNullOrEmptyConversations,
/** /**
* Saves a conversation to the database. * Saves a conversation to the database.
* @param {Object} req - The request object. * @param {Object} req - The request object.

View file

@ -109,8 +109,14 @@ router.post('/clear', async (req, res) => {
router.post('/update', async (req, res) => { router.post('/update', async (req, res) => {
const update = req.body.arg; const update = req.body.arg;
if (!update.conversationId) {
return res.status(400).json({ error: 'conversationId is required' });
}
try { try {
const dbResponse = await saveConvo(req, update, { context: 'POST /api/convos/update' }); const dbResponse = await saveConvo(req, update, {
context: `POST /api/convos/update ${update.conversationId}`,
});
res.status(201).json(dbResponse); res.status(201).json(dbResponse);
} catch (error) { } catch (error) {
logger.error('Error updating conversation', error); logger.error('Error updating conversation', error);

View file

@ -8,6 +8,7 @@ const { loadDefaultInterface } = require('./start/interface');
const { azureConfigSetup } = require('./start/azureOpenAI'); const { azureConfigSetup } = require('./start/azureOpenAI');
const { loadAndFormatTools } = require('./ToolService'); const { loadAndFormatTools } = require('./ToolService');
const { initializeRoles } = require('~/models/Role'); const { initializeRoles } = require('~/models/Role');
const { cleanup } = require('./cleanup');
const paths = require('~/config/paths'); const paths = require('~/config/paths');
/** /**
@ -17,6 +18,7 @@ const paths = require('~/config/paths');
* @param {Express.Application} app - The Express application object. * @param {Express.Application} app - The Express application object.
*/ */
const AppService = async (app) => { const AppService = async (app) => {
cleanup();
await initializeRoles(); await initializeRoles();
/** @type {TCustomConfig}*/ /** @type {TCustomConfig}*/
const config = (await loadCustomConfig()) ?? {}; const config = (await loadCustomConfig()) ?? {};

View file

@ -0,0 +1,13 @@
const { logger } = require('~/config');
const { deleteNullOrEmptyConversations } = require('~/models/Conversation');
const cleanup = async () => {
try {
await deleteNullOrEmptyConversations();
} catch (error) {
logger.error('[cleanup] Error during app cleanup', error);
} finally {
logger.debug('Startup cleanup complete');
}
};
module.exports = { cleanup };

View file

@ -1,7 +1,6 @@
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { OGDialog, OGDialogTrigger, Button } from '~/components'; import { OGDialog, OGDialogTrigger, Button } from '~/components';
import ArchivedChatsTable from './ArchivedChatsTable'; import ArchivedChatsTable from './ArchivedChatsTable';
export default function ArchivedChats() { export default function ArchivedChats() {

View file

@ -1,68 +1,80 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { import {
Search, Search,
ChevronRight,
ChevronLeft,
TrashIcon, TrashIcon,
ChevronLeft,
ChevronRight,
// ChevronsLeft,
// ChevronsRight,
MessageCircle, MessageCircle,
ArchiveRestore, ArchiveRestore,
ChevronsRight,
ChevronsLeft,
} from 'lucide-react'; } from 'lucide-react';
import type { TConversation } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider';
import { useAuthContext, useLocalize, useArchiveHandler } from '~/hooks';
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
import { import {
TooltipAnchor,
Table, Table,
TableBody, Input,
Button,
TableRow,
Skeleton,
OGDialog,
Separator,
TableCell, TableCell,
TableBody,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TooltipAnchor,
Separator,
Skeleton,
Button,
Input,
OGDialog,
OGDialogTrigger, OGDialogTrigger,
} from '~/components'; } from '~/components';
import { useConversationsInfiniteQuery, useArchiveConvoMutation } from '~/data-provider';
import { DeleteConversationDialog } from '~/components/Conversations/ConvoOptions';
import { useAuthContext, useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function ArchivedChatsTable() { export default function ArchivedChatsTable() {
const localize = useLocalize(); const localize = useLocalize();
const { isAuthenticated } = useAuthContext(); const { isAuthenticated } = useAuthContext();
const [conversationId, setConversationId] = useState<string | null>(null); const [isOpened, setIsOpened] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [totalPages, setTotalPages] = useState(1);
const [isOpened, setIsOpened] = useState(false);
const { data, isLoading, refetch } = useConversationsInfiniteQuery( const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
{ pageNumber: currentPage.toString(), limit: 10, isArchived: true }, useConversationsInfiniteQuery(
{ pageNumber: currentPage.toString(), isArchived: true },
{ enabled: isAuthenticated && isOpened }, { enabled: isAuthenticated && isOpened },
); );
const mutation = useArchiveConvoMutation();
const handleUnarchive = useCallback(
(conversationId: string) => {
mutation.mutate({ conversationId, isArchived: false });
},
[mutation],
);
useEffect(() => { const conversations = useMemo(
if (data) { () => data?.pages[currentPage - 1]?.conversations ?? [],
setTotalPages(Math.ceil(Number(data.pages))); [data, currentPage],
);
const totalPages = useMemo(() => Math.ceil(Number(data?.pages[0].pages ?? 1)) ?? 1, [data]);
const handleChatClick = useCallback((conversationId: string) => {
if (!conversationId) {
return;
} }
}, [data]);
const archiveHandler = useArchiveHandler(conversationId ?? '', false, () => {
refetch();
});
const handleChatClick = useCallback((conversationId) => {
window.open(`/c/${conversationId}`, '_blank'); window.open(`/c/${conversationId}`, '_blank');
}, []); }, []);
const handlePageChange = useCallback((newPage) => { const handlePageChange = useCallback(
(newPage: number) => {
setCurrentPage(newPage); setCurrentPage(newPage);
}, []); if (!(hasNextPage ?? false)) {
return;
}
fetchNextPage({ pageParam: newPage });
},
[fetchNextPage, hasNextPage],
);
const handleSearch = useCallback((query) => { const handleSearch = useCallback((query: string) => {
setSearchQuery(query); setSearchQuery(query);
setCurrentPage(1); setCurrentPage(1);
}, []); }, []);
@ -86,16 +98,14 @@ export default function ArchivedChatsTable() {
); );
}); });
if (isLoading) { if (isLoading || isFetchingNextPage) {
return <div className="text-gray-300">{skeletons}</div>; return <div className="text-text-secondary">{skeletons}</div>;
} }
if (!data || data.pages.length === 0 || data.pages[0].conversations.length === 0) { if (!data || (conversations.length === 0 && totalPages === 0)) {
return <div className="text-gray-300">{localize('com_nav_archived_chats_empty')}</div>; return <div className="text-text-secondary">{localize('com_nav_archived_chats_empty')}</div>;
} }
const conversations = data.pages.flatMap((page) => page.conversations);
return ( return (
<div <div
className={cn( className={cn(
@ -112,7 +122,7 @@ export default function ArchivedChatsTable() {
placeholder={localize('com_nav_search_placeholder')} placeholder={localize('com_nav_search_placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
className="w-full border-none" className="w-full border-none placeholder:text-text-secondary"
/> />
</div> </div>
<Separator /> <Separator />
@ -137,9 +147,16 @@ export default function ArchivedChatsTable() {
<TableRow key={conversation.conversationId} className="hover:bg-transparent"> <TableRow key={conversation.conversationId} className="hover:bg-transparent">
<TableCell className="flex items-center py-3 text-text-primary"> <TableCell className="flex items-center py-3 text-text-primary">
<button <button
type="button"
className="flex" className="flex"
aria-label="Open conversation in a new tab" aria-label="Open conversation in a new tab"
onClick={() => handleChatClick(conversation.conversationId)} onClick={() => {
const conversationId = conversation.conversationId ?? '';
if (!conversationId) {
return;
}
handleChatClick(conversationId);
}}
> >
<MessageCircle className="mr-1 h-5 w-5" /> <MessageCircle className="mr-1 h-5 w-5" />
<u>{conversation.title}</u> <u>{conversation.title}</u>
@ -161,19 +178,23 @@ export default function ArchivedChatsTable() {
description={localize('com_ui_unarchive')} description={localize('com_ui_unarchive')}
render={ render={
<Button <Button
type="button"
aria-label="Unarchive conversation" aria-label="Unarchive conversation"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8" className="size-8"
onClick={() => { onClick={() => {
setConversationId(conversation.conversationId); const conversationId = conversation.conversationId ?? '';
archiveHandler(); if (!conversationId) {
return;
}
handleUnarchive(conversationId);
}} }}
> >
<ArchiveRestore className="size-4" /> <ArchiveRestore className="size-4" />
</Button> </Button>
} }
></TooltipAnchor> />
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
@ -181,6 +202,7 @@ export default function ArchivedChatsTable() {
description={localize('com_ui_delete')} description={localize('com_ui_delete')}
render={ render={
<Button <Button
type="button"
aria-label="Delete archived conversation" aria-label="Delete archived conversation"
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -189,13 +211,13 @@ export default function ArchivedChatsTable() {
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
</Button> </Button>
} }
></TooltipAnchor> />
</OGDialogTrigger> </OGDialogTrigger>
{DeleteConversationDialog({ <DeleteConversationDialog
conversationId: conversation.conversationId ?? '', conversationId={conversation.conversationId ?? ''}
retainView: refetch, retainView={refetch}
title: conversation.title ?? '', title={conversation.title ?? ''}
})} />
</OGDialog> </OGDialog>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -208,7 +230,7 @@ export default function ArchivedChatsTable() {
Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button {/* <Button
variant="outline" variant="outline"
size="icon" size="icon"
aria-label="Go to the previous 10 pages" aria-label="Go to the previous 10 pages"
@ -216,7 +238,7 @@ export default function ArchivedChatsTable() {
disabled={currentPage === 1} disabled={currentPage === 1}
> >
<ChevronsLeft className="size-4" /> <ChevronsLeft className="size-4" />
</Button> </Button> */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@ -235,7 +257,7 @@ export default function ArchivedChatsTable() {
> >
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
</Button> </Button>
<Button {/* <Button
variant="outline" variant="outline"
size="icon" size="icon"
aria-label="Go to the next 10 pages" aria-label="Go to the next 10 pages"
@ -243,7 +265,7 @@ export default function ArchivedChatsTable() {
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
> >
<ChevronsRight className="size-4" /> <ChevronsRight className="size-4" />
</Button> </Button> */}
</div> </div>
</div> </div>
</> </>

View file

@ -7,6 +7,7 @@ interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string; description: string;
side?: 'top' | 'bottom' | 'left' | 'right'; side?: 'top' | 'bottom' | 'left' | 'right';
className?: string; className?: string;
role?: string;
} }
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor( export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
@ -50,7 +51,7 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
className={cn('cursor-pointer', className)} className={cn('cursor-pointer', className)}
/> />
<AnimatePresence> <AnimatePresence>
{mounted && ( {mounted === true && (
<Ariakit.Tooltip <Ariakit.Tooltip
gutter={4} gutter={4}
alwaysVisible alwaysVisible

View file

@ -138,7 +138,8 @@ export const useArchiveConversationMutation = (
(payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload), (payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload),
{ {
onSuccess: (_data, vars) => { onSuccess: (_data, vars) => {
if (vars.isArchived) { const isArchived = vars.isArchived === true;
if (isArchived) {
queryClient.setQueryData([QueryKeys.conversation, id], null); queryClient.setQueryData([QueryKeys.conversation, id], null);
} else { } else {
queryClient.setQueryData([QueryKeys.conversation, id], _data); queryClient.setQueryData([QueryKeys.conversation, id], _data);
@ -151,17 +152,17 @@ export const useArchiveConversationMutation = (
const pageSize = convoData.pages[0].pageSize as number; const pageSize = convoData.pages[0].pageSize as number;
return normalizeData( return normalizeData(
vars.isArchived ? deleteConversation(convoData, id) : addConversation(convoData, _data), isArchived ? deleteConversation(convoData, id) : addConversation(convoData, _data),
'conversations', 'conversations',
pageSize, pageSize,
); );
}); });
if (vars.isArchived) { if (isArchived) {
const current = queryClient.getQueryData<t.ConversationData>([ const current = queryClient.getQueryData<t.ConversationData>([
QueryKeys.allConversations, QueryKeys.allConversations,
]); ]);
refetch({ refetchPage: (page, index) => index === (current?.pages.length || 1) - 1 }); refetch({ refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1 });
} }
queryClient.setQueryData<t.ConversationData>( queryClient.setQueryData<t.ConversationData>(
@ -172,21 +173,19 @@ export const useArchiveConversationMutation = (
} }
const pageSize = convoData.pages[0].pageSize as number; const pageSize = convoData.pages[0].pageSize as number;
return normalizeData( return normalizeData(
vars.isArchived isArchived ? addConversation(convoData, _data) : deleteConversation(convoData, id),
? addConversation(convoData, _data)
: deleteConversation(convoData, id),
'conversations', 'conversations',
pageSize, pageSize,
); );
}, },
); );
if (!vars.isArchived) { if (!isArchived) {
const currentArchive = queryClient.getQueryData<t.ConversationData>([ const currentArchive = queryClient.getQueryData<t.ConversationData>([
QueryKeys.archivedConversations, QueryKeys.archivedConversations,
]); ]);
archiveRefetch({ archiveRefetch({
refetchPage: (page, index) => index === (currentArchive?.pages.length || 1) - 1, refetchPage: (page, index) => index === (currentArchive?.pages.length ?? 1) - 1,
}); });
} }
}, },
@ -194,6 +193,60 @@ export const useArchiveConversationMutation = (
); );
}; };
export const useArchiveConvoMutation = (options?: t.ArchiveConvoOptions) => {
const queryClient = useQueryClient();
const { onSuccess, ..._options } = options ?? {};
return useMutation<t.TArchiveConversationResponse, unknown, t.TArchiveConversationRequest>(
(payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload),
{
onSuccess: (_data, vars) => {
const { conversationId } = vars;
const isArchived = vars.isArchived === true;
if (isArchived) {
queryClient.setQueryData([QueryKeys.conversation, conversationId], null);
} else {
queryClient.setQueryData([QueryKeys.conversation, conversationId], _data);
}
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
}
const pageSize = convoData.pages[0].pageSize as number;
return normalizeData(
isArchived
? deleteConversation(convoData, conversationId)
: addConversation(convoData, _data),
'conversations',
pageSize,
);
});
queryClient.setQueryData<t.ConversationData>(
[QueryKeys.archivedConversations],
(convoData) => {
if (!convoData) {
return convoData;
}
const pageSize = convoData.pages[0].pageSize as number;
return normalizeData(
isArchived
? addConversation(convoData, _data)
: deleteConversation(convoData, conversationId),
'conversations',
pageSize,
);
},
);
onSuccess?.(_data, vars);
},
..._options,
},
);
};
export const useCreateSharedLinkMutation = ( export const useCreateSharedLinkMutation = (
options?: t.CreateSharedLinkOptions, options?: t.CreateSharedLinkOptions,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => { ): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => {

View file

@ -508,6 +508,7 @@ export const tConversationSchema = z.object({
conversationId: z.string().nullable(), conversationId: z.string().nullable(),
endpoint: eModelEndpointSchema.nullable(), endpoint: eModelEndpointSchema.nullable(),
endpointType: eModelEndpointSchema.optional(), endpointType: eModelEndpointSchema.optional(),
isArchived: z.boolean().optional(),
title: z.string().nullable().or(z.literal('New Chat')).default('New Chat'), title: z.string().nullable().or(z.literal('New Chat')).default('New Chat'),
user: z.string().optional(), user: z.string().optional(),
messages: z.array(z.string()).optional(), messages: z.array(z.string()).optional(),

View file

@ -165,6 +165,12 @@ export type UpdateSharedLinkOptions = MutationOptions<
types.TSharedLink, types.TSharedLink,
Partial<types.TSharedLink> Partial<types.TSharedLink>
>; >;
export type ArchiveConvoOptions = MutationOptions<
types.TArchiveConversationResponse,
types.TArchiveConversationRequest
>;
export type DeleteSharedLinkOptions = MutationOptions<types.TSharedLink, { shareId: string }>; export type DeleteSharedLinkOptions = MutationOptions<types.TSharedLink, { shareId: string }>;
export type TUpdatePromptContext = export type TUpdatePromptContext =