feat(drainage): execute frontend actions
This commit is contained in:
@@ -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)); }
|
||||
Reference in New Issue
Block a user