重构聊天会话管理,支持会话历史和存储
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { 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>
|
||||
);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user