Files
TJWaterFrontend_Refine/src/components/chat/ChatToolCallBlock.tsx
T

523 lines
15 KiB
TypeScript

"use client";
import React, { useCallback, useState } from "react";
import {
Box,
Button,
Chip,
Paper,
Stack,
Typography,
alpha,
useTheme,
} 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 {
useChatToolStore,
type ChatToolAction,
} from "@/store/chatToolStore";
import type { ToolCall } from "./chatMessageSections";
/* ------------------------------------------------------------------ */
/* 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 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",
},
};
/* ---------- helpers ---------- */
function getToolDescription(toolCall: ToolCall): string {
const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
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 = normalizeIds();
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) ?? "数据图表";
}
default:
return "";
}
}
function buildAction(toolCall: ToolCall): ChatToolAction | null {
const { params } = toolCall;
const normalizeIds = (): string[] => {
const rawIds = params.ids;
if (Array.isArray(rawIds)) {
return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0);
}
if (typeof rawIds === "string") {
return rawIds
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
return [];
};
const resolveScadaFeatureInfos = (): [string, string][] => {
const rawFeatureInfos = params.feature_infos;
if (Array.isArray(rawFeatureInfos)) {
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: normalizeIds(),
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: normalizeIds(),
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,
};
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 meta: ToolMeta = TOOL_META[toolCall.tool] ?? {
label: toolCall.tool,
icon: null,
actionLabel: "执行",
color: theme.palette.primary.main,
};
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.5,
mb: 1,
p: 1.5,
borderRadius: 3,
border: `1px solid ${alpha(meta.color, 0.25)}`,
bgcolor: alpha(meta.color, 0.04),
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
{/* Icon */}
<Box
sx={{
width: 32,
height: 32,
borderRadius: 2,
bgcolor: alpha(meta.color, 0.12),
display: "flex",
alignItems: "center",
justifyContent: "center",
color: meta.color,
flexShrink: 0,
}}
>
{meta.icon}
</Box>
{/* Description */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "text.primary",
display: "block",
}}
>
{meta.label}
</Typography>
{description && (
<Typography
variant="caption"
sx={{
color: "text.secondary",
fontSize: "0.75rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{description}
</Typography>
)}
</Box>
{/* Action */}
{executed ? (
<Chip
icon={<CheckCircleRounded sx={{ fontSize: 16 }} />}
label="已执行"
size="small"
sx={{
bgcolor: alpha("#4caf50", 0.1),
color: "#4caf50",
fontWeight: 600,
fontSize: "0.75rem",
}}
/>
) : (
<Button
size="small"
variant="outlined"
onClick={handleExecute}
sx={{
borderColor: alpha(meta.color, 0.4),
color: meta.color,
fontWeight: 600,
fontSize: "0.75rem",
borderRadius: 2,
textTransform: "none",
whiteSpace: "nowrap",
"&:hover": {
borderColor: meta.color,
bgcolor: alpha(meta.color, 0.08),
},
}}
>
{meta.actionLabel}
</Button>
)}
</Stack>
</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;
}
};