重构 Agent 聊天,支持分支管理与消息克隆

This commit is contained in:
2026-04-30 13:05:45 +08:00
parent e5ca9e24aa
commit 36d1a8d6ea
20 changed files with 1722 additions and 586 deletions
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+137 -108
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import Image from "next/image";
import React from "react"; import React from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { import {
Avatar,
Box, Box,
Chip, Chip,
Collapse, Collapse,
@@ -15,12 +15,12 @@ import {
alpha, alpha,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import SendRounded from "@mui/icons-material/SendRounded"; import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded"; import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded"; import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded"; import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
type AgentComposerProps = { type AgentComposerProps = {
input: string; input: string;
@@ -56,30 +56,41 @@ export const AgentComposer = ({
const [isPresetOpen, setIsPresetOpen] = React.useState(false); const [isPresetOpen, setIsPresetOpen] = React.useState(false);
return ( return (
<Box sx={{ px: 3, pb: 3, pt: 1.5, zIndex: 10 }}> <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
<Paper <Paper
elevation={0} elevation={isPresetOpen ? 4 : 0}
sx={{ sx={{
mb: isPresetOpen ? 1.25 : 0.8, mb: 1.5,
px: 1.2, px: 1.5,
py: 0.85, py: 1,
borderRadius: 3.5, borderRadius: 4,
bgcolor: alpha("#fff", 0.72), bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, border: `1px solid ${alpha("#fff", 0.5)}`,
backdropFilter: "blur(12px)", backdropFilter: "blur(24px)",
boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none",
transition: "all 0.3s ease",
}} }}
> >
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} /> <Image
<Typography variant="caption" color="text.secondary" fontWeight={800}> src="/ai-agent.svg"
alt="TJWater Agent"
</Typography> width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
<IconButton <IconButton
size="small" size="small"
onClick={() => setIsPresetOpen((value) => !value)} onClick={() => setIsPresetOpen((value) => !value)}
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"} aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
sx={{ width: 26, height: 26, color: "text.secondary" }} sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }}
> >
{isPresetOpen ? ( {isPresetOpen ? (
<KeyboardArrowDownRounded fontSize="small" /> <KeyboardArrowDownRounded fontSize="small" />
@@ -89,60 +100,56 @@ export const AgentComposer = ({
</IconButton> </IconButton>
</Stack> </Stack>
<Collapse in={isPresetOpen} timeout="auto" unmountOnExit> <Collapse in={isPresetOpen} timeout="auto" unmountOnExit>
<Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" sx={{ pt: 0.9 }}> <Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}>
{presets.map((prompt) => ( <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip {presets.map((prompt) => (
key={prompt} <Chip
label={prompt.replace(/[。.]$/, "")} key={prompt}
size="small" label={prompt.replace(/[。.]$/, "")}
clickable size="medium"
onClick={() => { clickable
onPresetSelect(prompt); onClick={() => {
setIsPresetOpen(false); onPresetSelect(prompt);
}} setIsPresetOpen(false);
sx={{ }}
maxWidth: "100%", sx={{
height: 28, height: 32,
borderRadius: 2, borderRadius: "16px",
bgcolor: alpha(theme.palette.primary.main, 0.07), bgcolor: alpha("#fff", 0.7),
color: "text.primary", border: `1px solid ${alpha("#00acc1", 0.15)}`,
fontWeight: 600, color: "text.primary",
"& .MuiChip-label": { fontWeight: 600,
overflow: "hidden", fontSize: '0.85rem',
textOverflow: "ellipsis", boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`,
}, backdropFilter: "blur(10px)",
}} "&:hover": {
/> bgcolor: alpha("#fff", 0.95),
))} boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`,
</Stack> borderColor: alpha("#00acc1", 0.4),
color: "#00acc1"
}
}}
/>
))}
</Box>
</Box>
</Collapse> </Collapse>
</Paper> </Paper>
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}> <motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
<Stack <Paper
direction="row"
alignItems="center"
component={Paper}
elevation={12} elevation={12}
sx={{ sx={{
p: "6px 8px", display: "flex",
flexDirection: "column",
p: 1.5,
borderRadius: 5, borderRadius: 5,
bgcolor: alpha("#fff", 0.92), bgcolor: alpha("#ffffff", 0.75),
backdropFilter: "blur(10px)", backdropFilter: "blur(40px)",
border: `1px solid ${alpha("#fff", 0.62)}`, border: `1px solid ${alpha("#ffffff", 0.9)}`,
boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`, boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
}} }}
> >
<Avatar
sx={{
width: 28,
height: 28,
ml: 0.5,
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`,
}}
>
<AutoAwesome sx={{ fontSize: 16, color: "#fff" }} />
</Avatar>
<TextField <TextField
inputRef={inputRef} inputRef={inputRef}
value={input} value={input}
@@ -153,62 +160,70 @@ export const AgentComposer = ({
onSend(); onSend();
} }
}} }}
placeholder="描述你的管网分析目标..." placeholder="描述你的分析目标,或点击上方指令库..."
fullWidth fullWidth
multiline multiline
maxRows={4} maxRows={5}
variant="standard" variant="standard"
InputProps={{ InputProps={{
disableUnderline: true, disableUnderline: true,
sx: { px: 2, py: 1.35, fontSize: "0.98rem" }, sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
}} }}
/> />
{isSttSupported ? ( <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", mr: 0.5 }}> <Stack direction="row" spacing={0.5} alignItems="center">
{isListening ? ( <IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
<motion.div <AttachFileRounded fontSize="small" />
animate={{ scale: [1, 1.14, 1] }} </IconButton>
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }} {isSttSupported ? (
> isListening ? (
<IconButton <motion.div
onClick={onStopListening} animate={{ scale: [1, 1.14, 1] }}
aria-label="停止语音输入" transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.1),
width: 42,
height: 42,
}}
> >
<MicRounded /> <IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton> </IconButton>
</motion.div> )
) : ( ) : null}
<IconButton </Stack>
onClick={onStartListening}
disabled={isStreaming}
aria-label="语音输入"
sx={{ color: "text.secondary", width: 42, height: 42 }}
>
<MicRounded />
</IconButton>
)}
</Box>
) : null}
<Box sx={{ pr: 0.5 }}>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{isStreaming ? ( {isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton <IconButton
onClick={onAbort} onClick={onAbort}
aria-label="停止生成" aria-label="停止生成"
size="small"
sx={{ sx={{
bgcolor: alpha(theme.palette.error.main, 0.1), bgcolor: "error.main",
color: "error.main", color: "#fff",
width: 42, width: 40,
height: 42, height: 40,
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
"&:hover": { bgcolor: "error.dark" },
}} }}
> >
<StopRounded /> <StopRounded />
@@ -220,12 +235,14 @@ export const AgentComposer = ({
disabled={!canSend} disabled={!canSend}
onClick={onSend} onClick={onSend}
aria-label="发送" aria-label="发送"
size="small"
sx={{ sx={{
bgcolor: canSend ? "primary.main" : "action.disabledBackground", bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5),
color: "#fff", color: canSend ? "#fff" : "action.disabled",
width: 42, width: 40,
height: 42, height: 40,
"&:hover": { bgcolor: canSend ? "primary.dark" : "action.disabledBackground" }, boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none",
"&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) },
}} }}
> >
<SendRounded sx={{ ml: 0.35 }} /> <SendRounded sx={{ ml: 0.35 }} />
@@ -233,9 +250,21 @@ export const AgentComposer = ({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</Box> </Stack>
</Stack> </Paper>
</motion.div> </motion.div>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}>
<Image
src="/deepseek-logo.svg"
alt="DeepSeek"
width={14}
height={14}
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
</Typography>
</Box>
</Box> </Box>
); );
}; };
+37 -18
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import Image from "next/image";
import React from "react"; import React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
@@ -15,7 +16,6 @@ import {
alpha, alpha,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
import CloseRounded from "@mui/icons-material/CloseRounded"; import CloseRounded from "@mui/icons-material/CloseRounded";
@@ -48,10 +48,14 @@ export const AgentHeader = ({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
backdropFilter: "blur(20px)",
borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
background: `linear-gradient(to bottom, ${alpha("#fff", 0.4)}, ${alpha("#fff", 0.1)})`,
boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`,
}} }}
> >
<Stack direction="row" alignItems="center" spacing={2}> <Stack direction="row" alignItems="center" spacing={2}>
<motion.div whileHover={{ rotate: 10, scale: 1.08 }} whileTap={{ scale: 0.95 }}> <motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton <IconButton
onClick={onMenuOpen} onClick={onMenuOpen}
aria-label="打开 Agent 菜单" aria-label="打开 Agent 菜单"
@@ -63,24 +67,39 @@ export const AgentHeader = ({
<Box sx={{ position: "relative" }}> <Box sx={{ position: "relative" }}>
<Avatar <Avatar
sx={{ sx={{
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.primary.main})`, background: alpha("#ffffff", 0.9),
boxShadow: `0 8px 20px ${alpha(theme.palette.primary.main, 0.4)}`, boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
width: 48, width: 44,
height: 48, height: 44,
border: `2px solid ${alpha("#fff", 0.8)}`,
p: 0.75,
}} }}
> >
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} /> <Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={30}
height={30}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
</Avatar> </Avatar>
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: 2, bottom: -2,
right: 2, right: -2,
width: 12, width: 14,
height: 12, height: 14,
bgcolor: isStreaming ? "warning.main" : "success.main", bgcolor: isStreaming ? "#ff9800" : "#00e676",
borderRadius: "50%", borderRadius: "50%",
border: "2px solid #fff", 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> </Box>
@@ -89,18 +108,18 @@ export const AgentHeader = ({
<Box> <Box>
<Typography <Typography
variant="h6" variant="h6"
fontWeight={900} fontWeight={800}
sx={{ sx={{
background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, background: `linear-gradient(90deg, #01579b, #00838f)`,
backgroundClip: "text", backgroundClip: "text",
color: "transparent", color: "transparent",
letterSpacing: -0.5, letterSpacing: -0.3,
}} }}
> >
TJWater Agent TJWater Agent
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" fontWeight={600}> <Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming ? "正在分析管网任务" : "管网分析工作台"} {isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"}
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
+192 -112
View File
@@ -3,8 +3,6 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
Box, Box,
Button,
Chip,
Collapse, Collapse,
LinearProgress, LinearProgress,
Stack, Stack,
@@ -20,6 +18,7 @@ import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import TaskAltRounded from "@mui/icons-material/TaskAltRounded"; import TaskAltRounded from "@mui/icons-material/TaskAltRounded";
import PsychologyRounded from "@mui/icons-material/PsychologyRounded"; import PsychologyRounded from "@mui/icons-material/PsychologyRounded";
import SyncRounded from "@mui/icons-material/SyncRounded"; import SyncRounded from "@mui/icons-material/SyncRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import type { ChatProgress } from "./GlobalChatbox.types"; import type { ChatProgress } from "./GlobalChatbox.types";
@@ -27,12 +26,12 @@ const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const sx = { fontSize: 16 }; const sx = { fontSize: 16 };
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />; if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />; if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />;
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "primary.main" }} />; if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />;
if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />; if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />;
if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />; if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />;
if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />; if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />;
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "primary.main" }} />; if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />;
return <AutoAwesome sx={{ ...sx, color: "primary.main" }} />; return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
}; };
const formatToolTitle = (item: ChatProgress) => { const formatToolTitle = (item: ChatProgress) => {
@@ -45,143 +44,224 @@ const formatToolTitle = (item: ChatProgress) => {
return item.title; return item.title;
}; };
export const AgentProgressTimeline = ({ progress }: { progress: ChatProgress[] }) => { export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
const theme = useTheme(); const theme = useTheme();
const hasComplete = progress.some(
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
); );
const hasRunning =
!hasComplete && progress.some((item) => item.status === "running"); // 修正状态判断:如果外部标记为中断,或者没有完成标记
const hasError = progress.some((item) => item.status === "error"); const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
const [expanded, setExpanded] = useState(hasRunning); const hasError = isAborted || progress.some((item) => item.status === "error");
// 展开状态逻辑:默认折叠,保持界面整洁
const [expanded, setExpanded] = useState(false);
const summary = useMemo(() => { const summary = useMemo(() => {
const completedCount = progress.filter((item) => item.status === "completed").length; if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`;
const runningItem = hasComplete if (isOverallComplete) {
? undefined return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`;
: [...progress].reverse().find((item) => item.status === "running"); }
if (runningItem) return runningItem.title; const runningItem = [...progress].reverse().find((item) => item.status === "running");
if (hasError) return "过程存在异常"; if (runningItem) return `${runningItem.title}...`;
if (hasComplete) return `已完成 ${progress.length}`; if (hasError) return "过程异常,尝试恢复中...";
return `完成 ${completedCount || progress.length}`; return `执行 ${progress.length}`;
}, [hasComplete, hasError, progress]); }, [isOverallComplete, hasError, progress, isAborted]);
// 根据整体状态决定顶部卡片的颜色主题
const statusColor = isOverallComplete
? "#4caf50" // Success Green
: isAborted || (hasError && !hasRunning)
? theme.palette.error.main // Error Red
: "#00acc1"; // Primary Cyan
// 默认折叠:只显示最新的三条
const visibleCount = 3;
const isCollapsible = progress.length > visibleCount;
return ( return (
<Box <Box
sx={{ sx={{
borderRadius: 3, borderRadius: 4,
bgcolor: alpha(theme.palette.primary.main, 0.045), bgcolor: alpha(statusColor, 0.04),
border: `1px solid ${alpha(theme.palette.primary.main, 0.14)}`, border: `1px solid ${alpha(statusColor, 0.15)}`,
backdropFilter: "blur(12px)",
overflow: "hidden", overflow: "hidden",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(statusColor, 0.06),
borderColor: alpha(statusColor, 0.25),
}
}} }}
> >
<Stack <Stack
direction="row" direction="row"
spacing={1} spacing={1.5}
alignItems="center" alignItems="center"
sx={{ px: 1.5, py: 1.1 }} onClick={() => setExpanded(!expanded)}
sx={{
px: 2,
py: 1.25,
cursor: "pointer",
userSelect: "none"
}}
> >
<AutoAwesome sx={{ fontSize: 17, color: "primary.main" }} /> {isOverallComplete ? (
<Typography variant="caption" fontWeight={800} color="text.primary"> <TaskAltRounded sx={{ fontSize: 18, color: statusColor }} />
Agent ) : hasRunning ? (
<AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} />
) : hasError ? (
<ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} />
) : (
<AutoAwesome sx={{ fontSize: 18, color: statusColor }} />
)}
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
Agent : {summary}
</Typography> </Typography>
<Chip
size="small" <KeyboardArrowDownRounded
label={summary} sx={{
color={hasError ? "error" : hasRunning ? "primary" : "success"} fontSize: 20,
variant="outlined" color: "text.secondary",
sx={{ height: 22, fontSize: "0.68rem", maxWidth: 180 }} transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
}}
/> />
<Box sx={{ flex: 1 }} />
<Button
size="small"
onClick={() => setExpanded((value) => !value)}
sx={{ minWidth: 0, px: 0.75, fontSize: "0.72rem" }}
>
{expanded ? "收起" : "展开"}
</Button>
</Stack> </Stack>
{hasRunning ? <LinearProgress sx={{ height: 3 }} /> : null}
<Collapse in={expanded} timeout="auto"> {hasRunning && !expanded ? (
<Stack spacing={1} sx={{ px: 1.5, pb: 1.35 }}> <LinearProgress
{progress.map((item, index) => ( sx={{
<Stack key={item.id} direction="row" spacing={1} alignItems="stretch"> height: 2,
<Box bgcolor: "transparent",
sx={{ "& .MuiLinearProgress-bar": { bgcolor: statusColor }
position: "relative", }}
width: 18, />
display: "flex", ) : null}
justifyContent: "center",
flexShrink: 0, <Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}>
pt: 0.1, <Box>
}} {hasRunning ? (
> <LinearProgress
{index < progress.length - 1 ? ( sx={{
<Box height: 1,
aria-hidden bgcolor: alpha(statusColor, 0.1),
sx={{ "& .MuiLinearProgress-bar": { bgcolor: statusColor }
position: "absolute", }}
top: 18, />
bottom: -10, ) : (
left: "50%", <Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} />
width: 2, )}
transform: "translateX(-50%)", <Stack spacing={0} sx={{ px: 2, py: 1.5 }}>
borderRadius: 99, {progress.map((item, index) => {
bgcolor: alpha( const isLast = index === progress.length - 1;
item.status === "error" const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
? theme.palette.error.main
: theme.palette.primary.main, const itemColor = isAborted && isLast
item.status === "completed" ? 0.22 : 0.36, ? theme.palette.error.main
), : item.status === "error"
}} ? theme.palette.error.main
/> : item.status === "completed"
) : null} ? "#4caf50"
: "#00acc1";
const content = (
<Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch">
<Box <Box
sx={{ sx={{
position: "relative", position: "relative",
zIndex: 1, width: 20,
width: 18,
height: 18,
borderRadius: "50%",
bgcolor: alpha("#fff", 0.92),
display: "flex", display: "flex",
alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0,
pt: 0.3,
}} }}
> >
{phaseIcon( {!isLast ? (
item.phase, <Box
hasComplete && item.status === "running" aria-hidden
? "completed" sx={{
: item.status, position: "absolute",
)} top: 22,
</Box> bottom: -6,
</Box> left: "50%",
<Box sx={{ minWidth: 0, flex: 1 }}> width: 2,
<Typography variant="caption" color="text.primary" fontWeight={700}> transform: "translateX(-50%)",
{item.phase === "tool" ? formatToolTitle(item) : item.title} borderRadius: 2,
</Typography> bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4),
{item.detail ? ( }}
<Typography />
variant="caption" ) : null}
component="pre" <Box
color="text.secondary"
sx={{ sx={{
display: "block", position: "relative",
mt: 0.25, zIndex: 1,
m: 0, width: 20,
whiteSpace: "pre-wrap", height: 20,
fontFamily: "inherit", borderRadius: "50%",
fontSize: "0.7rem", bgcolor: alpha(theme.palette.background.paper, 0.9),
boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}} }}
> >
{item.detail} {phaseIcon(
item.phase,
isAborted && isLast ? "error" :
isOverallComplete && item.status === "running"
? "completed"
: item.status,
)}
</Box>
</Box>
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
{item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography> </Typography>
) : null}
</Box> {item.detail && (
</Stack> <Collapse in={expanded || isLast} timeout="auto">
))} <Typography
</Stack> variant="caption"
component="div"
sx={{
mt: 0.5,
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(itemColor, 0.05),
border: `1px solid ${alpha(itemColor, 0.1)}`,
color: "text.secondary",
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono, monospace)",
fontSize: "0.7rem",
lineHeight: 1.5,
wordBreak: "break-all",
}}
>
{item.detail}
</Typography>
</Collapse>
)}
</Box>
</Stack>
);
if (isHiddenWhenCollapsed) {
return (
<Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}>
{content}
</Collapse>
);
}
return content;
})}
</Stack>
</Box>
</Collapse> </Collapse>
</Box> </Box>
); );
+394 -141
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
import Image from "next/image";
import React from "react"; import React from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { import {
Avatar, Avatar,
Box, Box,
Button,
IconButton, IconButton,
Paper, Paper,
Stack, Stack,
@@ -14,35 +16,44 @@ import {
alpha, alpha,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome"; import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded"; import RefreshRounded from "@mui/icons-material/RefreshRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded"; import EditRounded from "@mui/icons-material/EditRounded";
import PauseRounded from "@mui/icons-material/PauseRounded"; import CloseRounded from "@mui/icons-material/CloseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded";
import StopRounded from "@mui/icons-material/StopRounded"; import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded";
import { AgentArtifactPanel } from "./AgentArtifactPanel";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { import {
parseAssistantMessageSections, parseAssistantMessageSections,
parseContentWithToolCalls, parseContentWithToolCalls,
type ContentSegment, type ContentSegment,
} from "./chatMessageSections"; } from "./chatMessageSections";
import markdownStyles from "./GlobalChatboxMarkdown.module.css"; import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types"; import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils"; import { stripMarkdown } from "./GlobalChatbox.utils";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { AgentArtifactPanel } from "./AgentArtifactPanel";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import VolumeUpRounded from "@mui/icons-material/VolumeUpRounded";
import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import SendRounded from "@mui/icons-material/SendRounded";
type AgentTurnProps = { type AgentTurnProps = {
message: Message; message: Message;
branchState?: BranchState;
messageSpeechState: SpeechState; messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (messageId: string, text: string) => void;
onPause: () => void; onPause: () => void;
onResume: () => void; onResume: () => void;
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
}; };
const MarkdownBlock = ({ children }: { children: string }) => ( const MarkdownBlock = ({ children }: { children: string }) => (
@@ -54,16 +65,25 @@ const MarkdownBlock = ({ children }: { children: string }) => (
export const AgentTurn = React.memo( export const AgentTurn = React.memo(
({ ({
message, message,
branchState,
messageSpeechState, messageSpeechState,
onSpeak, onSpeak,
onPause, onPause,
onResume, onResume,
onStopSpeech, onStopSpeech,
isTtsSupported, isTtsSupported,
onRegenerate,
onEditResubmit,
onCycleBranch,
}: AgentTurnProps) => { }: AgentTurnProps) => {
const theme = useTheme(); const theme = useTheme();
const isUser = message.role === "user"; const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError); const isErrorMessage = Boolean(message.isError);
const [isHovered, setIsHovered] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(false);
const [editDraft, setEditDraft] = React.useState(message.content);
const rootMessageId = message.branchRootId ?? message.id;
const parsedAssistantSections = const parsedAssistantSections =
!isUser && !isErrorMessage !isUser && !isErrorMessage
? parseAssistantMessageSections(message.content) ? parseAssistantMessageSections(message.content)
@@ -73,7 +93,7 @@ export const AgentTurn = React.memo(
!isUser && !isErrorMessage !isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments ? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }]; : [{ type: "text", content: answerContent }];
if (isUser) { if (isUser) {
return ( return (
<motion.div <motion.div
@@ -81,34 +101,189 @@ export const AgentTurn = React.memo(
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8 }} exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }} transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{ alignSelf: "flex-end", maxWidth: "86%" }} style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
> >
<Paper {isEditing ? (
elevation={8} <Paper
sx={{ elevation={12}
p: 2, sx={{
borderRadius: 4, p: 1.5,
borderBottomRightRadius: 1.5, borderRadius: 5,
color: "#fff", bgcolor: alpha("#ffffff", 0.75),
background: `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`, backdropFilter: "blur(40px)",
boxShadow: `0 10px 28px -8px ${alpha(theme.palette.primary.main, 0.5)}`, border: `1px solid ${alpha("#ffffff", 0.9)}`,
"--chat-md-text": alpha("#fff", 0.96), boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
"--chat-md-heading": "#fff", minWidth: { xs: 260, sm: 320, md: 400 },
"--chat-md-link": "#E3F2FD", maxWidth: "100%",
"--chat-md-link-hover": "#fff", }}
"--chat-md-inline-code-bg": "rgba(255,255,255,0.2)", >
"--chat-md-inline-code-border": alpha("#fff", 0.16), <Box component="textarea"
"--chat-md-inline-code-text": "#fff", autoFocus
"--chat-md-pre-bg": "rgba(11, 18, 32, 0.56)", value={editDraft}
"--chat-md-pre-border": alpha("#fff", 0.12), onChange={(e) => setEditDraft(e.target.value)}
"--chat-md-pre-text": "#F8FAFC", onKeyDown={(e) => {
"--chat-md-quote-border": alpha("#fff", 0.5), if (e.key === "Enter" && !e.shiftKey) {
"--chat-md-quote-bg": alpha("#fff", 0.08), e.preventDefault();
"--chat-md-quote-text": alpha("#fff", 0.9), if (editDraft.trim() !== message.content) {
}} onEditResubmit(message.id, editDraft);
> }
<MarkdownBlock>{message.content}</MarkdownBlock> setIsEditing(false);
</Paper> } else if (e.key === "Escape") {
setEditDraft(message.content);
setIsEditing(false);
}
}}
sx={{
width: "100%",
minHeight: 60,
bgcolor: "transparent",
border: "none",
outline: "none",
resize: "none",
fontFamily: "inherit",
fontSize: "1rem",
color: "text.primary",
lineHeight: 1.6,
}}
/>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
<IconButton
size="small"
aria-label="取消"
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
sx={{
bgcolor: alpha("#000", 0.05),
color: "text.secondary",
width: 34, height: 34,
"&:hover": { bgcolor: alpha("#000", 0.1) }
}}
>
<CloseRounded fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="发送修改"
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
onClick={() => {
onEditResubmit(message.id, editDraft);
setIsEditing(false);
}}
sx={{
bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1),
color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled",
width: 34, height: 34,
boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none",
"&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) }
}}
>
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
</IconButton>
</Stack>
</Paper>
) : (
<>
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 5,
borderBottomRightRadius: 2,
color: "#fff",
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
backdropFilter: "blur(10px)",
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#e0f7fa",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
"--chat-md-inline-code-border": alpha("#fff", 0.1),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
"--chat-md-pre-border": alpha("#fff", 0.1),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.4),
"--chat-md-quote-bg": alpha("#fff", 0.05),
"--chat-md-quote-text": alpha("#fff", 0.8),
}}
>
<MarkdownBlock>{message.content}</MarkdownBlock>
<AnimatePresence>
{isHovered && !isEditing && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
>
<IconButton
size="small"
onClick={() => { setIsEditing(true); setEditDraft(message.content); }}
aria-label="编辑提问"
sx={{
width: 26,
height: 26,
bgcolor: alpha("#fff", 0.9),
color: "#00acc1",
boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`,
"&:hover": { bgcolor: "#fff", color: "#00838f" }
}}
>
<EditRounded sx={{ fontSize: 14 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Paper>
{branchState && branchState.total > 1 ? (
<Stack
direction="row"
justifyContent="flex-end"
sx={{ mt: 0.5, mr: 0.5 }}
>
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 0.5,
py: 0.25,
borderRadius: 4,
bgcolor: alpha("#000", 0.04),
backdropFilter: "blur(4px)",
border: `1px solid ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="上一分支"
onClick={() => onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronLeftRounded sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
{branchState.activeIndex + 1} / {branchState.total}
</Typography>
<IconButton
size="small"
aria-label="下一分支"
onClick={() => onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronRightRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</Stack>
) : null}
</>
)}
</motion.div> </motion.div>
); );
} }
@@ -119,24 +294,30 @@ export const AgentTurn = React.memo(
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }} exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }} transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ width: "100%" }} style={{ width: "100%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
> >
<Stack direction="row" spacing={1.25} alignItems="flex-start"> <Stack direction="row" spacing={1.5} alignItems="flex-start">
<Avatar <Avatar
sx={{ sx={{
width: 32, width: 34,
height: 32, height: 34,
bgcolor: isErrorMessage background: alpha("#ffffff", 0.9),
? alpha(theme.palette.error.main, 0.12) boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`,
: alpha(theme.palette.secondary.main, 0.12), border: `1.5px solid ${alpha("#fff", 0.8)}`,
color: "#00acc1",
mt: 0.25, mt: 0.25,
p: 0.5,
}} }}
> >
{isErrorMessage ? ( <Image
<ErrorOutlineRounded sx={{ fontSize: 17, color: "error.main" }} /> src="/ai-agent.svg"
) : ( alt="TJWater Agent"
<AutoAwesome sx={{ fontSize: 17, color: "secondary.main" }} /> width={18}
)} height={18}
style={{ objectFit: "contain" }}
/>
</Avatar> </Avatar>
<Paper <Paper
@@ -144,67 +325,45 @@ export const AgentTurn = React.memo(
sx={{ sx={{
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
p: 1.5, p: 2,
borderRadius: 4, borderRadius: 5,
bgcolor: alpha("#fff", 0.84), bgcolor: alpha("#ffffff", 0.65),
border: `1px solid ${alpha( border: `1px solid ${alpha("#fff", 0.8)}`,
isErrorMessage ? theme.palette.error.main : theme.palette.divider, boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`,
isErrorMessage ? 0.34 : 0.16, backdropFilter: "blur(20px)",
)}`, position: "relative",
boxShadow: `0 14px 40px -24px ${alpha(theme.palette.common.black, 0.32)}`, "--chat-md-text": "text.primary",
"--chat-md-text": isErrorMessage ? theme.palette.error.dark : "#1f2937", "--chat-md-heading": "text.primary",
"--chat-md-heading": isErrorMessage ? theme.palette.error.dark : "#111827", "--chat-md-link": "#00838f",
"--chat-md-link": isErrorMessage ? theme.palette.error.main : "#7C3AED", "--chat-md-link-hover": "#00acc1",
"--chat-md-link-hover": isErrorMessage ? theme.palette.error.dark : "#6D28D9", "--chat-md-inline-code-bg": alpha("#00acc1", 0.08),
"--chat-md-inline-code-bg": isErrorMessage "--chat-md-inline-code-border": alpha("#00acc1", 0.15),
? alpha(theme.palette.error.main, 0.08) "--chat-md-inline-code-text": "#006064",
: "#EEF2FF", "--chat-md-pre-bg": "#1e293b",
"--chat-md-inline-code-border": isErrorMessage "--chat-md-pre-border": "#475569",
? alpha(theme.palette.error.main, 0.25) "--chat-md-pre-text": "#f1f5f9",
: "#CBD5E1", "--chat-md-quote-border": "#00acc1",
"--chat-md-inline-code-text": isErrorMessage "--chat-md-quote-bg": alpha("#00acc1", 0.04),
? theme.palette.error.dark "--chat-md-quote-text": "text.secondary",
: "#334155",
"--chat-md-pre-bg": isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isErrorMessage ? theme.palette.error.dark : "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isErrorMessage ? theme.palette.error.dark : "#475569",
}} }}
> >
<Stack spacing={1.4}> <Stack spacing={1.5}>
{message.progress?.length && !isErrorMessage ? ( {message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} /> <AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null} ) : null}
<Box <Box
sx={{ sx={{
p: 1.35, p: 1.5,
borderRadius: 3, borderRadius: 4,
bgcolor: isErrorMessage bgcolor: alpha("#fff", 0.4),
? alpha(theme.palette.error.main, 0.055) border: `1px solid ${alpha("#fff", 0.6)}`,
: alpha("#fff", 0.72),
border: `1px solid ${alpha(
isErrorMessage ? theme.palette.error.main : theme.palette.divider,
isErrorMessage ? 0.18 : 0.12,
)}`,
}} }}
> >
<Stack spacing={1}> <Stack spacing={1.2}>
{!isErrorMessage ? ( <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
</Typography>
) : null}
{contentSegments.map((segment, segIdx) => { {contentSegments.map((segment, segIdx) => {
if (segment.type === "text") { if (segment.type === "text") {
const text = segment.content.trim(); const text = segment.content.trim();
@@ -249,45 +408,139 @@ export const AgentTurn = React.memo(
})} })}
</Stack> </Stack>
</Box> </Box>
{message.artifacts?.length ? (
<AgentArtifactPanel artifacts={message.artifacts} />
) : null}
</Stack> </Stack>
<AnimatePresence>
{isHovered && !isErrorMessage && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
>
<Paper
elevation={4}
sx={{
display: "flex",
gap: 0.5,
p: 0.5,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.8),
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="复制"
onClick={() => {
navigator.clipboard.writeText(message.content);
// Could add a toast here
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="重新生成"
onClick={() => {
onRegenerate();
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</motion.div>
)}
</AnimatePresence>
</Paper> </Paper>
</Stack> </Stack>
{!isErrorMessage && isTtsSupported ? ( {(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 5.4 }}> <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
{messageSpeechState === "idle" ? ( <Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
<IconButton {!isErrorMessage && isTtsSupported ? (
size="small" <>
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} {messageSpeechState === "idle" ? (
aria-label="朗读消息" <IconButton
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "playing" ? (
<>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "paused" ? (
<>
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
</>
) : null}
</Stack>
{branchState && branchState.total > 1 ? (
<Stack
direction="row"
justifyContent="flex-start"
sx={{ mr: 0.5 }}
> >
<VolumeUpRounded sx={{ fontSize: 16 }} /> <Paper
</IconButton> elevation={0}
) : null} sx={{
{messageSpeechState === "playing" ? ( display: "flex",
<> alignItems: "center",
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> gap: 0.5,
<PauseRounded sx={{ fontSize: 16 }} /> px: 0.5,
</IconButton> py: 0.25,
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> borderRadius: 4,
<StopRounded sx={{ fontSize: 16 }} /> bgcolor: alpha("#000", 0.04),
</IconButton> backdropFilter: "blur(4px)",
</> border: `1px solid ${alpha("#000", 0.08)}`,
) : null} }}
{messageSpeechState === "paused" ? ( >
<> <IconButton
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}> size="small"
<PlayArrowRounded sx={{ fontSize: 16 }} /> aria-label="上一分支"
</IconButton> onClick={() => onCycleBranch(rootMessageId, -1)}
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}> sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
<StopRounded sx={{ fontSize: 16 }} /> >
</IconButton> <ChevronLeftRounded sx={{ fontSize: 16 }} />
</> </IconButton>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
{branchState.activeIndex + 1} / {branchState.total}
</Typography>
<IconButton
size="small"
aria-label="下一分支"
onClick={() => onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronRightRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</Stack>
) : null} ) : null}
</Stack> </Stack>
) : null} ) : null}
+160 -59
View File
@@ -1,19 +1,27 @@
"use client"; "use client";
import Image from "next/image";
import React from "react"; import React from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Stack, Typography, alpha, useTheme } from "@mui/material"; import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
import MapRounded from "@mui/icons-material/MapRounded";
import { AgentTurn } from "./AgentTurn"; import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts"; import { TypingIndicator } from "./GlobalChatbox.parts";
import type { Message, SpeechState } from "./GlobalChatbox.types"; import type {
BranchGroup,
BranchTransition,
Message,
SpeechState,
} from "./GlobalChatbox.types";
type AgentWorkspaceProps = { type AgentWorkspaceProps = {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[];
branchTransition: BranchTransition | null;
isStreaming: boolean; isStreaming: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null; speakingMessageId: string | null;
@@ -23,14 +31,18 @@ type AgentWorkspaceProps = {
onResumeSpeech: () => void; onResumeSpeech: () => void;
onStopSpeech: () => void; onStopSpeech: () => void;
isTtsSupported: boolean; isTtsSupported: boolean;
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
}; };
const EmptyState = () => { const EmptyState = () => {
const theme = useTheme(); const theme = useTheme();
const capabilities = [ const capabilities = [
{ icon: <WaterDropRounded sx={{ fontSize: 18 }} />, label: "水力瓶颈识别" }, { icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 18 }} />, label: "SCADA 异常分析" }, { icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
{ icon: <TroubleshootRounded sx={{ fontSize: 18 }} />, label: "改造与调度建议" }, { icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" },
{ icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" },
]; ];
return ( return (
@@ -38,62 +50,101 @@ const EmptyState = () => {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }} transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%" }} style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
> >
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
p: 3, p: 4,
borderRadius: 5, borderRadius: 4,
bgcolor: alpha("#fff", 0.68), bgcolor: alpha("#ffffff", 0.4),
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`, border: `1px solid ${alpha("#fff", 0.8)}`,
maxWidth: 380, boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`,
mx: "auto",
textAlign: "center", textAlign: "center",
backdropFilter: "blur(10px)", backdropFilter: "blur(24px)",
position: "relative",
overflow: "hidden",
}} }}
> >
<Box sx={{
position: "absolute",
top: -100,
right: -100,
width: 200,
height: 200,
background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)",
}} />
<motion.div <motion.div
animate={{ y: [-5, 5, -5] }} animate={{
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }} y: [-6, 4, -6],
scale: [1, 1.04, 1],
rotate: [-3, 3, -3],
}}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 88,
height: 88,
marginBottom: 12,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)",
boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)",
}}
> >
<AutoAwesome <Image
sx={{ src="/ai-agent.svg"
fontSize: 54, alt="TJWater Agent"
color: "primary.main", width={54}
mb: 1.6, height={54}
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))", style={{
objectFit: "contain",
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
}} }}
/> />
</motion.div> </motion.div>
<Typography variant="h6" color="text.primary" fontWeight={900} gutterBottom> <Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
Agent
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.65, mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
使
</Typography> </Typography>
<Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" justifyContent="center">
<Grid container spacing={1.5}>
{capabilities.map((item) => ( {capabilities.map((item) => (
<Stack <Grid item xs={6} key={item.label}>
key={item.label} <motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
direction="row" <Stack
spacing={0.5} direction="row"
alignItems="center" spacing={1}
sx={{ alignItems="center"
px: 1, justifyContent="center"
py: 0.55, sx={{
borderRadius: 99, px: 1.5,
bgcolor: alpha(theme.palette.primary.main, 0.07), py: 1.5,
color: "text.secondary", borderRadius: 3,
}} bgcolor: alpha("#fff", 0.5),
> border: `1px solid ${alpha("#fff", 0.6)}`,
{item.icon} boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`,
<Typography variant="caption" fontWeight={700}> color: "text.primary",
{item.label} transition: "all 0.2s",
</Typography> "&:hover": {
</Stack> bgcolor: alpha("#fff", 0.8),
borderColor: alpha("#00acc1", 0.4),
boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`,
}
}}
>
{item.icon}
<Typography variant="caption" fontWeight={700}>
{item.label}
</Typography>
</Stack>
</motion.div>
</Grid>
))} ))}
</Stack> </Grid>
</Paper> </Paper>
</motion.div> </motion.div>
); );
@@ -101,6 +152,8 @@ const EmptyState = () => {
export const AgentWorkspace = ({ export const AgentWorkspace = ({
messages, messages,
branchGroups,
branchTransition,
isStreaming, isStreaming,
bottomRef, bottomRef,
speakingMessageId, speakingMessageId,
@@ -110,6 +163,9 @@ export const AgentWorkspace = ({
onResumeSpeech, onResumeSpeech,
onStopSpeech, onStopSpeech,
isTtsSupported, isTtsSupported,
onRegenerate,
onEditResubmit,
onCycleBranch,
}: AgentWorkspaceProps) => { }: AgentWorkspaceProps) => {
const theme = useTheme(); const theme = useTheme();
const latestAssistant = [...messages] const latestAssistant = [...messages]
@@ -120,6 +176,43 @@ export const AgentWorkspace = ({
(!latestAssistant || (!latestAssistant ||
(latestAssistant.content.trim().length === 0 && (latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length))); !(latestAssistant.artifacts?.length)));
const stableMessages = branchTransition
? messages.slice(0, branchTransition.parentCount)
: messages;
const transitionMessages = branchTransition
? messages.slice(branchTransition.parentCount)
: [];
const renderTurn = (message: Message) => {
const rootMessageId = message.branchRootId ?? message.id;
const branchGroup = branchGroups.find(
(group) => group.rootMessageId === rootMessageId,
);
return (
<AgentTurn
key={rootMessageId}
message={message}
branchState={
branchGroup && branchGroup.branches.length > 1
? {
activeIndex: branchGroup.activeIndex,
total: branchGroup.branches.length,
}
: undefined
}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onRegenerate={onRegenerate}
onEditResubmit={onEditResubmit}
onCycleBranch={onCycleBranch}
/>
);
};
return ( return (
<Box <Box
@@ -130,26 +223,34 @@ export const AgentWorkspace = ({
py: 2, py: 2,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 2,
zIndex: 5, zIndex: 5,
}} }}
> >
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null} {messages.length === 0 ? <EmptyState /> : null}
{messages.map((message) => (
<AgentTurn
key={message.id}
message={message}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
/>
))}
</AnimatePresence> </AnimatePresence>
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{stableMessages.map(renderTurn)}
{branchTransition ? (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{transitionMessages.map(renderTurn)}
</motion.div>
</AnimatePresence>
) : null}
</Box>
) : null}
{showTypingIndicator ? ( {showTypingIndicator ? (
<motion.div <motion.div
initial={{ opacity: 0, y: 10, scale: 0.94 }} initial={{ opacity: 0, y: 10, scale: 0.94 }}
+166 -97
View File
@@ -10,11 +10,15 @@ import {
Typography, Typography,
alpha, alpha,
useTheme, useTheme,
Collapse,
IconButton,
} from "@mui/material"; } from "@mui/material";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded"; import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import TimelineRounded from "@mui/icons-material/TimelineRounded"; import TimelineRounded from "@mui/icons-material/TimelineRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded"; import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import { import {
useChatToolStore, useChatToolStore,
@@ -45,6 +49,26 @@ const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
}; };
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]); const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
const LOCATE_ID_PARAM_KEYS = [
"ids",
"id",
"feature_ids",
"feature_id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id",
] as const;
const TOOL_META: Record<string, ToolMeta> = { const TOOL_META: Record<string, ToolMeta> = {
locate_features: { locate_features: {
@@ -111,21 +135,32 @@ const TOOL_META: Record<string, ToolMeta> = {
/* ---------- helpers ---------- */ /* ---------- helpers ---------- */
function getToolDescription(toolCall: ToolCall): string { function normalizeLocateIds(params: Record<string, unknown>): string[] {
const { params } = toolCall; for (const key of LOCATE_ID_PARAM_KEYS) {
const normalizeIds = (): string[] => { const rawValue = params[key];
const rawIds = params.ids; if (Array.isArray(rawValue)) {
if (Array.isArray(rawIds)) { const normalized = rawValue
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); .map((id) => String(id).trim())
.filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
} }
if (typeof rawIds === "string") { if (typeof rawValue === "string" || typeof rawValue === "number") {
return rawIds const normalized = String(rawValue)
.split(",") .split(",")
.map((id) => id.trim()) .map((id) => id.trim())
.filter(Boolean); .filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
} }
return []; }
}; return [];
}
function getToolDescription(toolCall: ToolCall): string {
const { params } = toolCall;
const resolveScadaFeatureInfos = (): [string, string][] => { const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos; const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) { if (Array.isArray(rawFeatureInfos)) {
@@ -189,7 +224,7 @@ function getToolDescription(toolCall: ToolCall): string {
case "locate_reservoirs": case "locate_reservoirs":
case "locate_pumps": case "locate_pumps":
case "locate_tanks": { case "locate_tanks": {
const ids = normalizeIds(); const ids = normalizeLocateIds(params);
const idsText = const idsText =
ids.length > 3 ids.length > 3
? `${ids.slice(0, 3).join(", ")}${ids.length}` ? `${ids.slice(0, 3).join(", ")}${ids.length}`
@@ -233,19 +268,6 @@ function getToolDescription(toolCall: ToolCall): string {
function buildAction(toolCall: ToolCall): ChatToolAction | null { function buildAction(toolCall: ToolCall): ChatToolAction | null {
const { params } = toolCall; const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const resolveScadaFeatureInfos = (): [string, string][] => { const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos; const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) { if (Array.isArray(rawFeatureInfos)) {
@@ -302,13 +324,13 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
? featureTypeRaw.trim().toLowerCase() ? featureTypeRaw.trim().toLowerCase()
: ""; : "";
const config = locateFeatureTypeToConfig(featureType); const config = locateFeatureTypeToConfig(featureType);
if (!config) return null; if (!config) return null;
return { return {
type: "locate_features", type: "locate_features",
ids: normalizeIds(), ids: normalizeLocateIds(params),
layer: config.layer, layer: config.layer,
geometryKind: config.geometryKind, geometryKind: config.geometryKind,
}; };
} }
case "locate_junctions": case "locate_junctions":
case "locate_pipes": case "locate_pipes":
@@ -320,7 +342,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
if (!layer) return null; if (!layer) return null;
return { return {
type: "locate_features", type: "locate_features",
ids: normalizeIds(), ids: normalizeLocateIds(params),
layer, layer,
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point", geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
}; };
@@ -378,12 +400,13 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
const theme = useTheme(); const theme = useTheme();
const dispatch = useChatToolStore((s) => s.dispatch); const dispatch = useChatToolStore((s) => s.dispatch);
const [executed, setExecuted] = useState(false); const [executed, setExecuted] = useState(false);
const [expanded, setExpanded] = useState(false);
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? { const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
label: toolCall.tool, label: toolCall.tool,
icon: null, icon: <TimelineRounded sx={{ fontSize: 18 }} />,
actionLabel: "执行", actionLabel: "执行",
color: theme.palette.primary.main, color: "#00acc1",
}; };
const description = getToolDescription(toolCall); const description = getToolDescription(toolCall);
@@ -400,97 +423,143 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
mt: 1.5, mt: 1,
mb: 1, mb: 1,
p: 1.5, overflow: "hidden",
borderRadius: 3, borderRadius: 4,
border: `1px solid ${alpha(meta.color, 0.25)}`, border: `1px solid ${alpha(meta.color, 0.3)}`,
bgcolor: alpha(meta.color, 0.04), bgcolor: alpha(meta.color, 0.05),
backdropFilter: "blur(12px)",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(meta.color, 0.08),
border: `1px solid ${alpha(meta.color, 0.4)}`,
}
}} }}
> >
<Stack direction="row" alignItems="center" spacing={1.5}> <Box
onClick={() => setExpanded(!expanded)}
sx={{
p: 1.5,
display: "flex",
alignItems: "center",
cursor: "pointer",
gap: 1.5,
}}
>
{/* Icon */} {/* Icon */}
<Box <Box
sx={{ sx={{
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 2, borderRadius: "50%",
bgcolor: alpha(meta.color, 0.12), bgcolor: alpha(meta.color, 0.15),
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
color: meta.color, color: meta.color,
flexShrink: 0, flexShrink: 0,
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
}} }}
> >
{meta.icon} {meta.icon}
</Box> </Box>
{/* Description */} {/* Title */}
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
<Typography <Typography
variant="caption" variant="body2"
sx={{ sx={{
fontWeight: 600, fontWeight: 700,
color: "text.primary", color: "text.primary",
display: "block",
}} }}
> >
{meta.label} {meta.label}
</Typography> </Typography>
{description && ( {!expanded && description && (
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: "text.secondary", color: "text.secondary",
fontSize: "0.75rem", fontSize: "0.75rem",
display: "block", overflow: "hidden",
overflow: "hidden", textOverflow: "ellipsis",
textOverflow: "ellipsis", whiteSpace: "nowrap",
whiteSpace: "nowrap", maxWidth: 180,
}} opacity: 0.8,
> }}
{description} >
</Typography> {description}
</Typography>
)} )}
</Box> </Box>
{/* Action */} <IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
{executed ? ( {expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
<Chip </IconButton>
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />} </Box>
label="已执行"
size="small" <Collapse in={expanded} timeout="auto" unmountOnExit>
sx={{ <Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
bgcolor: alpha("#4caf50", 0.1), <Stack direction="column" spacing={1.5}>
color: "#4caf50", {description && (
fontWeight: 600, <Box sx={{
fontSize: "0.75rem", p: 1.5,
}} borderRadius: 3,
/> bgcolor: alpha("#000", 0.03),
) : ( border: `1px solid ${alpha("#000", 0.05)}`,
<Button }}>
size="small" <Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
variant="outlined"
onClick={handleExecute} </Typography>
sx={{ <Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
borderColor: alpha(meta.color, 0.4), {description}
color: meta.color, </Typography>
fontWeight: 600, </Box>
fontSize: "0.75rem", )}
borderRadius: 2,
textTransform: "none", <Stack direction="row" justifyContent="flex-end">
whiteSpace: "nowrap", {executed ? (
"&:hover": { <Chip
borderColor: meta.color, icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
bgcolor: alpha(meta.color, 0.08), label="已执行"
}, size="small"
}} sx={{
> bgcolor: alpha("#00e676", 0.15),
{meta.actionLabel} color: "#00c853",
</Button> fontWeight: 700,
)} fontSize: "0.75rem",
</Stack> }}
/>
) : (
<Button
size="small"
variant="contained"
disableElevation
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
sx={{
bgcolor: meta.color,
color: "#fff",
fontWeight: 700,
fontSize: "0.8rem",
borderRadius: 2.5,
px: 2,
textTransform: "none",
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
"&:hover": {
bgcolor: meta.color,
filter: "brightness(0.9)",
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
},
}}
>
{meta.actionLabel}
</Button>
)}
</Stack>
</Stack>
</Box>
</Collapse>
</Paper> </Paper>
); );
}; };
+10
View File
@@ -47,8 +47,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const handleToolCall = useAgentToolActions(); const handleToolCall = useAgentToolActions();
const { const {
messages, messages,
branchGroups,
branchTransition,
isStreaming, isStreaming,
sendPrompt, sendPrompt,
regenerate,
editAndResubmit,
cycleBranch,
abort, abort,
reset, reset,
} = useAgentChatSession({ } = useAgentChatSession({
@@ -202,6 +207,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<AgentWorkspace <AgentWorkspace
messages={messages} messages={messages}
branchGroups={branchGroups}
branchTransition={branchTransition}
isStreaming={isStreaming} isStreaming={isStreaming}
bottomRef={bottomRef} bottomRef={bottomRef}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
@@ -211,6 +218,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onResumeSpeech={handleResumeSpeech} onResumeSpeech={handleResumeSpeech}
onStopSpeech={handleStopSpeech} onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported} isTtsSupported={isTtsSupported}
onRegenerate={regenerate}
onEditResubmit={editAndResubmit}
onCycleBranch={cycleBranch}
/> />
<AgentComposer <AgentComposer
@@ -24,6 +24,34 @@ export type Message = {
isError?: boolean; isError?: boolean;
progress?: ChatProgress[]; progress?: ChatProgress[];
artifacts?: AgentArtifact[]; artifacts?: AgentArtifact[];
branchRootId?: string;
};
export type BranchState = {
activeIndex: number;
total: number;
};
export type MessageBranch = {
id: string;
label: string;
sessionId?: string;
messages: Message[];
};
export type BranchGroup = {
id: string;
rootMessageId: string;
parentCount: number;
activeIndex: number;
branches: MessageBranch[];
};
export type BranchTransition = {
rootMessageId: string;
parentCount: number;
activeBranchId: string;
nonce: number;
}; };
export type Props = { export type Props = {
@@ -36,4 +64,5 @@ export type SpeechState = "idle" | "playing" | "paused";
export type PersistedChatState = { export type PersistedChatState = {
messages: Message[]; messages: Message[];
sessionId?: string; sessionId?: string;
branchGroups?: BranchGroup[];
}; };
+23 -2
View File
@@ -1,4 +1,4 @@
import type { PersistedChatState } from "./GlobalChatbox.types"; import type { BranchGroup, Message, PersistedChatState } from "./GlobalChatbox.types";
export const createId = () => export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -42,7 +42,11 @@ export const getInitialChatState = (): PersistedChatState => {
window.localStorage.removeItem(CHAT_STORAGE_KEY); window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], sessionId: undefined }; return { messages: [], sessionId: undefined };
} }
return { messages: parsed.messages, sessionId: parsed.sessionId }; return {
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
sessionId: parsed.sessionId,
branchGroups: Array.isArray(parsed.branchGroups) ? parsed.branchGroups : [],
};
} catch (error) { } catch (error) {
console.error( console.error(
"[GlobalChatbox] Failed to read persisted chat state:", "[GlobalChatbox] Failed to read persisted chat state:",
@@ -52,3 +56,20 @@ export const getInitialChatState = (): PersistedChatState => {
return { messages: [], sessionId: undefined }; return { messages: [], sessionId: undefined };
} }
}; };
export const cloneMessage = (message: Message): Message => ({
...message,
progress: message.progress ? [...message.progress] : undefined,
artifacts: message.artifacts ? [...message.artifacts] : undefined,
});
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
branchGroups.map((group) => ({
...group,
branches: group.branches.map((branch) => ({
...branch,
messages: cloneMessages(branch.messages),
})),
}));
+333 -34
View File
@@ -2,15 +2,23 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { streamAgentChat } from "@/lib/chatStream"; import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream";
import type { import type {
AgentArtifact, AgentArtifact,
BranchGroup,
BranchTransition,
ChatProgress, ChatProgress,
Message, Message,
PersistedChatState, PersistedChatState,
} from "../GlobalChatbox.types"; } from "../GlobalChatbox.types";
import { CHAT_STORAGE_KEY, createId, getInitialChatState } from "../GlobalChatbox.utils"; import {
CHAT_STORAGE_KEY,
cloneBranchGroups,
cloneMessages,
createId,
getInitialChatState,
} from "../GlobalChatbox.utils";
type UseAgentChatSessionOptions = { type UseAgentChatSessionOptions = {
onToolCall: ( onToolCall: (
@@ -23,6 +31,14 @@ type UseAgentChatSessionOptions = {
onBeforeSend?: () => void; onBeforeSend?: () => void;
}; };
type PromptRunOptions = {
prompt: string;
sessionIdOverride?: string;
preparedMessages?: Message[];
userMessage?: Message;
assistantMessage?: Message;
};
const upsertProgress = ( const upsertProgress = (
progress: ChatProgress[] | undefined, progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" }, event: StreamEvent & { type: "progress" },
@@ -49,6 +65,25 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
item.status === "running" ? { ...item, status: "completed" as const } : item, item.status === "running" ? { ...item, status: "completed" as const } : item,
); );
const createUserMessage = (content: string, branchRootId?: string): Message => {
const id = createId();
return {
id,
role: "user",
content,
branchRootId: branchRootId ?? id,
};
};
const createAssistantMessage = (): Message => ({
id: createId(),
role: "assistant",
content: "",
});
const messagesEqual = (left: Message[], right: Message[]) =>
JSON.stringify(left) === JSON.stringify(right);
export const useAgentChatSession = ({ export const useAgentChatSession = ({
onToolCall, onToolCall,
onBeforeSend, onBeforeSend,
@@ -64,16 +99,65 @@ export const useAgentChatSession = ({
const [sessionId, setSessionId] = useState<string | undefined>( const [sessionId, setSessionId] = useState<string | undefined>(
initialChatStateRef.current.sessionId, initialChatStateRef.current.sessionId,
); );
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>(
initialChatStateRef.current.branchGroups ?? [],
);
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(initialChatStateRef.current.sessionId);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => { useEffect(() => {
const state: PersistedChatState = { messages, sessionId }; sessionIdRef.current = sessionId;
}, [sessionId]);
useEffect(() => {
const state: PersistedChatState = { messages, sessionId, branchGroups };
try { try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) { } catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error); console.error("[GlobalChatbox] Failed to persist chat state:", error);
} }
}, [branchGroups, messages, sessionId]);
useEffect(() => {
setBranchGroups((prev) => {
let changed = false;
const next = prev.map((group) => {
const rootMessage = messages[group.parentCount];
if (
!rootMessage ||
rootMessage.role !== "user" ||
(rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId
) {
return group;
}
const activeBranch = group.branches[group.activeIndex];
if (!activeBranch) {
return group;
}
const nextSuffix = cloneMessages(messages.slice(group.parentCount));
if (
activeBranch.sessionId === sessionId &&
messagesEqual(activeBranch.messages, nextSuffix)
) {
return group;
}
changed = true;
const branches = group.branches.map((branch, index) =>
index === group.activeIndex
? { ...branch, sessionId, messages: nextSuffix }
: branch,
);
return { ...group, branches };
});
return changed ? next : prev;
});
}, [messages, sessionId]); }, [messages, sessionId]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
@@ -89,21 +173,33 @@ export const useAgentChatSession = ({
); );
}, []); }, []);
const sendPrompt = useCallback( const runPrompt = useCallback(
async (rawPrompt: string) => { async ({
prompt: rawPrompt,
sessionIdOverride,
preparedMessages,
userMessage,
assistantMessage,
}: PromptRunOptions) => {
const prompt = rawPrompt.trim(); const prompt = rawPrompt.trim();
if (!prompt || isStreaming) return; if (!prompt || isStreaming) return;
await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.(); onBeforeSend?.();
setBranchTransition(null);
const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
const nextMessages =
preparedMessages ??
[...messages, nextUserMessage, nextAssistantMessage];
const userId = createId();
const assistantId = createId();
setIsStreaming(true); setIsStreaming(true);
setMessages(cloneMessages(nextMessages));
setMessages((prev) => [ if (sessionIdOverride !== undefined) {
...prev, sessionIdRef.current = sessionIdOverride;
{ id: userId, role: "user", content: prompt }, setSessionId(sessionIdOverride);
{ id: assistantId, role: "assistant", content: "" }, }
]);
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
@@ -111,17 +207,18 @@ export const useAgentChatSession = ({
try { try {
await streamAgentChat({ await streamAgentChat({
message: prompt, message: prompt,
sessionId, sessionId: sessionIdOverride ?? sessionIdRef.current,
signal: controller.signal, signal: controller.signal,
onEvent: (event) => { onEvent: (event) => {
if ("sessionId" in event && !sessionId && event.sessionId) { if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) {
sessionIdRef.current = event.sessionId;
setSessionId(event.sessionId); setSessionId(event.sessionId);
} }
if (event.type === "token") { if (event.type === "token") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
message.id === assistantId message.id === nextAssistantMessage.id
? { ? {
...message, ...message,
content: message.content + event.content, content: message.content + event.content,
@@ -133,20 +230,20 @@ export const useAgentChatSession = ({
} else if (event.type === "progress") { } else if (event.type === "progress") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
message.id === assistantId message.id === nextAssistantMessage.id
? { ...message, progress: upsertProgress(message.progress, event) } ? { ...message, progress: upsertProgress(message.progress, event) }
: message, : message,
), ),
); );
} else if (event.type === "tool_call") { } else if (event.type === "tool_call") {
onToolCall(event, { onToolCall(event, {
assistantMessageId: assistantId, assistantMessageId: nextAssistantMessage.id,
appendArtifact, appendArtifact,
}); });
} else if (event.type === "done") { } else if (event.type === "done") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => { prev.map((message) => {
if (message.id !== assistantId) return message; if (message.id !== nextAssistantMessage.id) return message;
const completedProgress = completeRunningProgress(message.progress); const completedProgress = completeRunningProgress(message.progress);
if ( if (
message.content.trim().length === 0 && message.content.trim().length === 0 &&
@@ -166,7 +263,7 @@ export const useAgentChatSession = ({
} else if (event.type === "error") { } else if (event.type === "error") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
message.id === assistantId message.id === nextAssistantMessage.id
? { ? {
...message, ...message,
content: message.content || `⚠️ **错误:** ${event.message}`, content: message.content || `⚠️ **错误:** ${event.message}`,
@@ -181,23 +278,34 @@ export const useAgentChatSession = ({
}, },
}); });
} catch (error) { } catch (error) {
if (abortRef.current?.signal.aborted) { if (controller.signal.aborted) {
setMessages((prev) => setMessages((prev) =>
prev.filter( prev
(message) => .map((message) =>
!( message.id === nextAssistantMessage.id
message.id === assistantId && ? {
message.role === "assistant" && ...message,
message.content.trim().length === 0 && content: message.content || "⚠️ **请求已中断**",
!(message.artifacts?.length) isError: true,
), }
), : message,
)
.filter(
(message) =>
!(
message.id === nextAssistantMessage.id &&
message.role === "assistant" &&
message.content.trim().length === 0 &&
!(message.artifacts?.length) &&
!(message.progress?.length)
),
),
); );
return; return;
} }
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
message.id === assistantId message.id === nextAssistantMessage.id
? { ? {
...message, ...message,
content: `⚠️ **错误:** ${String(error)}`, content: `⚠️ **错误:** ${String(error)}`,
@@ -213,26 +321,217 @@ export const useAgentChatSession = ({
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[appendArtifact, isStreaming, onBeforeSend, onToolCall, sessionId], [appendArtifact, isStreaming, messages, onBeforeSend, onToolCall],
); );
const abort = useCallback(() => { const abort = useCallback(() => {
abortRef.current?.abort(); const controller = abortRef.current;
controller?.abort();
setIsStreaming(false); setIsStreaming(false);
const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => {
console.error("[GlobalChatbox] Failed to abort agent session:", error);
});
const trackedCancelPromise = cancelPromise.finally(() => {
if (cancelPromiseRef.current === trackedCancelPromise) {
cancelPromiseRef.current = null;
}
});
cancelPromiseRef.current = trackedCancelPromise;
}, []); }, []);
const reset = useCallback(() => { const reset = useCallback(() => {
abortRef.current?.abort(); const controller = abortRef.current;
controller?.abort();
const activeSessionId = sessionIdRef.current;
if (activeSessionId) {
const cancelPromise = abortAgentChat(activeSessionId).catch((error) => {
console.error("[GlobalChatbox] Failed to abort agent session during reset:", error);
});
const trackedCancelPromise = cancelPromise.finally(() => {
if (cancelPromiseRef.current === trackedCancelPromise) {
cancelPromiseRef.current = null;
}
});
cancelPromiseRef.current = trackedCancelPromise;
}
setMessages([]); setMessages([]);
setBranchGroups([]);
setBranchTransition(null);
setSessionId(undefined); setSessionId(undefined);
sessionIdRef.current = undefined;
setIsStreaming(false); setIsStreaming(false);
}, []); }, []);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
await runPrompt({ prompt: rawPrompt });
},
[runPrompt],
);
const regenerate = useCallback(async () => {
if (isStreaming || messages.length === 0) return;
let lastUserIndex = messages.length - 1;
while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") {
lastUserIndex--;
}
if (lastUserIndex < 0) return;
const lastUser = messages[lastUserIndex];
const lastUserContent = lastUser.content;
const nextMessages = cloneMessages(messages.slice(0, lastUserIndex));
const nextUserMessage = createUserMessage(
lastUserContent,
lastUser.branchRootId ?? lastUser.id,
);
const nextAssistantMessage = createAssistantMessage();
setMessages(nextMessages);
await runPrompt({
prompt: lastUserContent,
preparedMessages: [
...nextMessages,
nextUserMessage,
nextAssistantMessage,
],
userMessage: nextUserMessage,
assistantMessage: nextAssistantMessage,
});
}, [isStreaming, messages, runPrompt]);
const editAndResubmit = useCallback(
async (messageId: string, newContent: string) => {
if (isStreaming) return;
const trimmedContent = newContent.trim();
if (!trimmedContent) return;
const messageIndex = messages.findIndex((m) => m.id === messageId);
if (messageIndex < 0 || messages[messageIndex].role !== "user") return;
const originalMessage = messages[messageIndex];
if (trimmedContent === originalMessage.content.trim()) return;
const rootMessageId = originalMessage.branchRootId ?? originalMessage.id;
const currentSessionId = sessionIdRef.current;
const keepMessageCount = messageIndex;
const prefix = cloneMessages(messages.slice(0, messageIndex));
const originalSuffix = cloneMessages(messages.slice(messageIndex));
const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount);
const nextUserMessage = createUserMessage(trimmedContent, rootMessageId);
const nextAssistantMessage = createAssistantMessage();
const nextSuffix = [nextUserMessage, nextAssistantMessage];
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
const groupIndex = next.findIndex(
(group) =>
group.rootMessageId === rootMessageId && group.parentCount === messageIndex,
);
if (groupIndex >= 0) {
const group = next[groupIndex];
group.branches[group.activeIndex] = {
...group.branches[group.activeIndex],
sessionId: currentSessionId,
messages: originalSuffix,
};
group.branches.push({
id: createId(),
label: `分支 ${group.branches.length + 1}`,
sessionId: forkedSessionId,
messages: cloneMessages(nextSuffix),
});
group.activeIndex = group.branches.length - 1;
} else {
next.push({
id: rootMessageId,
rootMessageId,
parentCount: messageIndex,
activeIndex: 1,
branches: [
{
id: createId(),
label: "分支 1",
sessionId: currentSessionId,
messages: originalSuffix,
},
{
id: createId(),
label: "分支 2",
sessionId: forkedSessionId,
messages: cloneMessages(nextSuffix),
},
],
});
}
return next;
});
sessionIdRef.current = forkedSessionId;
setSessionId(forkedSessionId);
await runPrompt({
prompt: trimmedContent,
sessionIdOverride: forkedSessionId,
preparedMessages: [...prefix, ...nextSuffix],
userMessage: nextUserMessage,
assistantMessage: nextAssistantMessage,
});
},
[isStreaming, messages, runPrompt],
);
const cycleBranch = useCallback(
(rootMessageId: string, direction: -1 | 1) => {
if (isStreaming) return;
setBranchGroups((prev) => {
const next = cloneBranchGroups(prev);
const group = next.find((item) => item.rootMessageId === rootMessageId);
if (!group || group.branches.length < 2) {
return prev;
}
const nextIndex =
(group.activeIndex + direction + group.branches.length) % group.branches.length;
const selectedBranch = group.branches[nextIndex];
group.activeIndex = nextIndex;
const nextMessages = [
...cloneMessages(messages.slice(0, group.parentCount)),
...cloneMessages(selectedBranch.messages),
];
setBranchTransition({
rootMessageId,
parentCount: group.parentCount,
activeBranchId: selectedBranch.id,
nonce: Date.now(),
});
sessionIdRef.current = selectedBranch.sessionId;
setSessionId(selectedBranch.sessionId);
setMessages(nextMessages);
return next;
});
},
[isStreaming, messages],
);
return { return {
messages, messages,
branchGroups,
branchTransition,
isStreaming, isStreaming,
sessionId, sessionId,
sendPrompt, sendPrompt,
regenerate,
editAndResubmit,
cycleBranch,
abort, abort,
reset, reset,
}; };
@@ -43,16 +43,45 @@ const LOCATE_TOOL_CONFIG: Record<
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
}; };
const LOCATE_ID_PARAM_KEYS = [
"ids",
"id",
"feature_ids",
"feature_id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id",
] as const;
const normalizeIds = (params: Record<string, unknown>): string[] => { const normalizeIds = (params: Record<string, unknown>): string[] => {
const rawIds = params.ids; for (const key of LOCATE_ID_PARAM_KEYS) {
if (Array.isArray(rawIds)) { const rawValue = params[key];
return rawIds.map((id) => String(id).trim()).filter(Boolean); if (Array.isArray(rawValue)) {
} const normalized = rawValue.map((id) => String(id).trim()).filter(Boolean);
if (typeof rawIds === "string") { if (normalized.length > 0) {
return rawIds return normalized;
.split(",") }
.map((id) => id.trim()) }
.filter(Boolean); if (typeof rawValue === "string" || typeof rawValue === "number") {
const normalized = String(rawValue)
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
}
} }
return []; return [];
}; };
+26 -1
View File
@@ -20,6 +20,7 @@ import {
ShowChart, ShowChart,
TableChart, TableChart,
CleaningServices, CleaningServices,
Close,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from "@mui/icons-material"; } from "@mui/icons-material";
@@ -72,12 +73,22 @@ export interface SCADADataPanelProps {
start_time?: string; start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string; end_time?: string;
/** 关闭面板 */
onClose?: () => void;
} }
type PanelTab = "chart" | "table"; type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error"; type LoadingState = "idle" | "loading" | "success" | "error";
const panelHeaderActionSx = {
color: "primary.contrastText",
backgroundColor: "rgba(255,255,255,0.08)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.18)",
},
};
/** /**
* 从后端 API 获取 SCADA 数据 * 从后端 API 获取 SCADA 数据
*/ */
@@ -320,6 +331,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
onCleanData, onCleanData,
start_time, start_time,
end_time, end_time,
onClose,
}) => { }) => {
const { open } = useNotification(); const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>(); const { data: user } = useGetIdentity<IUser>();
@@ -1063,11 +1075,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/> />
</Stack> </Stack>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
{onClose && (
<Tooltip title="关闭">
<IconButton
size="small"
onClick={onClose}
aria-label="关闭 SCADA 历史数据面板"
sx={panelHeaderActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="收起"> <Tooltip title="收起">
<IconButton <IconButton
size="small" size="small"
onClick={() => setIsExpanded(false)} onClick={() => setIsExpanded(false)}
sx={{ color: "primary.contrastText" }} aria-label="收起 SCADA 历史数据面板"
sx={panelHeaderActionSx}
> >
<ChevronRight fontSize="small" /> <ChevronRight fontSize="small" />
</IconButton> </IconButton>
@@ -15,13 +15,14 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Divider, Divider,
IconButton,
Stack, Stack,
Tab, Tab,
Tabs, Tabs,
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { Refresh, ShowChart, TableChart } from "@mui/icons-material"; import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales"; import { zhCN } from "@mui/x-data-grid/locales";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
@@ -63,12 +64,22 @@ export interface SCADADataPanelProps {
start_time?: string; start_time?: string;
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
end_time?: string; end_time?: string;
/** 关闭面板 */
onClose?: () => void;
} }
type PanelTab = "chart" | "table"; type PanelTab = "chart" | "table";
type LoadingState = "idle" | "loading" | "success" | "error"; type LoadingState = "idle" | "loading" | "success" | "error";
const panelHeaderActionSx = {
color: "primary.contrastText",
backgroundColor: "rgba(255,255,255,0.08)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.18)",
},
};
/** /**
* 从后端 API 获取 SCADA 数据 * 从后端 API 获取 SCADA 数据
*/ */
@@ -419,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
fractionDigits = 2, fractionDigits = 2,
start_time, start_time,
end_time, end_time,
onClose,
}) => { }) => {
// 从 featureInfos 中提取设备 ID 列表 // 从 featureInfos 中提取设备 ID 列表
const deviceIds = useMemo( const deviceIds = useMemo(
@@ -850,7 +862,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return ( return (
<> <>
{/* 主面板 */} {/* 主面板 */}
<Draggable nodeRef={draggableRef} handle=".drag-handle"> <Draggable
nodeRef={draggableRef}
handle=".drag-handle"
cancel=".panel-close-button"
>
<Box <Box
ref={draggableRef} ref={draggableRef}
sx={{ sx={{
@@ -915,6 +931,19 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}} }}
/> />
</Stack> </Stack>
{onClose && (
<Tooltip title="关闭">
<IconButton
className="panel-close-button"
size="small"
onClick={onClose}
aria-label="关闭历史数据面板"
sx={panelHeaderActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack> </Stack>
</Box> </Box>
@@ -2,6 +2,8 @@
import React, { useRef } from "react"; import React, { useRef } from "react";
import Draggable from "react-draggable"; import Draggable from "react-draggable";
import { Close } from "@mui/icons-material";
import { IconButton, Tooltip } from "@mui/material";
interface BaseProperty { interface BaseProperty {
label: string; label: string;
@@ -24,14 +26,23 @@ interface PropertyPanelProps {
id?: string; id?: string;
type?: string; type?: string;
properties?: PropertyItem[]; properties?: PropertyItem[];
onClose?: () => void;
} }
const PropertyPanel: React.FC<PropertyPanelProps> = ({ const PropertyPanel: React.FC<PropertyPanelProps> = ({
id, id,
type = "未知类型", type = "未知类型",
properties = [], properties = [],
onClose,
}) => { }) => {
const draggableRef = useRef<HTMLDivElement>(null); const draggableRef = useRef<HTMLDivElement>(null);
const headerActionSx = {
color: "common.white",
backgroundColor: "rgba(255,255,255,0.08)",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.18)",
},
};
const formatValue = (property: BaseProperty) => { const formatValue = (property: BaseProperty) => {
if (property.formatter) { if (property.formatter) {
@@ -55,7 +66,11 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
: 0; : 0;
return ( return (
<Draggable nodeRef={draggableRef} handle=".drag-handle"> <Draggable
nodeRef={draggableRef}
handle=".drag-handle"
cancel=".panel-close-button"
>
<div <div
ref={draggableRef} ref={draggableRef}
className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100" className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100"
@@ -78,6 +93,19 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
</svg> </svg>
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
</div> </div>
{onClose && (
<Tooltip title="关闭">
<IconButton
className="panel-close-button"
size="small"
onClick={onClose}
aria-label="关闭属性面板"
sx={headerActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
)}
</div> </div>
{/* 内容区域 */} {/* 内容区域 */}
+16 -1
View File
@@ -22,18 +22,33 @@ export function useChatToolActionHandler(
handler: (action: ChatToolAction) => void, handler: (action: ChatToolAction) => void,
) { ) {
const handlerRef = useRef(handler); const handlerRef = useRef(handler);
const lastHandledSeqRef = useRef(0);
useEffect(() => { useEffect(() => {
handlerRef.current = handler; handlerRef.current = handler;
}, [handler]); }, [handler]);
useEffect(() => { useEffect(() => {
const initialState = useChatToolStore.getState();
if (
initialState.lastAction &&
initialState.actionSeq > lastHandledSeqRef.current &&
Date.now() - initialState.lastActionAt < 5000
) {
lastHandledSeqRef.current = initialState.actionSeq;
handlerRef.current(initialState.lastAction);
} else {
lastHandledSeqRef.current = initialState.actionSeq;
}
const unsubscribe = useChatToolStore.subscribe( const unsubscribe = useChatToolStore.subscribe(
(state, prevState) => { (state, prevState) => {
if ( if (
state.actionSeq !== prevState.actionSeq && state.actionSeq !== prevState.actionSeq &&
state.lastAction state.lastAction &&
state.actionSeq > lastHandledSeqRef.current
) { ) {
lastHandledSeqRef.current = state.actionSeq;
handlerRef.current(state.lastAction); handlerRef.current(state.lastAction);
} }
}, },
+46 -1
View File
@@ -1,4 +1,4 @@
import { streamAgentChat } from "./chatStream"; import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream";
import { ReadableStream } from "stream/web"; import { ReadableStream } from "stream/web";
import { TextEncoder, TextDecoder } from "util"; import { TextEncoder, TextDecoder } from "util";
@@ -147,4 +147,49 @@ describe("streamAgentChat", () => {
{ type: "error", message: "network request failed", detail: "Failed to fetch" }, { type: "error", message: "network request failed", detail: "Failed to fetch" },
]); ]);
}); });
it("calls abort endpoint for an active session", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 202,
text: async () => "",
});
await abortAgentChat("s1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/abort"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
}),
}),
);
});
it("calls fork endpoint and returns new session id", async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ session_id: "forked-s1" }),
text: async () => "",
});
const sessionId = await forkAgentChat("s1", 3);
expect(sessionId).toBe("forked-s1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/fork"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
session_id: "s1",
keep_message_count: 3,
}),
}),
);
});
}); });
+49
View File
@@ -181,3 +181,52 @@ export const streamAgentChat = async ({
} }
} }
}; };
export const abortAgentChat = async (sessionId?: string) => {
if (!sessionId) {
return;
}
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `abort request failed: ${response.status}`);
}
};
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
keep_message_count: keepMessageCount,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `fork request failed: ${response.status}`);
}
const payload = (await response.json()) as { session_id?: string };
if (!payload.session_id) {
throw new Error("fork request returned no session_id");
}
return payload.session_id;
};
+4
View File
@@ -41,6 +41,8 @@ interface ChatToolState {
lastAction: ChatToolAction | null; lastAction: ChatToolAction | null;
/** Monotonically increasing counter lets subscribers detect new actions. */ /** Monotonically increasing counter lets subscribers detect new actions. */
actionSeq: number; actionSeq: number;
/** Timestamp of the most recent action dispatch. */
lastActionAt: number;
/** Dispatch a tool action from the chat. */ /** Dispatch a tool action from the chat. */
dispatch: (action: ChatToolAction) => void; dispatch: (action: ChatToolAction) => void;
} }
@@ -48,9 +50,11 @@ interface ChatToolState {
export const useChatToolStore = create<ChatToolState>((set) => ({ export const useChatToolStore = create<ChatToolState>((set) => ({
lastAction: null, lastAction: null,
actionSeq: 0, actionSeq: 0,
lastActionAt: 0,
dispatch: (action) => dispatch: (action) =>
set((state) => ({ set((state) => ({
lastAction: action, lastAction: action,
actionSeq: state.actionSeq + 1, actionSeq: state.actionSeq + 1,
lastActionAt: Date.now(),
})), })),
})); }));