feat(drainage): execute frontend actions

This commit is contained in:
2026-07-13 17:26:03 +08:00
parent c2e1c937ae
commit 0b7e827024
8 changed files with 171 additions and 8 deletions
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
const TYPES = { junction: "drainage:geo_junctions", conduit: "drainage:geo_conduits", pipe: "drainage:geo_conduits", orifice: "drainage:geo_orifices", outfall: "drainage:geo_outfalls", pump: "drainage:geo_pumps" } as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null) as { ids?: unknown; featureType?: unknown } | null;
if (!body || !Array.isArray(body.ids) || body.ids.length < 1 || body.ids.length > 100 || !body.ids.every((id) => typeof id === "string" && id.trim())) return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400 });
if (typeof body.featureType !== "string" || !(body.featureType in TYPES)) return NextResponse.json({ code: "UNSUPPORTED_FEATURE_TYPE" }, { status: 422 });
const ids = body.ids.map((id) => String(id).trim()); const cql = `id IN (${ids.map((id) => `'${id.replaceAll("'", "''")}'`).join(",")})`;
const url = new URL("https://geoserver.waternetwork.cn/geoserver/drainage/ows"); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: TYPES[body.featureType as keyof typeof TYPES], outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString();
try { const response = await fetch(url, { signal: AbortSignal.timeout(8_000), cache: "no-store" }); const data = await response.json(); if (!response.ok || !data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) throw new Error(); return NextResponse.json({ type: "FeatureCollection", features: data.features.slice(0, 100) }); }
catch { return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); }
}
+18
View File
@@ -54,6 +54,8 @@ export type AgentSessionStreamDataEventType =
| "question_request" | "question_request"
| "question_response" | "question_response"
| "tool_call" | "tool_call"
| "frontend_action"
| "frontend_action_result"
| "ui_envelope" | "ui_envelope"
| "session_title" | "session_title"
| "done" | "done"
@@ -93,6 +95,8 @@ export type AgentApiClient = {
deleteSession: (sessionId: string) => Promise<void>; deleteSession: (sessionId: string) => Promise<void>;
getModels: () => Promise<AgentModelsResponse>; getModels: () => Promise<AgentModelsResponse>;
getUiRegistry: () => Promise<unknown>; getUiRegistry: () => Promise<unknown>;
getFrontendActionRegistry: () => Promise<unknown>;
submitFrontendActionResult: (sessionId: string, actionId: string, result: unknown) => Promise<void>;
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>; resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
replyPermission: ( replyPermission: (
requestId: string, requestId: string,
@@ -139,6 +143,18 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries); return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
}, },
async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
},
async submitFrontendActionResult(sessionId, actionId, result) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result)
});
},
async loadSession(sessionId) { async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl; activeBaseUrl = nextBaseUrl;
@@ -540,6 +556,8 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "question_request" || value === "question_request" ||
value === "question_response" || value === "question_response" ||
value === "tool_call" || value === "tool_call" ||
value === "frontend_action" ||
value === "frontend_action_result" ||
value === "ui_envelope" || value === "ui_envelope" ||
value === "session_title" || value === "session_title" ||
value === "done" || value === "done" ||
+5 -1
View File
@@ -4,24 +4,28 @@ import type {
AgentModelOption AgentModelOption
} from "./client"; } from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope"; import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
export const agentBootstrapKey = ["agent", "bootstrap"] as const; export const agentBootstrapKey = ["agent", "bootstrap"] as const;
export const agentSessionsKey = ["agent", "sessions"] as const; export const agentSessionsKey = ["agent", "sessions"] as const;
export type AgentBootstrapData = { export type AgentBootstrapData = {
registry: UIRegistry | null; registry: UIRegistry | null;
frontendActionRegistry?: FrontendActionRegistry | null;
models: AgentModelOption[]; models: AgentModelOption[];
defaultModel: string; defaultModel: string;
}; };
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> { export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, modelsResponse] = await Promise.all([ const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(), client.getUiRegistry(),
typeof client.getFrontendActionRegistry === "function" ? client.getFrontendActionRegistry() : Promise.resolve(undefined),
client.getModels() client.getModels()
]); ]);
return { return {
registry: parseUiRegistry(rawRegistry), registry: parseUiRegistry(rawRegistry),
...(rawFrontendActionRegistry === undefined ? {} : { frontendActionRegistry: parseFrontendActionRegistry(rawFrontendActionRegistry) }),
models: modelsResponse.models, models: modelsResponse.models,
defaultModel: modelsResponse.defaultModel defaultModel: modelsResponse.defaultModel
}; };
@@ -0,0 +1,55 @@
import {
parseFrontendActionRequest,
readActionResults,
storeActionResult,
type FrontendActionRequest,
type FrontendActionResult
} from ".";
type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
type SubmitResult = (sessionId: string, actionId: string, result: FrontendActionResult) => Promise<void>;
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/;
export class FrontendActionExecutor {
private readonly controllers = new Map<string, AbortController>();
constructor(private readonly execute: ExecuteAction, private readonly submit: SubmitResult) {}
async handle(value: unknown, activeSessionId: string | null): Promise<void> {
const request = parseFrontendActionRequest(value);
if (!request || request.sessionId !== activeSessionId) return;
const saved = readActionResults(request.sessionId)[request.actionId];
if (saved) {
await this.submit(request.sessionId, request.actionId, saved);
return;
}
if (this.controllers.has(request.actionId)) return;
const result = await this.executeOnce(request);
storeActionResult(request.sessionId, result);
await this.submit(request.sessionId, request.actionId, result);
}
cancelAll(): void {
for (const controller of this.controllers.values()) controller.abort();
this.controllers.clear();
}
private async executeOnce(request: FrontendActionRequest): Promise<FrontendActionResult> {
if (Date.now() >= request.expiresAt) return this.failure(request, "expired", "ACTION_EXPIRED", "frontend action expired before execution");
const controller = new AbortController();
this.controllers.set(request.actionId, controller);
try {
return { version: "frontend-action-result@1", actionId: request.actionId, status: "succeeded", output: await this.execute(request, controller.signal), completedAt: Date.now() };
} catch (error) {
const message = error instanceof Error ? error.message : "frontend action failed";
const code = controller.signal.aborted ? "ACTION_CANCELLED" : ERROR_CODE_PATTERN.test(message) ? message : "ACTION_FAILED";
return this.failure(request, controller.signal.aborted ? "cancelled" : "failed", code, message);
} finally {
this.controllers.delete(request.actionId);
}
}
private failure(request: FrontendActionRequest, status: "failed" | "cancelled" | "expired", code: string, message: string): FrontendActionResult {
return { version: "frontend-action-result@1", actionId: request.actionId, status, error: { code, message }, completedAt: Date.now() };
}
}
+11
View File
@@ -0,0 +1,11 @@
export const FRONTEND_ACTION_NAMES = ["zoom_to_map", "locate_features", "view_history", "view_scada"] as const;
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
export type FrontendActionResult = { version: "frontend-action-result@1"; actionId: string; status: "succeeded" | "failed" | "cancelled" | "expired"; output?: unknown; error?: { code: string; message: string }; completedAt: number };
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null { if (!isRecord(value) || value.schema_version !== "frontend-action-registry@1" || !Array.isArray(value.actions)) return null; const actions = value.actions.filter(isRecord); if (!FRONTEND_ACTION_NAMES.every((name) => actions.some((item) => item.id === name && item.version === "frontend-action@1"))) return null; return value as FrontendActionRegistry; }
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null { if (!isRecord(value) || value.version !== "frontend-action@1" || typeof value.actionId !== "string" || typeof value.toolCallId !== "string" || typeof value.sessionId !== "string" || !FRONTEND_ACTION_NAMES.includes(value.name as FrontendActionName) || !isRecord(value.params) || typeof value.issuedAt !== "number" || typeof value.expiresAt !== "number") return null; return value as FrontendActionRequest; }
const storageKey = (sessionId: string) => `tjwater:frontend-actions:${sessionId}`;
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> { try { const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}"); return isRecord(value) ? value as Record<string, FrontendActionResult> : {}; } catch { return {}; } }
export function storeActionResult(sessionId: string, result: FrontendActionResult) { const values = { ...readActionResults(sessionId), [result.actionId]: result }; const bounded = Object.fromEntries(Object.entries(values).sort(([, a], [, b]) => b.completedAt - a.completedAt).slice(0, 100)); sessionStorage.setItem(storageKey(sessionId), JSON.stringify(bounded)); }
@@ -26,6 +26,8 @@ type AgentUiDataParts = {
question_response: Record<string, unknown>; question_response: Record<string, unknown>;
stream_token: Record<string, unknown>; stream_token: Record<string, unknown>;
tool_call: Record<string, unknown>; tool_call: Record<string, unknown>;
frontend_action: Record<string, unknown>;
frontend_action_result: Record<string, unknown>;
ui_envelope: Record<string, unknown>; ui_envelope: Record<string, unknown>;
session_title: Record<string, unknown>; session_title: Record<string, unknown>;
}; };
@@ -31,6 +31,8 @@ import {
type UIEnvelopePayload, type UIEnvelopePayload,
type UIRegistry type UIRegistry
} from "@/features/agent/ui-envelope"; } from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
import { import {
appendStreamRenderToken, appendStreamRenderToken,
applySessionStreamEvent, applySessionStreamEvent,
@@ -53,9 +55,10 @@ type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "fa
type UseWorkbenchAgentOptions = { type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void; onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
}; };
export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) { export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
const collapseTimerRef = useRef<number | null>(null); const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null); const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null); const sessionStreamAbortRef = useRef<AbortController | null>(null);
@@ -64,6 +67,9 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
const clientRef = useRef(createAgentApiClient()); const clientRef = useRef(createAgentApiClient());
const sessionIdRef = useRef<string | null>(null); const sessionIdRef = useRef<string | null>(null);
const registryRef = useRef<UIRegistry | null>(null); const registryRef = useRef<UIRegistry | null>(null);
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true); const [panelOpen, setPanelOpen] = useState(true);
const [panelCollapsing, setPanelCollapsing] = useState(false); const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -88,6 +94,10 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
registryRef.current = registry; registryRef.current = registry;
}, [registry]); }, [registry]);
useEffect(() => {
onFrontendActionRef.current = onFrontendAction;
}, [onFrontendAction]);
const applySessionId = useCallback((nextSessionId: string | undefined) => { const applySessionId = useCallback((nextSessionId: string | undefined) => {
if (!nextSessionId || sessionIdRef.current === nextSessionId) { if (!nextSessionId || sessionIdRef.current === nextSessionId) {
return; return;
@@ -105,6 +115,30 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
return clientRef.current.resolveRenderRef(renderRef, nextSessionId); return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
}, []); }, []);
const frontendActionExecutor = useMemo(
() => new FrontendActionExecutor(
(request, signal) => onFrontendActionRef.current(request, signal),
(nextSessionId, actionId, result) => clientRef.current.submitFrontendActionResult(nextSessionId, actionId, result)
),
[]
);
const handleFrontendAction = useCallback(
(value: unknown) => frontendActionExecutor.handle(value, sessionIdRef.current),
[frontendActionExecutor]
);
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
if (payload.session_id !== activeSessionId) return false;
const key = `${activeSessionId}:${payload.envelope_id}`;
if (processedEnvelopeIdsRef.current.has(key)) return false;
processedEnvelopeIdsRef.current.add(key);
if (processedEnvelopeIdsRef.current.size > 256) {
const oldest = processedEnvelopeIdsRef.current.values().next().value;
if (oldest) processedEnvelopeIdsRef.current.delete(oldest);
}
return true;
}, []);
const handleDataPart = useCallback( const handleDataPart = useCallback(
(part: AgentDataPart) => { (part: AgentDataPart) => {
const eventSessionId = getDataString(part.data, "session_id"); const eventSessionId = getDataString(part.data, "session_id");
@@ -130,6 +164,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
} }
return; return;
} }
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
if (part.type === "data-session_title") { if (part.type === "data-session_title") {
const title = getDataString(part.data, "title"); const title = getDataString(part.data, "title");
@@ -153,6 +188,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
}); });
return; return;
} }
if (!claimEnvelope(payload, activeSessionId)) return;
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => { void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({ showMapNotice({
@@ -162,7 +198,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
}); });
}); });
}, },
[applySessionId, onUiEnvelope] [applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
); );
const transport = useMemo( const transport = useMemo(
@@ -178,7 +214,8 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
trigger, trigger,
messageId, messageId,
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined, session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
approval_mode: readBodyString(body, "approval_mode") ?? "request" approval_mode: readBodyString(body, "approval_mode") ?? "request",
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
} }
}; };
} }
@@ -253,9 +290,10 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
clearCollapseTimer(); clearCollapseTimer();
clearMobileCollapseTimer(); clearMobileCollapseTimer();
stopSessionStreamSubscription(); stopSessionStreamSubscription();
frontendActionExecutor.cancelAll();
chatRef.current.stop(); chatRef.current.stop();
}; };
}, [clearCollapseTimer, clearMobileCollapseTimer, stopSessionStreamSubscription]); }, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, stopSessionStreamSubscription]);
useEffect(() => { useEffect(() => {
if (!backendConnection) { if (!backendConnection) {
@@ -263,6 +301,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
} }
registryRef.current = backendConnection.registry; registryRef.current = backendConnection.registry;
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
setRegistry(backendConnection.registry); setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models); setModelOptions(backendConnection.models);
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || ""); setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
@@ -396,6 +435,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
}); });
return; return;
} }
if (!claimEnvelope(payload, activeSessionId)) return;
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => { void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({ showMapNotice({
tone: "error", tone: "error",
@@ -403,6 +443,8 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。" message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
}); });
}); });
} else if (event.type === "frontend_action") {
void handleFrontendAction(event.data);
} else if (event.type === "progress") { } else if (event.type === "progress") {
const title = getDataString(event.data, "title"); const title = getDataString(event.data, "title");
if (title) { if (title) {
@@ -422,7 +464,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event)); chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
}, },
[onUiEnvelope, refreshSessionHistory] [claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
); );
const startSessionStreamSubscription = useCallback( const startSessionStreamSubscription = useCallback(
+22 -2
View File
@@ -11,6 +11,7 @@ import {
type AgentUiResult type AgentUiResult
} from "@/features/agent"; } from "@/features/agent";
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope"; import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { import {
MapErrorNotice, MapErrorNotice,
MapLoadingNotice, MapLoadingNotice,
@@ -39,6 +40,7 @@ import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map"; import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement"; import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
import { import {
@@ -184,7 +186,8 @@ export function MapWorkbenchPage() {
}); });
const agent = useWorkbenchAgent({ const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction
}); });
const activeAgentUiResults = useMemo( const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId), () => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
@@ -328,7 +331,8 @@ export function MapWorkbenchPage() {
try { try {
const result = await exportMapViewImage(map, { const result = await exportMapViewImage(map, {
scale: preset === "current" ? 1 : undefined, scale: preset === "current" ? 1 : undefined,
targetLongEdge targetLongEdge,
selectedFeature: toMapFeatureReference(detailFeature)
}); });
showMapNotice({ showMapNotice({
id: "map-view-export", id: "map-view-export",
@@ -519,6 +523,22 @@ export function MapWorkbenchPage() {
}); });
} }
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
const map = mapRef.current;
if (request.name === "zoom_to_map") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const x = Number(request.params.x); const y = Number(request.params.y);
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
}
if (request.name === "locate_features") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED");
const features = geojson.features as Array<{ id?: string | number; properties?: Record<string, unknown>; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND");
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
}
const history = request.name === "view_history"; const itemCount = history ? (request.params.feature_infos as unknown[]).length : Array.isArray(request.params.device_ids) ? request.params.device_ids.length : 1; setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: history ? "HistoryPanel" : "ScadaPanel", surface: "side_panel", props: request.params } }]); return { component: history ? "HistoryPanel" : "ScadaPanel", rendered: true, itemCount };
}
async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) { async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) {
const trustedAction = toTrustedMapAction(action, params, fallbackText); const trustedAction = toTrustedMapAction(action, params, fallbackText);
if (!trustedAction) { if (!trustedAction) {