Agent 初版设计
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -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,39 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import { Box, Stack } from "@mui/material";
|
||||||
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";
|
|
||||||
|
|
||||||
export const TypingIndicator = () => {
|
export const TypingIndicator = () => {
|
||||||
return (
|
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,83 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useRef, useState, useEffect, useCallback } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
// MUI
|
import { AgentComposer } from "./AgentComposer";
|
||||||
import {
|
import { AgentHeader } from "./AgentHeader";
|
||||||
Avatar,
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
Box,
|
import { Blob } from "./GlobalChatbox.parts";
|
||||||
Drawer,
|
import type { Props } from "./GlobalChatbox.types";
|
||||||
IconButton,
|
import { PRESET_PROMPTS } from "./GlobalChatbox.utils";
|
||||||
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 { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice";
|
||||||
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
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 [input, setInput] = useState("");
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [width, setWidth] = useState(520);
|
||||||
const [width, setWidth] = useState(480);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [sessionId, setSessionId] = useState<string | undefined>(
|
|
||||||
initialChatStateRef.current.sessionId
|
|
||||||
);
|
|
||||||
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
|
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 bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
// --- Voice Features ---
|
|
||||||
const {
|
const {
|
||||||
speechState,
|
speechState,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
@@ -99,10 +44,18 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isSupported: isSttSupported,
|
isSupported: isSttSupported,
|
||||||
} = useSpeechRecognition(handleSpeechResult);
|
} = useSpeechRecognition(handleSpeechResult);
|
||||||
|
|
||||||
const canSend = useMemo(() => input.trim().length > 0 && !isStreaming, [input, isStreaming]);
|
const handleToolCall = useAgentToolActions();
|
||||||
const isHeaderMenuOpen = Boolean(headerMenuAnchorEl);
|
const {
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
sendPrompt,
|
||||||
|
abort,
|
||||||
|
reset,
|
||||||
|
} = useAgentChatSession({
|
||||||
|
onToolCall: handleToolCall,
|
||||||
|
onBeforeSend: stopListening,
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-scroll
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages, isStreaming]);
|
}, [messages, isStreaming]);
|
||||||
@@ -116,337 +69,49 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSend = useCallback(() => {
|
||||||
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 prompt = input.trim();
|
const prompt = input.trim();
|
||||||
if (!prompt || isStreaming) return;
|
if (!prompt || isStreaming) return;
|
||||||
await sendPrompt(prompt);
|
setInput("");
|
||||||
};
|
void sendPrompt(prompt);
|
||||||
|
}, [input, isStreaming, sendPrompt]);
|
||||||
const handleAbort = () => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
setIsStreaming(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePresetPromptSelect = useCallback((prompt: string) => {
|
const handlePresetPromptSelect = useCallback((prompt: string) => {
|
||||||
setInput(prompt);
|
setInput(prompt);
|
||||||
setIsPresetPanelOpen(false);
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHeaderMenuOpen = useCallback(
|
const handleHeaderMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
(event: React.MouseEvent<HTMLElement>) => {
|
setHeaderMenuAnchorEl(event.currentTarget);
|
||||||
setHeaderMenuAnchorEl(event.currentTarget);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleHeaderMenuClose = useCallback(() => {
|
const handleHeaderMenuClose = useCallback(() => {
|
||||||
setHeaderMenuAnchorEl(null);
|
setHeaderMenuAnchorEl(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
stopListening();
|
stopListening();
|
||||||
setMessages([]);
|
reset();
|
||||||
setSessionId(undefined);
|
|
||||||
setInput("");
|
setInput("");
|
||||||
setIsStreaming(false);
|
|
||||||
handleHeaderMenuClose();
|
handleHeaderMenuClose();
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [handleHeaderMenuClose, handleStopSpeech, stopListening]);
|
}, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]);
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((event: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
const newWidth = window.innerWidth - e.clientX;
|
const newWidth = window.innerWidth - event.clientX;
|
||||||
if (newWidth > 320 && newWidth < 1200) {
|
if (newWidth > 360 && newWidth < 1240) {
|
||||||
setWidth(newWidth);
|
setWidth(newWidth);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -466,26 +131,6 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
};
|
};
|
||||||
}, [isResizing]);
|
}, [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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
anchor="right"
|
||||||
@@ -499,9 +144,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
width: { xs: "100%", sm: width },
|
width: { xs: "100%", sm: width },
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
boxShadow: "none",
|
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,
|
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%",
|
height: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
bgcolor: alpha("#fff", 0.75), // Light glass base
|
bgcolor: alpha("#fff", 0.76),
|
||||||
backdropFilter: "blur(30px)",
|
backdropFilter: "blur(30px)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Resize Handle */}
|
|
||||||
<Box
|
<Box
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -539,435 +183,50 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
height: "40px",
|
height: "40px",
|
||||||
bgcolor: alpha(theme.palette.divider, 0.4),
|
bgcolor: alpha(theme.palette.divider, 0.4),
|
||||||
borderRadius: "1px",
|
borderRadius: "1px",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Ambient Blobs */}
|
<Blob color={alpha(theme.palette.primary.main, 0.28)} size={300} top="-10%" left="-20%" delay={0} />
|
||||||
<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.24)} size={250} top="40%" left="60%" delay={2} />
|
||||||
<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.18)} size={200} top="80%" left="-10%" delay={4} />
|
||||||
<Blob color={alpha(theme.palette.success.light, 0.2)} size={200} top="80%" left="-10%" delay={4} />
|
|
||||||
|
|
||||||
{/* Header - Transparent & Floating */}
|
<AgentHeader
|
||||||
<Box
|
isStreaming={isStreaming}
|
||||||
sx={{
|
menuAnchorEl={headerMenuAnchorEl}
|
||||||
p: 3,
|
onMenuOpen={handleHeaderMenuOpen}
|
||||||
zIndex: 10,
|
onMenuClose={handleHeaderMenuClose}
|
||||||
display: "flex",
|
onNewConversation={handleNewConversation}
|
||||||
alignItems: "center",
|
onClose={onClose}
|
||||||
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>
|
|
||||||
|
|
||||||
<Menu
|
<AgentWorkspace
|
||||||
id="global-chatbox-header-menu"
|
messages={messages}
|
||||||
anchorEl={headerMenuAnchorEl}
|
isStreaming={isStreaming}
|
||||||
open={isHeaderMenuOpen}
|
bottomRef={bottomRef}
|
||||||
onClose={handleHeaderMenuClose}
|
speakingMessageId={speakingMessageId}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
speechState={speechState}
|
||||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
onSpeak={handleSpeak}
|
||||||
slotProps={{
|
onPauseSpeech={handlePauseSpeech}
|
||||||
paper: {
|
onResumeSpeech={handleResumeSpeech}
|
||||||
elevation: 8,
|
onStopSpeech={handleStopSpeech}
|
||||||
sx: {
|
isTtsSupported={isTtsSupported}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Messages - Bouncy List */}
|
<AgentComposer
|
||||||
<Box
|
input={input}
|
||||||
sx={{
|
inputRef={inputRef}
|
||||||
flex: 1,
|
isStreaming={isStreaming}
|
||||||
overflowY: "auto",
|
isListening={isListening}
|
||||||
px: 2.5,
|
isSttSupported={isSttSupported}
|
||||||
py: 2,
|
presets={PRESET_PROMPTS}
|
||||||
display: "flex",
|
onInputChange={setInput}
|
||||||
flexDirection: "column",
|
onSend={handleSend}
|
||||||
gap: 2.5,
|
onAbort={abort}
|
||||||
zIndex: 5,
|
onStartListening={startListening}
|
||||||
}}
|
onStopListening={stopListening}
|
||||||
>
|
onPresetSelect={handlePresetPromptSelect}
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,12 +6,24 @@ export type ChatProgress = {
|
|||||||
detail?: string;
|
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 = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
|
artifacts?: AgentArtifact[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
|||||||
@@ -3,19 +3,14 @@ import type { PersistedChatState } from "./GlobalChatbox.types";
|
|||||||
export const createId = () =>
|
export const createId = () =>
|
||||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
|
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 = [
|
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 =>
|
export const stripMarkdown = (md: string): string =>
|
||||||
md
|
md
|
||||||
.replace(/```[\s\S]*?```/g, "")
|
.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],
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user