598 lines
25 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
};
|