Compare commits
15 Commits
a1442fc062
...
ba66abb4ee
| Author | SHA1 | Date | |
|---|---|---|---|
| ba66abb4ee | |||
| e0e78cd95a | |||
| c5b0f43a0d | |||
| 8f3c288823 | |||
| 24d81e04e0 | |||
| 85b4f45d4a | |||
| 36d1a8d6ea | |||
| e5ca9e24aa | |||
| 2c1afdc97c | |||
| 30d85173ee | |||
| 3b5a493cda | |||
| 49fd4f5eb1 | |||
| 3db2af0271 | |||
| 07861bee03 | |||
| 60181dba54 |
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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: "事件模拟",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 [];
|
||||
};
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getToolDescription(toolCall: ToolCall): string {
|
||||
const { params } = toolCall;
|
||||
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||
const rawFeatureInfos = params.feature_infos;
|
||||
if (Array.isArray(rawFeatureInfos)) {
|
||||
@@ -189,7 +224,7 @@ function getToolDescription(toolCall: ToolCall): string {
|
||||
case "locate_reservoirs":
|
||||
case "locate_pumps":
|
||||
case "locate_tanks": {
|
||||
const ids = normalizeIds();
|
||||
const ids = normalizeLocateIds(params);
|
||||
const idsText =
|
||||
ids.length > 3
|
||||
? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个`
|
||||
@@ -233,19 +268,6 @@ function getToolDescription(toolCall: ToolCall): string {
|
||||
|
||||
function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||
const { params } = toolCall;
|
||||
const normalizeIds = (): string[] => {
|
||||
const rawIds = params.ids;
|
||||
if (Array.isArray(rawIds)) {
|
||||
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
|
||||
}
|
||||
if (typeof rawIds === "string") {
|
||||
return rawIds
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||
const rawFeatureInfos = params.feature_infos;
|
||||
if (Array.isArray(rawFeatureInfos)) {
|
||||
@@ -302,13 +324,13 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||
? featureTypeRaw.trim().toLowerCase()
|
||||
: "";
|
||||
const config = locateFeatureTypeToConfig(featureType);
|
||||
if (!config) return null;
|
||||
return {
|
||||
type: "locate_features",
|
||||
ids: normalizeIds(),
|
||||
layer: config.layer,
|
||||
geometryKind: config.geometryKind,
|
||||
};
|
||||
if (!config) return null;
|
||||
return {
|
||||
type: "locate_features",
|
||||
ids: normalizeLocateIds(params),
|
||||
layer: config.layer,
|
||||
geometryKind: config.geometryKind,
|
||||
};
|
||||
}
|
||||
case "locate_junctions":
|
||||
case "locate_pipes":
|
||||
@@ -320,7 +342,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||
if (!layer) return null;
|
||||
return {
|
||||
type: "locate_features",
|
||||
ids: normalizeIds(),
|
||||
ids: normalizeLocateIds(params),
|
||||
layer,
|
||||
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
|
||||
};
|
||||
@@ -378,12 +400,13 @@ export const ChatToolCallBlock: React.FC<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,97 +423,143 @@ 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)}`,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
gap: 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 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontSize: "0.75rem",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
{!expanded && description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
fontSize: "0.75rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 180,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
• {description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action */}
|
||||
{executed ? (
|
||||
<Chip
|
||||
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
|
||||
label="已执行"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: alpha("#4caf50", 0.1),
|
||||
color: "#4caf50",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleExecute}
|
||||
sx={{
|
||||
borderColor: alpha(meta.color, 0.4),
|
||||
color: meta.color,
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
borderRadius: 2,
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
"&:hover": {
|
||||
borderColor: meta.color,
|
||||
bgcolor: alpha(meta.color, 0.08),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{meta.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<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("#00e676", 0.15),
|
||||
color: "#00c853",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
|
||||
sx={{
|
||||
bgcolor: meta.color,
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
fontSize: "0.8rem",
|
||||
borderRadius: 2.5,
|
||||
px: 2,
|
||||
textTransform: "none",
|
||||
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
|
||||
"&:hover": {
|
||||
bgcolor: meta.color,
|
||||
filter: "brightness(0.9)",
|
||||
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{meta.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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,158 +1,174 @@
|
||||
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({
|
||||
source: new XYZ({
|
||||
url: `https://api.mapbox.com/styles/v1/mapbox/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>',
|
||||
}),
|
||||
});
|
||||
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>',
|
||||
}),
|
||||
});
|
||||
const createTileLayer = (url: string, attributions: string) =>
|
||||
new TileLayer({
|
||||
source: new XYZ({
|
||||
url,
|
||||
tileSize: 512,
|
||||
maxZoom: 20,
|
||||
projection: "EPSG:3857",
|
||||
attributions,
|
||||
}),
|
||||
});
|
||||
|
||||
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}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
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 = [
|
||||
{
|
||||
id: "mapbox-light",
|
||||
name: "默认地图",
|
||||
layer: lightMapLayer,
|
||||
// layer: tiandituVectorLayerGroup,
|
||||
img: mapboxLight.src,
|
||||
},
|
||||
{
|
||||
id: "mapbox-satellite",
|
||||
name: "卫星地图",
|
||||
layer: satelliteLayer,
|
||||
// layer: tiandituImageLayerGroup,
|
||||
img: mapboxSatellite.src,
|
||||
},
|
||||
{
|
||||
id: "mapbox-satellite-streets",
|
||||
name: "卫星街道地图",
|
||||
layer: satelliteStreetsLayer,
|
||||
img: mapboxSatelliteStreet.src,
|
||||
},
|
||||
{
|
||||
id: "mapbox-streets",
|
||||
name: "街道地图",
|
||||
layer: streetsLayer,
|
||||
img: mapboxStreets.src,
|
||||
},
|
||||
];
|
||||
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}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituVectorAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
const tiandituImageAnnotationLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||
projection: "EPSG:3857",
|
||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||
}),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
id: "mapbox-light",
|
||||
name: "默认地图",
|
||||
layer: lightMapLayer,
|
||||
img: mapboxLight.src,
|
||||
},
|
||||
{
|
||||
id: "mapbox-satellite",
|
||||
name: "卫星地图",
|
||||
layer: satelliteLayer,
|
||||
img: mapboxSatellite.src,
|
||||
},
|
||||
{
|
||||
id: "mapbox-satellite-streets",
|
||||
name: "卫星街道地图",
|
||||
layer: satelliteStreetsLayer,
|
||||
img: mapboxSatelliteStreet.src,
|
||||
},
|
||||
{
|
||||
id: "mapbox-streets",
|
||||
name: "街道地图",
|
||||
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();
|
||||
if (!layers.includes(layerInfo.layer)) {
|
||||
map.getLayers().insertAt(0, layerInfo.layer);
|
||||
maps.forEach((targetMap) => {
|
||||
let layerEntries = layerSetsRef.current.get(targetMap);
|
||||
if (!layerEntries) {
|
||||
layerEntries = createBaseLayerEntries();
|
||||
layerSetsRef.current.set(targetMap, layerEntries);
|
||||
}
|
||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||
|
||||
layerEntries.forEach((layerInfo) => {
|
||||
const layers = targetMap.getLayers().getArray();
|
||||
if (!layers.includes(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}
|
||||
@@ -226,7 +241,7 @@ const BaseLayers: React.FC = () => {
|
||||
{baseLayers.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
|
||||
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
|
||||
onClick={() => handleMapLayers(item.id)}
|
||||
>
|
||||
<Image
|
||||
|
||||
@@ -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,32 +672,46 @@ 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: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[(index * 4 + sIndex) % colors.length],
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: "rgba(255, 255, 255, 0)",
|
||||
},
|
||||
]),
|
||||
opacity: 0.3,
|
||||
},
|
||||
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,
|
||||
color: colors[(index * 4 + sIndex) % colors.length],
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: "rgba(255, 255, 255, 0)",
|
||||
},
|
||||
]),
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,162 +66,20 @@ 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 ">
|
||||
{/* 头部 */}
|
||||
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold">属性面板</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{!id ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<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="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-16 h-16 mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">暂无属性信息</p>
|
||||
<p className="text-xs mt-1">请选择一个要素以查看其属性</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* ID 属性 */}
|
||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
||||
ID
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
||||
{id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型属性 */}
|
||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
||||
类型
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他属性(包含二级表格) */}
|
||||
{properties.map((property, index) => {
|
||||
// 二级表格
|
||||
if ("type" in property && property.type === "table") {
|
||||
return (
|
||||
<div
|
||||
key={`table-${index}`}
|
||||
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
|
||||
{property.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-200 text-gray-700">
|
||||
<tr>
|
||||
{property.columns.map((col, ci) => (
|
||||
<th
|
||||
key={ci}
|
||||
className="px-3 py-2 text-left font-semibold"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-300">
|
||||
{property.rows.map((row, ri) => (
|
||||
<tr key={ri} className="bg-white hover:bg-gray-50">
|
||||
{row.map((cell, cci) => (
|
||||
<td
|
||||
key={cci}
|
||||
className="px-3 py-2 text-gray-800"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通属性
|
||||
const base = property as BaseProperty;
|
||||
const isImportant = isImportantKeys.includes(base.label);
|
||||
return (
|
||||
<div
|
||||
key={`prop-${index}`}
|
||||
className={`group rounded-lg p-3 transition-all duration-200 ${
|
||||
isImportant
|
||||
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
|
||||
: "bg-gray-50 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span
|
||||
className={`font-medium text-xs uppercase tracking-wide ${
|
||||
isImportant ? "text-blue-700" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{base.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-semibold text-right flex-1 ${
|
||||
isImportant ? "text-blue-900" : "text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{formatValue(base)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计区域 */}
|
||||
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -214,20 +88,183 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
共 {totalProps} 个属性
|
||||
</span>
|
||||
{id && (
|
||||
<span className="text-green-600 flex items-center gap-1 font-medium">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
已选中
|
||||
</span>
|
||||
<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>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{!id ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<svg
|
||||
className="w-16 h-16 mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">暂无属性信息</p>
|
||||
<p className="text-xs mt-1">请选择一个要素以查看其属性</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* ID 属性 */}
|
||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
||||
ID
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
||||
{id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型属性 */}
|
||||
<div className="group rounded-lg p-3 transition-all duration-200 bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span className="font-medium text-xs uppercase tracking-wide text-blue-700">
|
||||
类型
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-right flex-1 text-blue-900">
|
||||
{type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 其他属性(包含二级表格) */}
|
||||
{properties.map((property, index) => {
|
||||
// 二级表格
|
||||
if ("type" in property && property.type === "table") {
|
||||
return (
|
||||
<div
|
||||
key={`table-${index}`}
|
||||
className="group rounded-lg p-3 transition-all duration-200 bg-gray-50 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span className="font-medium text-xs uppercase tracking-wide text-gray-600">
|
||||
{property.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4 mt-2 border border-gray-300 rounded-md overflow-hidden shadow-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-200 text-gray-700">
|
||||
<tr>
|
||||
{property.columns.map((col, ci) => (
|
||||
<th
|
||||
key={ci}
|
||||
className="px-3 py-2 text-left font-semibold"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-300">
|
||||
{property.rows.map((row, ri) => (
|
||||
<tr key={ri} className="bg-white hover:bg-gray-50">
|
||||
{row.map((cell, cci) => (
|
||||
<td
|
||||
key={cci}
|
||||
className="px-3 py-2 text-gray-800"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通属性
|
||||
const base = property as BaseProperty;
|
||||
const isImportant = isImportantKeys.includes(base.label);
|
||||
return (
|
||||
<div
|
||||
key={`prop-${index}`}
|
||||
className={`group rounded-lg p-3 transition-all duration-200 ${
|
||||
isImportant
|
||||
? "bg-blue-50 hover:bg-blue-100 border-l-4 border-blue-500"
|
||||
: "bg-gray-50 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<span
|
||||
className={`font-medium text-xs uppercase tracking-wide ${
|
||||
isImportant ? "text-blue-700" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{base.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-semibold text-right flex-1 ${
|
||||
isImportant ? "text-blue-900" : "text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{formatValue(base)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 底部统计区域 */}
|
||||
<div className="px-5 py-3 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
共 {totalProps} 个属性
|
||||
</span>
|
||||
{id && (
|
||||
<span className="text-green-600 flex items-center gap-1 font-medium">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
已选中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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");
|
||||
attachVectorTileSourceLoadedEvent(
|
||||
"junctions",
|
||||
junctionText,
|
||||
currentJunctionCalData
|
||||
);
|
||||
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,
|
||||
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;
|
||||
|
||||
@@ -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 updateDataStates = useCallback(
|
||||
(
|
||||
nodeResults: any[],
|
||||
linkResults: any[],
|
||||
target: "primary" | "compare" = "primary"
|
||||
) => {
|
||||
const setNodeData =
|
||||
target === "compare"
|
||||
? setCompareJunctionCalData
|
||||
: setCurrentJunctionCalData;
|
||||
const setLinkData =
|
||||
target === "compare" ? setComparePipeCalData : setCurrentPipeCalData;
|
||||
|
||||
const fetchFrameData = useCallback(async (
|
||||
queryTime: Date,
|
||||
junctionProperties: string,
|
||||
pipeProperties: string,
|
||||
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缓存
|
||||
if (junctionProperties !== "" && junctionProperties !== "elevation") {
|
||||
const nodeCacheKey = `${query_time}_${junctionProperties}_${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}`,
|
||||
));
|
||||
requests.push(nodePromise);
|
||||
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<Response> | null = null;
|
||||
let linkPromise: Promise<Response> | null = null;
|
||||
|
||||
if (junctionProperties !== "" && junctionProperties !== "elevation") {
|
||||
const nodeCacheKey = buildCacheKey(
|
||||
query_time,
|
||||
junctionProperties,
|
||||
sourceType,
|
||||
"node",
|
||||
schemeName || "",
|
||||
schemeType || ""
|
||||
);
|
||||
if (nodeCacheRef.current.has(nodeCacheKey)) {
|
||||
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
||||
} else {
|
||||
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}`;
|
||||
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}`,
|
||||
));
|
||||
requests.push(linkPromise);
|
||||
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 {
|
||||
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);
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
if (nodePromise) {
|
||||
const nodeResponse = responses.shift()!;
|
||||
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 || [],
|
||||
);
|
||||
}
|
||||
if (linkPromise) {
|
||||
const linkResponse = responses.shift()!;
|
||||
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 || [],
|
||||
);
|
||||
}
|
||||
// 更新状态
|
||||
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
|
||||
}, [disableDateSelection, updateDataStates]);
|
||||
if (nodePromise) {
|
||||
const nodeResponse = responses.shift()!;
|
||||
if (!nodeResponse.ok) {
|
||||
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
|
||||
}
|
||||
nodeRecords = await nodeResponse.json();
|
||||
nodeCacheRef.current.set(
|
||||
buildCacheKey(
|
||||
query_time,
|
||||
junctionProperties,
|
||||
sourceType,
|
||||
"node",
|
||||
schemeName || "",
|
||||
schemeType || ""
|
||||
),
|
||||
nodeRecords || []
|
||||
);
|
||||
}
|
||||
|
||||
if (linkPromise) {
|
||||
const linkResponse = responses.shift()!;
|
||||
if (!linkResponse.ok) {
|
||||
throw new Error(`Link fetch failed: ${linkResponse.status}`);
|
||||
}
|
||||
linkRecords = await linkResponse.json();
|
||||
linkCacheRef.current.set(
|
||||
buildCacheKey(
|
||||
query_time,
|
||||
normalizedPipeProperties,
|
||||
sourceType,
|
||||
"link",
|
||||
schemeName || "",
|
||||
schemeType || ""
|
||||
),
|
||||
linkRecords || []
|
||||
);
|
||||
}
|
||||
|
||||
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(":");
|
||||
|
||||
@@ -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);
|
||||
setHighlightFeatures([]);
|
||||
if (!activeTools.includes("history")) {
|
||||
setHighlightFeatures([]);
|
||||
}
|
||||
break;
|
||||
case "draw":
|
||||
setShowDrawPanel(false);
|
||||
break;
|
||||
case "history":
|
||||
setShowHistoryPanel(false);
|
||||
setHighlightFeatures([]);
|
||||
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"));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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,148 +1101,284 @@ 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({
|
||||
id: "junctionTextLayer",
|
||||
name: "节点文字",
|
||||
zIndex: 10,
|
||||
data: mergedJunctionData,
|
||||
getPosition: (d: any) => d.position,
|
||||
fontFamily: "Monaco, monospace",
|
||||
getText: (d: any) => {
|
||||
let idPart = showJunctionId ? d.id : "";
|
||||
let propPart = "";
|
||||
if (showJunctionTextLayer && d[junctionText] !== undefined) {
|
||||
const value = (d[junctionText] as number).toFixed(3);
|
||||
propPart = `${value}`;
|
||||
}
|
||||
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
||||
return idPart || propPart;
|
||||
},
|
||||
getSize: 14,
|
||||
fontWeight: "bold",
|
||||
getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见
|
||||
getAngle: 0,
|
||||
getTextAnchor: "middle",
|
||||
getAlignmentBaseline: "center",
|
||||
getPixelOffset: [0, -10],
|
||||
visible:
|
||||
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,
|
||||
updateTriggers: {
|
||||
getText: [showJunctionId, showJunctionTextLayer, junctionText],
|
||||
},
|
||||
extensions: [new CollisionFilterExtension()],
|
||||
collisionTestProps: {
|
||||
sizeScale: 3,
|
||||
},
|
||||
characterSet: "auto",
|
||||
fontSettings: {
|
||||
sdf: true,
|
||||
fontSize: 64,
|
||||
buffer: 6,
|
||||
},
|
||||
// outlineWidth: 3,
|
||||
// outlineColor: [255, 255, 255, 220],
|
||||
});
|
||||
|
||||
const pipeTextLayer = new TextLayer({
|
||||
id: "pipeTextLayer",
|
||||
name: "管道文字",
|
||||
zIndex: 10,
|
||||
data: mergedPipeData,
|
||||
getPosition: (d: any) => d.position,
|
||||
fontFamily: "Monaco, monospace",
|
||||
getText: (d: any) => {
|
||||
let idPart = showPipeId ? d.id : "";
|
||||
let propPart = "";
|
||||
if (showPipeTextLayer && d[pipeText] !== undefined) {
|
||||
let value;
|
||||
if (pipeText === "unit_headloss") {
|
||||
value = (
|
||||
(d["unit_headloss"] / (d["length"] / 1000)) as number
|
||||
).toFixed(3);
|
||||
} else {
|
||||
value = Math.abs(d[pipeText] as number).toFixed(3);
|
||||
}
|
||||
propPart = `${value}`;
|
||||
}
|
||||
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
||||
return idPart || propPart;
|
||||
},
|
||||
getSize: 14,
|
||||
fontWeight: "bold",
|
||||
getColor: [33, 37, 41], // 深灰色
|
||||
getAngle: (d: any) => d.angle || 0,
|
||||
getPixelOffset: [0, -8],
|
||||
getTextAnchor: "middle",
|
||||
getAlignmentBaseline: "bottom",
|
||||
visible:
|
||||
currentZoom <= 24 &&
|
||||
targetJunctionData.length > 0;
|
||||
const shouldShowPipeText =
|
||||
(showPipeTextLayer || showPipeId) &&
|
||||
currentZoom >= 15 &&
|
||||
currentZoom <= 24,
|
||||
updateTriggers: {
|
||||
getText: [showPipeId, showPipeTextLayer, pipeText],
|
||||
},
|
||||
extensions: [new CollisionFilterExtension()],
|
||||
collisionTestProps: {
|
||||
sizeScale: 3,
|
||||
},
|
||||
characterSet: "auto",
|
||||
fontSettings: {
|
||||
sdf: true,
|
||||
fontSize: 64,
|
||||
buffer: 6,
|
||||
},
|
||||
// outlineWidth: 3,
|
||||
// outlineColor: [255, 255, 255, 220],
|
||||
});
|
||||
currentZoom <= 24 &&
|
||||
targetPipeData.length > 0;
|
||||
const shouldShowContour =
|
||||
showContourLayer &&
|
||||
currentZoom >= 11 &&
|
||||
currentZoom <= 24 &&
|
||||
targetJunctionData.length > 0;
|
||||
|
||||
const contourLayer = new ContourLayer({
|
||||
id: "junctionContourLayer",
|
||||
name: "等值线",
|
||||
data: mergedJunctionData,
|
||||
aggregation: "MEAN",
|
||||
cellSize: 600,
|
||||
strokeWidth: 0,
|
||||
contours: contours,
|
||||
getPosition: (d) => d.position,
|
||||
getWeight: (d: any) =>
|
||||
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
|
||||
opacity: 1,
|
||||
visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24,
|
||||
updateTriggers: {
|
||||
// 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算
|
||||
getWeight: [mergedJunctionData, junctionText],
|
||||
},
|
||||
});
|
||||
if (deckLayer.getDeckLayerById("junctionTextLayer")) {
|
||||
// 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法
|
||||
deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
||||
} else {
|
||||
deckLayer.addDeckLayer(junctionTextLayer);
|
||||
}
|
||||
if (deckLayer.getDeckLayerById("pipeTextLayer")) {
|
||||
deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
|
||||
} else {
|
||||
deckLayer.addDeckLayer(pipeTextLayer);
|
||||
}
|
||||
if (deckLayer.getDeckLayerById("junctionContourLayer")) {
|
||||
deckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
|
||||
} else {
|
||||
deckLayer.addDeckLayer(contourLayer);
|
||||
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: targetJunctionData,
|
||||
getPosition: (d: any) => d.position,
|
||||
fontFamily: "Monaco, monospace",
|
||||
getText: (d: any) => {
|
||||
let idPart = showJunctionId ? d.id : "";
|
||||
let propPart = "";
|
||||
if (showJunctionTextLayer && d[junctionText] !== undefined) {
|
||||
const value = (d[junctionText] as number).toFixed(3);
|
||||
propPart = `${value}`;
|
||||
}
|
||||
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
||||
return idPart || propPart;
|
||||
},
|
||||
getSize: 14,
|
||||
fontWeight: "bold",
|
||||
getColor: [33, 37, 41],
|
||||
getAngle: 0,
|
||||
getTextAnchor: "middle",
|
||||
getAlignmentBaseline: "center",
|
||||
getPixelOffset: [0, -10],
|
||||
visible: true,
|
||||
updateTriggers: {
|
||||
getText: [showJunctionId, showJunctionTextLayer, junctionText],
|
||||
},
|
||||
extensions: [new CollisionFilterExtension()],
|
||||
collisionTestProps: {
|
||||
sizeScale: 3,
|
||||
},
|
||||
characterSet: "auto",
|
||||
fontSettings: {
|
||||
sdf: true,
|
||||
fontSize: 64,
|
||||
buffer: 6,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const pipeTextLayer = shouldShowPipeText
|
||||
? new TextLayer({
|
||||
id: "pipeTextLayer",
|
||||
name: "管道文字",
|
||||
zIndex: 10,
|
||||
data: targetPipeData,
|
||||
getPosition: (d: any) => d.position,
|
||||
fontFamily: "Monaco, monospace",
|
||||
getText: (d: any) => {
|
||||
let idPart = showPipeId ? d.id : "";
|
||||
let propPart = "";
|
||||
if (showPipeTextLayer && d[pipeText] !== undefined) {
|
||||
let value;
|
||||
if (pipeText === "unit_headloss") {
|
||||
value = (
|
||||
(d["unit_headloss"] / (d["length"] / 1000)) as number
|
||||
).toFixed(3);
|
||||
} else {
|
||||
value = Math.abs(d[pipeText] as number).toFixed(3);
|
||||
}
|
||||
propPart = `${value}`;
|
||||
}
|
||||
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
||||
return idPart || propPart;
|
||||
},
|
||||
getSize: 14,
|
||||
fontWeight: "bold",
|
||||
getColor: [33, 37, 41],
|
||||
getAngle: (d: any) => d.angle || 0,
|
||||
getPixelOffset: [0, -8],
|
||||
getTextAnchor: "middle",
|
||||
getAlignmentBaseline: "bottom",
|
||||
visible: true,
|
||||
updateTriggers: {
|
||||
getText: [showPipeId, showPipeTextLayer, pipeText],
|
||||
},
|
||||
extensions: [new CollisionFilterExtension()],
|
||||
collisionTestProps: {
|
||||
sizeScale: 3,
|
||||
},
|
||||
characterSet: "auto",
|
||||
fontSettings: {
|
||||
sdf: true,
|
||||
fontSize: 64,
|
||||
buffer: 6,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const contourLayer = shouldShowContour
|
||||
? new ContourLayer({
|
||||
id: "junctionContourLayer",
|
||||
name: "等值线",
|
||||
data: targetJunctionData,
|
||||
aggregation: "MEAN",
|
||||
cellSize: 600,
|
||||
strokeWidth: 0,
|
||||
contours: contours,
|
||||
getPosition: (d) => d.position,
|
||||
getWeight: (d: any) =>
|
||||
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
updateTriggers: {
|
||||
getWeight: [targetJunctionData, junctionText],
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) {
|
||||
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
||||
} else if (junctionTextLayer) {
|
||||
targetDeckLayer.addDeckLayer(junctionTextLayer);
|
||||
}
|
||||
if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) {
|
||||
targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
|
||||
} else if (pipeTextLayer) {
|
||||
targetDeckLayer.addDeckLayer(pipeTextLayer);
|
||||
}
|
||||
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;
|
||||
}
|
||||
const deckLayer = deckLayerRef.current;
|
||||
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
|
||||
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||
const shouldShowWaterflow =
|
||||
isWaterflowLayerAvailable &&
|
||||
showWaterflowLayer &&
|
||||
flowAnimation.current &&
|
||||
currentZoom >= 12 &&
|
||||
currentZoom <= 24;
|
||||
|
||||
let animationFrameId: number; // 保存 requestAnimationFrame 的 ID
|
||||
let animationFrameId: number;
|
||||
|
||||
// 动画循环
|
||||
const animate = () => {
|
||||
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
|
||||
// 动画总时长(秒)
|
||||
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 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 ref={mapRef} className="w-full h-full"></div>
|
||||
<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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user