重构聊天会话管理,支持会话历史和存储
This commit is contained in:
Generated
+7
@@ -30,6 +30,7 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
@@ -15843,6 +15844,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
|||||||
type AgentComposerProps = {
|
type AgentComposerProps = {
|
||||||
input: string;
|
input: string;
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
isHydrating?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isSttSupported: boolean;
|
isSttSupported: boolean;
|
||||||
@@ -40,6 +41,7 @@ type AgentComposerProps = {
|
|||||||
export const AgentComposer = ({
|
export const AgentComposer = ({
|
||||||
input,
|
input,
|
||||||
inputRef,
|
inputRef,
|
||||||
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isListening,
|
isListening,
|
||||||
isSttSupported,
|
isSttSupported,
|
||||||
@@ -52,7 +54,7 @@ export const AgentComposer = ({
|
|||||||
onPresetSelect,
|
onPresetSelect,
|
||||||
}: AgentComposerProps) => {
|
}: AgentComposerProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const canSend = input.trim().length > 0 && !isStreaming;
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -160,11 +162,12 @@ export const AgentComposer = ({
|
|||||||
onSend();
|
onSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="描述你的分析目标,或点击上方指令库..."
|
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
maxRows={5}
|
maxRows={5}
|
||||||
variant="standard"
|
variant="standard"
|
||||||
|
disabled={isHydrating}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
disableUnderline: true,
|
disableUnderline: true,
|
||||||
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
|
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
|
||||||
@@ -199,7 +202,7 @@ export const AgentComposer = ({
|
|||||||
) : (
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onStartListening}
|
onClick={onStartListening}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming || isHydrating}
|
||||||
aria-label="语音输入"
|
aria-label="语音输入"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||||
@@ -262,7 +265,7 @@ export const AgentComposer = ({
|
|||||||
style={{ width: 14, height: 14 }}
|
style={{ width: 14, height: 14 }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
|
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
|
||||||
Powered by DeepSeek V3 · TJWater Agent Intelligence
|
Powered by DeepSeek V4 · TJWater Agent Intelligence
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
+120
-107
@@ -7,37 +7,32 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
IconButton,
|
IconButton,
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Stack,
|
Stack,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
import CloseRounded from "@mui/icons-material/CloseRounded";
|
import CloseRounded from "@mui/icons-material/CloseRounded";
|
||||||
|
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
||||||
|
|
||||||
type AgentHeaderProps = {
|
type AgentHeaderProps = {
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
menuAnchorEl: HTMLElement | null;
|
isHistoryOpen: boolean;
|
||||||
onMenuOpen: (event: React.MouseEvent<HTMLElement>) => void;
|
onHistoryToggle: () => void;
|
||||||
onMenuClose: () => void;
|
|
||||||
onNewConversation: () => void;
|
onNewConversation: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentHeader = ({
|
export const AgentHeader = ({
|
||||||
isStreaming,
|
isStreaming,
|
||||||
menuAnchorEl,
|
isHistoryOpen,
|
||||||
onMenuOpen,
|
onHistoryToggle,
|
||||||
onMenuClose,
|
|
||||||
onNewConversation,
|
onNewConversation,
|
||||||
onClose,
|
onClose,
|
||||||
}: AgentHeaderProps) => {
|
}: AgentHeaderProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMenuOpen = Boolean(menuAnchorEl);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -55,55 +50,46 @@ export const AgentHeader = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={2}>
|
<Stack direction="row" alignItems="center" spacing={2}>
|
||||||
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex" }}>
|
||||||
<IconButton
|
<Box sx={{ position: "relative" }}>
|
||||||
onClick={onMenuOpen}
|
<Avatar
|
||||||
aria-label="打开 Agent 菜单"
|
sx={{
|
||||||
aria-controls={isMenuOpen ? "global-chatbox-header-menu" : undefined}
|
background: alpha("#ffffff", 0.9),
|
||||||
aria-expanded={isMenuOpen ? "true" : undefined}
|
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
|
||||||
aria-haspopup="menu"
|
width: 44,
|
||||||
sx={{ p: 0, borderRadius: "50%" }}
|
height: 44,
|
||||||
>
|
border: `2px solid ${alpha("#fff", 0.8)}`,
|
||||||
<Box sx={{ position: "relative" }}>
|
p: 0.75,
|
||||||
<Avatar
|
}}
|
||||||
sx={{
|
>
|
||||||
background: alpha("#ffffff", 0.9),
|
<Image
|
||||||
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
|
src="/ai-agent.svg"
|
||||||
width: 44,
|
alt="TJWater Agent"
|
||||||
height: 44,
|
width={30}
|
||||||
border: `2px solid ${alpha("#fff", 0.8)}`,
|
height={30}
|
||||||
p: 0.75,
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/ai-agent.svg"
|
|
||||||
alt="TJWater Agent"
|
|
||||||
width={30}
|
|
||||||
height={30}
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: -2,
|
|
||||||
right: -2,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
bgcolor: isStreaming ? "#ff9800" : "#00e676",
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: "2.5px solid #fff",
|
|
||||||
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
|
|
||||||
animation: isStreaming ? "pulse 1.5s infinite" : "none",
|
|
||||||
"@keyframes pulse": {
|
|
||||||
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
|
||||||
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
|
||||||
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Avatar>
|
||||||
</IconButton>
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
bgcolor: isStreaming ? "#ff9800" : "#00e676",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "2.5px solid #fff",
|
||||||
|
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
|
||||||
|
animation: isStreaming ? "pulse 1.5s infinite" : "none",
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
||||||
|
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
||||||
|
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -124,54 +110,81 @@ export const AgentHeader = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Menu
|
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||||
id="global-chatbox-header-menu"
|
<Tooltip title="新建对话">
|
||||||
anchorEl={menuAnchorEl}
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
open={isMenuOpen}
|
<IconButton
|
||||||
onClose={onMenuClose}
|
onClick={onNewConversation}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
aria-label="新建对话"
|
||||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
sx={{
|
||||||
slotProps={{
|
width: 36,
|
||||||
paper: {
|
height: 36,
|
||||||
elevation: 8,
|
color: "text.primary",
|
||||||
sx: {
|
bgcolor: alpha("#fff", 0.54),
|
||||||
mt: 1,
|
border: `1px solid ${alpha("#fff", 0.4)}`,
|
||||||
minWidth: 180,
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
borderRadius: 3,
|
"&:hover": {
|
||||||
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
bgcolor: "#fff",
|
||||||
backdropFilter: "blur(12px)",
|
color: "#00acc1",
|
||||||
bgcolor: alpha("#fff", 0.92),
|
borderColor: alpha("#fff", 0.8),
|
||||||
},
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={onNewConversation}>
|
<EditNoteRounded sx={{ fontSize: 22 }} />
|
||||||
<ListItemIcon>
|
</IconButton>
|
||||||
<AddCommentRounded fontSize="small" />
|
</motion.div>
|
||||||
</ListItemIcon>
|
</Tooltip>
|
||||||
<ListItemText
|
|
||||||
primary="新建对话"
|
|
||||||
secondary="清空当前会话"
|
|
||||||
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 700 } }}
|
|
||||||
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
|
|
||||||
/>
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }}>
|
<Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}>
|
||||||
<IconButton
|
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
onClick={onClose}
|
<IconButton
|
||||||
size="small"
|
onClick={onHistoryToggle}
|
||||||
aria-label="关闭 Agent"
|
aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"}
|
||||||
sx={{
|
sx={{
|
||||||
color: "text.primary",
|
width: 36,
|
||||||
bgcolor: alpha("#fff", 0.54),
|
height: 36,
|
||||||
"&:hover": { bgcolor: "#fff" },
|
color: isHistoryOpen ? "#00acc1" : "text.primary",
|
||||||
}}
|
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.12) : alpha("#fff", 0.54),
|
||||||
>
|
border: `1px solid ${isHistoryOpen ? alpha("#00acc1", 0.2) : alpha("#fff", 0.4)}`,
|
||||||
<CloseRounded />
|
boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`,
|
||||||
</IconButton>
|
"&:hover": {
|
||||||
</motion.div>
|
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.16) : "#fff",
|
||||||
|
borderColor: isHistoryOpen ? alpha("#00acc1", 0.3) : alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${isHistoryOpen ? alpha("#00acc1", 0.1) : alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryRounded sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="关闭 Agent">
|
||||||
|
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭 Agent"
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "text.primary",
|
||||||
|
bgcolor: alpha("#fff", 0.54),
|
||||||
|
border: `1px solid ${alpha("#fff", 0.4)}`,
|
||||||
|
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#fff",
|
||||||
|
color: "#e53935",
|
||||||
|
borderColor: alpha("#fff", 0.8),
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRounded sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</motion.div>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
|||||||
|
|
||||||
import { AgentComposer } from "./AgentComposer";
|
import { AgentComposer } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
import { Blob } from "./GlobalChatbox.parts";
|
import { Blob } from "./GlobalChatbox.parts";
|
||||||
import type { Props } from "./GlobalChatbox.types";
|
import type { Props } from "./GlobalChatbox.types";
|
||||||
@@ -17,7 +18,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -47,15 +48,20 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const handleToolCall = useAgentToolActions();
|
const handleToolCall = useAgentToolActions();
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
chatSessions,
|
||||||
|
activeStorageSessionId,
|
||||||
branchGroups,
|
branchGroups,
|
||||||
branchTransition,
|
branchTransition,
|
||||||
|
isHydrating,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
regenerate,
|
regenerate,
|
||||||
editAndResubmit,
|
editAndResubmit,
|
||||||
cycleBranch,
|
cycleBranch,
|
||||||
abort,
|
abort,
|
||||||
reset,
|
createSession,
|
||||||
|
removeSession,
|
||||||
|
switchSession,
|
||||||
} = useAgentChatSession({
|
} = useAgentChatSession({
|
||||||
onToolCall: handleToolCall,
|
onToolCall: handleToolCall,
|
||||||
onBeforeSend: stopListening,
|
onBeforeSend: stopListening,
|
||||||
@@ -88,24 +94,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
||||||
setHeaderMenuAnchorEl(event.currentTarget);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleHeaderMenuClose = useCallback(() => {
|
|
||||||
setHeaderMenuAnchorEl(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
reset();
|
void createSession();
|
||||||
setInput("");
|
setInput("");
|
||||||
handleHeaderMenuClose();
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]);
|
}, [createSession, handleStopSpeech, stopListening]);
|
||||||
|
|
||||||
|
const handleHistoryToggle = useCallback(() => {
|
||||||
|
setIsHistoryOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectSession = useCallback(
|
||||||
|
(storageSessionId: string) => {
|
||||||
|
setInput("");
|
||||||
|
void switchSession(storageSessionId);
|
||||||
|
},
|
||||||
|
[switchSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteSession = useCallback(
|
||||||
|
(storageSessionId: string) => {
|
||||||
|
void removeSession(storageSessionId);
|
||||||
|
},
|
||||||
|
[removeSession],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -198,45 +214,91 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
|
|
||||||
<AgentHeader
|
<AgentHeader
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
menuAnchorEl={headerMenuAnchorEl}
|
isHistoryOpen={isHistoryOpen}
|
||||||
onMenuOpen={handleHeaderMenuOpen}
|
onHistoryToggle={handleHistoryToggle}
|
||||||
onMenuClose={handleHeaderMenuClose}
|
|
||||||
onNewConversation={handleNewConversation}
|
onNewConversation={handleNewConversation}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentWorkspace
|
<Box sx={{ flex: 1, display: "flex", minHeight: 0, position: "relative", overflow: "hidden" }}>
|
||||||
messages={messages}
|
<Box
|
||||||
branchGroups={branchGroups}
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
branchTransition={branchTransition}
|
sx={{
|
||||||
isStreaming={isStreaming}
|
position: "absolute",
|
||||||
bottomRef={bottomRef}
|
inset: 0,
|
||||||
speakingMessageId={speakingMessageId}
|
bgcolor: alpha("#000", 0.05),
|
||||||
speechState={speechState}
|
backdropFilter: "blur(2px)",
|
||||||
onSpeak={handleSpeak}
|
opacity: isHistoryOpen ? 1 : 0,
|
||||||
onPauseSpeech={handlePauseSpeech}
|
pointerEvents: isHistoryOpen ? "auto" : "none",
|
||||||
onResumeSpeech={handleResumeSpeech}
|
transition: "opacity 0.3s ease",
|
||||||
onStopSpeech={handleStopSpeech}
|
zIndex: 10,
|
||||||
isTtsSupported={isTtsSupported}
|
}}
|
||||||
onRegenerate={regenerate}
|
/>
|
||||||
onEditResubmit={editAndResubmit}
|
<Box
|
||||||
onCycleBranch={cycleBranch}
|
sx={{
|
||||||
/>
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 268,
|
||||||
|
zIndex: 20,
|
||||||
|
transform: isHistoryOpen ? "translateX(0)" : "translateX(-100%)",
|
||||||
|
transition: "transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
boxShadow: isHistoryOpen ? `4px 0 24px ${alpha("#000", 0.08)}` : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentHistoryPanel
|
||||||
|
sessions={chatSessions}
|
||||||
|
activeSessionId={activeStorageSessionId}
|
||||||
|
isHydrating={isHydrating}
|
||||||
|
onNewSession={() => {
|
||||||
|
handleNewConversation();
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
onSelectSession={(id) => {
|
||||||
|
handleSelectSession(id);
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
}}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<AgentComposer
|
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
|
||||||
input={input}
|
<AgentWorkspace
|
||||||
inputRef={inputRef}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
branchGroups={branchGroups}
|
||||||
isListening={isListening}
|
branchTransition={branchTransition}
|
||||||
isSttSupported={isSttSupported}
|
isStreaming={isStreaming}
|
||||||
presets={PRESET_PROMPTS}
|
bottomRef={bottomRef}
|
||||||
onInputChange={setInput}
|
speakingMessageId={speakingMessageId}
|
||||||
onSend={handleSend}
|
speechState={speechState}
|
||||||
onAbort={abort}
|
onSpeak={handleSpeak}
|
||||||
onStartListening={startListening}
|
onPauseSpeech={handlePauseSpeech}
|
||||||
onStopListening={stopListening}
|
onResumeSpeech={handleResumeSpeech}
|
||||||
onPresetSelect={handlePresetPromptSelect}
|
onStopSpeech={handleStopSpeech}
|
||||||
/>
|
isTtsSupported={isTtsSupported}
|
||||||
|
onRegenerate={regenerate}
|
||||||
|
onEditResubmit={editAndResubmit}
|
||||||
|
onCycleBranch={cycleBranch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgentComposer
|
||||||
|
input={input}
|
||||||
|
inputRef={inputRef}
|
||||||
|
isHydrating={isHydrating}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
isListening={isListening}
|
||||||
|
isSttSupported={isSttSupported}
|
||||||
|
presets={PRESET_PROMPTS}
|
||||||
|
onInputChange={setInput}
|
||||||
|
onSend={handleSend}
|
||||||
|
onAbort={abort}
|
||||||
|
onStartListening={startListening}
|
||||||
|
onStopListening={stopListening}
|
||||||
|
onPresetSelect={handlePresetPromptSelect}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,8 +61,39 @@ export type Props = {
|
|||||||
|
|
||||||
export type SpeechState = "idle" | "playing" | "paused";
|
export type SpeechState = "idle" | "playing" | "paused";
|
||||||
|
|
||||||
export type PersistedChatState = {
|
export type LegacyPersistedChatState = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
branchGroups?: BranchGroup[];
|
branchGroups?: BranchGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChatSessionRecord = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
sessionId?: string;
|
||||||
|
messages: Message[];
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatSessionSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatStorageMeta = {
|
||||||
|
key: "chat-meta";
|
||||||
|
activeSessionId?: string;
|
||||||
|
migratedFromLocalStorage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoadedChatState = {
|
||||||
|
storageSessionId?: string;
|
||||||
|
title?: string;
|
||||||
|
messages: Message[];
|
||||||
|
sessionId?: string;
|
||||||
|
branchGroups: BranchGroup[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { BranchGroup, Message, PersistedChatState } from "./GlobalChatbox.types";
|
import type { BranchGroup, Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
export const createId = () =>
|
export const createId = () =>
|
||||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
|
|
||||||
export const PRESET_PROMPTS = [
|
export const PRESET_PROMPTS = [
|
||||||
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
|
||||||
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
"帮我分析当前管网压力异常点,并按风险等级排序。",
|
||||||
@@ -29,34 +28,6 @@ export const stripMarkdown = (md: string): string =>
|
|||||||
.replace(/<[^>]+>/g, "")
|
.replace(/<[^>]+>/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
export const getInitialChatState = (): PersistedChatState => {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return { messages: [], sessionId: undefined };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
|
||||||
if (!storedRaw) return { messages: [], sessionId: undefined };
|
|
||||||
const parsed = JSON.parse(storedRaw) as PersistedChatState;
|
|
||||||
if (!Array.isArray(parsed.messages)) {
|
|
||||||
console.error("[GlobalChatbox] Invalid persisted messages format.");
|
|
||||||
window.localStorage.removeItem(CHAT_STORAGE_KEY);
|
|
||||||
return { messages: [], sessionId: undefined };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
|
|
||||||
sessionId: parsed.sessionId,
|
|
||||||
branchGroups: Array.isArray(parsed.branchGroups) ? parsed.branchGroups : [],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[GlobalChatbox] Failed to read persisted chat state:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
window.localStorage.removeItem(CHAT_STORAGE_KEY);
|
|
||||||
return { messages: [], sessionId: undefined };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cloneMessage = (message: Message): Message => ({
|
export const cloneMessage = (message: Message): Message => ({
|
||||||
...message,
|
...message,
|
||||||
progress: message.progress ? [...message.progress] : undefined,
|
progress: message.progress ? [...message.progress] : undefined,
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { openDB, type DBSchema } from "idb";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BranchGroup,
|
||||||
|
ChatSessionRecord,
|
||||||
|
ChatSessionSummary,
|
||||||
|
ChatStorageMeta,
|
||||||
|
LegacyPersistedChatState,
|
||||||
|
LoadedChatState,
|
||||||
|
Message,
|
||||||
|
} from "./GlobalChatbox.types";
|
||||||
|
import {
|
||||||
|
cloneBranchGroups,
|
||||||
|
cloneMessages,
|
||||||
|
createId,
|
||||||
|
} from "./GlobalChatbox.utils";
|
||||||
|
|
||||||
|
const CHAT_DB_NAME = "tjwater-agent-chat";
|
||||||
|
const CHAT_DB_VERSION = 1;
|
||||||
|
const SESSION_STORE = "sessions";
|
||||||
|
const META_STORE = "meta";
|
||||||
|
const META_KEY = "chat-meta" as const;
|
||||||
|
const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
|
||||||
|
|
||||||
|
type ChatDB = DBSchema & {
|
||||||
|
sessions: {
|
||||||
|
key: string;
|
||||||
|
value: ChatSessionRecord;
|
||||||
|
indexes: {
|
||||||
|
"by-updatedAt": number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
key: string;
|
||||||
|
value: ChatStorageMeta;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyLoadedChatState = (): LoadedChatState => ({
|
||||||
|
storageSessionId: undefined,
|
||||||
|
title: undefined,
|
||||||
|
messages: [],
|
||||||
|
sessionId: undefined,
|
||||||
|
branchGroups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||||
|
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||||
|
|
||||||
|
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
||||||
|
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
||||||
|
|
||||||
|
const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => {
|
||||||
|
if (!session) return emptyLoadedChatState();
|
||||||
|
return {
|
||||||
|
storageSessionId: session.id,
|
||||||
|
title: session.title,
|
||||||
|
messages: sanitizeMessages(session.messages),
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
branchGroups: sanitizeBranchGroups(session.branchGroups),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({
|
||||||
|
id: session.id,
|
||||||
|
title: session.title,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSessionTitle = (messages: Message[]) => {
|
||||||
|
const firstUserMessage = messages.find((message) => message.role === "user");
|
||||||
|
if (!firstUserMessage) return "新对话";
|
||||||
|
const title = firstUserMessage.content.replace(/\s+/g, " ").trim();
|
||||||
|
if (!title) return "新对话";
|
||||||
|
return title.length > 24 ? `${title.slice(0, 24)}...` : title;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDb = () =>
|
||||||
|
openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
||||||
|
const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: "id" });
|
||||||
|
sessionStore.createIndex("by-updatedAt", "updatedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(META_STORE)) {
|
||||||
|
db.createObjectStore(META_STORE, { keyPath: "key" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const readLegacyChatState = (): LegacyPersistedChatState | null => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY);
|
||||||
|
if (!storedRaw) return null;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState;
|
||||||
|
if (!Array.isArray(parsed.messages)) {
|
||||||
|
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: sanitizeMessages(parsed.messages),
|
||||||
|
sessionId: parsed.sessionId,
|
||||||
|
branchGroups: sanitizeBranchGroups(parsed.branchGroups),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to read legacy chat state:", error);
|
||||||
|
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLegacyChatState = () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMeta = async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.get(META_STORE, META_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.put(META_STORE, {
|
||||||
|
key: META_KEY,
|
||||||
|
...meta,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestSession = async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
const sessions = await db.getAll(SESSION_STORE);
|
||||||
|
if (sessions.length === 0) return undefined;
|
||||||
|
return sessions.sort((left, right) => right.updatedAt - left.updatedAt)[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrateLegacyLocalStorage = async () => {
|
||||||
|
const meta = await getMeta();
|
||||||
|
if (meta?.migratedFromLocalStorage) return;
|
||||||
|
|
||||||
|
const legacyState = readLegacyChatState();
|
||||||
|
if (!legacyState) {
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: meta?.activeSessionId,
|
||||||
|
migratedFromLocalStorage: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContent =
|
||||||
|
legacyState.messages.length > 0 ||
|
||||||
|
(legacyState.branchGroups?.length ?? 0) > 0 ||
|
||||||
|
Boolean(legacyState.sessionId);
|
||||||
|
|
||||||
|
if (!hasContent) {
|
||||||
|
clearLegacyChatState();
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: undefined,
|
||||||
|
migratedFromLocalStorage: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionRecord: ChatSessionRecord = {
|
||||||
|
id: createId(),
|
||||||
|
title: buildSessionTitle(legacyState.messages),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
sessionId: legacyState.sessionId,
|
||||||
|
messages: sanitizeMessages(legacyState.messages),
|
||||||
|
branchGroups: sanitizeBranchGroups(legacyState.branchGroups),
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
await db.put(SESSION_STORE, sessionRecord);
|
||||||
|
clearLegacyChatState();
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: sessionRecord.id,
|
||||||
|
migratedFromLocalStorage: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
|
||||||
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
|
await migrateLegacyLocalStorage();
|
||||||
|
|
||||||
|
const meta = await getMeta();
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
if (meta?.activeSessionId) {
|
||||||
|
const activeSession = await db.get(SESSION_STORE, meta.activeSessionId);
|
||||||
|
if (activeSession) {
|
||||||
|
return toLoadedChatState(activeSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestSession = await getLatestSession();
|
||||||
|
if (!latestSession) {
|
||||||
|
return emptyLoadedChatState();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: latestSession.id,
|
||||||
|
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toLoadedChatState(latestSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveActiveChatState = async (
|
||||||
|
state: LoadedChatState,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
if (typeof window === "undefined") return state.storageSessionId;
|
||||||
|
|
||||||
|
const hasContent =
|
||||||
|
state.messages.length > 0 ||
|
||||||
|
state.branchGroups.length > 0 ||
|
||||||
|
Boolean(state.sessionId);
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const existingSession = state.storageSessionId
|
||||||
|
? await db.get(SESSION_STORE, state.storageSessionId)
|
||||||
|
: undefined;
|
||||||
|
const meta = await getMeta();
|
||||||
|
|
||||||
|
if (!hasContent) {
|
||||||
|
if (state.storageSessionId) {
|
||||||
|
await db.delete(SESSION_STORE, state.storageSessionId);
|
||||||
|
}
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: undefined,
|
||||||
|
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const storageSessionId = state.storageSessionId ?? createId();
|
||||||
|
const computedTitle = buildSessionTitle(state.messages);
|
||||||
|
const preferredTitle = state.title?.trim();
|
||||||
|
const finalTitle = preferredTitle || computedTitle;
|
||||||
|
const nextRecord: ChatSessionRecord = {
|
||||||
|
id: storageSessionId,
|
||||||
|
title: finalTitle,
|
||||||
|
createdAt: existingSession?.createdAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
messages: sanitizeMessages(state.messages),
|
||||||
|
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.put(SESSION_STORE, nextRecord);
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: storageSessionId,
|
||||||
|
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return storageSessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
|
||||||
|
await migrateLegacyLocalStorage();
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const sessions = await db.getAll(SESSION_STORE);
|
||||||
|
return sessions
|
||||||
|
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||||
|
.map(toSessionSummary);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
||||||
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
|
await migrateLegacyLocalStorage();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const session: ChatSessionRecord = {
|
||||||
|
id: createId(),
|
||||||
|
title: "新对话",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
sessionId: undefined,
|
||||||
|
messages: [],
|
||||||
|
branchGroups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
await db.put(SESSION_STORE, session);
|
||||||
|
const meta = await getMeta();
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: session.id,
|
||||||
|
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toLoadedChatState(session);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadChatSessionById = async (sessionId: string): Promise<LoadedChatState> => {
|
||||||
|
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||||
|
|
||||||
|
await migrateLegacyLocalStorage();
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
const session = await db.get(SESSION_STORE, sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return emptyLoadedChatState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await getMeta();
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: session.id,
|
||||||
|
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toLoadedChatState(session);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteChatSession = async (sessionId: string): Promise<string | undefined> => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
await db.delete(SESSION_STORE, sessionId);
|
||||||
|
|
||||||
|
const remainingSessions = await db.getAll(SESSION_STORE);
|
||||||
|
const nextActiveSession = remainingSessions.sort(
|
||||||
|
(left, right) => right.updatedAt - left.updatedAt,
|
||||||
|
)[0];
|
||||||
|
const meta = await getMeta();
|
||||||
|
|
||||||
|
await setMeta({
|
||||||
|
activeSessionId: nextActiveSession?.id,
|
||||||
|
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextActiveSession?.id;
|
||||||
|
};
|
||||||
@@ -9,16 +9,23 @@ import type {
|
|||||||
BranchGroup,
|
BranchGroup,
|
||||||
BranchTransition,
|
BranchTransition,
|
||||||
ChatProgress,
|
ChatProgress,
|
||||||
|
ChatSessionSummary,
|
||||||
|
LoadedChatState,
|
||||||
Message,
|
Message,
|
||||||
PersistedChatState,
|
|
||||||
} from "../GlobalChatbox.types";
|
} from "../GlobalChatbox.types";
|
||||||
import {
|
import {
|
||||||
CHAT_STORAGE_KEY,
|
|
||||||
cloneBranchGroups,
|
cloneBranchGroups,
|
||||||
cloneMessages,
|
cloneMessages,
|
||||||
createId,
|
createId,
|
||||||
getInitialChatState,
|
|
||||||
} from "../GlobalChatbox.utils";
|
} from "../GlobalChatbox.utils";
|
||||||
|
import {
|
||||||
|
createEmptyChatSession,
|
||||||
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadActiveChatState,
|
||||||
|
loadChatSessionById,
|
||||||
|
saveActiveChatState,
|
||||||
|
} from "../chatStorage";
|
||||||
|
|
||||||
type UseAgentChatSessionOptions = {
|
type UseAgentChatSessionOptions = {
|
||||||
onToolCall: (
|
onToolCall: (
|
||||||
@@ -88,24 +95,20 @@ export const useAgentChatSession = ({
|
|||||||
onToolCall,
|
onToolCall,
|
||||||
onBeforeSend,
|
onBeforeSend,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
const initialChatStateRef = useRef<PersistedChatState | null>(null);
|
const storageSessionIdRef = useRef<string | undefined>(undefined);
|
||||||
if (initialChatStateRef.current === null) {
|
const hydrationCompletedRef = useRef(false);
|
||||||
initialChatStateRef.current = getInitialChatState();
|
const hydrationNonceRef = useRef(0);
|
||||||
}
|
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>(
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
initialChatStateRef.current.messages,
|
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
|
||||||
);
|
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||||
const [sessionId, setSessionId] = useState<string | undefined>(
|
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
|
||||||
initialChatStateRef.current.sessionId,
|
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
|
||||||
);
|
|
||||||
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>(
|
|
||||||
initialChatStateRef.current.branchGroups ?? [],
|
|
||||||
);
|
|
||||||
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
|
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [isHydrating, setIsHydrating] = useState(true);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionIdRef = useRef<string | undefined>(initialChatStateRef.current.sessionId);
|
const sessionIdRef = useRef<string | undefined>(undefined);
|
||||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -113,13 +116,74 @@ export const useAgentChatSession = ({
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state: PersistedChatState = { messages, sessionId, branchGroups };
|
let cancelled = false;
|
||||||
try {
|
|
||||||
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
|
const hydrate = async () => {
|
||||||
} catch (error) {
|
try {
|
||||||
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
const [loadedState, sessions] = await Promise.all([
|
||||||
}
|
loadActiveChatState(),
|
||||||
}, [branchGroups, messages, sessionId]);
|
listChatSessions(),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
storageSessionIdRef.current = loadedState.storageSessionId;
|
||||||
|
sessionIdRef.current = loadedState.sessionId;
|
||||||
|
hydrationCompletedRef.current = true;
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
|
||||||
|
setMessages(loadedState.messages);
|
||||||
|
setSessionTitle(loadedState.title);
|
||||||
|
setSessionId(loadedState.sessionId);
|
||||||
|
setBranchGroups(loadedState.branchGroups);
|
||||||
|
setChatSessions(sessions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to hydrate chat state:", error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsHydrating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void hydrate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrating || !hydrationCompletedRef.current) return;
|
||||||
|
|
||||||
|
const currentHydrationNonce = hydrationNonceRef.current;
|
||||||
|
const persistTimer = window.setTimeout(() => {
|
||||||
|
const state: LoadedChatState = {
|
||||||
|
storageSessionId: storageSessionIdRef.current,
|
||||||
|
title: sessionTitle,
|
||||||
|
messages,
|
||||||
|
sessionId,
|
||||||
|
branchGroups,
|
||||||
|
};
|
||||||
|
|
||||||
|
void saveActiveChatState(state)
|
||||||
|
.then((storageSessionId) => {
|
||||||
|
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
|
storageSessionIdRef.current = storageSessionId;
|
||||||
|
return listChatSessions();
|
||||||
|
})
|
||||||
|
.then((sessions) => {
|
||||||
|
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
|
||||||
|
setChatSessions(sessions);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(persistTimer);
|
||||||
|
};
|
||||||
|
}, [branchGroups, isHydrating, messages, sessionId, sessionTitle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBranchGroups((prev) => {
|
setBranchGroups((prev) => {
|
||||||
@@ -182,7 +246,7 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessage,
|
assistantMessage,
|
||||||
}: PromptRunOptions) => {
|
}: PromptRunOptions) => {
|
||||||
const prompt = rawPrompt.trim();
|
const prompt = rawPrompt.trim();
|
||||||
if (!prompt || isStreaming) return;
|
if (!prompt || isStreaming || isHydrating) return;
|
||||||
|
|
||||||
await cancelPromiseRef.current?.catch(() => undefined);
|
await cancelPromiseRef.current?.catch(() => undefined);
|
||||||
onBeforeSend?.();
|
onBeforeSend?.();
|
||||||
@@ -240,6 +304,11 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId: nextAssistantMessage.id,
|
assistantMessageId: nextAssistantMessage.id,
|
||||||
appendArtifact,
|
appendArtifact,
|
||||||
});
|
});
|
||||||
|
} else if (event.type === "session_title") {
|
||||||
|
const nextTitle = event.title.trim();
|
||||||
|
if (nextTitle) {
|
||||||
|
setSessionTitle(nextTitle);
|
||||||
|
}
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
@@ -321,7 +390,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appendArtifact, isStreaming, messages, onBeforeSend, onToolCall],
|
[appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
|
||||||
);
|
);
|
||||||
|
|
||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
@@ -356,13 +425,115 @@ export const useAgentChatSession = ({
|
|||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}
|
}
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setSessionTitle(undefined);
|
||||||
setBranchGroups([]);
|
setBranchGroups([]);
|
||||||
setBranchTransition(null);
|
setBranchTransition(null);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
|
storageSessionIdRef.current = undefined;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const createSession = useCallback(async () => {
|
||||||
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
const controller = abortRef.current;
|
||||||
|
controller?.abort();
|
||||||
|
setBranchTransition(null);
|
||||||
|
|
||||||
|
const newState = await createEmptyChatSession();
|
||||||
|
const sessions = await listChatSessions();
|
||||||
|
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
storageSessionIdRef.current = newState.storageSessionId;
|
||||||
|
sessionIdRef.current = newState.sessionId;
|
||||||
|
setMessages(newState.messages);
|
||||||
|
setSessionTitle(newState.title);
|
||||||
|
setSessionId(newState.sessionId);
|
||||||
|
setBranchGroups(newState.branchGroups);
|
||||||
|
setChatSessions(sessions);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}, [isHydrating, isStreaming]);
|
||||||
|
|
||||||
|
const switchSession = useCallback(
|
||||||
|
async (nextStorageSessionId: string) => {
|
||||||
|
if (isHydrating || isStreaming || storageSessionIdRef.current === nextStorageSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsHydrating(true);
|
||||||
|
try {
|
||||||
|
const [nextState, sessions] = await Promise.all([
|
||||||
|
loadChatSessionById(nextStorageSessionId),
|
||||||
|
listChatSessions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
storageSessionIdRef.current = nextState.storageSessionId;
|
||||||
|
sessionIdRef.current = nextState.sessionId;
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages(nextState.messages);
|
||||||
|
setSessionTitle(nextState.title);
|
||||||
|
setSessionId(nextState.sessionId);
|
||||||
|
setBranchGroups(nextState.branchGroups);
|
||||||
|
setChatSessions(sessions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to switch chat session:", error);
|
||||||
|
} finally {
|
||||||
|
setIsHydrating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isHydrating, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeSession = useCallback(
|
||||||
|
async (targetStorageSessionId: string) => {
|
||||||
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextActiveSessionId = await deleteChatSession(targetStorageSessionId);
|
||||||
|
const sessions = await listChatSessions();
|
||||||
|
setChatSessions(sessions);
|
||||||
|
|
||||||
|
if (storageSessionIdRef.current !== targetStorageSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextActiveSessionId) {
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
storageSessionIdRef.current = undefined;
|
||||||
|
sessionIdRef.current = undefined;
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages([]);
|
||||||
|
setSessionTitle(undefined);
|
||||||
|
setSessionId(undefined);
|
||||||
|
setBranchGroups([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsHydrating(true);
|
||||||
|
const [nextState, sessionsAfterDelete] = await Promise.all([
|
||||||
|
loadChatSessionById(nextActiveSessionId),
|
||||||
|
listChatSessions(),
|
||||||
|
]);
|
||||||
|
hydrationNonceRef.current += 1;
|
||||||
|
storageSessionIdRef.current = nextState.storageSessionId;
|
||||||
|
sessionIdRef.current = nextState.sessionId;
|
||||||
|
setBranchTransition(null);
|
||||||
|
setMessages(nextState.messages);
|
||||||
|
setSessionTitle(nextState.title);
|
||||||
|
setSessionId(nextState.sessionId);
|
||||||
|
setBranchGroups(nextState.branchGroups);
|
||||||
|
setChatSessions(sessionsAfterDelete);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to delete chat session:", error);
|
||||||
|
} finally {
|
||||||
|
setIsHydrating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isHydrating, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
const sendPrompt = useCallback(
|
const sendPrompt = useCallback(
|
||||||
async (rawPrompt: string) => {
|
async (rawPrompt: string) => {
|
||||||
await runPrompt({ prompt: rawPrompt });
|
await runPrompt({ prompt: rawPrompt });
|
||||||
@@ -371,7 +542,7 @@ export const useAgentChatSession = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const regenerate = useCallback(async () => {
|
const regenerate = useCallback(async () => {
|
||||||
if (isStreaming || messages.length === 0) return;
|
if (isHydrating || isStreaming || messages.length === 0) return;
|
||||||
|
|
||||||
let lastUserIndex = messages.length - 1;
|
let lastUserIndex = messages.length - 1;
|
||||||
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
|
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
|
||||||
@@ -400,11 +571,11 @@ export const useAgentChatSession = ({
|
|||||||
userMessage: nextUserMessage,
|
userMessage: nextUserMessage,
|
||||||
assistantMessage: nextAssistantMessage,
|
assistantMessage: nextAssistantMessage,
|
||||||
});
|
});
|
||||||
}, [isStreaming, messages, runPrompt]);
|
}, [isHydrating, isStreaming, messages, runPrompt]);
|
||||||
|
|
||||||
const editAndResubmit = useCallback(
|
const editAndResubmit = useCallback(
|
||||||
async (messageId: string, newContent: string) => {
|
async (messageId: string, newContent: string) => {
|
||||||
if (isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
const trimmedContent = newContent.trim();
|
const trimmedContent = newContent.trim();
|
||||||
if (!trimmedContent) return;
|
if (!trimmedContent) return;
|
||||||
@@ -483,12 +654,12 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessage: nextAssistantMessage,
|
assistantMessage: nextAssistantMessage,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isStreaming, messages, runPrompt],
|
[isHydrating, isStreaming, messages, runPrompt],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cycleBranch = useCallback(
|
const cycleBranch = useCallback(
|
||||||
(rootMessageId: string, direction: -1 | 1) => {
|
(rootMessageId: string, direction: -1 | 1) => {
|
||||||
if (isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
setBranchGroups((prev) => {
|
setBranchGroups((prev) => {
|
||||||
const next = cloneBranchGroups(prev);
|
const next = cloneBranchGroups(prev);
|
||||||
@@ -519,13 +690,16 @@ export const useAgentChatSession = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isStreaming, messages],
|
[isHydrating, isStreaming, messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
chatSessions,
|
||||||
|
activeStorageSessionId: storageSessionIdRef.current,
|
||||||
branchGroups,
|
branchGroups,
|
||||||
branchTransition,
|
branchTransition,
|
||||||
|
isHydrating,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
sessionId,
|
sessionId,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
@@ -533,6 +707,9 @@ export const useAgentChatSession = ({
|
|||||||
editAndResubmit,
|
editAndResubmit,
|
||||||
cycleBranch,
|
cycleBranch,
|
||||||
abort,
|
abort,
|
||||||
|
createSession,
|
||||||
reset,
|
reset,
|
||||||
|
removeSession,
|
||||||
|
switchSession,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { config } from "@config/config";
|
|||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "token"; sessionId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
| { type: "done"; sessionId: string }
|
| { type: "done"; sessionId: string }
|
||||||
|
| { type: "session_title"; sessionId: string; title: string }
|
||||||
| {
|
| {
|
||||||
type: "progress";
|
type: "progress";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -182,6 +183,12 @@ export const streamAgentChat = async ({
|
|||||||
type: "done",
|
type: "done",
|
||||||
sessionId: parsed.session_id ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
});
|
});
|
||||||
|
} else if (event === "session_title") {
|
||||||
|
onEvent({
|
||||||
|
type: "session_title",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
title: typeof parsed.title === "string" ? parsed.title : "",
|
||||||
|
});
|
||||||
} else if (event === "error") {
|
} else if (event === "error") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
Reference in New Issue
Block a user