diff --git a/src/components/chat/ChatToolCallBlock.tsx b/src/components/chat/ChatToolCallBlock.tsx index a3f4db7..75ae719 100644 --- a/src/components/chat/ChatToolCallBlock.tsx +++ b/src/components/chat/ChatToolCallBlock.tsx @@ -34,8 +34,26 @@ type ToolMeta = { color: string; }; +const LOCATE_TOOL_TO_LAYER: Record = { + locate_features: "", + locate_junctions: "geo_junctions_mat", + locate_pipes: "geo_pipes_mat", + locate_valves: "geo_valves", + locate_reservoirs: "geo_reservoirs", + locate_pumps: "geo_pumps", + locate_tanks: "geo_tanks", +}; + +const LOCATE_LINE_TOOLS = new Set(["locate_pipes"]); + const TOOL_META: Record = { - locate_nodes: { + locate_features: { + label: "定位要素", + icon: , + actionLabel: "定位到地图", + color: "#5470c6", + }, + locate_junctions: { label: "定位节点", icon: , actionLabel: "定位到地图", @@ -47,6 +65,30 @@ const TOOL_META: Record = { actionLabel: "定位到地图", color: "#91cc75", }, + locate_valves: { + label: "定位阀门", + icon: , + actionLabel: "定位到地图", + color: "#9a60b4", + }, + locate_reservoirs: { + label: "定位水源", + icon: , + actionLabel: "定位到地图", + color: "#ea7ccc", + }, + locate_pumps: { + label: "定位泵站", + icon: , + actionLabel: "定位到地图", + color: "#fc8452", + }, + locate_tanks: { + label: "定位水池", + icon: , + actionLabel: "定位到地图", + color: "#3ba272", + }, view_history: { label: "查看计算结果", icon: , @@ -71,6 +113,19 @@ const TOOL_META: Record = { function getToolDescription(toolCall: ToolCall): string { const { params } = toolCall; + const normalizeIds = (): string[] => { + const rawIds = params.ids; + if (Array.isArray(rawIds)) { + return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); + } + if (typeof rawIds === "string") { + return rawIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + } + return []; + }; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -119,13 +174,36 @@ function getToolDescription(toolCall: ToolCall): string { (params.to as string | undefined) ?? (params.end as string | undefined), }); + const resolveLocateFeatureType = (): string => { + const rawType = params.feature_type; + if (typeof rawType === "string" && rawType.trim()) { + return rawType.trim().toLowerCase(); + } + return ""; + }; switch (toolCall.tool) { - case "locate_nodes": - case "locate_pipes": { - const ids = (params.ids as string[] | undefined) ?? []; - return ids.length > 3 + case "locate_features": + case "locate_junctions": + case "locate_pipes": + case "locate_valves": + case "locate_reservoirs": + case "locate_pumps": + case "locate_tanks": { + const ids = normalizeIds(); + const idsText = + ids.length > 3 ? `${ids.slice(0, 3).join(", ")} 等 ${ids.length} 个` : ids.join(", "); + if (toolCall.tool !== "locate_features") { + return idsText; + } + const featureType = resolveLocateFeatureType(); + if (!featureType) { + return idsText; + } + return idsText + ? `${featureType} · ${idsText}` + : featureType; } case "view_history": case "view_scada": { @@ -155,6 +233,19 @@ function getToolDescription(toolCall: ToolCall): string { function buildAction(toolCall: ToolCall): ChatToolAction | null { const { params } = toolCall; + const normalizeIds = (): string[] => { + const rawIds = params.ids; + if (Array.isArray(rawIds)) { + return rawIds.map((id) => String(id)).filter((id) => id.trim().length > 0); + } + if (typeof rawIds === "string") { + return rawIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + } + return []; + }; const resolveScadaFeatureInfos = (): [string, string][] => { const rawFeatureInfos = params.feature_infos; if (Array.isArray(rawFeatureInfos)) { @@ -204,16 +295,36 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null { (params.end as string | undefined), }); switch (toolCall.tool) { - case "locate_nodes": + case "locate_features": { + const featureTypeRaw = params.feature_type; + const featureType = + typeof featureTypeRaw === "string" + ? featureTypeRaw.trim().toLowerCase() + : ""; + const config = locateFeatureTypeToConfig(featureType); + if (!config) return null; return { - type: "locate_nodes", - ids: (params.ids as string[] | undefined) ?? [], + type: "locate_features", + ids: normalizeIds(), + layer: config.layer, + geometryKind: config.geometryKind, }; + } + case "locate_junctions": case "locate_pipes": + case "locate_valves": + case "locate_reservoirs": + case "locate_pumps": + case "locate_tanks": { + const layer = LOCATE_TOOL_TO_LAYER[toolCall.tool]; + if (!layer) return null; return { - type: "locate_pipes", - ids: (params.ids as string[] | undefined) ?? [], + type: "locate_features", + ids: normalizeIds(), + layer, + geometryKind: LOCATE_LINE_TOOLS.has(toolCall.tool) ? "line" : "point", }; + } case "view_history": { const historyRange = resolveTimeRange(); return { @@ -383,3 +494,29 @@ export const ChatToolCallBlock: React.FC = ({ ); }; + const locateFeatureTypeToConfig = ( + featureType: string, + ): { layer: string; geometryKind: "point" | "line" } | null => { + switch (featureType) { + case "junction": + case "junctions": + return { layer: "geo_junctions_mat", geometryKind: "point" }; + case "pipe": + case "pipes": + return { layer: "geo_pipes_mat", geometryKind: "line" }; + case "valve": + case "valves": + return { layer: "geo_valves", geometryKind: "point" }; + case "reservoir": + case "reservoirs": + return { layer: "geo_reservoirs", geometryKind: "point" }; + case "pump": + case "pumps": + return { layer: "geo_pumps", geometryKind: "point" }; + case "tank": + case "tanks": + return { layer: "geo_tanks", geometryKind: "point" }; + default: + return null; + } + }; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 84ffa38..9d1ee1b 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -789,15 +789,50 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { } // Other frontend tools → dispatch to chatToolStore immediately + const buildLocateFeaturesAction = ( + layer: string, + geometryKind: "point" | "line", + ): ChatToolAction => ({ + type: "locate_features" as const, + ids: (params.ids as string[]) ?? [], + layer, + geometryKind, + }); + const buildLocateByFeatureType = (): ChatToolAction | null => { + const rawType = params.feature_type; + const featureType = + typeof rawType === "string" ? rawType.trim().toLowerCase() : ""; + const featureTypeMap: Record< + string, + { layer: string; geometryKind: "point" | "line" } + > = { + junction: { layer: "geo_junctions_mat", geometryKind: "point" }, + junctions: { layer: "geo_junctions_mat", geometryKind: "point" }, + pipe: { layer: "geo_pipes_mat", geometryKind: "line" }, + pipes: { layer: "geo_pipes_mat", geometryKind: "line" }, + valve: { layer: "geo_valves", geometryKind: "point" }, + valves: { layer: "geo_valves", geometryKind: "point" }, + reservoir: { layer: "geo_reservoirs", geometryKind: "point" }, + reservoirs: { layer: "geo_reservoirs", geometryKind: "point" }, + pump: { layer: "geo_pumps", geometryKind: "point" }, + pumps: { layer: "geo_pumps", geometryKind: "point" }, + tank: { layer: "geo_tanks", geometryKind: "point" }, + tanks: { layer: "geo_tanks", geometryKind: "point" }, + }; + const config = featureTypeMap[featureType]; + if (!config) return null; + return buildLocateFeaturesAction(config.layer, config.geometryKind); + }; 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[]) ?? [], - }), + locate_features: buildLocateByFeatureType, + locate_pipes: () => buildLocateFeaturesAction("geo_pipes_mat", "line"), + locate_junctions: () => + buildLocateFeaturesAction("geo_junctions_mat", "point"), + locate_valves: () => buildLocateFeaturesAction("geo_valves", "point"), + locate_reservoirs: () => + buildLocateFeaturesAction("geo_reservoirs", "point"), + locate_pumps: () => buildLocateFeaturesAction("geo_pumps", "point"), + locate_tanks: () => buildLocateFeaturesAction("geo_tanks", "point"), view_history: () => ({ type: "view_history" as const, featureInfos: (params.feature_infos as [string, string][]) ?? [], @@ -837,6 +872,17 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { ); } else if (event.type === "done") { if (!conversationId && event.conversationId) setConversationId(event.conversationId); + setMessages((prev) => + prev.map((m) => + m.id === assistantId && m.content.trim().length === 0 + ? { + ...m, + content: "⚠️ **错误:** Copilot 未返回内容,请稍后重试。", + isError: true, + } + : m + ) + ); setIsStreaming(false); } else if (event.type === "error") { setMessages((prev) => diff --git a/src/components/chat/chatMessageSections.test.ts b/src/components/chat/chatMessageSections.test.ts index 7e138dc..bf73cc0 100644 --- a/src/components/chat/chatMessageSections.test.ts +++ b/src/components/chat/chatMessageSections.test.ts @@ -56,11 +56,11 @@ describe("parseContentWithToolCalls", () => { it("parses a complete tool_call block", () => { const content = - '分析完成。\n{"tool":"locate_nodes","params":{"ids":["J1","J2"]}}\n以上是结果。'; + '分析完成。\n{"tool":"locate_junctions","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].tool).toBe("locate_junctions"); expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] }); expect(result.segments).toHaveLength(3); @@ -70,7 +70,7 @@ describe("parseContentWithToolCalls", () => { }); expect(result.segments[1]).toMatchObject({ type: "tool_call", - toolCall: { tool: "locate_nodes" }, + toolCall: { tool: "locate_junctions" }, }); expect(result.segments[2]).toEqual({ type: "text", diff --git a/src/components/olmap/core/Controls/Toolbar.tsx b/src/components/olmap/core/Controls/Toolbar.tsx index 378da91..29946b5 100644 --- a/src/components/olmap/core/Controls/Toolbar.tsx +++ b/src/components/olmap/core/Controls/Toolbar.tsx @@ -15,6 +15,7 @@ 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 Point from "ol/geom/Point"; import { bbox, featureCollection } from "@turf/turf"; import StyleEditorPanel from "./StyleEditorPanel"; import { LayerStyleState } from "./StyleEditorPanel"; @@ -72,38 +73,52 @@ const Toolbar: React.FC = ({ useCallback( (action) => { const geojsonFormat = new GeoJSON(); - const zoomToFeatures = (features: Feature[]) => { + const zoomToFeatures = ( + features: Feature[], + geometryKind: "point" | "line", + ) => { if (features.length === 0) return; + + if (geometryKind === "point" && features.length === 1) { + const geometry = features[0].getGeometry(); + if (geometry instanceof Point) { + map?.getView().animate({ + center: geometry.getCoordinates(), + zoom: 18, + duration: 1000, + }); + 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 }); + map?.getView().fit(extent, { + maxZoom: 18, + duration: 1000, + padding: geometryKind === "line" ? [60, 60, 60, 60] : [40, 40, 40, 40], + }); } }; + const locateFeatures = ( + ids: string[], + layer: string, + geometryKind: "point" | "line", + ) => { + queryFeaturesByIds(ids, layer).then((features) => { + if (features.length > 0) { + setHighlightFeatures(features); + zoomToFeatures(features, geometryKind); + } + }); + }; 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); - } - }, - ); + case "locate_features": { + locateFeatures(action.ids, action.layer, action.geometryKind); break; } case "view_history": { diff --git a/src/hooks/useChatToolActionHandler.ts b/src/hooks/useChatToolActionHandler.ts index 48516e9..a58e417 100644 --- a/src/hooks/useChatToolActionHandler.ts +++ b/src/hooks/useChatToolActionHandler.ts @@ -11,8 +11,7 @@ import { * ```ts * useChatToolActionHandler((action) => { * switch (action.type) { - * case "locate_nodes": handleLocateNodes(action.ids); break; - * case "locate_pipes": handleLocatePipes(action.ids); break; + * case "locate_features": handleLocateFeatures(action.ids, action.layer, action.geometryKind); break; * case "view_history": openHistoryPanel(action.featureInfos, action.dataType); break; * case "view_scada": openScadaPanel(action.featureInfos); break; * } @@ -23,7 +22,10 @@ export function useChatToolActionHandler( handler: (action: ChatToolAction) => void, ) { const handlerRef = useRef(handler); - handlerRef.current = handler; + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); useEffect(() => { const unsubscribe = useChatToolStore.subscribe( diff --git a/src/store/chatToolStore.ts b/src/store/chatToolStore.ts index 0f37a0d..3ad963a 100644 --- a/src/store/chatToolStore.ts +++ b/src/store/chatToolStore.ts @@ -7,8 +7,12 @@ import { create } from "zustand"; /* ------------------------------------------------------------------ */ export type ChatToolAction = - | { type: "locate_nodes"; ids: string[] } - | { type: "locate_pipes"; ids: string[] } + | { + type: "locate_features"; + ids: string[]; + layer: string; + geometryKind: "point" | "line"; + } | { type: "view_history"; featureInfos: [string, string][];