添加工具调用解析和聊天工具操作处理
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
import * as echarts from "echarts";
|
||||||
|
import { Box, Paper, Typography, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Inline chart rendered inside a chat message bubble. */
|
||||||
|
/* Accepts structured data produced by the AI tool_call. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface ChatChartSeries {
|
||||||
|
name: string;
|
||||||
|
data: number[];
|
||||||
|
type?: "line" | "bar";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInlineChartProps {
|
||||||
|
title?: string;
|
||||||
|
chart_type?: "line" | "bar" | "pie";
|
||||||
|
x_data?: string[];
|
||||||
|
series?: ChatChartSeries[];
|
||||||
|
y_axis_name?: string;
|
||||||
|
x_axis_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#5470c6",
|
||||||
|
"#91cc75",
|
||||||
|
"#fac858",
|
||||||
|
"#ee6666",
|
||||||
|
"#73c0de",
|
||||||
|
"#3ba272",
|
||||||
|
"#fc8452",
|
||||||
|
"#9a60b4",
|
||||||
|
"#ea7ccc",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
||||||
|
title,
|
||||||
|
chart_type: chartType = "line",
|
||||||
|
x_data: xData,
|
||||||
|
series = [],
|
||||||
|
y_axis_name: yAxisName,
|
||||||
|
x_axis_name: xAxisName,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const option = useMemo(() => {
|
||||||
|
if (!series.length) return null;
|
||||||
|
|
||||||
|
/* ---------- Pie chart ---------- */
|
||||||
|
if (chartType === "pie") {
|
||||||
|
const pieData =
|
||||||
|
series[0]?.data.map((value, i) => ({
|
||||||
|
name: xData?.[i] ?? `${i}`,
|
||||||
|
value,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: "item" },
|
||||||
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
radius: ["30%", "60%"],
|
||||||
|
data: pieData,
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
label: { fontSize: 11 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color: COLORS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Line / Bar chart ---------- */
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: "axis", confine: true },
|
||||||
|
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||||
|
grid: {
|
||||||
|
left: "5%",
|
||||||
|
right: "5%",
|
||||||
|
bottom: "12%",
|
||||||
|
top: title ? "18%" : "14%",
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category" as const,
|
||||||
|
boundaryGap: chartType === "bar",
|
||||||
|
data: xData ?? [],
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
rotate: xData && xData.length > 10 ? 30 : 0,
|
||||||
|
},
|
||||||
|
name: xAxisName,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value" as const,
|
||||||
|
scale: true,
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
name: yAxisName,
|
||||||
|
},
|
||||||
|
dataZoom:
|
||||||
|
xData && xData.length > 20
|
||||||
|
? [{ type: "inside", start: 0, end: 100 }]
|
||||||
|
: undefined,
|
||||||
|
series: series.map((s, i) => {
|
||||||
|
const color = COLORS[i % COLORS.length];
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
type: (s.type ?? chartType) as string,
|
||||||
|
data: s.data,
|
||||||
|
symbol: chartType === "line" ? "none" : undefined,
|
||||||
|
smooth: chartType === "line",
|
||||||
|
itemStyle: { color },
|
||||||
|
...(chartType === "line"
|
||||||
|
? {
|
||||||
|
areaStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: alpha(color, 0.3) },
|
||||||
|
{ offset: 1, color: alpha(color, 0.05) },
|
||||||
|
]),
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
color: COLORS,
|
||||||
|
};
|
||||||
|
}, [chartType, xData, series, title, yAxisName, xAxisName]);
|
||||||
|
|
||||||
|
if (!option) {
|
||||||
|
return (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
图表数据为空
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{ px: 2, pt: 1.5, fontWeight: 600, color: "text.primary" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ px: 1, pb: 1 }}>
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
style={{ height: 240, width: "100%" }}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"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 TOOL_META: Record<string, ToolMeta> = {
|
||||||
|
locate_nodes: {
|
||||||
|
label: "定位节点",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#5470c6",
|
||||||
|
},
|
||||||
|
locate_pipes: {
|
||||||
|
label: "定位管道",
|
||||||
|
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
|
||||||
|
actionLabel: "定位到地图",
|
||||||
|
color: "#91cc75",
|
||||||
|
},
|
||||||
|
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 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_nodes":
|
||||||
|
case "locate_pipes": {
|
||||||
|
const ids = (params.ids as string[] | undefined) ?? [];
|
||||||
|
return ids.length > 3
|
||||||
|
? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个`
|
||||||
|
: ids.join(", ");
|
||||||
|
}
|
||||||
|
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 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_nodes":
|
||||||
|
return {
|
||||||
|
type: "locate_nodes",
|
||||||
|
ids: (params.ids as string[] | undefined) ?? [],
|
||||||
|
};
|
||||||
|
case "locate_pipes":
|
||||||
|
return {
|
||||||
|
type: "locate_pipes",
|
||||||
|
ids: (params.ids as string[] | undefined) ?? [],
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -41,7 +41,18 @@ import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
|||||||
|
|
||||||
// Logic
|
// Logic
|
||||||
import { streamCopilotChat } from "@/lib/chatStream";
|
import { streamCopilotChat } from "@/lib/chatStream";
|
||||||
import { parseAssistantMessageSections } from "./chatMessageSections";
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
import {
|
||||||
|
parseAssistantMessageSections,
|
||||||
|
parseContentWithToolCalls,
|
||||||
|
type ContentSegment,
|
||||||
|
} from "./chatMessageSections";
|
||||||
|
import { ChatInlineChart } from "./ChatInlineChart";
|
||||||
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
|
import {
|
||||||
|
useChatToolStore,
|
||||||
|
type ChatToolAction,
|
||||||
|
} from "@/store/chatToolStore";
|
||||||
|
|
||||||
// WebKit Speech Recognition compatibility
|
// WebKit Speech Recognition compatibility
|
||||||
interface SpeechRecognitionEvent extends Event {
|
interface SpeechRecognitionEvent extends Event {
|
||||||
@@ -213,10 +224,11 @@ type ChatMessageItemProps = {
|
|||||||
onResume: () => void;
|
onResume: () => void;
|
||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
|
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatMessageItem = React.memo(
|
const ChatMessageItem = React.memo(
|
||||||
({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported }: ChatMessageItemProps) => {
|
({ message, theme, messageSpeechState, onSpeak, onPause, onResume, onStopSpeech, isTtsSupported, sseChartParams }: ChatMessageItemProps) => {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
const parsedAssistantSections =
|
const parsedAssistantSections =
|
||||||
@@ -225,6 +237,12 @@ const ChatMessageItem = React.memo(
|
|||||||
: null;
|
: null;
|
||||||
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
const answerContent = parsedAssistantSections?.answer ?? message.content;
|
||||||
|
|
||||||
|
// Parse tool_call blocks from the answer for inline rendering
|
||||||
|
const contentSegments: ContentSegment[] =
|
||||||
|
!isUser && !isErrorMessage
|
||||||
|
? parseContentWithToolCalls(answerContent).segments
|
||||||
|
: [{ type: "text", content: answerContent }];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
||||||
@@ -338,9 +356,89 @@ const ChatMessageItem = React.memo(
|
|||||||
: "#475569",
|
: "#475569",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={markdownStyles.markdown}>
|
{contentSegments.map((segment, segIdx) => {
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{answerContent || "..."}</ReactMarkdown>
|
if (segment.type === "text") {
|
||||||
</div>
|
const text = segment.content.trim();
|
||||||
|
if (!text && contentSegments.length > 1) return null;
|
||||||
|
return (
|
||||||
|
<div key={segIdx} className={markdownStyles.markdown}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{text || "..."}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call") {
|
||||||
|
if (segment.toolCall.tool === "chart") {
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
{...(segment.toolCall.params as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.toolCall.tool === "show_chart") {
|
||||||
|
const p = segment.toolCall.params;
|
||||||
|
return (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
title={(p.title as string) ?? undefined}
|
||||||
|
chart_type={(p.chart_type as "line" | "bar" | "pie") ?? "line"}
|
||||||
|
x_data={(p.x_data as string[]) ?? []}
|
||||||
|
series={(p.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []}
|
||||||
|
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatToolCallBlock
|
||||||
|
key={segment.toolCall.id}
|
||||||
|
toolCall={segment.toolCall}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.type === "tool_call_pending") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key="tool-pending"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AutoAwesome
|
||||||
|
sx={{ fontSize: 14, color: "primary.main" }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
正在准备工具调用...
|
||||||
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{/* SSE-sourced inline charts (from show_chart tool_call events) */}
|
||||||
|
{sseChartParams?.map((chart, idx) => (
|
||||||
|
<ChatInlineChart
|
||||||
|
key={`sse-chart-${idx}`}
|
||||||
|
title={(chart.params.title as string) ?? undefined}
|
||||||
|
chart_type={(chart.params.chart_type as "line" | "bar" | "pie") ?? "line"}
|
||||||
|
x_data={(chart.params.x_data as string[]) ?? []}
|
||||||
|
series={(chart.params.series as import("./ChatInlineChart").ChatChartSeries[]) ?? []}
|
||||||
|
x_axis_name={(chart.params.x_axis_name as string) ?? undefined}
|
||||||
|
y_axis_name={(chart.params.y_axis_name as string) ?? undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Paper>
|
</Paper>
|
||||||
{!isUser && !isErrorMessage && isTtsSupported && (
|
{!isUser && !isErrorMessage && isTtsSupported && (
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, ml: 0.5 }}>
|
||||||
@@ -546,6 +644,13 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
);
|
);
|
||||||
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
|
const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
|
const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
// SSE tool_call → inline chart data (keyed by assistantMessageId)
|
||||||
|
const [sseCharts, setSseCharts] = useState<
|
||||||
|
Record<string, Array<{ tool: string; params: Record<string, unknown> }>>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const dispatchToolAction = useChatToolStore((s) => s.dispatch);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -619,6 +724,101 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
// Track SSE tool_call hashes to deduplicate against text-parsed tool_calls
|
||||||
|
const sseToolHashes = new Set<string>();
|
||||||
|
|
||||||
|
const handleSseToolCall = (event: StreamEvent & { type: "tool_call" }) => {
|
||||||
|
const { tool, params } = event;
|
||||||
|
const hash = `${tool}:${JSON.stringify(params)}`;
|
||||||
|
sseToolHashes.add(hash);
|
||||||
|
const startTime =
|
||||||
|
(params.start_time as string | undefined) ??
|
||||||
|
(params.startTime as string | undefined) ??
|
||||||
|
(params.from as string | undefined) ??
|
||||||
|
(params.start as string | undefined);
|
||||||
|
const endTime =
|
||||||
|
(params.end_time as string | undefined) ??
|
||||||
|
(params.endTime as string | undefined) ??
|
||||||
|
(params.to as string | undefined) ??
|
||||||
|
(params.end as string | undefined);
|
||||||
|
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"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// show_chart → store as inline chart for rendering
|
||||||
|
if (tool === "show_chart") {
|
||||||
|
setSseCharts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[assistantId]: [
|
||||||
|
...(prev[assistantId] ?? []),
|
||||||
|
{ tool, params },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other frontend tools → dispatch to chatToolStore immediately
|
||||||
|
const actionMap: Record<string, () => ChatToolAction | null> = {
|
||||||
|
locate_nodes: () => ({
|
||||||
|
type: "locate_nodes" as const,
|
||||||
|
ids: (params.ids as string[]) ?? [],
|
||||||
|
}),
|
||||||
|
locate_pipes: () => ({
|
||||||
|
type: "locate_pipes" as const,
|
||||||
|
ids: (params.ids as string[]) ?? [],
|
||||||
|
}),
|
||||||
|
view_history: () => ({
|
||||||
|
type: "view_history" as const,
|
||||||
|
featureInfos: (params.feature_infos as [string, string][]) ?? [],
|
||||||
|
dataType: (params.data_type as "realtime" | "scheme" | "none") ?? "realtime",
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
}),
|
||||||
|
view_scada: () => ({
|
||||||
|
type: "view_scada" as const,
|
||||||
|
featureInfos: resolveScadaFeatureInfos(),
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const buildAction = actionMap[tool];
|
||||||
|
if (buildAction) {
|
||||||
|
const action = buildAction();
|
||||||
|
if (action) dispatchToolAction(action);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamCopilotChat({
|
await streamCopilotChat({
|
||||||
message: prompt,
|
message: prompt,
|
||||||
@@ -651,6 +851,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
} else if (event.type === "tool_call") {
|
||||||
|
handleSseToolCall(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -674,7 +876,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversationId, isStreaming, stopListening],
|
[conversationId, isStreaming, stopListening, dispatchToolAction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
@@ -764,9 +966,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onResume={handleResumeSpeech}
|
onResume={handleResumeSpeech}
|
||||||
onStopSpeech={handleStopSpeech}
|
onStopSpeech={handleStopSpeech}
|
||||||
isTtsSupported={isTtsSupported}
|
isTtsSupported={isTtsSupported}
|
||||||
|
sseChartParams={sseCharts[message.id]}
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported],
|
[messages, theme, speechState, speakingMessageId, handleSpeak, handlePauseSpeech, handleResumeSpeech, handleStopSpeech, isTtsSupported, sseCharts],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { parseAssistantMessageSections } from "./chatMessageSections";
|
import {
|
||||||
|
parseAssistantMessageSections,
|
||||||
|
parseContentWithToolCalls,
|
||||||
|
} from "./chatMessageSections";
|
||||||
|
|
||||||
describe("parseAssistantMessageSections", () => {
|
describe("parseAssistantMessageSections", () => {
|
||||||
it("returns plain assistant content when there is no thought block", () => {
|
it("returns plain assistant content when there is no thought block", () => {
|
||||||
@@ -41,3 +44,88 @@ describe("parseAssistantMessageSections", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseContentWithToolCalls", () => {
|
||||||
|
it("returns a single text segment when there are no tool calls", () => {
|
||||||
|
const result = parseContentWithToolCalls("普通文本回答");
|
||||||
|
expect(result.segments).toEqual([
|
||||||
|
{ type: "text", content: "普通文本回答" },
|
||||||
|
]);
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a complete tool_call block", () => {
|
||||||
|
const content =
|
||||||
|
'分析完成。\n<tool_call>{"tool":"locate_nodes","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(1);
|
||||||
|
expect(result.toolCalls[0].tool).toBe("locate_nodes");
|
||||||
|
expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] });
|
||||||
|
|
||||||
|
expect(result.segments).toHaveLength(3);
|
||||||
|
expect(result.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "分析完成。",
|
||||||
|
});
|
||||||
|
expect(result.segments[1]).toMatchObject({
|
||||||
|
type: "tool_call",
|
||||||
|
toolCall: { tool: "locate_nodes" },
|
||||||
|
});
|
||||||
|
expect(result.segments[2]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "以上是结果。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple tool_call blocks", () => {
|
||||||
|
const content =
|
||||||
|
'文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(2);
|
||||||
|
expect(result.toolCalls[0].tool).toBe("locate_pipes");
|
||||||
|
expect(result.toolCalls[1].tool).toBe("chart");
|
||||||
|
expect(result.segments).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects an unclosed tool_call tag as pending (streaming)", () => {
|
||||||
|
const content = '正在分析...\n<tool_call>{"tool":"locate_no';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.segments).toHaveLength(2);
|
||||||
|
expect(result.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "正在分析...",
|
||||||
|
});
|
||||||
|
expect(result.segments[1]).toEqual({ type: "tool_call_pending" });
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips partial opening tags during streaming", () => {
|
||||||
|
const content = "正在分析...\n<tool_c";
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
expect(result.segments).toHaveLength(1);
|
||||||
|
expect(result.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
content: "正在分析...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed JSON gracefully", () => {
|
||||||
|
const content =
|
||||||
|
'前文\n<tool_call>{invalid json}</tool_call>\n后文';
|
||||||
|
const result = parseContentWithToolCalls(content);
|
||||||
|
|
||||||
|
// Malformed tool call is treated as text
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
expect(result.segments.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty segments for empty content", () => {
|
||||||
|
const result = parseContentWithToolCalls("");
|
||||||
|
expect(result.segments).toHaveLength(0);
|
||||||
|
expect(result.toolCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,30 @@ export type AssistantMessageSections = {
|
|||||||
thoughtComplete: boolean;
|
thoughtComplete: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tool-call types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type ToolCall = {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentSegment =
|
||||||
|
| { type: "text"; content: string }
|
||||||
|
| { type: "tool_call"; toolCall: ToolCall }
|
||||||
|
| { type: "tool_call_pending" };
|
||||||
|
|
||||||
|
export type ParsedToolContent = {
|
||||||
|
segments: ContentSegment[];
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Think-block parsing */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
|
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
|
||||||
const THINK_OPEN_TAG = "<think>";
|
const THINK_OPEN_TAG = "<think>";
|
||||||
const THINK_CLOSE_TAG = "</think>";
|
const THINK_CLOSE_TAG = "</think>";
|
||||||
@@ -53,3 +77,90 @@ export const parseAssistantMessageSections = (
|
|||||||
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought,
|
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tool-call parsing */
|
||||||
|
/* */
|
||||||
|
/* AI responses may embed tool calls using: */
|
||||||
|
/* <tool_call>{"tool":"locate_pipes","params":{...}}</tool_call> */
|
||||||
|
/* */
|
||||||
|
/* Returns ordered segments (text + tool_call interleaved) so the */
|
||||||
|
/* UI can render them inline where the AI placed them. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const TOOL_CALL_BLOCK_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
|
||||||
|
const TOOL_CALL_OPEN_TAG = "<tool_call>";
|
||||||
|
|
||||||
|
/** Regex to strip partial opening tag at the end of text during streaming. */
|
||||||
|
const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/;
|
||||||
|
|
||||||
|
export const parseContentWithToolCalls = (
|
||||||
|
content: string,
|
||||||
|
): ParsedToolContent => {
|
||||||
|
if (!content) {
|
||||||
|
return { segments: [], toolCalls: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: ContentSegment[] = [];
|
||||||
|
const toolCalls: ToolCall[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let tcIndex = 0;
|
||||||
|
|
||||||
|
// Find all complete <tool_call>...</tool_call> blocks
|
||||||
|
const regex = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
// Text before this tool call
|
||||||
|
const textBefore = content.slice(lastIndex, match.index);
|
||||||
|
if (textBefore.trim()) {
|
||||||
|
segments.push({ type: "text", content: textBefore.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the tool call JSON
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[1].trim()) as {
|
||||||
|
tool?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
id: `tc-${tcIndex++}`,
|
||||||
|
tool: parsed.tool ?? "unknown",
|
||||||
|
params: parsed.params ?? {},
|
||||||
|
};
|
||||||
|
segments.push({ type: "tool_call", toolCall });
|
||||||
|
toolCalls.push(toolCall);
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON – treat as plain text
|
||||||
|
segments.push({ type: "text", content: match[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remaining text after the last match
|
||||||
|
const remaining = content.slice(lastIndex);
|
||||||
|
|
||||||
|
// Check for an unclosed <tool_call> tag (still streaming)
|
||||||
|
const unclosedIdx = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG);
|
||||||
|
if (unclosedIdx !== -1) {
|
||||||
|
const textBefore = remaining.slice(0, unclosedIdx);
|
||||||
|
if (textBefore.trim()) {
|
||||||
|
segments.push({ type: "text", content: textBefore.trim() });
|
||||||
|
}
|
||||||
|
segments.push({ type: "tool_call_pending" });
|
||||||
|
} else {
|
||||||
|
// Strip partial opening tags at the end (e.g. "<tool_c" while streaming)
|
||||||
|
const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim();
|
||||||
|
if (cleaned) {
|
||||||
|
segments.push({ type: "text", content: cleaned });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing was parsed, return the original content as a single text segment
|
||||||
|
if (segments.length === 0) {
|
||||||
|
segments.push({ type: "text", content });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segments, toolCalls };
|
||||||
|
};
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export interface SCADADataPanelProps {
|
|||||||
showCleaning?: boolean;
|
showCleaning?: boolean;
|
||||||
/** 清洗数据的回调 */
|
/** 清洗数据的回调 */
|
||||||
onCleanData?: () => void;
|
onCleanData?: () => void;
|
||||||
|
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
|
start_time?: string;
|
||||||
|
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
|
end_time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelTab = "chart" | "table";
|
type PanelTab = "chart" | "table";
|
||||||
@@ -314,6 +318,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
showCleaning = false,
|
showCleaning = false,
|
||||||
onCleanData,
|
onCleanData,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const { data: user } = useGetIdentity<IUser>();
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
@@ -396,8 +402,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [showCleaning]);
|
}, [showCleaning]);
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
const [from, setFrom] = useState<Dayjs>(() => {
|
||||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
if (start_time) {
|
||||||
|
const parsedStart = dayjs(start_time);
|
||||||
|
if (parsedStart.isValid()) {
|
||||||
|
return parsedStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs().subtract(1, "day");
|
||||||
|
});
|
||||||
|
const [to, setTo] = useState<Dayjs>(() => {
|
||||||
|
if (end_time) {
|
||||||
|
const parsedEnd = dayjs(end_time);
|
||||||
|
if (parsedEnd.isValid()) {
|
||||||
|
return parsedEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs();
|
||||||
|
});
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
@@ -412,6 +434,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
}, [defaultTab]);
|
}, [defaultTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!start_time && !end_time) return;
|
||||||
|
if (start_time) {
|
||||||
|
const parsedStart = dayjs(start_time);
|
||||||
|
if (parsedStart.isValid()) {
|
||||||
|
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end_time) {
|
||||||
|
const parsedEnd = dayjs(end_time);
|
||||||
|
if (parsedEnd.isValid()) {
|
||||||
|
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [start_time, end_time]);
|
||||||
|
|
||||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
const hasDevices = deviceIds.length > 0;
|
const hasDevices = deviceIds.length > 0;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export interface SCADADataPanelProps {
|
|||||||
defaultTab?: "chart" | "table";
|
defaultTab?: "chart" | "table";
|
||||||
/** Y 轴数值的小数位数 */
|
/** Y 轴数值的小数位数 */
|
||||||
fractionDigits?: number;
|
fractionDigits?: number;
|
||||||
|
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
|
start_time?: string;
|
||||||
|
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||||
|
end_time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelTab = "chart" | "table";
|
type PanelTab = "chart" | "table";
|
||||||
@@ -396,6 +400,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
scheme_name,
|
scheme_name,
|
||||||
defaultTab = "chart",
|
defaultTab = "chart",
|
||||||
fractionDigits = 2,
|
fractionDigits = 2,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
}) => {
|
}) => {
|
||||||
// 从 featureInfos 中提取设备 ID 列表
|
// 从 featureInfos 中提取设备 ID 列表
|
||||||
const deviceIds = useMemo(
|
const deviceIds = useMemo(
|
||||||
@@ -403,8 +409,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
[featureInfos]
|
[featureInfos]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
const [from, setFrom] = useState<Dayjs>(() => {
|
||||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
if (start_time) {
|
||||||
|
const parsedStart = dayjs(start_time);
|
||||||
|
if (parsedStart.isValid()) {
|
||||||
|
return parsedStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs().subtract(1, "day");
|
||||||
|
});
|
||||||
|
const [to, setTo] = useState<Dayjs>(() => {
|
||||||
|
if (end_time) {
|
||||||
|
const parsedEnd = dayjs(end_time);
|
||||||
|
if (parsedEnd.isValid()) {
|
||||||
|
return parsedEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dayjs();
|
||||||
|
});
|
||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
@@ -418,6 +440,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
}, [defaultTab]);
|
}, [defaultTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!start_time && !end_time) return;
|
||||||
|
if (start_time) {
|
||||||
|
const parsedStart = dayjs(start_time);
|
||||||
|
if (parsedStart.isValid()) {
|
||||||
|
setFrom((prev) => (parsedStart.isSame(prev) ? prev : parsedStart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (end_time) {
|
||||||
|
const parsedEnd = dayjs(end_time);
|
||||||
|
if (parsedEnd.isValid()) {
|
||||||
|
setTo((prev) => (parsedEnd.isSame(prev) ? prev : parsedEnd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [start_time, end_time]);
|
||||||
|
|
||||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
const hasDevices = deviceIds.length > 0;
|
const hasDevices = deviceIds.length > 0;
|
||||||
|
|||||||
@@ -8,16 +8,20 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
|||||||
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
||||||
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
||||||
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
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 { 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 } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures as mapClickSelectFeatures, queryFeaturesByIds } from "@/utils/mapQueryService";
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
|
import { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
|
||||||
|
|
||||||
import { config } from "@/config/config";
|
import { config } from "@/config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
@@ -51,6 +55,89 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
const selectedDate = data?.selectedDate;
|
const selectedDate = data?.selectedDate;
|
||||||
const schemeName = data?.schemeName;
|
const schemeName = data?.schemeName;
|
||||||
|
|
||||||
|
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
|
||||||
|
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
|
||||||
|
[string, string][] | null
|
||||||
|
>(null);
|
||||||
|
const [chatPanelType, setChatPanelType] = useState<
|
||||||
|
"realtime" | "scheme" | "none"
|
||||||
|
>("none");
|
||||||
|
const [chatPanelTimeRange, setChatPanelTimeRange] = useState<{
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Wire up chat tool actions (locate, view_history, view_scada)
|
||||||
|
useChatToolActionHandler(
|
||||||
|
useCallback(
|
||||||
|
(action) => {
|
||||||
|
const geojsonFormat = new GeoJSON();
|
||||||
|
const zoomToFeatures = (features: Feature[]) => {
|
||||||
|
if (features.length === 0) 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case "locate_nodes": {
|
||||||
|
queryFeaturesByIds(action.ids, "geo_junctions_mat").then(
|
||||||
|
(features) => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
setHighlightFeatures(features);
|
||||||
|
zoomToFeatures(features);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "locate_pipes": {
|
||||||
|
queryFeaturesByIds(action.ids, "geo_pipes_mat").then(
|
||||||
|
(features) => {
|
||||||
|
if (features.length > 0) {
|
||||||
|
setHighlightFeatures(features);
|
||||||
|
zoomToFeatures(features);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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[]>([
|
||||||
{
|
{
|
||||||
@@ -328,6 +415,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
case "history":
|
case "history":
|
||||||
setShowHistoryPanel(false);
|
setShowHistoryPanel(false);
|
||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
|
setChatPanelFeatureInfos(null);
|
||||||
|
setChatPanelTimeRange(null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -354,6 +443,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
setHighlightFeatures([]);
|
setHighlightFeatures([]);
|
||||||
setShowDrawPanel(false);
|
setShowDrawPanel(false);
|
||||||
setShowHistoryPanel(false);
|
setShowHistoryPanel(false);
|
||||||
|
setChatPanelFeatureInfos(null);
|
||||||
|
setChatPanelTimeRange(null);
|
||||||
// 样式编辑器保持其当前状态,不自动关闭
|
// 样式编辑器保持其当前状态,不自动关闭
|
||||||
};
|
};
|
||||||
const [computedProperties, setComputedProperties] = useState<
|
const [computedProperties, setComputedProperties] = useState<
|
||||||
@@ -770,9 +861,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showHistoryPanel &&
|
{showHistoryPanel &&
|
||||||
(HistoryPanel ? (
|
(chatPanelType === "none" && chatPanelFeatureInfos ? (
|
||||||
|
<SCADADataPanel
|
||||||
|
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
|
||||||
|
visible={showHistoryPanel}
|
||||||
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
|
/>
|
||||||
|
) : HistoryPanel ? (
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
featureInfos={(() => {
|
featureInfos={chatPanelFeatureInfos ?? (() => {
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
@@ -810,11 +908,13 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
})()}
|
})()}
|
||||||
scheme_type="burst_Analysis"
|
scheme_type="burst_Analysis"
|
||||||
scheme_name={schemeName}
|
scheme_name={schemeName}
|
||||||
type={queryType as "realtime" | "scheme" | "none"}
|
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
||||||
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HistoryDataPanel
|
<HistoryDataPanel
|
||||||
featureInfos={(() => {
|
featureInfos={chatPanelFeatureInfos ?? (() => {
|
||||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
@@ -852,7 +952,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
})()}
|
})()}
|
||||||
scheme_type="burst_Analysis"
|
scheme_type="burst_Analysis"
|
||||||
scheme_name={schemeName}
|
scheme_name={schemeName}
|
||||||
type={queryType as "realtime" | "scheme" | "none"}
|
type={chatPanelFeatureInfos ? chatPanelType : (queryType as "realtime" | "scheme" | "none")}
|
||||||
|
start_time={chatPanelTimeRange?.startTime}
|
||||||
|
end_time={chatPanelTimeRange?.endTime}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
useChatToolStore,
|
||||||
|
type ChatToolAction,
|
||||||
|
} from "@/store/chatToolStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to chat tool actions and invoke `handler` for each new action.
|
||||||
|
*
|
||||||
|
* Usage (inside a component with map access):
|
||||||
|
* ```ts
|
||||||
|
* useChatToolActionHandler((action) => {
|
||||||
|
* switch (action.type) {
|
||||||
|
* case "locate_nodes": handleLocateNodes(action.ids); break;
|
||||||
|
* case "locate_pipes": handleLocatePipes(action.ids); break;
|
||||||
|
* case "view_history": openHistoryPanel(action.featureInfos, action.dataType); break;
|
||||||
|
* case "view_scada": openScadaPanel(action.featureInfos); break;
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useChatToolActionHandler(
|
||||||
|
handler: (action: ChatToolAction) => void,
|
||||||
|
) {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
handlerRef.current = handler;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = useChatToolStore.subscribe(
|
||||||
|
(state, prevState) => {
|
||||||
|
if (
|
||||||
|
state.actionSeq !== prevState.actionSeq &&
|
||||||
|
state.lastAction
|
||||||
|
) {
|
||||||
|
handlerRef.current(state.lastAction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ export type StreamEvent =
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
message: string;
|
message: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool_call";
|
||||||
|
conversationId: string;
|
||||||
|
tool: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamOptions = {
|
type StreamOptions = {
|
||||||
@@ -113,6 +119,8 @@ export const streamCopilotChat = async ({
|
|||||||
content?: string;
|
content?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
tool?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
if (event === "token") {
|
if (event === "token") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -132,6 +140,13 @@ export const streamCopilotChat = async ({
|
|||||||
message: parsed.message ?? "unknown error",
|
message: parsed.message ?? "unknown error",
|
||||||
detail: parsed.detail,
|
detail: parsed.detail,
|
||||||
});
|
});
|
||||||
|
} else if (event === "tool_call") {
|
||||||
|
onEvent({
|
||||||
|
type: "tool_call",
|
||||||
|
conversationId: parsed.conversationId ?? "",
|
||||||
|
tool: parsed.tool ?? "",
|
||||||
|
params: parsed.params ?? {},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
onEvent({
|
onEvent({
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Chat Tool Action Store */
|
||||||
|
/* Decouples chat tool calls from map/panel execution. */
|
||||||
|
/* Chat dispatches actions → map/panel components subscribe & react. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type ChatToolAction =
|
||||||
|
| { type: "locate_nodes"; ids: string[] }
|
||||||
|
| { type: "locate_pipes"; ids: string[] }
|
||||||
|
| {
|
||||||
|
type: "view_history";
|
||||||
|
featureInfos: [string, string][];
|
||||||
|
dataType: "realtime" | "scheme" | "none";
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "view_scada";
|
||||||
|
featureInfos: [string, string][];
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "show_chart";
|
||||||
|
title?: string;
|
||||||
|
chartType?: "line" | "bar" | "pie";
|
||||||
|
xData?: string[];
|
||||||
|
series?: Array<{ name: string; data: number[]; type?: "line" | "bar" }>;
|
||||||
|
xAxisName?: string;
|
||||||
|
yAxisName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChatToolState {
|
||||||
|
/** Most recent dispatched action (null until first dispatch). */
|
||||||
|
lastAction: ChatToolAction | null;
|
||||||
|
/** Monotonically increasing counter – lets subscribers detect new actions. */
|
||||||
|
actionSeq: number;
|
||||||
|
/** Dispatch a tool action from the chat. */
|
||||||
|
dispatch: (action: ChatToolAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatToolStore = create<ChatToolState>((set) => ({
|
||||||
|
lastAction: null,
|
||||||
|
actionSeq: 0,
|
||||||
|
dispatch: (action) =>
|
||||||
|
set((state) => ({
|
||||||
|
lastAction: action,
|
||||||
|
actionSeq: state.actionSeq + 1,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user