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 (
-
+
-
-
-
- 常用管网任务
-
+ >
+
+
+
+ 管网分析快捷指令
+
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 = ({
)}
-
-
+
+
+
+
+
+ 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`,
}}
>
-
+
-
+
@@ -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 ? (
-
- ) : (
-
- )}
+
-
- {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(),
})),
}));