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