blind-quill / web /api.jsx
JacobLinCool's picture
feat: stream stitch progress
5a07020
Raw
History Blame Contribute Delete
3.89 kB
// ============================================================
// BLIND QUILL — backend bridge
// Talks to the gradio.Server endpoints (app.py) through the
// Gradio JS Client. Runs the invisible editor (Qwen) behind the queue.
// Exposes BQ + KINDS to window for the Babel views.
// ============================================================
// Fragment kinds offered in the composer (player-facing labels).
const KINDS = ["a secret", "an object", "a scene", "a place", "a trait", "a strange rule", "a line of dialogue"];
// The Gradio client is imported as an ES module in index.html and parked on
// window.__bqClientReady (a promise). Wait for it, then connect once.
let _clientPromise = null;
async function bqClient() {
for (let i = 0; i < 200 && !window.__bqClientReady; i++) {
await new Promise((r) => setTimeout(r, 50));
}
if (!window.__bqClientReady) {
throw new Error("The bindery client could not load. Check your connection and reload.");
}
if (!_clientPromise) _clientPromise = window.__bqClientReady;
return _clientPromise;
}
// Call a named @app.api endpoint and return its single JSON payload.
async function bqCall(name, payload) {
const client = await bqClient();
let result;
try {
result = await client.predict("/" + name, payload || {});
} catch (err) {
throw new Error(bqErrorMessage(err));
}
const data = result && result.data;
return Array.isArray(data) ? data[0] : data;
}
function bqErrorMessage(err) {
if (!err) return "The bindery did not answer. Try again.";
if (typeof err === "string") return err;
if (err.message) return err.message;
return "The bindery did not answer. Try again.";
}
// Call a streaming generator endpoint. The editor yields { type: "progress" }
// events while it works and a final { type: "result" } event. Progress events
// are forwarded to onProgress; the result is returned. Some runs (ZeroGPU) emit
// only a coarse start event, so the UI must tolerate sparse progress.
async function bqStream(name, payload, onProgress) {
const client = await bqClient();
let stream;
try {
stream = client.submit("/" + name, payload || {});
} catch (err) {
throw new Error(bqErrorMessage(err));
}
let result = null;
try {
for await (const msg of stream) {
if (msg.type === "data") {
const item = msg.data && msg.data[0];
if (!item) continue;
if (item.type === "progress") {
if (onProgress) { try { onProgress(item); } catch (_e) { /* progress is best-effort */ } }
} else if (item.type === "result") {
result = item;
}
} else if (msg.type === "status" && msg.stage === "error") {
throw new Error(bqErrorMessage(msg.message || msg));
}
}
} catch (err) {
throw new Error(bqErrorMessage(err));
}
if (!result) throw new Error("The bindery did not finish the stitch. Try again.");
return result;
}
const BQ = {
// Gallery: capsule-level cards only (no hidden chapters).
listStories: () => bqCall("list_stories", {}).then((r) => (r && r.stories) || []),
// One story's full public capsule (still blinded — no chapters).
getCapsule: (story_id) => bqCall("get_capsule", { story_id }).then((r) => r && r.story),
// Create from a seed; the creator receives the full manuscript.
createStory: (seed) => bqCall("create_story", { seed }).then((r) => r && r.story),
// Stitch a fragment (streaming): forwards { type:"progress", ... } events to
// onProgress and resolves with { story (full, updated), reveal: {...} }.
stitch: (story_id, fragment, onProgress) => bqStream("stitch", { story_id, fragment }, onProgress),
// Read a manuscript end to end. The UI warns first when the reader has not contributed.
readManuscript: (story_id) => bqCall("read_manuscript", { story_id }).then((r) => r && r.story),
};
Object.assign(window, { BQ, KINDS });