重构 Agent 聊天,支持分支管理与消息克隆
This commit is contained in:
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 内容区域 */}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user