Files
TJWaterFrontend_Refine/src/components/chat/AgentHistoryPanel.tsx
T
jiang 9c0a7a2864
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
fix(chat): add history loading skeletons
2026-06-10 17:51:28 +08:00

598 lines
25 KiB
TypeScript

"use client";
import React from "react";
import { motion } from "framer-motion";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
IconButton,
Paper,
Skeleton,
Stack,
TextField,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import CheckRounded from "@mui/icons-material/CheckRounded";
import CloseRounded from "@mui/icons-material/CloseRounded";
import EditRounded from "@mui/icons-material/EditRounded";
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;
isLoadingSessions?: boolean;
loadingSessionId?: string;
onNewSession: () => void;
onRenameSession: (sessionId: string, title: string) => void;
onSelectSession: (sessionId: string, title: 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,
isLoadingSessions = false,
loadingSessionId,
onNewSession,
onRenameSession,
onSelectSession,
onDeleteSession,
}: AgentHistoryPanelProps) => {
const theme = useTheme();
const [keyword, setKeyword] = React.useState("");
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
const [draftTitle, setDraftTitle] = React.useState("");
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 sortedFilteredSessions = React.useMemo(
() =>
[...filteredSessions].sort((left, right) => {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) return createdAtDiff;
const updatedAtDiff = right.updatedAt - left.updatedAt;
if (updatedAtDiff !== 0) return updatedAtDiff;
return right.id.localeCompare(left.id);
}),
[filteredSessions],
);
const groupedSessions = React.useMemo(() => {
const groups = new Map<string, ChatSessionSummary[]>();
sortedFilteredSessions.forEach((session) => {
const label = getSessionGroupLabel(session.createdAt);
const existing = groups.get(label);
if (existing) {
existing.push(session);
} else {
groups.set(label, [session]);
}
});
return Array.from(groups.entries());
}, [sortedFilteredSessions]);
const pendingDeleteSession = filteredSessions.find(
(session) => session.id === pendingDeleteSessionId,
);
const renderSessionListSkeleton = () => (
<Stack spacing={1} aria-label="正在加载历史会话">
{Array.from({ length: 6 }, (_, index) => (
<Paper
key={index}
elevation={0}
sx={{
px: 1.25,
py: 1,
borderRadius: 3,
bgcolor: alpha("#fff", 0.48),
border: `1px solid ${alpha("#fff", 0.68)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.025)}`,
}}
>
<Stack spacing={0.75} sx={{ minHeight: 46, justifyContent: "center" }}>
<Skeleton variant="text" width={`${72 - (index % 3) * 12}%`} height={18} />
<Skeleton variant="text" width="32%" height={14} />
</Stack>
</Paper>
))}
</Stack>
);
const handleStartRename = (sessionId: string, title: string) => {
setEditingSessionId(sessionId);
setDraftTitle(title);
};
const handleCancelRename = () => {
setEditingSessionId(null);
setDraftTitle("");
};
const handleConfirmRename = (sessionId: string) => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle) return;
onRenameSession(sessionId, normalizedTitle);
handleCancelRename();
};
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>
</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 }}>
{isLoadingSessions ? (
renderSessionListSkeleton()
) : 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;
const isLoading = session.id === loadingSessionId;
return (
<Paper
key={session.id}
elevation={0}
aria-label={isLoading ? `正在加载会话 ${session.title}` : undefined}
onClick={() => {
if (editingSessionId === session.id || isLoading) return;
onSelectSession(session.id, session.title);
}}
sx={{
px: 1.25,
py: 1,
borderRadius: 3,
cursor: isHydrating || isLoading ? "default" : "pointer",
bgcolor:
isActive || isLoading
? alpha("#00acc1", 0.12)
: alpha("#fff", 0.56),
border: `1px solid ${
isActive || isLoading
? alpha("#00acc1", 0.25)
: alpha("#fff", 0.72)
}`,
boxShadow:
isActive || isLoading
? `0 8px 20px ${alpha("#00acc1", 0.12)}`
: `0 4px 12px ${alpha("#000", 0.03)}`,
transition: "all 0.2s ease",
pointerEvents: isHydrating || isLoading ? "none" : "auto",
"&:hover": {
bgcolor:
isActive || isLoading
? alpha("#00acc1", 0.14)
: alpha("#fff", 0.86),
borderColor: alpha("#00acc1", 0.2),
},
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Box sx={{ flex: 1, minWidth: 0 }}>
{editingSessionId === session.id ? (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minHeight: 46 }}>
<TextField
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
size="small"
autoFocus
placeholder="请输入会话标题"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
handleConfirmRename(session.id);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
handleCancelRename();
}
}}
sx={{
flex: 1,
minWidth: 0,
"& .MuiOutlinedInput-root": {
height: 32,
bgcolor: alpha("#fff", 0.75),
borderRadius: 1.5,
transition: "all 0.2s ease-in-out",
"& fieldset": {
borderColor: alpha("#000", 0.08),
},
"&:hover fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.4),
},
"&.Mui-focused fieldset": {
borderColor: theme.palette.primary.main,
borderWidth: "1.5px",
boxShadow: `0 0 0 3px ${alpha(theme.palette.primary.main, 0.1)}`,
},
},
"& .MuiInputBase-input": {
padding: "4px 10px",
fontSize: "0.85rem",
fontWeight: 700,
color: theme.palette.text.primary,
}
}}
/>
<IconButton
size="small"
aria-label="确认"
onClick={(event) => {
event.stopPropagation();
handleConfirmRename(session.id);
}}
disabled={!draftTitle.trim()}
sx={{
width: 28,
height: 28,
color: "success.main",
bgcolor: alpha(theme.palette.success.main, 0.1),
"&:hover": { bgcolor: alpha(theme.palette.success.main, 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消"
onClick={(event) => {
event.stopPropagation();
handleCancelRename();
}}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 16 }} />
</IconButton>
</Stack>
) : isLoading ? (
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<Skeleton variant="text" width="74%" height={18} />
<Skeleton variant="text" width="34%" height={14} sx={{ mt: 0.5 }} />
</Box>
) : pendingDeleteSessionId === session.id ? (
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minHeight: 46 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: alpha("#ef5350", 0.15),
color: "#ef5350",
flexShrink: 0
}}
>
<WarningRounded sx={{ fontSize: 13 }} />
</Box>
<Typography
variant="body2"
fontWeight={800}
color="error.main"
sx={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
确认删除此会话?
</Typography>
</Stack>
) : (
<Box sx={{ minHeight: 46, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<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.createdAt)}
</Typography>
</Box>
)}
</Box>
{!(editingSessionId === session.id || pendingDeleteSessionId === session.id || isLoading) && (
<Stack direction="row" spacing={0.25}>
<Tooltip title="修改会话标题">
<span>
<IconButton
size="small"
aria-label="修改会话标题"
onClick={(event) => {
event.stopPropagation();
handleStartRename(session.id, session.title);
}}
disabled={isHydrating || editingSessionId === session.id}
sx={{
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "primary.main",
bgcolor: alpha("#00acc1", 0.08),
},
}}
>
<EditRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="删除会话">
<span>
<IconButton
size="small"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
}}
disabled={isHydrating}
sx={{
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "error.main",
bgcolor: alpha("#ef5350", 0.08),
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Stack>
)}
{pendingDeleteSessionId === session.id && (
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton
size="small"
aria-label="确认删除"
onClick={(event) => {
event.stopPropagation();
onDeleteSession(session.id);
setPendingDeleteSessionId(null);
}}
sx={{
width: 28,
height: 28,
color: "error.main",
bgcolor: alpha("#ef5350", 0.1),
"&:hover": { bgcolor: alpha("#ef5350", 0.2) },
}}
>
<CheckRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="取消删除"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(null);
}}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.05),
"&:hover": { bgcolor: alpha("#000", 0.1) },
}}
>
<CloseRounded sx={{ fontSize: 16 }} />
</IconButton>
</Stack>
)}
</Stack>
</Paper>
);
})}
</Stack>
</Box>
))}
</Stack>
)}
</Box>
</Paper>
</>
);
};