From e5ca9e24aa7762fce357488354684806083bcc47 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 29 Apr 2026 17:15:49 +0800 Subject: [PATCH] =?UTF-8?q?Agent=20=E5=88=9D=E7=89=88=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/AgentArtifactPanel.tsx | 128 +++ src/components/chat/AgentComposer.tsx | 241 +++++ src/components/chat/AgentHeader.tsx | 158 ++++ .../chat/AgentProgressTimeline.test.tsx | 60 ++ src/components/chat/AgentProgressTimeline.tsx | 188 ++++ src/components/chat/AgentTurn.tsx | 299 ++++++ src/components/chat/AgentWorkspace.tsx | 177 ++++ src/components/chat/GlobalChatbox.parts.tsx | 431 +-------- src/components/chat/GlobalChatbox.tsx | 895 ++---------------- src/components/chat/GlobalChatbox.types.ts | 12 + src/components/chat/GlobalChatbox.utils.ts | 9 +- .../chat/hooks/useAgentChatSession.ts | 239 +++++ .../chat/hooks/useAgentToolActions.ts | 237 +++++ 13 files changed, 1819 insertions(+), 1255 deletions(-) create mode 100644 src/components/chat/AgentArtifactPanel.tsx create mode 100644 src/components/chat/AgentComposer.tsx create mode 100644 src/components/chat/AgentHeader.tsx create mode 100644 src/components/chat/AgentProgressTimeline.test.tsx create mode 100644 src/components/chat/AgentProgressTimeline.tsx create mode 100644 src/components/chat/AgentTurn.tsx create mode 100644 src/components/chat/AgentWorkspace.tsx create mode 100644 src/components/chat/hooks/useAgentChatSession.ts create mode 100644 src/components/chat/hooks/useAgentToolActions.ts 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], + ); +};