/** * Typed HTTP client for the interactive service. * * One reason to keep this in a dedicated module: * - All routes live under `/v1/interactive/*` * - All error shapes are the uniform `{code, error, data}` dict * produced by `routes/_common.http_error_from` * - Downstream components can import `createInteractiveApi` and * never touch `fetch`, so swapping auth / retry / tracing * becomes a one-file edit. * * Design notes: * - The client accepts `backendUrl` + `apiKey` up-front so hook * callers don't re-build it on every render. * - Every method returns a `Promise` with a typed shape. * - Failures are normalized to `InteractiveApiError` with the * backend `code` preserved so callers can branch on it * (e.g. `err.code === "invalid_input"`). * - `AbortSignal` threaded through so React effects can cancel * in-flight requests on unmount. */ import { ActionItem, AnalyticsSummary, AutoGenerateResult, AutoGenerateStreamEvent, CatalogItemView, ChatResult, EdgeItem, Experience, ExperienceMode, HealthInfo, InteractiveApiError, NodeItem, PendingResult, PlanAutoResult, PlanIntent, PlanningPreset, PersonaLiveGenerateResult, ProgressSnapshot, PublishResult, Publication, QAResult, ResolveResult, RuleItem, } from "./types"; export interface InteractiveApi { // Meta health(signal?: AbortSignal): Promise; listPresets(signal?: AbortSignal): Promise; // Experiences listExperiences(signal?: AbortSignal): Promise; getExperience(id: string, signal?: AbortSignal): Promise; createExperience(input: Partial): Promise; patchExperience(id: string, patch: Partial): Promise; deleteExperience(id: string): Promise; // Planner plan(req: { prompt: string; mode: ExperienceMode; audience_hints?: Record }): Promise; planAuto(req: { idea: string }): Promise; autoGenerate(id: string): Promise; /** * SSE variant of ``autoGenerate`` (PIPE-2). ``onEvent`` fires * once per phase event the backend emits (started, * generating_graph, persisting_nodes, ..., running_qa) so the * wizard spinner can surface real progress. Resolves with the * final ``result`` payload, matching the POST /auto-generate * body. Aborts via ``signal``. */ autoGenerateStream( id: string, opts: { onEvent?: (ev: AutoGenerateStreamEvent) => void; signal?: AbortSignal; }, ): Promise; /** * Full-project generation stream (BATCH B). Chains plan + * graph + per-scene asset rendering behind a single SSE feed * so the wizard modal can stay up through the entire * plan → render → ready lifecycle. Event kinds include * everything ``autoGenerateStream`` emits plus: * rendering_started / rendering_scene / scene_rendered * | scene_skipped | scene_render_failed / rendering_done. */ generateAllStream( id: string, opts: { onEvent?: (ev: AutoGenerateStreamEvent) => void; signal?: AbortSignal; }, ): Promise; /** * Re-render a single scene node (EDIT-3). Used by the Editor's * "Regenerate scene" button. Replaces the node's asset_ids * with the freshly-produced asset so the player always picks * the latest render. */ renderSingleScene( experienceId: string, nodeId: string, ): Promise<{ ok: boolean; scene_id: string; title: string; status: "rendered" | "skipped" | "failed" | "rendered_but_not_attached"; asset_id?: string; reason?: string; media_type?: "image" | "video"; }>; /** PATCH a node's editable fields (EDIT-2). */ patchNode( nodeId: string, patch: { title?: string; narration?: string; image_prompt?: string }, ): Promise; /** Resolve an asset_id to its player URL, or null if unknown. */ resolveAssetUrl( assetId: string, signal?: AbortSignal, ): Promise; seedGraph( id: string, req: { prompt: string; mode: ExperienceMode; audience_hints?: Record }, ): Promise<{ already_seeded: boolean; node_count: number; edge_count: number }>; // Graph listNodes(id: string, signal?: AbortSignal): Promise; listEdges(id: string, signal?: AbortSignal): Promise; // Catalog listActions(id: string, signal?: AbortSignal): Promise; createAction(id: string, payload: Partial & { label: string }): Promise; deleteAction(actionId: string): Promise; // Rules listRules(id: string, signal?: AbortSignal): Promise; createRule( id: string, payload: { name: string; condition: Record; action: Record; priority?: number; enabled?: boolean }, ): Promise; deleteRule(ruleId: string): Promise; // QA + Publish runQa(id: string): Promise; latestReport(id: string, signal?: AbortSignal): Promise<{ report: Record }>; publish(id: string, channel: string): Promise; listPublications(id: string, signal?: AbortSignal): Promise; // Analytics experienceAnalytics(id: string, signal?: AbortSignal): Promise; // Live-play (PLAY-*) startSession(req: { experience_id: string; viewer_ref?: string; language?: string; personalization?: Record; }): Promise<{ id: string; experience_id: string; current_node_id: string; initial_scene?: { node_id: string; asset_id: string; asset_url: string; media_kind: "image" | "video" | "unknown" | string; duration_sec: number; title: string; status?: "pending" | "rendering" | "ready" | "failed" | string; } | null; /** Set only for Persona Live Play when the backend generated * the in-character greeting — callers render it as the first * assistant bubble before the viewer types. */ opening_turn?: { reply_text: string; scene_prompt: string; character_turn_id: string }; /** Persona Live Play: the persona's canonical portrait URL. * The player uses it as the idle VideoStage background (so * the stage never shows a black backdrop) and as the avatar * fallback when the wizard didn't stamp ``persona_avatar_url``. */ persona_portrait_url?: string; /** "image" → the stage stays a still photo between turns. * "video" → the stage loops an img2vid clip derived from the * portrait. Defaults to whatever the wizard stamped. */ render_media_type?: "image" | "video"; }>; chat(sessionId: string, req: { text: string; viewer_region?: string }): Promise; pending( sessionId: string, opts?: { since_id?: string; limit?: number }, signal?: AbortSignal, ): Promise; getCatalog(sessionId: string, signal?: AbortSignal): Promise; getProgress(sessionId: string, signal?: AbortSignal): Promise; resolveTurn( sessionId: string, req: { action_id?: string; free_text?: string; viewer_region?: string }, ): Promise; // Persona Live personaLiveGenerate(req: { persona_id: string; vibe: string; mode: "image" | "video"; }): Promise; personaLiveStart(req: { persona_id: string; mode?: "image" | "video" }): Promise<{ id: string; persona_id: string; current_level: number; xp: number }>; personaLiveSession(sessionId: string): Promise>; personaLiveAction(sessionId: string, req: { action_id: string; message?: string }): Promise<{ job_id: string; status?: string; dialogue: { text: string } | string; scene_context?: Record; scene_memory?: Record; emotional_state?: Record; image_url?: string; xp?: number; level?: number; /** Remaining XP needed to reach next level. */ xp_to_next?: number; /** XP awarded by this specific action — used for celebrations. */ xp_delta?: number; /** Newly-unlocked actions when this action triggered a level-up. */ new_unlocks?: Array<{ id: string; label: string }>; versions?: Array>; render_skipped?: boolean; /** Library-hit fast path: backend returns the cached photo * inline so the frontend can skip the job-poll loop. */ media?: { type?: "image" | "video" | string; url?: string; status?: "ready" | "rendering" | string; }; source?: string; }>; personaLiveJob(jobId: string): Promise>; personaLiveRestore(sessionId: string, versionId: string): Promise<{ ok: boolean; session: Record; version: Record }>; personaLiveChat(sessionId: string, message: string): Promise<{ dialogue: { text: string }; scene_context?: Record; scene_memory?: Record; emotional_state?: Record; optional_action_suggestion?: { id: string; label: string } | null; /** Chat earns a smaller XP delta than tap-actions. */ xp?: number; level?: number; xp_to_next?: number; xp_delta?: number; }>; } /** * Shared SSE reader for /auto-generate/stream and * /generate-all/stream. Parses ``data: {...}\n\n`` frames, * forwards each to ``onEvent``, and resolves with the ``result`` * frame payload. Rejects with InteractiveApiError on HTTP * failures or an explicit ``error`` frame. Native EventSource * can't honour credentials:'include' across origins — hence the * hand-rolled fetch+ReadableStream reader. */ async function _consumeGenerationStream( url: string, authHeaders: Record, opts: { onEvent?: (ev: AutoGenerateStreamEvent) => void; signal?: AbortSignal; }, ): Promise { const resp = await fetch(url, { method: "GET", credentials: "include", headers: { Accept: "text/event-stream", ...authHeaders }, signal: opts.signal, }); if (!resp.ok || !resp.body) { const detail = await resp.text().catch(() => ""); throw new InteractiveApiError( detail || `generation stream failed (${resp.status})`, resp.status, ); } const reader = resp.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let finalResult: AutoGenerateResult | null = null; let errorMessage: string | null = null; // eslint-disable-next-line no-constant-condition while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx: number; while ((idx = buffer.indexOf("\n\n")) >= 0) { const raw = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 2); if (!raw.startsWith("data:")) continue; const body = raw.slice("data:".length).trim(); if (!body) continue; let frame: AutoGenerateStreamEvent; try { frame = JSON.parse(body) as AutoGenerateStreamEvent; } catch { continue; } if (opts.onEvent) { try { opts.onEvent(frame); } catch { /* hook must not kill stream */ } } if (frame.type === "result") { finalResult = (frame.payload as unknown as AutoGenerateResult) || null; } if (frame.type === "error") { const payload = (frame.payload || {}) as Record; errorMessage = typeof payload.reason === "string" ? payload.reason : "generation failed"; } if (frame.type === "done") break; } } if (errorMessage) throw new InteractiveApiError(errorMessage, 0); if (!finalResult) throw new InteractiveApiError("stream ended without a result frame", 0); return finalResult; } export function createInteractiveApi( backendUrl: string, apiKey?: string, ): InteractiveApi { const base = backendUrl.replace(/\/+$/, "") + "/v1/interactive"; const authHeaders: Record = apiKey ? { "x-api-key": apiKey.trim() } : {}; async function call( path: string, init: RequestInit = {}, signal?: AbortSignal, ): Promise { const headers: Record = { "Content-Type": "application/json", ...authHeaders, ...(init.headers as Record | undefined), }; let res: Response; try { // credentials: 'include' forwards the homepilot_session cookie // on cross-origin dev + same-origin packaged builds, so the // backend's viewer resolver can authenticate the request. Without // this, every /v1/interactive/* call 401s in dev and produces a // confusing error on the landing page. res = await fetch(`${base}${path}`, { ...init, headers, signal, credentials: "include", }); } catch (err) { if ((err as Error).name === "AbortError") throw err; throw new InteractiveApiError( `Network error contacting ${path}: ${(err as Error).message}`, 0, "network_error", ); } const contentType = res.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); const body = isJson ? await res.json().catch(() => ({})) : await res.text(); if (!res.ok) { const detail = (isJson && (body as { detail?: unknown }).detail) || body; if (detail && typeof detail === "object") { const d = detail as { code?: string; error?: string; data?: Record }; throw new InteractiveApiError( d.error || `HTTP ${res.status}`, res.status, d.code || "http_error", d.data || {}, ); } throw new InteractiveApiError( typeof detail === "string" ? detail : `HTTP ${res.status}`, res.status, "http_error", ); } return body as T; } return { health: (signal) => call("/health", { method: "GET" }, signal), listPresets: (signal) => call<{ items: PlanningPreset[] }>("/presets", { method: "GET" }, signal) .then((r) => r.items), listExperiences: (signal) => call<{ items: Experience[] }>("/experiences", { method: "GET" }, signal) .then((r) => r.items), getExperience: (id, signal) => call<{ experience: Experience }>(`/experiences/${encodeURIComponent(id)}`, { method: "GET" }, signal) .then((r) => r.experience), createExperience: (input) => call<{ experience: Experience }>("/experiences", { method: "POST", body: JSON.stringify(input), }).then((r) => r.experience), patchExperience: (id, patch) => call<{ experience: Experience }>(`/experiences/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch), }).then((r) => r.experience), deleteExperience: (id) => call<{ ok: boolean }>(`/experiences/${encodeURIComponent(id)}`, { method: "DELETE" }) .then(() => undefined), plan: (req) => call<{ intent: PlanIntent }>("/plan", { method: "POST", body: JSON.stringify(req), }).then((r) => r.intent), planAuto: (req) => call("/plan-auto", { method: "POST", body: JSON.stringify(req), }), autoGenerate: (id) => call( `/experiences/${encodeURIComponent(id)}/auto-generate`, { method: "POST", body: JSON.stringify({}) }, ), autoGenerateStream: (id, opts) => _consumeGenerationStream( `${base}/experiences/${encodeURIComponent(id)}/auto-generate/stream`, authHeaders, opts, ), generateAllStream: (id, opts) => _consumeGenerationStream( `${base}/experiences/${encodeURIComponent(id)}/generate-all/stream`, authHeaders, opts, ), renderSingleScene: (experienceId, nodeId) => call( `/experiences/${encodeURIComponent(experienceId)}/nodes/${encodeURIComponent(nodeId)}/render`, { method: "POST", body: JSON.stringify({}) }, ), patchNode: (nodeId, patch) => call<{ ok: boolean; node: NodeItem }>( `/nodes/${encodeURIComponent(nodeId)}`, { method: "PATCH", body: JSON.stringify(patch) }, ).then((r) => r.node), resolveAssetUrl: (assetId, signal) => call<{ ok: boolean; url: string | null }>( `/assets/${encodeURIComponent(assetId)}/url`, { method: "GET" }, signal, ).then((r) => r.url), seedGraph: (id, req) => call<{ already_seeded: boolean; node_count: number; edge_count: number }>( `/experiences/${encodeURIComponent(id)}/seed-graph`, { method: "POST", body: JSON.stringify(req) }, ), listNodes: (id, signal) => call<{ items: NodeItem[] }>( `/experiences/${encodeURIComponent(id)}/nodes`, { method: "GET" }, signal, ).then((r) => r.items), listEdges: (id, signal) => call<{ items: EdgeItem[] }>( `/experiences/${encodeURIComponent(id)}/edges`, { method: "GET" }, signal, ).then((r) => r.items), listActions: (id, signal) => call<{ items: ActionItem[] }>( `/experiences/${encodeURIComponent(id)}/actions`, { method: "GET" }, signal, ).then((r) => r.items), createAction: (id, payload) => call<{ action: ActionItem }>( `/experiences/${encodeURIComponent(id)}/actions`, { method: "POST", body: JSON.stringify(payload) }, ).then((r) => r.action), deleteAction: (actionId) => call<{ ok: boolean }>(`/actions/${encodeURIComponent(actionId)}`, { method: "DELETE" }) .then(() => undefined), listRules: (id, signal) => call<{ items: RuleItem[] }>( `/experiences/${encodeURIComponent(id)}/rules`, { method: "GET" }, signal, ).then((r) => r.items), createRule: (id, payload) => call<{ rule: RuleItem }>( `/experiences/${encodeURIComponent(id)}/rules`, { method: "POST", body: JSON.stringify(payload) }, ).then((r) => r.rule), deleteRule: (ruleId) => call<{ ok: boolean }>(`/rules/${encodeURIComponent(ruleId)}`, { method: "DELETE" }) .then(() => undefined), runQa: (id) => call( `/experiences/${encodeURIComponent(id)}/qa-run`, { method: "POST" }, ), latestReport: (id, signal) => call<{ report: Record }>( `/experiences/${encodeURIComponent(id)}/qa-reports`, { method: "GET" }, signal, ), publish: (id, channel) => call( `/experiences/${encodeURIComponent(id)}/publish`, { method: "POST", body: JSON.stringify({ channel }) }, ), listPublications: (id, signal) => call<{ items: Publication[] }>( `/experiences/${encodeURIComponent(id)}/publications`, { method: "GET" }, signal, ).then((r) => r.items), experienceAnalytics: (id, signal) => call( `/experiences/${encodeURIComponent(id)}/analytics`, { method: "GET" }, signal, ), startSession: (req) => call<{ session: { id: string; experience_id: string; current_node_id: string }; initial_scene?: { node_id: string; asset_id: string; asset_url: string; media_kind: "image" | "video" | "unknown" | string; duration_sec: number; title: string; status?: "pending" | "rendering" | "ready" | "failed" | string; } | null; opening_turn?: { reply_text: string; scene_prompt: string; character_turn_id: string }; persona_portrait_url?: string; render_media_type?: "image" | "video"; }>( "/play/sessions", { method: "POST", body: JSON.stringify(req) }, ).then((r) => ({ ...r.session, initial_scene: r.initial_scene || null, opening_turn: r.opening_turn, persona_portrait_url: r.persona_portrait_url, render_media_type: r.render_media_type, })), chat: (sessionId, req) => call( `/play/sessions/${encodeURIComponent(sessionId)}/chat`, { method: "POST", body: JSON.stringify(req) }, ), pending: (sessionId, opts, signal) => { const params = new URLSearchParams(); if (opts?.since_id) params.set("since_id", opts.since_id); if (opts?.limit !== undefined) params.set("limit", String(opts.limit)); const suffix = params.toString() ? `?${params.toString()}` : ""; return call( `/play/sessions/${encodeURIComponent(sessionId)}/pending${suffix}`, { method: "GET" }, signal, ); }, getCatalog: (sessionId, signal) => call<{ items: CatalogItemView[] }>( `/play/sessions/${encodeURIComponent(sessionId)}/catalog`, { method: "GET" }, signal, ).then((r) => r.items), getProgress: (sessionId, signal) => call( `/play/sessions/${encodeURIComponent(sessionId)}/progress`, { method: "GET" }, signal, ), resolveTurn: (sessionId, req) => call<{ resolved: ResolveResult }>( `/play/sessions/${encodeURIComponent(sessionId)}/resolve`, { method: "POST", body: JSON.stringify(req) }, ).then((r) => r.resolved), personaLiveGenerate: (req) => call( "/persona-live/generate", { method: "POST", body: JSON.stringify(req) }, ), personaLiveStart: (req) => call<{ ok: boolean; session: { id: string; persona_id: string; current_level: number; xp: number } }>( "/persona-live/start", { method: "POST", body: JSON.stringify(req) }, ).then((r) => r.session), personaLiveSession: (sessionId) => call>( `/persona-live/session/${encodeURIComponent(sessionId)}`, { method: "GET" }, ), personaLiveAction: (sessionId, req) => call<{ ok: boolean; job_id: string; status?: string; dialogue: { text: string } | string; scene_context?: Record; scene_memory?: Record; emotional_state?: Record; image_url?: string; xp?: number; level?: number; versions?: Array>; render_skipped?: boolean; media?: { type?: "image" | "video" | string; url?: string; status?: "ready" | "rendering" | string; }; source?: string; }>( `/persona-live/session/${encodeURIComponent(sessionId)}/action`, { method: "POST", body: JSON.stringify(req) }, ), personaLiveJob: (jobId) => call>( `/persona-live/jobs/${encodeURIComponent(jobId)}`, { method: "GET" }, ), personaLiveRestore: (sessionId, versionId) => call<{ ok: boolean; session: Record; version: Record }>( `/persona-live/session/${encodeURIComponent(sessionId)}/restore`, { method: "POST", body: JSON.stringify({ version_id: versionId }) }, ), personaLiveChat: (sessionId, message) => call<{ ok: boolean; dialogue: { text: string }; scene_context?: Record; scene_memory?: Record; emotional_state?: Record; optional_action_suggestion?: { id: string; label: string } | null; xp?: number; level?: number; xp_to_next?: number; xp_delta?: number; }>( `/persona-live/session/${encodeURIComponent(sessionId)}/chat`, { method: "POST", body: JSON.stringify({ message }) }, ).then((r) => ({ dialogue: r.dialogue, scene_context: r.scene_context, scene_memory: r.scene_memory, emotional_state: r.emotional_state, optional_action_suggestion: r.optional_action_suggestion, xp: r.xp, level: r.level, xp_to_next: r.xp_to_next, xp_delta: r.xp_delta, })), }; }