🧹 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 = {
Conversation,
searchConversation,
deleteNullOrEmptyConversations,
/**
* Saves a conversation to the database.
* @param {Object} req - The request object.

View file

@ -109,8 +109,14 @@ router.post('/clear', async (req, res) => {
router.post('/update', async (req, res) => {
const update = req.body.arg;
if (!update.conversationId) {
return res.status(400).json({ error: 'conversationId is required' });
}
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);
} catch (error) {
logger.error('Error updating conversation', error);

View file

@ -8,6 +8,7 @@ const { loadDefaultInterface } = require('./start/interface');
const { azureConfigSetup } = require('./start/azureOpenAI');
const { loadAndFormatTools } = require('./ToolService');
const { initializeRoles } = require('~/models/Role');
const { cleanup } = require('./cleanup');
const paths = require('~/config/paths');
/**
@ -17,6 +18,7 @@ const paths = require('~/config/paths');
* @param {Express.Application} app - The Express application object.
*/
const AppService = async (app) => {
cleanup();
await initializeRoles();
/** @type {TCustomConfig}*/
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 OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { OGDialog, OGDialogTrigger, Button } from '~/components';
import ArchivedChatsTable from './ArchivedChatsTable';
export default function ArchivedChats() {

View file

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

View file

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

View file

@ -138,7 +138,8 @@ export const useArchiveConversationMutation = (
(payload: t.TArchiveConversationRequest) => dataService.archiveConversation(payload),
{
onSuccess: (_data, vars) => {
if (vars.isArchived) {
const isArchived = vars.isArchived === true;
if (isArchived) {
queryClient.setQueryData([QueryKeys.conversation, id], null);
} else {
queryClient.setQueryData([QueryKeys.conversation, id], _data);
@ -151,17 +152,17 @@ export const useArchiveConversationMutation = (
const pageSize = convoData.pages[0].pageSize as number;
return normalizeData(
vars.isArchived ? deleteConversation(convoData, id) : addConversation(convoData, _data),
isArchived ? deleteConversation(convoData, id) : addConversation(convoData, _data),
'conversations',
pageSize,
);
});
if (vars.isArchived) {
if (isArchived) {
const current = queryClient.getQueryData<t.ConversationData>([
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>(
@ -172,21 +173,19 @@ export const useArchiveConversationMutation = (
}
const pageSize = convoData.pages[0].pageSize as number;
return normalizeData(
vars.isArchived
? addConversation(convoData, _data)
: deleteConversation(convoData, id),
isArchived ? addConversation(convoData, _data) : deleteConversation(convoData, id),
'conversations',
pageSize,
);
},
);
if (!vars.isArchived) {
if (!isArchived) {
const currentArchive = queryClient.getQueryData<t.ConversationData>([
QueryKeys.archivedConversations,
]);
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 = (
options?: t.CreateSharedLinkOptions,
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => {

View file

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

View file

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