重构聊天会话管理,支持会话历史和存储
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user