7 Commits

Author SHA1 Message Date
jiang e4424b87d1 优化渲染节点功能,使用 ref 文件渲染大量节点
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-18 16:15:38 +08:00
jiang 39ee9a02e5 拆分、重构 Toolbar 2026-05-18 15:49:38 +08:00
jiang 45274955c6 增加渲染节点功能,优化工具操作和样式 2026-05-18 15:44:36 +08:00
jiang 03ca56d2a7 增加触发 Gitea 管道的脚本,更新 package.json 2026-05-18 15:32:53 +08:00
jiang 570d2c7de1 增加会话标题支持,优化聊天头部展示
Build Push and Deploy / docker-image (push) Successful in 1m1s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-15 17:32:38 +08:00
jiang 8058b7b859 增加模型选择功能,支持不同 Agent 模型
Build Push and Deploy / docker-image (push) Successful in 1m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-05-13 18:12:22 +08:00
jiang a4486e3d89 优化 Agent 过程展示,增加时间格式化和状态管理 2026-05-13 17:43:06 +08:00
22 changed files with 1450 additions and 688 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"refine": "refine"
"refine": "refine",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
},
"dependencies": {
"@emotion/react": "^11.8.2",
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: bash scripts/trigger-gitea-pipeline.sh [remote] [tag]"
echo ""
echo "Examples:"
echo " bash scripts/trigger-gitea-pipeline.sh"
echo " bash scripts/trigger-gitea-pipeline.sh gitea latest"
echo " bash scripts/trigger-gitea-pipeline.sh gitea v2026.05.15.1"
exit 0
fi
REMOTE="${1:-gitea}"
TAG="${2:-latest}"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "[ERROR] Current directory is not a git repository."
exit 1
fi
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
echo "[ERROR] Remote '$REMOTE' does not exist."
echo "Available remotes:"
git remote -v
exit 1
fi
HEAD_SHA="$(git rev-parse --short HEAD)"
MESSAGE="manual trigger: ${TAG} $(date '+%F %T')"
echo "[INFO] HEAD: ${HEAD_SHA}"
echo "[INFO] Recreate annotated tag '${TAG}'"
git tag -fa "$TAG" -m "$MESSAGE"
echo "[INFO] Push '${TAG}' to remote '${REMOTE}' (force update)"
git push "$REMOTE" "refs/tags/${TAG}" --force
echo "[INFO] Verify remote tag reference"
git ls-remote --tags "$REMOTE" "refs/tags/${TAG}"
echo "[DONE] Pipeline trigger request sent by updating tag '${TAG}'."
+167 -40
View File
@@ -7,8 +7,11 @@ import {
Box,
Chip,
Collapse,
FormControl,
IconButton,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
@@ -21,6 +24,9 @@ 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";
import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import type { AgentModel } from "@/lib/chatStream";
type AgentComposerProps = {
input: string;
@@ -36,6 +42,8 @@ type AgentComposerProps = {
onStartListening: () => void;
onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void;
};
export const AgentComposer = ({
@@ -52,6 +60,8 @@ export const AgentComposer = ({
onStartListening,
onStopListening,
onPresetSelect,
selectedModel,
onModelChange,
}: AgentComposerProps) => {
const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
@@ -213,46 +223,163 @@ export const AgentComposer = ({
) : 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 direction="row" spacing={1} alignItems="center">
<FormControl size="small" sx={{ minWidth: 80 }}>
<Select
value={selectedModel}
onChange={(event) => onModelChange(event.target.value as AgentModel)}
disabled={isHydrating || isStreaming}
aria-label="模型选择"
renderValue={(val) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{val === "deepseek/deepseek-v4-flash" ? (
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
) : (
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
)}
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
</Typography>
</Box>
)}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "center" },
transformOrigin: { vertical: "bottom", horizontal: "center" },
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
PaperProps: {
sx: {
mb: 1.5,
width: 230,
borderRadius: 4,
bgcolor: alpha("#fff", 0.85),
backdropFilter: "blur(24px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
"& .MuiList-root": {
p: 1,
},
"& .MuiMenuItem-root": {
px: 1.5,
py: 1.2,
mb: 0.5,
"&:last-child": { mb: 0 },
borderRadius: 3,
alignItems: "flex-start",
transition: "all 0.2s ease",
"&:hover": {
bgcolor: alpha("#000", 0.03),
},
"&.Mui-selected": {
bgcolor: alpha("#00acc1", 0.08),
"&:hover": {
bgcolor: alpha("#00acc1", 0.12),
},
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
}
}
}
}
}}
sx={{
height: 36,
borderRadius: "18px",
bgcolor: "transparent",
color: "text.secondary",
transition: "all 0.2s ease",
".MuiOutlinedInput-notchedOutline": {
border: "none",
},
".MuiSelect-select": {
py: 0,
pl: 1,
pr: "28px !important",
display: "flex",
alignItems: "center",
},
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
bgcolor: alpha("#000", 0.06),
color: "text.primary",
".MuiSelect-icon": {
color: "text.primary",
}
},
".MuiSelect-icon": {
color: "text.secondary",
right: 4,
transition: "color 0.2s ease",
}
}}
>
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
<Box
component="img"
src="/deepseek-logo.svg"
alt="DeepSeek"
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
DEEPSEEK V4
</Typography>
</Box>
<MenuItem value="deepseek/deepseek-v4-flash">
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
<MenuItem value="deepseek/deepseek-v4-pro">
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}></Typography>
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}></Typography>
</Box>
</MenuItem>
</Select>
</FormControl>
<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>
</Stack>
</Paper>
</motion.div>
+14 -3
View File
@@ -18,6 +18,7 @@ import CloseRounded from "@mui/icons-material/CloseRounded";
import HistoryRounded from "@mui/icons-material/HistoryRounded";
type AgentHeaderProps = {
sessionTitle?: string;
isStreaming: boolean;
isHistoryOpen: boolean;
onHistoryToggle: () => void;
@@ -26,6 +27,7 @@ type AgentHeaderProps = {
};
export const AgentHeader = ({
sessionTitle,
isStreaming,
isHistoryOpen,
onHistoryToggle,
@@ -33,6 +35,7 @@ export const AgentHeader = ({
onClose,
}: AgentHeaderProps) => {
const theme = useTheme();
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
return (
<Box
@@ -91,7 +94,7 @@ export const AgentHeader = ({
/>
</Box>
</motion.div>
<Box>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="h6"
fontWeight={800}
@@ -100,12 +103,20 @@ export const AgentHeader = ({
backgroundClip: "text",
color: "transparent",
letterSpacing: -0.3,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: { xs: "calc(100vw - 220px)", sm: 320 },
}}
>
TJWater Agent
{displayTitle}
</Typography>
<Typography variant="caption" color="text.secondary" fontWeight={500}>
{isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"}
{isStreaming
? "正在思考分析任务..."
: displayTitle === "TJWater Agent"
? "基于大模型的水力分析引擎"
: "当前会话标题"}
</Typography>
</Box>
</Stack>
+2 -1
View File
@@ -317,6 +317,7 @@ export const AgentHistoryPanel = ({
<Dialog
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
TransitionProps={{
onExited: () => setPendingDeleteSessionId(null)
}}
@@ -346,7 +347,7 @@ export const AgentHistoryPanel = ({
>
<WarningRounded sx={{ fontSize: 22 }} />
</Box>
<Typography variant="h6" fontWeight={800} color="text.primary">
<Typography component="span" variant="h6" fontWeight={800} color="text.primary">
</Typography>
</DialogTitle>
@@ -5,13 +5,26 @@ import { AgentProgressTimeline } from "./AgentProgressTimeline";
import type { ChatProgress } from "./GlobalChatbox.types";
describe("AgentProgressTimeline", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
});
afterEach(() => {
jest.useRealTimers();
});
it("shows the running step and keeps the timeline expanded while running", () => {
const now = Date.now();
const progress: ChatProgress[] = [
{
id: "start",
phase: "start",
status: "completed",
status: "running",
title: "收到请求",
startedAt: now - 5000,
elapsedMs: 5000,
elapsedSnapshotAt: now,
},
{
id: "tool",
@@ -19,42 +32,79 @@ describe("AgentProgressTimeline", () => {
status: "running",
title: "正在调用 dynamic_http_call",
detail: "GET /api/v1/network/bottlenecks",
startedAt: now - 1200,
elapsedMs: 1200,
elapsedSnapshotAt: now,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("Agent 过程")).toBeInTheDocument();
expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument();
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
expect(screen.getByText("GET /api/v1/network/bottlenecks")).toBeInTheDocument();
expect(screen.getByText("1.2s")).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: "分析完成" },
{
id: "request-received",
phase: "start",
status: "completed",
title: "收到请求",
startedAt: Date.now() - 8000,
endedAt: Date.now(),
durationMs: 8000,
},
{
id: "done",
phase: "complete",
status: "completed",
title: "分析完成",
startedAt: Date.now() - 1000,
endedAt: Date.now(),
durationMs: 1000,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText(/耗时 8.0s/)).toBeInTheDocument();
expect(screen.queryByText("分析完成")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开" }));
fireEvent.click(screen.getByText(/Agent 过程:/));
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: "分析完成" },
{
id: "tool",
phase: "tool",
status: "completed",
title: "正在调用 dynamic_http_call",
startedAt: Date.now() - 4000,
endedAt: Date.now(),
},
{
id: "done",
phase: "complete",
status: "completed",
title: "分析完成",
startedAt: Date.now() - 500,
endedAt: Date.now(),
durationMs: 500,
},
];
render(<AgentProgressTimeline progress={progress} />);
expect(screen.getByText("已完成 2 步")).toBeInTheDocument();
expect(screen.getByText(/已完成 \(2 步\)/)).toBeInTheDocument();
expect(screen.getByText("4.0s")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
});
+94 -4
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Collapse,
@@ -22,6 +22,46 @@ import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRound
import type { ChatProgress } from "./GlobalChatbox.types";
const formatDuration = (durationMs: number) => {
if (!Number.isFinite(durationMs) || durationMs < 0) {
return "0s";
}
if (durationMs < 10_000) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
const totalSeconds = Math.round(durationMs / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`;
}
const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60;
return `${hours}h ${remainMinutes.toString().padStart(2, "0")}m`;
};
const getProgressElapsedMs = (item: ChatProgress, nowMs: number) => {
if (item.durationMs !== undefined) {
return item.durationMs;
}
if (item.status === "running") {
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
return Math.max(0, item.elapsedMs + (nowMs - item.elapsedSnapshotAt));
}
if (item.startedAt !== undefined) {
return Math.max(0, nowMs - item.startedAt);
}
return item.elapsedMs;
}
if (item.startedAt !== undefined && item.endedAt !== undefined) {
return Math.max(0, item.endedAt - item.startedAt);
}
return item.elapsedMs;
};
const phaseIcon = (phase: string, status: ChatProgress["status"]) => {
const sx = { fontSize: 16 };
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
@@ -41,11 +81,13 @@ const formatToolTitle = (item: ChatProgress) => {
if (text.includes("locate_features")) return "地图定位";
if (text.includes("view_history")) return "打开历史曲线";
if (text.includes("view_scada")) return "打开 SCADA 面板";
if (text.includes("render_junctions")) return "渲染节点";
return item.title;
};
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
const theme = useTheme();
const [nowMs, setNowMs] = useState(() => Date.now());
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
const isOverallComplete = progress.some(
@@ -56,6 +98,16 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
const hasError = isAborted || progress.some((item) => item.status === "error");
useEffect(() => {
if (!hasRunning) {
return;
}
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 500);
return () => window.clearInterval(timer);
}, [hasRunning]);
// 展开状态逻辑:默认折叠,保持界面整洁
const [expanded, setExpanded] = useState(false);
@@ -70,6 +122,31 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
return `已执行 ${progress.length}`;
}, [isOverallComplete, hasError, progress, isAborted]);
const totalDurationLabel = useMemo(() => {
const requestProgress = progress.find((item) => item.id === "request-received");
const requestElapsed =
requestProgress ? getProgressElapsedMs(requestProgress, nowMs) : undefined;
if (requestElapsed !== undefined) {
return formatDuration(requestElapsed);
}
const startedAtValues = progress
.map((item) => item.startedAt)
.filter((value): value is number => value !== undefined);
if (startedAtValues.length === 0) {
return undefined;
}
const minStartedAt = Math.min(...startedAtValues);
const endedAtValues = progress
.map((item) => item.endedAt)
.filter((value): value is number => value !== undefined);
const endAnchor = isOverallComplete
? endedAtValues.length > 0
? Math.max(...endedAtValues)
: nowMs
: nowMs;
return formatDuration(Math.max(0, endAnchor - minStartedAt));
}, [isOverallComplete, nowMs, progress]);
// 根据整体状态决定顶部卡片的颜色主题
const statusColor = isOverallComplete
? "#4caf50" // Success Green
@@ -120,6 +197,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
Agent : {summary}
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
</Typography>
<KeyboardArrowDownRounded
@@ -159,6 +237,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
{progress.map((item, index) => {
const isLast = index === progress.length - 1;
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
const itemColor = isAborted && isLast
? theme.palette.error.main
@@ -219,9 +298,20 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
</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>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
{item.phase === "tool" ? formatToolTitle(item) : item.title}
</Typography>
{stepElapsedMs !== undefined ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "0.68rem", fontFamily: "var(--font-mono, monospace)" }}
>
{formatDuration(stepElapsedMs)}
</Typography>
) : null}
</Stack>
{item.detail && (
<Collapse in={expanded || isLast} timeout="auto">
+27 -22
View File
@@ -12,6 +12,7 @@ import {
IconButton,
Paper,
Stack,
Tooltip,
Typography,
alpha,
useTheme,
@@ -411,7 +412,7 @@ export const AgentTurn = React.memo(
</Stack>
<AnimatePresence>
{isHovered && !isErrorMessage && (
{isHovered && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -432,27 +433,31 @@ export const AgentTurn = React.memo(
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>
<Tooltip title="复制">
<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>
</Tooltip>
<Tooltip title="重新生成">
<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>
</Tooltip>
</Paper>
</motion.div>
)}
+20
View File
@@ -131,6 +131,12 @@ const TOOL_META: Record<string, ToolMeta> = {
actionLabel: "显示",
color: "#73c0de",
},
render_junctions: {
label: "渲染节点",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "应用渲染",
color: "#3b82f6",
},
};
/* ---------- helpers ---------- */
@@ -261,6 +267,9 @@ function getToolDescription(toolCall: ToolCall): string {
case "show_chart": {
return (params.title as string | undefined) ?? "数据图表";
}
case "render_junctions": {
return (params.render_ref as string | undefined) ?? "渲染引用";
}
default:
return "";
}
@@ -383,6 +392,17 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
xAxisName: params.x_axis_name as string | undefined,
yAxisName: params.y_axis_name as string | undefined,
};
case "render_junctions": {
const renderRef =
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
if (!renderRef) {
return null;
}
return {
type: "render_junctions",
renderRef,
};
}
default:
return null;
}
+44 -2
View File
@@ -1,8 +1,15 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Box, Drawer, alpha, useTheme } from "@mui/material";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Box, Drawer, alpha, useMediaQuery, useTheme } from "@mui/material";
import type { AgentModel } from "@/lib/chatStream";
import { AgentComposer } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader";
import { AgentHistoryPanel } from "./AgentHistoryPanel";
@@ -19,10 +26,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>(
"deepseek/deepseek-v4-pro",
);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up("sm"));
const {
speechState,
@@ -54,6 +65,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
branchTransition,
isHydrating,
isStreaming,
sessionTitle,
sendPrompt,
regenerate,
editAndResubmit,
@@ -65,6 +77,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} = useAgentChatSession({
onToolCall: handleToolCall,
onBeforeSend: stopListening,
getModel: () => selectedModel,
});
useEffect(() => {
@@ -152,6 +165,32 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
};
}, [isResizing]);
useLayoutEffect(() => {
const body = document.body;
const html = document.documentElement;
const previousBodyPaddingRight = body.style.paddingRight;
const previousBodyTransition = body.style.transition;
const previousBodyBoxSizing = body.style.boxSizing;
const previousHtmlBoxSizing = html.style.boxSizing;
const reservedWidth = open && isDesktop ? `${width}px` : "0px";
body.style.boxSizing = "border-box";
html.style.boxSizing = "border-box";
body.style.paddingRight = reservedWidth;
body.style.transition = isResizing
? previousBodyTransition
: [previousBodyTransition, "padding-right 240ms cubic-bezier(0.2, 0.8, 0.2, 1)"]
.filter(Boolean)
.join(", ");
return () => {
body.style.paddingRight = previousBodyPaddingRight;
body.style.transition = previousBodyTransition;
body.style.boxSizing = previousBodyBoxSizing;
html.style.boxSizing = previousHtmlBoxSizing;
};
}, [isDesktop, isResizing, open, width]);
return (
<Drawer
anchor="right"
@@ -215,6 +254,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
<Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} />
<AgentHeader
sessionTitle={sessionTitle}
isStreaming={isStreaming}
isHistoryOpen={isHistoryOpen}
onHistoryToggle={handleHistoryToggle}
@@ -298,6 +338,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
onStartListening={startListening}
onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
</Box>
</Box>
@@ -4,6 +4,11 @@ export type ChatProgress = {
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream";
import type { StreamEvent } from "@/lib/chatStream";
import type { AgentModel, StreamEvent } from "@/lib/chatStream";
import type {
AgentArtifact,
BranchGroup,
@@ -37,6 +37,7 @@ type UseAgentChatSessionOptions = {
},
) => void;
onBeforeSend?: () => void;
getModel?: () => AgentModel;
};
type PromptRunOptions = {
@@ -53,12 +54,39 @@ const upsertProgress = (
) => {
const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id);
const existing = index >= 0 ? next[index] : undefined;
const now = Date.now();
const startedAt = event.startedAt ?? existing?.startedAt;
const isRunning = event.status === "running";
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
const elapsedMs = isRunning
? event.elapsedMs ??
existing?.elapsedMs ??
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
: undefined;
const elapsedSnapshotAt = isRunning
? event.elapsedMs !== undefined
? now
: existing?.elapsedSnapshotAt ?? now
: undefined;
const durationMs = !isRunning
? event.durationMs ??
existing?.durationMs ??
(startedAt !== undefined && endedAt !== undefined
? Math.max(0, endedAt - startedAt)
: undefined)
: undefined;
const nextItem: ChatProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
startedAt,
endedAt,
elapsedMs,
elapsedSnapshotAt,
durationMs,
};
if (index >= 0) {
next[index] = nextItem;
@@ -69,9 +97,24 @@ const upsertProgress = (
};
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) =>
item.status === "running" ? { ...item, status: "completed" as const } : item,
);
progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined
? Math.max(0, endedAt - item.startedAt)
: item.elapsedMs),
};
});
const createUserMessage = (content: string, branchRootId?: string): Message => {
const id = createId();
@@ -95,6 +138,7 @@ const messagesEqual = (left: Message[], right: Message[]) =>
export const useAgentChatSession = ({
onToolCall,
onBeforeSend,
getModel,
}: UseAgentChatSessionOptions) => {
const storageSessionIdRef = useRef<string | undefined>(undefined);
const hydrationCompletedRef = useRef(false);
@@ -275,6 +319,7 @@ export const useAgentChatSession = ({
await streamAgentChat({
message: prompt,
sessionId: sessionIdOverride ?? sessionIdRef.current,
model: getModel?.(),
signal: controller.signal,
onEvent: (event) => {
if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) {
@@ -406,7 +451,7 @@ export const useAgentChatSession = ({
setIsStreaming(false);
}
},
[appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
[appendArtifact, getModel, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
);
const abort = useCallback(() => {
@@ -722,6 +767,7 @@ export const useAgentChatSession = ({
branchTransition,
isHydrating,
isStreaming,
sessionTitle,
sessionId,
sendPrompt,
regenerate,
@@ -230,6 +230,24 @@ const buildToolAction = (
};
}
if (tool === "render_junctions") {
const renderRef =
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
return {
action: renderRef
? {
type: "render_junctions",
renderRef,
sessionId: undefined,
}
: null,
kind: "map",
title: "渲染节点分区",
description: renderRef || "渲染引用",
};
}
return {
action: null,
kind: "tool",
@@ -248,6 +266,11 @@ export const useAgentToolActions = () => {
event.params,
);
const normalizedAction =
action?.type === "render_junctions"
? { ...action, sessionId: event.sessionId }
: action;
options.appendArtifact(options.assistantMessageId, {
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
tool: event.tool,
@@ -257,8 +280,8 @@ export const useAgentToolActions = () => {
params: event.params,
});
if (action) {
dispatchToolAction(action);
if (normalizedAction) {
dispatchToolAction(normalizedAction);
}
},
[dispatchToolAction],
@@ -17,18 +17,14 @@ import {
ChevronRight,
FormatListBulleted,
} from "@mui/icons-material";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile";
import { VectorTile } from "ol";
import { FlatStyleLike } from "ol/style/flat";
import { useMap } from "@components/olmap/core/MapComponent";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import AnalysisParameters from "./AnalysisParameters";
import SchemeQuery from "./SchemeQuery";
import RecognitionResults from "./RecognitionResults";
import { applyJunctionAreaRender } from "./applyJunctionAreaRender";
import { getAreaColor } from "./utils";
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
import { config } from "@/config/config";
const TabPanel = ({
value,
@@ -82,101 +78,26 @@ const DMALeakDetectionPanel: React.FC = () => {
useEffect(() => {
if (!map) return;
const junctionLayer = map
.getAllLayers()
.find(
(layer) =>
layer instanceof WebGLVectorTileLayer && layer.get("value") === "junctions",
) as WebGLVectorTileLayer | undefined;
if (!junctionLayer) return;
const source = junctionLayer.getSource() as VectorTileSource;
if (!source) return;
if (!loadedResult || !loadedResult.node_area_map) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return;
}
const fallbackAreaIds = Array.from(
new Set(Object.values(loadedResult.node_area_map || {}).map(String)),
new Set(Object.values(loadedResult?.node_area_map ?? {}).map(String)),
);
const areaIds = (loadedResult.areas || []).length
? loadedResult.areas.map((area) => String(area.area_id))
const areaIds = (loadedResult?.areas ?? []).length
? (loadedResult?.areas ?? []).map((area) => String(area.area_id))
: fallbackAreaIds;
if (areaIds.length === 0) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return;
}
const areaIdToIndex = new Map<string, number>();
areaIds.forEach((areaId, index) => {
areaIdToIndex.set(areaId, index + 1);
});
const nodeAreaIndexMap = new Map<string, number>();
Object.entries(loadedResult.node_area_map || {}).forEach(([nodeId, areaId]) => {
const idx = areaIdToIndex.get(String(areaId));
if (idx !== undefined) {
nodeAreaIndexMap.set(String(nodeId), idx);
}
});
const applyFeatureAreaIndex = (renderFeature: any) => {
const featureId = String(renderFeature.get("id") ?? "");
const areaIndex = nodeAreaIndexMap.get(featureId);
if (areaIndex !== undefined) {
renderFeature.properties_[DMA_AREA_INDEX_PROPERTY] = areaIndex;
}
};
const sourceTiles = (source as any).sourceTiles_;
if (sourceTiles) {
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
});
}
const listener = (event: any) => {
try {
if (event.tile instanceof VectorTile) {
const renderFeatures = event.tile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
}
} catch (error) {
console.error("Error applying DMA area mapping:", error);
}
};
source.on("tileloadend", listener);
const fillCases: any[] = [];
areaIds.forEach((areaId, index) => {
fillCases.push(
["==", ["get", DMA_AREA_INDEX_PROPERTY], index + 1],
getAreaColor(areaId),
);
});
const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]);
const defaultStrokeColor = String(
config.MAP_DEFAULT_STYLE["circle-stroke-color"],
const areaColors = Object.fromEntries(
areaIds.map((areaId) => [areaId, getAreaColor(areaId)]),
);
const dmaStyle: FlatStyleLike = {
...config.MAP_DEFAULT_STYLE,
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
};
junctionLayer.setStyle(dmaStyle);
return () => {
source.un("tileloadend", listener);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
};
return applyJunctionAreaRender(
map,
{
nodeAreaMap: loadedResult?.node_area_map ?? {},
areaIds,
areaColors,
},
{ propertyKey: DMA_AREA_INDEX_PROPERTY },
);
}, [map, loadedResult]);
return (
@@ -0,0 +1,147 @@
import { Map as OlMap, VectorTile } from "ol";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorTileSource from "ol/source/VectorTile";
import { FlatStyleLike } from "ol/style/flat";
import { config } from "@/config/config";
import { getAreaColor } from "./utils";
const JUNCTION_LAYER_VALUE = "junctions";
const RENDER_OWNER_KEY = "junction-area-render-owner";
export type JunctionAreaRenderPayload = {
nodeAreaMap: Record<string, string>;
areaIds?: string[];
areaColors?: Record<string, string>;
};
type ApplyJunctionAreaRenderOptions = {
propertyKey?: string;
};
const DEFAULT_PROPERTY_KEY = "junction_area_render_index";
const getJunctionLayer = (map: OlMap) =>
map
.getAllLayers()
.find(
(layer) =>
layer instanceof WebGLVectorTileLayer &&
layer.get("value") === JUNCTION_LAYER_VALUE,
) as WebGLVectorTileLayer | undefined;
export const applyJunctionAreaRender = (
map: OlMap,
payload: JunctionAreaRenderPayload,
options: ApplyJunctionAreaRenderOptions = {},
) => {
const propertyKey = options.propertyKey ?? DEFAULT_PROPERTY_KEY;
const junctionLayer = getJunctionLayer(map);
if (!junctionLayer) {
return () => {};
}
const source = junctionLayer.getSource() as VectorTileSource | null;
if (!source) {
return () => {};
}
const ownerId = `${propertyKey}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const normalizedNodeAreaMap = Object.fromEntries(
Object.entries(payload.nodeAreaMap ?? {}).map(([nodeId, areaId]) => [
String(nodeId),
String(areaId),
]),
);
const areaIds = (
payload.areaIds?.length
? payload.areaIds
: Array.from(new Set(Object.values(normalizedNodeAreaMap)))
)
.map(String)
.filter(Boolean);
if (Object.keys(normalizedNodeAreaMap).length === 0 || areaIds.length === 0) {
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
return () => {};
}
const areaIdToIndex = new Map<string, number>();
areaIds.forEach((areaId, index) => {
areaIdToIndex.set(areaId, index + 1);
});
const nodeAreaIndexMap = new Map<string, number>();
Object.entries(normalizedNodeAreaMap).forEach(([nodeId, areaId]) => {
const areaIndex = areaIdToIndex.get(areaId);
if (areaIndex !== undefined) {
nodeAreaIndexMap.set(nodeId, areaIndex);
}
});
const applyFeatureAreaIndex = (renderFeature: any) => {
const featureId = String(renderFeature.get("id") ?? "");
const areaIndex = nodeAreaIndexMap.get(featureId);
if (areaIndex !== undefined) {
renderFeature.properties_[propertyKey] = areaIndex;
}
};
const sourceTiles = (source as any).sourceTiles_;
if (sourceTiles) {
Object.values(sourceTiles).forEach((vectorTile: any) => {
const renderFeatures = vectorTile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
});
}
const listener = (event: any) => {
try {
if (!(event.tile instanceof VectorTile)) return;
const renderFeatures = event.tile.getFeatures();
if (!renderFeatures || renderFeatures.length === 0) return;
renderFeatures.forEach((renderFeature: any) => {
applyFeatureAreaIndex(renderFeature);
});
} catch (error) {
console.error("Error applying junction area render:", error);
}
};
source.on("tileloadend", listener);
const fillCases: any[] = [];
areaIds.forEach((areaId, index) => {
fillCases.push(
["==", ["get", propertyKey], index + 1],
payload.areaColors?.[areaId] ?? getAreaColor(areaId),
);
});
const defaultFillColor = String(config.MAP_DEFAULT_STYLE["circle-fill-color"]);
const defaultStrokeColor = String(
config.MAP_DEFAULT_STYLE["circle-stroke-color"],
);
junctionLayer.set(RENDER_OWNER_KEY, ownerId);
junctionLayer.setStyle({
...config.MAP_DEFAULT_STYLE,
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
} as FlatStyleLike);
return () => {
source.un("tileloadend", listener);
if (junctionLayer.get(RENDER_OWNER_KEY) === ownerId) {
junctionLayer.unset(RENDER_OWNER_KEY, true);
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
}
};
};
+34 -503
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useData, useMap } from "../MapComponent";
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
@@ -8,26 +8,24 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import { Style, Stroke, Fill, Circle } from "ol/style";
import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import Point from "ol/geom/Point";
import { bbox, featureCollection } from "@turf/turf";
import StyleEditorPanel from "./StyleEditorPanel";
import { LayerStyleState } from "./StyleEditorPanel";
import StyleLegend from "./StyleLegend"; // 引入图例组件
import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService";
import { handleMapClickSelectFeatures as mapClickSelectFeatures } from "@/utils/mapQueryService";
import { useNotification } from "@refinedev/core";
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
import ToolbarHistoryPanel from "./ToolbarHistoryPanel";
import {
buildFeatureProperties,
} from "./toolbarFeatureHelpers";
import { useToolbarChatActions } from "./useToolbarChatActions";
import { config } from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
// 添加接口定义隐藏按钮的props
interface ToolbarProps {
@@ -82,90 +80,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
endTime?: string;
} | null>(null);
// Wire up chat tool actions (locate, view_history, view_scada)
useChatToolActionHandler(
useCallback(
(action) => {
const geojsonFormat = new GeoJSON();
const zoomToFeatures = (
features: Feature[],
geometryKind: "point" | "line",
) => {
if (features.length === 0) return;
if (geometryKind === "point" && features.length === 1) {
const geometry = features[0].getGeometry();
if (geometry instanceof Point) {
map?.getView().animate({
center: geometry.getCoordinates(),
zoom: 18,
duration: 1000,
});
return;
}
}
const geojsonFeatures = features.map((f) =>
geojsonFormat.writeFeatureObject(f),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding: geometryKind === "line" ? [60, 60, 60, 60] : [40, 40, 40, 40],
});
}
};
const locateFeatures = (
ids: string[],
layer: string,
geometryKind: "point" | "line",
) => {
queryFeaturesByIds(ids, layer).then((features) => {
if (features.length > 0) {
setHighlightFeatures(features);
zoomToFeatures(features, geometryKind);
}
});
};
switch (action.type) {
case "locate_features": {
locateFeatures(action.ids, action.layer, action.geometryKind);
break;
}
case "view_history": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType(action.dataType);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
break;
}
case "view_scada": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType("none");
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
setActiveTools((prev) => {
if (prev.includes("history")) {
return prev;
}
return [...prev, "history"];
});
break;
}
}
},
[map],
),
);
useToolbarChatActions({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelTimeRange,
setShowHistoryPanel,
setActiveTools,
});
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
@@ -527,306 +449,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
if (currentTime !== -1 && queryType) queryComputedProperties();
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
// 从要素属性中提取属性面板需要的数据
const getFeatureProperties = useCallback(() => {
if (highlightFeatures.length === 0) return {};
const highlightFeature = highlightFeatures[0];
const layer = highlightFeature?.getId()?.toString().split(".")[0];
const properties = highlightFeature.getProperties();
// 计算属性字段,增加 key 字段
const pipeComputedFields = [
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "friction", label: "摩阻", unit: "" },
{ key: "headloss", label: "水头损失", unit: "m" },
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
{ key: "quality", label: "水质", unit: "mg/L" },
{ key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "quality", label: "水质", unit: "mg/L" },
];
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
let result = {
id: properties.id,
type: "管道",
properties: [
{ label: "起始节点ID", value: properties.node1 },
{ label: "终点节点ID", value: properties.node2 },
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
{
label: "管径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{ label: "粗糙度", value: properties.roughness },
{ label: "局部损失", value: properties.minor_loss },
{ label: "初始状态", value: "开" },
],
};
// 追加计算属性
if (computedProperties) {
pipeComputedFields.forEach(({ key, label, unit }) => {
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
// 如果是单位水头损失且后端未返回,则通过水头损失/长度计算 (单位 m/km)
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
}
return result;
}
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
let result = {
id: properties.id,
type: "节点",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
// 将 demand1~demand5 与 pattern1~pattern5 作为二级表格展示
{
type: "table",
label: "基本需水量",
columns: ["demand", "pattern"],
rows: Array.from({ length: 5 }, (_, i) => i + 1)
.map((idx) => {
let d = properties?.[`demand${idx}`];
const p = properties?.[`pattern${idx}`];
// 仅当 demand 有效时展示该行
if (d !== undefined && d !== null && d !== "") {
d = toM3h(Number(d), "lps");
return [typeof d === "number" ? d.toFixed(3) : d, p ?? "-"];
}
})
.filter(Boolean) as (string | number)[][],
} as any,
],
};
// 追加计算属性
if (computedProperties) {
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties.push({
label,
value:
value?.toFixed?.(3) || value,
unit,
});
}
});
}
return result;
}
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
return {
id: properties.id,
type: "水池",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
label: "初始水位",
value: properties.init_level?.toFixed?.(1),
unit: "m",
},
{
label: "最低水位",
value: properties.min_level?.toFixed?.(1),
unit: "m",
},
{
label: "最高水位",
value: properties.max_level?.toFixed?.(1),
unit: "m",
},
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "m",
},
{
label: "最小容积",
value: properties.min_vol?.toFixed?.(1),
unit: "m³",
},
// {
// label: "容积曲线",
// value: properties.vol_curve,
// },
{
label: "溢出",
value: properties.overflow ? "是" : "否",
},
],
};
}
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
return {
id: properties.id,
type: "水库",
properties: [
{
label: "水头",
value: properties.head?.toFixed?.(1),
unit: "m",
},
// {
// label: "模式",
// value: properties.pattern,
// },
],
};
}
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
return {
id: properties.id,
type: "水泵",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "功率",
value: properties.power?.toFixed?.(1),
unit: "kW",
},
{
label: "扬程",
value: properties.head?.toFixed?.(1),
unit: "m",
},
{
label: "转速",
value: properties.speed?.toFixed?.(1),
unit: "rpm",
},
{
label: "模式",
value: properties.pattern,
},
],
};
}
if (layer === "geo_valves_mat" || layer === "geo_valves") {
return {
id: properties.id,
type: "阀门",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{
label: "阀门类型",
value: properties.v_type,
},
// {
// label: "设置",
// value: properties.setting?.toFixed?.(2),
// },
{
label: "局部损失",
value: properties.minor_loss?.toFixed?.(2),
},
],
};
}
// 传输频率文字对应
const getTransmissionFrequency = (transmission_frequency: string) => {
// 传输频率文本:00:01:0000:05:0000:10:0000:30:0001:00:00,转换为分钟数
const parts = transmission_frequency.split(":");
if (parts.length !== 3) return transmission_frequency;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseInt(parts[2], 10);
const totalMinutes = hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
return totalMinutes;
};
// 可靠度文字映射
const getReliability = (reliability: number) => {
switch (reliability) {
case 1:
return "高";
case 2:
return "中";
case 3:
return "低";
default:
return "未知";
}
};
if (layer === "geo_scada_mat" || layer === "geo_scada") {
let result = {
id: properties.id,
type: "SCADA设备",
properties: [
{
label: "类型",
value:
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
},
{
label: "关联节点 ID",
value: properties.associated_element_id,
},
{
label: "传输模式",
value:
properties.transmission_mode === "non_realtime"
? "定时传输"
: "实时传输",
},
{
label: "传输频率",
value: getTransmissionFrequency(properties.transmission_frequency),
unit: "分钟",
},
{
label: "可靠性",
value: getReliability(properties.reliability),
},
],
};
return result;
}
return {};
}, [highlightFeatures, computedProperties]);
const propertyPanelData = useMemo(
() => buildFeatureProperties(highlightFeatures[0], computedProperties),
[highlightFeatures, computedProperties],
);
if (!data) {
return null;
@@ -879,7 +505,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
</div>
{showPropertyPanel && (
<PropertyPanel
{...getFeatureProperties()}
{...propertyPanelData}
onClose={() => {
deactivateTool("info");
setActiveTools((prev) => prev.filter((t) => t !== "info"));
@@ -893,115 +519,20 @@ const Toolbar: React.FC<ToolbarProps> = ({
setLayerStyleStates={setLayerStyleStates}
/>
</div>
{showHistoryPanel &&
(chatPanelType === "none" && chatPanelFeatureInfos ? (
<SCADADataPanel
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
visible={showHistoryPanel}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
) : HistoryPanel ? (
<HistoryPanel
featureInfos={chatPanelFeatureInfos ?? (() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
return highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
// 从图层名称推断类型
const layerId =
feature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type];
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
) : (
<HistoryDataPanel
featureInfos={chatPanelFeatureInfos ?? (() => {
if (highlightFeatures.length === 0 || !showHistoryPanel)
return [];
return highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
// 从图层名称推断类型
const layerId =
feature.getId()?.toString().split(".")[0] || "";
let type = "unknown";
if (layerId.includes("pipe")) {
type = "pipe";
} else if (layerId.includes("junction")) {
type = "junction";
} else if (layerId.includes("tank")) {
type = "tank";
} else if (layerId.includes("reservoir")) {
type = "reservoir";
} else if (layerId.includes("pump")) {
type = "pump";
} else if (layerId.includes("valve")) {
type = "valve";
}
// 仅处理 type 为 pipe 或 junction 的情况
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type];
})
.filter(Boolean) as [string, string][];
})()}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
))}
<ToolbarHistoryPanel
showHistoryPanel={showHistoryPanel}
chatPanelType={chatPanelType}
chatPanelFeatureInfos={chatPanelFeatureInfos}
chatPanelTimeRange={chatPanelTimeRange}
highlightFeatures={highlightFeatures}
HistoryPanel={HistoryPanel}
schemeName={schemeName}
queryType={queryType}
onClose={() => {
deactivateTool("history");
setActiveTools((prev) => prev.filter((t) => t !== "history"));
}}
/>
{/* 图例显示 */}
{activeLegendConfigs.length > 0 && (
@@ -0,0 +1,92 @@
"use client";
import React, { useMemo } from "react";
import Feature from "ol/Feature";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
import { inferHistoryFeatureInfos } from "./toolbarFeatureHelpers";
import HistoryDataPanel from "./HistoryDataPanel";
type ToolbarHistoryPanelProps = {
showHistoryPanel: boolean;
chatPanelType: "realtime" | "scheme" | "none";
chatPanelFeatureInfos: [string, string][] | null;
chatPanelTimeRange: {
startTime?: string;
endTime?: string;
} | null;
highlightFeatures: Feature[];
HistoryPanel?: React.FC<any>;
schemeName?: string;
queryType?: string;
onClose: () => void;
};
const ToolbarHistoryPanel: React.FC<ToolbarHistoryPanelProps> = ({
showHistoryPanel,
chatPanelType,
chatPanelFeatureInfos,
chatPanelTimeRange,
highlightFeatures,
HistoryPanel,
schemeName,
queryType,
onClose,
}) => {
const featureInfos = useMemo(
() => chatPanelFeatureInfos ?? inferHistoryFeatureInfos(highlightFeatures),
[chatPanelFeatureInfos, highlightFeatures],
);
if (!showHistoryPanel) {
return null;
}
if (chatPanelType === "none" && chatPanelFeatureInfos) {
return (
<SCADADataPanel
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
visible={showHistoryPanel}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={onClose}
/>
);
}
if (HistoryPanel) {
return (
<HistoryPanel
featureInfos={featureInfos}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={
chatPanelFeatureInfos
? chatPanelType
: (queryType as "realtime" | "scheme" | "none")
}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={onClose}
/>
);
}
return (
<HistoryDataPanel
featureInfos={featureInfos}
scheme_type="burst_analysis"
scheme_name={schemeName}
type={
chatPanelFeatureInfos
? chatPanelType
: (queryType as "realtime" | "scheme" | "none")
}
start_time={chatPanelTimeRange?.startTime}
end_time={chatPanelTimeRange?.endTime}
onClose={onClose}
/>
);
};
export default ToolbarHistoryPanel;
@@ -0,0 +1,350 @@
import Feature from "ol/Feature";
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
type ToolbarBaseProperty = {
label: string;
value: string | number;
unit?: string;
formatter?: (value: string | number) => string;
};
type ToolbarTableProperty = {
type: "table";
label: string;
columns: string[];
rows: (string | number)[][];
};
export type ToolbarPropertyItem = ToolbarBaseProperty | ToolbarTableProperty;
export type ToolbarPropertyPanelData = {
id?: string;
type?: string;
properties?: ToolbarPropertyItem[];
};
const getFeatureHistoryType = (feature: Feature): string | null => {
const layerId = feature.getId()?.toString().split(".")[0] || "";
if (layerId.includes("pipe")) return "pipe";
if (layerId.includes("junction")) return "junction";
if (layerId.includes("tank")) return "tank";
if (layerId.includes("reservoir")) return "reservoir";
if (layerId.includes("pump")) return "pump";
if (layerId.includes("valve")) return "valve";
return null;
};
export const inferHistoryFeatureInfos = (
highlightFeatures: Feature[],
): [string, string][] =>
highlightFeatures
.map((feature) => {
const properties = feature.getProperties();
const id = properties.id;
if (!id) return null;
const type = getFeatureHistoryType(feature);
if (type !== "pipe" && type !== "junction") {
return null;
}
return [id, type] as [string, string];
})
.filter(Boolean) as [string, string][];
export const buildFeatureProperties = (
highlightFeature: Feature | undefined,
computedProperties: Record<string, any>,
): ToolbarPropertyPanelData => {
if (!highlightFeature) return {};
const layer = highlightFeature.getId()?.toString().split(".")[0];
const properties = highlightFeature.getProperties();
const pipeComputedFields = [
{ key: "flow", label: "流量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "friction", label: "摩阻", unit: "" },
{ key: "headloss", label: "水头损失", unit: "m" },
{ key: "unit_headloss", label: "单位水头损失", unit: "m/km" },
{ key: "quality", label: "水质", unit: "mg/L" },
{ key: "reaction", label: "反应", unit: "1/d" },
{ key: "setting", label: "设置", unit: "" },
{ key: "status", label: "状态", unit: "" },
{ key: "velocity", label: "流速", unit: "m/s" },
];
const nodeComputedFields = [
{ key: "actual_demand", label: "实际需水量", unit: `${FLOW_DISPLAY_UNIT}` },
{ key: "total_head", label: "水头", unit: "m" },
{ key: "pressure", label: "压力", unit: "m" },
{ key: "quality", label: "水质", unit: "mg/L" },
];
if (layer === "geo_pipes_mat" || layer === "geo_pipes") {
const result: ToolbarPropertyPanelData = {
id: properties.id,
type: "管道",
properties: [
{ label: "起始节点ID", value: properties.node1 },
{ label: "终点节点ID", value: properties.node2 },
{ label: "长度", value: properties.length?.toFixed?.(1), unit: "m" },
{
label: "管径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{ label: "粗糙度", value: properties.roughness },
{ label: "局部损失", value: properties.minor_loss },
{ label: "初始状态", value: "开" },
],
};
pipeComputedFields.forEach(({ key, label, unit }) => {
let value = computedProperties[key];
if (key === "flow" && value !== undefined) {
value = toM3h(value, "lps");
}
if (
key === "unit_headloss" &&
value === undefined &&
computedProperties.headloss !== undefined &&
properties.length
) {
value = (computedProperties.headloss / properties.length) * 1000;
}
if (value !== undefined) {
result.properties?.push({
label,
value: typeof value === "number" ? value.toFixed(3) : value,
unit,
});
}
});
return result;
}
if (layer === "geo_junctions_mat" || layer === "geo_junctions") {
const result: ToolbarPropertyPanelData = {
id: properties.id,
type: "节点",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
type: "table",
label: "基本需水量",
columns: ["demand", "pattern"],
rows: Array.from({ length: 5 }, (_, i) => i + 1)
.map((idx) => {
let demand = properties?.[`demand${idx}`];
const pattern = properties?.[`pattern${idx}`];
if (
demand !== undefined &&
demand !== null &&
demand !== ""
) {
demand = toM3h(Number(demand), "lps");
return [
typeof demand === "number" ? demand.toFixed(3) : demand,
pattern ?? "-",
];
}
return null;
})
.filter(Boolean) as (string | number)[][],
},
],
};
nodeComputedFields.forEach(({ key, label, unit }) => {
if (computedProperties[key] !== undefined) {
let value = computedProperties[key];
if (key === "actual_demand") {
value = toM3h(value, "lps");
}
result.properties?.push({
label,
value: value?.toFixed?.(3) || value,
unit,
});
}
});
return result;
}
if (layer === "geo_tanks_mat" || layer === "geo_tanks") {
return {
id: properties.id,
type: "水池",
properties: [
{
label: "高程",
value: properties.elevation?.toFixed?.(1),
unit: "m",
},
{
label: "初始水位",
value: properties.init_level?.toFixed?.(1),
unit: "m",
},
{
label: "最低水位",
value: properties.min_level?.toFixed?.(1),
unit: "m",
},
{
label: "最高水位",
value: properties.max_level?.toFixed?.(1),
unit: "m",
},
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "m",
},
{
label: "最小容积",
value: properties.min_vol?.toFixed?.(1),
unit: "m³",
},
{
label: "溢出",
value: properties.overflow ? "是" : "否",
},
],
};
}
if (layer === "geo_reservoirs_mat" || layer === "geo_reservoirs") {
return {
id: properties.id,
type: "水库",
properties: [
{
label: "水头",
value: properties.head?.toFixed?.(1),
unit: "m",
},
],
};
}
if (layer === "geo_pumps_mat" || layer === "geo_pumps") {
return {
id: properties.id,
type: "水泵",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "功率",
value: properties.power?.toFixed?.(1),
unit: "kW",
},
{
label: "扬程",
value: properties.head?.toFixed?.(1),
unit: "m",
},
{
label: "转速",
value: properties.speed?.toFixed?.(1),
unit: "rpm",
},
{
label: "模式",
value: properties.pattern,
},
],
};
}
if (layer === "geo_valves_mat" || layer === "geo_valves") {
return {
id: properties.id,
type: "阀门",
properties: [
{ label: "起始节点 ID", value: properties.node1 },
{ label: "终点节点 ID", value: properties.node2 },
{
label: "直径",
value: properties.diameter?.toFixed?.(1),
unit: "mm",
},
{
label: "阀门类型",
value: properties.v_type,
},
{
label: "局部损失",
value: properties.minor_loss?.toFixed?.(2),
},
],
};
}
const getTransmissionFrequency = (transmissionFrequency: string) => {
const parts = transmissionFrequency.split(":");
if (parts.length !== 3) return transmissionFrequency;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseInt(parts[2], 10);
return hours * 60 + minutes + (seconds >= 30 ? 1 : 0);
};
const getReliability = (reliability: number) => {
switch (reliability) {
case 1:
return "高";
case 2:
return "中";
case 3:
return "低";
default:
return "未知";
}
};
if (layer === "geo_scada_mat" || layer === "geo_scada") {
return {
id: properties.id,
type: "SCADA设备",
properties: [
{
label: "类型",
value:
properties.type === "pipe_flow" ? "流量传感器" : "压力传感器",
},
{
label: "关联节点 ID",
value: properties.associated_element_id,
},
{
label: "传输模式",
value:
properties.transmission_mode === "non_realtime"
? "定时传输"
: "实时传输",
},
{
label: "传输频率",
value: getTransmissionFrequency(properties.transmission_frequency),
unit: "分钟",
},
{
label: "可靠性",
value: getReliability(properties.reliability),
},
],
};
}
return {};
};
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import Feature from "ol/Feature";
import { GeoJSON } from "ol/format";
import Point from "ol/geom/Point";
import { bbox, featureCollection } from "@turf/turf";
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
import {
applyJunctionAreaRender,
type JunctionAreaRenderPayload,
} from "@components/olmap/DMALeakDetection/applyJunctionAreaRender";
import { apiFetch } from "@/lib/apiFetch";
import { queryFeaturesByIds } from "@/utils/mapQueryService";
import { config } from "@/config/config";
import { useMap } from "../MapComponent";
type UseToolbarChatActionsParams = {
setHighlightFeatures: Dispatch<SetStateAction<Feature[]>>;
setChatPanelFeatureInfos: Dispatch<SetStateAction<[string, string][] | null>>;
setChatPanelType: Dispatch<SetStateAction<"realtime" | "scheme" | "none">>;
setChatPanelTimeRange: Dispatch<
SetStateAction<{ startTime?: string; endTime?: string } | null>
>;
setShowHistoryPanel: Dispatch<SetStateAction<boolean>>;
setActiveTools: Dispatch<SetStateAction<string[]>>;
};
export const useToolbarChatActions = ({
setHighlightFeatures,
setChatPanelFeatureInfos,
setChatPanelType,
setChatPanelTimeRange,
setShowHistoryPanel,
setActiveTools,
}: UseToolbarChatActionsParams) => {
const map = useMap();
const chatJunctionRenderCleanupRef = useRef<(() => void) | null>(null);
const renderRequestSeqRef = useRef(0);
const disposeChatJunctionRender = useCallback(() => {
chatJunctionRenderCleanupRef.current?.();
chatJunctionRenderCleanupRef.current = null;
}, []);
useEffect(() => () => disposeChatJunctionRender(), [disposeChatJunctionRender]);
useChatToolActionHandler(
useCallback(
(action) => {
const geojsonFormat = new GeoJSON();
const zoomToFeatures = (
features: Feature[],
geometryKind: "point" | "line",
) => {
if (features.length === 0) return;
if (geometryKind === "point" && features.length === 1) {
const geometry = features[0].getGeometry();
if (geometry instanceof Point) {
map?.getView().animate({
center: geometry.getCoordinates(),
zoom: 18,
duration: 1000,
});
return;
}
}
const geojsonFeatures = features.map((feature) =>
geojsonFormat.writeFeatureObject(feature),
);
const extent = bbox(featureCollection(geojsonFeatures as any));
if (extent) {
map?.getView().fit(extent, {
maxZoom: 18,
duration: 1000,
padding:
geometryKind === "line"
? [60, 60, 60, 60]
: [40, 40, 40, 40],
});
}
};
const locateFeatures = (
ids: string[],
layer: string,
geometryKind: "point" | "line",
) => {
queryFeaturesByIds(ids, layer).then((features) => {
if (features.length > 0) {
setHighlightFeatures(features);
zoomToFeatures(features, geometryKind);
}
});
};
switch (action.type) {
case "locate_features": {
locateFeatures(action.ids, action.layer, action.geometryKind);
break;
}
case "view_history": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType(action.dataType);
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
break;
}
case "view_scada": {
setChatPanelFeatureInfos(action.featureInfos);
setChatPanelType("none");
setChatPanelTimeRange({
startTime: action.startTime,
endTime: action.endTime,
});
setShowHistoryPanel(true);
setActiveTools((prev) => {
if (prev.includes("history")) {
return prev;
}
return [...prev, "history"];
});
break;
}
case "render_junctions": {
disposeChatJunctionRender();
renderRequestSeqRef.current += 1;
const requestSeq = renderRequestSeqRef.current;
if (!action.renderRef || !map) {
break;
}
void (async () => {
try {
const query = action.sessionId
? `?session_id=${encodeURIComponent(action.sessionId)}`
: "";
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/render-ref/${encodeURIComponent(action.renderRef)}${query}`,
{
method: "GET",
projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
throw new Error(`render ref request failed: ${response.status}`);
}
const payload = (await response.json()) as {
data?: {
node_area_map?: Record<string, unknown>;
area_ids?: unknown[];
area_colors?: Record<string, unknown>;
};
};
const data = payload.data;
if (!data?.node_area_map) {
throw new Error("render ref payload missing node_area_map");
}
const renderPayload: JunctionAreaRenderPayload = {
nodeAreaMap: Object.fromEntries(
Object.entries(data.node_area_map).map(([key, value]) => [
String(key),
String(value ?? ""),
]),
),
areaIds: Array.isArray(data.area_ids)
? data.area_ids.map((item) => String(item).trim()).filter(Boolean)
: [],
areaColors:
data.area_colors && typeof data.area_colors === "object"
? Object.fromEntries(
Object.entries(data.area_colors).map(([key, value]) => [
String(key),
String(value ?? ""),
]),
)
: {},
};
if (
requestSeq !== renderRequestSeqRef.current ||
Object.keys(renderPayload.nodeAreaMap).length === 0
) {
return;
}
chatJunctionRenderCleanupRef.current = applyJunctionAreaRender(
map,
renderPayload,
{ propertyKey: "chat_junction_render_index" },
);
} catch (error) {
console.error("Failed to resolve render_ref for junction render:", error);
}
})();
break;
}
}
},
[
disposeChatJunctionRender,
map,
setActiveTools,
setChatPanelFeatureInfos,
setChatPanelTimeRange,
setChatPanelType,
setHighlightFeatures,
setShowHistoryPanel,
],
),
);
};
+6
View File
@@ -51,6 +51,7 @@ describe("streamAgentChat", () => {
await streamAgentChat({
message: "hi",
model: "deepseek/deepseek-v4-pro",
onEvent: (event) => events.push(event),
});
@@ -60,6 +61,11 @@ describe("streamAgentChat", () => {
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
message: "hi",
session_id: undefined,
model: "deepseek/deepseek-v4-pro",
}),
}),
);
+24 -1
View File
@@ -1,9 +1,13 @@
import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
export type AgentModel =
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4-pro";
export type StreamEvent =
| { type: "token"; sessionId: string; content: string }
| { type: "done"; sessionId: string }
| { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string }
| {
type: "progress";
@@ -13,12 +17,17 @@ export type StreamEvent =
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
durationMs?: number;
}
| {
type: "error";
sessionId?: string;
message: string;
detail?: string;
totalDurationMs?: number;
}
| {
type: "tool_call";
@@ -30,6 +39,7 @@ export type StreamEvent =
type StreamOptions = {
message: string;
sessionId?: string;
model?: AgentModel;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
@@ -80,6 +90,7 @@ const resolveToolParams = (
export const streamAgentChat = async ({
message,
sessionId,
model,
signal,
onEvent,
}: StreamOptions) => {
@@ -97,6 +108,7 @@ export const streamAgentChat = async ({
body: JSON.stringify({
message,
session_id: sessionId,
model,
}),
projectHeaderMode: "include",
userHeaderMode: "include",
@@ -162,6 +174,11 @@ export const streamAgentChat = async ({
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
};
if (event === "token") {
onEvent({
@@ -178,11 +195,16 @@ export const streamAgentChat = async ({
status: parsed.status ?? "running",
title: parsed.title ?? "正在处理",
detail: parsed.detail,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
} else if (event === "done") {
onEvent({
type: "done",
sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "session_title") {
onEvent({
@@ -196,6 +218,7 @@ export const streamAgentChat = async ({
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "tool_call") {
onEvent({
+5
View File
@@ -34,6 +34,11 @@ export type ChatToolAction =
series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>;
xAxisName?: string;
yAxisName?: string;
}
| {
type: "render_junctions";
renderRef: string;
sessionId?: string;
};
interface ChatToolState {