feat: extend supply workbench demo and map navigation

This commit is contained in:
2026-09-23 13:19:30 +08:00
parent bebcf2a06c
commit 1752b9ef84
45 changed files with 2137 additions and 449 deletions
@@ -21,6 +21,7 @@ import {
type AgentApprovalMode,
type AgentChatMessage,
type AgentConnectionStatus,
type AgentEventTriggerContext,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
@@ -694,6 +695,70 @@ export function useWorkbenchAgent({
[approvalMode, chat, selectedModel, streaming]
);
const triggerEvent = useCallback(
async (trigger: AgentEventTriggerContext, prompt: string) => {
if (streaming) {
throw new Error("Agent 正在处理上一条指令,请稍后重试事件触发。");
}
stopSessionStreamSubscription();
const reserved = await clientRef.current.createSession({ triggerContext: trigger });
const nextSessionId = reserved.session_id;
const loadedSession = await clientRef.current.loadSession(nextSessionId);
if (!loadedSession) {
throw new Error("Agent 事件会话创建后无法读取。");
}
applySessionId(nextSessionId);
setSessionTitle(loadedSession.title || "水质投诉事件调查");
setPermissionOverrides({});
setQuestionOverrides({});
const nextMessages = toAgentUiMessages(loadedSession.messages);
chat.setMessages(nextMessages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
setConnectionStatus("online");
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
setStatusLabel("Agent 事件调查进行中");
startSessionStreamSubscription(nextSessionId);
return { sessionId: nextSessionId, reused: true };
}
if (nextMessages.length > 0) {
setStatusLabel("Agent 事件调查会话已打开");
await refreshSessionHistory();
return { sessionId: nextSessionId, reused: true };
}
setStatusLabel("Agent 正在调查水质投诉事件");
const eventRun = chat.sendMessage(
{ text: prompt },
{
body: {
session_id: nextSessionId,
model: selectedModel || undefined,
approval_mode: approvalMode,
trigger_context: trigger
}
}
);
void eventRun
.then(() => refreshSessionHistory())
.catch(() => undefined);
return { sessionId: nextSessionId, reused: reserved.event_duplicate === true };
},
[
approvalMode,
applySessionId,
chat,
refreshSessionHistory,
selectedModel,
startSessionStreamSubscription,
stopSessionStreamSubscription,
streaming
]
);
const stopPrompt = useCallback(() => {
chat.stop();
stopSessionStreamSubscription();
@@ -845,6 +910,7 @@ export function useWorkbenchAgent({
replyPermission,
replyQuestion,
resolveRenderRef,
triggerEvent,
submitPrompt
};
}
@@ -50,6 +50,7 @@ import {
scadaInteractionLayers
} from "../map/scada";
import { registerSupplyAssetImages } from "../map/supply-icons";
import { installCityScene } from "../map/city-scene-control";
import { useMapInteractions } from "./use-map-interactions";
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
@@ -79,7 +80,10 @@ type UseWorkbenchMapResult = {
sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[];
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
getScadaFeatures: (
deviceIds?: string[],
signal?: AbortSignal
) => Promise<FeatureCollection<Point, ScadaDevice>>;
scadaObservedAt: string | null;
refreshGeoServerLayers: () => boolean;
fitNetworkBounds: () => void;
@@ -96,6 +100,7 @@ export type WorkbenchSourceStatus = {
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"tianditu-base": "天地图底图",
"geoserver-mvt": "GeoServer MVT",
"geoserver-catalog": "GeoServer 图层目录",
"scada-api": "SCADA API",
@@ -106,6 +111,10 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
"tianditu-vector": "tianditu-base",
"tianditu-vector-annotation": "tianditu-base",
"tianditu-image": "tianditu-base",
"tianditu-image-annotation": "tianditu-base",
...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
@@ -197,9 +206,11 @@ export function useWorkbenchMap({
? current
: availability.availableSourceIds
);
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts);
setSourceFeatureCounts((current) =>
areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts
);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
@@ -251,12 +262,13 @@ export function useWorkbenchMap({
setMapReady(false);
setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const tiandituToken = env.TJWATER_TIANDITU_TOKEN || undefined;
const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
style: createBaseStyle(mapboxToken, tiandituToken),
pitch: 0,
bearing: 0,
maxZoom: MAP_MAX_ZOOM,
@@ -265,9 +277,11 @@ export function useWorkbenchMap({
});
window.__waterNetworkMap = map;
let removeCityScene: (() => void) | undefined;
map.on("load", async () => {
map.resize();
removeCityScene = installCityScene(map);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
@@ -279,11 +293,7 @@ export function useWorkbenchMap({
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(
map,
simulationOverlayRef.current,
allowDemoSimulationRef.current
);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
await registerSupplyAssetImages(map);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers =
@@ -340,10 +350,7 @@ export function useWorkbenchMap({
});
const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot(
getAccessToken,
scadaAbortController.signal
);
const snapshotPromise = fetchScadaSnapshot(getAccessToken, scadaAbortController.signal);
scadaLoadPromiseRef.current = snapshotPromise
.then((snapshot) => snapshot.collection)
.catch(() => scadaCollectionRef.current);
@@ -428,6 +435,7 @@ export function useWorkbenchMap({
if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId);
}
removeCityScene?.();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
@@ -500,8 +508,10 @@ function areFeatureCountsEqual(
) {
const currentEntries = Object.entries(current);
const nextEntries = Object.entries(next);
return currentEntries.length === nextEntries.length
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count);
return (
currentEntries.length === nextEntries.length &&
nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count)
);
}
function networkViewKey(view: NetworkViewParams) {
@@ -549,8 +559,9 @@ function syncDataDependentGeoServerLayers(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIdSet.has(sourceId)) return;
map.getStyle().layers
.filter((layer) => "source" in layer && layer.source === sourceId)
map
.getStyle()
.layers.filter((layer) => "source" in layer && layer.source === sourceId)
.reverse()
.forEach((layer) => map.removeLayer(layer.id));
if (map.getSource(sourceId)) map.removeSource(sourceId);
@@ -711,11 +722,15 @@ function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { mess
return "mapbox-base";
}
if (message.includes("tianditu.gov.cn") || message.includes("tianditu")) {
return "tianditu-base";
}
return null;
}
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (sourceGroupId === "mapbox-base") {
if (["mapbox-base", "tianditu-base"].includes(sourceGroupId)) {
return status === "online"
? "底图瓦片已恢复正常。"
: "底图瓦片请求中断,业务图层仍可继续使用。";