410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
"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)}
|
||
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
|
||
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 component="span" 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>
|
||
</>
|
||
);
|
||
};
|