重构聊天会话管理,支持会话历史和存储

This commit is contained in:
2026-04-30 15:02:08 +08:00
parent c5b0f43a0d
commit e0e78cd95a
11 changed files with 1247 additions and 221 deletions
+7
View File
@@ -30,6 +30,7 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5",
"next": "^16.1.6",
"next-auth": "^4.24.5",
@@ -15843,6 +15844,12 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+1
View File
@@ -38,6 +38,7 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5",
"next": "^16.1.6",
"next-auth": "^4.24.5",
+7 -4
View File
@@ -25,6 +25,7 @@ import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
type AgentComposerProps = {
input: string;
inputRef: React.RefObject<HTMLInputElement | null>;
isHydrating?: boolean;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
@@ -40,6 +41,7 @@ type AgentComposerProps = {
export const AgentComposer = ({
input,
inputRef,
isHydrating = false,
isStreaming,
isListening,
isSttSupported,
@@ -52,7 +54,7 @@ export const AgentComposer = ({
onPresetSelect,
}: AgentComposerProps) => {
const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming;
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
return (
@@ -160,11 +162,12 @@ export const AgentComposer = ({
onSend();
}
}}
placeholder="描述你的分析目标,或点击上方指令库..."
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating}
InputProps={{
disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
@@ -199,7 +202,7 @@ export const AgentComposer = ({
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
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 }}
/>
<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>
</Box>
</Box>
+120 -107
View File
@@ -7,37 +7,32 @@ import {
Avatar,
Box,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Stack,
Tooltip,
Typography,
alpha,
useTheme,
} 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 HistoryRounded from "@mui/icons-material/HistoryRounded";
type AgentHeaderProps = {
isStreaming: boolean;
menuAnchorEl: HTMLElement | null;
onMenuOpen: (event: React.MouseEvent<HTMLElement>) => void;
onMenuClose: () => void;
isHistoryOpen: boolean;
onHistoryToggle: () => void;
onNewConversation: () => void;
onClose: () => void;
};
export const AgentHeader = ({
isStreaming,
menuAnchorEl,
onMenuOpen,
onMenuClose,
isHistoryOpen,
onHistoryToggle,
onNewConversation,
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
const isMenuOpen = Boolean(menuAnchorEl);
return (
<Box
@@ -55,55 +50,46 @@ export const AgentHeader = ({
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
onClick={onMenuOpen}
aria-label="打开 Agent 菜单"
aria-controls={isMenuOpen ? "global-chatbox-header-menu" : undefined}
aria-expanded={isMenuOpen ? "true" : undefined}
aria-haspopup="menu"
sx={{ p: 0, borderRadius: "50%" }}
>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: alpha("#ffffff", 0.9),
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
width: 44,
height: 44,
border: `2px solid ${alpha("#fff", 0.8)}`,
p: 0.75,
}}
>
<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)}` },
}
}}
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex" }}>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: alpha("#ffffff", 0.9),
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
width: 44,
height: 44,
border: `2px solid ${alpha("#fff", 0.8)}`,
p: 0.75,
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={30}
height={30}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
</Box>
</IconButton>
</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>
</motion.div>
<Box>
<Typography
@@ -124,54 +110,81 @@ export const AgentHeader = ({
</Box>
</Stack>
<Menu
id="global-chatbox-header-menu"
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={onMenuClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
elevation: 8,
sx: {
mt: 1,
minWidth: 180,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
backdropFilter: "blur(12px)",
bgcolor: alpha("#fff", 0.92),
},
},
}}
>
<MenuItem onClick={onNewConversation}>
<ListItemIcon>
<AddCommentRounded fontSize="small" />
</ListItemIcon>
<ListItemText
primary="新建对话"
secondary="清空当前会话"
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 700 } }}
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
/>
</MenuItem>
</Menu>
<Stack direction="row" spacing={1.25} alignItems="center">
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onNewConversation}
aria-label="新建对话"
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: "#00acc1",
borderColor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<EditNoteRounded sx={{ fontSize: 22 }} />
</IconButton>
</motion.div>
</Tooltip>
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }}>
<IconButton
onClick={onClose}
size="small"
aria-label="关闭 Agent"
sx={{
color: "text.primary",
bgcolor: alpha("#fff", 0.54),
"&:hover": { bgcolor: "#fff" },
}}
>
<CloseRounded />
</IconButton>
</motion.div>
<Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}>
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onHistoryToggle}
aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"}
sx={{
width: 36,
height: 36,
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)}`,
boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`,
"&:hover": {
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>
);
};
+408
View File
@@ -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>
</>
);
};
+109 -47
View File
@@ -5,6 +5,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import { AgentComposer } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader";
import { AgentHistoryPanel } from "./AgentHistoryPanel";
import { AgentWorkspace } from "./AgentWorkspace";
import { Blob } from "./GlobalChatbox.parts";
import type { Props } from "./GlobalChatbox.types";
@@ -17,7 +18,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [input, setInput] = useState("");
const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -47,15 +48,20 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const handleToolCall = useAgentToolActions();
const {
messages,
chatSessions,
activeStorageSessionId,
branchGroups,
branchTransition,
isHydrating,
isStreaming,
sendPrompt,
regenerate,
editAndResubmit,
cycleBranch,
abort,
reset,
createSession,
removeSession,
switchSession,
} = useAgentChatSession({
onToolCall: handleToolCall,
onBeforeSend: stopListening,
@@ -88,24 +94,34 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
}, 0);
}, []);
const handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => {
setHeaderMenuAnchorEl(event.currentTarget);
}, []);
const handleHeaderMenuClose = useCallback(() => {
setHeaderMenuAnchorEl(null);
}, []);
const handleNewConversation = useCallback(() => {
handleStopSpeech();
stopListening();
reset();
void createSession();
setInput("");
handleHeaderMenuClose();
window.setTimeout(() => {
inputRef.current?.focus();
}, 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) => {
event.preventDefault();
@@ -198,45 +214,91 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentHeader
isStreaming={isStreaming}
menuAnchorEl={headerMenuAnchorEl}
onMenuOpen={handleHeaderMenuOpen}
onMenuClose={handleHeaderMenuClose}
isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle}
onNewConversation={handleNewConversation}
onClose={onClose}
/>
<AgentWorkspace
messages={messages}
branchGroups={branchGroups}
branchTransition={branchTransition}
isStreaming={isStreaming}
bottomRef={bottomRef}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={handleSpeak}
onPauseSpeech={handlePauseSpeech}
onResumeSpeech={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={regenerate}
onEditResubmit={editAndResubmit}
onCycleBranch={cycleBranch}
/>
<Box sx={{ flex: 1, display: "flex", minHeight: 0, position: "relative", overflow: "hidden" }}>
<Box
onClick={() => setIsHistoryOpen(false)}
sx={{
position: "absolute",
inset: 0,
bgcolor: alpha("#000", 0.05),
backdropFilter: "blur(2px)",
opacity: isHistoryOpen ? 1 : 0,
pointerEvents: isHistoryOpen ? "auto" : "none",
transition: "opacity 0.3s ease",
zIndex: 10,
}}
/>
<Box
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
input={input}
inputRef={inputRef}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
presets={PRESET_PROMPTS}
onInputChange={setInput}
onSend={handleSend}
onAbort={abort}
onStartListening={startListening}
onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
/>
<Box sx={{ flex: 1, display: "flex", minWidth: 0, flexDirection: "column" }}>
<AgentWorkspace
messages={messages}
branchGroups={branchGroups}
branchTransition={branchTransition}
isStreaming={isStreaming}
bottomRef={bottomRef}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={handleSpeak}
onPauseSpeech={handlePauseSpeech}
onResumeSpeech={handleResumeSpeech}
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>
</Drawer>
);
+32 -1
View File
@@ -61,8 +61,39 @@ export type Props = {
export type SpeechState = "idle" | "playing" | "paused";
export type PersistedChatState = {
export type LegacyPersistedChatState = {
messages: Message[];
sessionId?: string;
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 -30
View File
@@ -1,8 +1,7 @@
import type { BranchGroup, Message, PersistedChatState } from "./GlobalChatbox.types";
import type { BranchGroup, Message } from "./GlobalChatbox.types";
export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
export const PRESET_PROMPTS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
@@ -29,34 +28,6 @@ export const stripMarkdown = (md: string): string =>
.replace(/<[^>]+>/g, "")
.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 => ({
...message,
progress: message.progress ? [...message.progress] : undefined,
+346
View File
@@ -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;
};
+209 -32
View File
@@ -9,16 +9,23 @@ import type {
BranchGroup,
BranchTransition,
ChatProgress,
ChatSessionSummary,
LoadedChatState,
Message,
PersistedChatState,
} from "../GlobalChatbox.types";
import {
CHAT_STORAGE_KEY,
cloneBranchGroups,
cloneMessages,
createId,
getInitialChatState,
} from "../GlobalChatbox.utils";
import {
createEmptyChatSession,
deleteChatSession,
listChatSessions,
loadActiveChatState,
loadChatSessionById,
saveActiveChatState,
} from "../chatStorage";
type UseAgentChatSessionOptions = {
onToolCall: (
@@ -88,24 +95,20 @@ export const useAgentChatSession = ({
onToolCall,
onBeforeSend,
}: UseAgentChatSessionOptions) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
initialChatStateRef.current = getInitialChatState();
}
const storageSessionIdRef = useRef<string | undefined>(undefined);
const hydrationCompletedRef = useRef(false);
const hydrationNonceRef = useRef(0);
const [messages, setMessages] = useState<Message[]>(
initialChatStateRef.current.messages,
);
const [sessionId, setSessionId] = useState<string | undefined>(
initialChatStateRef.current.sessionId,
);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>(
initialChatStateRef.current.branchGroups ?? [],
);
const [messages, setMessages] = useState<Message[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
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);
useEffect(() => {
@@ -113,13 +116,74 @@ export const useAgentChatSession = ({
}, [sessionId]);
useEffect(() => {
const state: PersistedChatState = { messages, sessionId, branchGroups };
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
}
}, [branchGroups, messages, sessionId]);
let cancelled = false;
const hydrate = async () => {
try {
const [loadedState, sessions] = await Promise.all([
loadActiveChatState(),
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(() => {
setBranchGroups((prev) => {
@@ -182,7 +246,7 @@ export const useAgentChatSession = ({
assistantMessage,
}: PromptRunOptions) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming) return;
if (!prompt || isStreaming || isHydrating) return;
await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.();
@@ -240,6 +304,11 @@ export const useAgentChatSession = ({
assistantMessageId: nextAssistantMessage.id,
appendArtifact,
});
} else if (event.type === "session_title") {
const nextTitle = event.title.trim();
if (nextTitle) {
setSessionTitle(nextTitle);
}
} else if (event.type === "done") {
setMessages((prev) =>
prev.map((message) => {
@@ -321,7 +390,7 @@ export const useAgentChatSession = ({
setIsStreaming(false);
}
},
[appendArtifact, isStreaming, messages, onBeforeSend, onToolCall],
[appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
);
const abort = useCallback(() => {
@@ -356,13 +425,115 @@ export const useAgentChatSession = ({
cancelPromiseRef.current = trackedCancelPromise;
}
setMessages([]);
setSessionTitle(undefined);
setBranchGroups([]);
setBranchTransition(null);
setSessionId(undefined);
sessionIdRef.current = undefined;
storageSessionIdRef.current = undefined;
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(
async (rawPrompt: string) => {
await runPrompt({ prompt: rawPrompt });
@@ -371,7 +542,7 @@ export const useAgentChatSession = ({
);
const regenerate = useCallback(async () => {
if (isStreaming || messages.length === 0) return;
if (isHydrating || isStreaming || messages.length === 0) return;
let lastUserIndex = messages.length - 1;
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
@@ -400,11 +571,11 @@ export const useAgentChatSession = ({
userMessage: nextUserMessage,
assistantMessage: nextAssistantMessage,
});
}, [isStreaming, messages, runPrompt]);
}, [isHydrating, isStreaming, messages, runPrompt]);
const editAndResubmit = useCallback(
async (messageId: string, newContent: string) => {
if (isStreaming) return;
if (isHydrating || isStreaming) return;
const trimmedContent = newContent.trim();
if (!trimmedContent) return;
@@ -483,12 +654,12 @@ export const useAgentChatSession = ({
assistantMessage: nextAssistantMessage,
});
},
[isStreaming, messages, runPrompt],
[isHydrating, isStreaming, messages, runPrompt],
);
const cycleBranch = useCallback(
(rootMessageId: string, direction: -1 | 1) => {
if (isStreaming) return;
if (isHydrating || isStreaming) return;
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
@@ -519,13 +690,16 @@ export const useAgentChatSession = ({
return next;
});
},
[isStreaming, messages],
[isHydrating, isStreaming, messages],
);
return {
messages,
chatSessions,
activeStorageSessionId: storageSessionIdRef.current,
branchGroups,
branchTransition,
isHydrating,
isStreaming,
sessionId,
sendPrompt,
@@ -533,6 +707,9 @@ export const useAgentChatSession = ({
editAndResubmit,
cycleBranch,
abort,
createSession,
reset,
removeSession,
switchSession,
};
};
+7
View File
@@ -4,6 +4,7 @@ import { config } from "@config/config";
export type StreamEvent =
| { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string }
| { type: "session_title"; sessionId: string; title: string }
| {
type: "progress";
sessionId: string;
@@ -182,6 +183,12 @@ export const streamAgentChat = async ({
type: "done",
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") {
onEvent({
type: "error",