From 36d1a8d6eaf11a085af6a8dd6b235d6fbcfe7a2c Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Apr 2026 13:05:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20Agent=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=86=E6=94=AF=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E6=B6=88=E6=81=AF=E5=85=8B=E9=9A=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ai-agent.svg | 1 + public/deepseek-logo.svg | 1 + src/components/chat/AgentComposer.tsx | 245 ++++---- src/components/chat/AgentHeader.tsx | 55 +- src/components/chat/AgentProgressTimeline.tsx | 304 ++++++---- src/components/chat/AgentTurn.tsx | 535 +++++++++++++----- src/components/chat/AgentWorkspace.tsx | 219 +++++-- src/components/chat/ChatToolCallBlock.tsx | 263 +++++---- src/components/chat/GlobalChatbox.tsx | 10 + src/components/chat/GlobalChatbox.types.ts | 29 + src/components/chat/GlobalChatbox.utils.ts | 25 +- .../chat/hooks/useAgentChatSession.ts | 367 ++++++++++-- .../chat/hooks/useAgentToolActions.ts | 47 +- src/components/olmap/SCADA/SCADADataPanel.tsx | 27 +- .../olmap/core/Controls/HistoryDataPanel.tsx | 33 +- .../olmap/core/Controls/PropertyPanel.tsx | 30 +- src/hooks/useChatToolActionHandler.ts | 17 +- src/lib/chatStream.test.ts | 47 +- src/lib/chatStream.ts | 49 ++ src/store/chatToolStore.ts | 4 + 20 files changed, 1722 insertions(+), 586 deletions(-) create mode 100644 public/ai-agent.svg create mode 100644 public/deepseek-logo.svg diff --git a/public/ai-agent.svg b/public/ai-agent.svg new file mode 100644 index 0000000..454f10d --- /dev/null +++ b/public/ai-agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/deepseek-logo.svg b/public/deepseek-logo.svg new file mode 100644 index 0000000..71fd103 --- /dev/null +++ b/public/deepseek-logo.svg @@ -0,0 +1 @@ + diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index af4e7c2..a6484a9 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -1,9 +1,9 @@ "use client"; +import Image from "next/image"; import React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { - Avatar, Box, Chip, Collapse, @@ -15,12 +15,12 @@ import { 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"; +import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; type AgentComposerProps = { input: string; @@ -56,30 +56,41 @@ export const AgentComposer = ({ const [isPresetOpen, setIsPresetOpen] = React.useState(false); return ( - + - - - - 常用管网任务 - + > + + TJWater Agent + + 管网分析快捷指令 + setIsPresetOpen((value) => !value)} aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"} - sx={{ width: 26, height: 26, color: "text.secondary" }} + sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }} > {isPresetOpen ? ( @@ -89,60 +100,56 @@ export const AgentComposer = ({ - - {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", - }, - }} - /> - ))} - + + + {presets.map((prompt) => ( + { + onPresetSelect(prompt); + setIsPresetOpen(false); + }} + sx={{ + height: 32, + borderRadius: "16px", + bgcolor: alpha("#fff", 0.7), + border: `1px solid ${alpha("#00acc1", 0.15)}`, + color: "text.primary", + fontWeight: 600, + fontSize: '0.85rem', + boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`, + backdropFilter: "blur(10px)", + "&:hover": { + bgcolor: alpha("#fff", 0.95), + boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`, + borderColor: alpha("#00acc1", 0.4), + color: "#00acc1" + } + }} + /> + ))} + + - - - - - {isSttSupported ? ( - - {isListening ? ( - - + + + + + {isSttSupported ? ( + isListening ? ( + - + + + + + ) : ( + + - - ) : ( - - - - )} - - ) : null} + ) + ) : null} + - {isStreaming ? ( @@ -220,12 +235,14 @@ export const AgentComposer = ({ disabled={!canSend} onClick={onSend} aria-label="发送" + size="small" sx={{ - bgcolor: canSend ? "primary.main" : "action.disabledBackground", - color: "#fff", - width: 42, - height: 42, - "&:hover": { bgcolor: canSend ? "primary.dark" : "action.disabledBackground" }, + bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5), + color: canSend ? "#fff" : "action.disabled", + width: 40, + height: 40, + boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none", + "&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) }, }} > @@ -233,9 +250,21 @@ export const AgentComposer = ({ )} - - + + + + DeepSeek + + Powered by DeepSeek V3 · TJWater Agent Intelligence + + ); }; diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index 472ce3a..b627e72 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import React from "react"; import { motion } from "framer-motion"; import { @@ -15,7 +16,6 @@ import { 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"; @@ -48,10 +48,14 @@ export const AgentHeader = ({ display: "flex", alignItems: "center", justifyContent: "space-between", + backdropFilter: "blur(20px)", + borderBottom: `1px solid ${alpha(theme.palette.divider, 0.1)}`, + background: `linear-gradient(to bottom, ${alpha("#fff", 0.4)}, ${alpha("#fff", 0.1)})`, + boxShadow: `0 1px 0 ${alpha("#fff", 0.6)} inset`, }} > - + - + TJWater Agent @@ -89,18 +108,18 @@ export const AgentHeader = ({ TJWater Agent - - {isStreaming ? "正在分析管网任务" : "管网分析工作台"} + + {isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"} diff --git a/src/components/chat/AgentProgressTimeline.tsx b/src/components/chat/AgentProgressTimeline.tsx index fc92ef9..7cbb327 100644 --- a/src/components/chat/AgentProgressTimeline.tsx +++ b/src/components/chat/AgentProgressTimeline.tsx @@ -3,8 +3,6 @@ import React, { useMemo, useState } from "react"; import { Box, - Button, - Chip, Collapse, LinearProgress, Stack, @@ -20,6 +18,7 @@ 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 KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded"; import type { ChatProgress } from "./GlobalChatbox.types"; @@ -27,12 +26,12 @@ 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 === "planning") return ; if (phase === "tool") return ; if (phase === "complete") return ; if (phase === "session") return ; - if (phase === "start") return ; - return ; + if (phase === "start") return ; + return ; }; const formatToolTitle = (item: ChatProgress) => { @@ -45,143 +44,224 @@ const formatToolTitle = (item: ChatProgress) => { return item.title; }; -export const AgentProgressTimeline = ({ progress }: { progress: ChatProgress[] }) => { +export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => { const theme = useTheme(); - const hasComplete = progress.some( + + // 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功) + const isOverallComplete = 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 hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running"); + const hasError = isAborted || progress.some((item) => item.status === "error"); + + // 展开状态逻辑:默认折叠,保持界面整洁 + const [expanded, setExpanded] = useState(false); 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]); + if (isAborted) return `已中断 (进行到第 ${progress.length} 步)`; + if (isOverallComplete) { + return hasError ? `已完成 (含 ${progress.length} 步探索)` : `已完成 (${progress.length} 步)`; + } + const runningItem = [...progress].reverse().find((item) => item.status === "running"); + if (runningItem) return `${runningItem.title}...`; + if (hasError) return "过程异常,尝试恢复中..."; + return `已执行 ${progress.length} 步`; + }, [isOverallComplete, hasError, progress, isAborted]); + + // 根据整体状态决定顶部卡片的颜色主题 + const statusColor = isOverallComplete + ? "#4caf50" // Success Green + : isAborted || (hasError && !hasRunning) + ? theme.palette.error.main // Error Red + : "#00acc1"; // Primary Cyan + + // 默认折叠:只显示最新的三条 + const visibleCount = 3; + const isCollapsible = progress.length > visibleCount; return ( setExpanded(!expanded)} + sx={{ + px: 2, + py: 1.25, + cursor: "pointer", + userSelect: "none" + }} > - - - Agent 过程 + {isOverallComplete ? ( + + ) : hasRunning ? ( + + ) : hasError ? ( + + ) : ( + + )} + + + Agent 过程: {summary} - - - - {hasRunning ? : null} - - - {progress.map((item, index) => ( - - - {index < progress.length - 1 ? ( - - ) : null} + + {hasRunning && !expanded ? ( + + ) : null} + + + + {hasRunning ? ( + + ) : ( + + )} + + {progress.map((item, index) => { + const isLast = index === progress.length - 1; + const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount; + + const itemColor = isAborted && isLast + ? theme.palette.error.main + : item.status === "error" + ? theme.palette.error.main + : item.status === "completed" + ? "#4caf50" + : "#00acc1"; + + const content = ( + - {phaseIcon( - item.phase, - hasComplete && item.status === "running" - ? "completed" - : item.status, - )} - - - - - {item.phase === "tool" ? formatToolTitle(item) : item.title} - - {item.detail ? ( - + ) : null} + - {item.detail} + {phaseIcon( + item.phase, + isAborted && isLast ? "error" : + isOverallComplete && item.status === "running" + ? "completed" + : item.status, + )} + + + + + {item.phase === "tool" ? formatToolTitle(item) : item.title} - ) : null} - - - ))} - + + {item.detail && ( + + + {item.detail} + + + )} + + + ); + + if (isHiddenWhenCollapsed) { + return ( + + {content} + + ); + } + return content; + })} + + ); diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index ff94738..65cc1d9 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -1,12 +1,14 @@ "use client"; +import Image from "next/image"; import React from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { motion } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { Avatar, Box, + Button, IconButton, Paper, Stack, @@ -14,35 +16,44 @@ import { 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 ContentCopyRounded from "@mui/icons-material/ContentCopyRounded"; +import RefreshRounded from "@mui/icons-material/RefreshRounded"; +import EditRounded from "@mui/icons-material/EditRounded"; +import CloseRounded from "@mui/icons-material/CloseRounded"; +import ChevronLeftRounded from "@mui/icons-material/ChevronLeftRounded"; +import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded"; import { parseAssistantMessageSections, parseContentWithToolCalls, type ContentSegment, } from "./chatMessageSections"; import markdownStyles from "./GlobalChatboxMarkdown.module.css"; -import type { Message, SpeechState } from "./GlobalChatbox.types"; +import type { BranchState, Message, SpeechState } from "./GlobalChatbox.types"; import { stripMarkdown } from "./GlobalChatbox.utils"; +import { AgentProgressTimeline } from "./AgentProgressTimeline"; +import { ChatInlineChart } from "./ChatInlineChart"; +import type { ChatChartSeries } from "./ChatInlineChart"; +import { ChatToolCallBlock } from "./ChatToolCallBlock"; +import { AgentArtifactPanel } from "./AgentArtifactPanel"; +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 SendRounded from "@mui/icons-material/SendRounded"; type AgentTurnProps = { message: Message; + branchState?: BranchState; messageSpeechState: SpeechState; onSpeak: (messageId: string, text: string) => void; onPause: () => void; onResume: () => void; onStopSpeech: () => void; isTtsSupported: boolean; + onRegenerate: () => void; + onEditResubmit: (messageId: string, newContent: string) => void; + onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; }; const MarkdownBlock = ({ children }: { children: string }) => ( @@ -54,16 +65,25 @@ const MarkdownBlock = ({ children }: { children: string }) => ( export const AgentTurn = React.memo( ({ message, + branchState, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, + onRegenerate, + onEditResubmit, + onCycleBranch, }: AgentTurnProps) => { const theme = useTheme(); const isUser = message.role === "user"; const isErrorMessage = Boolean(message.isError); + const [isHovered, setIsHovered] = React.useState(false); + const [isEditing, setIsEditing] = React.useState(false); + const [editDraft, setEditDraft] = React.useState(message.content); + const rootMessageId = message.branchRootId ?? message.id; + const parsedAssistantSections = !isUser && !isErrorMessage ? parseAssistantMessageSections(message.content) @@ -73,7 +93,7 @@ export const AgentTurn = React.memo( !isUser && !isErrorMessage ? parseContentWithToolCalls(answerContent).segments : [{ type: "text", content: answerContent }]; - + if (isUser) { return ( setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - - {message.content} - + {isEditing ? ( + + setEditDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (editDraft.trim() !== message.content) { + onEditResubmit(message.id, editDraft); + } + setIsEditing(false); + } else if (e.key === "Escape") { + setEditDraft(message.content); + setIsEditing(false); + } + }} + sx={{ + width: "100%", + minHeight: 60, + bgcolor: "transparent", + border: "none", + outline: "none", + resize: "none", + fontFamily: "inherit", + fontSize: "1rem", + color: "text.primary", + lineHeight: 1.6, + }} + /> + + { setEditDraft(message.content); setIsEditing(false); }} + sx={{ + bgcolor: alpha("#000", 0.05), + color: "text.secondary", + width: 34, height: 34, + "&:hover": { bgcolor: alpha("#000", 0.1) } + }} + > + + + { + onEditResubmit(message.id, editDraft); + setIsEditing(false); + }} + sx={{ + bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00acc1" : alpha("#000", 0.1), + color: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#fff" : "action.disabled", + width: 34, height: 34, + boxShadow: editDraft.trim() !== "" && editDraft.trim() !== message.content ? `0 4px 12px ${alpha("#00acc1", 0.4)}` : "none", + "&:hover": { bgcolor: editDraft.trim() !== "" && editDraft.trim() !== message.content ? "#00838f" : alpha("#000", 0.1) } + }} + > + + + + + ) : ( + <> + + {message.content} + + + {isHovered && !isEditing && ( + + { setIsEditing(true); setEditDraft(message.content); }} + aria-label="编辑提问" + sx={{ + width: 26, + height: 26, + bgcolor: alpha("#fff", 0.9), + color: "#00acc1", + boxShadow: `0 2px 8px ${alpha("#000", 0.15)}`, + "&:hover": { bgcolor: "#fff", color: "#00838f" } + }} + > + + + + )} + + + + {branchState && branchState.total > 1 ? ( + + + onCycleBranch(rootMessageId, -1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + + + + {branchState.activeIndex + 1} / {branchState.total} + + onCycleBranch(rootMessageId, 1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + + + + + ) : null} + + )} ); } @@ -119,24 +294,30 @@ export const AgentTurn = React.memo( animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} transition={{ type: "spring", stiffness: 320, damping: 26 }} - style={{ width: "100%" }} + style={{ width: "100%", position: "relative" }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - + - {isErrorMessage ? ( - - ) : ( - - )} + TJWater Agent - - {message.progress?.length && !isErrorMessage ? ( - + + {message.progress?.length ? ( + ) : null} - - {!isErrorMessage ? ( - - 回答 - - ) : null} + + + 分析结果 + {contentSegments.map((segment, segIdx) => { if (segment.type === "text") { const text = segment.content.trim(); @@ -249,45 +408,139 @@ export const AgentTurn = React.memo( })} - - {message.artifacts?.length ? ( - - ) : null} + + + {isHovered && !isErrorMessage && ( + + + { + navigator.clipboard.writeText(message.content); + // Could add a toast here + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + + + { + onRegenerate(); + }} + sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} + > + + + + + )} + + - {!isErrorMessage && isTtsSupported ? ( - - {messageSpeechState === "idle" ? ( - onSpeak(message.id, stripMarkdown(answerContent))} - aria-label="朗读消息" - sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} + {(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? ( + + + {!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} + + + {branchState && branchState.total > 1 ? ( + - - - ) : null} - {messageSpeechState === "playing" ? ( - <> - - - - - - - - ) : null} - {messageSpeechState === "paused" ? ( - <> - - - - - - - + + onCycleBranch(rootMessageId, -1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + + + + {branchState.activeIndex + 1} / {branchState.total} + + onCycleBranch(rootMessageId, 1)} + sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }} + > + + + + ) : null} ) : null} diff --git a/src/components/chat/AgentWorkspace.tsx b/src/components/chat/AgentWorkspace.tsx index 208891f..06eafb9 100644 --- a/src/components/chat/AgentWorkspace.tsx +++ b/src/components/chat/AgentWorkspace.tsx @@ -1,19 +1,27 @@ "use client"; +import Image from "next/image"; 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 { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material"; import WaterDropRounded from "@mui/icons-material/WaterDropRounded"; import SensorsRounded from "@mui/icons-material/SensorsRounded"; import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded"; +import MapRounded from "@mui/icons-material/MapRounded"; import { AgentTurn } from "./AgentTurn"; import { TypingIndicator } from "./GlobalChatbox.parts"; -import type { Message, SpeechState } from "./GlobalChatbox.types"; +import type { + BranchGroup, + BranchTransition, + Message, + SpeechState, +} from "./GlobalChatbox.types"; type AgentWorkspaceProps = { messages: Message[]; + branchGroups: BranchGroup[]; + branchTransition: BranchTransition | null; isStreaming: boolean; bottomRef: React.RefObject; speakingMessageId: string | null; @@ -23,14 +31,18 @@ type AgentWorkspaceProps = { onResumeSpeech: () => void; onStopSpeech: () => void; isTtsSupported: boolean; + onRegenerate: () => void; + onEditResubmit: (messageId: string, newContent: string) => void; + onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void; }; const EmptyState = () => { const theme = useTheme(); const capabilities = [ - { icon: , label: "水力瓶颈识别" }, - { icon: , label: "SCADA 异常分析" }, - { icon: , label: "改造与调度建议" }, + { icon: , label: "水力瓶颈识别" }, + { icon: , label: "异常状态预警" }, + { icon: , label: "调度与改造建议" }, + { icon: , label: "GIS 地图联动" }, ]; return ( @@ -38,62 +50,101 @@ const EmptyState = () => { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 200, damping: 20 }} - style={{ margin: "auto", width: "100%" }} + style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }} > + - - - 管网分析 Agent 已就绪 + + 我已就绪,请描述任务 - - 可以描述你的分析目标,我会展示规划、数据查询过程、地图动作和最终建议。 + + 你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。 - + + {capabilities.map((item) => ( - - {item.icon} - - {item.label} - - + + + + {item.icon} + + {item.label} + + + + ))} - + ); @@ -101,6 +152,8 @@ const EmptyState = () => { export const AgentWorkspace = ({ messages, + branchGroups, + branchTransition, isStreaming, bottomRef, speakingMessageId, @@ -110,6 +163,9 @@ export const AgentWorkspace = ({ onResumeSpeech, onStopSpeech, isTtsSupported, + onRegenerate, + onEditResubmit, + onCycleBranch, }: AgentWorkspaceProps) => { const theme = useTheme(); const latestAssistant = [...messages] @@ -120,6 +176,43 @@ export const AgentWorkspace = ({ (!latestAssistant || (latestAssistant.content.trim().length === 0 && !(latestAssistant.artifacts?.length))); + const stableMessages = branchTransition + ? messages.slice(0, branchTransition.parentCount) + : messages; + const transitionMessages = branchTransition + ? messages.slice(branchTransition.parentCount) + : []; + + const renderTurn = (message: Message) => { + const rootMessageId = message.branchRootId ?? message.id; + const branchGroup = branchGroups.find( + (group) => group.rootMessageId === rootMessageId, + ); + + return ( + 1 + ? { + activeIndex: branchGroup.activeIndex, + total: branchGroup.branches.length, + } + : undefined + } + messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} + onSpeak={onSpeak} + onPause={onPauseSpeech} + onResume={onResumeSpeech} + onStopSpeech={onStopSpeech} + isTtsSupported={isTtsSupported} + onRegenerate={onRegenerate} + onEditResubmit={onEditResubmit} + onCycleBranch={onCycleBranch} + /> + ); + }; return ( {messages.length === 0 ? : null} - {messages.map((message) => ( - - ))} + {messages.length > 0 ? ( + + {stableMessages.map(renderTurn)} + + {branchTransition ? ( + + + {transitionMessages.map(renderTurn)} + + + ) : null} + + ) : null} + {showTypingIndicator ? ( = { }; const LOCATE_LINE_TOOLS = new Set(["locate_pipes"]); +const LOCATE_ID_PARAM_KEYS = [ + "ids", + "id", + "feature_ids", + "feature_id", + "node_ids", + "node_id", + "junction_ids", + "junction_id", + "pipe_ids", + "pipe_id", + "valve_ids", + "valve_id", + "reservoir_ids", + "reservoir_id", + "pump_ids", + "pump_id", + "tank_ids", + "tank_id", +] as const; const TOOL_META: Record = { locate_features: { @@ -111,21 +135,32 @@ const TOOL_META: Record = { /* ---------- helpers ---------- */ -function getToolDescription(toolCall: ToolCall): string { - const { params } = toolCall; - const normalizeIds = (): string[] => { - const rawIds = params.ids; - if (Array.isArray(rawIds)) { - return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); +function normalizeLocateIds(params: Record): string[] { + for (const key of LOCATE_ID_PARAM_KEYS) { + const rawValue = params[key]; + if (Array.isArray(rawValue)) { + const normalized = rawValue + .map((id) => String(id).trim()) + .filter(Boolean); + if (normalized.length > 0) { + return normalized; + } } - if (typeof rawIds === "string") { - return rawIds + if (typeof rawValue === "string" || typeof rawValue === "number") { + const normalized = String(rawValue) .split(",") .map((id) => id.trim()) .filter(Boolean); + if (normalized.length > 0) { + return normalized; + } } - return []; - }; + } + return []; +} + +function getToolDescription(toolCall: ToolCall): string { + const { params } = toolCall; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -189,7 +224,7 @@ function getToolDescription(toolCall: ToolCall): string { case "locate_reservoirs": case "locate_pumps": case "locate_tanks": { - const ids = normalizeIds(); + const ids = normalizeLocateIds(params); const idsText = ids.length > 3 ? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个` @@ -233,19 +268,6 @@ function getToolDescription(toolCall: ToolCall): string { function buildAction(toolCall: ToolCall): ChatToolAction | null { const { params } = toolCall; - const normalizeIds = (): string[] => { - const rawIds = params.ids; - if (Array.isArray(rawIds)) { - return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); - } - if (typeof rawIds === "string") { - return rawIds - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - } - return []; - }; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -302,13 +324,13 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { ? featureTypeRaw.trim().toLowerCase() : ""; const config = locateFeatureTypeToConfig(featureType); - if (!config) return null; - return { - type: "locate_features", - ids: normalizeIds(), - layer: config.layer, - geometryKind: config.geometryKind, - }; + if (!config) return null; + return { + type: "locate_features", + ids: normalizeLocateIds(params), + layer: config.layer, + geometryKind: config.geometryKind, + }; } case "locate_junctions": case "locate_pipes": @@ -320,7 +342,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { if (!layer) return null; return { type: "locate_features", - ids: normalizeIds(), + ids: normalizeLocateIds(params), layer, geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point", }; @@ -378,12 +400,13 @@ export const ChatToolCallBlock: React.FC = ({ const theme = useTheme(); const dispatch = useChatToolStore((s) => s.dispatch); const [executed, setExecuted] = useState(false); + const [expanded, setExpanded] = useState(false); const meta: ToolMeta = TOOL_META[toolCall.tool] ?? { label: toolCall.tool, - icon: null, + icon: , actionLabel: "执行", - color: theme.palette.primary.main, + color: "#00acc1", }; const description = getToolDescription(toolCall); @@ -400,97 +423,143 @@ export const ChatToolCallBlock: React.FC = ({ - + setExpanded(!expanded)} + sx={{ + p: 1.5, + display: "flex", + alignItems: "center", + cursor: "pointer", + gap: 1.5, + }} + > {/* Icon */} {meta.icon} - {/* Description */} - + {/* Title */} + {meta.label} - {description && ( - - {description} - + {!expanded && description && ( + + • {description} + )} - {/* Action */} - {executed ? ( - } - label="已执行" - size="small" - sx={{ - bgcolor: alpha("#4caf50", 0.1), - color: "#4caf50", - fontWeight: 600, - fontSize: "0.75rem", - }} - /> - ) : ( - - )} - + + {expanded ? : } + + + + + + + {description && ( + + + 执行参数 + + + {description} + + + )} + + + {executed ? ( + } + label="已执行" + size="small" + sx={{ + bgcolor: alpha("#00e676", 0.15), + color: "#00c853", + fontWeight: 700, + fontSize: "0.75rem", + }} + /> + ) : ( + + )} + + + + ); }; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 0e1af3c..375771d 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -47,8 +47,13 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const handleToolCall = useAgentToolActions(); const { messages, + branchGroups, + branchTransition, isStreaming, sendPrompt, + regenerate, + editAndResubmit, + cycleBranch, abort, reset, } = useAgentChatSession({ @@ -202,6 +207,8 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { = ({ open, onClose }) => { onResumeSpeech={handleResumeSpeech} onStopSpeech={handleStopSpeech} isTtsSupported={isTtsSupported} + onRegenerate={regenerate} + onEditResubmit={editAndResubmit} + onCycleBranch={cycleBranch} /> `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -42,7 +42,11 @@ export const getInitialChatState = (): PersistedChatState => { window.localStorage.removeItem(CHAT_STORAGE_KEY); return { messages: [], sessionId: undefined }; } - return { messages: parsed.messages, sessionId: parsed.sessionId }; + return { + messages: Array.isArray(parsed.messages) ? parsed.messages : [], + sessionId: parsed.sessionId, + branchGroups: Array.isArray(parsed.branchGroups) ? parsed.branchGroups : [], + }; } catch (error) { console.error( "[GlobalChatbox] Failed to read persisted chat state:", @@ -52,3 +56,20 @@ export const getInitialChatState = (): PersistedChatState => { return { messages: [], sessionId: undefined }; } }; + +export const cloneMessage = (message: Message): Message => ({ + ...message, + progress: message.progress ? [...message.progress] : undefined, + artifacts: message.artifacts ? [...message.artifacts] : undefined, +}); + +export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage); + +export const cloneBranchGroups = (branchGroups: BranchGroup[]) => + branchGroups.map((group) => ({ + ...group, + branches: group.branches.map((branch) => ({ + ...branch, + messages: cloneMessages(branch.messages), + })), + })); diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index e7d2074..bd82922 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { streamAgentChat } from "@/lib/chatStream"; +import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream"; import type { StreamEvent } from "@/lib/chatStream"; import type { AgentArtifact, + BranchGroup, + BranchTransition, ChatProgress, Message, PersistedChatState, } from "../GlobalChatbox.types"; -import { CHAT_STORAGE_KEY, createId, getInitialChatState } from "../GlobalChatbox.utils"; +import { + CHAT_STORAGE_KEY, + cloneBranchGroups, + cloneMessages, + createId, + getInitialChatState, +} from "../GlobalChatbox.utils"; type UseAgentChatSessionOptions = { onToolCall: ( @@ -23,6 +31,14 @@ type UseAgentChatSessionOptions = { onBeforeSend?: () => void; }; +type PromptRunOptions = { + prompt: string; + sessionIdOverride?: string; + preparedMessages?: Message[]; + userMessage?: Message; + assistantMessage?: Message; +}; + const upsertProgress = ( progress: ChatProgress[] | undefined, event: StreamEvent & { type: "progress" }, @@ -49,6 +65,25 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => item.status === "running" ? { ...item, status: "completed" as const } : item, ); +const createUserMessage = (content: string, branchRootId?: string): Message => { + const id = createId(); + return { + id, + role: "user", + content, + branchRootId: branchRootId ?? id, + }; +}; + +const createAssistantMessage = (): Message => ({ + id: createId(), + role: "assistant", + content: "", +}); + +const messagesEqual = (left: Message[], right: Message[]) => + JSON.stringify(left) === JSON.stringify(right); + export const useAgentChatSession = ({ onToolCall, onBeforeSend, @@ -64,16 +99,65 @@ export const useAgentChatSession = ({ const [sessionId, setSessionId] = useState( initialChatStateRef.current.sessionId, ); + const [branchGroups, setBranchGroups] = useState( + initialChatStateRef.current.branchGroups ?? [], + ); + const [branchTransition, setBranchTransition] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const abortRef = useRef(null); + const sessionIdRef = useRef(initialChatStateRef.current.sessionId); + const cancelPromiseRef = useRef | null>(null); useEffect(() => { - const state: PersistedChatState = { messages, sessionId }; + sessionIdRef.current = sessionId; + }, [sessionId]); + + useEffect(() => { + const state: PersistedChatState = { messages, sessionId, branchGroups }; try { window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); } catch (error) { console.error("[GlobalChatbox] Failed to persist chat state:", error); } + }, [branchGroups, messages, sessionId]); + + useEffect(() => { + setBranchGroups((prev) => { + let changed = false; + const next = prev.map((group) => { + const rootMessage = messages[group.parentCount]; + if ( + !rootMessage || + rootMessage.role !== "user" || + (rootMessage.branchRootId ?? rootMessage.id) !== group.rootMessageId + ) { + return group; + } + + const activeBranch = group.branches[group.activeIndex]; + if (!activeBranch) { + return group; + } + + const nextSuffix = cloneMessages(messages.slice(group.parentCount)); + if ( + activeBranch.sessionId === sessionId && + messagesEqual(activeBranch.messages, nextSuffix) + ) { + return group; + } + + changed = true; + const branches = group.branches.map((branch, index) => + index === group.activeIndex + ? { ...branch, sessionId, messages: nextSuffix } + : branch, + ); + return { ...group, branches }; + }); + + return changed ? next : prev; + }); }, [messages, sessionId]); const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => { @@ -89,21 +173,33 @@ export const useAgentChatSession = ({ ); }, []); - const sendPrompt = useCallback( - async (rawPrompt: string) => { + const runPrompt = useCallback( + async ({ + prompt: rawPrompt, + sessionIdOverride, + preparedMessages, + userMessage, + assistantMessage, + }: PromptRunOptions) => { const prompt = rawPrompt.trim(); if (!prompt || isStreaming) return; + + await cancelPromiseRef.current?.catch(() => undefined); onBeforeSend?.(); + setBranchTransition(null); + + const nextUserMessage = userMessage ?? createUserMessage(prompt); + const nextAssistantMessage = assistantMessage ?? createAssistantMessage(); + const nextMessages = + preparedMessages ?? + [...messages, nextUserMessage, nextAssistantMessage]; - const userId = createId(); - const assistantId = createId(); setIsStreaming(true); - - setMessages((prev) => [ - ...prev, - { id: userId, role: "user", content: prompt }, - { id: assistantId, role: "assistant", content: "" }, - ]); + setMessages(cloneMessages(nextMessages)); + if (sessionIdOverride !== undefined) { + sessionIdRef.current = sessionIdOverride; + setSessionId(sessionIdOverride); + } const controller = new AbortController(); abortRef.current = controller; @@ -111,17 +207,18 @@ export const useAgentChatSession = ({ try { await streamAgentChat({ message: prompt, - sessionId, + sessionId: sessionIdOverride ?? sessionIdRef.current, signal: controller.signal, onEvent: (event) => { - if ("sessionId" in event && !sessionId && event.sessionId) { + if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) { + sessionIdRef.current = event.sessionId; setSessionId(event.sessionId); } if (event.type === "token") { setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, content: message.content + event.content, @@ -133,20 +230,20 @@ export const useAgentChatSession = ({ } else if (event.type === "progress") { setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, progress: upsertProgress(message.progress, event) } : message, ), ); } else if (event.type === "tool_call") { onToolCall(event, { - assistantMessageId: assistantId, + assistantMessageId: nextAssistantMessage.id, appendArtifact, }); } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { - if (message.id !== assistantId) return message; + if (message.id !== nextAssistantMessage.id) return message; const completedProgress = completeRunningProgress(message.progress); if ( message.content.trim().length === 0 && @@ -166,7 +263,7 @@ export const useAgentChatSession = ({ } else if (event.type === "error") { setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, content: message.content || `⚠️ **错误:** ${event.message}`, @@ -181,23 +278,34 @@ export const useAgentChatSession = ({ }, }); } catch (error) { - if (abortRef.current?.signal.aborted) { + if (controller.signal.aborted) { setMessages((prev) => - prev.filter( - (message) => - !( - message.id === assistantId && - message.role === "assistant" && - message.content.trim().length === 0 && - !(message.artifacts?.length) - ), - ), + prev + .map((message) => + message.id === nextAssistantMessage.id + ? { + ...message, + content: message.content || "⚠️ **请求已中断**", + isError: true, + } + : message, + ) + .filter( + (message) => + !( + message.id === nextAssistantMessage.id && + message.role === "assistant" && + message.content.trim().length === 0 && + !(message.artifacts?.length) && + !(message.progress?.length) + ), + ), ); return; } setMessages((prev) => prev.map((message) => - message.id === assistantId + message.id === nextAssistantMessage.id ? { ...message, content: `⚠️ **错误:** ${String(error)}`, @@ -213,26 +321,217 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, isStreaming, onBeforeSend, onToolCall, sessionId], + [appendArtifact, isStreaming, messages, onBeforeSend, onToolCall], ); const abort = useCallback(() => { - abortRef.current?.abort(); + const controller = abortRef.current; + controller?.abort(); setIsStreaming(false); + + const cancelPromise = abortAgentChat(sessionIdRef.current).catch((error) => { + console.error("[GlobalChatbox] Failed to abort agent session:", error); + }); + const trackedCancelPromise = cancelPromise.finally(() => { + if (cancelPromiseRef.current === trackedCancelPromise) { + cancelPromiseRef.current = null; + } + }); + cancelPromiseRef.current = trackedCancelPromise; }, []); const reset = useCallback(() => { - abortRef.current?.abort(); + const controller = abortRef.current; + controller?.abort(); + const activeSessionId = sessionIdRef.current; + if (activeSessionId) { + const cancelPromise = abortAgentChat(activeSessionId).catch((error) => { + console.error("[GlobalChatbox] Failed to abort agent session during reset:", error); + }); + const trackedCancelPromise = cancelPromise.finally(() => { + if (cancelPromiseRef.current === trackedCancelPromise) { + cancelPromiseRef.current = null; + } + }); + cancelPromiseRef.current = trackedCancelPromise; + } setMessages([]); + setBranchGroups([]); + setBranchTransition(null); setSessionId(undefined); + sessionIdRef.current = undefined; setIsStreaming(false); }, []); + const sendPrompt = useCallback( + async (rawPrompt: string) => { + await runPrompt({ prompt: rawPrompt }); + }, + [runPrompt], + ); + + const regenerate = useCallback(async () => { + if (isStreaming || messages.length === 0) return; + + let lastUserIndex = messages.length - 1; + while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { + lastUserIndex--; + } + + if (lastUserIndex < 0) return; + + const lastUser = messages[lastUserIndex]; + const lastUserContent = lastUser.content; + const nextMessages = cloneMessages(messages.slice(0, lastUserIndex)); + const nextUserMessage = createUserMessage( + lastUserContent, + lastUser.branchRootId ?? lastUser.id, + ); + const nextAssistantMessage = createAssistantMessage(); + + setMessages(nextMessages); + await runPrompt({ + prompt: lastUserContent, + preparedMessages: [ + ...nextMessages, + nextUserMessage, + nextAssistantMessage, + ], + userMessage: nextUserMessage, + assistantMessage: nextAssistantMessage, + }); + }, [isStreaming, messages, runPrompt]); + + const editAndResubmit = useCallback( + async (messageId: string, newContent: string) => { + if (isStreaming) return; + + const trimmedContent = newContent.trim(); + if (!trimmedContent) return; + + const messageIndex = messages.findIndex((m) => m.id === messageId); + if (messageIndex < 0 || messages[messageIndex].role !== "user") return; + + const originalMessage = messages[messageIndex]; + if (trimmedContent === originalMessage.content.trim()) return; + + const rootMessageId = originalMessage.branchRootId ?? originalMessage.id; + const currentSessionId = sessionIdRef.current; + const keepMessageCount = messageIndex; + const prefix = cloneMessages(messages.slice(0, messageIndex)); + const originalSuffix = cloneMessages(messages.slice(messageIndex)); + const forkedSessionId = await forkAgentChat(currentSessionId, keepMessageCount); + + const nextUserMessage = createUserMessage(trimmedContent, rootMessageId); + const nextAssistantMessage = createAssistantMessage(); + const nextSuffix = [nextUserMessage, nextAssistantMessage]; + + setBranchGroups((prev) => { + const next = cloneBranchGroups(prev); + const groupIndex = next.findIndex( + (group) => + group.rootMessageId === rootMessageId && group.parentCount === messageIndex, + ); + + if (groupIndex >= 0) { + const group = next[groupIndex]; + group.branches[group.activeIndex] = { + ...group.branches[group.activeIndex], + sessionId: currentSessionId, + messages: originalSuffix, + }; + group.branches.push({ + id: createId(), + label: `分支 ${group.branches.length + 1}`, + sessionId: forkedSessionId, + messages: cloneMessages(nextSuffix), + }); + group.activeIndex = group.branches.length - 1; + } else { + next.push({ + id: rootMessageId, + rootMessageId, + parentCount: messageIndex, + activeIndex: 1, + branches: [ + { + id: createId(), + label: "分支 1", + sessionId: currentSessionId, + messages: originalSuffix, + }, + { + id: createId(), + label: "分支 2", + sessionId: forkedSessionId, + messages: cloneMessages(nextSuffix), + }, + ], + }); + } + + return next; + }); + + sessionIdRef.current = forkedSessionId; + setSessionId(forkedSessionId); + await runPrompt({ + prompt: trimmedContent, + sessionIdOverride: forkedSessionId, + preparedMessages: [...prefix, ...nextSuffix], + userMessage: nextUserMessage, + assistantMessage: nextAssistantMessage, + }); + }, + [isStreaming, messages, runPrompt], + ); + + const cycleBranch = useCallback( + (rootMessageId: string, direction: -1 | 1) => { + if (isStreaming) return; + + setBranchGroups((prev) => { + const next = cloneBranchGroups(prev); + const group = next.find((item) => item.rootMessageId === rootMessageId); + if (!group || group.branches.length < 2) { + return prev; + } + + const nextIndex = + (group.activeIndex + direction + group.branches.length) % group.branches.length; + const selectedBranch = group.branches[nextIndex]; + group.activeIndex = nextIndex; + + const nextMessages = [ + ...cloneMessages(messages.slice(0, group.parentCount)), + ...cloneMessages(selectedBranch.messages), + ]; + setBranchTransition({ + rootMessageId, + parentCount: group.parentCount, + activeBranchId: selectedBranch.id, + nonce: Date.now(), + }); + sessionIdRef.current = selectedBranch.sessionId; + setSessionId(selectedBranch.sessionId); + setMessages(nextMessages); + + return next; + }); + }, + [isStreaming, messages], + ); + return { messages, + branchGroups, + branchTransition, isStreaming, sessionId, sendPrompt, + regenerate, + editAndResubmit, + cycleBranch, abort, reset, }; diff --git a/src/components/chat/hooks/useAgentToolActions.ts b/src/components/chat/hooks/useAgentToolActions.ts index d66d26f..7d57e62 100644 --- a/src/components/chat/hooks/useAgentToolActions.ts +++ b/src/components/chat/hooks/useAgentToolActions.ts @@ -43,16 +43,45 @@ const LOCATE_TOOL_CONFIG: Record< locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" }, }; +const LOCATE_ID_PARAM_KEYS = [ + "ids", + "id", + "feature_ids", + "feature_id", + "node_ids", + "node_id", + "junction_ids", + "junction_id", + "pipe_ids", + "pipe_id", + "valve_ids", + "valve_id", + "reservoir_ids", + "reservoir_id", + "pump_ids", + "pump_id", + "tank_ids", + "tank_id", +] as const; + 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); + for (const key of LOCATE_ID_PARAM_KEYS) { + const rawValue = params[key]; + if (Array.isArray(rawValue)) { + const normalized = rawValue.map((id) => String(id).trim()).filter(Boolean); + if (normalized.length > 0) { + return normalized; + } + } + if (typeof rawValue === "string" || typeof rawValue === "number") { + const normalized = String(rawValue) + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + if (normalized.length > 0) { + return normalized; + } + } } return []; }; diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index 1477e55..c8b4e40 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -20,6 +20,7 @@ import { ShowChart, TableChart, CleaningServices, + Close, ChevronLeft, ChevronRight, } from "@mui/icons-material"; @@ -72,12 +73,22 @@ export interface SCADADataPanelProps { start_time?: string; /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ end_time?: string; + /** 关闭面板 */ + onClose?: () => void; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; +const panelHeaderActionSx = { + color: "primary.contrastText", + backgroundColor: "rgba(255,255,255,0.08)", + "&:hover": { + backgroundColor: "rgba(255,255,255,0.18)", + }, +}; + /** * 从后端 API 获取 SCADA 数据 */ @@ -320,6 +331,7 @@ const SCADADataPanel: React.FC = ({ onCleanData, start_time, end_time, + onClose, }) => { const { open } = useNotification(); const { data: user } = useGetIdentity(); @@ -1063,11 +1075,24 @@ const SCADADataPanel: React.FC = ({ /> + {onClose && ( + + + + + + )} setIsExpanded(false)} - sx={{ color: "primary.contrastText" }} + aria-label="收起 SCADA 历史数据面板" + sx={panelHeaderActionSx} > diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 340f41c..04deb10 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -15,13 +15,14 @@ import { Chip, CircularProgress, Divider, + IconButton, Stack, Tab, Tabs, Tooltip, Typography, } from "@mui/material"; -import { Refresh, ShowChart, TableChart } from "@mui/icons-material"; +import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { zhCN } from "@mui/x-data-grid/locales"; import ReactECharts from "echarts-for-react"; @@ -63,12 +64,22 @@ export interface SCADADataPanelProps { start_time?: string; /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ end_time?: string; + /** 关闭面板 */ + onClose?: () => void; } type PanelTab = "chart" | "table"; type LoadingState = "idle" | "loading" | "success" | "error"; +const panelHeaderActionSx = { + color: "primary.contrastText", + backgroundColor: "rgba(255,255,255,0.08)", + "&:hover": { + backgroundColor: "rgba(255,255,255,0.18)", + }, +}; + /** * 从后端 API 获取 SCADA 数据 */ @@ -419,6 +430,7 @@ const SCADADataPanel: React.FC = ({ fractionDigits = 2, start_time, end_time, + onClose, }) => { // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( @@ -850,7 +862,11 @@ const SCADADataPanel: React.FC = ({ return ( <> {/* 主面板 */} - + = ({ }} /> + {onClose && ( + + + + + + )} diff --git a/src/components/olmap/core/Controls/PropertyPanel.tsx b/src/components/olmap/core/Controls/PropertyPanel.tsx index 2cd4d89..2f6343f 100644 --- a/src/components/olmap/core/Controls/PropertyPanel.tsx +++ b/src/components/olmap/core/Controls/PropertyPanel.tsx @@ -2,6 +2,8 @@ import React, { useRef } from "react"; import Draggable from "react-draggable"; +import { Close } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; interface BaseProperty { label: string; @@ -24,14 +26,23 @@ interface PropertyPanelProps { id?: string; type?: string; properties?: PropertyItem[]; + onClose?: () => void; } const PropertyPanel: React.FC = ({ id, type = "未知类型", properties = [], + onClose, }) => { const draggableRef = useRef(null); + const headerActionSx = { + color: "common.white", + backgroundColor: "rgba(255,255,255,0.08)", + "&:hover": { + backgroundColor: "rgba(255,255,255,0.18)", + }, + }; const formatValue = (property: BaseProperty) => { if (property.formatter) { @@ -55,7 +66,11 @@ const PropertyPanel: React.FC = ({ : 0; return ( - +
= ({

属性面板

+ {onClose && ( + + + + + + )} {/* 内容区域 */} diff --git a/src/hooks/useChatToolActionHandler.ts b/src/hooks/useChatToolActionHandler.ts index a58e417..19905bc 100644 --- a/src/hooks/useChatToolActionHandler.ts +++ b/src/hooks/useChatToolActionHandler.ts @@ -22,18 +22,33 @@ export function useChatToolActionHandler( handler: (action: ChatToolAction) => void, ) { const handlerRef = useRef(handler); + const lastHandledSeqRef = useRef(0); useEffect(() => { handlerRef.current = handler; }, [handler]); useEffect(() => { + const initialState = useChatToolStore.getState(); + if ( + initialState.lastAction && + initialState.actionSeq > lastHandledSeqRef.current && + Date.now() - initialState.lastActionAt < 5000 + ) { + lastHandledSeqRef.current = initialState.actionSeq; + handlerRef.current(initialState.lastAction); + } else { + lastHandledSeqRef.current = initialState.actionSeq; + } + const unsubscribe = useChatToolStore.subscribe( (state, prevState) => { if ( state.actionSeq !== prevState.actionSeq && - state.lastAction + state.lastAction && + state.actionSeq > lastHandledSeqRef.current ) { + lastHandledSeqRef.current = state.actionSeq; handlerRef.current(state.lastAction); } }, diff --git a/src/lib/chatStream.test.ts b/src/lib/chatStream.test.ts index 1db2bd5..a2e5103 100644 --- a/src/lib/chatStream.test.ts +++ b/src/lib/chatStream.test.ts @@ -1,4 +1,4 @@ -import { streamAgentChat } from "./chatStream"; +import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream"; import { ReadableStream } from "stream/web"; import { TextEncoder, TextDecoder } from "util"; @@ -147,4 +147,49 @@ describe("streamAgentChat", () => { { type: "error", message: "network request failed", detail: "Failed to fetch" }, ]); }); + + it("calls abort endpoint for an active session", async () => { + apiFetch.mockResolvedValue({ + ok: true, + status: 202, + text: async () => "", + }); + + await abortAgentChat("s1"); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/abort"), + expect.objectContaining({ + method: "POST", + projectHeaderMode: "include", + skipAuthRedirect: true, + body: JSON.stringify({ + session_id: "s1", + }), + }), + ); + }); + + it("calls fork endpoint and returns new session id", async () => { + apiFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ session_id: "forked-s1" }), + text: async () => "", + }); + + const sessionId = await forkAgentChat("s1", 3); + + expect(sessionId).toBe("forked-s1"); + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/agent/chat/fork"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + session_id: "s1", + keep_message_count: 3, + }), + }), + ); + }); }); diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index ad163ad..4d2746a 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -181,3 +181,52 @@ export const streamAgentChat = async ({ } } }; + +export const abortAgentChat = async (sessionId?: string) => { + if (!sessionId) { + return; + } + + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + }), + projectHeaderMode: "include", + skipAuthRedirect: true, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `abort request failed: ${response.status}`); + } +}; + +export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: sessionId, + keep_message_count: keepMessageCount, + }), + projectHeaderMode: "include", + skipAuthRedirect: true, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || `fork request failed: ${response.status}`); + } + + const payload = (await response.json()) as { session_id?: string }; + if (!payload.session_id) { + throw new Error("fork request returned no session_id"); + } + return payload.session_id; +}; diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 3ad963a..c226489 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -41,6 +41,8 @@ interface ChatToolState { lastAction: ChatToolAction | null; /** Monotonically increasing counter – lets subscribers detect new actions. */ actionSeq: number; + /** Timestamp of the most recent action dispatch. */ + lastActionAt: number; /** Dispatch a tool action from the chat. */ dispatch: (action: ChatToolAction) => void; } @@ -48,9 +50,11 @@ interface ChatToolState { export const useChatToolStore = create((set) => ({ lastAction: null, actionSeq: 0, + lastActionAt: 0, dispatch: (action) => set((state) => ({ lastAction: action, actionSeq: state.actionSeq + 1, + lastActionAt: Date.now(), })), }));