diff --git a/src/components/chat/AgentArtifactPanel.tsx b/src/components/chat/AgentArtifactPanel.tsx
new file mode 100644
index 0000000..ab20934
--- /dev/null
+++ b/src/components/chat/AgentArtifactPanel.tsx
@@ -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 ;
+ if (kind === "map") return ;
+ if (kind === "panel") return ;
+ return ;
+};
+
+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 (
+
+
+
+ 结果与动作
+
+
+
+
+ {artifacts.map((artifact) => {
+ const color = artifactColor(artifact.kind, theme);
+ if (artifact.kind === "chart") {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {artifactIcon(artifact.kind)}
+
+
+
+ {artifact.title}
+
+ {artifact.description ? (
+
+ {artifact.description}
+
+ ) : null}
+
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx
new file mode 100644
index 0000000..af4e7c2
--- /dev/null
+++ b/src/components/chat/AgentComposer.tsx
@@ -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;
+ 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 (
+
+
+
+
+
+ 常用管网任务
+
+
+ setIsPresetOpen((value) => !value)}
+ aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
+ sx={{ width: 26, height: 26, color: "text.secondary" }}
+ >
+ {isPresetOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {presets.map((prompt) => (
+ {
+ 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",
+ },
+ }}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+ 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 ? (
+
+ {isListening ? (
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+ ) : null}
+
+
+
+ {isStreaming ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx
new file mode 100644
index 0000000..472ce3a
--- /dev/null
+++ b/src/components/chat/AgentHeader.tsx
@@ -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) => 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TJWater Agent
+
+
+ {isStreaming ? "正在分析管网任务" : "管网分析工作台"}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/chat/AgentProgressTimeline.test.tsx b/src/components/chat/AgentProgressTimeline.test.tsx
new file mode 100644
index 0000000..60cf369
--- /dev/null
+++ b/src/components/chat/AgentProgressTimeline.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx
new file mode 100644
index 0000000..fc92ef9
--- /dev/null
+++ b/src/components/chat/AgentProgressTimeline.tsx
@@ -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 ;
+ if (status === "error") return ;
+ if (phase === "planning") return ;
+ if (phase === "tool") return ;
+ if (phase === "complete") return ;
+ if (phase === "session") return ;
+ if (phase === "start") return ;
+ return ;
+};
+
+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 (
+
+
+
+
+ Agent 过程
+
+
+
+
+
+ {hasRunning ? : null}
+
+
+ {progress.map((item, index) => (
+
+
+ {index < progress.length - 1 ? (
+
+ ) : null}
+
+ {phaseIcon(
+ item.phase,
+ hasComplete && item.status === "running"
+ ? "completed"
+ : item.status,
+ )}
+
+
+
+
+ {item.phase === "tool" ? formatToolTitle(item) : item.title}
+
+ {item.detail ? (
+
+ {item.detail}
+
+ ) : null}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx
new file mode 100644
index 0000000..ff94738
--- /dev/null
+++ b/src/components/chat/AgentTurn.tsx
@@ -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 }) => (
+
+ {children}
+
+);
+
+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 (
+
+
+ {message.content}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {isErrorMessage ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {message.progress?.length && !isErrorMessage ? (
+
+ ) : null}
+
+
+
+ {!isErrorMessage ? (
+
+ 回答
+
+ ) : null}
+ {contentSegments.map((segment, segIdx) => {
+ if (segment.type === "text") {
+ const text = segment.content.trim();
+ if (!text && contentSegments.length > 1) return null;
+ return {text || "..."};
+ }
+ if (segment.type === "tool_call") {
+ if (
+ segment.toolCall.tool === "chart" ||
+ segment.toolCall.tool === "show_chart"
+ ) {
+ const p = segment.toolCall.params;
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+ if (segment.type === "tool_call_pending") {
+ return (
+
+ 正在准备工具调用...
+
+ );
+ }
+ return null;
+ })}
+
+
+
+ {message.artifacts?.length ? (
+
+ ) : null}
+
+
+
+
+ {!isErrorMessage && isTtsSupported ? (
+
+ {messageSpeechState === "idle" ? (
+ onSpeak(message.id, stripMarkdown(answerContent))}
+ aria-label="朗读消息"
+ sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
+ >
+
+
+ ) : null}
+ {messageSpeechState === "playing" ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
+ {messageSpeechState === "paused" ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+ );
+ },
+);
+
+AgentTurn.displayName = "AgentTurn";
diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx
new file mode 100644
index 0000000..208891f
--- /dev/null
+++ b/src/components/chat/AgentWorkspace.tsx
@@ -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;
+ 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: , label: "水力瓶颈识别" },
+ { icon: , label: "SCADA 异常分析" },
+ { icon: , label: "改造与调度建议" },
+ ];
+
+ return (
+
+
+
+
+
+
+ 管网分析 Agent 已就绪
+
+
+ 可以描述你的分析目标,我会展示规划、数据查询过程、地图动作和最终建议。
+
+
+ {capabilities.map((item) => (
+
+ {item.icon}
+
+ {item.label}
+
+
+ ))}
+
+
+
+ );
+};
+
+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 (
+
+
+ {messages.length === 0 ? : null}
+ {messages.map((message) => (
+
+ ))}
+
+
+ {showTypingIndicator ? (
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/src/components/chat/GlobalChatbox.parts.tsx b/src/components/chat/GlobalChatbox.parts.tsx
index 5b03fb8..780f839 100644
--- a/src/components/chat/GlobalChatbox.parts.tsx
+++ b/src/components/chat/GlobalChatbox.parts.tsx
@@ -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 }>;
-};
-
-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 (
-
- {!isUser && (
-
- {isErrorMessage ? (
-
- ) : (
-
- )}
-
- )}
-
-
-
- {!isUser && !isErrorMessage && message.progress?.length ? (
-
- ) : null}
- {contentSegments.map((segment, segIdx) => {
- if (segment.type === "text") {
- const text = segment.content.trim();
- if (!text && contentSegments.length > 1) return null;
- return (
-
-
- {text || "..."}
-
-
- );
- }
- if (segment.type === "tool_call") {
- if (segment.toolCall.tool === "chart") {
- return (
- )}
- />
- );
- }
- if (segment.toolCall.tool === "show_chart") {
- const p = segment.toolCall.params;
- return (
-
- );
- }
- return (
-
- );
- }
- if (segment.type === "tool_call_pending") {
- return (
-
-
-
- 正在准备工具调用...
-
-
- );
- }
- return null;
- })}
- {sseChartParams?.map((chart, idx) => (
-
- ))}
-
- {!isUser && !isErrorMessage && isTtsSupported && (
-
- {messageSpeechState === "idle" && (
- onSpeak(message.id, stripMarkdown(answerContent))}
- aria-label="朗读消息"
- sx={{
- color: "text.secondary",
- opacity: 0.6,
- "&:hover": { opacity: 1 },
- p: 0.5,
- }}
- >
-
-
- )}
- {messageSpeechState === "playing" && (
- <>
-
-
-
-
-
-
- >
- )}
- {messageSpeechState === "paused" && (
- <>
-
-
-
-
-
-
- >
- )}
-
- )}
-
-
- );
- },
-);
-
-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 (
-
-
-
-
-
- Agent 过程
-
- {latestRunning ? (
-
- ) : null}
-
- {latestRunning ? : null}
-
- {progress.slice(-5).map((item) => (
-
- {item.status === "completed" ? (
-
- ) : item.status === "error" ? (
-
- ) : (
-
- )}
-
-
- {item.title}
-
- {item.detail ? (
-
- {item.detail}
-
- ) : null}
-
-
- ))}
-
-
-
- );
-};
diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx
index 4868595..0e1af3c 100644
--- a/src/components/chat/GlobalChatbox.tsx
+++ b/src/components/chat/GlobalChatbox.tsx
@@ -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 = ({ open, onClose }) => {
- const initialChatStateRef = useRef(null);
- if (initialChatStateRef.current === null) {
- initialChatStateRef.current = getInitialChatState();
- }
-
- const [messages, setMessages] = useState(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(
- initialChatStateRef.current.sessionId
- );
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState(null);
- const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
- // SSE tool_call → inline chart data (keyed by assistantMessageId)
- const [sseCharts, setSseCharts] = useState<
- Record }>>
- >({});
-
- const dispatchToolAction = useChatToolStore((s) => s.dispatch);
- const abortRef = useRef(null);
const bottomRef = useRef(null);
const inputRef = useRef(null);
const theme = useTheme();
- // --- Voice Features ---
const {
speechState,
speakingMessageId,
@@ -99,10 +44,18 @@ export const GlobalChatbox: React.FC = ({ 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 = ({ 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();
-
- 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 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) => {
- setHeaderMenuAnchorEl(event.currentTarget);
- },
- [],
- );
+ const handleHeaderMenuOpen = useCallback((event: React.MouseEvent) => {
+ 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 = ({ open, onClose }) => {
};
}, [isResizing]);
- const renderedMessages = useMemo(
- () =>
- messages.map((message) => (
-
- )),
- [messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts],
- );
-
-
return (
= ({ 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 = ({ 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 */}
= ({ open, onClose }) => {
height: "40px",
bgcolor: alpha(theme.palette.divider, 0.4),
borderRadius: "1px",
- }
+ },
}}
/>
- {/* Ambient Blobs */}
-
-
-
+
+
+
- {/* Header - Transparent & Floating */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Agent
-
-
- 你的 AI 助手
-
-
-
+
-
-
-
-
-
-
-
-
+
- {/* Messages - Bouncy List */}
-
-
- {messages.length === 0 && (
-
-
-
-
-
-
- 你好呀!👋
-
-
- 我已准备好为你提供帮助,尽管问我吧!
-
-
-
- )}
-
- {renderedMessages}
-
-
- {isStreaming && (
-
-
-
-
-
- )}
-
-
-
-
- {/* Input Area - Floating Capsule */}
-
-
-
-
- {isPresetPanelOpen && (
-
-
-
- {PRESET_PROMPTS.map((prompt, index) => (
- 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}
-
- ))}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
- 常用功能
-
- setIsPresetPanelOpen((prev) => !prev)}
- aria-label={isPresetPanelOpen ? "收起常用功能" : "展开常用功能"}
- sx={{ color: "text.secondary" }}
- >
- {isPresetPanelOpen ? : }
-
-
-
-
-
-
-
-
-
- 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 && (
-
- {isListening ? (
-
-
-
-
-
- ) : (
-
-
-
- )}
-
- )}
-
-
-
- {isStreaming ? (
-
-
-
-
-
- ) : (
-
- 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)}`
- }
- }}
- >
-
-
-
- )}
-
-
-
-
-
+
);
diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts
index e8d0a85..745b769 100644
--- a/src/components/chat/GlobalChatbox.types.ts
+++ b/src/components/chat/GlobalChatbox.types.ts
@@ -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;
+};
+
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
progress?: ChatProgress[];
+ artifacts?: AgentArtifact[];
};
export type Props = {
diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts
index 94846a2..33879c6 100644
--- a/src/components/chat/GlobalChatbox.utils.ts
+++ b/src/components/chat/GlobalChatbox.utils.ts
@@ -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 ? "" : "",
- );
-
export const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts
new file mode 100644
index 0000000..e7d2074
--- /dev/null
+++ b/src/components/chat/hooks/useAgentChatSession.ts
@@ -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(null);
+ if (initialChatStateRef.current === null) {
+ initialChatStateRef.current = getInitialChatState();
+ }
+
+ const [messages, setMessages] = useState(
+ initialChatStateRef.current.messages,
+ );
+ const [sessionId, setSessionId] = useState(
+ initialChatStateRef.current.sessionId,
+ );
+ const [isStreaming, setIsStreaming] = useState(false);
+ const abortRef = useRef(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,
+ };
+};
diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts
new file mode 100644
index 0000000..d66d26f
--- /dev/null
+++ b/src/components/chat/hooks/useAgentToolActions.ts
@@ -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[] => {
+ 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, 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) => ({
+ 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,
+): { artifact: Omit; 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,
+): { 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],
+ );
+};