File size: 12,321 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | /**
* 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<T>` 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 { InteractiveApiError, } from "./types";
/**
* 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, authHeaders, opts) {
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 = null;
let errorMessage = 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;
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;
try {
frame = JSON.parse(body);
}
catch {
continue;
}
if (opts.onEvent) {
try {
opts.onEvent(frame);
}
catch { /* hook must not kill stream */ }
}
if (frame.type === "result") {
finalResult = frame.payload || null;
}
if (frame.type === "error") {
const payload = (frame.payload || {});
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, apiKey) {
const base = backendUrl.replace(/\/+$/, "") + "/v1/interactive";
const authHeaders = apiKey
? { "x-api-key": apiKey.trim() }
: {};
async function call(path, init = {}, signal) {
const headers = {
"Content-Type": "application/json",
...authHeaders,
...init.headers,
};
let res;
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.name === "AbortError")
throw err;
throw new InteractiveApiError(`Network error contacting ${path}: ${err.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.detail) || body;
if (detail && typeof detail === "object") {
const d = detail;
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;
}
return {
health: (signal) => call("/health", { method: "GET" }, signal),
listPresets: (signal) => call("/presets", { method: "GET" }, signal)
.then((r) => r.items),
listExperiences: (signal) => call("/experiences", { method: "GET" }, signal)
.then((r) => r.items),
getExperience: (id, signal) => call(`/experiences/${encodeURIComponent(id)}`, { method: "GET" }, signal)
.then((r) => r.experience),
createExperience: (input) => call("/experiences", {
method: "POST",
body: JSON.stringify(input),
}).then((r) => r.experience),
patchExperience: (id, patch) => call(`/experiences/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(patch),
}).then((r) => r.experience),
deleteExperience: (id) => call(`/experiences/${encodeURIComponent(id)}`, { method: "DELETE" })
.then(() => undefined),
plan: (req) => call("/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(`/nodes/${encodeURIComponent(nodeId)}`, { method: "PATCH", body: JSON.stringify(patch) }).then((r) => r.node),
resolveAssetUrl: (assetId, signal) => call(`/assets/${encodeURIComponent(assetId)}/url`, { method: "GET" }, signal).then((r) => r.url),
seedGraph: (id, req) => call(`/experiences/${encodeURIComponent(id)}/seed-graph`, { method: "POST", body: JSON.stringify(req) }),
listNodes: (id, signal) => call(`/experiences/${encodeURIComponent(id)}/nodes`, { method: "GET" }, signal).then((r) => r.items),
listEdges: (id, signal) => call(`/experiences/${encodeURIComponent(id)}/edges`, { method: "GET" }, signal).then((r) => r.items),
listActions: (id, signal) => call(`/experiences/${encodeURIComponent(id)}/actions`, { method: "GET" }, signal).then((r) => r.items),
createAction: (id, payload) => call(`/experiences/${encodeURIComponent(id)}/actions`, { method: "POST", body: JSON.stringify(payload) }).then((r) => r.action),
deleteAction: (actionId) => call(`/actions/${encodeURIComponent(actionId)}`, { method: "DELETE" })
.then(() => undefined),
listRules: (id, signal) => call(`/experiences/${encodeURIComponent(id)}/rules`, { method: "GET" }, signal).then((r) => r.items),
createRule: (id, payload) => call(`/experiences/${encodeURIComponent(id)}/rules`, { method: "POST", body: JSON.stringify(payload) }).then((r) => r.rule),
deleteRule: (ruleId) => call(`/rules/${encodeURIComponent(ruleId)}`, { method: "DELETE" })
.then(() => undefined),
runQa: (id) => call(`/experiences/${encodeURIComponent(id)}/qa-run`, { method: "POST" }),
latestReport: (id, signal) => call(`/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(`/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("/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(`/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(`/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("/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(`/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(`/persona-live/session/${encodeURIComponent(sessionId)}/restore`, { method: "POST", body: JSON.stringify({ version_id: versionId }) }),
personaLiveChat: (sessionId, message) => call(`/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,
})),
};
}
|