feat(drainage): execute frontend actions
This commit is contained in:
@@ -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 }); }
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export type AgentSessionStreamDataEventType =
|
||||
| "question_request"
|
||||
| "question_response"
|
||||
| "tool_call"
|
||||
| "frontend_action"
|
||||
| "frontend_action_result"
|
||||
| "ui_envelope"
|
||||
| "session_title"
|
||||
| "done"
|
||||
@@ -93,6 +95,8 @@ export type AgentApiClient = {
|
||||
deleteSession: (sessionId: string) => Promise<void>;
|
||||
getModels: () => Promise<AgentModelsResponse>;
|
||||
getUiRegistry: () => Promise<unknown>;
|
||||
getFrontendActionRegistry: () => Promise<unknown>;
|
||||
submitFrontendActionResult: (sessionId: string, actionId: string, result: unknown) => Promise<void>;
|
||||
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
|
||||
replyPermission: (
|
||||
requestId: string,
|
||||
@@ -139,6 +143,18 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
||||
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) {
|
||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
||||
activeBaseUrl = nextBaseUrl;
|
||||
@@ -540,6 +556,8 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
|
||||
value === "question_request" ||
|
||||
value === "question_response" ||
|
||||
value === "tool_call" ||
|
||||
value === "frontend_action" ||
|
||||
value === "frontend_action_result" ||
|
||||
value === "ui_envelope" ||
|
||||
value === "session_title" ||
|
||||
value === "done" ||
|
||||
|
||||
@@ -4,24 +4,28 @@ import type {
|
||||
AgentModelOption
|
||||
} from "./client";
|
||||
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
|
||||
import { parseFrontendActionRegistry, type FrontendActionRegistry } from "../frontend-action";
|
||||
|
||||
export const agentBootstrapKey = ["agent", "bootstrap"] as const;
|
||||
export const agentSessionsKey = ["agent", "sessions"] as const;
|
||||
|
||||
export type AgentBootstrapData = {
|
||||
registry: UIRegistry | null;
|
||||
frontendActionRegistry?: FrontendActionRegistry | null;
|
||||
models: AgentModelOption[];
|
||||
defaultModel: string;
|
||||
};
|
||||
|
||||
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
|
||||
const [rawRegistry, modelsResponse] = await Promise.all([
|
||||
const [rawRegistry, rawFrontendActionRegistry, modelsResponse] = await Promise.all([
|
||||
client.getUiRegistry(),
|
||||
typeof client.getFrontendActionRegistry === "function" ? client.getFrontendActionRegistry() : Promise.resolve(undefined),
|
||||
client.getModels()
|
||||
]);
|
||||
|
||||
return {
|
||||
registry: parseUiRegistry(rawRegistry),
|
||||
...(rawFrontendActionRegistry === undefined ? {} : { frontendActionRegistry: parseFrontendActionRegistry(rawFrontendActionRegistry) }),
|
||||
models: modelsResponse.models,
|
||||
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() };
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
stream_token: Record<string, unknown>;
|
||||
tool_call: Record<string, unknown>;
|
||||
frontend_action: Record<string, unknown>;
|
||||
frontend_action_result: Record<string, unknown>;
|
||||
ui_envelope: Record<string, unknown>;
|
||||
session_title: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type UIEnvelopePayload,
|
||||
type UIRegistry
|
||||
} from "@/features/agent/ui-envelope";
|
||||
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
||||
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
|
||||
import {
|
||||
appendStreamRenderToken,
|
||||
applySessionStreamEvent,
|
||||
@@ -53,9 +55,10 @@ type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "fa
|
||||
|
||||
type UseWorkbenchAgentOptions = {
|
||||
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 mobileCollapseTimerRef = useRef<number | null>(null);
|
||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||
@@ -64,6 +67,9 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
const clientRef = useRef(createAgentApiClient());
|
||||
const sessionIdRef = useRef<string | 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 [panelCollapsing, setPanelCollapsing] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -88,6 +94,10 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
registryRef.current = registry;
|
||||
}, [registry]);
|
||||
|
||||
useEffect(() => {
|
||||
onFrontendActionRef.current = onFrontendAction;
|
||||
}, [onFrontendAction]);
|
||||
|
||||
const applySessionId = useCallback((nextSessionId: string | undefined) => {
|
||||
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
|
||||
return;
|
||||
@@ -105,6 +115,30 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
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(
|
||||
(part: AgentDataPart) => {
|
||||
const eventSessionId = getDataString(part.data, "session_id");
|
||||
@@ -130,6 +164,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
|
||||
|
||||
if (part.type === "data-session_title") {
|
||||
const title = getDataString(part.data, "title");
|
||||
@@ -153,6 +188,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!claimEnvelope(payload, activeSessionId)) return;
|
||||
|
||||
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||
showMapNotice({
|
||||
@@ -162,7 +198,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
});
|
||||
});
|
||||
},
|
||||
[applySessionId, onUiEnvelope]
|
||||
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
|
||||
);
|
||||
|
||||
const transport = useMemo(
|
||||
@@ -178,7 +214,8 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
trigger,
|
||||
messageId,
|
||||
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();
|
||||
clearMobileCollapseTimer();
|
||||
stopSessionStreamSubscription();
|
||||
frontendActionExecutor.cancelAll();
|
||||
chatRef.current.stop();
|
||||
};
|
||||
}, [clearCollapseTimer, clearMobileCollapseTimer, stopSessionStreamSubscription]);
|
||||
}, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, stopSessionStreamSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendConnection) {
|
||||
@@ -263,6 +301,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
}
|
||||
|
||||
registryRef.current = backendConnection.registry;
|
||||
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
|
||||
setRegistry(backendConnection.registry);
|
||||
setModelOptions(backendConnection.models);
|
||||
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
||||
@@ -396,6 +435,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!claimEnvelope(payload, activeSessionId)) return;
|
||||
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
||||
showMapNotice({
|
||||
tone: "error",
|
||||
@@ -403,6 +443,8 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
||||
});
|
||||
});
|
||||
} else if (event.type === "frontend_action") {
|
||||
void handleFrontendAction(event.data);
|
||||
} else if (event.type === "progress") {
|
||||
const title = getDataString(event.data, "title");
|
||||
if (title) {
|
||||
@@ -422,7 +464,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
|
||||
|
||||
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
|
||||
},
|
||||
[onUiEnvelope, refreshSessionHistory]
|
||||
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
|
||||
);
|
||||
|
||||
const startSessionStreamSubscription = useCallback(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AgentUiResult
|
||||
} from "@/features/agent";
|
||||
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
|
||||
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
||||
import {
|
||||
MapErrorNotice,
|
||||
MapLoadingNotice,
|
||||
@@ -39,6 +40,7 @@ import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||||
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 { exportMapViewImage } from "./map/export-view";
|
||||
import {
|
||||
@@ -184,7 +186,8 @@ export function MapWorkbenchPage() {
|
||||
});
|
||||
|
||||
const agent = useWorkbenchAgent({
|
||||
onUiEnvelope: handleAgentUiEnvelope
|
||||
onUiEnvelope: handleAgentUiEnvelope,
|
||||
onFrontendAction: handleFrontendAction
|
||||
});
|
||||
const activeAgentUiResults = useMemo(
|
||||
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
|
||||
@@ -328,7 +331,8 @@ export function MapWorkbenchPage() {
|
||||
try {
|
||||
const result = await exportMapViewImage(map, {
|
||||
scale: preset === "current" ? 1 : undefined,
|
||||
targetLongEdge
|
||||
targetLongEdge,
|
||||
selectedFeature: toMapFeatureReference(detailFeature)
|
||||
});
|
||||
showMapNotice({
|
||||
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) {
|
||||
const trustedAction = toTrustedMapAction(action, params, fallbackText);
|
||||
if (!trustedAction) {
|
||||
|
||||
Reference in New Issue
Block a user