639 lines
19 KiB
TypeScript
639 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useState } from "react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
Paper,
|
|
Stack,
|
|
Typography,
|
|
alpha,
|
|
useTheme,
|
|
Collapse,
|
|
IconButton,
|
|
} from "@mui/material";
|
|
import LocationOnRounded from "@mui/icons-material/LocationOnRounded";
|
|
import TimelineRounded from "@mui/icons-material/TimelineRounded";
|
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
|
|
|
import {
|
|
useChatToolStore,
|
|
type ChatToolAction,
|
|
} from "@/store/chatToolStore";
|
|
import type { ToolCall } from "./chatMessageSections";
|
|
import {
|
|
APPLY_LAYER_STYLE_TOOL,
|
|
describeApplyLayerStyle,
|
|
parseApplyLayerStylePayload,
|
|
} from "./toolCallStyleHelpers";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Interactive card rendered inside a chat bubble for tool actions */
|
|
/* (locate nodes/pipes, open history/SCADA panels). */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type ToolMeta = {
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
actionLabel: string;
|
|
color: string;
|
|
};
|
|
|
|
const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
|
|
locate_features: "",
|
|
locate_junctions: "geo_junctions_mat",
|
|
locate_pipes: "geo_pipes_mat",
|
|
locate_valves: "geo_valves",
|
|
locate_reservoirs: "geo_reservoirs",
|
|
locate_pumps: "geo_pumps",
|
|
locate_tanks: "geo_tanks",
|
|
};
|
|
|
|
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
|
|
const LOCATE_ID_PARAM_KEYS = [
|
|
"ids",
|
|
"id",
|
|
"feature_ids",
|
|
"feature_id",
|
|
"node_ids",
|
|
"node_id",
|
|
"junction_ids",
|
|
"junction_id",
|
|
"pipe_ids",
|
|
"pipe_id",
|
|
"valve_ids",
|
|
"valve_id",
|
|
"reservoir_ids",
|
|
"reservoir_id",
|
|
"pump_ids",
|
|
"pump_id",
|
|
"tank_ids",
|
|
"tank_id",
|
|
] as const;
|
|
|
|
const TOOL_META: Record<string, ToolMeta> = {
|
|
locate_features: {
|
|
label: "定位要素",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#5470c6",
|
|
},
|
|
locate_junctions: {
|
|
label: "定位节点",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#5470c6",
|
|
},
|
|
locate_pipes: {
|
|
label: "定位管道",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#91cc75",
|
|
},
|
|
locate_valves: {
|
|
label: "定位阀门",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#9a60b4",
|
|
},
|
|
locate_reservoirs: {
|
|
label: "定位水源",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#ea7ccc",
|
|
},
|
|
locate_pumps: {
|
|
label: "定位泵站",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#fc8452",
|
|
},
|
|
locate_tanks: {
|
|
label: "定位水池",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "定位到地图",
|
|
color: "#3ba272",
|
|
},
|
|
view_history: {
|
|
label: "查看计算结果",
|
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "查看曲线",
|
|
color: "#fac858",
|
|
},
|
|
view_scada: {
|
|
label: "查看监测数据",
|
|
icon: <SensorsRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "查看数据",
|
|
color: "#ee6666",
|
|
},
|
|
show_chart: {
|
|
label: "显示图表",
|
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "显示",
|
|
color: "#73c0de",
|
|
},
|
|
render_junctions: {
|
|
label: "渲染节点",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "应用渲染",
|
|
color: "#3b82f6",
|
|
},
|
|
[APPLY_LAYER_STYLE_TOOL]: {
|
|
label: "图层样式",
|
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "应用样式",
|
|
color: "#14b8a6",
|
|
},
|
|
};
|
|
|
|
/* ---------- helpers ---------- */
|
|
|
|
function normalizeLocateIds(params: Record<string, unknown>): string[] {
|
|
for (const key of LOCATE_ID_PARAM_KEYS) {
|
|
const rawValue = params[key];
|
|
if (Array.isArray(rawValue)) {
|
|
const normalized = rawValue
|
|
.map((id) => String(id).trim())
|
|
.filter(Boolean);
|
|
if (normalized.length > 0) {
|
|
return normalized;
|
|
}
|
|
}
|
|
if (typeof rawValue === "string" || typeof rawValue === "number") {
|
|
const normalized = String(rawValue)
|
|
.split(",")
|
|
.map((id) => id.trim())
|
|
.filter(Boolean);
|
|
if (normalized.length > 0) {
|
|
return normalized;
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function getToolDescription(toolCall: ToolCall): string {
|
|
const { params } = toolCall;
|
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
|
const rawFeatureInfos = params.feature_infos;
|
|
if (Array.isArray(rawFeatureInfos)) {
|
|
const normalizedFeatureInfos = rawFeatureInfos
|
|
.map((item) => (Array.isArray(item) ? item : null))
|
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
|
.map(
|
|
(item) =>
|
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
|
string,
|
|
string,
|
|
],
|
|
)
|
|
.filter(([id]) => id.trim().length > 0);
|
|
if (normalizedFeatureInfos.length > 0) {
|
|
return normalizedFeatureInfos;
|
|
}
|
|
}
|
|
|
|
const rawDeviceIds =
|
|
params.device_ids ??
|
|
params.deviceId ??
|
|
params.device_id ??
|
|
params.id ??
|
|
params.ids;
|
|
const deviceIds = Array.isArray(rawDeviceIds)
|
|
? rawDeviceIds.map((id) => String(id))
|
|
: typeof rawDeviceIds === "string"
|
|
? rawDeviceIds
|
|
.split(",")
|
|
.map((id) => id.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
return deviceIds.map((id) => [id, "scada"]);
|
|
};
|
|
const resolveTimeRange = () => ({
|
|
startTime:
|
|
(params.start_time as string | undefined) ??
|
|
(params.startTime as string | undefined) ??
|
|
(params.from as string | undefined) ??
|
|
(params.start as string | undefined),
|
|
endTime:
|
|
(params.end_time as string | undefined) ??
|
|
(params.endTime as string | undefined) ??
|
|
(params.to as string | undefined) ??
|
|
(params.end as string | undefined),
|
|
});
|
|
const resolveLocateFeatureType = (): string => {
|
|
const rawType = params.feature_type;
|
|
if (typeof rawType === "string" && rawType.trim()) {
|
|
return rawType.trim().toLowerCase();
|
|
}
|
|
return "";
|
|
};
|
|
switch (toolCall.tool) {
|
|
case "locate_features":
|
|
case "locate_junctions":
|
|
case "locate_pipes":
|
|
case "locate_valves":
|
|
case "locate_reservoirs":
|
|
case "locate_pumps":
|
|
case "locate_tanks": {
|
|
const ids = normalizeLocateIds(params);
|
|
const idsText =
|
|
ids.length > 3
|
|
? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个`
|
|
: ids.join(", ");
|
|
if (toolCall.tool !== "locate_features") {
|
|
return idsText;
|
|
}
|
|
const featureType = resolveLocateFeatureType();
|
|
if (!featureType) {
|
|
return idsText;
|
|
}
|
|
return idsText
|
|
? `${featureType} · ${idsText}`
|
|
: featureType;
|
|
}
|
|
case "view_history":
|
|
case "view_scada": {
|
|
const infos =
|
|
toolCall.tool === "view_scada"
|
|
? resolveScadaFeatureInfos()
|
|
: ((params.feature_infos as [string, string][] | undefined) ?? []);
|
|
const names = infos.map(([id]) => id);
|
|
const base =
|
|
names.length > 3
|
|
? `${names.slice(0, 3).join(", ")} 等 ${names.length} 个`
|
|
: names.join(", ");
|
|
const { startTime, endTime } = resolveTimeRange();
|
|
if (!startTime && !endTime) {
|
|
return base;
|
|
}
|
|
const rangeLabel = `时间段: ${startTime ?? "--"} ~ ${endTime ?? "--"}`;
|
|
return base ? `${base} · ${rangeLabel}` : rangeLabel;
|
|
}
|
|
case "show_chart": {
|
|
return (params.title as string | undefined) ?? "数据图表";
|
|
}
|
|
case "render_junctions": {
|
|
return (params.render_ref as string | undefined) ?? "渲染引用";
|
|
}
|
|
case APPLY_LAYER_STYLE_TOOL: {
|
|
const payload = parseApplyLayerStylePayload(params);
|
|
return payload ? describeApplyLayerStyle(payload) : "图层样式";
|
|
}
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function buildAction(toolCall: ToolCall): ChatToolAction | null {
|
|
const { params } = toolCall;
|
|
const resolveScadaFeatureInfos = (): [string, string][] => {
|
|
const rawFeatureInfos = params.feature_infos;
|
|
if (Array.isArray(rawFeatureInfos)) {
|
|
const normalizedFeatureInfos = rawFeatureInfos
|
|
.map((item) => (Array.isArray(item) ? item : null))
|
|
.filter((item): item is [unknown, unknown] => Boolean(item))
|
|
.map(
|
|
(item) =>
|
|
[String(item[0] ?? ""), String(item[1] ?? "scada")] as [
|
|
string,
|
|
string,
|
|
],
|
|
)
|
|
.filter(([id]) => id.trim().length > 0);
|
|
if (normalizedFeatureInfos.length > 0) {
|
|
return normalizedFeatureInfos;
|
|
}
|
|
}
|
|
|
|
const rawDeviceIds =
|
|
params.device_ids ??
|
|
params.deviceId ??
|
|
params.device_id ??
|
|
params.id ??
|
|
params.ids;
|
|
const deviceIds = Array.isArray(rawDeviceIds)
|
|
? rawDeviceIds.map((id) => String(id))
|
|
: typeof rawDeviceIds === "string"
|
|
? rawDeviceIds
|
|
.split(",")
|
|
.map((id) => id.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
return deviceIds.map((id) => [id, "scada"]);
|
|
};
|
|
const resolveTimeRange = () => ({
|
|
startTime:
|
|
(params.start_time as string | undefined) ??
|
|
(params.startTime as string | undefined) ??
|
|
(params.from as string | undefined) ??
|
|
(params.start as string | undefined),
|
|
endTime:
|
|
(params.end_time as string | undefined) ??
|
|
(params.endTime as string | undefined) ??
|
|
(params.to as string | undefined) ??
|
|
(params.end as string | undefined),
|
|
});
|
|
switch (toolCall.tool) {
|
|
case "locate_features": {
|
|
const featureTypeRaw = params.feature_type;
|
|
const featureType =
|
|
typeof featureTypeRaw === "string"
|
|
? featureTypeRaw.trim().toLowerCase()
|
|
: "";
|
|
const config = locateFeatureTypeToConfig(featureType);
|
|
if (!config) return null;
|
|
return {
|
|
type: "locate_features",
|
|
ids: normalizeLocateIds(params),
|
|
layer: config.layer,
|
|
geometryKind: config.geometryKind,
|
|
};
|
|
}
|
|
case "locate_junctions":
|
|
case "locate_pipes":
|
|
case "locate_valves":
|
|
case "locate_reservoirs":
|
|
case "locate_pumps":
|
|
case "locate_tanks": {
|
|
const layer = LOCATE_TOOL_TO_LAYER[toolCall.tool];
|
|
if (!layer) return null;
|
|
return {
|
|
type: "locate_features",
|
|
ids: normalizeLocateIds(params),
|
|
layer,
|
|
geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point",
|
|
};
|
|
}
|
|
case "view_history": {
|
|
const historyRange = resolveTimeRange();
|
|
return {
|
|
type: "view_history",
|
|
featureInfos:
|
|
(params.feature_infos as [string, string][] | undefined) ?? [],
|
|
dataType:
|
|
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
|
|
"realtime",
|
|
startTime: historyRange.startTime,
|
|
endTime: historyRange.endTime,
|
|
};
|
|
}
|
|
case "view_scada": {
|
|
const scadaRange = resolveTimeRange();
|
|
return {
|
|
type: "view_scada",
|
|
featureInfos: resolveScadaFeatureInfos(),
|
|
startTime: scadaRange.startTime,
|
|
endTime: scadaRange.endTime,
|
|
};
|
|
}
|
|
case "show_chart":
|
|
return {
|
|
type: "show_chart",
|
|
title: params.title as string | undefined,
|
|
chartType:
|
|
(params.chart_type as "line" | "bar" | "pie" | undefined) ?? "line",
|
|
xData: (params.x_data as string[] | undefined) ?? [],
|
|
series:
|
|
(params.series as
|
|
| Array<{ name: string; data: number[]; type?: "line" | "bar" }>
|
|
| undefined) ?? [],
|
|
xAxisName: params.x_axis_name as string | undefined,
|
|
yAxisName: params.y_axis_name as string | undefined,
|
|
};
|
|
case "render_junctions": {
|
|
const renderRef =
|
|
typeof params.render_ref === "string" ? params.render_ref.trim() : "";
|
|
if (!renderRef) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: "render_junctions",
|
|
renderRef,
|
|
};
|
|
}
|
|
case APPLY_LAYER_STYLE_TOOL: {
|
|
const payload = parseApplyLayerStylePayload(params);
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
return {
|
|
type: "apply_layer_style",
|
|
layerId: payload.layerId,
|
|
resetToDefault: payload.resetToDefault,
|
|
styleConfig: payload.styleConfig,
|
|
};
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* ---------- component ---------- */
|
|
|
|
export interface ChatToolCallBlockProps {
|
|
toolCall: ToolCall;
|
|
}
|
|
|
|
export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
|
|
toolCall,
|
|
}) => {
|
|
const theme = useTheme();
|
|
const dispatch = useChatToolStore((s) => s.dispatch);
|
|
const [executed, setExecuted] = useState(false);
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
|
|
label: toolCall.tool,
|
|
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
|
|
actionLabel: "执行",
|
|
color: "#00acc1",
|
|
};
|
|
|
|
const description = getToolDescription(toolCall);
|
|
|
|
const handleExecute = useCallback(() => {
|
|
const action = buildAction(toolCall);
|
|
if (action) {
|
|
dispatch(action);
|
|
setExecuted(true);
|
|
}
|
|
}, [toolCall, dispatch]);
|
|
|
|
return (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
mt: 1,
|
|
mb: 1,
|
|
overflow: "hidden",
|
|
borderRadius: 4,
|
|
border: `1px solid ${alpha(meta.color, 0.3)}`,
|
|
bgcolor: alpha(meta.color, 0.05),
|
|
backdropFilter: "blur(12px)",
|
|
transition: "all 0.3s ease",
|
|
"&:hover": {
|
|
bgcolor: alpha(meta.color, 0.08),
|
|
border: `1px solid ${alpha(meta.color, 0.4)}`,
|
|
}
|
|
}}
|
|
>
|
|
<Box
|
|
onClick={() => setExpanded(!expanded)}
|
|
sx={{
|
|
p: 1.5,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
cursor: "pointer",
|
|
gap: 1.5,
|
|
}}
|
|
>
|
|
{/* Icon */}
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: "50%",
|
|
bgcolor: alpha(meta.color, 0.15),
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: meta.color,
|
|
flexShrink: 0,
|
|
boxShadow: `0 2px 8px ${alpha(meta.color, 0.2)}`,
|
|
}}
|
|
>
|
|
{meta.icon}
|
|
</Box>
|
|
|
|
{/* Title */}
|
|
<Box sx={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 1 }}>
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontWeight: 700,
|
|
color: "text.primary",
|
|
}}
|
|
>
|
|
{meta.label}
|
|
</Typography>
|
|
{!expanded && description && (
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: "text.secondary",
|
|
fontSize: "0.75rem",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
maxWidth: 180,
|
|
opacity: 0.8,
|
|
}}
|
|
>
|
|
• {description}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
<IconButton size="small" sx={{ color: "text.secondary", width: 28, height: 28, pointerEvents: "none" }}>
|
|
{expanded ? <KeyboardArrowUpRounded fontSize="small" /> : <KeyboardArrowDownRounded fontSize="small" />}
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
|
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
|
|
<Stack direction="column" spacing={1.5}>
|
|
{description && (
|
|
<Box sx={{
|
|
p: 1.5,
|
|
borderRadius: 3,
|
|
bgcolor: alpha("#000", 0.03),
|
|
border: `1px solid ${alpha("#000", 0.05)}`,
|
|
}}>
|
|
<Typography variant="caption" color="text.secondary" fontWeight={700} sx={{ mb: 0.5, display: 'block' }}>
|
|
执行参数
|
|
</Typography>
|
|
<Typography variant="body2" color="text.primary" sx={{ wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
|
{description}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
<Stack direction="row" justifyContent="flex-end">
|
|
{executed ? (
|
|
<Chip
|
|
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
|
|
label="已执行"
|
|
size="small"
|
|
sx={{
|
|
bgcolor: alpha("#00e676", 0.15),
|
|
color: "#00c853",
|
|
fontWeight: 700,
|
|
fontSize: "0.75rem",
|
|
}}
|
|
/>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
disableElevation
|
|
onClick={(e) => { e.stopPropagation(); handleExecute(); }}
|
|
sx={{
|
|
bgcolor: meta.color,
|
|
color: "#fff",
|
|
fontWeight: 700,
|
|
fontSize: "0.8rem",
|
|
borderRadius: 2.5,
|
|
px: 2,
|
|
textTransform: "none",
|
|
boxShadow: `0 4px 12px ${alpha(meta.color, 0.3)}`,
|
|
"&:hover": {
|
|
bgcolor: meta.color,
|
|
filter: "brightness(0.9)",
|
|
boxShadow: `0 6px 16px ${alpha(meta.color, 0.4)}`,
|
|
},
|
|
}}
|
|
>
|
|
{meta.actionLabel}
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
</Box>
|
|
</Collapse>
|
|
</Paper>
|
|
);
|
|
};
|
|
const locateFeatureTypeToConfig = (
|
|
featureType: string,
|
|
): { layer: string; geometryKind: "point" | "line" } | null => {
|
|
switch (featureType) {
|
|
case "junction":
|
|
case "junctions":
|
|
return { layer: "geo_junctions_mat", geometryKind: "point" };
|
|
case "pipe":
|
|
case "pipes":
|
|
return { layer: "geo_pipes_mat", geometryKind: "line" };
|
|
case "valve":
|
|
case "valves":
|
|
return { layer: "geo_valves", geometryKind: "point" };
|
|
case "reservoir":
|
|
case "reservoirs":
|
|
return { layer: "geo_reservoirs", geometryKind: "point" };
|
|
case "pump":
|
|
case "pumps":
|
|
return { layer: "geo_pumps", geometryKind: "point" };
|
|
case "tank":
|
|
case "tanks":
|
|
return { layer: "geo_tanks", geometryKind: "point" };
|
|
default:
|
|
return null;
|
|
}
|
|
};
|