抽象统一定位方法,支持多种地理要素

This commit is contained in:
2026-04-03 13:45:37 +08:00
parent d610a09c14
commit c484aad1d3
6 changed files with 252 additions and 48 deletions
+147 -10
View File
@@ -34,8 +34,26 @@ type ToolMeta = {
color: string;
};
const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
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<string>(["locate_pipes"]);
const TOOL_META: Record<string, ToolMeta> = {
locate_nodes: {
locate_features: {
label: "定位要素",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#5470c6",
},
locate_junctions: {
label: "定位节点",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
@@ -47,6 +65,30 @@ const TOOL_META: Record<string, ToolMeta> = {
actionLabel: "定位到地图",
color: "#91cc75",
},
locate_valves: {
label: "定位阀门",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#9a60b4",
},
locate_reservoirs: {
label: "定位水源",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#ea7ccc",
},
locate_pumps: {
label: "定位泵站",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#fc8452",
},
locate_tanks: {
label: "定位水池",
icon: <LocationOnRounded sx={{ fontSize: 18 }} />,
actionLabel: "定位到地图",
color: "#3ba272",
},
view_history: {
label: "查看计算结果",
icon: <TimelineRounded sx={{ fontSize: 18 }} />,
@@ -71,6 +113,19 @@ const TOOL_META: Record<string, ToolMeta> = {
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<ChatToolCallBlockProps> = ({
</Paper>
);
};
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;
}
};
+54 -8
View File
@@ -789,15 +789,50 @@ export const GlobalChatbox: React.FC<Props> = ({ 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<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[]) ?? [],
}),
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<Props> = ({ 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) =>
@@ -56,11 +56,11 @@ describe("parseContentWithToolCalls", () => {
it("parses a complete tool_call block", () => {
const content =
'分析完成。\n<tool_call>{"tool":"locate_nodes","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
'分析完成。\n<tool_call>{"tool":"locate_junctions","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].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",
+37 -22
View File
@@ -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<ToolbarProps> = ({
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": {
+5 -3
View File
@@ -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(
+6 -2
View File
@@ -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][];