diff --git a/app/api/agent-locate/route.ts b/app/api/agent-locate/route.ts new file mode 100644 index 0000000..e09a87f --- /dev/null +++ b/app/api/agent-locate/route.ts @@ -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 }); } +} diff --git a/features/agent/api/client.ts b/features/agent/api/client.ts index 8637c06..2c2f39e 100644 --- a/features/agent/api/client.ts +++ b/features/agent/api/client.ts @@ -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; getModels: () => Promise; getUiRegistry: () => Promise; + getFrontendActionRegistry: () => Promise; + submitFrontendActionResult: (sessionId: string, actionId: string, result: unknown) => Promise; resolveRenderRef: (renderRef: string, sessionId: string) => Promise; 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(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry"); + }, + + async submitFrontendActionResult(sessionId, actionId, result) { + await requestJsonWithFallback(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" || diff --git a/features/agent/api/swr.ts b/features/agent/api/swr.ts index cf81bd1..27b38f1 100644 --- a/features/agent/api/swr.ts +++ b/features/agent/api/swr.ts @@ -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 { - 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 }; diff --git a/features/agent/frontend-action/executor.ts b/features/agent/frontend-action/executor.ts new file mode 100644 index 0000000..27aa04c --- /dev/null +++ b/features/agent/frontend-action/executor.ts @@ -0,0 +1,55 @@ +import { + parseFrontendActionRequest, + readActionResults, + storeActionResult, + type FrontendActionRequest, + type FrontendActionResult +} from "."; + +type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise; +type SubmitResult = (sessionId: string, actionId: string, result: FrontendActionResult) => Promise; +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/; + +export class FrontendActionExecutor { + private readonly controllers = new Map(); + + constructor(private readonly execute: ExecuteAction, private readonly submit: SubmitResult) {} + + async handle(value: unknown, activeSessionId: string | null): Promise { + 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 { + 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() }; + } +} diff --git a/features/agent/frontend-action/index.ts b/features/agent/frontend-action/index.ts new file mode 100644 index 0000000..f25004d --- /dev/null +++ b/features/agent/frontend-action/index.ts @@ -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; 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 => 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 { try { const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}"); return isRecord(value) ? value as Record : {}; } 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)); } diff --git a/features/workbench/hooks/agent-session-message-state.ts b/features/workbench/hooks/agent-session-message-state.ts index af968ed..ef6b975 100644 --- a/features/workbench/hooks/agent-session-message-state.ts +++ b/features/workbench/hooks/agent-session-message-state.ts @@ -26,6 +26,8 @@ type AgentUiDataParts = { question_response: Record; stream_token: Record; tool_call: Record; + frontend_action: Record; + frontend_action_result: Record; ui_envelope: Record; session_title: Record; }; diff --git a/features/workbench/hooks/use-workbench-agent.ts b/features/workbench/hooks/use-workbench-agent.ts index 5c4b597..b795d50 100644 --- a/features/workbench/hooks/use-workbench-agent.ts +++ b/features/workbench/hooks/use-workbench-agent.ts @@ -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; + onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise; }; -export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) { +export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) { const collapseTimerRef = useRef(null); const mobileCollapseTimerRef = useRef(null); const sessionStreamAbortRef = useRef(null); @@ -64,6 +67,9 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) { const clientRef = useRef(createAgentApiClient()); const sessionIdRef = useRef(null); const registryRef = useRef(null); + const frontendActionEnabledRef = useRef(false); + const onFrontendActionRef = useRef(onFrontendAction); + const processedEnvelopeIdsRef = useRef(new Set()); 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( diff --git a/features/workbench/map-workbench-page.tsx b/features/workbench/map-workbench-page.tsx index 0a708a2..86e9371 100644 --- a/features/workbench/map-workbench-page.tsx +++ b/features/workbench/map-workbench-page.tsx @@ -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; 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) {