15 Commits

Author SHA1 Message Date
jiang ba66abb4ee ci: improve deploy webhook diagnostics
Build Push and Deploy / docker-image (push) Failing after 3m13s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-04-30 16:11:56 +08:00
jiang e0e78cd95a 重构聊天会话管理,支持会话历史和存储 2026-04-30 15:02:08 +08:00
jiang c5b0f43a0d 强制要求 onClose 属性,简化面板关闭逻辑 2026-04-30 13:47:45 +08:00
jiang 8f3c288823 优化关闭按钮逻辑,简化代码结构 2026-04-30 13:46:22 +08:00
jiang 24d81e04e0 优化工具栏面板关闭逻辑,增强用户体验 2026-04-30 13:42:04 +08:00
jiang 85b4f45d4a 解析工具调用参数,优化事件处理逻辑 2026-04-30 13:38:53 +08:00
jiang 36d1a8d6ea 重构 Agent 聊天,支持分支管理与消息克隆 2026-04-30 13:05:45 +08:00
jiang e5ca9e24aa Agent 初版设计 2026-04-29 17:15:49 +08:00
jiang 2c1afdc97c 添加进度面板,优化消息处理逻辑 2026-04-29 16:55:14 +08:00
jiang 30d85173ee 修复会话 ID 设置错误,更新类型定义 2026-04-29 15:42:37 +08:00
jiang 3b5a493cda 适配新的 opencode Agent 框架 2026-04-29 15:33:08 +08:00
jiang 49fd4f5eb1 调整比较模式提示框位置 2026-04-27 16:08:00 +08:00
jiang 3db2af0271 更新配置文件,优化路径匹配规则 2026-04-27 16:00:02 +08:00
jiang 07861bee03 添加对比模式功能,优化地图组件 2026-04-27 15:59:49 +08:00
jiang 60181dba54 更新属性面板为可拖动,优化工具栏激活逻辑 2026-04-27 11:56:56 +08:00
43 changed files with 5685 additions and 1991 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ NEXTAUTH_URL="https://demo.waternetwork.cn/"
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
NEXT_PUBLIC_COPILOT_URL="https://agent.waternetwork.cn"
NEXT_PUBLIC_AGENT_URL="https://agent.waternetwork.cn"
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
+13 -5
View File
@@ -59,11 +59,12 @@ jobs:
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
@@ -102,7 +103,7 @@ jobs:
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
--build-arg NEXT_PUBLIC_COPILOT_URL="${{ vars.NEXT_PUBLIC_COPILOT_URL }}" \
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
@@ -116,10 +117,17 @@ jobs:
- name: Notify Deploy Server
run: |
curl -fsSL -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
http_code=$(curl -sS -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
-d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
-d "{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}")
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Deploy webhook failed with HTTP ${http_code}"
echo "Response body:"
cat /tmp/deploy_response.txt
exit 1
fi
deploy-fallback-log:
runs-on: ubuntu-22.04
+1 -1
View File
@@ -18,7 +18,7 @@ FROM base AS builder
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_COPILOT_URL
ARG NEXT_PUBLIC_AGENT_URL
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
ARG NEXT_PUBLIC_MAP_URL
ARG NEXT_PUBLIC_MAP_WORKSPACE
+1 -1
View File
@@ -8,7 +8,7 @@ services:
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
NEXT_PUBLIC_COPILOT_URL: ${NEXT_PUBLIC_COPILOT_URL}
NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || ".next",
output: "standalone",
images: {
remotePatterns: [
+7
View File
@@ -30,6 +30,7 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5",
"next": "^16.1.6",
"next-auth": "^4.24.5",
@@ -15843,6 +15844,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+1
View File
@@ -38,6 +38,7 @@
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5",
"next": "^16.1.6",
"next-auth": "^4.24.5",
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777523623582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11701" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z" fill="#CE75FF" p-id="11702"></path><path d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z" fill="#F3E2FF" p-id="11703"></path><path d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#F62E76" p-id="11704"></path><path d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z" fill="#CD88FF" p-id="11705"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1777457471585" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5556" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M550.4 486.4c0-8.533333 4.266667-12.8 12.8-12.8h4.266667c4.266667 0 4.266667 4.266667 4.266666 4.266667s4.266667 4.266667 4.266667 8.533333v4.266667s0 4.266667-4.266667 4.266666c0 0-4.266667 0-4.266666 4.266667h-4.266667-4.266667s-4.266667 0-4.266666-4.266667c0 0 0-4.266667-4.266667-4.266666v-4.266667z" fill="#4D6BFE" p-id="5557"></path><path d="M994.133333 196.266667c-8.533333-4.266667-12.8 4.266667-21.333333 8.533333l-4.266667 4.266667c-12.8 17.066667-34.133333 25.6-55.466666 25.6-34.133333 0-59.733333 8.533333-85.333334 34.133333-4.266667-29.866667-21.333333-51.2-51.2-64-12.8-4.266667-29.866667-12.8-38.4-25.6-8.533333-8.533333-8.533333-21.333333-12.8-29.866667 0-4.266667 0-12.8-8.533333-12.8s-12.8 4.266667-12.8 12.8c-12.8 21.333333-21.333333 46.933333-17.066667 72.533334 0 59.733333 25.6 106.666667 72.533334 136.533333 4.266667 4.266667 8.533333 8.533333 4.266666 12.8-4.266667 12.8-8.533333 21.333333-8.533333 34.133333-4.266667 8.533333-4.266667 8.533333-12.8 4.266667-25.6-12.8-51.2-29.866667-68.266667-46.933333-34.133333-34.133333-64-72.533333-102.4-102.4-8.533333-8.533333-17.066667-12.8-25.6-21.333334-46.933333-34.133333 0-64 8.533334-68.266666 12.8-4.266667 4.266667-17.066667-29.866667-17.066667-34.133333 0-68.266667 12.8-106.666667 29.866667-8.533333 0-12.8 0-21.333333 4.266666-38.4-8.533333-76.8-8.533333-115.2-4.266666-76.8 8.533333-136.533333 42.666667-179.2 106.666666-51.2 76.8-64 157.866667-51.2 247.466667 17.066667 93.866667 64 170.666667 132.266667 230.4 72.533333 64 157.866667 93.866667 256 85.333333 59.733333-4.266667 123.733333-12.8 200.533333-76.8 17.066667 8.533333 38.4 12.8 72.533333 17.066667 25.6 4.266667 51.2 0 68.266667-4.266667 29.866667-4.266667 25.6-34.133333 17.066667-38.4-85.333333-42.666667-68.266667-25.6-85.333334-38.4 42.666667-51.2 110.933333-106.666667 136.533334-285.866666v-34.133334c0-8.533333 4.266667-8.533333 12.8-8.533333 21.333333-4.266667 42.666667-8.533333 59.733333-21.333333 55.466667-29.866667 76.8-81.066667 85.333333-145.066667 0-8.533333 0-17.066667-12.8-21.333333zM507.733333 746.666667c-85.333333-68.266667-123.733333-89.6-140.8-89.6-17.066667 0-12.8 21.333333-8.533333 29.866666 4.266667 12.8 8.533333 21.333333 12.8 29.866667 4.266667 8.533333 8.533333 17.066667-4.266667 25.6-25.6 17.066667-72.533333-4.266667-76.8-8.533333-55.466667-34.133333-98.133333-76.8-132.266666-136.533334-29.866667-51.2-46.933333-110.933333-46.933334-174.933333 0-17.066667 4.266667-21.333333 17.066667-25.6 21.333333-4.266667 42.666667-4.266667 59.733333 0 85.333333 12.8 157.866667 51.2 217.6 115.2 34.133333 34.133333 59.733333 76.8 89.6 119.466667 29.866667 42.666667 59.733333 85.333333 98.133334 119.466666 12.8 12.8 25.6 21.333333 34.133333 25.6-29.866667 0-81.066667 0-119.466667-29.866666z m166.4-196.266667c-8.533333 4.266667-17.066667 4.266667-25.6 4.266667-12.8 0-25.6-4.266667-29.866666-8.533334-12.8-8.533333-17.066667-12.8-21.333334-29.866666v-25.6c4.266667-12.8 0-21.333333-8.533333-29.866667-8.533333-4.266667-17.066667-8.533333-25.6-8.533333-4.266667 0-8.533333 0-8.533333-4.266667 0 0-4.266667 0-4.266667-4.266667v-4.266666-4.266667-4.266667c0-4.266667 8.533333-8.533333 8.533333-8.533333 12.8-8.533333 29.866667-4.266667 46.933334 0 12.8 4.266667 25.6 17.066667 38.4 29.866667 17.066667 17.066667 17.066667 25.6 25.6 38.4 8.533333 12.8 12.8 21.333333 17.066666 34.133333 0 12.8-4.266667 21.333333-12.8 25.6z" fill="#4D6BFE" p-id="5558"></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -8,7 +8,11 @@ export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_analysis" />
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</MapComponent>
</div>
@@ -8,7 +8,11 @@ export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="contaminant_analysis" />
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</MapComponent>
</div>
+1 -1
View File
@@ -169,7 +169,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
{
name: "Hydraulic Simulation",
meta: {
icon: <MdWater className="w-6 h-6" />,
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
},
+128
View File
@@ -0,0 +1,128 @@
"use client";
import React from "react";
import {
Box,
Chip,
Paper,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import BarChartRounded from "@mui/icons-material/BarChartRounded";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import { ChatInlineChart } from "./ChatInlineChart";
import type { ChatChartSeries } from "./ChatInlineChart";
import type { AgentArtifact } from "./GlobalChatbox.types";
const artifactIcon = (kind: AgentArtifact["kind"]) => {
if (kind === "chart") return <BarChartRounded sx={{ fontSize: 18 }} />;
if (kind === "map") return <LocationOnRounded sx={{ fontSize: 18 }} />;
if (kind === "panel") return <SensorsRounded sx={{ fontSize: 18 }} />;
return <BuildCircleRounded sx={{ fontSize: 18 }} />;
};
const artifactColor = (kind: AgentArtifact["kind"], theme: Theme) => {
if (kind === "chart") return theme.palette.info.main;
if (kind === "map") return theme.palette.success.main;
if (kind === "panel") return theme.palette.warning.main;
return theme.palette.primary.main;
};
export const AgentArtifactPanel = ({ artifacts }: { artifacts: AgentArtifact[] }) => {
const theme = useTheme();
if (!artifacts.length) return null;
return (
<Stack spacing={1.25}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="caption" fontWeight={800} color="text.primary">
</Typography>
<Chip
size="small"
label={`${artifacts.length}`}
sx={{ height: 20, fontSize: "0.68rem" }}
/>
</Stack>
{artifacts.map((artifact) => {
const color = artifactColor(artifact.kind, theme);
if (artifact.kind === "chart") {
return (
<ChatInlineChart
key={artifact.id}
title={(artifact.params.title as string) ?? artifact.title}
chart_type={
(artifact.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(artifact.params.x_data as string[]) ?? []}
series={(artifact.params.series as ChatChartSeries[]) ?? []}
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
/>
);
}
return (
<Paper
key={artifact.id}
elevation={0}
sx={{
p: 1.35,
borderRadius: 3,
border: `1px solid ${alpha(color, 0.22)}`,
bgcolor: alpha(color, 0.055),
}}
>
<Stack direction="row" spacing={1.25} alignItems="center">
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(color, 0.12),
color,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{artifactIcon(artifact.kind)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="caption" fontWeight={800} color="text.primary">
{artifact.title}
</Typography>
{artifact.description ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{artifact.description}
</Typography>
) : null}
</Box>
<Chip
size="small"
label="已执行"
sx={{
height: 22,
fontSize: "0.68rem",
bgcolor: alpha(color, 0.12),
color,
}}
/>
</Stack>
</Paper>
);
})}
</Stack>
);
};
+273
View File
@@ -0,0 +1,273 @@
"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Box,
Chip,
Collapse,
IconButton,
Paper,
Stack,
TextField,
Typography,
alpha,
useTheme,
} from "@mui/material";
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;
inputRef: React.RefObject<HTMLInputElement | null>;
isHydrating?: boolean;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
presets: string[];
onInputChange: (value: string) => void;
onSend: () => void;
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
};
export const AgentComposer = ({
input,
inputRef,
isHydrating = false,
isStreaming,
isListening,
isSttSupported,
presets,
onInputChange,
onSend,
onAbort,
onStartListening,
onStopListening,
onPresetSelect,
}: AgentComposerProps) => {
const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
<Paper
elevation={isPresetOpen ? 4 : 0}
sx={{
mb: 1.5,
px: 1.5,
py: 1,
borderRadius: 4,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha("#fff", 0.5)}`,
backdropFilter: "blur(24px)",
boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none",
transition: "all 0.3s ease",
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
<Box sx={{ flex: 1 }} />
<IconButton
size="small"
onClick={() => setIsPresetOpen((value) => !value)}
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }}
>
{isPresetOpen ? (
<KeyboardArrowDownRounded fontSize="small" />
) : (
<KeyboardArrowUpRounded fontSize="small" />
)}
</IconButton>
</Stack>
<Collapse in={isPresetOpen} timeout="auto" unmountOnExit>
<Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{presets.map((prompt) => (
<Chip
key={prompt}
label={prompt.replace(/[。.]$/, "")}
size="medium"
clickable
onClick={() => {
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"
}
}}
/>
))}
</Box>
</Box>
</Collapse>
</Paper>
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
<Paper
elevation={12}
sx={{
display: "flex",
flexDirection: "column",
p: 1.5,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.75),
backdropFilter: "blur(40px)",
border: `1px solid ${alpha("#ffffff", 0.9)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
}}
>
<TextField
inputRef={inputRef}
value={input}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating}
InputProps={{
disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
}}
/>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
<AttachFileRounded fontSize="small" />
</IconButton>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
</Stack>
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
onClick={onAbort}
aria-label="停止生成"
size="small"
sx={{
bgcolor: "error.main",
color: "#fff",
width: 40,
height: 40,
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
"&:hover": { bgcolor: "error.dark" },
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
disabled={!canSend}
onClick={onSend}
aria-label="发送"
size="small"
sx={{
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) },
}}
>
<SendRounded sx={{ ml: 0.35 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Stack>
</Paper>
</motion.div>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}>
<Image
src="/deepseek-logo.svg"
alt="DeepSeek"
width={14}
height={14}
style={{ width: 14, height: 14 }}
/>
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
Powered by DeepSeek V4 · TJWater Agent Intelligence
</Typography>
</Box>
</Box>
);
};
+190
View File
@@ -0,0 +1,190 @@
"use client";
import Image from "next/image";
import React from "react";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Stack,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import CloseRounded from "@mui/icons-material/CloseRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
type AgentHeaderProps = {
isStreaming: boolean;
isHistoryOpen: boolean;
onHistoryToggle: () => void;
onNewConversation: () => void;
onClose: () => void;
};
export const AgentHeader = ({
isStreaming,
isHistoryOpen,
onHistoryToggle,
onNewConversation,
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
return (
<Box
sx={{
px: 3,
py: 2.5,
zIndex: 10,
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`,
}}
>
<Stack direction="row" alignItems="center" spacing={2}>
<motion.div whileHover={{ rotate: 10, scale: 1.05 }} whileTap={{ scale: 0.95 }} style={{ display: "flex" }}>
<Box sx={{ position: "relative" }}>
<Avatar
sx={{
background: alpha("#ffffff", 0.9),
boxShadow: `0 8px 24px ${alpha("#00acc1", 0.4)}`,
width: 44,
height: 44,
border: `2px solid ${alpha("#fff", 0.8)}`,
p: 0.75,
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={30}
height={30}
style={{ width: "100%", height: "100%", objectFit: "contain" }}
/>
</Avatar>
<Box
sx={{
position: "absolute",
bottom: -2,
right: -2,
width: 14,
height: 14,
bgcolor: isStreaming ? "#ff9800" : "#00e676",
borderRadius: "50%",
border: "2.5px solid #fff",
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
animation: isStreaming ? "pulse 1.5s infinite" : "none",
"@keyframes pulse": {
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
"100%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0)}` },
}
}}
/>
</Box>
</motion.div>
<Box>
<Typography
variant="h6"
fontWeight={800}
sx={{
background: `linear-gradient(90deg, #01579b, #00838f)`,
backgroundClip: "text",
color: "transparent",
letterSpacing: -0.3,
}}
>
TJWater Agent
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"}
</Typography>
</Box>
</Stack>
<Stack direction="row" spacing={1.25} alignItems="center">
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onNewConversation}
aria-label="新建对话"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.54),
border: `1px solid ${alpha("#fff", 0.4)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#00acc1",
borderColor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<EditNoteRounded sx={{ fontSize: 22 }} />
</IconButton>
</motion.div>
</Tooltip>
<Tooltip title={isHistoryOpen ? "收起历史会话" : "打开历史会话"}>
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onHistoryToggle}
aria-label={isHistoryOpen ? "收起历史会话" : "打开历史会话"}
sx={{
width: 36,
height: 36,
color: isHistoryOpen ? "#00acc1" : "text.primary",
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.12) : alpha("#fff", 0.54),
border: `1px solid ${isHistoryOpen ? alpha("#00acc1", 0.2) : alpha("#fff", 0.4)}`,
boxShadow: `0 2px 8px ${isHistoryOpen ? alpha("#00acc1", 0.05) : alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: isHistoryOpen ? alpha("#00acc1", 0.16) : "#fff",
borderColor: isHistoryOpen ? alpha("#00acc1", 0.3) : alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${isHistoryOpen ? alpha("#00acc1", 0.1) : alpha("#000", 0.05)}`,
},
}}
>
<HistoryRounded sx={{ fontSize: 20 }} />
</IconButton>
</motion.div>
</Tooltip>
<Tooltip title="关闭 Agent">
<motion.div whileHover={{ scale: 1.08, rotate: 90 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
onClick={onClose}
aria-label="关闭 Agent"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.54),
border: `1px solid ${alpha("#fff", 0.4)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#e53935",
borderColor: alpha("#fff", 0.8),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<CloseRounded sx={{ fontSize: 20 }} />
</IconButton>
</motion.div>
</Tooltip>
</Stack>
</Box>
);
};
+408
View File
@@ -0,0 +1,408 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
IconButton,
Paper,
Stack,
TextField,
Tooltip,
Typography,
alpha,
} from "@mui/material";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded";
import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded";
import SearchRounded from "@mui/icons-material/SearchRounded";
import WarningRounded from "@mui/icons-material/WarningRounded";
import type { ChatSessionSummary } from "./GlobalChatbox.types";
type AgentHistoryPanelProps = {
sessions: ChatSessionSummary[];
activeSessionId?: string;
isHydrating?: boolean;
onNewSession: () => void;
onSelectSession: (sessionId: string) => void;
onDeleteSession: (sessionId: string) => void;
};
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const isSameDay = date.toDateString() === now.toDateString();
if (isSameDay) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
});
};
const getDayStart = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const getSessionGroupLabel = (timestamp: number) => {
const now = new Date();
const todayStart = getDayStart(now);
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000;
if (timestamp >= todayStart) return "今天";
if (timestamp >= yesterdayStart) return "昨天";
if (timestamp >= lastWeekStart) return "过去 7 天";
return "更早";
};
export const AgentHistoryPanel = ({
sessions,
activeSessionId,
isHydrating = false,
onNewSession,
onSelectSession,
onDeleteSession,
}: AgentHistoryPanelProps) => {
const [keyword, setKeyword] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState<string | null>(null);
const filteredSessions = React.useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return sessions;
return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword));
}, [keyword, sessions]);
const groupedSessions = React.useMemo(() => {
const groups = new Map<string, ChatSessionSummary[]>();
filteredSessions.forEach((session) => {
const label = getSessionGroupLabel(session.updatedAt);
const existing = groups.get(label);
if (existing) {
existing.push(session);
} else {
groups.set(label, [session]);
}
});
return Array.from(groups.entries());
}, [filteredSessions]);
const pendingDeleteSession = filteredSessions.find(
(session) => session.id === pendingDeleteSessionId,
);
return (
<>
<Paper
elevation={0}
sx={{
width: 268,
minWidth: 268,
height: "100%",
display: "flex",
flexDirection: "column",
bgcolor: alpha("#ffffff", 0.54),
borderRight: `1px solid ${alpha("#fff", 0.75)}`,
backdropFilter: "blur(28px)",
boxShadow: `inset -1px 0 0 ${alpha("#fff", 0.35)}`,
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
<Box>
<Typography variant="subtitle2" fontWeight={800} color="text.primary">
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
<Tooltip title="新建对话">
<motion.div whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.92 }} style={{ display: "flex" }}>
<IconButton
disabled={isHydrating}
onClick={onNewSession}
aria-label="新建对话"
sx={{
width: 36,
height: 36,
color: "text.primary",
bgcolor: alpha("#fff", 0.65),
border: `1px solid ${alpha("#fff", 0.5)}`,
boxShadow: `0 2px 8px ${alpha("#000", 0.02)}`,
"&:hover": {
bgcolor: "#fff",
color: "#00acc1",
borderColor: alpha("#fff", 0.9),
boxShadow: `0 4px 12px ${alpha("#000", 0.05)}`,
},
}}
>
<EditNoteRounded sx={{ fontSize: 22 }} />
</IconButton>
</motion.div>
</Tooltip>
</Stack>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<TextField
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索历史会话"
size="small"
fullWidth
disabled={isHydrating}
InputProps={{
startAdornment: <SearchRounded sx={{ fontSize: 16, color: "text.secondary", mr: 0.75 }} />,
sx: {
borderRadius: 3,
bgcolor: alpha("#fff", 0.62),
fontSize: "0.85rem",
},
}}
/>
</Box>
<Divider sx={{ borderColor: alpha("#fff", 0.6) }} />
<Box sx={{ flex: 1, overflowY: "auto", px: 1.25, py: 1.25 }}>
{sessions.length === 0 ? (
<Stack
alignItems="center"
justifyContent="center"
spacing={1}
sx={{
height: "100%",
textAlign: "center",
color: "text.secondary",
px: 2,
}}
>
<ChatBubbleOutlineRounded sx={{ fontSize: 24, opacity: 0.7 }} />
<Typography variant="body2" fontWeight={700}>
</Typography>
<Typography variant="caption">
</Typography>
</Stack>
) : filteredSessions.length === 0 ? (
<Stack
alignItems="center"
justifyContent="center"
spacing={1}
sx={{
height: "100%",
textAlign: "center",
color: "text.secondary",
px: 2,
}}
>
<SearchRounded sx={{ fontSize: 24, opacity: 0.7 }} />
<Typography variant="body2" fontWeight={700}>
</Typography>
<Typography variant="caption">
</Typography>
</Stack>
) : (
<Stack spacing={1.5}>
{groupedSessions.map(([groupLabel, groupSessions]) => (
<Box key={groupLabel}>
<Typography
variant="caption"
color="text.secondary"
fontWeight={800}
sx={{ px: 0.5, mb: 0.75, display: "block", letterSpacing: 0.3 }}
>
{groupLabel}
</Typography>
<Stack spacing={1}>
{groupSessions.map((session) => {
const isActive = session.id === activeSessionId;
return (
<Paper
key={session.id}
elevation={0}
onClick={() => onSelectSession(session.id)}
sx={{
px: 1.25,
py: 1,
borderRadius: 3,
cursor: isHydrating ? "default" : "pointer",
bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56),
border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`,
boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`,
transition: "all 0.2s ease",
pointerEvents: isHydrating ? "none" : "auto",
"&:hover": {
bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86),
borderColor: alpha("#00acc1", 0.2),
},
}}
>
<Stack direction="row" spacing={1} alignItems="flex-start">
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
fontWeight={isActive ? 800 : 700}
color="text.primary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{session.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: "block" }}>
{formatRelativeDate(session.updatedAt)}
</Typography>
</Box>
<Tooltip title="删除会话">
<span>
<IconButton
size="small"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setPendingDeleteSessionId(session.id);
setIsDeleteDialogOpen(true);
}}
sx={{
width: 24,
height: 24,
color: "text.secondary",
"&:hover": {
color: "error.main",
bgcolor: alpha("#ef5350", 0.08),
},
}}
>
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</Stack>
</Paper>
);
})}
</Stack>
</Box>
))}
</Stack>
)}
</Box>
</Paper>
<Dialog
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
TransitionProps={{
onExited: () => setPendingDeleteSessionId(null)
}}
PaperProps={{
sx: {
borderRadius: 4,
bgcolor: alpha("#fff", 0.85),
backdropFilter: "blur(24px)",
boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`,
border: `1px solid ${alpha("#fff", 0.6)}`,
minWidth: 320,
},
}}
>
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1.5, pb: 1, pt: 3, px: 3 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: "50%",
bgcolor: alpha("#ef5350", 0.12),
color: "#ef5350",
}}
>
<WarningRounded sx={{ fontSize: 22 }} />
</Box>
<Typography variant="h6" fontWeight={800} color="text.primary">
</Typography>
</DialogTitle>
<DialogContent sx={{ px: 3, pb: 2 }}>
<DialogContentText color="text.secondary" sx={{ fontSize: "0.95rem" }}>
{pendingDeleteSession ? (
<Typography component="span" fontWeight={700} color="text.primary">
{pendingDeleteSession.title}
</Typography>
) : (
"该会话"
)}
<br />
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3, pt: 1 }}>
<Button
onClick={() => setIsDeleteDialogOpen(false)}
sx={{
color: "text.secondary",
fontWeight: 600,
borderRadius: 2.5,
px: 2.5,
"&:hover": { bgcolor: alpha("#000", 0.04) },
}}
>
</Button>
<Button
variant="contained"
onClick={() => {
if (pendingDeleteSessionId) {
onDeleteSession(pendingDeleteSessionId);
}
setIsDeleteDialogOpen(false);
}}
sx={{
bgcolor: "#ef5350",
color: "#fff",
fontWeight: 700,
borderRadius: 2.5,
px: 3,
boxShadow: `0 4px 12px ${alpha("#ef5350", 0.3)}`,
"&:hover": {
bgcolor: "#e53935",
boxShadow: `0 6px 16px ${alpha("#ef5350", 0.4)}`,
},
}}
>
</Button>
</DialogActions>
</Dialog>
</>
);
};
@@ -0,0 +1,60 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { AgentProgressTimeline } from "./AgentProgressTimeline";
import type { ChatProgress } from "./GlobalChatbox.types";
describe("AgentProgressTimeline", () => {
it("shows the running step and keeps the timeline expanded while running", () => {
const progress: ChatProgress[] = [
{
id: "start",
phase: "start",
status: "completed",
title: "收到请求",
},
{
id: "tool",
phase: "tool",
status: "running",
title: "正在调用 dynamic_http_call",
detail: "GET /api/v1/network/bottlenecks",
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("Agent 过程")).toBeInTheDocument();
expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument();
});
it("summarizes completed steps and lets users expand details", async () => {
const progress: ChatProgress[] = [
{ id: "start", phase: "start", status: "completed", title: "收到请求" },
{ id: "done", phase: "complete", status: "completed", title: "分析完成" },
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.queryByText("分析完成")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开" }));
expect(screen.getByText("分析完成")).toBeVisible();
});
it("treats stale running steps as finished after a complete event", () => {
const progress: ChatProgress[] = [
{ id: "tool", phase: "tool", status: "running", title: "正在调用 dynamic_http_call" },
{ id: "done", phase: "complete", status: "completed", title: "分析完成" },
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,268 @@
"use client";
import React, { useMemo, useState } from "react";
import {
Box,
Collapse,
LinearProgress,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
import AutoAwesome from "@mui/icons-material/AutoAwesome";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import ManageSearchRounded from "@mui/icons-material/ManageSearchRounded";
import BuildCircleRounded from "@mui/icons-material/BuildCircleRounded";
import TaskAltRounded from "@mui/icons-material/TaskAltRounded";
import PsychologyRounded from "@mui/icons-material/PsychologyRounded";
import SyncRounded from "@mui/icons-material/SyncRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import type { ChatProgress } from "./GlobalChatbox.types";
const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const sx = { fontSize: 16 };
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
if (status === "error") return <ErrorOutlineRounded sx={{ ...sx, color: "error.main" }} />;
if (phase === "planning") return <PsychologyRounded sx={{ ...sx, color: "#00acc1" }} />;
if (phase === "tool") return <BuildCircleRounded sx={{ ...sx, color: "warning.main" }} />;
if (phase === "complete") return <TaskAltRounded sx={{ ...sx, color: "success.main" }} />;
if (phase === "session") return <SyncRounded sx={{ ...sx, color: "info.main" }} />;
if (phase === "start") return <ManageSearchRounded sx={{ ...sx, color: "#00acc1" }} />;
return <AutoAwesome sx={{ ...sx, color: "#00acc1" }} />;
};
const formatToolTitle = (item: ChatProgress) => {
const text = `${item.title} ${item.detail ?? ""}`;
if (text.includes("dynamic_http_call")) return "查询后端数据";
if (text.includes("show_chart")) return "生成图表";
if (text.includes("locate_features")) return "地图定位";
if (text.includes("view_history")) return "打开历史曲线";
if (text.includes("view_scada")) return "打开 SCADA 面板";
return item.title;
};
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
const theme = useTheme();
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some(
(item) => item.phase === "complete" && item.status === "completed",
);
// 修正状态判断:如果外部标记为中断,或者没有完成标记
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(() => {
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 (
<Box
sx={{
borderRadius: 4,
bgcolor: alpha(statusColor, 0.04),
border: `1px solid ${alpha(statusColor, 0.15)}`,
backdropFilter: "blur(12px)",
overflow: "hidden",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(statusColor, 0.06),
borderColor: alpha(statusColor, 0.25),
}
}}
>
<Stack
direction="row"
spacing={1.5}
alignItems="center"
onClick={() => setExpanded(!expanded)}
sx={{
px: 2,
py: 1.25,
cursor: "pointer",
userSelect: "none"
}}
>
{isOverallComplete ? (
<TaskAltRounded sx={{ fontSize: 18, color: statusColor }} />
) : hasRunning ? (
<AutoAwesome sx={{ fontSize: 18, color: statusColor, animation: "spin 2s linear infinite", "@keyframes spin": { "0%": { transform: "rotate(0deg)" }, "100%": { transform: "rotate(360deg)" } } }} />
) : hasError ? (
<ErrorOutlineRounded sx={{ fontSize: 18, color: statusColor }} />
) : (
<AutoAwesome sx={{ fontSize: 18, color: statusColor }} />
)}
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
Agent : {summary}
</Typography>
<KeyboardArrowDownRounded
sx={{
fontSize: 20,
color: "text.secondary",
transform: expanded ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
}}
/>
</Stack>
{hasRunning && !expanded ? (
<LinearProgress
sx={{
height: 2,
bgcolor: "transparent",
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
}}
/>
) : null}
<Collapse in={expanded || hasRunning} timeout="auto" unmountOnExit={false}>
<Box>
{hasRunning ? (
<LinearProgress
sx={{
height: 1,
bgcolor: alpha(statusColor, 0.1),
"& .MuiLinearProgress-bar": { bgcolor: statusColor }
}}
/>
) : (
<Box sx={{ height: 1, bgcolor: alpha(statusColor, 0.1) }} />
)}
<Stack spacing={0} sx={{ px: 2, py: 1.5 }}>
{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 = (
<Stack key={item.id} direction="row" spacing={1.5} alignItems="stretch">
<Box
sx={{
position: "relative",
width: 20,
display: "flex",
justifyContent: "center",
flexShrink: 0,
pt: 0.3,
}}
>
{!isLast ? (
<Box
aria-hidden
sx={{
position: "absolute",
top: 22,
bottom: -6,
left: "50%",
width: 2,
transform: "translateX(-50%)",
borderRadius: 2,
bgcolor: alpha(itemColor, item.status === "completed" ? 0.2 : 0.4),
}}
/>
) : null}
<Box
sx={{
position: "relative",
zIndex: 1,
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: alpha(theme.palette.background.paper, 0.9),
boxShadow: `0 0 0 2px ${alpha(itemColor, 0.1)}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{phaseIcon(
item.phase,
isAborted && isLast ? "error" :
isOverallComplete && item.status === "running"
? "completed"
: item.status,
)}
</Box>
</Box>
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
{item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography>
{item.detail && (
<Collapse in={expanded || isLast} timeout="auto">
<Typography
variant="caption"
component="div"
sx={{
mt: 0.5,
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(itemColor, 0.05),
border: `1px solid ${alpha(itemColor, 0.1)}`,
color: "text.secondary",
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono, monospace)",
fontSize: "0.7rem",
lineHeight: 1.5,
wordBreak: "break-all",
}}
>
{item.detail}
</Typography>
</Collapse>
)}
</Box>
</Stack>
);
if (isHiddenWhenCollapsed) {
return (
<Collapse key={item.id} in={expanded} timeout="auto" unmountOnExit={false}>
{content}
</Collapse>
);
}
return content;
})}
</Stack>
</Box>
</Collapse>
</Box>
);
};
+552
View File
@@ -0,0 +1,552 @@
"use client";
import Image from "next/image";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { AnimatePresence, motion } from "framer-motion";
import {
Avatar,
Box,
Button,
IconButton,
Paper,
Stack,
Typography,
alpha,
useTheme,
} from "@mui/material";
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 { 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 }) => (
<div className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
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)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
if (isUser) {
return (
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{ alignSelf: "flex-end", maxWidth: "86%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isEditing ? (
<Paper
elevation={12}
sx={{
p: 1.5,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.75),
backdropFilter: "blur(40px)",
border: `1px solid ${alpha("#ffffff", 0.9)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
minWidth: { xs: 260, sm: 320, md: 400 },
maxWidth: "100%",
}}
>
<Box component="textarea"
autoFocus
value={editDraft}
onChange={(e) => 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,
}}
/>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 1 }}>
<IconButton
size="small"
aria-label="取消"
onClick={() => { setEditDraft(message.content); setIsEditing(false); }}
sx={{
bgcolor: alpha("#000", 0.05),
color: "text.secondary",
width: 34, height: 34,
"&:hover": { bgcolor: alpha("#000", 0.1) }
}}
>
<CloseRounded fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="发送修改"
disabled={editDraft.trim() === "" || editDraft.trim() === message.content}
onClick={() => {
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) }
}}
>
<SendRounded fontSize="small" sx={{ ml: 0.2 }} />
</IconButton>
</Stack>
</Paper>
) : (
<>
<Paper
elevation={4}
sx={{
p: 2,
borderRadius: 5,
borderBottomRightRadius: 2,
color: "#fff",
background: `linear-gradient(135deg, #0288d1, #00acc1)`,
boxShadow: `0 8px 24px -8px ${alpha("#00acc1", 0.5)}, inset 0 2px 4px ${alpha("#fff", 0.2)}`,
backdropFilter: "blur(10px)",
"--chat-md-text": alpha("#fff", 0.96),
"--chat-md-heading": "#fff",
"--chat-md-link": "#e0f7fa",
"--chat-md-link-hover": "#fff",
"--chat-md-inline-code-bg": "rgba(255,255,255,0.15)",
"--chat-md-inline-code-border": alpha("#fff", 0.1),
"--chat-md-inline-code-text": "#fff",
"--chat-md-pre-bg": "rgba(0, 0, 0, 0.25)",
"--chat-md-pre-border": alpha("#fff", 0.1),
"--chat-md-pre-text": "#F8FAFC",
"--chat-md-quote-border": alpha("#fff", 0.4),
"--chat-md-quote-bg": alpha("#fff", 0.05),
"--chat-md-quote-text": alpha("#fff", 0.8),
}}
>
<MarkdownBlock>{message.content}</MarkdownBlock>
<AnimatePresence>
{isHovered && !isEditing && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -12, right: -8, zIndex: 10 }}
>
<IconButton
size="small"
onClick={() => { 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" }
}}
>
<EditRounded sx={{ fontSize: 14 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Paper>
{branchState && branchState.total > 1 ? (
<Stack
direction="row"
justifyContent="flex-end"
sx={{ mt: 0.5, mr: 0.5 }}
>
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 0.5,
py: 0.25,
borderRadius: 4,
bgcolor: alpha("#000", 0.04),
backdropFilter: "blur(4px)",
border: `1px solid ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="上一分支"
onClick={() => onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronLeftRounded sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
{branchState.activeIndex + 1} / {branchState.total}
</Typography>
<IconButton
size="small"
aria-label="下一分支"
onClick={() => onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronRightRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</Stack>
) : null}
</>
)}
</motion.div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ width: "100%", position: "relative" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Avatar
sx={{
width: 34,
height: 34,
background: alpha("#ffffff", 0.9),
boxShadow: `0 4px 12px ${alpha("#00acc1", 0.25)}`,
border: `1.5px solid ${alpha("#fff", 0.8)}`,
color: "#00acc1",
mt: 0.25,
p: 0.5,
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{ objectFit: "contain" }}
/>
</Avatar>
<Paper
elevation={0}
sx={{
flex: 1,
minWidth: 0,
p: 2,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.65),
border: `1px solid ${alpha("#fff", 0.8)}`,
boxShadow: `0 10px 30px -10px ${alpha(theme.palette.common.black, 0.08)}`,
backdropFilter: "blur(20px)",
position: "relative",
"--chat-md-text": "text.primary",
"--chat-md-heading": "text.primary",
"--chat-md-link": "#00838f",
"--chat-md-link-hover": "#00acc1",
"--chat-md-inline-code-bg": alpha("#00acc1", 0.08),
"--chat-md-inline-code-border": alpha("#00acc1", 0.15),
"--chat-md-inline-code-text": "#006064",
"--chat-md-pre-bg": "#1e293b",
"--chat-md-pre-border": "#475569",
"--chat-md-pre-text": "#f1f5f9",
"--chat-md-quote-border": "#00acc1",
"--chat-md-quote-bg": alpha("#00acc1", 0.04),
"--chat-md-quote-text": "text.secondary",
}}
>
<Stack spacing={1.5}>
{message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null}
<Box
sx={{
p: 1.5,
borderRadius: 4,
bgcolor: alpha("#fff", 0.4),
border: `1px solid ${alpha("#fff", 0.6)}`,
}}
>
<Stack spacing={1.2}>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
</Typography>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
}
if (segment.type === "tool_call") {
if (
segment.toolCall.tool === "chart" ||
segment.toolCall.tool === "show_chart"
) {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={(p.series as ChatChartSeries[]) ?? []}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<Typography key="tool-pending" variant="caption" color="text.secondary">
...
</Typography>
);
}
return null;
})}
</Stack>
</Box>
</Stack>
<AnimatePresence>
{isHovered && !isErrorMessage && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 5 }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
>
<Paper
elevation={4}
sx={{
display: "flex",
gap: 0.5,
p: 0.5,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.8),
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="复制"
onClick={() => {
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) } }}
>
<ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
aria-label="重新生成"
onClick={() => {
onRegenerate();
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
>
<RefreshRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</motion.div>
)}
</AnimatePresence>
</Paper>
</Stack>
{(!isErrorMessage && isTtsSupported) || (branchState && branchState.total > 1) ? (
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.5, ml: 6, mb: 1 }}>
<Stack direction="row" spacing={0.5} sx={{ opacity: isHovered ? 1 : 0.4, transition: "opacity 0.2s" }}>
{!isErrorMessage && isTtsSupported ? (
<>
{messageSpeechState === "idle" ? (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
) : null}
{messageSpeechState === "playing" ? (
<>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "paused" ? (
<>
<IconButton size="small" onClick={onResume} aria-label="继续朗读" sx={{ color: "primary.main", p: 0.5 }}>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
</>
) : null}
</Stack>
{branchState && branchState.total > 1 ? (
<Stack
direction="row"
justifyContent="flex-start"
sx={{ mr: 0.5 }}
>
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 0.5,
py: 0.25,
borderRadius: 4,
bgcolor: alpha("#000", 0.04),
backdropFilter: "blur(4px)",
border: `1px solid ${alpha("#000", 0.08)}`,
}}
>
<IconButton
size="small"
aria-label="上一分支"
onClick={() => onCycleBranch(rootMessageId, -1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronLeftRounded sx={{ fontSize: 16 }} />
</IconButton>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600, fontSize: "0.7rem", px: 0.5, userSelect: "none" }}>
{branchState.activeIndex + 1} / {branchState.total}
</Typography>
<IconButton
size="small"
aria-label="下一分支"
onClick={() => onCycleBranch(rootMessageId, 1)}
sx={{ width: 22, height: 22, color: "text.secondary", "&:hover": { bgcolor: alpha("#000", 0.08) } }}
>
<ChevronRightRounded sx={{ fontSize: 16 }} />
</IconButton>
</Paper>
</Stack>
) : null}
</Stack>
) : null}
</motion.div>
);
},
);
AgentTurn.displayName = "AgentTurn";
+278
View File
@@ -0,0 +1,278 @@
"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
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 {
BranchGroup,
BranchTransition,
Message,
SpeechState,
} from "./GlobalChatbox.types";
type AgentWorkspaceProps = {
messages: Message[];
branchGroups: BranchGroup[];
branchTransition: BranchTransition | null;
isStreaming: boolean;
bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null;
speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
onRegenerate: () => void;
onEditResubmit: (messageId: string, newContent: string) => void;
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
};
const EmptyState = () => {
const theme = useTheme();
const capabilities = [
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
{ icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" },
{ icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" },
];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
>
<Paper
elevation={0}
sx={{
p: 4,
borderRadius: 4,
bgcolor: alpha("#ffffff", 0.4),
border: `1px solid ${alpha("#fff", 0.8)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`,
textAlign: "center",
backdropFilter: "blur(24px)",
position: "relative",
overflow: "hidden",
}}
>
<Box sx={{
position: "absolute",
top: -100,
right: -100,
width: 200,
height: 200,
background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)",
}} />
<motion.div
animate={{
y: [-6, 4, -6],
scale: [1, 1.04, 1],
rotate: [-3, 3, -3],
}}
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 88,
height: 88,
marginBottom: 12,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)",
boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)",
}}
>
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={54}
height={54}
style={{
objectFit: "contain",
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
}}
/>
</motion.div>
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
使
</Typography>
<Grid container spacing={1.5}>
{capabilities.map((item) => (
<Grid item xs={6} key={item.label}>
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
sx={{
px: 1.5,
py: 1.5,
borderRadius: 3,
bgcolor: alpha("#fff", 0.5),
border: `1px solid ${alpha("#fff", 0.6)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`,
color: "text.primary",
transition: "all 0.2s",
"&:hover": {
bgcolor: alpha("#fff", 0.8),
borderColor: alpha("#00acc1", 0.4),
boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`,
}
}}
>
{item.icon}
<Typography variant="caption" fontWeight={700}>
{item.label}
</Typography>
</Stack>
</motion.div>
</Grid>
))}
</Grid>
</Paper>
</motion.div>
);
};
export const AgentWorkspace = ({
messages,
branchGroups,
branchTransition,
isStreaming,
bottomRef,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
isTtsSupported,
onRegenerate,
onEditResubmit,
onCycleBranch,
}: AgentWorkspaceProps) => {
const theme = useTheme();
const latestAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
const showTypingIndicator =
isStreaming &&
(!latestAssistant ||
(latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length)));
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 (
<AgentTurn
key={rootMessageId}
message={message}
branchState={
branchGroup && branchGroup.branches.length > 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 (
<Box
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
py: 2,
display: "flex",
flexDirection: "column",
zIndex: 5,
}}
>
<AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null}
</AnimatePresence>
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{stableMessages.map(renderTurn)}
{branchTransition ? (
<AnimatePresence initial={false} mode="wait">
<motion.div
key={`${branchTransition.rootMessageId}:${branchTransition.activeBranchId}:${branchTransition.nonce}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{transitionMessages.map(renderTurn)}
</motion.div>
</AnimatePresence>
) : null}
</Box>
) : null}
{showTypingIndicator ? (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
>
<Paper
elevation={0}
sx={{
p: 1.3,
borderRadius: 4,
bgcolor: alpha("#fff", 0.82),
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
}}
>
<TypingIndicator />
</Paper>
</motion.div>
) : null}
<div ref={bottomRef} style={{ height: 1 }} />
</Box>
);
};
+126 -57
View File
@@ -10,11 +10,15 @@ import {
Typography,
alpha,
useTheme,
Collapse,
IconButton,
} from "@mui/material";
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
import TimelineRounded from "@mui/icons-material/TimelineRounded";
import SensorsRounded from "@mui/icons-material/SensorsRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import {
useChatToolStore,
@@ -45,6 +49,26 @@ const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
};
const LOCATE_LINE_TOOLS = new Set<string>(["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<string, ToolMeta> = {
locate_features: {
@@ -111,21 +135,32 @@ const TOOL_META: Record<string, ToolMeta> = {
/* ---------- 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, unknown>): 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 [];
};
}
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)) {
@@ -305,7 +327,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
if (!config) return null;
return {
type: "locate_features",
ids: normalizeIds(),
ids: normalizeLocateIds(params),
layer: config.layer,
geometryKind: config.geometryKind,
};
@@ -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<ChatToolCallBlockProps> = ({
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: <TimelineRounded sx={{ fontSize: 18 }} />,
actionLabel: "执行",
color: theme.palette.primary.main,
color: "#00acc1",
};
const description = getToolDescription(toolCall);
@@ -400,90 +423,133 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
<Paper
elevation={0}
sx={{
mt: 1.5,
mt: 1,
mb: 1,
p: 1.5,
borderRadius: 3,
border: `1px solid ${alpha(meta.color, 0.25)}`,
bgcolor: alpha(meta.color, 0.04),
overflow: "hidden",
borderRadius: 4,
border: `1px solid ${alpha(meta.color, 0.3)}`,
bgcolor: alpha(meta.color, 0.05),
backdropFilter: "blur(12px)",
transition: "all 0.3s ease",
"&:hover": {
bgcolor: alpha(meta.color, 0.08),
border: `1px solid ${alpha(meta.color, 0.4)}`,
}
}}
>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
p: 1.5,
display: "flex",
alignItems: "center",
cursor: "pointer",
gap: 1.5,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
{/* Icon */}
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(meta.color, 0.12),
borderRadius: "50%",
bgcolor: alpha(meta.color, 0.15),
display: "flex",
alignItems: "center",
justifyContent: "center",
color: meta.color,
flexShrink: 0,
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
}}
>
{meta.icon}
</Box>
{/* Description */}
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* Title */}
<Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
<Typography
variant="caption"
variant="body2"
sx={{
fontWeight: 600,
fontWeight: 700,
color: "text.primary",
display: "block",
}}
>
{meta.label}
</Typography>
{description && (
{!expanded && description && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
fontSize: "0.75rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 180,
opacity: 0.8,
}}
>
{description}
{description}
</Typography>
)}
</Box>
{/* Action */}
<IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
{expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
</IconButton>
</Box>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
<Stack direction="column" spacing={1.5}>
{description && (
<Box sx={{
p: 1.5,
borderRadius: 3,
bgcolor: alpha("#000", 0.03),
border: `1px solid ${alpha("#000", 0.05)}`,
}}>
<Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
</Typography>
<Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
{description}
</Typography>
</Box>
)}
<Stack direction="row" justifyContent="flex-end">
{executed ? (
<Chip
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
label="已执行"
size="small"
sx={{
bgcolor: alpha("#4caf50", 0.1),
color: "#4caf50",
fontWeight: 600,
bgcolor: alpha("#00e676", 0.15),
color: "#00c853",
fontWeight: 700,
fontSize: "0.75rem",
}}
/>
) : (
<Button
size="small"
variant="outlined"
onClick={handleExecute}
variant="contained"
disableElevation
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
sx={{
borderColor: alpha(meta.color, 0.4),
color: meta.color,
fontWeight: 600,
fontSize: "0.75rem",
borderRadius: 2,
bgcolor: meta.color,
color: "#fff",
fontWeight: 700,
fontSize: "0.8rem",
borderRadius: 2.5,
px: 2,
textTransform: "none",
whiteSpace: "nowrap",
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
"&:hover": {
borderColor: meta.color,
bgcolor: alpha(meta.color, 0.08),
bgcolor: meta.color,
filter: "brightness(0.9)",
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
},
}}
>
@@ -491,6 +557,9 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
</Button>
)}
</Stack>
</Stack>
</Box>
</Collapse>
</Paper>
);
};
+1 -351
View File
@@ -1,35 +1,8 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { motion } from "framer-motion";
import {
Avatar,
Box,
IconButton,
Paper,
Stack,
Typography,
alpha,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
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 {
parseAssistantMessageSections,
parseContentWithToolCalls,
type ContentSegment,
} from "./chatMessageSections";
import { ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock";
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
import type { Message, SpeechState } from "./GlobalChatbox.types";
import { stripMarkdown } from "./GlobalChatbox.utils";
import { Box, Stack } from "@mui/material";
export const TypingIndicator = () => {
return (
@@ -101,326 +74,3 @@ export const Blob = ({
}}
/>
);
type ChatMessageItemProps = {
message: Message;
theme: Theme;
messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void;
onPause: () => void;
onResume: () => void;
onStopSpeech: () => void;
isTtsSupported: boolean;
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
};
export const ChatMessageItem = React.memo(
({
message,
theme,
messageSpeechState,
onSpeak,
onPause,
onResume,
onStopSpeech,
isTtsSupported,
sseChartParams,
}: ChatMessageItemProps) => {
const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError);
const parsedAssistantSections =
!isUser && !isErrorMessage
? parseAssistantMessageSections(message.content)
: null;
const answerContent = parsedAssistantSections?.answer ?? message.content;
const contentSegments: ContentSegment[] =
!isUser && !isErrorMessage
? parseContentWithToolCalls(answerContent).segments
: [{ type: "text", content: answerContent }];
return (
<motion.div
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 350, damping: 25 }}
style={{
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "85%",
display: "flex",
flexDirection: isUser ? "row-reverse" : "row",
gap: 12,
alignItems: "flex-end",
}}
>
{!isUser && (
<Avatar
sx={{
width: 28,
height: 28,
bgcolor: isErrorMessage
? alpha(theme.palette.error.main, 0.12)
: alpha(theme.palette.secondary.main, 0.1),
mb: 0.5,
}}
>
{isErrorMessage ? (
<ErrorOutlineRounded sx={{ fontSize: 16, color: "error.main" }} />
) : (
<AutoAwesome sx={{ fontSize: 16, color: "secondary.main" }} />
)}
</Avatar>
)}
<Box>
<Paper
elevation={isUser ? 8 : isErrorMessage ? 1 : 2}
sx={{
p: 2.5,
borderRadius: 4,
borderBottomRightRadius: isUser ? 4 : 24,
borderBottomLeftRadius: !isUser ? 4 : 24,
bgcolor: isUser
? "primary.main"
: isErrorMessage
? alpha(theme.palette.error.light, 0.18)
: "#fff",
color: isUser ? "#fff" : isErrorMessage ? "error.dark" : "text.primary",
background: isUser
? `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.dark})`
: isErrorMessage
? `linear-gradient(135deg, ${alpha(theme.palette.error.light, 0.28)}, ${alpha(theme.palette.error.main, 0.12)})`
: undefined,
border: isErrorMessage
? `1px solid ${alpha(theme.palette.error.main, 0.35)}`
: "none",
boxShadow: isUser
? `0 8px 24px -4px ${alpha(theme.palette.primary.main, 0.5)}`
: isErrorMessage
? `0 4px 16px -4px ${alpha(theme.palette.error.main, 0.2)}`
: `0 4px 16px -4px ${alpha("#000", 0.05)}`,
"--chat-md-text": isUser
? alpha("#fff", 0.96)
: isErrorMessage
? theme.palette.error.dark
: "#1f2937",
"--chat-md-heading": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#111827",
"--chat-md-link": isUser
? "#E3F2FD"
: isErrorMessage
? theme.palette.error.main
: "#7C3AED",
"--chat-md-link-hover": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#6D28D9",
"--chat-md-inline-code-bg": isUser
? "rgba(255,255,255,0.2)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#EEF2FF",
"--chat-md-inline-code-border": isUser
? alpha("#fff", 0.16)
: isErrorMessage
? alpha(theme.palette.error.main, 0.25)
: "#CBD5E1",
"--chat-md-inline-code-text": isUser
? "#fff"
: isErrorMessage
? theme.palette.error.dark
: "#334155",
"--chat-md-pre-bg": isUser
? "rgba(11, 18, 32, 0.56)"
: isErrorMessage
? alpha(theme.palette.error.main, 0.08)
: "#111827",
"--chat-md-pre-border": isUser
? alpha("#fff", 0.12)
: isErrorMessage
? alpha(theme.palette.error.main, 0.3)
: "#64748B",
"--chat-md-pre-text": isUser
? "#F8FAFC"
: isErrorMessage
? theme.palette.error.dark
: "#E5E7EB",
"--chat-md-quote-border": isErrorMessage
? alpha(theme.palette.error.main, 0.5)
: isUser
? alpha("#fff", 0.5)
: "#7C3AED",
"--chat-md-quote-bg": isUser
? alpha("#fff", 0.08)
: isErrorMessage
? alpha(theme.palette.error.main, 0.06)
: "#F5F3FF",
"--chat-md-quote-text": isUser
? alpha("#fff", 0.9)
: isErrorMessage
? theme.palette.error.dark
: "#475569",
}}
>
{contentSegments.map((segment, segIdx) => {
if (segment.type === "text") {
const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null;
return (
<div key={segIdx} className={markdownStyles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{text || "..."}
</ReactMarkdown>
</div>
);
}
if (segment.type === "tool_call") {
if (segment.toolCall.tool === "chart") {
return (
<ChatInlineChart
key={segment.toolCall.id}
{...(segment.toolCall.params as Record<string, unknown>)}
/>
);
}
if (segment.toolCall.tool === "show_chart") {
const p = segment.toolCall.params;
return (
<ChatInlineChart
key={segment.toolCall.id}
title={(p.title as string) ?? undefined}
chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(p.x_data as string[]) ?? []}
series={
(p.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(p.x_axis_name as string) ?? undefined}
y_axis_name={(p.y_axis_name as string) ?? undefined}
/>
);
}
return (
<ChatToolCallBlock
key={segment.toolCall.id}
toolCall={segment.toolCall}
/>
);
}
if (segment.type === "tool_call_pending") {
return (
<motion.div
key="tool-pending"
initial={{ opacity: 0 }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
style={{
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<AutoAwesome sx={{ fontSize: 14, color: "primary.main" }} />
<Typography variant="caption" color="text.secondary">
...
</Typography>
</motion.div>
);
}
return null;
})}
{sseChartParams?.map((chart, idx) => (
<ChatInlineChart
key={`sse-chart-${idx}`}
title={(chart.params.title as string) ?? undefined}
chart_type={
(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"
}
x_data={(chart.params.x_data as string[]) ?? []}
series={
(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ??
[]
}
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
/>
))}
</Paper>
{!isUser && !isErrorMessage && isTtsSupported && (
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
{messageSpeechState === "idle" && (
<IconButton
size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))}
aria-label="朗读消息"
sx={{
color: "text.secondary",
opacity: 0.6,
"&:hover": { opacity: 1 },
p: 0.5,
}}
>
<VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton>
)}
{messageSpeechState === "playing" && (
<>
<IconButton
size="small"
onClick={onPause}
aria-label="暂停朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PauseRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
{messageSpeechState === "paused" && (
<>
<IconButton
size="small"
onClick={onResume}
aria-label="继续朗读"
sx={{ color: "primary.main", p: 0.5 }}
>
<PlayArrowRounded sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
size="small"
onClick={onStopSpeech}
aria-label="停止朗读"
sx={{ color: "error.main", p: 0.5 }}
>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
)}
</Stack>
)}
</Box>
</motion.div>
);
},
);
ChatMessageItem.displayName = "ChatMessageItem";
File diff suppressed because it is too large Load Diff
+83 -2
View File
@@ -1,8 +1,57 @@
export type ChatProgress = {
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
};
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
export type AgentArtifact = {
id: string;
tool: string;
kind: AgentArtifactKind;
title: string;
description?: string;
params: Record<string, unknown>;
};
export type Message = {
id: string;
role: "user" | "assistant";
content: string;
isError?: boolean;
progress?: ChatProgress[];
artifacts?: AgentArtifact[];
branchRootId?: string;
};
export type BranchState = {
activeIndex: number;
total: number;
};
export type MessageBranch = {
id: string;
label: string;
sessionId?: string;
messages: Message[];
};
export type BranchGroup = {
id: string;
rootMessageId: string;
parentCount: number;
activeIndex: number;
branches: MessageBranch[];
};
export type BranchTransition = {
rootMessageId: string;
parentCount: number;
activeBranchId: string;
nonce: number;
};
export type Props = {
@@ -12,7 +61,39 @@ export type Props = {
export type SpeechState = "idle" | "playing" | "paused";
export type PersistedChatState = {
export type LegacyPersistedChatState = {
messages: Message[];
conversationId?: string;
sessionId?: string;
branchGroups?: BranchGroup[];
};
export type ChatSessionRecord = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
sessionId?: string;
messages: Message[];
branchGroups: BranchGroup[];
};
export type ChatSessionSummary = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
export type ChatStorageMeta = {
key: "chat-meta";
activeSessionId?: string;
migratedFromLocalStorage?: boolean;
};
export type LoadedChatState = {
storageSessionId?: string;
title?: string;
messages: Message[];
sessionId?: string;
branchGroups: BranchGroup[];
};
+19 -32
View File
@@ -1,21 +1,15 @@
import type { PersistedChatState } from "./GlobalChatbox.types";
import type { BranchGroup, Message } from "./GlobalChatbox.types";
export const createId = () =>
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
export const CHAT_STORAGE_KEY = "tjwater_copilot_chat_state_v1";
const THINK_TAG_ALIAS_PATTERN =
/<\s*(\/?)\s*(thinking|reasoning|thought)\b[^>]*>/gi;
export const PRESET_PROMPTS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
"查询关键 SCADA 点位最近 24 小时的异常波动。",
"排查当前管网爆管风险,并说明优先处置建议。",
];
export const normalizeThoughtTagToken = (token: string): string =>
token.replace(THINK_TAG_ALIAS_PATTERN, (_, closingSlash: string) =>
closingSlash ? "</think>" : "<think>",
);
export const stripMarkdown = (md: string): string =>
md
.replace(/```[\s\S]*?```/g, "")
@@ -34,26 +28,19 @@ export const stripMarkdown = (md: string): string =>
.replace(/<[^>]+>/g, "")
.trim();
export const getInitialChatState = (): PersistedChatState => {
if (typeof window === "undefined") {
return { messages: [], conversationId: undefined };
}
try {
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
if (!storedRaw) return { messages: [], conversationId: undefined };
const parsed = JSON.parse(storedRaw) as PersistedChatState;
if (!Array.isArray(parsed.messages)) {
console.error("[GlobalChatbox] Invalid persisted messages format.");
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: undefined };
}
return { messages: parsed.messages, conversationId: parsed.conversationId };
} catch (error) {
console.error(
"[GlobalChatbox] Failed to read persisted chat state:",
error,
);
window.localStorage.removeItem(CHAT_STORAGE_KEY);
return { messages: [], conversationId: 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),
})),
}));
+346
View File
@@ -0,0 +1,346 @@
import { openDB, type DBSchema } from "idb";
import type {
BranchGroup,
ChatSessionRecord,
ChatSessionSummary,
ChatStorageMeta,
LegacyPersistedChatState,
LoadedChatState,
Message,
} from "./GlobalChatbox.types";
import {
cloneBranchGroups,
cloneMessages,
createId,
} from "./GlobalChatbox.utils";
const CHAT_DB_NAME = "tjwater-agent-chat";
const CHAT_DB_VERSION = 1;
const SESSION_STORE = "sessions";
const META_STORE = "meta";
const META_KEY = "chat-meta" as const;
const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1";
type ChatDB = DBSchema & {
sessions: {
key: string;
value: ChatSessionRecord;
indexes: {
"by-updatedAt": number;
};
};
meta: {
key: string;
value: ChatStorageMeta;
};
};
const emptyLoadedChatState = (): LoadedChatState => ({
storageSessionId: undefined,
title: undefined,
messages: [],
sessionId: undefined,
branchGroups: [],
});
const sanitizeMessages = (messages: Message[] | undefined) =>
Array.isArray(messages) ? cloneMessages(messages) : [];
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => {
if (!session) return emptyLoadedChatState();
return {
storageSessionId: session.id,
title: session.title,
messages: sanitizeMessages(session.messages),
sessionId: session.sessionId,
branchGroups: sanitizeBranchGroups(session.branchGroups),
};
};
const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({
id: session.id,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
});
const buildSessionTitle = (messages: Message[]) => {
const firstUserMessage = messages.find((message) => message.role === "user");
if (!firstUserMessage) return "新对话";
const title = firstUserMessage.content.replace(/\s+/g, " ").trim();
if (!title) return "新对话";
return title.length > 24 ? `${title.slice(0, 24)}...` : title;
};
const getDb = () =>
openDB<ChatDB>(CHAT_DB_NAME, CHAT_DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(SESSION_STORE)) {
const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: "id" });
sessionStore.createIndex("by-updatedAt", "updatedAt");
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE, { keyPath: "key" });
}
},
});
const readLegacyChatState = (): LegacyPersistedChatState | null => {
if (typeof window === "undefined") return null;
try {
const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY);
if (!storedRaw) return null;
const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState;
if (!Array.isArray(parsed.messages)) {
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
return null;
}
return {
messages: sanitizeMessages(parsed.messages),
sessionId: parsed.sessionId,
branchGroups: sanitizeBranchGroups(parsed.branchGroups),
};
} catch (error) {
console.error("[GlobalChatbox] Failed to read legacy chat state:", error);
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
return null;
}
};
const clearLegacyChatState = () => {
if (typeof window === "undefined") return;
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
};
const getMeta = async () => {
const db = await getDb();
return db.get(META_STORE, META_KEY);
};
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => {
const db = await getDb();
await db.put(META_STORE, {
key: META_KEY,
...meta,
});
};
const getLatestSession = async () => {
const db = await getDb();
const sessions = await db.getAll(SESSION_STORE);
if (sessions.length === 0) return undefined;
return sessions.sort((left, right) => right.updatedAt - left.updatedAt)[0];
};
const migrateLegacyLocalStorage = async () => {
const meta = await getMeta();
if (meta?.migratedFromLocalStorage) return;
const legacyState = readLegacyChatState();
if (!legacyState) {
await setMeta({
activeSessionId: meta?.activeSessionId,
migratedFromLocalStorage: true,
});
return;
}
const hasContent =
legacyState.messages.length > 0 ||
(legacyState.branchGroups?.length ?? 0) > 0 ||
Boolean(legacyState.sessionId);
if (!hasContent) {
clearLegacyChatState();
await setMeta({
activeSessionId: undefined,
migratedFromLocalStorage: true,
});
return;
}
const now = Date.now();
const sessionRecord: ChatSessionRecord = {
id: createId(),
title: buildSessionTitle(legacyState.messages),
createdAt: now,
updatedAt: now,
sessionId: legacyState.sessionId,
messages: sanitizeMessages(legacyState.messages),
branchGroups: sanitizeBranchGroups(legacyState.branchGroups),
};
const db = await getDb();
await db.put(SESSION_STORE, sessionRecord);
clearLegacyChatState();
await setMeta({
activeSessionId: sessionRecord.id,
migratedFromLocalStorage: true,
});
};
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage();
const meta = await getMeta();
const db = await getDb();
if (meta?.activeSessionId) {
const activeSession = await db.get(SESSION_STORE, meta.activeSessionId);
if (activeSession) {
return toLoadedChatState(activeSession);
}
}
const latestSession = await getLatestSession();
if (!latestSession) {
return emptyLoadedChatState();
}
await setMeta({
activeSessionId: latestSession.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(latestSession);
};
export const saveActiveChatState = async (
state: LoadedChatState,
): Promise<string | undefined> => {
if (typeof window === "undefined") return state.storageSessionId;
const hasContent =
state.messages.length > 0 ||
state.branchGroups.length > 0 ||
Boolean(state.sessionId);
const db = await getDb();
const existingSession = state.storageSessionId
? await db.get(SESSION_STORE, state.storageSessionId)
: undefined;
const meta = await getMeta();
if (!hasContent) {
if (state.storageSessionId) {
await db.delete(SESSION_STORE, state.storageSessionId);
}
await setMeta({
activeSessionId: undefined,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return undefined;
}
const now = Date.now();
const storageSessionId = state.storageSessionId ?? createId();
const computedTitle = buildSessionTitle(state.messages);
const preferredTitle = state.title?.trim();
const finalTitle = preferredTitle || computedTitle;
const nextRecord: ChatSessionRecord = {
id: storageSessionId,
title: finalTitle,
createdAt: existingSession?.createdAt ?? now,
updatedAt: now,
sessionId: state.sessionId,
messages: sanitizeMessages(state.messages),
branchGroups: sanitizeBranchGroups(state.branchGroups),
};
await db.put(SESSION_STORE, nextRecord);
await setMeta({
activeSessionId: storageSessionId,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return storageSessionId;
};
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
if (typeof window === "undefined") return [];
await migrateLegacyLocalStorage();
const db = await getDb();
const sessions = await db.getAll(SESSION_STORE);
return sessions
.sort((left, right) => right.updatedAt - left.updatedAt)
.map(toSessionSummary);
};
export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage();
const now = Date.now();
const session: ChatSessionRecord = {
id: createId(),
title: "新对话",
createdAt: now,
updatedAt: now,
sessionId: undefined,
messages: [],
branchGroups: [],
};
const db = await getDb();
await db.put(SESSION_STORE, session);
const meta = await getMeta();
await setMeta({
activeSessionId: session.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(session);
};
export const loadChatSessionById = async (sessionId: string): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage();
const db = await getDb();
const session = await db.get(SESSION_STORE, sessionId);
if (!session) {
return emptyLoadedChatState();
}
const meta = await getMeta();
await setMeta({
activeSessionId: session.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(session);
};
export const deleteChatSession = async (sessionId: string): Promise<string | undefined> => {
if (typeof window === "undefined") return undefined;
const db = await getDb();
await db.delete(SESSION_STORE, sessionId);
const remainingSessions = await db.getAll(SESSION_STORE);
const nextActiveSession = remainingSessions.sort(
(left, right) => right.updatedAt - left.updatedAt,
)[0];
const meta = await getMeta();
await setMeta({
activeSessionId: nextActiveSession?.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return nextActiveSession?.id;
};
@@ -0,0 +1,715 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import type {
AgentArtifact,
BranchGroup,
BranchTransition,
ChatProgress,
ChatSessionSummary,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import {
cloneBranchGroups,
cloneMessages,
createId,
} from "../GlobalChatbox.utils";
import {
createEmptyChatSession,
deleteChatSession,
listChatSessions,
loadActiveChatState,
loadChatSessionById,
saveActiveChatState,
} from "../chatStorage";
type UseAgentChatSessionOptions = {
onToolCall: (
event: StreamEvent & { type: "tool_call" },
options: {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
},
) => void;
onBeforeSend?: () => void;
};
type PromptRunOptions = {
prompt: string;
sessionIdOverride?: string;
preparedMessages?: Message[];
userMessage?: Message;
assistantMessage?: Message;
};
const upsertProgress = (
progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" },
) => {
const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id);
const nextItem: ChatProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) =>
item.status === "running" ? { ...item, status: "completed" as const } : item,
);
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,
}: UseAgentChatSessionOptions) => {
const storageSessionIdRef = useRef<string | undefined>(undefined);
const hydrationCompletedRef = useRef(false);
const hydrationNonceRef = useRef(0);
const [messages, setMessages] = useState<Message[]>([]);
const [sessionTitle, setSessionTitle] = useState<string | undefined>(undefined);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [branchGroups, setBranchGroups] = useState<BranchGroup[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSessionSummary[]>([]);
const [branchTransition, setBranchTransition] = useState<BranchTransition | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | undefined>(undefined);
const cancelPromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
useEffect(() => {
let cancelled = false;
const hydrate = async () => {
try {
const [loadedState, sessions] = await Promise.all([
loadActiveChatState(),
listChatSessions(),
]);
if (cancelled) return;
storageSessionIdRef.current = loadedState.storageSessionId;
sessionIdRef.current = loadedState.sessionId;
hydrationCompletedRef.current = true;
hydrationNonceRef.current += 1;
setMessages(loadedState.messages);
setSessionTitle(loadedState.title);
setSessionId(loadedState.sessionId);
setBranchGroups(loadedState.branchGroups);
setChatSessions(sessions);
} catch (error) {
console.error("[GlobalChatbox] Failed to hydrate chat state:", error);
} finally {
if (!cancelled) {
setIsHydrating(false);
}
}
};
void hydrate();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (isHydrating || !hydrationCompletedRef.current) return;
const currentHydrationNonce = hydrationNonceRef.current;
const persistTimer = window.setTimeout(() => {
const state: LoadedChatState = {
storageSessionId: storageSessionIdRef.current,
title: sessionTitle,
messages,
sessionId,
branchGroups,
};
void saveActiveChatState(state)
.then((storageSessionId) => {
if (hydrationNonceRef.current !== currentHydrationNonce) return;
storageSessionIdRef.current = storageSessionId;
return listChatSessions();
})
.then((sessions) => {
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
setChatSessions(sessions);
})
.catch((error) => {
console.error("[GlobalChatbox] Failed to persist chat state:", error);
});
}, 150);
return () => {
window.clearTimeout(persistTimer);
};
}, [branchGroups, isHydrating, messages, sessionId, sessionTitle]);
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) => {
setMessages((prev) =>
prev.map((message) =>
message.id === messageId
? {
...message,
artifacts: [...(message.artifacts ?? []), artifact],
}
: message,
),
);
}, []);
const runPrompt = useCallback(
async ({
prompt: rawPrompt,
sessionIdOverride,
preparedMessages,
userMessage,
assistantMessage,
}: PromptRunOptions) => {
const prompt = rawPrompt.trim();
if (!prompt || isStreaming || isHydrating) return;
await cancelPromiseRef.current?.catch(() => undefined);
onBeforeSend?.();
setBranchTransition(null);
const nextUserMessage = userMessage ?? createUserMessage(prompt);
const nextAssistantMessage = assistantMessage ?? createAssistantMessage();
const nextMessages =
preparedMessages ??
[...messages, nextUserMessage, nextAssistantMessage];
setIsStreaming(true);
setMessages(cloneMessages(nextMessages));
if (sessionIdOverride !== undefined) {
sessionIdRef.current = sessionIdOverride;
setSessionId(sessionIdOverride);
}
const controller = new AbortController();
abortRef.current = controller;
try {
await streamAgentChat({
message: prompt,
sessionId: sessionIdOverride ?? sessionIdRef.current,
signal: controller.signal,
onEvent: (event) => {
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 === nextAssistantMessage.id
? {
...message,
content: message.content + event.content,
isError: false,
}
: message,
),
);
} else if (event.type === "progress") {
setMessages((prev) =>
prev.map((message) =>
message.id === nextAssistantMessage.id
? { ...message, progress: upsertProgress(message.progress, event) }
: message,
),
);
} else if (event.type === "tool_call") {
onToolCall(event, {
assistantMessageId: nextAssistantMessage.id,
appendArtifact,
});
} else if (event.type === "session_title") {
const nextTitle = event.title.trim();
if (nextTitle) {
setSessionTitle(nextTitle);
}
} else if (event.type === "done") {
setMessages((prev) =>
prev.map((message) => {
if (message.id !== nextAssistantMessage.id) return message;
const completedProgress = completeRunningProgress(message.progress);
if (
message.content.trim().length === 0 &&
!(message.artifacts?.length)
) {
return {
...message,
content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress,
};
}
return { ...message, progress: completedProgress };
}),
);
setIsStreaming(false);
} else if (event.type === "error") {
setMessages((prev) =>
prev.map((message) =>
message.id === nextAssistantMessage.id
? {
...message,
content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true,
progress: completeRunningProgress(message.progress),
}
: message,
),
);
setIsStreaming(false);
}
},
});
} catch (error) {
if (controller.signal.aborted) {
setMessages((prev) =>
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 === nextAssistantMessage.id
? {
...message,
content: `⚠️ **错误:** ${String(error)}`,
isError: true,
progress: completeRunningProgress(message.progress),
}
: message,
),
);
setIsStreaming(false);
} finally {
abortRef.current = null;
setIsStreaming(false);
}
},
[appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
);
const abort = useCallback(() => {
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(() => {
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([]);
setSessionTitle(undefined);
setBranchGroups([]);
setBranchTransition(null);
setSessionId(undefined);
sessionIdRef.current = undefined;
storageSessionIdRef.current = undefined;
setIsStreaming(false);
}, []);
const createSession = useCallback(async () => {
if (isHydrating || isStreaming) return;
const controller = abortRef.current;
controller?.abort();
setBranchTransition(null);
const newState = await createEmptyChatSession();
const sessions = await listChatSessions();
hydrationNonceRef.current += 1;
storageSessionIdRef.current = newState.storageSessionId;
sessionIdRef.current = newState.sessionId;
setMessages(newState.messages);
setSessionTitle(newState.title);
setSessionId(newState.sessionId);
setBranchGroups(newState.branchGroups);
setChatSessions(sessions);
setIsStreaming(false);
}, [isHydrating, isStreaming]);
const switchSession = useCallback(
async (nextStorageSessionId: string) => {
if (isHydrating || isStreaming || storageSessionIdRef.current === nextStorageSessionId) {
return;
}
setIsHydrating(true);
try {
const [nextState, sessions] = await Promise.all([
loadChatSessionById(nextStorageSessionId),
listChatSessions(),
]);
hydrationNonceRef.current += 1;
storageSessionIdRef.current = nextState.storageSessionId;
sessionIdRef.current = nextState.sessionId;
setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessions);
} catch (error) {
console.error("[GlobalChatbox] Failed to switch chat session:", error);
} finally {
setIsHydrating(false);
}
},
[isHydrating, isStreaming],
);
const removeSession = useCallback(
async (targetStorageSessionId: string) => {
if (isHydrating || isStreaming) return;
try {
const nextActiveSessionId = await deleteChatSession(targetStorageSessionId);
const sessions = await listChatSessions();
setChatSessions(sessions);
if (storageSessionIdRef.current !== targetStorageSessionId) {
return;
}
if (!nextActiveSessionId) {
hydrationNonceRef.current += 1;
storageSessionIdRef.current = undefined;
sessionIdRef.current = undefined;
setBranchTransition(null);
setMessages([]);
setSessionTitle(undefined);
setSessionId(undefined);
setBranchGroups([]);
return;
}
setIsHydrating(true);
const [nextState, sessionsAfterDelete] = await Promise.all([
loadChatSessionById(nextActiveSessionId),
listChatSessions(),
]);
hydrationNonceRef.current += 1;
storageSessionIdRef.current = nextState.storageSessionId;
sessionIdRef.current = nextState.sessionId;
setBranchTransition(null);
setMessages(nextState.messages);
setSessionTitle(nextState.title);
setSessionId(nextState.sessionId);
setBranchGroups(nextState.branchGroups);
setChatSessions(sessionsAfterDelete);
} catch (error) {
console.error("[GlobalChatbox] Failed to delete chat session:", error);
} finally {
setIsHydrating(false);
}
},
[isHydrating, isStreaming],
);
const sendPrompt = useCallback(
async (rawPrompt: string) => {
await runPrompt({ prompt: rawPrompt });
},
[runPrompt],
);
const regenerate = useCallback(async () => {
if (isHydrating || 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,
});
}, [isHydrating, isStreaming, messages, runPrompt]);
const editAndResubmit = useCallback(
async (messageId: string, newContent: string) => {
if (isHydrating || 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,
});
},
[isHydrating, isStreaming, messages, runPrompt],
);
const cycleBranch = useCallback(
(rootMessageId: string, direction: -1 | 1) => {
if (isHydrating || 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;
});
},
[isHydrating, isStreaming, messages],
);
return {
messages,
chatSessions,
activeStorageSessionId: storageSessionIdRef.current,
branchGroups,
branchTransition,
isHydrating,
isStreaming,
sessionId,
sendPrompt,
regenerate,
editAndResubmit,
cycleBranch,
abort,
createSession,
reset,
removeSession,
switchSession,
};
};
@@ -0,0 +1,266 @@
"use client";
import { useCallback } from "react";
import { useChatToolStore, type ChatToolAction } from "@/store/chatToolStore";
import type { StreamEvent } from "@/lib/chatStream";
import type { AgentArtifact, AgentArtifactKind } from "../GlobalChatbox.types";
type ToolCallEvent = StreamEvent & { type: "tool_call" };
type HandleToolCallOptions = {
assistantMessageId: string;
appendArtifact: (messageId: string, artifact: AgentArtifact) => void;
};
const FEATURE_TYPE_MAP: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
junction: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
pipe: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
valve: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
reservoir: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
pump: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
tank: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
};
const LOCATE_TOOL_CONFIG: Record<
string,
{ layer: string; geometryKind: "point" | "line"; label: string }
> = {
locate_pipes: { layer: "geo_pipes_mat", geometryKind: "line", label: "管道" },
locate_junctions: { layer: "geo_junctions_mat", geometryKind: "point", label: "节点" },
locate_valves: { layer: "geo_valves", geometryKind: "point", label: "阀门" },
locate_reservoirs: { layer: "geo_reservoirs", geometryKind: "point", label: "水源" },
locate_pumps: { layer: "geo_pumps", geometryKind: "point", label: "泵站" },
locate_tanks: { layer: "geo_tanks", geometryKind: "point", label: "水池" },
};
const 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, unknown>): 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 rawValue === "string" || typeof rawValue === "number") {
const normalized = String(rawValue)
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (normalized.length > 0) {
return normalized;
}
}
}
return [];
};
const resolveScadaFeatureInfos = (params: Record<string, unknown>): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
const normalizedFeatureInfos = rawFeatureInfos
.map((item) => (Array.isArray(item) ? item : null))
.filter((item): item is [unknown, unknown] => Boolean(item))
.map(
(item) =>
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
string,
string,
],
)
.filter(([id]) => id.trim().length > 0);
if (normalizedFeatureInfos.length > 0) {
return normalizedFeatureInfos;
}
}
const rawDeviceIds =
params.device_ids ??
params.deviceId ??
params.device_id ??
params.id ??
params.ids;
const deviceIds = Array.isArray(rawDeviceIds)
? rawDeviceIds.map((id) => String(id))
: typeof rawDeviceIds === "string"
? rawDeviceIds
.split(",")
.map((id) => id.trim())
.filter(Boolean)
: [];
return deviceIds.map((id) => [id, "scada"]);
};
const resolveTimeRange = (params: Record<string, unknown>) => ({
startTime:
(params.start_time as string | undefined) ??
(params.startTime as string | undefined) ??
(params.from as string | undefined) ??
(params.start as string | undefined),
endTime:
(params.end_time as string | undefined) ??
(params.endTime as string | undefined) ??
(params.to as string | undefined) ??
(params.end as string | undefined),
});
const compactNames = (names: string[]) => {
if (!names.length) return "";
return names.length > 3
? `${names.slice(0, 3).join(", ")}${names.length}`
: names.join(", ");
};
const buildLocateArtifact = (
tool: string,
params: Record<string, unknown>,
): { artifact: Omit<AgentArtifact, "id" | "params" | "tool">; action: ChatToolAction | null } => {
const ids = normalizeIds(params);
const rawType = params.feature_type;
const featureType =
typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
const config = tool === "locate_features"
? FEATURE_TYPE_MAP[featureType]
: LOCATE_TOOL_CONFIG[tool];
return {
artifact: {
kind: "map",
title: config ? `地图定位${config.label}` : "地图定位",
description: compactNames(ids),
},
action: config
? {
type: "locate_features",
ids,
layer: config.layer,
geometryKind: config.geometryKind,
}
: null,
};
};
const buildToolAction = (
tool: string,
params: Record<string, unknown>,
): { action: ChatToolAction | null; kind: AgentArtifactKind; title: string; description?: string } => {
if (tool === "show_chart") {
return {
action: null,
kind: "chart",
title: (params.title as string | undefined) ?? "生成图表",
description: "已生成可视化图表",
};
}
if (tool === "locate_features" || LOCATE_TOOL_CONFIG[tool]) {
const locate = buildLocateArtifact(tool, params);
return {
action: locate.action,
kind: locate.artifact.kind,
title: locate.artifact.title,
description: locate.artifact.description,
};
}
if (tool === "view_history") {
const featureInfos = (params.feature_infos as [string, string][] | undefined) ?? [];
const { startTime, endTime } = resolveTimeRange(params);
return {
action: {
type: "view_history",
featureInfos,
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime,
endTime,
},
kind: "panel",
title: "打开计算结果曲线",
description: compactNames(featureInfos.map(([id]) => id)),
};
}
if (tool === "view_scada") {
const featureInfos = resolveScadaFeatureInfos(params);
const { startTime, endTime } = resolveTimeRange(params);
return {
action: {
type: "view_scada",
featureInfos,
startTime,
endTime,
},
kind: "panel",
title: "打开 SCADA 数据面板",
description: compactNames(featureInfos.map(([id]) => id)),
};
}
return {
action: null,
kind: "tool",
title: tool || "工具调用",
description: "Agent 已执行工具动作",
};
};
export const useAgentToolActions = () => {
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
return useCallback(
(event: ToolCallEvent, options: HandleToolCallOptions) => {
const { action, kind, title, description } = buildToolAction(
event.tool,
event.params,
);
options.appendArtifact(options.assistantMessageId, {
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
tool: event.tool,
kind,
title,
description,
params: event.params,
});
if (action) {
dispatchToolAction(action);
}
},
[dispatchToolAction],
);
};
+27 -2
View File
@@ -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<SCADADataPanelProps> = ({
onCleanData,
start_time,
end_time,
onClose,
}) => {
const { open } = useNotification();
const { data: user } = useGetIdentity<IUser>();
@@ -986,7 +998,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<Box
className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={() => setIsExpanded(true)}
sx={{ zIndex: 1300 }}
sx={{ zIndex: 1290 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<ShowChart className="text-[#257DD4] w-5 h-5" />
@@ -1063,11 +1075,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/>
</Stack>
<Stack direction="row" spacing={1}>
{onClose && (
<Tooltip title="关闭">
<IconButton
size="small"
onClick={onClose}
aria-label="关闭 SCADA 历史数据面板"
sx={panelHeaderActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="收起">
<IconButton
size="small"
onClick={() => setIsExpanded(false)}
sx={{ color: "primary.contrastText" }}
aria-label="收起 SCADA 历史数据面板"
sx={panelHeaderActionSx}
>
<ChevronRight fontSize="small" />
</IconButton>
@@ -1,60 +1,51 @@
import React, { useState, useEffect } from "react";
"use client";
import React, { useState, useEffect, useMemo, useRef } from "react";
import Image from "next/image";
import { useMap } from "../MapComponent";
import { useData, useMap } from "../MapComponent";
import TileLayer from "ol/layer/Tile.js";
import XYZ from "ol/source/XYZ.js";
import Group from "ol/layer/Group";
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
import mapboxLight from "@assets/map/layers/mapbox-light.png";
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
import clsx from "clsx";
import Group from "ol/layer/Group";
import { MAPBOX_TOKEN } from "@config/config";
import { TIANDITU_TOKEN } from "@config/config";
import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config";
import type { Map as OlMap } from "ol";
const INITIAL_LAYER = "mapbox-light";
const streetsLayer = new TileLayer({
const createTileLayer = (url: string, attributions: string) =>
new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
url,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const lightMapLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const satelliteLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
const satelliteStreetsLayer = new TileLayer({
source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
tileSize: 512,
maxZoom: 20,
projection: "EPSG:3857",
attributions:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
attributions,
}),
});
const createBaseLayerEntries = () => {
const streetsLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const lightMapLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteStreetsLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const tiandituVectorLayer = new TileLayer({
source: new XYZ({
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
@@ -83,25 +74,18 @@ const tiandituImageAnnotationLayer = new TileLayer({
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
const tiandituVectorLayerGroup = new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
});
const tiandituImageLayerGroup = new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
});
const baseLayers = [
return [
{
id: "mapbox-light",
name: "默认地图",
layer: lightMapLayer,
// layer: tiandituVectorLayerGroup,
img: mapboxLight.src,
},
{
id: "mapbox-satellite",
name: "卫星地图",
layer: satelliteLayer,
// layer: tiandituImageLayerGroup,
img: mapboxSatellite.src,
},
{
@@ -116,43 +100,75 @@ const baseLayers = [
layer: streetsLayer,
img: mapboxStreets.src,
},
{
id: "tianditu-vector",
name: "天地图矢量",
layer: new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
}),
img: mapboxOutdoors.src,
},
{
id: "tianditu-image",
name: "天地图影像",
layer: new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
}),
img: mapboxSatellite.src,
},
];
};
const BaseLayers: React.FC = () => {
const map = useMap();
// 切换底图选项展开,控制显示和卸载
const data = useData();
const maps = useMemo(() => {
if (data?.maps?.length) return data.maps;
return map ? [map] : [];
}, [data?.maps, map]);
const layerSetsRef = useRef(new WeakMap<OlMap, ReturnType<typeof createBaseLayerEntries>>());
const [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false);
// 快速切换底图
const [activeId, setActiveId] = useState(INITIAL_LAYER);
// 初始化默认底图
useEffect(() => {
if (!map) return;
// 添加所有底图至地图并根据 activeId 控制可见性
baseLayers.forEach((layerInfo) => {
const layers = map.getLayers().getArray();
maps.forEach((targetMap) => {
let layerEntries = layerSetsRef.current.get(targetMap);
if (!layerEntries) {
layerEntries = createBaseLayerEntries();
layerSetsRef.current.set(targetMap, layerEntries);
}
layerEntries.forEach((layerInfo) => {
const layers = targetMap.getLayers().getArray();
if (!layers.includes(layerInfo.layer)) {
map.getLayers().insertAt(0, layerInfo.layer);
targetMap.getLayers().insertAt(0, layerInfo.layer);
}
layerInfo.layer.setVisible(layerInfo.id === activeId);
});
}, [map, activeId]);
});
}, [activeId, maps]);
const changeMapLayers = (id: string) => {
if (map) {
// 根据 id 设置每个图层的可见性
baseLayers.forEach(({ id: lid, layer }) => {
layer.setVisible(lid === id);
maps.forEach((targetMap) => {
const layerEntries = layerSetsRef.current.get(targetMap);
layerEntries?.forEach(({ id: layerId, layer }) => {
layer.setVisible(layerId === id);
});
});
}
};
const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({
id,
name,
img,
})), []);
const handleQuickSwitch = () => {
const nextId =
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
setActiveId(nextId);
handleMapLayers(nextId);
changeMapLayers(nextId);
};
const handleMapLayers = (id: string) => {
@@ -160,7 +176,6 @@ const BaseLayers: React.FC = () => {
changeMapLayers(id);
};
// 记录定时器,避免多次触发
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
const handleEnter = () => {
@@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => {
{isExpanded && (
<div
className={clsx(
"absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
"absolute flex right-24 bottom-0 w-132 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
isShow ? "opacity-100" : "opacity-0"
)}
onMouseEnter={handleEnter}
@@ -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 数据
*/
@@ -129,14 +140,17 @@ const fetchFromBackend = async (
"raw"
);
} else if (type === "scheme") {
// 查询策略模拟值、清洗值和监测值
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([
// 查询策略模拟值、实时模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
apiFetch(cleanedDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
apiFetch(schemeSimulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
@@ -146,40 +160,18 @@ const fetchFromBackend = async (
// 如果清洗数据有值,则不显示原始监测值
const rawData =
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
const simulationData = transformBackendData(simulationRes, featureIds);
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
// 合并三组数据
const timeMap = new Map<string, Record<string, number | null>>();
[cleanedData, rawData, schemeSimData].forEach((data, index) => {
const suffix = ["clean", "raw", "scheme_sim"][index];
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
featureIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(
([timestamp, values]) => ({
timestamp,
values,
})
return mergeMultipleTimeSeriesData(
[
{ data: cleanedData, suffix: "clean" },
{ data: rawData, suffix: "raw" },
{ data: simulationData, suffix: "sim" },
{ data: schemeSimData, suffix: "scheme_sim" },
],
featureIds
);
result.sort(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
} else {
// realtime: 查询模拟值、清洗值和监测值
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
@@ -336,6 +328,42 @@ const mergeTimeSeriesData = (
return result;
};
const mergeMultipleTimeSeriesData = (
datasets: Array<{
data: TimeSeriesPoint[];
suffix: string;
}>,
deviceIds: string[]
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
datasets.forEach(({ data, suffix }) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
});
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const formatTimestamp = (timestamp: string) =>
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
@@ -402,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
fractionDigits = 2,
start_time,
end_time,
onClose,
}) => {
// 从 featureInfos 中提取设备 ID 列表
const deviceIds = useMemo(
@@ -537,7 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const suffixes = [
{ key: "clean", name: "清洗值" },
{ key: "raw", name: "监测值" },
{ key: "sim", name: "模拟值" },
{ key: "sim", name: "实时模拟值" },
{ key: "scheme_sim", name: "方案模拟值" },
];
@@ -643,20 +672,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
: suffix === "raw"
? "监测值"
: suffix === "sim"
? "模拟"
? "实时模拟"
: "方案模拟";
series.push({
name: `${id} (${displayName})`,
type: "line",
symbol: "none",
symbol:
suffix === "clean"
? "circle"
: suffix === "raw"
? "diamond"
: "none",
symbolSize: suffix === "clean" || suffix === "raw" ? 7 : 0,
showSymbol: suffix === "clean" || suffix === "raw",
sampling: "lttb",
connectNulls: true,
connectNulls: suffix !== "clean" && suffix !== "raw",
itemStyle: {
color: colors[(index * 4 + sIndex) % colors.length],
},
data: dataset.map((item) => item[key]),
areaStyle: {
lineStyle:
suffix === "clean" || suffix === "raw"
? { width: 0 }
: undefined,
areaStyle:
suffix === "clean" || suffix === "raw"
? undefined
: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
@@ -819,7 +862,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
return (
<>
{/* 主面板 */}
<Draggable nodeRef={draggableRef} handle=".drag-handle">
<Draggable
nodeRef={draggableRef}
handle=".drag-handle"
cancel=".panel-close-button"
>
<Box
ref={draggableRef}
sx={{
@@ -840,7 +887,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
border: "none",
display: "flex",
flexDirection: "column",
zIndex: 1300,
zIndex: 1290,
backgroundColor: "white",
overflow: "hidden",
"&:hover": {
@@ -884,6 +931,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
}}
/>
</Stack>
<Tooltip title="关闭">
<IconButton
className="panel-close-button"
size="small"
onClick={onClose}
aria-label="关闭历史数据面板"
sx={panelHeaderActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
@@ -5,6 +5,7 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorLayer from "ol/layer/Vector";
import VectorTileLayer from "ol/layer/VectorTile";
import { DeckLayer } from "@utils/layers";
import type { Map as OlMap } from "ol";
// 定义统一的图层项接口
interface LayerItem {
@@ -30,8 +31,10 @@ const LAYER_ORDER = [
const LayerControl: React.FC = () => {
const map = useMap();
const data = useData();
const maps: OlMap[] = data?.maps?.length ? data.maps : map ? [map] : [];
const [refreshKey, setRefreshKey] = useState(0);
const deckLayer = data?.deckLayer;
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
const isContourLayerAvailable = data?.isContourLayerAvailable;
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
@@ -117,8 +120,16 @@ const LayerControl: React.FC = () => {
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
if (item.type === "ol") {
item.layerRef.setVisible(checked);
} else if (item.type === "deck" && deckLayer) {
maps.forEach((targetMap) => {
targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === item.id)
.forEach((layer) => layer.setVisible(checked));
});
} else if (item.type === "deck" && deckLayers.length > 0) {
deckLayers.forEach((targetDeckLayer) => {
targetDeckLayer.setDeckLayerVisible(item.id, checked);
});
if (item.id === "junctionContourLayer") {
setShowContourLayer && setShowContourLayer(checked);
}
@@ -1,4 +1,9 @@
import React from "react";
"use client";
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;
@@ -21,13 +26,24 @@ interface PropertyPanelProps {
id?: string;
type?: string;
properties?: PropertyItem[];
onClose: () => void;
}
const PropertyPanel: React.FC<PropertyPanelProps> = ({
id,
type = "未知类型",
properties = [],
onClose,
}) => {
const draggableRef = useRef<HTMLDivElement>(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) {
return property.formatter(property.value);
@@ -50,9 +66,17 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
: 0;
return (
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 ">
<Draggable
nodeRef={draggableRef}
handle=".drag-handle"
cancel=".panel-close-button"
>
<div
ref={draggableRef}
className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100"
>
{/* 头部 */}
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
<div className="drag-handle flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white cursor-move select-none">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
@@ -69,6 +93,17 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
</svg>
<h3 className="text-lg font-semibold"></h3>
</div>
<Tooltip title="关闭">
<IconButton
className="panel-close-button"
size="small"
onClick={onClose}
aria-label="关闭属性面板"
sx={headerActionSx}
>
<Close fontSize="small" />
</IconButton>
</Tooltip>
</div>
{/* 内容区域 */}
@@ -198,6 +233,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
})}
</div>
)}
</div>
{/* 底部统计区域 */}
@@ -228,6 +264,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
</div>
</div>
</div>
</Draggable>
);
};
@@ -29,6 +29,7 @@ import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor";
import { VectorTile } from "ol";
import type { Map as OlMap } from "ol";
import { useNotification } from "@refinedev/core";
import { config } from "@/config/config";
@@ -182,6 +183,13 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
const data = useData();
const currentJunctionCalData = data?.currentJunctionCalData;
const currentPipeCalData = data?.currentPipeCalData;
const compareJunctionCalData = data?.compareJunctionCalData;
const comparePipeCalData = data?.comparePipeCalData;
const compareMap = data?.compareMap;
const activeMaps = useMemo<OlMap[]>(
() => (data?.maps?.length ? data.maps : map ? [map] : []),
[data?.maps, map]
);
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
@@ -229,6 +237,45 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
customColors: [],
});
const getRenderLayersById = useCallback(
(layerId: string) =>
activeMaps.flatMap((targetMap) =>
targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.filter((layer): layer is WebGLVectorTileLayer => layer instanceof WebGLVectorTileLayer)
),
[activeMaps]
);
const getMapKey = useCallback((targetMap: OlMap, layerId: string) => {
const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map";
return `${mapUid}:${layerId}`;
}, []);
const getDataForMap = useCallback(
(targetMap: OlMap, layerId: string) => {
if (layerId === "junctions") {
return targetMap === compareMap
? compareJunctionCalData || []
: currentJunctionCalData || [];
}
if (layerId === "pipes") {
return targetMap === compareMap
? comparePipeCalData || []
: currentPipeCalData || [];
}
return [];
},
[
compareJunctionCalData,
compareMap,
comparePipeCalData,
currentJunctionCalData,
currentPipeCalData,
]
);
const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
@@ -613,13 +660,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
return;
}
const styleConfig = layerStyleConfig.styleConfig;
const renderLayer = renderLayers.filter((layer) => {
return layer.get("value") === layerStyleConfig.layerId;
})[0];
const targetLayers = getRenderLayersById(layerStyleConfig.layerId);
const renderLayer = targetLayers[0];
if (!renderLayer || !styleConfig?.property) return;
const layerType: string = renderLayer?.get("type");
const source = renderLayer.getSource();
if (!source) return;
const layerType: string = renderLayer.get("type");
const breaksLength = breaks.length;
// 根据 breaks 计算每个分段的颜色,线条粗细
@@ -757,7 +801,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
dynamicStyle["circle-stroke-width"] = 2;
}
// 应用样式到图层
renderLayer.setStyle(dynamicStyle);
targetLayers.forEach((targetLayer) => {
targetLayer.setStyle(dynamicStyle);
});
// 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性
const layerId = renderLayer.get("value");
const initLayerStyleState = layerStyleStates.find(
@@ -844,10 +890,12 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
if (!selectedRenderLayer) return;
// 重置 WebGL 图层样式
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
selectedRenderLayer.setStyle(defaultFlatStyle);
const layerId = selectedRenderLayer.get("value");
getRenderLayersById(layerId).forEach((targetLayer) => {
targetLayer.setStyle(defaultFlatStyle);
});
// 删除对应图层的样式状态,从而移除图例显示
const layerId = selectedRenderLayer.get("value");
if (layerId !== undefined) {
setLayerStyleStates((prev) =>
prev.filter((state) => state.layerId !== layerId)
@@ -870,11 +918,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}
};
// 更新当前 VectorTileSource 中的所有缓冲要素属性
const updateVectorTileSource = (property: string, data: any[]) => {
if (!map) return;
const vectorTileSources = map
const updateVectorTileSource = (
targetMap: OlMap,
layerId: string,
property: string,
data: any[]
) => {
const vectorTileSources = targetMap
.getAllLayers()
.filter((layer) => layer instanceof WebGLVectorTileLayer)
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source);
@@ -911,16 +963,16 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
};
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
const tileLoadListenersRef = useRef<
Map<VectorTileSource, (event: any) => void>
Map<string, { source: VectorTileSource; listener: (event: any) => void }>
>(new Map());
const attachVectorTileSourceLoadedEvent = (
targetMap: OlMap,
layerId: string,
property: string,
data: any[]
) => {
if (!map) return;
const vectorTileSource = map
const vectorTileSource = targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
@@ -956,24 +1008,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}
};
const listenerKey = getMapKey(targetMap, layerId);
vectorTileSource.on("tileloadend", listener);
tileLoadListenersRef.current.set(vectorTileSource, listener);
tileLoadListenersRef.current.set(listenerKey, {
source: vectorTileSource,
listener,
});
};
// 新增函数:取消对应 layerId 已添加的 on 事件
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
if (!map) return;
const vectorTileSource = map
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source)[0];
if (!vectorTileSource) return;
const listener = tileLoadListenersRef.current.get(vectorTileSource);
if (listener) {
vectorTileSource.un("tileloadend", listener);
tileLoadListenersRef.current.delete(vectorTileSource);
const removeVectorTileSourceLoadedEvent = useCallback(
(targetMap: OlMap, layerId: string) => {
const listenerKey = getMapKey(targetMap, layerId);
const listenerState = tileLoadListenersRef.current.get(listenerKey);
if (listenerState) {
listenerState.source.un("tileloadend", listenerState.listener);
tileLoadListenersRef.current.delete(listenerKey);
}
};
},
[getMapKey]
);
// 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发
useEffect(() => {
@@ -998,20 +1051,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
);
if (isElevation) {
removeVectorTileSourceLoadedEvent("junctions");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
return;
}
if (!currentJunctionCalData) return;
// 更新现有的 VectorTileSource
updateVectorTileSource(junctionText, currentJunctionCalData);
// 移除旧的监听器,并添加新的监听器
removeVectorTileSourceLoadedEvent("junctions");
activeMaps.forEach((targetMap) => {
const targetData = getDataForMap(targetMap, "junctions");
if (!targetData || targetData.length === 0) return;
updateVectorTileSource(targetMap, "junctions", junctionText, targetData);
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
attachVectorTileSourceLoadedEvent(
targetMap,
"junctions",
junctionText,
currentJunctionCalData
targetData
);
});
};
const updatePipeStyle = () => {
const pipeStyleConfigState = layerStyleStates.find(
@@ -1023,16 +1080,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig);
if (isDiameter) {
removeVectorTileSourceLoadedEvent("pipes");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
return;
}
if (!currentPipeCalData) return;
// 更新现有的 VectorTileSource
updateVectorTileSource(pipeText, currentPipeCalData);
// 移除旧的监听器,并添加新的监听器
removeVectorTileSourceLoadedEvent("pipes");
attachVectorTileSourceLoadedEvent("pipes", pipeText, currentPipeCalData);
activeMaps.forEach((targetMap) => {
const targetData = getDataForMap(targetMap, "pipes");
if (!targetData || targetData.length === 0) return;
updateVectorTileSource(targetMap, "pipes", pipeText, targetData);
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
attachVectorTileSourceLoadedEvent(
targetMap,
"pipes",
pipeText,
targetData
);
});
};
if (isUserTrigger) {
if (selectedRenderLayer?.get("value") === "junctions") {
@@ -1060,10 +1125,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
updatePipeStyle();
}
if (!applyJunctionStyle) {
removeVectorTileSourceLoadedEvent("junctions");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
}
if (!applyPipeStyle) {
removeVectorTileSourceLoadedEvent("pipes");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
}
// This effect is intentionally driven by explicit style triggers and data snapshots.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1073,8 +1142,20 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
applyPipeStyle,
currentJunctionCalData,
currentPipeCalData,
compareJunctionCalData,
comparePipeCalData,
activeMaps,
]);
useEffect(() => {
return () => {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
};
}, [activeMaps, removeVectorTileSourceLoadedEvent]);
// 获取地图中的矢量图层,用于选择图层选项
useEffect(() => {
if (!map) return;
+173 -61
View File
@@ -63,6 +63,9 @@ const Timeline: React.FC<TimelineProps> = ({
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
const setCompareJunctionCalData = data?.setCompareJunctionCalData;
const setComparePipeCalData = data?.setComparePipeCalData;
const isCompareMode = data?.isCompareMode ?? false;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const { open } = useNotification();
@@ -94,100 +97,209 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
if (setCurrentJunctionCalData) {
setCurrentJunctionCalData(nodeResults);
} else {
console.log("setCurrentJunctionCalData is undefined");
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
const fetchFrameData = useCallback(async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string,
const updateDataStates = useCallback(
(
nodeResults: any[],
linkResults: any[],
target: "primary" | "compare" = "primary"
) => {
const setNodeData =
target === "compare"
? setCompareJunctionCalData
: setCurrentJunctionCalData;
const setLinkData =
target === "compare" ? setComparePipeCalData : setCurrentPipeCalData;
setNodeData?.(nodeResults);
setLinkData?.(linkResults);
},
[
setCompareJunctionCalData,
setComparePipeCalData,
setCurrentJunctionCalData,
setCurrentPipeCalData,
]
);
const buildCacheKey = useCallback(
(
queryTime: string,
property: string,
sourceType: "scheme" | "realtime",
resultType: "node" | "link",
targetSchemeName: string,
targetSchemeType: string
) =>
[
queryTime,
sourceType,
resultType,
property,
targetSchemeName || "default",
targetSchemeType || "default",
].join("::"),
[]
);
const fetchDataBySource = useCallback(
async ({
queryTime,
junctionProperties,
pipeProperties,
sourceType,
target,
schemeName,
schemeType,
}: {
queryTime: Date;
junctionProperties: string;
pipeProperties: string;
sourceType: "scheme" | "realtime";
target: "primary" | "compare";
schemeName?: string;
schemeType?: string;
}) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = [];
let nodePromise: Promise<any> | null = null;
let linkPromise: Promise<any> | null = null;
// 检查node缓存
let nodePromise: Promise<Response> | null = null;
let linkPromise: Promise<Response> | null = null;
if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`;
const nodeCacheKey = buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
);
if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
disableDateSelection && schemeName
? (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
))
: (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
));
nodePromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`
);
requests.push(nodePromise);
}
}
// 处理特殊属性名称
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
// 检查link缓存
if (pipeProperties !== "" && pipeProperties !== "diameter") {
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
const normalizedPipeProperties =
pipeProperties === "unit_headloss" ? "headloss" : pipeProperties;
if (normalizedPipeProperties !== "" && normalizedPipeProperties !== "diameter") {
const linkCacheKey = buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
);
if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
disableDateSelection && schemeName
? (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`,
))
: (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
));
linkPromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
);
requests.push(linkPromise);
}
}
// 等待所有有效请求
const responses = await Promise.all(requests);
if (nodePromise) {
const nodeResponse = responses.shift()!;
if (!nodeResponse.ok)
if (!nodeResponse.ok) {
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
}
nodeRecords = await nodeResponse.json();
// 缓存数据(修复键以包含 schemeName
nodeCacheRef.current.set(
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`,
nodeRecords || [],
buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
),
nodeRecords || []
);
}
if (linkPromise) {
const linkResponse = responses.shift()!;
if (!linkResponse.ok)
if (!linkResponse.ok) {
throw new Error(`Link fetch failed: ${linkResponse.status}`);
}
linkRecords = await linkResponse.json();
// 缓存数据(修复键以包含 schemeName
linkCacheRef.current.set(
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`,
linkRecords || [],
buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
),
linkRecords || []
);
}
// 更新状态
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
}, [disableDateSelection, updateDataStates]);
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
},
[buildCacheKey, updateDataStates]
);
const fetchFrameData = useCallback(
async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string
) => {
const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime";
const tasks = [
fetchDataBySource({
queryTime,
junctionProperties,
pipeProperties,
sourceType: primarySourceType,
target: "primary",
schemeName,
schemeType,
}),
];
if (isCompareMode && disableDateSelection && schemeName) {
tasks.push(
fetchDataBySource({
queryTime,
junctionProperties,
pipeProperties,
sourceType: "realtime",
target: "compare",
})
);
}
await Promise.all(tasks);
},
[disableDateSelection, fetchDataBySource, isCompareMode]
);
// 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
@@ -453,9 +565,9 @@ const Timeline: React.FC<TimelineProps> = ({
if (!cacheRef.current) return;
const cacheKeys = Array.from(cacheRef.current.keys());
cacheKeys.forEach((key) => {
const keyParts = key.split("_");
const cacheDate = keyParts[0].split("T")[0];
const cacheTimeStr = keyParts[0].split("T")[1];
const cacheTimeKey = key.split("::")[0];
const cacheDate = cacheTimeKey.split("T")[0];
const cacheTimeStr = cacheTimeKey.split("T")[1];
if (cacheDate === dateStr && cacheTimeStr) {
const [hours, minutes] = cacheTimeStr.split(":");
+50 -20
View File
@@ -5,6 +5,7 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
@@ -34,12 +35,14 @@ interface ToolbarProps {
queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
enableCompare?: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons,
queryType,
schemeType,
HistoryPanel,
enableCompare = false,
}) => {
const map = useMap();
const data = useData();
@@ -55,6 +58,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName;
const isCompareMode = data?.isCompareMode ?? false;
const toggleCompareMode = data?.toggleCompareMode;
const canToggleCompare = Boolean(
enableCompare && (isCompareMode || (queryType === "scheme" && schemeName)),
);
useEffect(() => {
if (!enableCompare && isCompareMode) {
toggleCompareMode?.();
}
}, [enableCompare, isCompareMode, toggleCompareMode]);
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
@@ -402,15 +416,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
deactivateTool(tool);
setActiveTools((prev) => prev.filter((t) => t !== tool));
} else {
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具
// 关闭所有面板(但保持样式编辑器状态)
closeAllPanelsExceptStyle();
// 取消激活所有非样式工具
setActiveTools((prev) => {
const styleActive = prev.includes("style");
return styleActive ? ["style", tool] : [tool];
});
// 如果当前工具未激活,保留其他已打开工具,仅新增当前工具
setActiveTools((prev) => [...prev, tool]);
// 激活当前工具并打开对应面板
activateTool(tool);
@@ -422,14 +429,18 @@ const Toolbar: React.FC<ToolbarProps> = ({
switch (tool) {
case "info":
setShowPropertyPanel(false);
if (!activeTools.includes("history")) {
setHighlightFeatures([]);
}
break;
case "draw":
setShowDrawPanel(false);
break;
case "history":
setShowHistoryPanel(false);
if (!activeTools.includes("info")) {
setHighlightFeatures([]);
}
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
break;
@@ -452,16 +463,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
}
};
// 关闭所有面板(除了样式编辑器)
const closeAllPanelsExceptStyle = () => {
setShowPropertyPanel(false);
setHighlightFeatures([]);
setShowDrawPanel(false);
setShowHistoryPanel(false);
setChatPanelFeatureInfos(null);
setChatPanelTimeRange(null);
// 样式编辑器保持其当前状态,不自动关闭
};
const [computedProperties, setComputedProperties] = useState<
Record<string, any>
>({});
@@ -866,8 +867,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClick={() => handleToolClick("style")}
/>
)}
{enableCompare && (
<ToolbarButton
icon={<CompareArrowsOutlinedIcon />}
name={isCompareMode ? "关闭对比" : "双屏对比"}
isActive={isCompareMode}
onClick={() => toggleCompareMode?.()}
disabled={!canToggleCompare}
/>
)}
</div>
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
{showPropertyPanel && (
<PropertyPanel
{...getFeatureProperties()}
onClose={() => {
deactivateTool("info");
setActiveTools((prev) => prev.filter((t) => t !== "info"));
}}
/>
)}
{showDrawPanel && map && <DrawPanel />}
<div style={{ display: showStyleEditor ? "block" : "none" }}>
<StyleEditorPanel
@@ -882,6 +900,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
visible={showHistoryPanel}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
) : HistoryPanel ? (
<HistoryPanel
@@ -926,6 +948,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
) : (
<HistoryDataPanel
@@ -970,6 +996,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
))}
+514 -80
View File
@@ -7,6 +7,7 @@ import React, {
useState,
useEffect,
useMemo,
useCallback,
useRef,
} from "react";
import { Map as OlMap, VectorTile } from "ol";
@@ -49,6 +50,13 @@ interface DataContextType {
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
currentPipeCalData?: any[]; // 当前计算结果
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
compareJunctionCalData?: any[];
setCompareJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
comparePipeCalData?: any[];
setComparePipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
isCompareMode?: boolean;
setCompareMode?: React.Dispatch<React.SetStateAction<boolean>>;
toggleCompareMode?: () => void;
showJunctionText?: boolean; // 是否显示节点文本
showPipeText?: boolean; // 是否显示管道文本
showJunctionId?: boolean; // 是否显示节点ID
@@ -69,6 +77,10 @@ interface DataContextType {
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
setContours?: React.Dispatch<React.SetStateAction<any[]>>;
deckLayer?: DeckLayer;
compareDeckLayer?: DeckLayer;
deckLayers?: DeckLayer[];
compareMap?: OlMap;
maps?: OlMap[];
diameterRange?: [number, number];
elevationRange?: [number, number];
}
@@ -128,12 +140,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const compareMapRef = useRef<HTMLDivElement | null>(null);
const compareCanvasRef = useRef<HTMLCanvasElement | null>(null);
const deckLayerRef = useRef<DeckLayer | null>(null);
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
const [compareMap, setCompareMap] = useState<OlMap>();
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
// currentCalData 用于存储当前计算结果
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
@@ -144,6 +162,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[],
);
const [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]);
const [compareJunctionCalData, setCompareJunctionCalData] = useState<any[]>(
[],
);
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
const [isCompareMode, setCompareMode] = useState(false);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
const [junctionData, setJunctionDataState] = useState<any[]>([]);
@@ -201,6 +224,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
});
}, [pipeData, currentPipeCalData, pipeText]);
const mergedCompareJunctionData = useMemo(() => {
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r]));
return junctionData.map((j) => {
const record = nodeMap.get(j.id);
let val = record ? record.value : undefined;
if (val !== undefined && junctionText === "actualdemand") {
val = toM3h(val, "lps");
}
return record ? { ...j, [junctionText]: val } : j;
});
}, [junctionData, compareJunctionCalData, junctionText]);
const mergedComparePipeData = useMemo(() => {
const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r]));
return pipeData.map((p) => {
const record = linkMap.get(p.id);
if (!record) return p;
const isFlow = pipeText === "flow";
let val = record.value;
if (val !== undefined && isFlow) {
val = toM3h(val, "lps");
}
return {
...p,
[pipeText]: isFlow ? Math.abs(val) : val,
flowFlag: isFlow && record.value < 0 ? -1 : 1,
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
};
});
}, [pipeData, comparePipeCalData, pipeText]);
const [diameterRange, setDiameterRange] = useState<
[number, number] | undefined
>();
@@ -208,6 +262,24 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[number, number] | undefined
>();
const toggleCompareMode = useCallback(() => {
setCompareMode((prev) => !prev);
}, []);
const maps = useMemo(
() =>
[map, isCompareMode ? compareMap : undefined].filter(Boolean) as OlMap[],
[compareMap, isCompareMode, map],
);
const deckLayers = useMemo(
() =>
[deckLayer, isCompareMode ? compareDeckLayer : undefined].filter(
Boolean,
) as DeckLayer[],
[compareDeckLayer, deckLayer, isCompareMode],
);
const setJunctionData = (newData: any[]) => {
const uniqueNewData = newData.filter((item) => {
if (!item || !item.id) return false;
@@ -518,6 +590,178 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
},
});
const createOperationalLayers = () => {
const nextJunctionSource = new VectorTileSource({
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`,
format: new MVT(),
projection: "EPSG:3857",
});
const nextPipeSource = new VectorTileSource({
url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`,
format: new MVT(),
projection: "EPSG:3857",
});
const nextJunctionsLayer = new WebGLVectorTileLayer({
source: nextJunctionSource as any,
style: defaultFlatStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "节点",
value: "junctions",
type: "point",
properties: [
{ name: "高程", value: "elevation" },
{ name: "实际需水量", value: "actual_demand" },
{ name: "水头", value: "total_head" },
{ name: "压力", value: "pressure" },
{ name: "水质", value: "quality" },
],
},
});
const nextPipesLayer = new WebGLVectorTileLayer({
source: nextPipeSource as any,
style: defaultFlatStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "管道",
value: "pipes",
type: "linestring",
properties: [
{ name: "管径", value: "diameter" },
{ name: "流量", value: "flow" },
{ name: "摩阻系数", value: "friction" },
{ name: "水头损失", value: "headloss" },
{ name: "单位水头损失", value: "unit_headloss" },
{ name: "水质", value: "quality" },
{ name: "反应速率", value: "reaction" },
{ name: "设置值", value: "setting" },
{ name: "状态", value: "status" },
{ name: "流速", value: "velocity" },
],
},
});
const nextValvesLayer = new WebGLVectorTileLayer({
source: valveSource as any,
style: valveStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 16,
properties: {
name: "阀门",
value: "valves",
type: "linestring",
properties: [],
},
});
const nextReservoirsLayer = new VectorLayer({
source: reservoirSource,
style: reservoirStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "水库",
value: "reservoirs",
type: "point",
properties: [],
},
});
const nextPumpsLayer = new VectorLayer({
source: pumpSource,
style: pumpStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "水泵",
value: "pumps",
type: "linestring",
properties: [],
},
});
const nextTanksLayer = new VectorLayer({
source: tankSource,
style: tankStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "水箱",
value: "tanks",
type: "point",
properties: [],
},
});
const nextScadaLayer = new VectorLayer({
source: scadaSource,
style: scadaStyle,
extent: MAP_EXTENT,
maxZoom: 24,
minZoom: 11,
properties: {
name: "SCADA",
value: "scada",
type: "point",
properties: [],
},
});
const availableLayers: any[] = [];
config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => {
switch (layerValue) {
case "junctions":
availableLayers.push(nextJunctionsLayer);
break;
case "pipes":
availableLayers.push(nextPipesLayer);
break;
case "valves":
availableLayers.push(nextValvesLayer);
break;
case "reservoirs":
availableLayers.push(nextReservoirsLayer);
break;
case "pumps":
availableLayers.push(nextPumpsLayer);
break;
case "tanks":
availableLayers.push(nextTanksLayer);
break;
case "scada":
availableLayers.push(nextScadaLayer);
break;
}
});
availableLayers.sort((a, b) => {
const order = [
"valves",
"junctions",
"scada",
"reservoirs",
"pumps",
"tanks",
"pipes",
].reverse();
const getValue = (layer: any) => {
const props = layer.get ? layer.get("properties") : undefined;
return (props && props.value) || layer.get?.("value") || "";
};
const aVal = getValue(a);
const bVal = getValue(b);
let ia = order.indexOf(aVal);
let ib = order.indexOf(bVal);
if (ia === -1) ia = order.length;
if (ib === -1) ib = order.length;
return ia - ib;
});
return availableLayers;
};
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
useEffect(() => {
if (!mapRef.current) return;
@@ -857,19 +1101,142 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [MAP_WORKSPACE, MAP_EXTENT]);
useEffect(() => {
if (!isCompareMode) {
isCompareDisposingRef.current = true;
setCompareJunctionCalData([]);
setComparePipeCalData([]);
return;
}
if (!map || !compareMapRef.current || !compareCanvasRef.current) return;
isCompareDisposingRef.current = false;
const availableLayers = createOperationalLayers();
const nextCompareMap = new OlMap({
target: compareMapRef.current,
view: map.getView(),
layers: availableLayers.slice(),
controls: [],
});
nextCompareMap.getAllLayers().forEach((layer) => {
const layerId = layer.get("value");
if (!layerId) return;
const primaryLayer = map
.getAllLayers()
.find((currentLayer) => currentLayer.get("value") === layerId);
if (primaryLayer) {
layer.setVisible(primaryLayer.getVisible());
}
});
setCompareMap(nextCompareMap);
const compareDeck = new Deck({
initialViewState: {
longitude: 0,
latitude: 0,
zoom: 1,
},
canvas: compareCanvasRef.current,
controller: false,
layers: [],
});
const nextCompareDeckLayer = new DeckLayer(
compareDeck,
compareCanvasRef.current,
{
name: "compareDeckLayer",
value: "deckLayer",
},
);
compareDeckLayerRef.current = nextCompareDeckLayer;
setCompareDeckLayer(nextCompareDeckLayer);
nextCompareMap.addLayer(nextCompareDeckLayer);
const resizeTimerId = window.setTimeout(() => {
map.updateSize();
nextCompareMap.updateSize();
}, 0);
return () => {
isCompareDisposingRef.current = true;
window.clearTimeout(resizeTimerId);
if (
compareDeckLayerRef.current &&
!compareDeckLayerRef.current.isDisposedLayer()
) {
try {
nextCompareMap.removeLayer(compareDeckLayerRef.current);
} catch {
// Layer may have already been removed during teardown.
}
compareDeckLayerRef.current.disposeDeck();
}
compareDeckLayerRef.current = null;
setCompareDeckLayer(undefined);
setCompareMap(undefined);
nextCompareMap.setTarget(undefined);
nextCompareMap.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCompareMode, map]);
useEffect(() => {
const resizeTimerId = window.setTimeout(() => {
map?.updateSize();
compareMap?.updateSize();
}, 0);
return () => {
window.clearTimeout(resizeTimerId);
};
}, [compareMap, isCompareMode, map]);
// 当数据变化时,更新 deck.gl 图层
useEffect(() => {
if (isDisposingRef.current) return;
const deckLayer = deckLayerRef.current;
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
if (deckLayer.isDisposedLayer()) return;
if (!mergedJunctionData.length) return;
if (!mergedPipeData.length) return;
const junctionTextLayer = new TextLayer({
const syncDeckOverlay = (
targetDeckLayer: DeckLayer | null,
targetJunctionData: any[],
targetPipeData: any[],
disposing: boolean,
) => {
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return;
}
const shouldShowJunctionText =
(showJunctionTextLayer || showJunctionId) &&
currentZoom >= 15 &&
currentZoom <= 24 &&
targetJunctionData.length > 0;
const shouldShowPipeText =
(showPipeTextLayer || showPipeId) &&
currentZoom >= 15 &&
currentZoom <= 24 &&
targetPipeData.length > 0;
const shouldShowContour =
showContourLayer &&
currentZoom >= 11 &&
currentZoom <= 24 &&
targetJunctionData.length > 0;
if (!shouldShowJunctionText) {
targetDeckLayer.removeDeckLayer("junctionTextLayer");
}
if (!shouldShowPipeText) {
targetDeckLayer.removeDeckLayer("pipeTextLayer");
}
if (!shouldShowContour) {
targetDeckLayer.removeDeckLayer("junctionContourLayer");
}
if (!shouldShowJunctionText && !shouldShowPipeText && !shouldShowContour) {
return;
}
const junctionTextLayer = shouldShowJunctionText
? new TextLayer({
id: "junctionTextLayer",
name: "节点文字",
zIndex: 10,
data: mergedJunctionData,
data: targetJunctionData,
getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace",
getText: (d: any) => {
@@ -884,15 +1251,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
},
getSize: 14,
fontWeight: "bold",
getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见
getColor: [33, 37, 41],
getAngle: 0,
getTextAnchor: "middle",
getAlignmentBaseline: "center",
getPixelOffset: [0, -10],
visible:
(showJunctionTextLayer || showJunctionId) &&
currentZoom >= 15 &&
currentZoom <= 24,
visible: true,
updateTriggers: {
getText: [showJunctionId, showJunctionTextLayer, junctionText],
},
@@ -906,15 +1270,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
fontSize: 64,
buffer: 6,
},
// outlineWidth: 3,
// outlineColor: [255, 255, 255, 220],
});
})
: null;
const pipeTextLayer = new TextLayer({
const pipeTextLayer = shouldShowPipeText
? new TextLayer({
id: "pipeTextLayer",
name: "管道文字",
zIndex: 10,
data: mergedPipeData,
data: targetPipeData,
getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace",
getText: (d: any) => {
@@ -936,15 +1300,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
},
getSize: 14,
fontWeight: "bold",
getColor: [33, 37, 41], // 深灰色
getColor: [33, 37, 41],
getAngle: (d: any) => d.angle || 0,
getPixelOffset: [0, -8],
getTextAnchor: "middle",
getAlignmentBaseline: "bottom",
visible:
(showPipeTextLayer || showPipeId) &&
currentZoom >= 15 &&
currentZoom <= 24,
visible: true,
updateTriggers: {
getText: [showPipeId, showPipeTextLayer, pipeText],
},
@@ -958,14 +1319,14 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
fontSize: 64,
buffer: 6,
},
// outlineWidth: 3,
// outlineColor: [255, 255, 255, 220],
});
})
: null;
const contourLayer = new ContourLayer({
const contourLayer = shouldShowContour
? new ContourLayer({
id: "junctionContourLayer",
name: "等值线",
data: mergedJunctionData,
data: targetJunctionData,
aggregation: "MEAN",
cellSize: 600,
strokeWidth: 0,
@@ -974,31 +1335,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
getWeight: (d: any) =>
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
opacity: 1,
visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24,
visible: true,
updateTriggers: {
// 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算
getWeight: [mergedJunctionData, junctionText],
getWeight: [targetJunctionData, junctionText],
},
});
if (deckLayer.getDeckLayerById("junctionTextLayer")) {
// 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法
deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
} else {
deckLayer.addDeckLayer(junctionTextLayer);
})
: null;
if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) {
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
} else if (junctionTextLayer) {
targetDeckLayer.addDeckLayer(junctionTextLayer);
}
if (deckLayer.getDeckLayerById("pipeTextLayer")) {
deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
} else {
deckLayer.addDeckLayer(pipeTextLayer);
if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) {
targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
} else if (pipeTextLayer) {
targetDeckLayer.addDeckLayer(pipeTextLayer);
}
if (deckLayer.getDeckLayerById("junctionContourLayer")) {
deckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
} else {
deckLayer.addDeckLayer(contourLayer);
if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) {
targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
} else if (contourLayer) {
targetDeckLayer.addDeckLayer(contourLayer);
}
};
syncDeckOverlay(
deckLayerRef.current,
mergedJunctionData,
mergedPipeData,
isDisposingRef.current,
);
if (isCompareMode) {
syncDeckOverlay(
compareDeckLayerRef.current,
mergedCompareJunctionData,
mergedComparePipeData,
isCompareDisposingRef.current,
);
}
}, [
mergedJunctionData,
mergedPipeData,
mergedCompareJunctionData,
mergedComparePipeData,
isCompareMode,
junctionText,
pipeText,
currentZoom,
@@ -1012,57 +1392,69 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 控制流动动画开关
useEffect(() => {
if (isDisposingRef.current) return;
if (pipeText === "flow" && currentPipeCalData.length > 0) {
flowAnimation.current = true;
} else {
flowAnimation.current = false;
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
const shouldShowWaterflow =
isWaterflowLayerAvailable &&
showWaterflowLayer &&
flowAnimation.current &&
currentZoom >= 12 &&
currentZoom <= 24;
let animationFrameId: number;
const syncWaterflowLayer = (
targetDeckLayer: DeckLayer | null,
targetPipeData: any[],
disposing: boolean,
) => {
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return;
}
if (!shouldShowWaterflow || targetPipeData.length === 0) {
targetDeckLayer.removeDeckLayer("waterflowLayer");
return;
}
const deckLayer = deckLayerRef.current;
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
let animationFrameId: number; // 保存 requestAnimationFrame 的 ID
// 动画循环
const animate = () => {
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
// 动画总时长(秒)
const animationDuration = 10;
const bufferTime = 2;
const loopLength = animationDuration + bufferTime;
const currentTime = (Date.now() / 1000) % loopLength;
const currentFrameTime = (Date.now() / 1000) % loopLength;
const waterflowLayer = new TripsLayer({
id: "waterflowLayer",
name: "水流",
data: mergedPipeData,
data: targetPipeData,
getPath: (d) => d.path,
getTimestamps: (d) => {
return d.timestamps; // 这些应该是与 currentTime 匹配的数值
},
getTimestamps: (d) => d.timestamps,
getColor: [0, 220, 255],
opacity: 0.8,
visible:
isWaterflowLayerAvailable &&
showWaterflowLayer &&
flowAnimation.current && // 保持动画标志作为可见性的一部分
currentZoom >= 12 &&
currentZoom <= 24,
visible: true,
widthMinPixels: 5,
jointRounded: true, // 拐角变圆
// capRounded: true, // 端点变圆
trailLength: 2, // 水流尾迹淡出时间
currentTime: currentTime,
jointRounded: true,
trailLength: 2,
currentTime: currentFrameTime,
});
if (deckLayer.getDeckLayerById("waterflowLayer")) {
deckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
if (targetDeckLayer.getDeckLayerById("waterflowLayer")) {
targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
} else {
deckLayer.addDeckLayer(waterflowLayer);
targetDeckLayer.addDeckLayer(waterflowLayer);
}
};
// 只有在需要动画时才请求下一帧,但图层已经添加到了 deckLayer 中
if (flowAnimation.current) {
const animate = () => {
syncWaterflowLayer(
deckLayerRef.current,
mergedPipeData,
isDisposingRef.current,
);
if (isCompareMode) {
syncWaterflowLayer(
compareDeckLayerRef.current,
mergedComparePipeData,
isCompareDisposingRef.current,
);
}
if (shouldShowWaterflow) {
animationFrameId = requestAnimationFrame(animate);
}
};
@@ -1078,6 +1470,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
currentPipeCalData,
currentZoom,
mergedPipeData,
mergedComparePipeData,
isCompareMode,
pipeText,
isWaterflowLayerAvailable,
showWaterflowLayer,
@@ -1097,6 +1491,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setCurrentJunctionCalData,
currentPipeCalData,
setCurrentPipeCalData,
compareJunctionCalData,
setCompareJunctionCalData,
comparePipeCalData,
setComparePipeCalData,
isCompareMode,
setCompareMode,
toggleCompareMode,
setShowJunctionTextLayer,
setShowPipeTextLayer,
setShowJunctionId,
@@ -1115,17 +1516,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
pipeText,
setContours,
deckLayer,
compareDeckLayer,
deckLayers,
compareMap,
maps,
diameterRange,
elevationRange,
}}
>
<MapContext.Provider value={map}>
<div className="relative w-full h-full">
<div className="flex w-full h-full">
<div
className={`relative h-full ${isCompareMode ? "w-1/2" : "w-full"}`}
>
<div ref={mapRef} className="w-full h-full"></div>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0"
/>
{isCompareMode && (
<div className="pointer-events-none absolute right-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
</div>
)}
</div>
{isCompareMode && (
<div className="relative h-full w-1/2 border-l border-white/40">
<div ref={compareMapRef} className="w-full h-full"></div>
<canvas
ref={compareCanvasRef}
className="pointer-events-none absolute inset-0"
/>
<div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
</div>
</div>
)}
</div>
{isCompareMode && (
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px -translate-x-1/2 bg-white/85 shadow-[0_0_0_1px_rgba(15,23,42,0.18)]" />
)}
<MapTools />
{children}
</div>
<canvas ref={canvasRef} />
</MapContext.Provider>
</DataContext.Provider>
</>
+1 -1
View File
@@ -1,6 +1,6 @@
export const config = {
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000",
COPILOT_URL: process.env.NEXT_PUBLIC_COPILOT_URL || "http://127.0.0.1:8787",
AGENT_URL: process.env.NEXT_PUBLIC_AGENT_URL || "http://127.0.0.1:8788",
AUDIO_SERVICE_URL:
process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083",
MAP_URL: process.env.NEXT_PUBLIC_MAP_URL || "http://127.0.0.1:8080/geoserver",
+16 -1
View File
@@ -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);
}
},
+115 -14
View File
@@ -1,4 +1,4 @@
import { streamCopilotChat } from "./chatStream";
import { abortAgentChat, forkAgentChat, streamAgentChat } from "./chatStream";
import { ReadableStream } from "stream/web";
import { TextEncoder, TextDecoder } from "util";
@@ -32,7 +32,7 @@ const makeStream = (chunks: string[]) =>
},
});
describe("streamCopilotChat", () => {
describe("streamAgentChat", () => {
beforeEach(() => {
apiFetch.mockReset();
});
@@ -41,21 +41,21 @@ describe("streamCopilotChat", () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: token\ndata: {"conversationId":"c1","content":"he"}\n\n',
'event: token\ndata: {"conversationId":"c1","content":"llo"}\n\n',
'event: done\ndata: {"conversationId":"c1"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{ type: string; content?: string; conversationId?: string }> = [];
const events: Array<{ type: string; content?: string; sessionId?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/copilot/chat/stream"),
expect.stringContaining("/api/v1/agent/chat/stream"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
@@ -64,12 +64,68 @@ describe("streamCopilotChat", () => {
);
expect(events).toEqual([
{ type: "token", conversationId: "c1", content: "he" },
{ type: "token", conversationId: "c1", content: "llo" },
{ type: "done", conversationId: "c1" },
{ type: "token", sessionId: "s1", content: "he" },
{ type: "token", sessionId: "s1", content: "llo" },
{ type: "done", sessionId: "s1" },
]);
});
it("parses progress events", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{ type: string; title?: string; status?: string; detail?: string }> = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events[0]).toEqual({
type: "progress",
sessionId: "s1",
id: "p1",
phase: "tool",
status: "running",
title: "正在调用后端数据查询",
detail: "GET /api/v1/demo",
});
});
it("parses legacy tool_call arguments when params is empty", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: tool_call\ndata: {"conversationId":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
'event: done\ndata: {"session_id":"agent-1e75dd01-29e"}\n\n',
]),
});
const events: Array<{
type: string;
sessionId?: string;
tool?: string;
params?: Record<string, unknown>;
}> = [];
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
expect(events[0]).toEqual({
type: "tool_call",
sessionId: "agent-1e75dd01-29e",
tool: "locate_features",
params: { ids: ["142902"], feature_type: "junction" },
});
});
it("emits error when response is not ok", async () => {
apiFetch.mockResolvedValue({
ok: false,
@@ -78,7 +134,7 @@ describe("streamCopilotChat", () => {
});
const events: Array<{ type: string; message?: string; detail?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
@@ -97,7 +153,7 @@ describe("streamCopilotChat", () => {
});
const events: Array<{ type: string; message?: string; detail?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
@@ -111,7 +167,7 @@ describe("streamCopilotChat", () => {
apiFetch.mockRejectedValue(new TypeError("Failed to fetch"));
const events: Array<{ type: string; message?: string; detail?: string }> = [];
await streamCopilotChat({
await streamAgentChat({
message: "hi",
onEvent: (event) => events.push(event),
});
@@ -120,4 +176,49 @@ describe("streamCopilotChat", () => {
{ 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,
}),
}),
);
});
});
+119 -14
View File
@@ -2,24 +2,34 @@ import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
export type StreamEvent =
| { type: "token"; conversationId: string; content: string }
| { type: "done"; conversationId: string }
| { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string }
| { type: "session_title"; sessionId: string; title: string }
| {
type: "progress";
sessionId: string;
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
}
| {
type: "error";
conversationId?: string;
sessionId?: string;
message: string;
detail?: string;
}
| {
type: "tool_call";
conversationId: string;
sessionId: string;
tool: string;
params: Record<string, unknown>;
};
type StreamOptions = {
message: string;
conversationId?: string;
sessionId?: string;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
@@ -43,16 +53,40 @@ const parseEventBlock = (block: string): { event?: string; data?: string } => {
};
};
export const streamCopilotChat = async ({
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const resolveToolParams = (
params: unknown,
argumentsPayload: unknown,
): Record<string, unknown> => {
if (isObjectRecord(params) && Object.keys(params).length > 0) {
return params;
}
if (isObjectRecord(argumentsPayload)) {
return argumentsPayload;
}
if (typeof argumentsPayload === "string") {
try {
const parsed = JSON.parse(argumentsPayload) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return isObjectRecord(params) ? params : {};
};
export const streamAgentChat = async ({
message,
conversationId,
sessionId,
signal,
onEvent,
}: StreamOptions) => {
let response: Response;
try {
response = await apiFetch(
`${config.COPILOT_URL}/api/v1/copilot/chat/stream`,
`${config.AGENT_URL}/api/v1/agent/chat/stream`,
{
method: "POST",
signal,
@@ -62,7 +96,7 @@ export const streamCopilotChat = async ({
},
body: JSON.stringify({
message,
conversation_id: conversationId,
session_id: sessionId,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
@@ -115,37 +149,59 @@ export const streamCopilotChat = async ({
try {
const parsed = JSON.parse(data) as {
session_id?: string;
conversationId?: string;
content?: string;
message?: string;
detail?: string;
tool?: string;
params?: Record<string, unknown>;
arguments?: unknown;
id?: string;
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
};
if (event === "token") {
onEvent({
type: "token",
conversationId: parsed.conversationId ?? "",
sessionId: parsed.session_id ?? "",
content: parsed.content ?? "",
});
} else if (event === "progress") {
onEvent({
type: "progress",
sessionId: parsed.session_id ?? "",
id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`,
phase: parsed.phase ?? "progress",
status: parsed.status ?? "running",
title: parsed.title ?? "正在处理",
detail: parsed.detail,
});
} else if (event === "done") {
onEvent({
type: "done",
conversationId: parsed.conversationId ?? "",
sessionId: parsed.session_id ?? "",
});
} else if (event === "session_title") {
onEvent({
type: "session_title",
sessionId: parsed.session_id ?? "",
title: typeof parsed.title === "string" ? parsed.title : "",
});
} else if (event === "error") {
onEvent({
type: "error",
conversationId: parsed.conversationId,
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
});
} else if (event === "tool_call") {
onEvent({
type: "tool_call",
conversationId: parsed.conversationId ?? "",
sessionId: parsed.session_id ?? parsed.conversationId ?? "",
tool: parsed.tool ?? "",
params: parsed.params ?? {},
params: resolveToolParams(parsed.params, parsed.arguments),
});
}
} catch {
@@ -158,3 +214,52 @@ export const streamCopilotChat = 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;
};
+4
View File
@@ -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<ChatToolState>((set) => ({
lastAction: null,
actionSeq: 0,
lastActionAt: 0,
dispatch: (action) =>
set((state) => ({
lastAction: action,
actionSeq: state.actionSeq + 1,
lastActionAt: Date.now(),
})),
}));
+6
View File
@@ -14,6 +14,10 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"types": [
"jest",
"node"
],
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
@@ -63,6 +67,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next*/types/**/*.ts",
".next*/dev/types/**/*.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],