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