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_ 前缀
|
||||||
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
|
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_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
|
||||||
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
|
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
|
||||||
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
|
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
|
||||||
|
|||||||
@@ -59,11 +59,12 @@ jobs:
|
|||||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
||||||
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
||||||
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
||||||
REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
||||||
IMAGE_NAME="${REGISTRY_HOST}/${REPOSITORY_PATH}"
|
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
||||||
{
|
{
|
||||||
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
||||||
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
||||||
|
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
|
||||||
echo "IMAGE_NAME=${IMAGE_NAME}"
|
echo "IMAGE_NAME=${IMAGE_NAME}"
|
||||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||||
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
@@ -102,7 +103,7 @@ jobs:
|
|||||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||||
-t "${IMAGE_NAME}:latest" \
|
-t "${IMAGE_NAME}:latest" \
|
||||||
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
|
--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_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_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
|
||||||
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
|
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
|
||||||
@@ -116,10 +117,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Notify Deploy Server
|
- name: Notify Deploy Server
|
||||||
run: |
|
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 "Content-Type: application/json" \
|
||||||
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|
-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:
|
deploy-fallback-log:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ FROM base AS builder
|
|||||||
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
|
||||||
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
|
||||||
ARG NEXT_PUBLIC_BACKEND_URL
|
ARG NEXT_PUBLIC_BACKEND_URL
|
||||||
ARG NEXT_PUBLIC_COPILOT_URL
|
ARG NEXT_PUBLIC_AGENT_URL
|
||||||
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
|
||||||
ARG NEXT_PUBLIC_MAP_URL
|
ARG NEXT_PUBLIC_MAP_URL
|
||||||
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
ARG NEXT_PUBLIC_MAP_WORKSPACE
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
|
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_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
|
||||||
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
|
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
|
||||||
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
|
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
distDir: process.env.NEXT_DIST_DIR || ".next",
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
Generated
+7
@@ -30,6 +30,7 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
@@ -15843,6 +15844,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.5",
|
"echarts-for-react": "^3.0.5",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-auth": "^4.24.5",
|
"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 (
|
return (
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
<MapComponent>
|
<MapComponent>
|
||||||
<MapToolbar queryType="scheme" schemeType="burst_analysis" />
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="burst_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
<BurstPipeAnalysisPanel />
|
<BurstPipeAnalysisPanel />
|
||||||
</MapComponent>
|
</MapComponent>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full overflow-hidden">
|
<div className="relative w-full h-full overflow-hidden">
|
||||||
<MapComponent>
|
<MapComponent>
|
||||||
<MapToolbar queryType="scheme" schemeType="contaminant_analysis" />
|
<MapToolbar
|
||||||
|
queryType="scheme"
|
||||||
|
schemeType="contaminant_analysis"
|
||||||
|
enableCompare
|
||||||
|
/>
|
||||||
<WaterQualityPanel />
|
<WaterQualityPanel />
|
||||||
</MapComponent>
|
</MapComponent>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
{
|
{
|
||||||
name: "Hydraulic Simulation",
|
name: "Hydraulic Simulation",
|
||||||
meta: {
|
meta: {
|
||||||
icon: <MdWater className="w-6 h-6" />,
|
// icon: <MdWater className="w-6 h-6" />,
|
||||||
label: "事件模拟",
|
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,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
|
Collapse,
|
||||||
|
IconButton,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
||||||
import TimelineRounded from "@mui/icons-material/TimelineRounded";
|
import TimelineRounded from "@mui/icons-material/TimelineRounded";
|
||||||
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useChatToolStore,
|
useChatToolStore,
|
||||||
@@ -45,6 +49,26 @@ const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
|
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> = {
|
const TOOL_META: Record<string, ToolMeta> = {
|
||||||
locate_features: {
|
locate_features: {
|
||||||
@@ -111,21 +135,32 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
function getToolDescription(toolCall: ToolCall): string {
|
function normalizeLocateIds(params: Record<string, unknown>): string[] {
|
||||||
const { params } = toolCall;
|
for (const key of LOCATE_ID_PARAM_KEYS) {
|
||||||
const normalizeIds = (): string[] => {
|
const rawValue = params[key];
|
||||||
const rawIds = params.ids;
|
if (Array.isArray(rawValue)) {
|
||||||
if (Array.isArray(rawIds)) {
|
const normalized = rawValue
|
||||||
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
|
.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(",")
|
.split(",")
|
||||||
.map((id) => id.trim())
|
.map((id) => id.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function getToolDescription(toolCall: ToolCall): string {
|
||||||
|
const { params } = toolCall;
|
||||||
const resolveScadaFeatureInfos = (): [string, string][] => {
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
const rawFeatureInfos = params.feature_infos;
|
const rawFeatureInfos = params.feature_infos;
|
||||||
if (Array.isArray(rawFeatureInfos)) {
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
@@ -189,7 +224,7 @@ function getToolDescription(toolCall: ToolCall): string {
|
|||||||
case "locate_reservoirs":
|
case "locate_reservoirs":
|
||||||
case "locate_pumps":
|
case "locate_pumps":
|
||||||
case "locate_tanks": {
|
case "locate_tanks": {
|
||||||
const ids = normalizeIds();
|
const ids = normalizeLocateIds(params);
|
||||||
const idsText =
|
const idsText =
|
||||||
ids.length > 3
|
ids.length > 3
|
||||||
? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个`
|
? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个`
|
||||||
@@ -233,19 +268,6 @@ function getToolDescription(toolCall: ToolCall): string {
|
|||||||
|
|
||||||
function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
||||||
const { params } = toolCall;
|
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 resolveScadaFeatureInfos = (): [string, string][] => {
|
||||||
const rawFeatureInfos = params.feature_infos;
|
const rawFeatureInfos = params.feature_infos;
|
||||||
if (Array.isArray(rawFeatureInfos)) {
|
if (Array.isArray(rawFeatureInfos)) {
|
||||||
@@ -305,7 +327,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
return {
|
return {
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
ids: normalizeIds(),
|
ids: normalizeLocateIds(params),
|
||||||
layer: config.layer,
|
layer: config.layer,
|
||||||
geometryKind: config.geometryKind,
|
geometryKind: config.geometryKind,
|
||||||
};
|
};
|
||||||
@@ -320,7 +342,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
if (!layer) return null;
|
if (!layer) return null;
|
||||||
return {
|
return {
|
||||||
type: "locate_features",
|
type: "locate_features",
|
||||||
ids: normalizeIds(),
|
ids: normalizeLocateIds(params),
|
||||||
layer,
|
layer,
|
||||||
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
|
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
|
||||||
};
|
};
|
||||||
@@ -378,12 +400,13 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const dispatch = useChatToolStore((s) => s.dispatch);
|
const dispatch = useChatToolStore((s) => s.dispatch);
|
||||||
const [executed, setExecuted] = useState(false);
|
const [executed, setExecuted] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
|
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
|
||||||
label: toolCall.tool,
|
label: toolCall.tool,
|
||||||
icon: null,
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
||||||
actionLabel: "执行",
|
actionLabel: "执行",
|
||||||
color: theme.palette.primary.main,
|
color: "#00acc1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const description = getToolDescription(toolCall);
|
const description = getToolDescription(toolCall);
|
||||||
@@ -400,90 +423,133 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
|
|||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1.5,
|
mt: 1,
|
||||||
mb: 1,
|
mb: 1,
|
||||||
p: 1.5,
|
overflow: "hidden",
|
||||||
borderRadius: 3,
|
borderRadius: 4,
|
||||||
border: `1px solid ${alpha(meta.color, 0.25)}`,
|
border: `1px solid ${alpha(meta.color, 0.3)}`,
|
||||||
bgcolor: alpha(meta.color, 0.04),
|
bgcolor: alpha(meta.color, 0.05),
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: alpha(meta.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(meta.color, 0.4)}`,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
gap: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: 2,
|
borderRadius: "50%",
|
||||||
bgcolor: alpha(meta.color, 0.12),
|
bgcolor: alpha(meta.color, 0.15),
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
color: meta.color,
|
color: meta.color,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{meta.icon}
|
{meta.icon}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Title */}
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="body2"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
color: "text.primary",
|
color: "text.primary",
|
||||||
display: "block",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{description && (
|
{!expanded && description && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{
|
sx={{
|
||||||
color: "text.secondary",
|
color: "text.secondary",
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
display: "block",
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 180,
|
||||||
|
opacity: 0.8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
• {description}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action */}
|
<IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
|
||||||
|
{expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
|
||||||
|
<Stack direction="column" spacing={1.5}>
|
||||||
|
{description && (
|
||||||
|
<Box sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha("#000", 0.03),
|
||||||
|
border: `1px solid ${alpha("#000", 0.05)}`,
|
||||||
|
}}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
|
||||||
|
执行参数
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" justifyContent="flex-end">
|
||||||
{executed ? (
|
{executed ? (
|
||||||
<Chip
|
<Chip
|
||||||
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
|
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
|
||||||
label="已执行"
|
label="已执行"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: alpha("#4caf50", 0.1),
|
bgcolor: alpha("#00e676", 0.15),
|
||||||
color: "#4caf50",
|
color: "#00c853",
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="contained"
|
||||||
onClick={handleExecute}
|
disableElevation
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
|
||||||
sx={{
|
sx={{
|
||||||
borderColor: alpha(meta.color, 0.4),
|
bgcolor: meta.color,
|
||||||
color: meta.color,
|
color: "#fff",
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.8rem",
|
||||||
borderRadius: 2,
|
borderRadius: 2.5,
|
||||||
|
px: 2,
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
whiteSpace: "nowrap",
|
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
borderColor: meta.color,
|
bgcolor: meta.color,
|
||||||
bgcolor: alpha(meta.color, 0.08),
|
filter: "brightness(0.9)",
|
||||||
|
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -491,6 +557,9 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,35 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import { Box, Stack } from "@mui/material";
|
||||||
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";
|
|
||||||
|
|
||||||
export const TypingIndicator = () => {
|
export const TypingIndicator = () => {
|
||||||
return (
|
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 = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
isError?: boolean;
|
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 = {
|
export type Props = {
|
||||||
@@ -12,7 +61,39 @@ export type Props = {
|
|||||||
|
|
||||||
export type SpeechState = "idle" | "playing" | "paused";
|
export type SpeechState = "idle" | "playing" | "paused";
|
||||||
|
|
||||||
export type PersistedChatState = {
|
export type LegacyPersistedChatState = {
|
||||||
messages: Message[];
|
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 = () =>
|
export const createId = () =>
|
||||||
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`${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 = [
|
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 =>
|
export const stripMarkdown = (md: string): string =>
|
||||||
md
|
md
|
||||||
.replace(/```[\s\S]*?```/g, "")
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
@@ -34,26 +28,19 @@ export const stripMarkdown = (md: string): string =>
|
|||||||
.replace(/<[^>]+>/g, "")
|
.replace(/<[^>]+>/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
export const getInitialChatState = (): PersistedChatState => {
|
export const cloneMessage = (message: Message): Message => ({
|
||||||
if (typeof window === "undefined") {
|
...message,
|
||||||
return { messages: [], conversationId: undefined };
|
progress: message.progress ? [...message.progress] : undefined,
|
||||||
}
|
artifacts: message.artifacts ? [...message.artifacts] : undefined,
|
||||||
try {
|
});
|
||||||
const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
|
||||||
if (!storedRaw) return { messages: [], conversationId: undefined };
|
export const cloneMessages = (messages: Message[]) => messages.map(cloneMessage);
|
||||||
const parsed = JSON.parse(storedRaw) as PersistedChatState;
|
|
||||||
if (!Array.isArray(parsed.messages)) {
|
export const cloneBranchGroups = (branchGroups: BranchGroup[]) =>
|
||||||
console.error("[GlobalChatbox] Invalid persisted messages format.");
|
branchGroups.map((group) => ({
|
||||||
window.localStorage.removeItem(CHAT_STORAGE_KEY);
|
...group,
|
||||||
return { messages: [], conversationId: undefined };
|
branches: group.branches.map((branch) => ({
|
||||||
}
|
...branch,
|
||||||
return { messages: parsed.messages, conversationId: parsed.conversationId };
|
messages: cloneMessages(branch.messages),
|
||||||
} catch (error) {
|
})),
|
||||||
console.error(
|
}));
|
||||||
"[GlobalChatbox] Failed to read persisted chat state:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
window.localStorage.removeItem(CHAT_STORAGE_KEY);
|
|
||||||
return { messages: [], conversationId: undefined };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
ShowChart,
|
||||||
TableChart,
|
TableChart,
|
||||||
CleaningServices,
|
CleaningServices,
|
||||||
|
Close,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
@@ -72,12 +73,22 @@ export interface SCADADataPanelProps {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
/** 关闭面板 */
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelTab = "chart" | "table";
|
type PanelTab = "chart" | "table";
|
||||||
|
|
||||||
type LoadingState = "idle" | "loading" | "success" | "error";
|
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 数据
|
* 从后端 API 获取 SCADA 数据
|
||||||
*/
|
*/
|
||||||
@@ -320,6 +331,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
onCleanData,
|
onCleanData,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
@@ -986,7 +998,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
<Box
|
<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"
|
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)}
|
onClick={() => setIsExpanded(true)}
|
||||||
sx={{ zIndex: 1300 }}
|
sx={{ zIndex: 1290 }}
|
||||||
>
|
>
|
||||||
<Box className="flex flex-col items-center py-3 px-3 gap-1">
|
<Box className="flex flex-col items-center py-3 px-3 gap-1">
|
||||||
<ShowChart className="text-[#257DD4] w-5 h-5" />
|
<ShowChart className="text-[#257DD4] w-5 h-5" />
|
||||||
@@ -1063,11 +1075,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" spacing={1}>
|
<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="收起">
|
<Tooltip title="收起">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
sx={{ color: "primary.contrastText" }}
|
aria-label="收起 SCADA 历史数据面板"
|
||||||
|
sx={panelHeaderActionSx}
|
||||||
>
|
>
|
||||||
<ChevronRight fontSize="small" />
|
<ChevronRight fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -1,60 +1,51 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMap } from "../MapComponent";
|
import { useData, useMap } from "../MapComponent";
|
||||||
import TileLayer from "ol/layer/Tile.js";
|
import TileLayer from "ol/layer/Tile.js";
|
||||||
import XYZ from "ol/source/XYZ.js";
|
import XYZ from "ol/source/XYZ.js";
|
||||||
|
import Group from "ol/layer/Group";
|
||||||
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
|
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
|
||||||
import mapboxLight from "@assets/map/layers/mapbox-light.png";
|
import mapboxLight from "@assets/map/layers/mapbox-light.png";
|
||||||
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
|
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
|
||||||
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
|
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
|
||||||
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
|
import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Group from "ol/layer/Group";
|
import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config";
|
||||||
import { MAPBOX_TOKEN } from "@config/config";
|
import type { Map as OlMap } from "ol";
|
||||||
import { TIANDITU_TOKEN } from "@config/config";
|
|
||||||
const INITIAL_LAYER = "mapbox-light";
|
const INITIAL_LAYER = "mapbox-light";
|
||||||
|
|
||||||
const streetsLayer = new TileLayer({
|
const createTileLayer = (url: string, attributions: string) =>
|
||||||
|
new TileLayer({
|
||||||
source: new XYZ({
|
source: new XYZ({
|
||||||
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
|
url,
|
||||||
tileSize: 512,
|
tileSize: 512,
|
||||||
maxZoom: 20,
|
maxZoom: 20,
|
||||||
projection: "EPSG:3857",
|
projection: "EPSG:3857",
|
||||||
attributions:
|
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 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({
|
const tiandituVectorLayer = new TileLayer({
|
||||||
source: new XYZ({
|
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}`,
|
url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`,
|
||||||
@@ -83,25 +74,18 @@ const tiandituImageAnnotationLayer = new TileLayer({
|
|||||||
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const tiandituVectorLayerGroup = new Group({
|
|
||||||
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer],
|
return [
|
||||||
});
|
|
||||||
const tiandituImageLayerGroup = new Group({
|
|
||||||
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
|
|
||||||
});
|
|
||||||
const baseLayers = [
|
|
||||||
{
|
{
|
||||||
id: "mapbox-light",
|
id: "mapbox-light",
|
||||||
name: "默认地图",
|
name: "默认地图",
|
||||||
layer: lightMapLayer,
|
layer: lightMapLayer,
|
||||||
// layer: tiandituVectorLayerGroup,
|
|
||||||
img: mapboxLight.src,
|
img: mapboxLight.src,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mapbox-satellite",
|
id: "mapbox-satellite",
|
||||||
name: "卫星地图",
|
name: "卫星地图",
|
||||||
layer: satelliteLayer,
|
layer: satelliteLayer,
|
||||||
// layer: tiandituImageLayerGroup,
|
|
||||||
img: mapboxSatellite.src,
|
img: mapboxSatellite.src,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,43 +100,75 @@ const baseLayers = [
|
|||||||
layer: streetsLayer,
|
layer: streetsLayer,
|
||||||
img: mapboxStreets.src,
|
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 BaseLayers: React.FC = () => {
|
||||||
const map = useMap();
|
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 [isShow, setShow] = useState(false);
|
||||||
const [isExpanded, setExpanded] = useState(false);
|
const [isExpanded, setExpanded] = useState(false);
|
||||||
// 快速切换底图
|
|
||||||
const [activeId, setActiveId] = useState(INITIAL_LAYER);
|
const [activeId, setActiveId] = useState(INITIAL_LAYER);
|
||||||
|
|
||||||
// 初始化默认底图
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
maps.forEach((targetMap) => {
|
||||||
// 添加所有底图至地图并根据 activeId 控制可见性
|
let layerEntries = layerSetsRef.current.get(targetMap);
|
||||||
baseLayers.forEach((layerInfo) => {
|
if (!layerEntries) {
|
||||||
const layers = map.getLayers().getArray();
|
layerEntries = createBaseLayerEntries();
|
||||||
|
layerSetsRef.current.set(targetMap, layerEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
layerEntries.forEach((layerInfo) => {
|
||||||
|
const layers = targetMap.getLayers().getArray();
|
||||||
if (!layers.includes(layerInfo.layer)) {
|
if (!layers.includes(layerInfo.layer)) {
|
||||||
map.getLayers().insertAt(0, layerInfo.layer);
|
targetMap.getLayers().insertAt(0, layerInfo.layer);
|
||||||
}
|
}
|
||||||
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
layerInfo.layer.setVisible(layerInfo.id === activeId);
|
||||||
});
|
});
|
||||||
}, [map, activeId]);
|
});
|
||||||
|
}, [activeId, maps]);
|
||||||
|
|
||||||
const changeMapLayers = (id: string) => {
|
const changeMapLayers = (id: string) => {
|
||||||
if (map) {
|
maps.forEach((targetMap) => {
|
||||||
// 根据 id 设置每个图层的可见性
|
const layerEntries = layerSetsRef.current.get(targetMap);
|
||||||
baseLayers.forEach(({ id: lid, layer }) => {
|
layerEntries?.forEach(({ id: layerId, layer }) => {
|
||||||
layer.setVisible(lid === id);
|
layer.setVisible(layerId === id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
img,
|
||||||
|
})), []);
|
||||||
|
|
||||||
const handleQuickSwitch = () => {
|
const handleQuickSwitch = () => {
|
||||||
const nextId =
|
const nextId =
|
||||||
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
|
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
|
||||||
setActiveId(nextId);
|
setActiveId(nextId);
|
||||||
handleMapLayers(nextId);
|
changeMapLayers(nextId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMapLayers = (id: string) => {
|
const handleMapLayers = (id: string) => {
|
||||||
@@ -160,7 +176,6 @@ const BaseLayers: React.FC = () => {
|
|||||||
changeMapLayers(id);
|
changeMapLayers(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 记录定时器,避免多次触发
|
|
||||||
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
|
const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
@@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => {
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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"
|
isShow ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
onMouseEnter={handleEnter}
|
onMouseEnter={handleEnter}
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Divider,
|
Divider,
|
||||||
|
IconButton,
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||||
import { zhCN } from "@mui/x-data-grid/locales";
|
import { zhCN } from "@mui/x-data-grid/locales";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
@@ -63,12 +64,22 @@ export interface SCADADataPanelProps {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
/** 关闭面板 */
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelTab = "chart" | "table";
|
type PanelTab = "chart" | "table";
|
||||||
|
|
||||||
type LoadingState = "idle" | "loading" | "success" | "error";
|
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 数据
|
* 从后端 API 获取 SCADA 数据
|
||||||
*/
|
*/
|
||||||
@@ -129,14 +140,17 @@ const fetchFromBackend = async (
|
|||||||
"raw"
|
"raw"
|
||||||
);
|
);
|
||||||
} else if (type === "scheme") {
|
} else if (type === "scheme") {
|
||||||
// 查询策略模拟值、清洗值和监测值
|
// 查询策略模拟值、实时模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, schemeSimRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes, schemeSimRes] = await Promise.all([
|
||||||
apiFetch(cleanedDataUrl)
|
apiFetch(cleanedDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
apiFetch(rawDataUrl)
|
apiFetch(rawDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
|
apiFetch(simulationDataUrl)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.catch(() => null),
|
||||||
apiFetch(schemeSimulationDataUrl)
|
apiFetch(schemeSimulationDataUrl)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
@@ -146,40 +160,18 @@ const fetchFromBackend = async (
|
|||||||
// 如果清洗数据有值,则不显示原始监测值
|
// 如果清洗数据有值,则不显示原始监测值
|
||||||
const rawData =
|
const rawData =
|
||||||
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
cleanedData.length > 0 ? [] : transformBackendData(rawRes, featureIds);
|
||||||
|
const simulationData = transformBackendData(simulationRes, featureIds);
|
||||||
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
|
const schemeSimData = transformBackendData(schemeSimRes, featureIds);
|
||||||
|
|
||||||
// 合并三组数据
|
return mergeMultipleTimeSeriesData(
|
||||||
const timeMap = new Map<string, Record<string, number | null>>();
|
[
|
||||||
|
{ data: cleanedData, suffix: "clean" },
|
||||||
[cleanedData, rawData, schemeSimData].forEach((data, index) => {
|
{ data: rawData, suffix: "raw" },
|
||||||
const suffix = ["clean", "raw", "scheme_sim"][index];
|
{ data: simulationData, suffix: "sim" },
|
||||||
data.forEach((point) => {
|
{ data: schemeSimData, suffix: "scheme_sim" },
|
||||||
if (!timeMap.has(point.timestamp)) {
|
],
|
||||||
timeMap.set(point.timestamp, {});
|
featureIds
|
||||||
}
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
result.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} else {
|
} else {
|
||||||
// realtime: 查询模拟值、清洗值和监测值
|
// realtime: 查询模拟值、清洗值和监测值
|
||||||
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
const [cleanedRes, rawRes, simulationRes] = await Promise.all([
|
||||||
@@ -336,6 +328,42 @@ const mergeTimeSeriesData = (
|
|||||||
return result;
|
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) =>
|
const formatTimestamp = (timestamp: string) =>
|
||||||
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
dayjs(timestamp).tz("Asia/Shanghai").format("YYYY-MM-DD HH:mm");
|
||||||
|
|
||||||
@@ -402,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
// 从 featureInfos 中提取设备 ID 列表
|
// 从 featureInfos 中提取设备 ID 列表
|
||||||
const deviceIds = useMemo(
|
const deviceIds = useMemo(
|
||||||
@@ -537,7 +566,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const suffixes = [
|
const suffixes = [
|
||||||
{ key: "clean", name: "清洗值" },
|
{ key: "clean", name: "清洗值" },
|
||||||
{ key: "raw", name: "监测值" },
|
{ key: "raw", name: "监测值" },
|
||||||
{ key: "sim", name: "模拟值" },
|
{ key: "sim", name: "实时模拟值" },
|
||||||
{ key: "scheme_sim", name: "方案模拟值" },
|
{ key: "scheme_sim", name: "方案模拟值" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -643,20 +672,34 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
: suffix === "raw"
|
: suffix === "raw"
|
||||||
? "监测值"
|
? "监测值"
|
||||||
: suffix === "sim"
|
: suffix === "sim"
|
||||||
? "模拟"
|
? "实时模拟"
|
||||||
: "方案模拟";
|
: "方案模拟";
|
||||||
|
|
||||||
series.push({
|
series.push({
|
||||||
name: `${id} (${displayName})`,
|
name: `${id} (${displayName})`,
|
||||||
type: "line",
|
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",
|
sampling: "lttb",
|
||||||
connectNulls: true,
|
connectNulls: suffix !== "clean" && suffix !== "raw",
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: colors[(index * 4 + sIndex) % colors.length],
|
color: colors[(index * 4 + sIndex) % colors.length],
|
||||||
},
|
},
|
||||||
data: dataset.map((item) => item[key]),
|
data: dataset.map((item) => item[key]),
|
||||||
areaStyle: {
|
lineStyle:
|
||||||
|
suffix === "clean" || suffix === "raw"
|
||||||
|
? { width: 0 }
|
||||||
|
: undefined,
|
||||||
|
areaStyle:
|
||||||
|
suffix === "clean" || suffix === "raw"
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -819,7 +862,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 主面板 */}
|
{/* 主面板 */}
|
||||||
<Draggable nodeRef={draggableRef} handle=".drag-handle">
|
<Draggable
|
||||||
|
nodeRef={draggableRef}
|
||||||
|
handle=".drag-handle"
|
||||||
|
cancel=".panel-close-button"
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
ref={draggableRef}
|
ref={draggableRef}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -840,7 +887,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
border: "none",
|
border: "none",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
zIndex: 1300,
|
zIndex: 1290,
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
@@ -884,6 +931,17 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Tooltip title="关闭">
|
||||||
|
<IconButton
|
||||||
|
className="panel-close-button"
|
||||||
|
size="small"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭历史数据面板"
|
||||||
|
sx={panelHeaderActionSx}
|
||||||
|
>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
|
|||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorTileLayer from "ol/layer/VectorTile";
|
import VectorTileLayer from "ol/layer/VectorTile";
|
||||||
import { DeckLayer } from "@utils/layers";
|
import { DeckLayer } from "@utils/layers";
|
||||||
|
import type { Map as OlMap } from "ol";
|
||||||
|
|
||||||
// 定义统一的图层项接口
|
// 定义统一的图层项接口
|
||||||
interface LayerItem {
|
interface LayerItem {
|
||||||
@@ -30,8 +31,10 @@ const LAYER_ORDER = [
|
|||||||
const LayerControl: React.FC = () => {
|
const LayerControl: React.FC = () => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
|
const maps: OlMap[] = data?.maps?.length ? data.maps : map ? [map] : [];
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const deckLayer = data?.deckLayer;
|
const deckLayer = data?.deckLayer;
|
||||||
|
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
|
||||||
const isContourLayerAvailable = data?.isContourLayerAvailable;
|
const isContourLayerAvailable = data?.isContourLayerAvailable;
|
||||||
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
|
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
|
||||||
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
|
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
|
||||||
@@ -117,8 +120,16 @@ const LayerControl: React.FC = () => {
|
|||||||
|
|
||||||
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
|
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
|
||||||
if (item.type === "ol") {
|
if (item.type === "ol") {
|
||||||
item.layerRef.setVisible(checked);
|
maps.forEach((targetMap) => {
|
||||||
} else if (item.type === "deck" && deckLayer) {
|
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") {
|
if (item.id === "junctionContourLayer") {
|
||||||
setShowContourLayer && setShowContourLayer(checked);
|
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 {
|
interface BaseProperty {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -21,13 +26,24 @@ interface PropertyPanelProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
properties?: PropertyItem[];
|
properties?: PropertyItem[];
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||||
id,
|
id,
|
||||||
type = "未知类型",
|
type = "未知类型",
|
||||||
properties = [],
|
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) => {
|
const formatValue = (property: BaseProperty) => {
|
||||||
if (property.formatter) {
|
if (property.formatter) {
|
||||||
return property.formatter(property.value);
|
return property.formatter(property.value);
|
||||||
@@ -50,9 +66,17 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100 transition-all duration-300 ">
|
<Draggable
|
||||||
|
nodeRef={draggableRef}
|
||||||
|
handle=".drag-handle"
|
||||||
|
cancel=".panel-close-button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={draggableRef}
|
||||||
|
className="absolute top-4 right-4 bg-white shadow-2xl rounded-xl overflow-hidden w-96 max-h-[850px] flex flex-col backdrop-blur-sm z-1300 opacity-95 hover:opacity-100"
|
||||||
|
>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white">
|
<div className="drag-handle flex justify-between items-center px-5 py-4 bg-[#257DD4] text-white cursor-move select-none">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -69,6 +93,17 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
<h3 className="text-lg font-semibold">属性面板</h3>
|
<h3 className="text-lg font-semibold">属性面板</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<Tooltip title="关闭">
|
||||||
|
<IconButton
|
||||||
|
className="panel-close-button"
|
||||||
|
size="small"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭属性面板"
|
||||||
|
sx={headerActionSx}
|
||||||
|
>
|
||||||
|
<Close fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
@@ -198,6 +233,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部统计区域 */}
|
{/* 底部统计区域 */}
|
||||||
@@ -228,6 +264,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Draggable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { FlatStyleLike } from "ol/style/flat";
|
|||||||
import { calculateClassification } from "@utils/breaks_classification";
|
import { calculateClassification } from "@utils/breaks_classification";
|
||||||
import { parseColor } from "@utils/parseColor";
|
import { parseColor } from "@utils/parseColor";
|
||||||
import { VectorTile } from "ol";
|
import { VectorTile } from "ol";
|
||||||
|
import type { Map as OlMap } from "ol";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
|
|
||||||
@@ -182,6 +183,13 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
const data = useData();
|
const data = useData();
|
||||||
const currentJunctionCalData = data?.currentJunctionCalData;
|
const currentJunctionCalData = data?.currentJunctionCalData;
|
||||||
const currentPipeCalData = data?.currentPipeCalData;
|
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 junctionText = data?.junctionText ?? "";
|
||||||
const pipeText = data?.pipeText ?? "";
|
const pipeText = data?.pipeText ?? "";
|
||||||
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
|
||||||
@@ -229,6 +237,45 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
customColors: [],
|
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 = (
|
const getDefaultCustomColors = (
|
||||||
segments: number,
|
segments: number,
|
||||||
existingColors: string[] = []
|
existingColors: string[] = []
|
||||||
@@ -613,13 +660,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const styleConfig = layerStyleConfig.styleConfig;
|
const styleConfig = layerStyleConfig.styleConfig;
|
||||||
const renderLayer = renderLayers.filter((layer) => {
|
const targetLayers = getRenderLayersById(layerStyleConfig.layerId);
|
||||||
return layer.get("value") === layerStyleConfig.layerId;
|
const renderLayer = targetLayers[0];
|
||||||
})[0];
|
|
||||||
if (!renderLayer || !styleConfig?.property) return;
|
if (!renderLayer || !styleConfig?.property) return;
|
||||||
const layerType: string = renderLayer?.get("type");
|
const layerType: string = renderLayer.get("type");
|
||||||
const source = renderLayer.getSource();
|
|
||||||
if (!source) return;
|
|
||||||
|
|
||||||
const breaksLength = breaks.length;
|
const breaksLength = breaks.length;
|
||||||
// 根据 breaks 计算每个分段的颜色,线条粗细
|
// 根据 breaks 计算每个分段的颜色,线条粗细
|
||||||
@@ -757,7 +801,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
dynamicStyle["circle-stroke-width"] = 2;
|
dynamicStyle["circle-stroke-width"] = 2;
|
||||||
}
|
}
|
||||||
// 应用样式到图层
|
// 应用样式到图层
|
||||||
renderLayer.setStyle(dynamicStyle);
|
targetLayers.forEach((targetLayer) => {
|
||||||
|
targetLayer.setStyle(dynamicStyle);
|
||||||
|
});
|
||||||
// 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性
|
// 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性
|
||||||
const layerId = renderLayer.get("value");
|
const layerId = renderLayer.get("value");
|
||||||
const initLayerStyleState = layerStyleStates.find(
|
const initLayerStyleState = layerStyleStates.find(
|
||||||
@@ -844,10 +890,12 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
if (!selectedRenderLayer) return;
|
if (!selectedRenderLayer) return;
|
||||||
// 重置 WebGL 图层样式
|
// 重置 WebGL 图层样式
|
||||||
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
|
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) {
|
if (layerId !== undefined) {
|
||||||
setLayerStyleStates((prev) =>
|
setLayerStyleStates((prev) =>
|
||||||
prev.filter((state) => state.layerId !== layerId)
|
prev.filter((state) => state.layerId !== layerId)
|
||||||
@@ -870,11 +918,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 更新当前 VectorTileSource 中的所有缓冲要素属性
|
// 更新当前 VectorTileSource 中的所有缓冲要素属性
|
||||||
const updateVectorTileSource = (property: string, data: any[]) => {
|
const updateVectorTileSource = (
|
||||||
if (!map) return;
|
targetMap: OlMap,
|
||||||
const vectorTileSources = map
|
layerId: string,
|
||||||
|
property: string,
|
||||||
|
data: any[]
|
||||||
|
) => {
|
||||||
|
const vectorTileSources = targetMap
|
||||||
.getAllLayers()
|
.getAllLayers()
|
||||||
.filter((layer) => layer instanceof WebGLVectorTileLayer)
|
.filter((layer) => layer.get("value") === layerId)
|
||||||
.map((layer) => layer.getSource() as VectorTileSource)
|
.map((layer) => layer.getSource() as VectorTileSource)
|
||||||
.filter((source) => source);
|
.filter((source) => source);
|
||||||
|
|
||||||
@@ -911,16 +963,16 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
};
|
};
|
||||||
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
|
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
|
||||||
const tileLoadListenersRef = useRef<
|
const tileLoadListenersRef = useRef<
|
||||||
Map<VectorTileSource, (event: any) => void>
|
Map<string, { source: VectorTileSource; listener: (event: any) => void }>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
|
||||||
const attachVectorTileSourceLoadedEvent = (
|
const attachVectorTileSourceLoadedEvent = (
|
||||||
|
targetMap: OlMap,
|
||||||
layerId: string,
|
layerId: string,
|
||||||
property: string,
|
property: string,
|
||||||
data: any[]
|
data: any[]
|
||||||
) => {
|
) => {
|
||||||
if (!map) return;
|
const vectorTileSource = targetMap
|
||||||
const vectorTileSource = map
|
|
||||||
.getAllLayers()
|
.getAllLayers()
|
||||||
.filter((layer) => layer.get("value") === layerId)
|
.filter((layer) => layer.get("value") === layerId)
|
||||||
.map((layer) => layer.getSource() as VectorTileSource)
|
.map((layer) => layer.getSource() as VectorTileSource)
|
||||||
@@ -956,24 +1008,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listenerKey = getMapKey(targetMap, layerId);
|
||||||
vectorTileSource.on("tileloadend", listener);
|
vectorTileSource.on("tileloadend", listener);
|
||||||
tileLoadListenersRef.current.set(vectorTileSource, listener);
|
tileLoadListenersRef.current.set(listenerKey, {
|
||||||
|
source: vectorTileSource,
|
||||||
|
listener,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
// 新增函数:取消对应 layerId 已添加的 on 事件
|
// 新增函数:取消对应 layerId 已添加的 on 事件
|
||||||
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
|
const removeVectorTileSourceLoadedEvent = useCallback(
|
||||||
if (!map) return;
|
(targetMap: OlMap, layerId: string) => {
|
||||||
const vectorTileSource = map
|
const listenerKey = getMapKey(targetMap, layerId);
|
||||||
.getAllLayers()
|
const listenerState = tileLoadListenersRef.current.get(listenerKey);
|
||||||
.filter((layer) => layer.get("value") === layerId)
|
if (listenerState) {
|
||||||
.map((layer) => layer.getSource() as VectorTileSource)
|
listenerState.source.un("tileloadend", listenerState.listener);
|
||||||
.filter((source) => source)[0];
|
tileLoadListenersRef.current.delete(listenerKey);
|
||||||
if (!vectorTileSource) return;
|
|
||||||
const listener = tileLoadListenersRef.current.get(vectorTileSource);
|
|
||||||
if (listener) {
|
|
||||||
vectorTileSource.un("tileloadend", listener);
|
|
||||||
tileLoadListenersRef.current.delete(vectorTileSource);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[getMapKey]
|
||||||
|
);
|
||||||
|
|
||||||
// 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发
|
// 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -998,20 +1051,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isElevation) {
|
if (isElevation) {
|
||||||
removeVectorTileSourceLoadedEvent("junctions");
|
activeMaps.forEach((targetMap) => {
|
||||||
|
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentJunctionCalData) return;
|
activeMaps.forEach((targetMap) => {
|
||||||
// 更新现有的 VectorTileSource
|
const targetData = getDataForMap(targetMap, "junctions");
|
||||||
updateVectorTileSource(junctionText, currentJunctionCalData);
|
if (!targetData || targetData.length === 0) return;
|
||||||
// 移除旧的监听器,并添加新的监听器
|
updateVectorTileSource(targetMap, "junctions", junctionText, targetData);
|
||||||
removeVectorTileSourceLoadedEvent("junctions");
|
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
|
||||||
attachVectorTileSourceLoadedEvent(
|
attachVectorTileSourceLoadedEvent(
|
||||||
|
targetMap,
|
||||||
"junctions",
|
"junctions",
|
||||||
junctionText,
|
junctionText,
|
||||||
currentJunctionCalData
|
targetData
|
||||||
);
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const updatePipeStyle = () => {
|
const updatePipeStyle = () => {
|
||||||
const pipeStyleConfigState = layerStyleStates.find(
|
const pipeStyleConfigState = layerStyleStates.find(
|
||||||
@@ -1023,16 +1080,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig);
|
applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig);
|
||||||
|
|
||||||
if (isDiameter) {
|
if (isDiameter) {
|
||||||
removeVectorTileSourceLoadedEvent("pipes");
|
activeMaps.forEach((targetMap) => {
|
||||||
|
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentPipeCalData) return;
|
activeMaps.forEach((targetMap) => {
|
||||||
// 更新现有的 VectorTileSource
|
const targetData = getDataForMap(targetMap, "pipes");
|
||||||
updateVectorTileSource(pipeText, currentPipeCalData);
|
if (!targetData || targetData.length === 0) return;
|
||||||
// 移除旧的监听器,并添加新的监听器
|
updateVectorTileSource(targetMap, "pipes", pipeText, targetData);
|
||||||
removeVectorTileSourceLoadedEvent("pipes");
|
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
|
||||||
attachVectorTileSourceLoadedEvent("pipes", pipeText, currentPipeCalData);
|
attachVectorTileSourceLoadedEvent(
|
||||||
|
targetMap,
|
||||||
|
"pipes",
|
||||||
|
pipeText,
|
||||||
|
targetData
|
||||||
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
if (isUserTrigger) {
|
if (isUserTrigger) {
|
||||||
if (selectedRenderLayer?.get("value") === "junctions") {
|
if (selectedRenderLayer?.get("value") === "junctions") {
|
||||||
@@ -1060,10 +1125,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
updatePipeStyle();
|
updatePipeStyle();
|
||||||
}
|
}
|
||||||
if (!applyJunctionStyle) {
|
if (!applyJunctionStyle) {
|
||||||
removeVectorTileSourceLoadedEvent("junctions");
|
activeMaps.forEach((targetMap) => {
|
||||||
|
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!applyPipeStyle) {
|
if (!applyPipeStyle) {
|
||||||
removeVectorTileSourceLoadedEvent("pipes");
|
activeMaps.forEach((targetMap) => {
|
||||||
|
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// This effect is intentionally driven by explicit style triggers and data snapshots.
|
// This effect is intentionally driven by explicit style triggers and data snapshots.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -1073,8 +1142,20 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
|
|||||||
applyPipeStyle,
|
applyPipeStyle,
|
||||||
currentJunctionCalData,
|
currentJunctionCalData,
|
||||||
currentPipeCalData,
|
currentPipeCalData,
|
||||||
|
compareJunctionCalData,
|
||||||
|
comparePipeCalData,
|
||||||
|
activeMaps,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
activeMaps.forEach((targetMap) => {
|
||||||
|
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
|
||||||
|
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [activeMaps, removeVectorTileSourceLoadedEvent]);
|
||||||
|
|
||||||
// 获取地图中的矢量图层,用于选择图层选项
|
// 获取地图中的矢量图层,用于选择图层选项
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
|
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
|
||||||
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
|
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
|
||||||
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
|
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
|
||||||
|
const setCompareJunctionCalData = data?.setCompareJunctionCalData;
|
||||||
|
const setComparePipeCalData = data?.setComparePipeCalData;
|
||||||
|
const isCompareMode = data?.isCompareMode ?? false;
|
||||||
const junctionText = data?.junctionText ?? "";
|
const junctionText = data?.junctionText ?? "";
|
||||||
const pipeText = data?.pipeText ?? "";
|
const pipeText = data?.pipeText ?? "";
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
@@ -94,100 +97,209 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
// 添加防抖引用
|
// 添加防抖引用
|
||||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
|
const updateDataStates = useCallback(
|
||||||
if (setCurrentJunctionCalData) {
|
(
|
||||||
setCurrentJunctionCalData(nodeResults);
|
nodeResults: any[],
|
||||||
} else {
|
linkResults: any[],
|
||||||
console.log("setCurrentJunctionCalData is undefined");
|
target: "primary" | "compare" = "primary"
|
||||||
}
|
|
||||||
if (setCurrentPipeCalData) {
|
|
||||||
setCurrentPipeCalData(linkResults);
|
|
||||||
} else {
|
|
||||||
console.log("setCurrentPipeCalData is undefined");
|
|
||||||
}
|
|
||||||
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
|
|
||||||
|
|
||||||
const fetchFrameData = useCallback(async (
|
|
||||||
queryTime: Date,
|
|
||||||
junctionProperties: string,
|
|
||||||
pipeProperties: string,
|
|
||||||
schemeName: string,
|
|
||||||
schemeType: string,
|
|
||||||
) => {
|
) => {
|
||||||
|
const setNodeData =
|
||||||
|
target === "compare"
|
||||||
|
? setCompareJunctionCalData
|
||||||
|
: setCurrentJunctionCalData;
|
||||||
|
const setLinkData =
|
||||||
|
target === "compare" ? setComparePipeCalData : setCurrentPipeCalData;
|
||||||
|
|
||||||
|
setNodeData?.(nodeResults);
|
||||||
|
setLinkData?.(linkResults);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setCompareJunctionCalData,
|
||||||
|
setComparePipeCalData,
|
||||||
|
setCurrentJunctionCalData,
|
||||||
|
setCurrentPipeCalData,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildCacheKey = useCallback(
|
||||||
|
(
|
||||||
|
queryTime: string,
|
||||||
|
property: string,
|
||||||
|
sourceType: "scheme" | "realtime",
|
||||||
|
resultType: "node" | "link",
|
||||||
|
targetSchemeName: string,
|
||||||
|
targetSchemeType: string
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
queryTime,
|
||||||
|
sourceType,
|
||||||
|
resultType,
|
||||||
|
property,
|
||||||
|
targetSchemeName || "default",
|
||||||
|
targetSchemeType || "default",
|
||||||
|
].join("::"),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchDataBySource = useCallback(
|
||||||
|
async ({
|
||||||
|
queryTime,
|
||||||
|
junctionProperties,
|
||||||
|
pipeProperties,
|
||||||
|
sourceType,
|
||||||
|
target,
|
||||||
|
schemeName,
|
||||||
|
schemeType,
|
||||||
|
}: {
|
||||||
|
queryTime: Date;
|
||||||
|
junctionProperties: string;
|
||||||
|
pipeProperties: string;
|
||||||
|
sourceType: "scheme" | "realtime";
|
||||||
|
target: "primary" | "compare";
|
||||||
|
schemeName?: string;
|
||||||
|
schemeType?: string;
|
||||||
|
}) => {
|
||||||
const query_time = queryTime.toISOString();
|
const query_time = queryTime.toISOString();
|
||||||
let nodeRecords: any = { results: [] };
|
let nodeRecords: any = { results: [] };
|
||||||
let linkRecords: any = { results: [] };
|
let linkRecords: any = { results: [] };
|
||||||
const requests: Promise<Response>[] = [];
|
const requests: Promise<Response>[] = [];
|
||||||
let nodePromise: Promise<any> | null = null;
|
let nodePromise: Promise<Response> | null = null;
|
||||||
let linkPromise: Promise<any> | null = null;
|
let linkPromise: Promise<Response> | null = null;
|
||||||
// 检查node缓存
|
|
||||||
if (junctionProperties !== "" && junctionProperties !== "elevation") {
|
if (junctionProperties !== "" && junctionProperties !== "elevation") {
|
||||||
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`;
|
const nodeCacheKey = buildCacheKey(
|
||||||
|
query_time,
|
||||||
|
junctionProperties,
|
||||||
|
sourceType,
|
||||||
|
"node",
|
||||||
|
schemeName || "",
|
||||||
|
schemeType || ""
|
||||||
|
);
|
||||||
if (nodeCacheRef.current.has(nodeCacheKey)) {
|
if (nodeCacheRef.current.has(nodeCacheKey)) {
|
||||||
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
disableDateSelection && schemeName
|
nodePromise =
|
||||||
? (nodePromise = apiFetch(
|
sourceType === "scheme" && schemeName
|
||||||
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${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}`,
|
`${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(
|
: 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}`
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
|
);
|
||||||
));
|
|
||||||
requests.push(nodePromise);
|
requests.push(nodePromise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 处理特殊属性名称
|
|
||||||
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
|
|
||||||
|
|
||||||
// 检查link缓存
|
const normalizedPipeProperties =
|
||||||
if (pipeProperties !== "" && pipeProperties !== "diameter") {
|
pipeProperties === "unit_headloss" ? "headloss" : pipeProperties;
|
||||||
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
|
|
||||||
|
if (normalizedPipeProperties !== "" && normalizedPipeProperties !== "diameter") {
|
||||||
|
const linkCacheKey = buildCacheKey(
|
||||||
|
query_time,
|
||||||
|
normalizedPipeProperties,
|
||||||
|
sourceType,
|
||||||
|
"link",
|
||||||
|
schemeName || "",
|
||||||
|
schemeType || ""
|
||||||
|
);
|
||||||
if (linkCacheRef.current.has(linkCacheKey)) {
|
if (linkCacheRef.current.has(linkCacheKey)) {
|
||||||
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
|
||||||
} else {
|
} else {
|
||||||
disableDateSelection && schemeName
|
linkPromise =
|
||||||
? (linkPromise = apiFetch(
|
sourceType === "scheme" && schemeName
|
||||||
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${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=${pipeProperties}`,
|
`${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}`
|
||||||
))
|
)
|
||||||
: (linkPromise = apiFetch(
|
: 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=${normalizedPipeProperties}`
|
||||||
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
|
);
|
||||||
));
|
|
||||||
requests.push(linkPromise);
|
requests.push(linkPromise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待所有有效请求
|
|
||||||
const responses = await Promise.all(requests);
|
const responses = await Promise.all(requests);
|
||||||
|
|
||||||
if (nodePromise) {
|
if (nodePromise) {
|
||||||
const nodeResponse = responses.shift()!;
|
const nodeResponse = responses.shift()!;
|
||||||
if (!nodeResponse.ok)
|
if (!nodeResponse.ok) {
|
||||||
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
|
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
|
||||||
|
}
|
||||||
nodeRecords = await nodeResponse.json();
|
nodeRecords = await nodeResponse.json();
|
||||||
// 缓存数据(修复键以包含 schemeName)
|
|
||||||
nodeCacheRef.current.set(
|
nodeCacheRef.current.set(
|
||||||
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`,
|
buildCacheKey(
|
||||||
nodeRecords || [],
|
query_time,
|
||||||
|
junctionProperties,
|
||||||
|
sourceType,
|
||||||
|
"node",
|
||||||
|
schemeName || "",
|
||||||
|
schemeType || ""
|
||||||
|
),
|
||||||
|
nodeRecords || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linkPromise) {
|
if (linkPromise) {
|
||||||
const linkResponse = responses.shift()!;
|
const linkResponse = responses.shift()!;
|
||||||
if (!linkResponse.ok)
|
if (!linkResponse.ok) {
|
||||||
throw new Error(`Link fetch failed: ${linkResponse.status}`);
|
throw new Error(`Link fetch failed: ${linkResponse.status}`);
|
||||||
|
}
|
||||||
linkRecords = await linkResponse.json();
|
linkRecords = await linkResponse.json();
|
||||||
// 缓存数据(修复键以包含 schemeName)
|
|
||||||
linkCacheRef.current.set(
|
linkCacheRef.current.set(
|
||||||
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`,
|
buildCacheKey(
|
||||||
linkRecords || [],
|
query_time,
|
||||||
|
normalizedPipeProperties,
|
||||||
|
sourceType,
|
||||||
|
"link",
|
||||||
|
schemeName || "",
|
||||||
|
schemeType || ""
|
||||||
|
),
|
||||||
|
linkRecords || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 更新状态
|
|
||||||
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
|
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
|
||||||
}, [disableDateSelection, updateDataStates]);
|
},
|
||||||
|
[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分钟一个刻度)
|
// 时间刻度数组 (每5分钟一个刻度)
|
||||||
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
|
||||||
@@ -453,9 +565,9 @@ const Timeline: React.FC<TimelineProps> = ({
|
|||||||
if (!cacheRef.current) return;
|
if (!cacheRef.current) return;
|
||||||
const cacheKeys = Array.from(cacheRef.current.keys());
|
const cacheKeys = Array.from(cacheRef.current.keys());
|
||||||
cacheKeys.forEach((key) => {
|
cacheKeys.forEach((key) => {
|
||||||
const keyParts = key.split("_");
|
const cacheTimeKey = key.split("::")[0];
|
||||||
const cacheDate = keyParts[0].split("T")[0];
|
const cacheDate = cacheTimeKey.split("T")[0];
|
||||||
const cacheTimeStr = keyParts[0].split("T")[1];
|
const cacheTimeStr = cacheTimeKey.split("T")[1];
|
||||||
|
|
||||||
if (cacheDate === dateStr && cacheTimeStr) {
|
if (cacheDate === dateStr && cacheTimeStr) {
|
||||||
const [hours, minutes] = cacheTimeStr.split(":");
|
const [hours, minutes] = cacheTimeStr.split(":");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
|||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
|
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
|
||||||
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
||||||
|
import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined";
|
||||||
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
||||||
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
||||||
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
||||||
@@ -34,12 +35,14 @@ interface ToolbarProps {
|
|||||||
queryType?: string; // 可选的查询类型参数
|
queryType?: string; // 可选的查询类型参数
|
||||||
schemeType?: string; // 可选的方案类型参数
|
schemeType?: string; // 可选的方案类型参数
|
||||||
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
|
||||||
|
enableCompare?: boolean;
|
||||||
}
|
}
|
||||||
const Toolbar: React.FC<ToolbarProps> = ({
|
const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
hiddenButtons,
|
hiddenButtons,
|
||||||
queryType,
|
queryType,
|
||||||
schemeType,
|
schemeType,
|
||||||
HistoryPanel,
|
HistoryPanel,
|
||||||
|
enableCompare = false,
|
||||||
}) => {
|
}) => {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const data = useData();
|
const data = useData();
|
||||||
@@ -55,6 +58,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const currentTime = data?.currentTime;
|
const currentTime = data?.currentTime;
|
||||||
const selectedDate = data?.selectedDate;
|
const selectedDate = data?.selectedDate;
|
||||||
const schemeName = data?.schemeName;
|
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)
|
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
|
||||||
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
|
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
|
||||||
@@ -402,15 +416,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
deactivateTool(tool);
|
deactivateTool(tool);
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== tool));
|
setActiveTools((prev) => prev.filter((t) => t !== tool));
|
||||||
} else {
|
} else {
|
||||||
// 如果当前工具未激活,先关闭所有其他工具,然后激活当前工具
|
// 如果当前工具未激活,保留其他已打开工具,仅新增当前工具
|
||||||
// 关闭所有面板(但保持样式编辑器状态)
|
setActiveTools((prev) => [...prev, tool]);
|
||||||
closeAllPanelsExceptStyle();
|
|
||||||
|
|
||||||
// 取消激活所有非样式工具
|
|
||||||
setActiveTools((prev) => {
|
|
||||||
const styleActive = prev.includes("style");
|
|
||||||
return styleActive ? ["style", tool] : [tool];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 激活当前工具并打开对应面板
|
// 激活当前工具并打开对应面板
|
||||||
activateTool(tool);
|
activateTool(tool);
|
||||||
@@ -422,14 +429,18 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "info":
|
case "info":
|
||||||
setShowPropertyPanel(false);
|
setShowPropertyPanel(false);
|
||||||
|
if (!activeTools.includes("history")) {
|
||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "draw":
|
case "draw":
|
||||||
setShowDrawPanel(false);
|
setShowDrawPanel(false);
|
||||||
break;
|
break;
|
||||||
case "history":
|
case "history":
|
||||||
setShowHistoryPanel(false);
|
setShowHistoryPanel(false);
|
||||||
|
if (!activeTools.includes("info")) {
|
||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
|
}
|
||||||
setChatPanelFeatureInfos(null);
|
setChatPanelFeatureInfos(null);
|
||||||
setChatPanelTimeRange(null);
|
setChatPanelTimeRange(null);
|
||||||
break;
|
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<
|
const [computedProperties, setComputedProperties] = useState<
|
||||||
Record<string, any>
|
Record<string, any>
|
||||||
>({});
|
>({});
|
||||||
@@ -866,8 +867,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
onClick={() => handleToolClick("style")}
|
onClick={() => handleToolClick("style")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{enableCompare && (
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<CompareArrowsOutlinedIcon />}
|
||||||
|
name={isCompareMode ? "关闭对比" : "双屏对比"}
|
||||||
|
isActive={isCompareMode}
|
||||||
|
onClick={() => toggleCompareMode?.()}
|
||||||
|
disabled={!canToggleCompare}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
|
{showPropertyPanel && (
|
||||||
|
<PropertyPanel
|
||||||
|
{...getFeatureProperties()}
|
||||||
|
onClose={() => {
|
||||||
|
deactivateTool("info");
|
||||||
|
setActiveTools((prev) => prev.filter((t) => t !== "info"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showDrawPanel && map && <DrawPanel />}
|
{showDrawPanel && map && <DrawPanel />}
|
||||||
<div style={{ display: showStyleEditor ? "block" : "none" }}>
|
<div style={{ display: showStyleEditor ? "block" : "none" }}>
|
||||||
<StyleEditorPanel
|
<StyleEditorPanel
|
||||||
@@ -882,6 +900,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
visible={showHistoryPanel}
|
visible={showHistoryPanel}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
onClose={() => {
|
||||||
|
deactivateTool("history");
|
||||||
|
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : HistoryPanel ? (
|
) : HistoryPanel ? (
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
@@ -926,6 +948,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
onClose={() => {
|
||||||
|
deactivateTool("history");
|
||||||
|
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HistoryDataPanel
|
<HistoryDataPanel
|
||||||
@@ -970,6 +996,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
onClose={() => {
|
||||||
|
deactivateTool("history");
|
||||||
|
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Map as OlMap, VectorTile } from "ol";
|
import { Map as OlMap, VectorTile } from "ol";
|
||||||
@@ -49,6 +50,13 @@ interface DataContextType {
|
|||||||
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
currentPipeCalData?: any[]; // 当前计算结果
|
currentPipeCalData?: any[]; // 当前计算结果
|
||||||
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<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; // 是否显示节点文本
|
showJunctionText?: boolean; // 是否显示节点文本
|
||||||
showPipeText?: boolean; // 是否显示管道文本
|
showPipeText?: boolean; // 是否显示管道文本
|
||||||
showJunctionId?: boolean; // 是否显示节点ID
|
showJunctionId?: boolean; // 是否显示节点ID
|
||||||
@@ -69,6 +77,10 @@ interface DataContextType {
|
|||||||
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
|
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setContours?: React.Dispatch<React.SetStateAction<any[]>>;
|
setContours?: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
deckLayer?: DeckLayer;
|
deckLayer?: DeckLayer;
|
||||||
|
compareDeckLayer?: DeckLayer;
|
||||||
|
deckLayers?: DeckLayer[];
|
||||||
|
compareMap?: OlMap;
|
||||||
|
maps?: OlMap[];
|
||||||
diameterRange?: [number, number];
|
diameterRange?: [number, number];
|
||||||
elevationRange?: [number, number];
|
elevationRange?: [number, number];
|
||||||
}
|
}
|
||||||
@@ -128,12 +140,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | 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 deckLayerRef = useRef<DeckLayer | null>(null);
|
||||||
|
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
|
||||||
const isDisposingRef = useRef(false);
|
const isDisposingRef = useRef(false);
|
||||||
|
const isCompareDisposingRef = useRef(false);
|
||||||
const pendingTimeoutsRef = useRef<number[]>([]);
|
const pendingTimeoutsRef = useRef<number[]>([]);
|
||||||
|
|
||||||
const [map, setMap] = useState<OlMap>();
|
const [map, setMap] = useState<OlMap>();
|
||||||
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
||||||
|
const [compareMap, setCompareMap] = useState<OlMap>();
|
||||||
|
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
|
||||||
// currentCalData 用于存储当前计算结果
|
// currentCalData 用于存储当前计算结果
|
||||||
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
|
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
|
||||||
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
|
// 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 [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]);
|
||||||
|
const [compareJunctionCalData, setCompareJunctionCalData] = useState<any[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
|
||||||
|
const [isCompareMode, setCompareMode] = useState(false);
|
||||||
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
|
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
|
||||||
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
|
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
|
||||||
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
||||||
@@ -201,6 +224,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
});
|
});
|
||||||
}, [pipeData, currentPipeCalData, pipeText]);
|
}, [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<
|
const [diameterRange, setDiameterRange] = useState<
|
||||||
[number, number] | undefined
|
[number, number] | undefined
|
||||||
>();
|
>();
|
||||||
@@ -208,6 +262,24 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
[number, number] | undefined
|
[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 setJunctionData = (newData: any[]) => {
|
||||||
const uniqueNewData = newData.filter((item) => {
|
const uniqueNewData = newData.filter((item) => {
|
||||||
if (!item || !item.id) return false;
|
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.
|
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current) return;
|
if (!mapRef.current) return;
|
||||||
@@ -857,19 +1101,142 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
}, [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 图层
|
// 当数据变化时,更新 deck.gl 图层
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDisposingRef.current) return;
|
const syncDeckOverlay = (
|
||||||
const deckLayer = deckLayerRef.current;
|
targetDeckLayer: DeckLayer | null,
|
||||||
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
|
targetJunctionData: any[],
|
||||||
if (deckLayer.isDisposedLayer()) return;
|
targetPipeData: any[],
|
||||||
if (!mergedJunctionData.length) return;
|
disposing: boolean,
|
||||||
if (!mergedPipeData.length) return;
|
) => {
|
||||||
const junctionTextLayer = new TextLayer({
|
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shouldShowJunctionText =
|
||||||
|
(showJunctionTextLayer || showJunctionId) &&
|
||||||
|
currentZoom >= 15 &&
|
||||||
|
currentZoom <= 24 &&
|
||||||
|
targetJunctionData.length > 0;
|
||||||
|
const shouldShowPipeText =
|
||||||
|
(showPipeTextLayer || showPipeId) &&
|
||||||
|
currentZoom >= 15 &&
|
||||||
|
currentZoom <= 24 &&
|
||||||
|
targetPipeData.length > 0;
|
||||||
|
const shouldShowContour =
|
||||||
|
showContourLayer &&
|
||||||
|
currentZoom >= 11 &&
|
||||||
|
currentZoom <= 24 &&
|
||||||
|
targetJunctionData.length > 0;
|
||||||
|
|
||||||
|
if (!shouldShowJunctionText) {
|
||||||
|
targetDeckLayer.removeDeckLayer("junctionTextLayer");
|
||||||
|
}
|
||||||
|
if (!shouldShowPipeText) {
|
||||||
|
targetDeckLayer.removeDeckLayer("pipeTextLayer");
|
||||||
|
}
|
||||||
|
if (!shouldShowContour) {
|
||||||
|
targetDeckLayer.removeDeckLayer("junctionContourLayer");
|
||||||
|
}
|
||||||
|
if (!shouldShowJunctionText && !shouldShowPipeText && !shouldShowContour) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const junctionTextLayer = shouldShowJunctionText
|
||||||
|
? new TextLayer({
|
||||||
id: "junctionTextLayer",
|
id: "junctionTextLayer",
|
||||||
name: "节点文字",
|
name: "节点文字",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
data: mergedJunctionData,
|
data: targetJunctionData,
|
||||||
getPosition: (d: any) => d.position,
|
getPosition: (d: any) => d.position,
|
||||||
fontFamily: "Monaco, monospace",
|
fontFamily: "Monaco, monospace",
|
||||||
getText: (d: any) => {
|
getText: (d: any) => {
|
||||||
@@ -884,15 +1251,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
},
|
},
|
||||||
getSize: 14,
|
getSize: 14,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见
|
getColor: [33, 37, 41],
|
||||||
getAngle: 0,
|
getAngle: 0,
|
||||||
getTextAnchor: "middle",
|
getTextAnchor: "middle",
|
||||||
getAlignmentBaseline: "center",
|
getAlignmentBaseline: "center",
|
||||||
getPixelOffset: [0, -10],
|
getPixelOffset: [0, -10],
|
||||||
visible:
|
visible: true,
|
||||||
(showJunctionTextLayer || showJunctionId) &&
|
|
||||||
currentZoom >= 15 &&
|
|
||||||
currentZoom <= 24,
|
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getText: [showJunctionId, showJunctionTextLayer, junctionText],
|
getText: [showJunctionId, showJunctionTextLayer, junctionText],
|
||||||
},
|
},
|
||||||
@@ -906,15 +1270,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
fontSize: 64,
|
fontSize: 64,
|
||||||
buffer: 6,
|
buffer: 6,
|
||||||
},
|
},
|
||||||
// outlineWidth: 3,
|
})
|
||||||
// outlineColor: [255, 255, 255, 220],
|
: null;
|
||||||
});
|
|
||||||
|
|
||||||
const pipeTextLayer = new TextLayer({
|
const pipeTextLayer = shouldShowPipeText
|
||||||
|
? new TextLayer({
|
||||||
id: "pipeTextLayer",
|
id: "pipeTextLayer",
|
||||||
name: "管道文字",
|
name: "管道文字",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
data: mergedPipeData,
|
data: targetPipeData,
|
||||||
getPosition: (d: any) => d.position,
|
getPosition: (d: any) => d.position,
|
||||||
fontFamily: "Monaco, monospace",
|
fontFamily: "Monaco, monospace",
|
||||||
getText: (d: any) => {
|
getText: (d: any) => {
|
||||||
@@ -936,15 +1300,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
},
|
},
|
||||||
getSize: 14,
|
getSize: 14,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
getColor: [33, 37, 41], // 深灰色
|
getColor: [33, 37, 41],
|
||||||
getAngle: (d: any) => d.angle || 0,
|
getAngle: (d: any) => d.angle || 0,
|
||||||
getPixelOffset: [0, -8],
|
getPixelOffset: [0, -8],
|
||||||
getTextAnchor: "middle",
|
getTextAnchor: "middle",
|
||||||
getAlignmentBaseline: "bottom",
|
getAlignmentBaseline: "bottom",
|
||||||
visible:
|
visible: true,
|
||||||
(showPipeTextLayer || showPipeId) &&
|
|
||||||
currentZoom >= 15 &&
|
|
||||||
currentZoom <= 24,
|
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getText: [showPipeId, showPipeTextLayer, pipeText],
|
getText: [showPipeId, showPipeTextLayer, pipeText],
|
||||||
},
|
},
|
||||||
@@ -958,14 +1319,14 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
fontSize: 64,
|
fontSize: 64,
|
||||||
buffer: 6,
|
buffer: 6,
|
||||||
},
|
},
|
||||||
// outlineWidth: 3,
|
})
|
||||||
// outlineColor: [255, 255, 255, 220],
|
: null;
|
||||||
});
|
|
||||||
|
|
||||||
const contourLayer = new ContourLayer({
|
const contourLayer = shouldShowContour
|
||||||
|
? new ContourLayer({
|
||||||
id: "junctionContourLayer",
|
id: "junctionContourLayer",
|
||||||
name: "等值线",
|
name: "等值线",
|
||||||
data: mergedJunctionData,
|
data: targetJunctionData,
|
||||||
aggregation: "MEAN",
|
aggregation: "MEAN",
|
||||||
cellSize: 600,
|
cellSize: 600,
|
||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
@@ -974,31 +1335,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
getWeight: (d: any) =>
|
getWeight: (d: any) =>
|
||||||
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
|
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24,
|
visible: true,
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
// 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算
|
getWeight: [targetJunctionData, junctionText],
|
||||||
getWeight: [mergedJunctionData, junctionText],
|
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
if (deckLayer.getDeckLayerById("junctionTextLayer")) {
|
: null;
|
||||||
// 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法
|
|
||||||
deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) {
|
||||||
} else {
|
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
||||||
deckLayer.addDeckLayer(junctionTextLayer);
|
} else if (junctionTextLayer) {
|
||||||
|
targetDeckLayer.addDeckLayer(junctionTextLayer);
|
||||||
}
|
}
|
||||||
if (deckLayer.getDeckLayerById("pipeTextLayer")) {
|
if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) {
|
||||||
deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
|
targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
|
||||||
} else {
|
} else if (pipeTextLayer) {
|
||||||
deckLayer.addDeckLayer(pipeTextLayer);
|
targetDeckLayer.addDeckLayer(pipeTextLayer);
|
||||||
}
|
}
|
||||||
if (deckLayer.getDeckLayerById("junctionContourLayer")) {
|
if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) {
|
||||||
deckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
|
targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
|
||||||
} else {
|
} else if (contourLayer) {
|
||||||
deckLayer.addDeckLayer(contourLayer);
|
targetDeckLayer.addDeckLayer(contourLayer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncDeckOverlay(
|
||||||
|
deckLayerRef.current,
|
||||||
|
mergedJunctionData,
|
||||||
|
mergedPipeData,
|
||||||
|
isDisposingRef.current,
|
||||||
|
);
|
||||||
|
if (isCompareMode) {
|
||||||
|
syncDeckOverlay(
|
||||||
|
compareDeckLayerRef.current,
|
||||||
|
mergedCompareJunctionData,
|
||||||
|
mergedComparePipeData,
|
||||||
|
isCompareDisposingRef.current,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
mergedJunctionData,
|
mergedJunctionData,
|
||||||
mergedPipeData,
|
mergedPipeData,
|
||||||
|
mergedCompareJunctionData,
|
||||||
|
mergedComparePipeData,
|
||||||
|
isCompareMode,
|
||||||
junctionText,
|
junctionText,
|
||||||
pipeText,
|
pipeText,
|
||||||
currentZoom,
|
currentZoom,
|
||||||
@@ -1012,57 +1392,69 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
|
|
||||||
// 控制流动动画开关
|
// 控制流动动画开关
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDisposingRef.current) return;
|
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
|
||||||
if (pipeText === "flow" && currentPipeCalData.length > 0) {
|
const shouldShowWaterflow =
|
||||||
flowAnimation.current = true;
|
isWaterflowLayerAvailable &&
|
||||||
} else {
|
showWaterflowLayer &&
|
||||||
flowAnimation.current = false;
|
flowAnimation.current &&
|
||||||
|
currentZoom >= 12 &&
|
||||||
|
currentZoom <= 24;
|
||||||
|
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
const syncWaterflowLayer = (
|
||||||
|
targetDeckLayer: DeckLayer | null,
|
||||||
|
targetPipeData: any[],
|
||||||
|
disposing: boolean,
|
||||||
|
) => {
|
||||||
|
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shouldShowWaterflow || targetPipeData.length === 0) {
|
||||||
|
targetDeckLayer.removeDeckLayer("waterflowLayer");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const deckLayer = deckLayerRef.current;
|
|
||||||
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
|
|
||||||
|
|
||||||
let animationFrameId: number; // 保存 requestAnimationFrame 的 ID
|
|
||||||
|
|
||||||
// 动画循环
|
|
||||||
const animate = () => {
|
|
||||||
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
|
|
||||||
// 动画总时长(秒)
|
|
||||||
const animationDuration = 10;
|
const animationDuration = 10;
|
||||||
const bufferTime = 2;
|
const bufferTime = 2;
|
||||||
const loopLength = animationDuration + bufferTime;
|
const loopLength = animationDuration + bufferTime;
|
||||||
const currentTime = (Date.now() / 1000) % loopLength;
|
const currentFrameTime = (Date.now() / 1000) % loopLength;
|
||||||
|
|
||||||
const waterflowLayer = new TripsLayer({
|
const waterflowLayer = new TripsLayer({
|
||||||
id: "waterflowLayer",
|
id: "waterflowLayer",
|
||||||
name: "水流",
|
name: "水流",
|
||||||
data: mergedPipeData,
|
data: targetPipeData,
|
||||||
getPath: (d) => d.path,
|
getPath: (d) => d.path,
|
||||||
getTimestamps: (d) => {
|
getTimestamps: (d) => d.timestamps,
|
||||||
return d.timestamps; // 这些应该是与 currentTime 匹配的数值
|
|
||||||
},
|
|
||||||
getColor: [0, 220, 255],
|
getColor: [0, 220, 255],
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
visible:
|
visible: true,
|
||||||
isWaterflowLayerAvailable &&
|
|
||||||
showWaterflowLayer &&
|
|
||||||
flowAnimation.current && // 保持动画标志作为可见性的一部分
|
|
||||||
currentZoom >= 12 &&
|
|
||||||
currentZoom <= 24,
|
|
||||||
widthMinPixels: 5,
|
widthMinPixels: 5,
|
||||||
jointRounded: true, // 拐角变圆
|
jointRounded: true,
|
||||||
// capRounded: true, // 端点变圆
|
trailLength: 2,
|
||||||
trailLength: 2, // 水流尾迹淡出时间
|
currentTime: currentFrameTime,
|
||||||
currentTime: currentTime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deckLayer.getDeckLayerById("waterflowLayer")) {
|
if (targetDeckLayer.getDeckLayerById("waterflowLayer")) {
|
||||||
deckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
|
targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
|
||||||
} else {
|
} else {
|
||||||
deckLayer.addDeckLayer(waterflowLayer);
|
targetDeckLayer.addDeckLayer(waterflowLayer);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 只有在需要动画时才请求下一帧,但图层已经添加到了 deckLayer 中
|
const animate = () => {
|
||||||
if (flowAnimation.current) {
|
syncWaterflowLayer(
|
||||||
|
deckLayerRef.current,
|
||||||
|
mergedPipeData,
|
||||||
|
isDisposingRef.current,
|
||||||
|
);
|
||||||
|
if (isCompareMode) {
|
||||||
|
syncWaterflowLayer(
|
||||||
|
compareDeckLayerRef.current,
|
||||||
|
mergedComparePipeData,
|
||||||
|
isCompareDisposingRef.current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shouldShowWaterflow) {
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1078,6 +1470,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
currentPipeCalData,
|
currentPipeCalData,
|
||||||
currentZoom,
|
currentZoom,
|
||||||
mergedPipeData,
|
mergedPipeData,
|
||||||
|
mergedComparePipeData,
|
||||||
|
isCompareMode,
|
||||||
pipeText,
|
pipeText,
|
||||||
isWaterflowLayerAvailable,
|
isWaterflowLayerAvailable,
|
||||||
showWaterflowLayer,
|
showWaterflowLayer,
|
||||||
@@ -1097,6 +1491,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
setCurrentJunctionCalData,
|
setCurrentJunctionCalData,
|
||||||
currentPipeCalData,
|
currentPipeCalData,
|
||||||
setCurrentPipeCalData,
|
setCurrentPipeCalData,
|
||||||
|
compareJunctionCalData,
|
||||||
|
setCompareJunctionCalData,
|
||||||
|
comparePipeCalData,
|
||||||
|
setComparePipeCalData,
|
||||||
|
isCompareMode,
|
||||||
|
setCompareMode,
|
||||||
|
toggleCompareMode,
|
||||||
setShowJunctionTextLayer,
|
setShowJunctionTextLayer,
|
||||||
setShowPipeTextLayer,
|
setShowPipeTextLayer,
|
||||||
setShowJunctionId,
|
setShowJunctionId,
|
||||||
@@ -1115,17 +1516,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|||||||
pipeText,
|
pipeText,
|
||||||
setContours,
|
setContours,
|
||||||
deckLayer,
|
deckLayer,
|
||||||
|
compareDeckLayer,
|
||||||
|
deckLayers,
|
||||||
|
compareMap,
|
||||||
|
maps,
|
||||||
diameterRange,
|
diameterRange,
|
||||||
elevationRange,
|
elevationRange,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapContext.Provider value={map}>
|
<MapContext.Provider value={map}>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
|
<div className="flex w-full h-full">
|
||||||
|
<div
|
||||||
|
className={`relative h-full ${isCompareMode ? "w-1/2" : "w-full"}`}
|
||||||
|
>
|
||||||
<div ref={mapRef} className="w-full h-full"></div>
|
<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 />
|
<MapTools />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<canvas ref={canvasRef} />
|
|
||||||
</MapContext.Provider>
|
</MapContext.Provider>
|
||||||
</DataContext.Provider>
|
</DataContext.Provider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const config = {
|
export const config = {
|
||||||
BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:8000",
|
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:
|
AUDIO_SERVICE_URL:
|
||||||
process.env.NEXT_PUBLIC_AUDIO_SERVICE_URL || "http://127.0.0.1:18083",
|
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",
|
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,
|
handler: (action: ChatToolAction) => void,
|
||||||
) {
|
) {
|
||||||
const handlerRef = useRef(handler);
|
const handlerRef = useRef(handler);
|
||||||
|
const lastHandledSeqRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlerRef.current = handler;
|
handlerRef.current = handler;
|
||||||
}, [handler]);
|
}, [handler]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(
|
const unsubscribe = useChatToolStore.subscribe(
|
||||||
(state, prevState) => {
|
(state, prevState) => {
|
||||||
if (
|
if (
|
||||||
state.actionSeq !== prevState.actionSeq &&
|
state.actionSeq !== prevState.actionSeq &&
|
||||||
state.lastAction
|
state.lastAction &&
|
||||||
|
state.actionSeq > lastHandledSeqRef.current
|
||||||
) {
|
) {
|
||||||
|
lastHandledSeqRef.current = state.actionSeq;
|
||||||
handlerRef.current(state.lastAction);
|
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 { ReadableStream } from "stream/web";
|
||||||
import { TextEncoder, TextDecoder } from "util";
|
import { TextEncoder, TextDecoder } from "util";
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ const makeStream = (chunks: string[]) =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("streamCopilotChat", () => {
|
describe("streamAgentChat", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiFetch.mockReset();
|
apiFetch.mockReset();
|
||||||
});
|
});
|
||||||
@@ -41,21 +41,21 @@ describe("streamCopilotChat", () => {
|
|||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
body: makeStream([
|
body: makeStream([
|
||||||
'event: token\ndata: {"conversationId":"c1","content":"he"}\n\n',
|
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
|
||||||
'event: token\ndata: {"conversationId":"c1","content":"llo"}\n\n',
|
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
|
||||||
'event: done\ndata: {"conversationId":"c1"}\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",
|
message: "hi",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith(
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("/api/v1/copilot/chat/stream"),
|
expect.stringContaining("/api/v1/agent/chat/stream"),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
@@ -64,12 +64,68 @@ describe("streamCopilotChat", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([
|
||||||
{ type: "token", conversationId: "c1", content: "he" },
|
{ type: "token", sessionId: "s1", content: "he" },
|
||||||
{ type: "token", conversationId: "c1", content: "llo" },
|
{ type: "token", sessionId: "s1", content: "llo" },
|
||||||
{ type: "done", conversationId: "c1" },
|
{ 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 () => {
|
it("emits error when response is not ok", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -78,7 +134,7 @@ describe("streamCopilotChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
||||||
await streamCopilotChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
@@ -97,7 +153,7 @@ describe("streamCopilotChat", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
||||||
await streamCopilotChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
@@ -111,7 +167,7 @@ describe("streamCopilotChat", () => {
|
|||||||
apiFetch.mockRejectedValue(new TypeError("Failed to fetch"));
|
apiFetch.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
const events: Array<{ type: string; message?: string; detail?: string }> = [];
|
||||||
await streamCopilotChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
@@ -120,4 +176,49 @@ describe("streamCopilotChat", () => {
|
|||||||
{ type: "error", message: "network request failed", detail: "Failed to fetch" },
|
{ 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";
|
import { config } from "@config/config";
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "token"; conversationId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
| { type: "done"; conversationId: 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";
|
type: "error";
|
||||||
conversationId?: string;
|
sessionId?: string;
|
||||||
message: string;
|
message: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "tool_call";
|
type: "tool_call";
|
||||||
conversationId: string;
|
sessionId: string;
|
||||||
tool: string;
|
tool: string;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamOptions = {
|
type StreamOptions = {
|
||||||
message: string;
|
message: string;
|
||||||
conversationId?: string;
|
sessionId?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onEvent: (event: StreamEvent) => void;
|
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,
|
message,
|
||||||
conversationId,
|
sessionId,
|
||||||
signal,
|
signal,
|
||||||
onEvent,
|
onEvent,
|
||||||
}: StreamOptions) => {
|
}: StreamOptions) => {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
`${config.COPILOT_URL}/api/v1/copilot/chat/stream`,
|
`${config.AGENT_URL}/api/v1/agent/chat/stream`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
@@ -62,7 +96,7 @@ export const streamCopilotChat = async ({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message,
|
message,
|
||||||
conversation_id: conversationId,
|
session_id: sessionId,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
@@ -115,37 +149,59 @@ export const streamCopilotChat = async ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as {
|
const parsed = JSON.parse(data) as {
|
||||||
|
session_id?: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
|
arguments?: unknown;
|
||||||
|
id?: string;
|
||||||
|
phase?: string;
|
||||||
|
status?: "running" | "completed" | "error";
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
if (event === "token") {
|
if (event === "token") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "token",
|
type: "token",
|
||||||
conversationId: parsed.conversationId ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
content: parsed.content ?? "",
|
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") {
|
} else if (event === "done") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "done",
|
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") {
|
} else if (event === "error") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "error",
|
type: "error",
|
||||||
conversationId: parsed.conversationId,
|
sessionId: parsed.session_id,
|
||||||
message: parsed.message ?? "unknown error",
|
message: parsed.message ?? "unknown error",
|
||||||
detail: parsed.detail,
|
detail: parsed.detail,
|
||||||
});
|
});
|
||||||
} else if (event === "tool_call") {
|
} else if (event === "tool_call") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
conversationId: parsed.conversationId ?? "",
|
sessionId: parsed.session_id ?? parsed.conversationId ?? "",
|
||||||
tool: parsed.tool ?? "",
|
tool: parsed.tool ?? "",
|
||||||
params: parsed.params ?? {},
|
params: resolveToolParams(parsed.params, parsed.arguments),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} 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;
|
lastAction: ChatToolAction | null;
|
||||||
/** Monotonically increasing counter – lets subscribers detect new actions. */
|
/** Monotonically increasing counter – lets subscribers detect new actions. */
|
||||||
actionSeq: number;
|
actionSeq: number;
|
||||||
|
/** Timestamp of the most recent action dispatch. */
|
||||||
|
lastActionAt: number;
|
||||||
/** Dispatch a tool action from the chat. */
|
/** Dispatch a tool action from the chat. */
|
||||||
dispatch: (action: ChatToolAction) => void;
|
dispatch: (action: ChatToolAction) => void;
|
||||||
}
|
}
|
||||||
@@ -48,9 +50,11 @@ interface ChatToolState {
|
|||||||
export const useChatToolStore = create<ChatToolState>((set) => ({
|
export const useChatToolStore = create<ChatToolState>((set) => ({
|
||||||
lastAction: null,
|
lastAction: null,
|
||||||
actionSeq: 0,
|
actionSeq: 0,
|
||||||
|
lastActionAt: 0,
|
||||||
dispatch: (action) =>
|
dispatch: (action) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
lastAction: action,
|
lastAction: action,
|
||||||
actionSeq: state.actionSeq + 1,
|
actionSeq: state.actionSeq + 1,
|
||||||
|
lastActionAt: Date.now(),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"types": [
|
||||||
|
"jest",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
@@ -63,6 +67,8 @@
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
|
".next*/types/**/*.ts",
|
||||||
|
".next*/dev/types/**/*.ts",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user