feat: extend agent-driven map workbench
This commit is contained in:
@@ -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 }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user