feat: extend agent-driven map workbench

This commit is contained in:
2026-07-20 19:57:35 +08:00
parent 86e9b08235
commit 7fbd8a5618
63 changed files with 4506 additions and 457 deletions
@@ -1,5 +1,5 @@
import type { UIMessage } from "ai";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type { Dispatch, SetStateAction } from "react";
import type {
AgentChatMessage,
AgentPermissionReply,
@@ -48,13 +48,11 @@ export type QuestionOverride = {
};
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
type StreamRenderChunkIdRef = MutableRefObject<number>;
export function appendStreamRenderToken(
export function markStreamRenderPending(
data: Record<string, unknown>,
messages: AgentUiMessage[],
setStreamRenderState: StreamRenderStateSetter,
chunkIdRef: StreamRenderChunkIdRef
setStreamRenderState: StreamRenderStateSetter
) {
const text = getDataString(data, "content");
if (!text) {
@@ -66,17 +64,14 @@ export function appendStreamRenderToken(
return;
}
const chunkId = chunkIdRef.current;
chunkIdRef.current += 1;
setStreamRenderState((current) => {
const previous = current[messageId] ?? { chunks: [], done: false };
if (current[messageId]?.done === false) {
return current;
}
return {
...current,
[messageId]: {
chunks: [...previous.chunks, { id: chunkId, text }],
done: false
}
[messageId]: { done: false }
};
});
}
@@ -84,10 +79,7 @@ export function appendStreamRenderToken(
export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState {
return messages.reduce<AgentStreamRenderState>((next, message) => {
if (message.role === "assistant") {
next[message.id] = {
chunks: [],
done: true
};
next[message.id] = { done: true };
}
return next;
}, {});
@@ -107,10 +99,7 @@ export function markLastAssistantStreamDone(
return {
...current,
[messageId]: {
chunks: current[messageId]?.chunks ?? [],
done: true
}
[messageId]: { done: true }
};
});
}
+21 -16
View File
@@ -8,6 +8,7 @@ import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { showMapNotice } from "@/features/map/core/components/notice";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
@@ -34,11 +35,11 @@ import {
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
import {
appendStreamRenderToken,
applySessionStreamEvent,
createCompletedStreamRenderState,
getDataString,
markLastAssistantStreamDone,
markStreamRenderPending,
readBodyString,
toAgentChatMessages,
toAgentUiMessages,
@@ -62,10 +63,9 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const sessionStreamIdRef = useRef<string | null>(null);
const streamRenderChunkIdRef = useRef(0);
const clientRef = useRef(createAgentApiClient());
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
@@ -82,6 +82,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
@@ -90,6 +91,11 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
sessionIdRef.current = sessionId;
}, [sessionId]);
const setApprovalMode = useCallback((mode: AgentApprovalMode) => {
approvalModeRef.current = mode;
setApprovalModeState(mode);
}, []);
useEffect(() => {
registryRef.current = registry;
}, [registry]);
@@ -148,11 +154,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
applySessionId(eventSessionId);
if (part.type === "data-stream_token") {
appendStreamRenderToken(
markStreamRenderPending(
part.data,
chatRef.current.messages,
setStreamRenderState,
streamRenderChunkIdRef
setStreamRenderState
);
return;
}
@@ -214,7 +219,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
trigger,
messageId,
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
approval_mode: readBodyString(body, "approval_mode") ?? "request",
approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
}
};
@@ -281,7 +286,6 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const stopSessionStreamSubscription = useCallback(() => {
sessionStreamAbortRef.current?.abort();
sessionStreamAbortRef.current = null;
sessionStreamIdRef.current = null;
setResumedStreaming(false);
}, []);
@@ -418,11 +422,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
setSessionTitle(title);
}
} else if (event.type === "token") {
appendStreamRenderToken(
markStreamRenderPending(
event.data,
chatRef.current.messages,
setStreamRenderState,
streamRenderChunkIdRef
setStreamRenderState
);
} else if (event.type === "ui_envelope") {
const payload = parseUiEnvelopePayload(event.data);
@@ -473,7 +476,6 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
const controller = new AbortController();
sessionStreamAbortRef.current = controller;
sessionStreamIdRef.current = nextSessionId;
setResumedStreaming(true);
void clientRef.current
@@ -496,7 +498,6 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
.finally(() => {
if (sessionStreamAbortRef.current === controller) {
sessionStreamAbortRef.current = null;
sessionStreamIdRef.current = null;
setResumedStreaming(false);
void refreshSessionHistory();
}
@@ -608,7 +609,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
async (nextSessionId: string) => {
if (streaming) {
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" });
return;
return false;
}
try {
@@ -623,6 +624,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
}
await refreshSessionHistory();
return true;
} catch (error) {
showMapNotice({
id: "agent-history-status",
@@ -630,6 +632,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
title: "Agent 历史记录删除失败",
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
});
return false;
}
},
[mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming]
@@ -664,12 +667,12 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
body: {
session_id: sessionIdRef.current ?? undefined,
model: selectedModel || undefined,
approval_mode: "request"
approval_mode: approvalMode
}
}
);
},
[chat, selectedModel, streaming]
[approvalMode, chat, selectedModel, streaming]
);
const stopPrompt = useCallback(() => {
@@ -788,6 +791,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
}, []);
return {
approvalMode,
collapsePanel,
expandPanel,
mobileOpen,
@@ -811,6 +815,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
statusLabel,
streamRenderState,
selectedModel,
setApprovalMode,
setSelectedModel,
stopPrompt,
streaming,
@@ -0,0 +1,39 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
import { WorkbenchMapController } from "../map/workbench-map-controller";
export function useWorkbenchMapController({
mapRef,
mapReady,
leftPanelOpen,
rightPanelOpen
}: {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
}) {
const valuesRef = useRef({ mapReady, leftPanelOpen, rightPanelOpen });
valuesRef.current = { mapReady, leftPanelOpen, rightPanelOpen };
const controllerRef = useRef<WorkbenchMapController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new WorkbenchMapController({
getMap: () => mapRef.current,
isReady: () => valuesRef.current.mapReady,
getPadding: () => {
const map = mapRef.current;
return map
? getResponsiveWorkbenchPadding(map, valuesRef.current.leftPanelOpen, valuesRef.current.rightPanelOpen)
: { top: 48, right: 48, bottom: 48, left: 48 };
}
});
}
const controller = controllerRef.current;
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
useEffect(() => () => controller.destroy(), [controller]);
return { controller, state };
}
@@ -17,6 +17,11 @@ import {
waterNetworkInteractionLayers
} from "../map/layers";
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
import {
VALUE_LABEL_LAYER_IDS,
VALUE_LABEL_SOURCE_ID,
createEmptyValueLabelCollection
} from "../map/value-label";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
registerScadaImages,
@@ -24,6 +29,11 @@ import {
scadaLayers,
type ScadaImageRegistrationResult
} from "../map/scada";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection,
ensureScadaAnalysisLayers
} from "../map/scada-analysis";
import {
createBaseStyle,
createWaterNetworkSources,
@@ -134,12 +144,33 @@ export function useWorkbenchMap({
map.addSource("outfalls", sources.outfalls);
map.addSource("pumps", sources.pumps);
map.addSource("scada", sources.scada);
map.addSource(SCADA_ANALYSIS_SOURCE_ID, {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() });
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[0],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["==", ["geometry-type"], "Point"],
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[1],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
let scadaRegistration: ScadaImageRegistrationResult = {
status: "unavailable",
failedImageIds: []
@@ -156,6 +187,7 @@ export function useWorkbenchMap({
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
map.addLayer(presentationLayers[presentationLayers.length - 1]);
ensureScadaAnalysisLayers(map);
} finally {
setSimulationLayersVisibility(map, impactVisibleRef.current);
setMapReady(true);