Agent 初版设计

This commit is contained in:
2026-04-29 17:15:49 +08:00
parent 2c1afdc97c
commit e5ca9e24aa
13 changed files with 1819 additions and 1255 deletions
+128
View File
@@ -0,0 +1,128 @@
"use client";
import React from "react";
import {
Box,
Chip,
Paper,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import BarChartRounded from "@mui/icons-material/BarChartRounded";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import type { AgentArtifact } from "./GlobalChatbox.types";
const artifactIcon = (kind: AgentArtifact["kind"]) => {
if (kind === "chart") return <BarChartRounded sx={{ fontSize: 18 }} />;
if (kind === "map") return <LocationOnRounded sx={{ fontSize: 18 }} />;
if (kind === "panel") return <SensorsRounded sx={{ fontSize: 18 }} />;
return <BuildCircleRounded sx={{ fontSize: 18 }} />;
};
const artifactColor = (kind: AgentArtifact["kind"], theme: Theme) => {
if (kind === "chart") return theme.palette.info.main;
if (kind === "map") return theme.palette.success.main;
if (kind === "panel") return theme.palette.warning.main;
return theme.palette.primary.main;
};
export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }) => {
const theme = useTheme();
if (!artifacts.length) return null;
return (
<Stack spacing={1.25}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="caption" fontWeight={800} color="text.primary">
</Typography>
<Chip
size="small"
label={`${artifacts.length}`}
sx={{ height: 20, fontSize: "0.68rem" }}
/>
</Stack>
{artifacts.map((artifact) => {
const color = artifactColor(artifact.kind, theme);
if (artifact.kind === "chart") {
return (
<ChatInlineChart
key={artifact.id}
title={(artifact.params.title as string) ?? artifact.title}
chart_type={
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(artifact.params.x_data as string[]) ?? []}
series={(artifact.params.series as ChatChartSeries[]) ?? []}
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
/>
);
}
return (
<Paper
key={artifact.id}
elevation={0}
sx={{
p: 1.35,
borderRadius: 3,
border: `1px solid ${alpha(color, 0.22)}`,
bgcolor: alpha(color, 0.055),
}}
>
<Stack direction="row" spacing={1.25} alignItems="center">
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(color, 0.12),
color,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{artifactIcon(artifact.kind)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" fontWeight={800} color="text.primary">
{artifact.title}
</Typography>
{artifact.description ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{artifact.description}
</Typography>
) : null}
</Box>
<Chip
size="small"
label="已执行"
sx={{
height: 22,
fontSize: "0.68rem",
bgcolor: alpha(color, 0.12),
color,
}}
/>
</Stack>
</Paper>
);
})}
</Stack>
);
};
+241
View File
@@ -0,0 +1,241 @@
"use client";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Avatar,
Box,
Chip,
Collapse,
IconButton,
Paper,
Stack,
TextField,
Typography,
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";
type AgentComposerProps = {
input: string;
inputRef: React.RefObject<HTMLInputElement | null>;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
presets: string[];
onInputChange: (value: string) => void;
onSend: () => void;
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
};
export const AgentComposer = ({
input,
inputRef,
isStreaming,
isListening,
isSttSupported,
presets,
onInputChange,
onSend,
onAbort,
onStartListening,
onStopListening,
onPresetSelect,
}: AgentComposerProps) => {
const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming;
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
return (
<Box sx={{ px: 3, pb: 3, pt: 1.5, zIndex: 10 }}>
<Paper
elevation={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)",
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} />
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
<Box sx={{ flex: 1 }} />
<IconButton
size="small"
onClick={() => setIsPresetOpen((value) => !value)}
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
sx={{ width: 26, height: 26, color: "text.secondary" }}
>
{isPresetOpen ? (
<KeyboardArrowDownRounded fontSize="small" />
) : (
<KeyboardArrowUpRounded fontSize="small" />
)}
</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>
</Collapse>
</Paper>
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
<Stack
direction="row"
alignItems="center"
component={Paper}
elevation={12}
sx={{
p: "6px 8px",
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)}`,
}}
>
<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}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder="描述你的管网分析目标..."
fullWidth
multiline
maxRows={4}
variant="standard"
InputProps={{
disableUnderline: true,
sx: { px: 2, py: 1.35, fontSize: "0.98rem" },
}}
/>
{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,
}}
>
<MicRounded />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming}
aria-label="语音输入"
sx={{ color: "text.secondary", width: 42, height: 42 }}
>
<MicRounded />
</IconButton>
)}
</Box>
) : null}
<Box sx={{ pr: 0.5 }}>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
onClick={onAbort}
aria-label="停止生成"
sx={{
bgcolor: alpha(theme.palette.error.main, 0.1),
color: "error.main",
width: 42,
height: 42,
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
disabled={!canSend}
onClick={onSend}
aria-label="发送"
sx={{
bgcolor: canSend ? "primary.main" : "action.disabledBackground",
color: "#fff",
width: 42,
height: 42,
"&:hover": { bgcolor: canSend ? "primary.dark" : "action.disabledBackground" },
}}
>
<SendRounded sx={{ ml: 0.35 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Box>
</Stack>
</motion.div>
</Box>
);
};
+158
View File
@@ -0,0 +1,158 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Stack,
Typography,
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";
type AgentHeaderProps = {
isStreaming: boolean;
menuAnchorEl: HTMLElement | null;
onMenuOpen: (event: React.MouseEvent<HTMLElement>) => void;
onMenuClose: () => void;
onNewConversation: () => void;
onClose: () => void;
};
export const AgentHeader = ({
isStreaming,
menuAnchorEl,
onMenuOpen,
onMenuClose,
onNewConversation,
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
const isMenuOpen = Boolean(menuAnchorEl);
return (
<Box
sx={{
px: 3,
py: 2.5,
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div whileHover={{ rotate: 10, scale: 1.08 }} whileTap={{ scale: 0.95 }}>
<IconButton
onClick={onMenuOpen}
aria-label="打开 Agent 菜单"
aria-controls={isMenuOpen ? "global-chatbox-header-menu" : undefined}
aria-expanded={isMenuOpen ? "true" : undefined}
aria-haspopup="menu"
sx={{ p: 0, borderRadius: "50%" }}
>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: `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,
}}
>
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
</Avatar>
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: isStreaming ? "warning.main" : "success.main",
borderRadius: "50%",
border: "2px solid #fff",
}}
/>
</Box>
</IconButton>
</motion.div>
<Box>
<Typography
variant="h6"
fontWeight={900}
sx={{
background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`,
backgroundClip: "text",
color: "transparent",
letterSpacing: -0.5,
}}
>
TJWater Agent
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={600}>
{isStreaming ? "正在分析管网任务" : "管网分析工作台"}
</Typography>
</Box>
</Stack>
<Menu
id="global-chatbox-header-menu"
anchorEl={menuAnchorEl}
open={isMenuOpen}
onClose={onMenuClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
elevation: 8,
sx: {
mt: 1,
minWidth: 180,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
backdropFilter: "blur(12px)",
bgcolor: alpha("#fff", 0.92),
},
},
}}
>
<MenuItem onClick={onNewConversation}>
<ListItemIcon>
<AddCommentRounded fontSize="small" />
</ListItemIcon>
<ListItemText
primary="新建对话"
secondary="清空当前会话"
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 700 } }}
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
/>
</MenuItem>
</Menu>
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }}>
<IconButton
onClick={onClose}
size="small"
aria-label="关闭 Agent"
sx={{
color: "text.primary",
bgcolor: alpha("#fff", 0.54),
"&:hover": { bgcolor: "#fff" },
}}
>
<CloseRounded />
</IconButton>
</motion.div>
</Box>
);
};
@@ -0,0 +1,60 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import type { ChatProgress } from "./GlobalChatbox.types";
describe("AgentProgressTimeline", () => {
it("shows the running step and keeps the timeline expanded while running", () => {
const progress: ChatProgress[] = [
{
id: "start",
phase: "start",
status: "completed",
title: "收到请求",
},
{
id: "tool",
phase: "tool",
status: "running",
title: "正在调用 dynamic_http_call",
detail: "GET /api/v1/network/bottlenecks",
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("Agent 过程")).toBeInTheDocument();
expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument();
});
it("summarizes completed steps and lets users expand details", async () => {
const progress: ChatProgress[] = [
{ id: "start", phase: "start", status: "completed", title: "收到请求" },
{ id: "done", phase: "complete", status: "completed", title: "分析完成" },
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.queryByText("分析完成")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开" }));
expect(screen.getByText("分析完成")).toBeVisible();
});
it("treats stale running steps as finished after a complete event", () => {
const progress: ChatProgress[] = [
{ id: "tool", phase: "tool", status: "running", title: "正在调用 dynamic_http_call" },
{ id: "done", phase: "complete", status: "completed", title: "分析完成" },
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,188 @@
"use client";
import React, { useMemo, useState } from "react";
import {
Box,
Button,
Chip,
Collapse,
LinearProgress,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
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 type { ChatProgress } from "./GlobalChatbox.types";
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 === "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" }} />;
};
const formatToolTitle = (item: ChatProgress) => {
const text = `${item.title} ${item.detail ?? ""}`;
if (text.includes("dynamic_http_call")) return "查询后端数据";
if (text.includes("show_chart")) return "生成图表";
if (text.includes("locate_features")) return "地图定位";
if (text.includes("view_history")) return "打开历史曲线";
if (text.includes("view_scada")) return "打开 SCADA 面板";
return item.title;
};
export const AgentProgressTimeline = ({ progress }: { progress: ChatProgress[] }) => {
const theme = useTheme();
const hasComplete = 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 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]);
return (
<Box
sx={{
borderRadius: 3,
bgcolor: alpha(theme.palette.primary.main, 0.045),
border: `1px solid ${alpha(theme.palette.primary.main, 0.14)}`,
overflow: "hidden",
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ px: 1.5, py: 1.1 }}
>
<AutoAwesome sx={{ fontSize: 17, color: "primary.main" }} />
<Typography variant="caption" fontWeight={800} color="text.primary">
Agent
</Typography>
<Chip
size="small"
label={summary}
color={hasError ? "error" : hasRunning ? "primary" : "success"}
variant="outlined"
sx={{ height: 22, fontSize: "0.68rem", maxWidth: 180 }}
/>
<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}
<Box
sx={{
position: "relative",
zIndex: 1,
width: 18,
height: 18,
borderRadius: "50%",
bgcolor: alpha("#fff", 0.92),
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{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"
sx={{
display: "block",
mt: 0.25,
m: 0,
whiteSpace: "pre-wrap",
fontFamily: "inherit",
fontSize: "0.7rem",
}}
>
{item.detail}
</Typography>
) : null}
</Box>
</Stack>
))}
</Stack>
</Collapse>
</Box>
);
};
+299
View File
@@ -0,0 +1,299 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Typography,
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 {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
type AgentTurnProps = {
message: Message;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
};
const MarkdownBlock = ({ children }: { children: string }) => (
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
export const AgentTurn = React.memo(
({
message,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
}: AgentTurnProps) => {
const theme = useTheme();
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
if (isUser) {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
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%" }}
>
<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>
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ width: "100%" }}
>
<Stack direction="row" spacing={1.25} 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),
mt: 0.25,
}}
>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 17, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 17, color: "secondary.main" }} />
)}
</Avatar>
<Paper
elevation={0}
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",
}}
>
<Stack spacing={1.4}>
{message.progress?.length && !isErrorMessage ? (
<AgentProgressTimeline progress={message.progress} />
) : 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,
)}`,
}}
>
<Stack spacing={1}>
{!isErrorMessage ? (
<Typography variant="caption" color="text.secondary" fontWeight={800}>
</Typography>
) : null}
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={(p.series as ChatChartSeries[]) ?? []}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<Typography key="tool-pending" variant="caption" color="text.secondary">
...
</Typography>
);
}
return null;
})}
</Stack>
</Box>
{message.artifacts?.length ? (
<AgentArtifactPanel artifacts={message.artifacts} />
) : null}
</Stack>
</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 }}
>
<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}
</Stack>
) : null}
</motion.div>
);
},
);
AgentTurn.displayName = "AgentTurn";
+177
View File
@@ -0,0 +1,177 @@
"use client";
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 WaterDropRounded from "@mui/icons-material/WaterDropRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
import { AgentTurn } from "./AgentTurn";
import { TypingIndicator } from "./GlobalChatbox.parts";
import type { Message, SpeechState } from "./GlobalChatbox.types";
type AgentWorkspaceProps = {
messages: Message[];
isStreaming: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
};
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: "改造与调度建议" },
];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%" }}
>
<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",
textAlign: "center",
backdropFilter: "blur(10px)",
}}
>
<motion.div
animate={{ y: [-5, 5, -5] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
>
<AutoAwesome
sx={{
fontSize: 54,
color: "primary.main",
mb: 1.6,
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))",
}}
/>
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={900} gutterBottom>
Agent
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.65, mb: 2 }}>
</Typography>
<Stack direction="row" spacing={0.8} useFlexGap flexWrap="wrap" justifyContent="center">
{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>
))}
</Stack>
</Paper>
</motion.div>
);
};
export const AgentWorkspace = ({
messages,
isStreaming,
bottomRef,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
isTtsSupported,
}: AgentWorkspaceProps) => {
const theme = useTheme();
const latestAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
const showTypingIndicator =
isStreaming &&
(!latestAssistant ||
(latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length)));
return (
<Box
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
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>
{showTypingIndicator ? (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
>
<Paper
elevation={0}
sx={{
p: 1.3,
borderRadius: 4,
bgcolor: alpha("#fff", 0.82),
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
}}
>
<TypingIndicator />
</Paper>
</motion.div>
) : null}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
);
};
+1 -430
View File
@@ -1,39 +1,8 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
Chip,
IconButton,
LinearProgress,
Paper,
Stack,
Typography,
alpha,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import HourglassEmptyRounded from "@mui/icons-material/HourglassEmptyRounded";
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 {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { ChatProgress, Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { Box, Stack } from "@mui/material";
export const TypingIndicator = () => {
return (
@@ -105,401 +74,3 @@ export const Blob = ({
}}
/>
);
type ChatMessageItemProps = {
message: Message;
theme: Theme;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
export const ChatMessageItem = React.memo(
({
message,
theme,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
sseChartParams,
}: ChatMessageItemProps) => {
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
return (
<motion.div
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar
sx={{
width: 28,
height: 28,
bgcolor: isErrorMessage
? alpha(theme.palette.error.main, 0.12)
: alpha(theme.palette.secondary.main, 0.1),
mb: 0.5,
}}
>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
)}
</Avatar>
)}
<Box>
<Paper
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser
? "primary.main"
: isErrorMessage
? alpha(theme.palette.error.light, 0.18)
: "#fff",
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
background: isUser
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: isErrorMessage
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
: undefined,
border: isErrorMessage
? `1px solid ${alpha(theme.palette.error.main, 0.35)}`
: "none",
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
? alpha("#fff", 0.96)
: isErrorMessage
? theme.palette.error.dark
: "#1f2937",
"--chat-md-heading": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#111827",
"--chat-md-link": isUser
? "#E3F2FD"
: isErrorMessage
? theme.palette.error.main
: "#7C3AED",
"--chat-md-link-hover": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#6D28D9",
"--chat-md-inline-code-bg": isUser
? "rgba(255,255,255,0.2)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isUser
? alpha("#fff", 0.16)
: isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isUser
? "rgba(11, 18, 32, 0.56)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isUser
? alpha("#fff", 0.12)
: isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isUser
? "#F8FAFC"
: isErrorMessage
? theme.palette.error.dark
: "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: isUser
? alpha("#fff", 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isUser
? alpha("#fff", 0.9)
: isErrorMessage
? theme.palette.error.dark
: "#475569",
}}
>
{!isUser && !isErrorMessage && message.progress?.length ? (
<ChatProgressPanel progress={message.progress} />
) : null}
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={
(p.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(chart.params.x_data as string[]) ?? []}
series={
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
{messageSpeechState === "idle" && (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{
color: "text.secondary",
opacity: 0.6,
"&:hover": { opacity: 1 },
p: 0.5,
}}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
)}
{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>
</>
)}
{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>
</>
)}
</Stack>
)}
</Box>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
const ChatProgressPanel = ({ progress }: { progress: ChatProgress[] }) => {
const isComplete = progress.some(
(item) => item.phase === "complete" && item.status === "completed",
);
const latestRunning = isComplete
? undefined
: [...progress].reverse().find((item) => item.status === "running");
return (
<Box
sx={{
mb: 1.5,
p: 1.25,
borderRadius: 2.5,
bgcolor: "rgba(99, 102, 241, 0.06)",
border: "1px solid rgba(99, 102, 241, 0.14)",
}}
>
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesome sx={{ fontSize: 16, color: "primary.main" }} />
<Typography variant="caption" fontWeight={800} color="text.primary">
Agent
</Typography>
{latestRunning ? (
<Chip
size="small"
label={latestRunning.title}
sx={{ height: 20, fontSize: "0.68rem", bgcolor: "rgba(124, 58, 237, 0.08)" }}
/>
) : null}
</Stack>
{latestRunning ? <LinearProgress sx={{ height: 4, borderRadius: 99 }} /> : null}
<Stack spacing={0.7}>
{progress.slice(-5).map((item) => (
<Stack key={item.id} direction="row" spacing={0.8} alignItems="flex-start">
{item.status === "completed" ? (
<CheckCircleRounded sx={{ fontSize: 15, color: "success.main", mt: 0.2 }} />
) : item.status === "error" ? (
<ErrorOutlineRounded sx={{ fontSize: 15, color: "error.main", mt: 0.2 }} />
) : (
<HourglassEmptyRounded sx={{ fontSize: 15, color: "primary.main", mt: 0.2 }} />
)}
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" color="text.primary" fontWeight={700}>
{item.title}
</Typography>
{item.detail ? (
<Typography
variant="caption"
component="pre"
color="text.secondary"
sx={{
display: "block",
mt: 0.25,
m: 0,
whiteSpace: "pre-wrap",
fontFamily: "inherit",
fontSize: "0.7rem",
}}
>
{item.detail}
</Typography>
) : null}
</Box>
</Stack>
))}
</Stack>
</Stack>
</Box>
);
};
+77 -818
View File
@@ -1,83 +1,28 @@
"use client";
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Box, Drawer, alpha, useTheme } from "@mui/material";
// MUI
import {
Avatar,
Box,
Drawer,
IconButton,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
Paper,
Stack,
TextField,
Typography,
useTheme,
alpha,
} from "@mui/material";
// Icons
import CloseRounded from "@mui/icons-material/CloseRounded";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import AutoAwesome from "@mui/icons-material/AutoAwesome"; // Sparkle icon for AI
import AddCommentRounded from "@mui/icons-material/AddCommentRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
// Logic
import { streamAgentChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
import type { Message, PersistedChatState, Props } from "./GlobalChatbox.types";
import {
CHAT_STORAGE_KEY,
PRESET_PROMPTS,
createId,
getInitialChatState,
normalizeThoughtTagToken,
} from "./GlobalChatbox.utils";
import { Blob, ChatMessageItem, TypingIndicator } from "./GlobalChatbox.parts";
import { AgentComposer } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader";
import { AgentWorkspace } from "./AgentWorkspace";
import { Blob } from "./GlobalChatbox.parts";
import type { Props } from "./GlobalChatbox.types";
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
initialChatStateRef.current = getInitialChatState();
}
const [messages, setMessages] = useState<Message[]>(initialChatStateRef.current.messages);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [width, setWidth] = useState(480);
const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(
initialChatStateRef.current.sessionId
);
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
// SSE tool_call → inline chart data (keyed by assistantMessageId)
const [sseCharts, setSseCharts] = useState<
Record<string, Array<{ tool: string; params: Record<string, unknown> }>>
>({});
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
const abortRef = useRef<AbortController | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const theme = useTheme();
// --- Voice Features ---
const {
speechState,
speakingMessageId,
@@ -99,10 +44,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isSupported: isSttSupported,
} = useSpeechRecognition(handleSpeechResult);
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
const isHeaderMenuOpen = Boolean(headerMenuAnchorEl);
const handleToolCall = useAgentToolActions();
const {
messages,
isStreaming,
sendPrompt,
abort,
reset,
} = useAgentChatSession({
onToolCall: handleToolCall,
onBeforeSend: stopListening,
});
// Auto-scroll
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
@@ -116,337 +69,49 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
const state: PersistedChatState = { messages, sessionId };
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
}
}, [messages, sessionId]);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming) return;
stopListening();
const userId = createId();
const assistantId = createId();
setInput("");
setIsStreaming(true);
setMessages((prev) => [
...prev,
{ id: userId, role: "user", content: prompt },
{ id: assistantId, role: "assistant", content: "" },
]);
const controller = new AbortController();
abortRef.current = controller;
// Track SSE tool_call hashes to deduplicate against text-parsed tool_calls
const sseToolHashes = new Set<string>();
const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => {
const { tool, params } = event;
const hash = `${tool}:${JSON.stringify(params)}`;
sseToolHashes.add(hash);
const startTime =
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined);
const endTime =
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined);
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
// show_chart → store as inline chart for rendering
if (tool === "show_chart") {
setSseCharts((prev) => ({
...prev,
[assistantId]: [
...(prev[assistantId] ?? []),
{ tool, params },
],
}));
return;
}
// Other frontend tools → dispatch to chatToolStore immediately
const normalizeIds = (): 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);
}
return [];
};
const buildLocateFeaturesAction = (
layer: string,
geometryKind: "point" | "line",
): ChatToolAction => ({
type: "locate_features" as const,
ids: normalizeIds(),
layer,
geometryKind,
});
const buildLocateByFeatureType = (): ChatToolAction | null => {
const rawType = params.feature_type;
const featureType =
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
const featureTypeMap: Record<
string,
{ layer: string; geometryKind: "point" | "line" }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line" },
valve: { layer: "geo_valves", geometryKind: "point" },
valves: { layer: "geo_valves", geometryKind: "point" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point" },
pump: { layer: "geo_pumps", geometryKind: "point" },
pumps: { layer: "geo_pumps", geometryKind: "point" },
tank: { layer: "geo_tanks", geometryKind: "point" },
tanks: { layer: "geo_tanks", geometryKind: "point" },
};
const config = featureTypeMap[featureType];
if (!config) return null;
return buildLocateFeaturesAction(config.layer, config.geometryKind);
};
const actionMap: Record<string, () => ChatToolAction | null> = {
locate_features: buildLocateByFeatureType,
locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"),
locate_junctions: () =>
buildLocateFeaturesAction("geo_junctions_mat", "point"),
locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"),
locate_reservoirs: () =>
buildLocateFeaturesAction("geo_reservoirs", "point"),
locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"),
locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"),
view_history: () => ({
type: "view_history" as const,
featureInfos: (params.feature_infos as [string, string][]) ?? [],
dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime",
startTime,
endTime,
}),
view_scada: () => ({
type: "view_scada" as const,
featureInfos: resolveScadaFeatureInfos(),
startTime,
endTime,
}),
};
const buildAction = actionMap[tool];
if (buildAction) {
const action = buildAction();
if (action) dispatchToolAction(action);
}
};
try {
await streamAgentChat({
message: prompt,
sessionId,
signal: controller.signal,
onEvent: (event) => {
if (event.type === "token") {
if (!sessionId && event.sessionId) setSessionId(event.sessionId);
const normalizedToken = normalizeThoughtTagToken(event.content);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: m.content + normalizedToken, isError: false }
: m
)
);
} else if (event.type === "done") {
if (!sessionId && event.sessionId) setSessionId(event.sessionId);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId && m.content.trim().length === 0
? {
...m,
content: "⚠️ **错误:** Agent 未返回内容,请稍后重试。",
isError: true,
}
: m.id === assistantId
? {
...m,
progress: m.progress?.map((item) =>
item.status === "running"
? { ...item, status: "completed" as const }
: item,
),
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "progress") {
if (!sessionId && event.sessionId) setSessionId(event.sessionId);
setMessages((prev) =>
prev.map((m) => {
if (m.id !== assistantId) return m;
const progress = [...(m.progress ?? [])];
const index = progress.findIndex((item) => item.id === event.id);
const nextProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
};
if (index >= 0) {
progress[index] = nextProgress;
} else {
progress.push(nextProgress);
}
return { ...m, progress };
})
);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: m.content || `⚠️ **错误:** ${event.message}`,
isError: true,
}
: m
)
);
setIsStreaming(false);
} else if (event.type === "tool_call") {
handleSseToolCall(event);
}
},
});
} catch (error) {
if (abortRef.current?.signal.aborted) {
setMessages((prev) =>
prev.filter((m) => !(m.id === assistantId && m.role === "assistant" && m.content.trim().length === 0))
);
return;
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `⚠️ **错误:** ${String(error)}`, isError: true }
: m
)
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[sessionId, isStreaming, stopListening, dispatchToolAction],
);
const handleSend = async () => {
const handleSend = useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming) return;
await sendPrompt(prompt);
};
const handleAbort = () => {
abortRef.current?.abort();
setIsStreaming(false);
};
setInput("");
void sendPrompt(prompt);
}, [input, isStreaming, sendPrompt]);
const handlePresetPromptSelect = useCallback((prompt: string) => {
setInput(prompt);
setIsPresetPanelOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
const handleHeaderMenuOpen = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
setHeaderMenuAnchorEl(event.currentTarget);
},
[],
);
const handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => {
setHeaderMenuAnchorEl(event.currentTarget);
}, []);
const handleHeaderMenuClose = useCallback(() => {
setHeaderMenuAnchorEl(null);
}, []);
const handleNewConversation = useCallback(() => {
abortRef.current?.abort();
handleStopSpeech();
stopListening();
setMessages([]);
setSessionId(undefined);
reset();
setInput("");
setIsStreaming(false);
handleHeaderMenuClose();
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, [handleHeaderMenuClose, handleStopSpeech, stopListening]);
}, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const handleMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault();
setIsResizing(true);
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const handleMouseMove = (event: MouseEvent) => {
if (!isResizing) return;
const newWidth = window.innerWidth - e.clientX;
if (newWidth > 320 && newWidth < 1200) {
const newWidth = window.innerWidth - event.clientX;
if (newWidth > 360 && newWidth < 1240) {
setWidth(newWidth);
}
};
@@ -466,26 +131,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
};
}, [isResizing]);
const renderedMessages = useMemo(
() =>
messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
theme={theme}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={handleSpeak}
onPause={handlePauseSpeech}
onResume={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
sseChartParams={sseCharts[message.id]}
/>
)),
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts],
);
return (
<Drawer
anchor="right"
@@ -499,9 +144,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
width: { xs: "100%", sm: width },
background: "transparent",
boxShadow: "none",
overflow: "visible", // Changed from "hidden" to show resizer handle if needed, though handle is inside.
overflow: "visible",
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)", // Disable transition during resize
transition: isResizing ? "none" : "width 0.2s cubic-bezier(0, 0, 0.2, 1)",
},
}}
>
@@ -510,12 +155,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#fff", 0.75), // Light glass base
bgcolor: alpha("#fff", 0.76),
backdropFilter: "blur(30px)",
position: "relative",
}}
>
{/* Resize Handle */}
<Box
onMouseDown={handleMouseDown}
sx={{
@@ -539,435 +183,50 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
height: "40px",
bgcolor: alpha(theme.palette.divider, 0.4),
borderRadius: "1px",
}
},
}}
/>
{/* Ambient Blobs */}
<Blob color={alpha(theme.palette.primary.main, 0.3)} size={300} top="-10%" left="-20%" delay={0} />
<Blob color={alpha(theme.palette.secondary.main, 0.3)} size={250} top="40%" left="60%" delay={2} />
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
<Blob color={alpha(theme.palette.primary.main, 0.28)} size={300} top="-10%" left="-20%" delay={0} />
<Blob color={alpha(theme.palette.secondary.main, 0.24)} size={250} top="40%" left="60%" delay={2} />
<Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} />
{/* Header - Transparent & Floating */}
<Box
sx={{
p: 3,
zIndex: 10,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div
whileHover={{ rotate: 10, scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
onClick={handleHeaderMenuOpen}
aria-label="打开聊天菜单"
aria-controls={isHeaderMenuOpen ? "global-chatbox-header-menu" : undefined}
aria-expanded={isHeaderMenuOpen ? "true" : undefined}
aria-haspopup="menu"
sx={{
p: 0,
borderRadius: "50%",
}}
>
<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,
}}
>
<AutoAwesome fontSize="medium" sx={{ color: "#fff" }} />
</Avatar>
<Box
sx={{
position: "absolute",
bottom: 2,
right: 2,
width: 12,
height: 12,
bgcolor: "success.main",
borderRadius: "50%",
border: "2px solid #fff",
boxShadow: "0 0 0 2px rgba(255,255,255,0.5)"
}}
/>
</Box>
</IconButton>
</motion.div>
<Box>
<Typography variant="h6" fontWeight={800} sx={{ background: `linear-gradient(90deg, ${theme.palette.primary.dark}, ${theme.palette.secondary.dark})`, backgroundClip: "text", color: "transparent", letterSpacing: -0.5 }}>
Agent
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
AI
</Typography>
</Box>
</Stack>
<AgentHeader
isStreaming={isStreaming}
menuAnchorEl={headerMenuAnchorEl}
onMenuOpen={handleHeaderMenuOpen}
onMenuClose={handleHeaderMenuClose}
onNewConversation={handleNewConversation}
onClose={onClose}
/>
<Menu
id="global-chatbox-header-menu"
anchorEl={headerMenuAnchorEl}
open={isHeaderMenuOpen}
onClose={handleHeaderMenuClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
elevation: 8,
sx: {
mt: 1,
minWidth: 180,
borderRadius: 3,
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
backdropFilter: "blur(12px)",
bgcolor: alpha("#fff", 0.92),
boxShadow: `0 16px 40px -16px ${alpha(theme.palette.common.black, 0.28)}`,
},
},
}}
>
<MenuItem onClick={handleNewConversation}>
<ListItemIcon>
<AddCommentRounded fontSize="small" />
</ListItemIcon>
<ListItemText
primary="新建对话"
secondary="清空当前会话"
primaryTypographyProps={{ sx: { fontSize: "0.95rem", fontWeight: 600 } }}
secondaryTypographyProps={{ sx: { fontSize: "0.8rem" } }}
/>
</MenuItem>
</Menu>
<motion.div whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<IconButton onClick={onClose} size="small" sx={{ color: "text.primary", bgcolor: alpha("#fff", 0.5), "&:hover": { bgcolor: "#fff" } }}>
<CloseRounded />
</IconButton>
</motion.div>
</Box>
<AgentWorkspace
messages={messages}
isStreaming={isStreaming}
bottomRef={bottomRef}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={handleSpeak}
onPauseSpeech={handlePauseSpeech}
onResumeSpeech={handleResumeSpeech}
onStopSpeech={handleStopSpeech}
isTtsSupported={isTtsSupported}
/>
{/* Messages - Bouncy List */}
<Box
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
py: 2,
display: "flex",
flexDirection: "column",
gap: 2.5,
zIndex: 5,
}}
>
<AnimatePresence initial={false}>
{messages.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%" }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 6,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
maxWidth: 320,
mx: "auto",
textAlign: "center",
backdropFilter: "blur(10px)",
}}
>
<motion.div
animate={{ y: [-5, 5, -5] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
>
<AutoAwesome sx={{ fontSize: 56, color: "primary.main", mb: 2, filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.1))" }} />
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={700} gutterBottom>
👋
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6 }}>
</Typography>
</Paper>
</motion.div>
)}
{renderedMessages}
</AnimatePresence>
{isStreaming && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 40 }}
>
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`
}}
>
<TypingIndicator />
</Paper>
</motion.div>
)}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
{/* Input Area - Floating Capsule */}
<Box sx={{ p: 3, zIndex: 10 }}>
<Box sx={{ mb: 1.25, display: "flex", justifyContent: "flex-end" }}>
<Box sx={{ position: "relative", width: "100%", maxWidth: 520, display: "flex", justifyContent: "flex-end" }}>
<AnimatePresence initial={false}>
{isPresetPanelOpen && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ position: "absolute", right: 0, bottom: "calc(100% + 10px)", width: "100%", zIndex: 3 }}
>
<Paper
elevation={12}
sx={{
p: 1.2,
borderRadius: 3,
bgcolor: alpha("#fff", 0.92),
backdropFilter: "blur(12px)",
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
boxShadow: `0 20px 48px -20px ${alpha(theme.palette.common.black, 0.3)}`,
}}
>
<Stack spacing={0.8}>
{PRESET_PROMPTS.map((prompt, index) => (
<Box
key={`preset-${index}`}
component="button"
type="button"
onClick={() => handlePresetPromptSelect(prompt)}
sx={{
textAlign: "left",
width: "100%",
px: 1.1,
py: 0.9,
borderRadius: 2,
border: `1px solid ${alpha(theme.palette.divider, 0.24)}`,
bgcolor: alpha("#fff", 0.72),
color: "text.secondary",
fontSize: "0.84rem",
lineHeight: 1.45,
cursor: "pointer",
transition: "all 0.18s ease",
"&:hover": {
borderColor: alpha(theme.palette.primary.main, 0.45),
color: "text.primary",
transform: "translateY(-1px)",
boxShadow: `0 8px 24px -16px ${alpha(theme.palette.primary.main, 0.6)}`,
},
}}
>
{prompt}
</Box>
))}
</Stack>
</Paper>
</motion.div>
)}
</AnimatePresence>
<motion.div whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}>
<Paper
elevation={10}
sx={{
borderRadius: 99,
border: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
boxShadow: `0 14px 40px -14px ${alpha(theme.palette.primary.main, 0.35)}`,
overflow: "hidden",
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ pl: 1.2, pr: 0.5, py: 0.5 }}>
<Avatar
sx={{
width: 28,
height: 28,
background: `linear-gradient(135deg, ${theme.palette.primary.light}, ${theme.palette.secondary.main})`,
}}
>
<AutoAwesome sx={{ fontSize: 16, color: "#fff" }} />
</Avatar>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.2 }}>
</Typography>
<IconButton
size="small"
onClick={() => setIsPresetPanelOpen((prev) => !prev)}
aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"}
sx={{ color: "text.secondary" }}
>
{isPresetPanelOpen ? <KeyboardArrowDownRounded /> : <KeyboardArrowUpRounded />}
</IconButton>
</Stack>
</Paper>
</motion.div>
</Box>
</Box>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Stack
direction="row"
alignItems="center"
component={Paper}
elevation={12}
sx={{
p: "6px 8px",
borderRadius: 50, // Full capsule
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(10px)",
border: `1px solid ${alpha("#fff", 0.6)}`,
boxShadow: `0 12px 40px -8px ${alpha(theme.palette.primary.main, 0.15)}`,
transition: "all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 16px 48px -8px ${alpha(theme.palette.primary.main, 0.25)}`,
}
}}
>
<TextField
inputRef={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder="输入消息给 Agent..."
fullWidth
multiline
maxRows={3}
variant="standard"
InputProps={{
disableUnderline: true,
sx: { px: 2.5, py: 1.5, fontSize: "1rem" },
}}
/>
{isSttSupported && (
<Box sx={{ display: "flex", alignItems: "center", mr: 1 }}>
{isListening ? (
<motion.div
animate={{ scale: [1, 1.15, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={stopListening}
aria-label="停止语音输入"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.1),
width: 44,
height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) },
}}
>
<MicRounded />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={startListening}
disabled={isStreaming}
aria-label="语音输入"
sx={{
color: "text.secondary",
width: 44,
height: 44,
"&:hover": { color: "primary.main" },
}}
>
<MicRounded />
</IconButton>
)}
</Box>
)}
<Box sx={{ pr: 0.5 }}>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div
key="stop"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, rotate: 180 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
onClick={handleAbort}
sx={{
bgcolor: alpha(theme.palette.error.main, 0.1),
color: "error.main",
width: 44, height: 44,
"&:hover": { bgcolor: alpha(theme.palette.error.main, 0.2) }
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div
key="send"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<IconButton
disabled={!canSend}
onClick={() => void handleSend()}
sx={{
bgcolor: canSend ? "primary.main" : "action.disabledBackground",
color: "#fff",
width: 44, height: 44,
transition: "background-color 0.2s",
"&:hover": {
bgcolor: "primary.dark",
boxShadow: `0 4px 12px ${alpha(theme.palette.primary.main, 0.5)}`
}
}}
>
<SendRounded sx={{ ml: 0.5 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Box>
</Stack>
</motion.div>
</Box>
<AgentComposer
input={input}
inputRef={inputRef}
isStreaming={isStreaming}
isListening={isListening}
isSttSupported={isSttSupported}
presets={PRESET_PROMPTS}
onInputChange={setInput}
onSend={handleSend}
onAbort={abort}
onStartListening={startListening}
onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
/>
</Box>
</Drawer>
);
@@ -6,12 +6,24 @@ export type ChatProgress = {
detail?: string;
};
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
export type AgentArtifact = {
id: string;
tool: string;
kind: AgentArtifactKind;
title: string;
description?: string;
params: Record<string, unknown>;
};
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
progress?: ChatProgress[];
artifacts?: AgentArtifact[];
};
export type Props = {
+2 -7
View File
@@ -3,19 +3,14 @@ import type { PersistedChatState } from "./GlobalChatbox.types";
export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
const THINK_TAG_ALIAS_PATTERN =
/<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi;
export const PRESET_PROMPTS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
"查询关键 SCADA 点位最近 24 小时的异常波动。",
"排查当前管网爆管风险,并说明优先处置建议。",
];
export const normalizeThoughtTagToken = (token: string): string =>
token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) =>
closingSlash ? "</think>" : "<think>",
);
export const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
@@ -0,0 +1,239 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { streamAgentChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import type {
AgentArtifact,
ChatProgress,
Message,
PersistedChatState,
} from "../GlobalChatbox.types";
import { CHAT_STORAGE_KEY, createId, getInitialChatState } from "../GlobalChatbox.utils";
type UseAgentChatSessionOptions = {
onToolCall: (
event: StreamEvent & { type: "tool_call" },
options: {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
},
) => void;
onBeforeSend?: () => void;
};
const upsertProgress = (
progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" },
) => {
const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id);
const nextItem: ChatProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) =>
item.status === "running" ? { ...item, status: "completed" as const } : item,
);
export const useAgentChatSession = ({
onToolCall,
onBeforeSend,
}: UseAgentChatSessionOptions) => {
const initialChatStateRef = useRef<PersistedChatState | null>(null);
if (initialChatStateRef.current === null) {
initialChatStateRef.current = getInitialChatState();
}
const [messages, setMessages] = useState<Message[]>(
initialChatStateRef.current.messages,
);
const [sessionId, setSessionId] = useState<string | undefined>(
initialChatStateRef.current.sessionId,
);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
const state: PersistedChatState = { messages, sessionId };
try {
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
}
}, [messages, sessionId]);
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
setMessages((prev) =>
prev.map((message) =>
message.id === messageId
? {
...message,
artifacts: [...(message.artifacts ?? []), artifact],
}
: message,
),
);
}, []);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming) return;
onBeforeSend?.();
const userId = createId();
const assistantId = createId();
setIsStreaming(true);
setMessages((prev) => [
...prev,
{ id: userId, role: "user", content: prompt },
{ id: assistantId, role: "assistant", content: "" },
]);
const controller = new AbortController();
abortRef.current = controller;
try {
await streamAgentChat({
message: prompt,
sessionId,
signal: controller.signal,
onEvent: (event) => {
if ("sessionId" in event && !sessionId && event.sessionId) {
setSessionId(event.sessionId);
}
if (event.type === "token") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? {
...message,
content: message.content + event.content,
isError: false,
}
: message,
),
);
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? { ...message, progress: upsertProgress(message.progress, event) }
: message,
),
);
} else if (event.type === "tool_call") {
onToolCall(event, {
assistantMessageId: assistantId,
appendArtifact,
});
} else if (event.type === "done") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== assistantId) return message;
const completedProgress = completeRunningProgress(message.progress);
if (
message.content.trim().length === 0 &&
!(message.artifacts?.length)
) {
return {
...message,
content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress,
};
}
return { ...message, progress: completedProgress };
}),
);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? {
...message,
content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true,
progress: completeRunningProgress(message.progress),
}
: message,
),
);
setIsStreaming(false);
}
},
});
} catch (error) {
if (abortRef.current?.signal.aborted) {
setMessages((prev) =>
prev.filter(
(message) =>
!(
message.id === assistantId &&
message.role === "assistant" &&
message.content.trim().length === 0 &&
!(message.artifacts?.length)
),
),
);
return;
}
setMessages((prev) =>
prev.map((message) =>
message.id === assistantId
? {
...message,
content: `⚠️ **错误:** ${String(error)}`,
isError: true,
progress: completeRunningProgress(message.progress),
}
: message,
),
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[appendArtifact, isStreaming, onBeforeSend, onToolCall, sessionId],
);
const abort = useCallback(() => {
abortRef.current?.abort();
setIsStreaming(false);
}, []);
const reset = useCallback(() => {
abortRef.current?.abort();
setMessages([]);
setSessionId(undefined);
setIsStreaming(false);
}, []);
return {
messages,
isStreaming,
sessionId,
sendPrompt,
abort,
reset,
};
};
@@ -0,0 +1,237 @@
"use client";
import { useCallback } from "react";
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
import type { StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
type ToolCallEvent = StreamEvent & { type: "tool_call" };
type HandleToolCallOptions = {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
};
const FEATURE_TYPE_MAP: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
};
const LOCATE_TOOL_CONFIG: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
};
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);
}
return [];
};
const resolveScadaFeatureInfos = (params: Record<string, unknown>): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
const resolveTimeRange = (params: Record<string, unknown>) => ({
startTime:
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined),
endTime:
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined),
});
const compactNames = (names: string[]) => {
if (!names.length) return "";
return names.length > 3
? `${names.slice(0, 3).join(", ")}${names.length}`
: names.join(", ");
};
const buildLocateArtifact = (
tool: string,
params: Record<string, unknown>,
): { artifact: Omit<AgentArtifact, "id" | "params" | "tool">; action: ChatToolAction | null } => {
const ids = normalizeIds(params);
const rawType = params.feature_type;
const featureType =
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
const config = tool === "locate_features"
? FEATURE_TYPE_MAP[featureType]
: LOCATE_TOOL_CONFIG[tool];
return {
artifact: {
kind: "map",
title: config ? `地图定位${config.label}` : "地图定位",
description: compactNames(ids),
},
action: config
? {
type: "locate_features",
ids,
layer: config.layer,
geometryKind: config.geometryKind,
}
: null,
};
};
const buildToolAction = (
tool: string,
params: Record<string, unknown>,
): { action: ChatToolAction | null; kind: AgentArtifactKind; title: string; description?: string } => {
if (tool === "show_chart") {
return {
action: null,
kind: "chart",
title: (params.title as string | undefined) ?? "生成图表",
description: "已生成可视化图表",
};
}
if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) {
const locate = buildLocateArtifact(tool, params);
return {
action: locate.action,
kind: locate.artifact.kind,
title: locate.artifact.title,
description: locate.artifact.description,
};
}
if (tool === "view_history") {
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
const { startTime, endTime } = resolveTimeRange(params);
return {
action: {
type: "view_history",
featureInfos,
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime,
endTime,
},
kind: "panel",
title: "打开计算结果曲线",
description: compactNames(featureInfos.map(([id]) => id)),
};
}
if (tool === "view_scada") {
const featureInfos = resolveScadaFeatureInfos(params);
const { startTime, endTime } = resolveTimeRange(params);
return {
action: {
type: "view_scada",
featureInfos,
startTime,
endTime,
},
kind: "panel",
title: "打开 SCADA 数据面板",
description: compactNames(featureInfos.map(([id]) => id)),
};
}
return {
action: null,
kind: "tool",
title: tool || "工具调用",
description: "Agent 已执行工具动作",
};
};
export const useAgentToolActions = () => {
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
return useCallback(
(event: ToolCallEvent, options: HandleToolCallOptions) => {
const { action, kind, title, description } = buildToolAction(
event.tool,
event.params,
);
options.appendArtifact(options.assistantMessageId, {
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
tool: event.tool,
kind,
title,
description,
params: event.params,
});
if (action) {
dispatchToolAction(action);
}
},
[dispatchToolAction],
);
};