diff --git a/src/components/chat/ChatInlineChart.tsx b/src/components/chat/ChatInlineChart.tsx new file mode 100644 index 0000000..1f7f9e4 --- /dev/null +++ b/src/components/chat/ChatInlineChart.tsx @@ -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 = ({ + 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 ( + + 图表数据为空 + + ); + } + + return ( + + {title && ( + + {title} + + )} + + + + + ); +}; diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx new file mode 100644 index 0000000..a3f4db7 --- /dev/null +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -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 = { + locate_nodes: { + label: "定位节点", + icon: , + actionLabel: "定位到地图", + color: "#5470c6", + }, + locate_pipes: { + label: "定位管道", + icon: , + actionLabel: "定位到地图", + color: "#91cc75", + }, + view_history: { + label: "查看计算结果", + icon: , + actionLabel: "查看曲线", + color: "#fac858", + }, + view_scada: { + label: "查看监测数据", + icon: , + actionLabel: "查看数据", + color: "#ee6666", + }, + show_chart: { + label: "显示图表", + icon: , + 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 = ({ + 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 ( + + + {/* Icon */} + + {meta.icon} + + + {/* Description */} + + + {meta.label} + + {description && ( + + {description} + + )} + + + {/* Action */} + {executed ? ( + } + label="已执行" + size="small" + sx={{ + bgcolor: alpha("#4caf50", 0.1), + color: "#4caf50", + fontWeight: 600, + fontSize: "0.75rem", + }} + /> + ) : ( + + )} + + + ); +}; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 4f0f238..84ffa38 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -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 }>; }; 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 ( -
- {answerContent || "..."} -
+ {contentSegments.map((segment, segIdx) => { + if (segment.type === "text") { + const text = segment.content.trim(); + if (!text && contentSegments.length > 1) return null; + return ( +
+ + {text || "..."} + +
+ ); + } + if (segment.type === "tool_call") { + if (segment.toolCall.tool === "chart") { + return ( + )} + /> + ); + } + if (segment.toolCall.tool === "show_chart") { + const p = segment.toolCall.params; + return ( + + ); + } + return ( + + ); + } + if (segment.type === "tool_call_pending") { + return ( + + + + 正在准备工具调用... + + + ); + } + return null; + })} + {/* SSE-sourced inline charts (from show_chart tool_call events) */} + {sseChartParams?.map((chart, idx) => ( + + ))} {!isUser && !isErrorMessage && isTtsSupported && ( @@ -546,6 +644,13 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { ); const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState(null); const [isPresetPanelOpen, setIsPresetPanelOpen] = useState(false); + + // SSE tool_call → inline chart data (keyed by assistantMessageId) + const [sseCharts, setSseCharts] = useState< + Record }>> + >({}); + + const dispatchToolAction = useChatToolStore((s) => s.dispatch); const abortRef = useRef(null); const bottomRef = useRef(null); const inputRef = useRef(null); @@ -619,6 +724,101 @@ export const GlobalChatbox: React.FC = ({ 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(); + + 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 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 = ({ open, onClose }) => { ) ); setIsStreaming(false); + } else if (event.type === "tool_call") { + handleSseToolCall(event); } }, }); @@ -674,7 +876,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { setIsStreaming(false); } }, - [conversationId, isStreaming, stopListening], + [conversationId, isStreaming, stopListening, dispatchToolAction], ); const handleSend = async () => { @@ -764,9 +966,10 @@ export const GlobalChatbox: React.FC = ({ 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], ); diff --git a/src/components/chat/chatMessageSections.test.ts b/src/components/chat/chatMessageSections.test.ts index 9034f32..7e138dc 100644 --- a/src/components/chat/chatMessageSections.test.ts +++ b/src/components/chat/chatMessageSections.test.ts @@ -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":"locate_nodes","params":{"ids":["J1","J2"]}}\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":"locate_pipes","params":{"ids":["P1"]}}\n文本2\n{"tool":"chart","params":{"title":"图"}}'; + 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":"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 { + const content = + '前文\n{invalid json}\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); + }); +}); diff --git a/src/components/chat/chatMessageSections.ts b/src/components/chat/chatMessageSections.ts index f5bc7fe..89e349a 100644 --- a/src/components/chat/chatMessageSections.ts +++ b/src/components/chat/chatMessageSections.ts @@ -4,6 +4,30 @@ export type AssistantMessageSections = { thoughtComplete: boolean; }; +/* ------------------------------------------------------------------ */ +/* Tool-call types */ +/* ------------------------------------------------------------------ */ + +export type ToolCall = { + id: string; + tool: string; + params: Record; +}; + +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 = /([\s\S]*?)<\/think>/gi; const THINK_OPEN_TAG = ""; const THINK_CLOSE_TAG = ""; @@ -53,3 +77,90 @@ export const parseAssistantMessageSections = ( thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought, }; }; + +/* ------------------------------------------------------------------ */ +/* Tool-call parsing */ +/* */ +/* AI responses may embed tool calls using: */ +/* {"tool":"locate_pipes","params":{...}} */ +/* */ +/* Returns ordered segments (text + tool_call interleaved) so the */ +/* UI can render them inline where the AI placed them. */ +/* ------------------------------------------------------------------ */ + +const TOOL_CALL_BLOCK_PATTERN = /([\s\S]*?)<\/tool_call>/gi; +const TOOL_CALL_OPEN_TAG = ""; + +/** 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 ... blocks + const regex = /([\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; + }; + 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 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. " void; + /** 外部传入开始时间(ISO8601 字符串),用于初始化并触发查询 */ + start_time?: string; + /** 外部传入结束时间(ISO8601 字符串),用于初始化并触发查询 */ + end_time?: string; } type PanelTab = "chart" | "table"; @@ -314,6 +318,8 @@ const SCADADataPanel: React.FC = ({ fractionDigits = 2, showCleaning = false, onCleanData, + start_time, + end_time, }) => { const { open } = useNotification(); const { data: user } = useGetIdentity(); @@ -396,8 +402,24 @@ const SCADADataPanel: React.FC = ({ }; }, [showCleaning]); - const [from, setFrom] = useState(() => dayjs().subtract(1, "day")); - const [to, setTo] = useState(() => dayjs()); + const [from, setFrom] = useState(() => { + if (start_time) { + const parsedStart = dayjs(start_time); + if (parsedStart.isValid()) { + return parsedStart; + } + } + return dayjs().subtract(1, "day"); + }); + const [to, setTo] = useState(() => { + if (end_time) { + const parsedEnd = dayjs(end_time); + if (parsedEnd.isValid()) { + return parsedEnd; + } + } + return dayjs(); + }); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); @@ -412,6 +434,22 @@ const SCADADataPanel: React.FC = ({ 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; diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index e8fb328..39b38b3 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -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 = ({ scheme_name, defaultTab = "chart", fractionDigits = 2, + start_time, + end_time, }) => { // 从 featureInfos 中提取设备 ID 列表 const deviceIds = useMemo( @@ -403,8 +409,24 @@ const SCADADataPanel: React.FC = ({ [featureInfos] ); - const [from, setFrom] = useState(() => dayjs().subtract(1, "day")); - const [to, setTo] = useState(() => dayjs()); + const [from, setFrom] = useState(() => { + if (start_time) { + const parsedStart = dayjs(start_time); + if (parsedStart.isValid()) { + return parsedStart; + } + } + return dayjs().subtract(1, "day"); + }); + const [to, setTo] = useState(() => { + if (end_time) { + const parsedEnd = dayjs(end_time); + if (parsedEnd.isValid()) { + return parsedEnd; + } + } + return dayjs(); + }); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); @@ -418,6 +440,22 @@ const SCADADataPanel: React.FC = ({ 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; diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 0c7345c..378da91 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -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 = ({ 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([ { @@ -328,6 +415,8 @@ const Toolbar: React.FC = ({ case "history": setShowHistoryPanel(false); setHighlightFeatures([]); + setChatPanelFeatureInfos(null); + setChatPanelTimeRange(null); break; } }; @@ -354,6 +443,8 @@ const Toolbar: React.FC = ({ setHighlightFeatures([]); setShowDrawPanel(false); setShowHistoryPanel(false); + setChatPanelFeatureInfos(null); + setChatPanelTimeRange(null); // 样式编辑器保持其当前状态,不自动关闭 }; const [computedProperties, setComputedProperties] = useState< @@ -770,9 +861,16 @@ const Toolbar: React.FC = ({ /> {showHistoryPanel && - (HistoryPanel ? ( + (chatPanelType === "none" && chatPanelFeatureInfos ? ( + id)} + visible={showHistoryPanel} + start_time={chatPanelTimeRange?.startTime} + end_time={chatPanelTimeRange?.endTime} + /> + ) : HistoryPanel ? ( { + featureInfos={chatPanelFeatureInfos ?? (() => { if (highlightFeatures.length === 0 || !showHistoryPanel) return []; @@ -810,11 +908,13 @@ const Toolbar: React.FC = ({ })()} 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} /> ) : ( { + featureInfos={chatPanelFeatureInfos ?? (() => { if (highlightFeatures.length === 0 || !showHistoryPanel) return []; @@ -852,7 +952,9 @@ const Toolbar: React.FC = ({ })()} 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} /> ))} diff --git a/src/hooks/useChatToolActionHandler.ts b/src/hooks/useChatToolActionHandler.ts new file mode 100644 index 0000000..48516e9 --- /dev/null +++ b/src/hooks/useChatToolActionHandler.ts @@ -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; + }, []); +} diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 9bc6d36..3745809 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -9,6 +9,12 @@ export type StreamEvent = conversationId?: string; message: string; detail?: string; + } + | { + type: "tool_call"; + conversationId: string; + tool: string; + params: Record; }; type StreamOptions = { @@ -113,6 +119,8 @@ export const streamCopilotChat = async ({ content?: string; message?: string; detail?: string; + tool?: string; + params?: Record; }; 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({ diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts new file mode 100644 index 0000000..0f37a0d --- /dev/null +++ b/src/store/chatToolStore.ts @@ -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((set) => ({ + lastAction: null, + actionSeq: 0, + dispatch: (action) => + set((state) => ({ + lastAction: action, + actionSeq: state.actionSeq + 1, + })), +}));