添加工具调用解析和聊天工具操作处理
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
|
||||
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
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
@@ -213,10 +224,11 @@ type ChatMessageItemProps = {
|
||||
onResume: () => void;
|
||||
onStopSpeech: () => void;
|
||||
isTtsSupported: boolean;
|
||||
sseChartParams?: Array<{ tool: string; params: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
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 isErrorMessage = Boolean(message.isError);
|
||||
const parsedAssistantSections =
|
||||
@@ -225,6 +237,12 @@ const ChatMessageItem = React.memo(
|
||||
: null;
|
||||
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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8, x: isUser ? 50 : -50 }}
|
||||
@@ -338,9 +356,89 @@ const ChatMessageItem = React.memo(
|
||||
: "#475569",
|
||||
}}
|
||||
>
|
||||
<div className={markdownStyles.markdown}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{answerContent || "..."}</ReactMarkdown>
|
||||
</div>
|
||||
{contentSegments.map((segment, segIdx) => {
|
||||
if (segment.type === "text") {
|
||||
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>
|
||||
{!isUser && !isErrorMessage && isTtsSupported && (
|
||||
<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 [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 bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -619,6 +724,101 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
const controller = new AbortController();
|
||||
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 {
|
||||
await streamCopilotChat({
|
||||
message: prompt,
|
||||
@@ -651,6 +851,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
)
|
||||
);
|
||||
setIsStreaming(false);
|
||||
} else if (event.type === "tool_call") {
|
||||
handleSseToolCall(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -674,7 +876,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
[conversationId, isStreaming, stopListening],
|
||||
[conversationId, isStreaming, stopListening, dispatchToolAction],
|
||||
);
|
||||
|
||||
const handleSend = async () => {
|
||||
@@ -764,9 +966,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||
onResume={handleResumeSpeech}
|
||||
onStopSpeech={handleStopSpeech}
|
||||
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", () => {
|
||||
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;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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_OPEN_TAG = "<think>";
|
||||
const THINK_CLOSE_TAG = "</think>";
|
||||
@@ -53,3 +77,90 @@ export const parseAssistantMessageSections = (
|
||||
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;
|
||||
/** 清洗数据的回调 */
|
||||
onCleanData?: () => void;
|
||||
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||
start_time?: string;
|
||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
type PanelTab = "chart" | "table";
|
||||
@@ -314,6 +318,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
fractionDigits = 2,
|
||||
showCleaning = false,
|
||||
onCleanData,
|
||||
start_time,
|
||||
end_time,
|
||||
}) => {
|
||||
const { open } = useNotification();
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
@@ -396,8 +402,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
};
|
||||
}, [showCleaning]);
|
||||
|
||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
||||
const [from, setFrom] = useState<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 [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
@@ -412,6 +434,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setActiveTab(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 hasDevices = deviceIds.length > 0;
|
||||
|
||||
@@ -59,6 +59,10 @@ export interface SCADADataPanelProps {
|
||||
defaultTab?: "chart" | "table";
|
||||
/** Y 轴数值的小数位数 */
|
||||
fractionDigits?: number;
|
||||
/** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||
start_time?: string;
|
||||
/** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
type PanelTab = "chart" | "table";
|
||||
@@ -396,6 +400,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
scheme_name,
|
||||
defaultTab = "chart",
|
||||
fractionDigits = 2,
|
||||
start_time,
|
||||
end_time,
|
||||
}) => {
|
||||
// 从 featureInfos 中提取设备 ID 列表
|
||||
const deviceIds = useMemo(
|
||||
@@ -403,8 +409,24 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
[featureInfos]
|
||||
);
|
||||
|
||||
const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
|
||||
const [to, setTo] = useState<Dayjs>(() => dayjs());
|
||||
const [from, setFrom] = useState<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 [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
@@ -418,6 +440,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
setActiveTab(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 hasDevices = deviceIds.length > 0;
|
||||
|
||||
@@ -8,16 +8,20 @@ import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
|
||||
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
|
||||
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
|
||||
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
|
||||
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
|
||||
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import { Style, Stroke, Fill, Circle } from "ol/style";
|
||||
import Feature from "ol/Feature";
|
||||
import { GeoJSON } from "ol/format";
|
||||
import { bbox, featureCollection } from "@turf/turf";
|
||||
import StyleEditorPanel from "./StyleEditorPanel";
|
||||
import { LayerStyleState } from "./StyleEditorPanel";
|
||||
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 { useChatToolActionHandler } from "@/hooks/useChatToolActionHandler";
|
||||
|
||||
import { config } from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
@@ -51,6 +55,89 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const selectedDate = data?.selectedDate;
|
||||
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 中管理,带有默认样式
|
||||
const [layerStyleStates, setLayerStyleStates] = useState<LayerStyleState[]>([
|
||||
{
|
||||
@@ -328,6 +415,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
case "history":
|
||||
setShowHistoryPanel(false);
|
||||
setHighlightFeatures([]);
|
||||
setChatPanelFeatureInfos(null);
|
||||
setChatPanelTimeRange(null);
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -354,6 +443,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
setHighlightFeatures([]);
|
||||
setShowDrawPanel(false);
|
||||
setShowHistoryPanel(false);
|
||||
setChatPanelFeatureInfos(null);
|
||||
setChatPanelTimeRange(null);
|
||||
// 样式编辑器保持其当前状态,不自动关闭
|
||||
};
|
||||
const [computedProperties, setComputedProperties] = useState<
|
||||
@@ -770,9 +861,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{showHistoryPanel &&
|
||||
(HistoryPanel ? (
|
||||
(chatPanelType === "none" && chatPanelFeatureInfos ? (
|
||||
<SCADADataPanel
|
||||
deviceIds={chatPanelFeatureInfos.map(([id]) => id)}
|
||||
visible={showHistoryPanel}
|
||||
start_time={chatPanelTimeRange?.startTime}
|
||||
end_time={chatPanelTimeRange?.endTime}
|
||||
/>
|
||||
) : HistoryPanel ? (
|
||||
<HistoryPanel
|
||||
featureInfos={(() => {
|
||||
featureInfos={chatPanelFeatureInfos ?? (() => {
|
||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||
return [];
|
||||
|
||||
@@ -810,11 +908,13 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
})()}
|
||||
scheme_type="burst_Analysis"
|
||||
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
|
||||
featureInfos={(() => {
|
||||
featureInfos={chatPanelFeatureInfos ?? (() => {
|
||||
if (highlightFeatures.length === 0 || !showHistoryPanel)
|
||||
return [];
|
||||
|
||||
@@ -852,7 +952,9 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
||||
})()}
|
||||
scheme_type="burst_Analysis"
|
||||
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;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "tool_call";
|
||||
conversationId: string;
|
||||
tool: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type StreamOptions = {
|
||||
@@ -113,6 +119,8 @@ export const streamCopilotChat = async ({
|
||||
content?: string;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
tool?: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
if (event === "token") {
|
||||
onEvent({
|
||||
@@ -132,6 +140,13 @@ export const streamCopilotChat = async ({
|
||||
message: parsed.message ?? "unknown error",
|
||||
detail: parsed.detail,
|
||||
});
|
||||
} else if (event === "tool_call") {
|
||||
onEvent({
|
||||
type: "tool_call",
|
||||
conversationId: parsed.conversationId ?? "",
|
||||
tool: parsed.tool ?? "",
|
||||
params: parsed.params ?? {},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
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