重构聊天会话管理,支持会话历史和存储

This commit is contained in:
2026-04-30 15:02:08 +08:00
parent c5b0f43a0d
commit e0e78cd95a
11 changed files with 1247 additions and 221 deletions
+408
View File
@@ -0,0 +1,408 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
alpha,
} from "@mui/material";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
import SearchRounded from "@mui/icons-material/SearchRounded";
import WarningRounded from "@mui/icons-material/WarningRounded";
import type { ChatSessionSummary } from "./GlobalChatbox.types";
type AgentHistoryPanelProps = {
sessions: ChatSessionSummary[];
activeSessionId?: string;
isHydrating?: boolean;
onNewSession: () => void;
onSelectSession: (sessionId: string) => void;
onDeleteSession: (sessionId: string) => void;
};
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const isSameDay = date.toDateString() === now.toDateString();
if (isSameDay) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
});
};
const getDayStart = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const getSessionGroupLabel = (timestamp: number) => {
const now = new Date();
const todayStart = getDayStart(now);
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000;
if (timestamp >= todayStart) return "今天";
if (timestamp >= yesterdayStart) return "昨天";
if (timestamp >= lastWeekStart) return "过去 7 天";
return "更早";
};
export const AgentHistoryPanel = ({
sessions,
activeSessionId,
isHydrating = false,
onNewSession,
onSelectSession,
onDeleteSession,
}: AgentHistoryPanelProps) => {
const [keyword, setKeyword] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
const filteredSessions = React.useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return sessions;
return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword));
}, [keyword, sessions]);
const groupedSessions = React.useMemo(() => {
const groups = new Map<string, ChatSessionSummary[]>();
filteredSessions.forEach((session) => {
const label = getSessionGroupLabel(session.updatedAt);
const existing = groups.get(label);
if (existing) {
existing.push(session);
} else {
groups.set(label, [session]);
}
});
return Array.from(groups.entries());
}, [filteredSessions]);
const pendingDeleteSession = filteredSessions.find(
(session) => session.id === pendingDeleteSessionId,
);
return (
<>
<Paper
elevation={0}
sx={{
width: 268,
minWidth: 268,
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#ffffff", 0.54),
borderRight: `1px solid ${alpha("#fff", 0.75)}`,
backdropFilter: "blur(28px)",
boxShadow: `inset -1px 0 0 ${alpha("#fff", 0.35)}`,
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
<Box>
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
disabled={isHydrating}
onClick={onNewSession}
aria-label="新建对话"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.65),
border: `1px solid ${alpha("#fff", 0.5)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#00acc1",
borderColor: alpha("#fff", 0.9),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<EditNoteRounded sx={{ fontSize: 22 }} />
</IconButton>
</motion.div>
</Tooltip>
</Stack>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<TextField
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索历史会话"
size="small"
fullWidth
disabled={isHydrating}
InputProps={{
startAdornment: <SearchRounded sx={{ fontSize: 16, color: "text.secondary", mr: 0.75 }} />,
sx: {
borderRadius: 3,
bgcolor: alpha("#fff", 0.62),
fontSize: "0.85rem",
},
}}
/>
</Box>
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
{sessions.length === 0 ? (
<Stack
alignItems="center"
justifyContent="center"
spacing={1}
sx={{
height: "100%",
textAlign: "center",
color: "text.secondary",
px: 2,
}}
>
<ChatBubbleOutlineRounded sx={{ fontSize: 24, opacity: 0.7 }} />
<Typography variant="body2" fontWeight={700}>
</Typography>
<Typography variant="caption">
</Typography>
</Stack>
) : filteredSessions.length === 0 ? (
<Stack
alignItems="center"
justifyContent="center"
spacing={1}
sx={{
height: "100%",
textAlign: "center",
color: "text.secondary",
px: 2,
}}
>
<SearchRounded sx={{ fontSize: 24, opacity: 0.7 }} />
<Typography variant="body2" fontWeight={700}>
</Typography>
<Typography variant="caption">
</Typography>
</Stack>
) : (
<Stack spacing={1.5}>
{groupedSessions.map(([groupLabel, groupSessions]) => (
<Box key={groupLabel}>
<Typography
variant="caption"
color="text.secondary"
fontWeight={800}
sx={{ px: 0.5, mb: 0.75, display: "block", letterSpacing: 0.3 }}
>
{groupLabel}
</Typography>
<Stack spacing={1}>
{groupSessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
<Paper
key={session.id}
elevation={0}
onClick={() => onSelectSession(session.id)}
sx={{
px: 1.25,
py: 1,
borderRadius: 3,
cursor: isHydrating ? "default" : "pointer",
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
transition: "all 0.2s ease",
pointerEvents: isHydrating ? "none" : "auto",
"&:hover": {
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86),
borderColor: alpha("#00acc1", 0.2),
},
}}
>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
fontWeight={isActive ? 800 : 700}
color="text.primary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{session.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
{formatRelativeDate(session.updatedAt)}
</Typography>
</Box>
<Tooltip title="删除会话">
<span>
<IconButton
size="small"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
setIsDeleteDialogOpen(true);
}}
sx={{
width: 24,
height: 24,
color: "text.secondary",
"&:hover": {
color: "error.main",
bgcolor: alpha("#ef5350", 0.08),
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Stack>
</Paper>
);
})}
</Stack>
</Box>
))}
</Stack>
)}
</Box>
</Paper>
<Dialog
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
TransitionProps={{
onExited: () => setPendingDeleteSessionId(null)
}}
PaperProps={{
sx: {
borderRadius: 4,
bgcolor: alpha("#fff", 0.85),
backdropFilter: "blur(24px)",
boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`,
border: `1px solid ${alpha("#fff", 0.6)}`,
minWidth: 320,
},
}}
>
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: "50%",
bgcolor: alpha("#ef5350", 0.12),
color: "#ef5350",
}}
>
<WarningRounded sx={{ fontSize: 22 }} />
</Box>
<Typography variant="h6" fontWeight={800} color="text.primary">
</Typography>
</DialogTitle>
<DialogContent sx={{ px: 3, pb: 2 }}>
<DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}>
{pendingDeleteSession ? (
<Typography component="span" fontWeight={700} color="text.primary">
{pendingDeleteSession.title}
</Typography>
) : (
"该会话"
)}
<br />
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3, pt: 1 }}>
<Button
onClick={() => setIsDeleteDialogOpen(false)}
sx={{
color: "text.secondary",
fontWeight: 600,
borderRadius: 2.5,
px: 2.5,
"&:hover": { bgcolor: alpha("#000", 0.04) },
}}
>
</Button>
<Button
variant="contained"
onClick={() => {
if (pendingDeleteSessionId) {
onDeleteSession(pendingDeleteSessionId);
}
setIsDeleteDialogOpen(false);
}}
sx={{
bgcolor: "#ef5350",
color: "#fff",
fontWeight: 700,
borderRadius: 2.5,
px: 3,
boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`,
"&:hover": {
bgcolor: "#e53935",
boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`,
},
}}
>
</Button>
</DialogActions>
</Dialog>
</>
);
};