lovart3/components/sidebar-history.tsx
2025-12-17 20:15:18 +08:00

369 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
import { motion } from "framer-motion";
import { useParams, useRouter } from "next/navigation";
import type { User } from "next-auth";
import { useState } from "react";
import { toast } from "sonner";
import useSWRInfinite from "swr/infinite";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
import { ChatItem } from "./sidebar-history-item";
type GroupedChats = {
today: Chat[];
yesterday: Chat[];
lastWeek: Chat[];
lastMonth: Chat[];
older: Chat[];
};
export type ChatHistory = {
chats: Chat[];
hasMore: boolean;
};
const PAGE_SIZE = 20;
const groupChatsByDate = (chats: Chat[]): GroupedChats => {
const now = new Date();
const oneWeekAgo = subWeeks(now, 1);
const oneMonthAgo = subMonths(now, 1);
return chats.reduce(
(groups, chat) => {
const chatDate = new Date(chat.createdAt);
if (isToday(chatDate)) {
groups.today.push(chat);
} else if (isYesterday(chatDate)) {
groups.yesterday.push(chat);
} else if (chatDate > oneWeekAgo) {
groups.lastWeek.push(chat);
} else if (chatDate > oneMonthAgo) {
groups.lastMonth.push(chat);
} else {
groups.older.push(chat);
}
return groups;
},
{
today: [],
yesterday: [],
lastWeek: [],
lastMonth: [],
older: [],
} as GroupedChats
);
};
export function getChatHistoryPaginationKey(
pageIndex: number,
previousPageData: ChatHistory
) {
if (previousPageData && previousPageData.hasMore === false) {
return null;
}
if (pageIndex === 0) {
return `/api/history?limit=${PAGE_SIZE}`;
}
const firstChatFromPage = previousPageData.chats.at(-1);
if (!firstChatFromPage) {
return null;
}
return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
}
export function SidebarHistory({ user }: { user: User | undefined }) {
const { setOpenMobile } = useSidebar();
const { id } = useParams();
const {
data: paginatedChatHistories,
setSize,
isValidating,
isLoading,
mutate,
} = useSWRInfinite<ChatHistory>(getChatHistoryPaginationKey, fetcher, {
fallbackData: [],
});
const router = useRouter();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const hasReachedEnd = paginatedChatHistories
? paginatedChatHistories.some((page) => page.hasMore === false)
: false;
const hasEmptyChatHistory = paginatedChatHistories
? paginatedChatHistories.every((page) => page.chats.length === 0)
: false;
const handleDelete = () => {
const deletePromise = fetch(`/api/chat?id=${deleteId}`, {
method: "DELETE",
});
toast.promise(deletePromise, {
loading: "正在删除会话...",
success: () => {
mutate((chatHistories) => {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter((chat) => chat.id !== deleteId),
}));
}
});
return "会话删除成功";
},
error: "删除会话失败",
});
setShowDeleteDialog(false);
if (deleteId === id) {
router.push("/");
}
};
if (!user) {
return (
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
登录后即可保存并查看历史会话
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (isLoading) {
return (
<SidebarGroup>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
今天
</div>
<SidebarGroupContent>
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-md px-2"
key={item}
>
<div
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
style={
{
"--skeleton-width": `${item}%`,
} as React.CSSProperties
}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (hasEmptyChatHistory) {
return (
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
开始聊天后,会话记录将展示在这里
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
return (
<>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
(() => {
const chatsFromHistory = paginatedChatHistories.flatMap(
(paginatedChatHistory) => paginatedChatHistory.chats
);
const groupedChats = groupChatsByDate(chatsFromHistory);
return (
<div className="flex flex-col gap-6">
{groupedChats.today.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
今天
</div>
{groupedChats.today.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.yesterday.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
昨天
</div>
{groupedChats.yesterday.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastWeek.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
最近 7 天
</div>
{groupedChats.lastWeek.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastMonth.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
最近 30 天
</div>
{groupedChats.lastMonth.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.older.length > 0 && (
<div>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
早于 30 天
</div>
{groupedChats.older.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
</div>
);
})()}
</SidebarMenu>
<motion.div
onViewportEnter={() => {
if (!isValidating && !hasReachedEnd) {
setSize((size) => size + 1);
}
}}
/>
{hasReachedEnd ? (
<div className="mt-8 flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
没有更多会话记录了
</div>
) : (
<div className="mt-8 flex flex-row items-center gap-2 p-2 text-zinc-500 dark:text-zinc-400">
<div className="animate-spin">
<LoaderIcon />
</div>
<div>正在加载会话...</div>
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定要删除该会话?</AlertDialogTitle>
<AlertDialogDescription>
删除后将无法恢复,并会从服务器永久移除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}