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_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" ||
|
||||||
|
|||||||
@@ -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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user