Compare commits
7 Commits
536cd6a5d1
...
e4424b87d1
| Author | SHA1 | Date | |
|---|---|---|---|
| e4424b87d1 | |||
| 39ee9a02e5 | |||
| 45274955c6 | |||
| 03ca56d2a7 | |||
| 570d2c7de1 | |||
| 8058b7b859 | |||
| a4486e3d89 |
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"refine": "refine"
|
"refine": "refine",
|
||||||
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
|
|||||||
Executable
+43
@@ -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}'."
|
||||||
@@ -7,8 +7,11 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
Collapse,
|
Collapse,
|
||||||
|
FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -21,6 +24,9 @@ import MicRounded from "@mui/icons-material/MicRounded";
|
|||||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
import AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
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 = {
|
type AgentComposerProps = {
|
||||||
input: string;
|
input: string;
|
||||||
@@ -36,6 +42,8 @@ type AgentComposerProps = {
|
|||||||
onStartListening: () => void;
|
onStartListening: () => void;
|
||||||
onStopListening: () => void;
|
onStopListening: () => void;
|
||||||
onPresetSelect: (prompt: string) => void;
|
onPresetSelect: (prompt: string) => void;
|
||||||
|
selectedModel: AgentModel;
|
||||||
|
onModelChange: (model: AgentModel) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentComposer = ({
|
export const AgentComposer = ({
|
||||||
@@ -52,6 +60,8 @@ export const AgentComposer = ({
|
|||||||
onStartListening,
|
onStartListening,
|
||||||
onStopListening,
|
onStopListening,
|
||||||
onPresetSelect,
|
onPresetSelect,
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
}: AgentComposerProps) => {
|
}: AgentComposerProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||||
@@ -213,46 +223,163 @@ export const AgentComposer = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
{isStreaming ? (
|
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||||
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
<Select
|
||||||
<IconButton
|
value={selectedModel}
|
||||||
onClick={onAbort}
|
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||||
aria-label="停止生成"
|
disabled={isHydrating || isStreaming}
|
||||||
size="small"
|
aria-label="模型选择"
|
||||||
sx={{
|
renderValue={(val) => (
|
||||||
bgcolor: "error.main",
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
color: "#fff",
|
{val === "deepseek/deepseek-v4-flash" ? (
|
||||||
width: 40,
|
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
||||||
height: 40,
|
) : (
|
||||||
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
|
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
||||||
"&:hover": { bgcolor: "error.dark" },
|
)}
|
||||||
}}
|
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
||||||
>
|
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
|
||||||
<StopRounded />
|
</Typography>
|
||||||
</IconButton>
|
</Box>
|
||||||
</motion.div>
|
)}
|
||||||
) : (
|
MenuProps={{
|
||||||
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
|
anchorOrigin: { vertical: "top", horizontal: "center" },
|
||||||
<IconButton
|
transformOrigin: { vertical: "bottom", horizontal: "center" },
|
||||||
disabled={!canSend}
|
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
|
||||||
onClick={onSend}
|
PaperProps: {
|
||||||
aria-label="发送"
|
sx: {
|
||||||
size="small"
|
mb: 1.5,
|
||||||
sx={{
|
width: 230,
|
||||||
bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5),
|
borderRadius: 4,
|
||||||
color: canSend ? "#fff" : "action.disabled",
|
bgcolor: alpha("#fff", 0.85),
|
||||||
width: 40,
|
backdropFilter: "blur(24px)",
|
||||||
height: 40,
|
border: `1px solid ${alpha("#fff", 0.9)}`,
|
||||||
boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none",
|
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
|
||||||
"&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) },
|
"& .MuiList-root": {
|
||||||
}}
|
p: 1,
|
||||||
>
|
},
|
||||||
<SendRounded sx={{ ml: 0.35 }} />
|
"& .MuiMenuItem-root": {
|
||||||
</IconButton>
|
px: 1.5,
|
||||||
</motion.div>
|
py: 1.2,
|
||||||
)}
|
mb: 0.5,
|
||||||
</AnimatePresence>
|
"&: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>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import CloseRounded from "@mui/icons-material/CloseRounded";
|
|||||||
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
||||||
|
|
||||||
type AgentHeaderProps = {
|
type AgentHeaderProps = {
|
||||||
|
sessionTitle?: string;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isHistoryOpen: boolean;
|
isHistoryOpen: boolean;
|
||||||
onHistoryToggle: () => void;
|
onHistoryToggle: () => void;
|
||||||
@@ -26,6 +27,7 @@ type AgentHeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AgentHeader = ({
|
export const AgentHeader = ({
|
||||||
|
sessionTitle,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isHistoryOpen,
|
isHistoryOpen,
|
||||||
onHistoryToggle,
|
onHistoryToggle,
|
||||||
@@ -33,6 +35,7 @@ export const AgentHeader = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}: AgentHeaderProps) => {
|
}: AgentHeaderProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const displayTitle = sessionTitle?.trim() || "TJWater Agent";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -91,7 +94,7 @@ export const AgentHeader = ({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<Box>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
fontWeight={800}
|
fontWeight={800}
|
||||||
@@ -100,12 +103,20 @@ export const AgentHeader = ({
|
|||||||
backgroundClip: "text",
|
backgroundClip: "text",
|
||||||
color: "transparent",
|
color: "transparent",
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: { xs: "calc(100vw - 220px)", sm: 320 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
TJWater Agent
|
{displayTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
<Typography variant="caption" color="text.secondary" fontWeight={500}>
|
||||||
{isStreaming ? "正在思考分析任务..." : "基于大模型的水力分析引擎"}
|
{isStreaming
|
||||||
|
? "正在思考分析任务..."
|
||||||
|
: displayTitle === "TJWater Agent"
|
||||||
|
? "基于大模型的水力分析引擎"
|
||||||
|
: "当前会话标题"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ export const AgentHistoryPanel = ({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onClose={() => setIsDeleteDialogOpen(false)}
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
|
sx={{ zIndex: (theme) => theme.zIndex.modal + 200 }}
|
||||||
TransitionProps={{
|
TransitionProps={{
|
||||||
onExited: () => setPendingDeleteSessionId(null)
|
onExited: () => setPendingDeleteSessionId(null)
|
||||||
}}
|
}}
|
||||||
@@ -346,7 +347,7 @@ export const AgentHistoryPanel = ({
|
|||||||
>
|
>
|
||||||
<WarningRounded sx={{ fontSize: 22 }} />
|
<WarningRounded sx={{ fontSize: 22 }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6" fontWeight={800} color="text.primary">
|
<Typography component="span" variant="h6" fontWeight={800} color="text.primary">
|
||||||
删除确认
|
删除确认
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|||||||
@@ -5,13 +5,26 @@ import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
|||||||
import type { ChatProgress } from "./GlobalChatbox.types";
|
import type { ChatProgress } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
describe("AgentProgressTimeline", () => {
|
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", () => {
|
it("shows the running step and keeps the timeline expanded while running", () => {
|
||||||
|
const now = Date.now();
|
||||||
const progress: ChatProgress[] = [
|
const progress: ChatProgress[] = [
|
||||||
{
|
{
|
||||||
id: "start",
|
id: "start",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
status: "completed",
|
status: "running",
|
||||||
title: "收到请求",
|
title: "收到请求",
|
||||||
|
startedAt: now - 5000,
|
||||||
|
elapsedMs: 5000,
|
||||||
|
elapsedSnapshotAt: now,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tool",
|
id: "tool",
|
||||||
@@ -19,42 +32,79 @@ describe("AgentProgressTimeline", () => {
|
|||||||
status: "running",
|
status: "running",
|
||||||
title: "正在调用 dynamic_http_call",
|
title: "正在调用 dynamic_http_call",
|
||||||
detail: "GET /api/v1/network/bottlenecks",
|
detail: "GET /api/v1/network/bottlenecks",
|
||||||
|
startedAt: now - 1200,
|
||||||
|
elapsedMs: 1200,
|
||||||
|
elapsedSnapshotAt: now,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<AgentProgressTimeline progress={progress} />);
|
render(<AgentProgressTimeline progress={progress} />);
|
||||||
|
|
||||||
expect(screen.getByText("Agent 过程")).toBeInTheDocument();
|
expect(screen.getByText(/Agent 过程:/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("正在调用 dynamic_http_call")).toBeInTheDocument();
|
expect(screen.getByText(/耗时 5.0s/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
|
expect(screen.getByText("查询后端数据")).toBeInTheDocument();
|
||||||
expect(screen.getByText("GET /api/v1/network/bottlenecks")).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 () => {
|
it("summarizes completed steps and lets users expand details", async () => {
|
||||||
const progress: ChatProgress[] = [
|
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} />);
|
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();
|
expect(screen.queryByText("分析完成")).not.toBeVisible();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "展开" }));
|
fireEvent.click(screen.getByText(/Agent 过程:/));
|
||||||
|
|
||||||
expect(screen.getByText("分析完成")).toBeVisible();
|
expect(screen.getByText("分析完成")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats stale running steps as finished after a complete event", () => {
|
it("treats stale running steps as finished after a complete event", () => {
|
||||||
const progress: ChatProgress[] = [
|
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} />);
|
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();
|
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Collapse,
|
Collapse,
|
||||||
@@ -22,6 +22,46 @@ import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRound
|
|||||||
|
|
||||||
import type { ChatProgress } from "./GlobalChatbox.types";
|
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 phaseIcon = (phase: string, status: ChatProgress["status"]) => {
|
||||||
const sx = { fontSize: 16 };
|
const sx = { fontSize: 16 };
|
||||||
if (status === "completed") return <CheckCircleRounded sx={{ ...sx, color: "success.main" }} />;
|
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("locate_features")) return "地图定位";
|
||||||
if (text.includes("view_history")) return "打开历史曲线";
|
if (text.includes("view_history")) return "打开历史曲线";
|
||||||
if (text.includes("view_scada")) return "打开 SCADA 面板";
|
if (text.includes("view_scada")) return "打开 SCADA 面板";
|
||||||
|
if (text.includes("render_junctions")) return "渲染节点";
|
||||||
return item.title;
|
return item.title;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
|
export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatProgress[], isAborted?: boolean }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
|
// 判断是否最终完成(哪怕中间有报错,只要有完整的标记就算成功)
|
||||||
const isOverallComplete = progress.some(
|
const isOverallComplete = progress.some(
|
||||||
@@ -55,6 +97,16 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
|||||||
// 修正状态判断:如果外部标记为中断,或者没有完成标记
|
// 修正状态判断:如果外部标记为中断,或者没有完成标记
|
||||||
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
|
const hasRunning = !isAborted && !isOverallComplete && progress.some((item) => item.status === "running");
|
||||||
const hasError = isAborted || progress.some((item) => item.status === "error");
|
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);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -70,6 +122,31 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
|||||||
return `已执行 ${progress.length} 步`;
|
return `已执行 ${progress.length} 步`;
|
||||||
}, [isOverallComplete, hasError, progress, isAborted]);
|
}, [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
|
const statusColor = isOverallComplete
|
||||||
? "#4caf50" // Success Green
|
? "#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 }}>
|
<Typography variant="caption" fontWeight={700} color="text.primary" sx={{ flex: 1, letterSpacing: 0.3 }}>
|
||||||
Agent 过程: {summary}
|
Agent 过程: {summary}
|
||||||
|
{totalDurationLabel ? ` · 耗时 ${totalDurationLabel}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<KeyboardArrowDownRounded
|
<KeyboardArrowDownRounded
|
||||||
@@ -159,6 +237,7 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
|||||||
{progress.map((item, index) => {
|
{progress.map((item, index) => {
|
||||||
const isLast = index === progress.length - 1;
|
const isLast = index === progress.length - 1;
|
||||||
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
|
const isHiddenWhenCollapsed = isCollapsible && index < progress.length - visibleCount;
|
||||||
|
const stepElapsedMs = getProgressElapsedMs(item, nowMs);
|
||||||
|
|
||||||
const itemColor = isAborted && isLast
|
const itemColor = isAborted && isLast
|
||||||
? theme.palette.error.main
|
? theme.palette.error.main
|
||||||
@@ -219,9 +298,20 @@ export const AgentProgressTimeline = ({ progress, isAborted }: { progress: ChatP
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
|
<Box sx={{ minWidth: 0, flex: 1, pb: isLast ? 0 : 2 }}>
|
||||||
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
|
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
|
||||||
{item.phase === "tool" ? formatToolTitle(item) : item.title}
|
<Typography variant="caption" color="text.primary" fontWeight={600} sx={{ fontSize: "0.75rem" }}>
|
||||||
</Typography>
|
{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 && (
|
{item.detail && (
|
||||||
<Collapse in={expanded || isLast} timeout="auto">
|
<Collapse in={expanded || isLast} timeout="auto">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
@@ -411,7 +412,7 @@ export const AgentTurn = React.memo(
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isHovered && !isErrorMessage && (
|
{isHovered && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
initial={{ opacity: 0, scale: 0.9, y: 5 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
@@ -432,27 +433,31 @@ export const AgentTurn = React.memo(
|
|||||||
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<Tooltip title="复制">
|
||||||
size="small"
|
<IconButton
|
||||||
aria-label="复制"
|
size="small"
|
||||||
onClick={() => {
|
aria-label="复制"
|
||||||
navigator.clipboard.writeText(message.content);
|
onClick={() => {
|
||||||
// Could add a toast here
|
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) } }}
|
}}
|
||||||
>
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||||
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
>
|
||||||
</IconButton>
|
<ContentCopyRounded sx={{ fontSize: 16 }} />
|
||||||
<IconButton
|
</IconButton>
|
||||||
size="small"
|
</Tooltip>
|
||||||
aria-label="重新生成"
|
<Tooltip title="重新生成">
|
||||||
onClick={() => {
|
<IconButton
|
||||||
onRegenerate();
|
size="small"
|
||||||
}}
|
aria-label="重新生成"
|
||||||
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
onClick={() => {
|
||||||
>
|
onRegenerate();
|
||||||
<RefreshRounded sx={{ fontSize: 16 }} />
|
}}
|
||||||
</IconButton>
|
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
|
||||||
|
>
|
||||||
|
<RefreshRounded sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
</Paper>
|
</Paper>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||||||
actionLabel: "显示",
|
actionLabel: "显示",
|
||||||
color: "#73c0de",
|
color: "#73c0de",
|
||||||
},
|
},
|
||||||
|
render_junctions: {
|
||||||
|
label: "渲染节点",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "应用渲染",
|
||||||
|
color: "#3b82f6",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
@@ -261,6 +267,9 @@ function getToolDescription(toolCall: ToolCall): string {
|
|||||||
case "show_chart": {
|
case "show_chart": {
|
||||||
return (params.title as string | undefined) ?? "数据图表";
|
return (params.title as string | undefined) ?? "数据图表";
|
||||||
}
|
}
|
||||||
|
case "render_junctions": {
|
||||||
|
return (params.render_ref as string | undefined) ?? "渲染引用";
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -383,6 +392,17 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|||||||
xAxisName: params.x_axis_name as string | undefined,
|
xAxisName: params.x_axis_name as string | undefined,
|
||||||
yAxisName: params.y_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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
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 { AgentComposer } from "./AgentComposer";
|
||||||
import { AgentHeader } from "./AgentHeader";
|
import { AgentHeader } from "./AgentHeader";
|
||||||
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
import { AgentHistoryPanel } from "./AgentHistoryPanel";
|
||||||
@@ -19,10 +26,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
||||||
|
"deepseek/deepseek-v4-pro",
|
||||||
|
);
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const isDesktop = useMediaQuery(theme.breakpoints.up("sm"));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
speechState,
|
speechState,
|
||||||
@@ -54,6 +65,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
branchTransition,
|
branchTransition,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
sessionTitle,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
regenerate,
|
regenerate,
|
||||||
editAndResubmit,
|
editAndResubmit,
|
||||||
@@ -65,6 +77,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
} = useAgentChatSession({
|
} = useAgentChatSession({
|
||||||
onToolCall: handleToolCall,
|
onToolCall: handleToolCall,
|
||||||
onBeforeSend: stopListening,
|
onBeforeSend: stopListening,
|
||||||
|
getModel: () => selectedModel,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -152,6 +165,32 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
};
|
};
|
||||||
}, [isResizing]);
|
}, [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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
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} />
|
<Blob color={alpha(theme.palette.success.light, 0.18)} size={200} top="80%" left="-10%" delay={4} />
|
||||||
|
|
||||||
<AgentHeader
|
<AgentHeader
|
||||||
|
sessionTitle={sessionTitle}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isHistoryOpen={isHistoryOpen}
|
isHistoryOpen={isHistoryOpen}
|
||||||
onHistoryToggle={handleHistoryToggle}
|
onHistoryToggle={handleHistoryToggle}
|
||||||
@@ -298,6 +338,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onStartListening={startListening}
|
onStartListening={startListening}
|
||||||
onStopListening={stopListening}
|
onStopListening={stopListening}
|
||||||
onPresetSelect={handlePresetPromptSelect}
|
onPresetSelect={handlePresetPromptSelect}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export type ChatProgress = {
|
|||||||
status: "running" | "completed" | "error";
|
status: "running" | "completed" | "error";
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
elapsedSnapshotAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
|
export type AgentArtifactKind = "chart" | "map" | "panel" | "tool";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream";
|
import { abortAgentChat, forkAgentChat, streamAgentChat } from "@/lib/chatStream";
|
||||||
import type { StreamEvent } from "@/lib/chatStream";
|
import type { AgentModel, StreamEvent } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
AgentArtifact,
|
AgentArtifact,
|
||||||
BranchGroup,
|
BranchGroup,
|
||||||
@@ -37,6 +37,7 @@ type UseAgentChatSessionOptions = {
|
|||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
onBeforeSend?: () => void;
|
onBeforeSend?: () => void;
|
||||||
|
getModel?: () => AgentModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PromptRunOptions = {
|
type PromptRunOptions = {
|
||||||
@@ -53,12 +54,39 @@ const upsertProgress = (
|
|||||||
) => {
|
) => {
|
||||||
const next = [...(progress ?? [])];
|
const next = [...(progress ?? [])];
|
||||||
const index = next.findIndex((item) => item.id === event.id);
|
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 = {
|
const nextItem: ChatProgress = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
phase: event.phase,
|
phase: event.phase,
|
||||||
status: event.status,
|
status: event.status,
|
||||||
title: event.title,
|
title: event.title,
|
||||||
detail: event.detail,
|
detail: event.detail,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
elapsedSnapshotAt,
|
||||||
|
durationMs,
|
||||||
};
|
};
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
next[index] = nextItem;
|
next[index] = nextItem;
|
||||||
@@ -69,9 +97,24 @@ const upsertProgress = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
||||||
progress?.map((item) =>
|
progress?.map((item) => {
|
||||||
item.status === "running" ? { ...item, status: "completed" as const } : 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 createUserMessage = (content: string, branchRootId?: string): Message => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
@@ -95,6 +138,7 @@ const messagesEqual = (left: Message[], right: Message[]) =>
|
|||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
onToolCall,
|
onToolCall,
|
||||||
onBeforeSend,
|
onBeforeSend,
|
||||||
|
getModel,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
const storageSessionIdRef = useRef<string | undefined>(undefined);
|
const storageSessionIdRef = useRef<string | undefined>(undefined);
|
||||||
const hydrationCompletedRef = useRef(false);
|
const hydrationCompletedRef = useRef(false);
|
||||||
@@ -275,6 +319,7 @@ export const useAgentChatSession = ({
|
|||||||
await streamAgentChat({
|
await streamAgentChat({
|
||||||
message: prompt,
|
message: prompt,
|
||||||
sessionId: sessionIdOverride ?? sessionIdRef.current,
|
sessionId: sessionIdOverride ?? sessionIdRef.current,
|
||||||
|
model: getModel?.(),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onEvent: (event) => {
|
onEvent: (event) => {
|
||||||
if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) {
|
if ("sessionId" in event && event.sessionId && event.sessionId !== sessionIdRef.current) {
|
||||||
@@ -406,7 +451,7 @@ export const useAgentChatSession = ({
|
|||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
|
[appendArtifact, getModel, isHydrating, isStreaming, messages, onBeforeSend, onToolCall],
|
||||||
);
|
);
|
||||||
|
|
||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
@@ -722,6 +767,7 @@ export const useAgentChatSession = ({
|
|||||||
branchTransition,
|
branchTransition,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
sessionTitle,
|
||||||
sessionId,
|
sessionId,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
regenerate,
|
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 {
|
return {
|
||||||
action: null,
|
action: null,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
@@ -248,6 +266,11 @@ export const useAgentToolActions = () => {
|
|||||||
event.params,
|
event.params,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const normalizedAction =
|
||||||
|
action?.type === "render_junctions"
|
||||||
|
? { ...action, sessionId: event.sessionId }
|
||||||
|
: action;
|
||||||
|
|
||||||
options.appendArtifact(options.assistantMessageId, {
|
options.appendArtifact(options.assistantMessageId, {
|
||||||
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${event.tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
tool: event.tool,
|
tool: event.tool,
|
||||||
@@ -257,8 +280,8 @@ export const useAgentToolActions = () => {
|
|||||||
params: event.params,
|
params: event.params,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (action) {
|
if (normalizedAction) {
|
||||||
dispatchToolAction(action);
|
dispatchToolAction(normalizedAction);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatchToolAction],
|
[dispatchToolAction],
|
||||||
|
|||||||
@@ -17,18 +17,14 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FormatListBulleted,
|
FormatListBulleted,
|
||||||
} from "@mui/icons-material";
|
} 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 { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
|
||||||
import AnalysisParameters from "./AnalysisParameters";
|
import AnalysisParameters from "./AnalysisParameters";
|
||||||
import SchemeQuery from "./SchemeQuery";
|
import SchemeQuery from "./SchemeQuery";
|
||||||
import RecognitionResults from "./RecognitionResults";
|
import RecognitionResults from "./RecognitionResults";
|
||||||
|
import { applyJunctionAreaRender } from "./applyJunctionAreaRender";
|
||||||
import { getAreaColor } from "./utils";
|
import { getAreaColor } from "./utils";
|
||||||
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
import { LeakageResultDetail, LeakageSchemeRecord } from "./types";
|
||||||
import { config } from "@/config/config";
|
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
value,
|
value,
|
||||||
@@ -82,101 +78,26 @@ const DMALeakDetectionPanel: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return;
|
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(
|
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
|
const areaIds = (loadedResult?.areas ?? []).length
|
||||||
? loadedResult.areas.map((area) => String(area.area_id))
|
? (loadedResult?.areas ?? []).map((area) => String(area.area_id))
|
||||||
: fallbackAreaIds;
|
: fallbackAreaIds;
|
||||||
if (areaIds.length === 0) {
|
const areaColors = Object.fromEntries(
|
||||||
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
areaIds.map((areaId) => [areaId, getAreaColor(areaId)]),
|
||||||
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 dmaStyle: FlatStyleLike = {
|
|
||||||
...config.MAP_DEFAULT_STYLE,
|
|
||||||
"circle-fill-color": ["case", ...fillCases, defaultFillColor],
|
|
||||||
"circle-stroke-color": ["case", ...fillCases, defaultStrokeColor],
|
|
||||||
};
|
|
||||||
junctionLayer.setStyle(dmaStyle);
|
|
||||||
|
|
||||||
return () => {
|
return applyJunctionAreaRender(
|
||||||
source.un("tileloadend", listener);
|
map,
|
||||||
junctionLayer.setStyle(config.MAP_DEFAULT_STYLE as FlatStyleLike);
|
{
|
||||||
};
|
nodeAreaMap: loadedResult?.node_area_map ?? {},
|
||||||
|
areaIds,
|
||||||
|
areaColors,
|
||||||
|
},
|
||||||
|
{ propertyKey: DMA_AREA_INDEX_PROPERTY },
|
||||||
|
);
|
||||||
}, [map, loadedResult]);
|
}, [map, loadedResult]);
|
||||||
|
|
||||||
return (
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 { useData, useMap } from "../MapComponent";
|
||||||
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
|
import ToolbarButton from "@/components/olmap/common/ToolbarButton";
|
||||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
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 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 SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
|
||||||
|
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import { Style, Stroke, Fill, Circle } from "ol/style";
|
import { Style, Stroke, Fill, Circle } from "ol/style";
|
||||||
import Feature from "ol/Feature";
|
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 StyleEditorPanel from "./StyleEditorPanel";
|
||||||
import { LayerStyleState } from "./StyleEditorPanel";
|
import { LayerStyleState } from "./StyleEditorPanel";
|
||||||
import StyleLegend from "./StyleLegend"; // 引入图例组件
|
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 { 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 { config } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3h } from "@utils/units";
|
|
||||||
|
|
||||||
// 添加接口定义隐藏按钮的props
|
// 添加接口定义隐藏按钮的props
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
@@ -82,90 +80,14 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
endTime?: string;
|
endTime?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Wire up chat tool actions (locate, view_history, view_scada)
|
useToolbarChatActions({
|
||||||
useChatToolActionHandler(
|
setHighlightFeatures,
|
||||||
useCallback(
|
setChatPanelFeatureInfos,
|
||||||
(action) => {
|
setChatPanelType,
|
||||||
const geojsonFormat = new GeoJSON();
|
setChatPanelTimeRange,
|
||||||
const zoomToFeatures = (
|
setShowHistoryPanel,
|
||||||
features: Feature[],
|
setActiveTools,
|
||||||
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],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
// 样式状态管理 - 在 Toolbar 中管理,带有默认样式
|
||||||
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
||||||
@@ -527,306 +449,10 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
if (currentTime !== -1 && queryType) queryComputedProperties();
|
if (currentTime !== -1 && queryType) queryComputedProperties();
|
||||||
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
}, [highlightFeatures, currentTime, selectedDate, queryType, schemeName, schemeType, showPropertyPanel]);
|
||||||
|
|
||||||
// 从要素属性中提取属性面板需要的数据
|
const propertyPanelData = useMemo(
|
||||||
const getFeatureProperties = useCallback(() => {
|
() => buildFeatureProperties(highlightFeatures[0], computedProperties),
|
||||||
if (highlightFeatures.length === 0) return {};
|
[highlightFeatures, computedProperties],
|
||||||
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:00,00:05:00,00:10:00,00:30:00,01: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]);
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
@@ -879,7 +505,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{showPropertyPanel && (
|
{showPropertyPanel && (
|
||||||
<PropertyPanel
|
<PropertyPanel
|
||||||
{...getFeatureProperties()}
|
{...propertyPanelData}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
deactivateTool("info");
|
deactivateTool("info");
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== "info"));
|
setActiveTools((prev) => prev.filter((t) => t !== "info"));
|
||||||
@@ -893,115 +519,20 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setLayerStyleStates={setLayerStyleStates}
|
setLayerStyleStates={setLayerStyleStates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showHistoryPanel &&
|
<ToolbarHistoryPanel
|
||||||
(chatPanelType === "none" && chatPanelFeatureInfos ? (
|
showHistoryPanel={showHistoryPanel}
|
||||||
<SCADADataPanel
|
chatPanelType={chatPanelType}
|
||||||
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
|
chatPanelFeatureInfos={chatPanelFeatureInfos}
|
||||||
visible={showHistoryPanel}
|
chatPanelTimeRange={chatPanelTimeRange}
|
||||||
start_time={chatPanelTimeRange?.startTime}
|
highlightFeatures={highlightFeatures}
|
||||||
end_time={chatPanelTimeRange?.endTime}
|
HistoryPanel={HistoryPanel}
|
||||||
onClose={() => {
|
schemeName={schemeName}
|
||||||
deactivateTool("history");
|
queryType={queryType}
|
||||||
setActiveTools((prev) => prev.filter((t) => t !== "history"));
|
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"));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 图例显示 */}
|
{/* 图例显示 */}
|
||||||
{activeLegendConfigs.length > 0 && (
|
{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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -51,6 +51,7 @@ describe("streamAgentChat", () => {
|
|||||||
|
|
||||||
await streamAgentChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
|
model: "deepseek/deepseek-v4-pro",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +61,11 @@ describe("streamAgentChat", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "hi",
|
||||||
|
session_id: undefined,
|
||||||
|
model: "deepseek/deepseek-v4-pro",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+24
-1
@@ -1,9 +1,13 @@
|
|||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
export type AgentModel =
|
||||||
|
| "deepseek/deepseek-v4-flash"
|
||||||
|
| "deepseek/deepseek-v4-pro";
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| { type: "token"; sessionId: string; content: string }
|
| { type: "token"; sessionId: string; content: string }
|
||||||
| { type: "done"; sessionId: string }
|
| { type: "done"; sessionId: string; totalDurationMs?: number }
|
||||||
| { type: "session_title"; sessionId: string; title: string }
|
| { type: "session_title"; sessionId: string; title: string }
|
||||||
| {
|
| {
|
||||||
type: "progress";
|
type: "progress";
|
||||||
@@ -13,12 +17,17 @@ export type StreamEvent =
|
|||||||
status: "running" | "completed" | "error";
|
status: "running" | "completed" | "error";
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
endedAt?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "error";
|
type: "error";
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
message: string;
|
message: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
totalDurationMs?: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "tool_call";
|
type: "tool_call";
|
||||||
@@ -30,6 +39,7 @@ export type StreamEvent =
|
|||||||
type StreamOptions = {
|
type StreamOptions = {
|
||||||
message: string;
|
message: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
model?: AgentModel;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
onEvent: (event: StreamEvent) => void;
|
onEvent: (event: StreamEvent) => void;
|
||||||
};
|
};
|
||||||
@@ -80,6 +90,7 @@ const resolveToolParams = (
|
|||||||
export const streamAgentChat = async ({
|
export const streamAgentChat = async ({
|
||||||
message,
|
message,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
model,
|
||||||
signal,
|
signal,
|
||||||
onEvent,
|
onEvent,
|
||||||
}: StreamOptions) => {
|
}: StreamOptions) => {
|
||||||
@@ -97,6 +108,7 @@ export const streamAgentChat = async ({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message,
|
message,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
|
model,
|
||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
userHeaderMode: "include",
|
userHeaderMode: "include",
|
||||||
@@ -162,6 +174,11 @@ export const streamAgentChat = async ({
|
|||||||
phase?: string;
|
phase?: string;
|
||||||
status?: "running" | "completed" | "error";
|
status?: "running" | "completed" | "error";
|
||||||
title?: string;
|
title?: string;
|
||||||
|
started_at?: number;
|
||||||
|
ended_at?: number;
|
||||||
|
elapsed_ms?: number;
|
||||||
|
duration_ms?: number;
|
||||||
|
total_duration_ms?: number;
|
||||||
};
|
};
|
||||||
if (event === "token") {
|
if (event === "token") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -178,11 +195,16 @@ export const streamAgentChat = async ({
|
|||||||
status: parsed.status ?? "running",
|
status: parsed.status ?? "running",
|
||||||
title: parsed.title ?? "正在处理",
|
title: parsed.title ?? "正在处理",
|
||||||
detail: parsed.detail,
|
detail: parsed.detail,
|
||||||
|
startedAt: parsed.started_at,
|
||||||
|
endedAt: parsed.ended_at,
|
||||||
|
elapsedMs: parsed.elapsed_ms,
|
||||||
|
durationMs: parsed.duration_ms,
|
||||||
});
|
});
|
||||||
} else if (event === "done") {
|
} else if (event === "done") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "done",
|
type: "done",
|
||||||
sessionId: parsed.session_id ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
|
totalDurationMs: parsed.total_duration_ms,
|
||||||
});
|
});
|
||||||
} else if (event === "session_title") {
|
} else if (event === "session_title") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -196,6 +218,7 @@ export const streamAgentChat = async ({
|
|||||||
sessionId: parsed.session_id,
|
sessionId: parsed.session_id,
|
||||||
message: parsed.message ?? "unknown error",
|
message: parsed.message ?? "unknown error",
|
||||||
detail: parsed.detail,
|
detail: parsed.detail,
|
||||||
|
totalDurationMs: parsed.total_duration_ms,
|
||||||
});
|
});
|
||||||
} else if (event === "tool_call") {
|
} else if (event === "tool_call") {
|
||||||
onEvent({
|
onEvent({
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export type ChatToolAction =
|
|||||||
series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>;
|
series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>;
|
||||||
xAxisName?: string;
|
xAxisName?: string;
|
||||||
yAxisName?: string;
|
yAxisName?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "render_junctions";
|
||||||
|
renderRef: string;
|
||||||
|
sessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ChatToolState {
|
interface ChatToolState {
|
||||||
|
|||||||
Reference in New Issue
Block a user