添加工具调用解析和聊天工具操作处理

This commit is contained in:
2026-04-03 11:49:05 +08:00
parent a1c8041b11
commit d610a09c14
11 changed files with 1269 additions and 18 deletions
+178
View File
@@ -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>
);
};
+385
View File
@@ -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>
);
};
+210 -7
View File
@@ -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);
});
});
+111
View File
@@ -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 };
};
+40 -2
View File
@@ -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;
+108 -6
View File
@@ -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}
/>
))}
+41
View File
@@ -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;
}, []);
}
+15
View File
@@ -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({
+52
View File
@@ -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,
})),
}));