Switch frontend to /gradio_api/call/v2 protocol; join before SSE
Browse filesThe /queue/data SSE handler 404s on its first tick if the session_hash
isn't registered in pending_messages_per_session yet. Opening the
EventSource BEFORE POSTing to /queue/join let the SSE handler win that
race, raising HTTPException(404) after SSE headers were already flushed
('Caught handled exception, but response already started').
Move to Gradio v6's per-event /call/v2/<api_name>/<event_id> protocol:
1. POST /gradio_api/call/v2/chat with a named-key body -> {event_id}
2. GET /gradio_api/call/v2/chat/<event_id> for SSE
The join completes and registers the session before the stream opens, so
no race. The stream emits event:generating / event:complete with data as
the raw output array ([0] = the yielded str), matching chat()'s generator.
Verified end-to-end against the live Space: events stream correctly.
Default model stays thinkingmachines/Inkling:auto.
Co-Authored-By: Claude <noreply@anthropic.com>
- index.html +63 -69
|
@@ -138,7 +138,7 @@
|
|
| 138 |
<button id="stop" title="Stop generating">Stop</button>
|
| 139 |
<button id="send">Send</button>
|
| 140 |
</div>
|
| 141 |
-
<div class="hint">Backend: gradio.Server Β· SSE from <code>/gradio_api/
|
| 142 |
</div>
|
| 143 |
|
| 144 |
<script>
|
|
@@ -224,13 +224,9 @@ function setBusy(busy, msg = "") {
|
|
| 224 |
statusEl.textContent = msg;
|
| 225 |
}
|
| 226 |
|
| 227 |
-
//
|
| 228 |
-
//
|
| 229 |
-
|
| 230 |
-
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
| 231 |
-
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4)).toString(16));
|
| 232 |
-
}
|
| 233 |
-
const sessionHash = uid();
|
| 234 |
|
| 235 |
/**
|
| 236 |
* Hit a JSON endpoint and return its parsed body. Throws with the response
|
|
@@ -252,88 +248,86 @@ async function jpost(path, body) {
|
|
| 252 |
/**
|
| 253 |
* Stream a chat completion. Resolves with the final assistant text.
|
| 254 |
*
|
| 255 |
-
*
|
| 256 |
-
*
|
| 257 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
*/
|
| 259 |
async function streamChat(payload) {
|
| 260 |
-
// 1)
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
const es = new EventSource(sseUrl, { withCredentials: false });
|
| 263 |
liveStream = es;
|
| 264 |
|
| 265 |
-
|
| 266 |
-
let opened = false;
|
| 267 |
let latest = "";
|
| 268 |
-
let
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
|
|
|
| 276 |
try {
|
| 277 |
-
const
|
| 278 |
-
const arr = msg.data ?? msg;
|
| 279 |
if (Array.isArray(arr) && typeof arr[0] === "string") {
|
| 280 |
latest = arr[0];
|
| 281 |
onDelta(latest);
|
| 282 |
}
|
| 283 |
-
} catch (_) { /* ignore
|
| 284 |
});
|
| 285 |
|
|
|
|
| 286 |
es.addEventListener("complete", (ev) => {
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
let payload = "";
|
| 296 |
-
try { payload = JSON.parse(ev.data)?.message ?? ""; } catch (_) {}
|
| 297 |
-
completed = true;
|
| 298 |
-
try { es.close(); } catch (_) {}
|
| 299 |
-
if (payload) reject(new Error(payload));
|
| 300 |
-
else if (!opened) reject(new Error("EventSource failed to open β is the backend reachable?"));
|
| 301 |
-
else if (!latest) reject(new Error("Stream closed without producing any output."));
|
| 302 |
-
else resolve(latest); // partial text is better than nothing
|
| 303 |
});
|
| 304 |
|
|
|
|
| 305 |
es.addEventListener("error", (ev) => {
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
| 309 |
});
|
| 310 |
-
});
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
data: [
|
| 322 |
-
payload.messages,
|
| 323 |
-
payload.provider,
|
| 324 |
-
payload.system_prompt,
|
| 325 |
-
payload.temperature,
|
| 326 |
-
payload.max_tokens,
|
| 327 |
-
],
|
| 328 |
-
fn_index: 0,
|
| 329 |
-
session_hash: sessionHash,
|
| 330 |
-
trigger_id: 0,
|
| 331 |
-
event_data: null,
|
| 332 |
-
};
|
| 333 |
-
// jpost will throw if the join fails; reject the streaming promise too.
|
| 334 |
-
await jpost("/gradio_api/queue/join", joinBody);
|
| 335 |
-
|
| 336 |
-
return done;
|
| 337 |
}
|
| 338 |
|
| 339 |
async function send() {
|
|
|
|
| 138 |
<button id="stop" title="Stop generating">Stop</button>
|
| 139 |
<button id="send">Send</button>
|
| 140 |
</div>
|
| 141 |
+
<div class="hint">Backend: gradio.Server Β· SSE from <code>/gradio_api/call/v2/chat</code></div>
|
| 142 |
</div>
|
| 143 |
|
| 144 |
<script>
|
|
|
|
| 224 |
statusEl.textContent = msg;
|
| 225 |
}
|
| 226 |
|
| 227 |
+
// The /gradio_api/call/v2 protocol is per-event, so the server owns the
|
| 228 |
+
// session scoping via the event_id returned by the join POST β the frontend
|
| 229 |
+
// needs no session hash of its own.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
/**
|
| 232 |
* Hit a JSON endpoint and return its parsed body. Throws with the response
|
|
|
|
| 248 |
/**
|
| 249 |
* Stream a chat completion. Resolves with the final assistant text.
|
| 250 |
*
|
| 251 |
+
* Wire protocol (Gradio v6 "call" API β avoids the /queue/data session race):
|
| 252 |
+
* 1. POST /gradio_api/call/v2/chat with a NAMED-key body
|
| 253 |
+
* { messages, provider, system_prompt, temperature, max_tokens }
|
| 254 |
+
* β returns { event_id }
|
| 255 |
+
* 2. GET /gradio_api/call/v2/chat/<event_id> opens an SSE stream that
|
| 256 |
+
* emits, per Gradio's simple_predict_get process_msg:
|
| 257 |
+
* event: generating\ndata: [latest_yield]\n\n (each yield of the fn)
|
| 258 |
+
* event: complete\ndata: [final]\n\n (generator returned)
|
| 259 |
+
* event: error\ndata: "<msg>"\n\n (failure)
|
| 260 |
+
* event: heartbeat\ndata: null\n\n (keepalive)
|
| 261 |
+
* `data` is the raw output array; [0] is the single str our generator
|
| 262 |
+
* yields. Because the join POST runs to completion before we open the
|
| 263 |
+
* SSE, the session is already registered on the server when the
|
| 264 |
+
* stream handler checks it β so no 404 "response already started".
|
| 265 |
*/
|
| 266 |
async function streamChat(payload) {
|
| 267 |
+
// 1) Join first β named keys, server resolves them to positional args
|
| 268 |
+
// via the /chat endpoint's parameter schema.
|
| 269 |
+
const join = await jpost("/gradio_api/call/v2/chat", payload);
|
| 270 |
+
const eventId = join.event_id;
|
| 271 |
+
if (!eventId) throw new Error("Server did not return an event_id.");
|
| 272 |
+
|
| 273 |
+
// 2) Open the per-event SSE stream.
|
| 274 |
+
const sseUrl = `/gradio_api/call/v2/chat/${encodeURIComponent(eventId)}`;
|
| 275 |
const es = new EventSource(sseUrl, { withCredentials: false });
|
| 276 |
liveStream = es;
|
| 277 |
|
| 278 |
+
return new Promise((resolve, reject) => {
|
|
|
|
| 279 |
let latest = "";
|
| 280 |
+
let settled = false;
|
| 281 |
|
| 282 |
+
const finish = (fn) => {
|
| 283 |
+
if (settled) return;
|
| 284 |
+
settled = true;
|
| 285 |
+
setTimeout(() => { try { es.close(); } catch (_) {} }, 0);
|
| 286 |
+
fn();
|
| 287 |
+
};
|
| 288 |
|
| 289 |
+
// Each intermediate yield of chat()'s generator arrives here. `data`
|
| 290 |
+
// is the raw output array, so [0] is the accumulated-so-far string.
|
| 291 |
+
es.addEventListener("generating", (ev) => {
|
| 292 |
try {
|
| 293 |
+
const arr = JSON.parse(ev.data);
|
|
|
|
| 294 |
if (Array.isArray(arr) && typeof arr[0] === "string") {
|
| 295 |
latest = arr[0];
|
| 296 |
onDelta(latest);
|
| 297 |
}
|
| 298 |
+
} catch (_) { /* ignore malformed frames */ }
|
| 299 |
});
|
| 300 |
|
| 301 |
+
// Generator returned. `data` is the final output array.
|
| 302 |
es.addEventListener("complete", (ev) => {
|
| 303 |
+
try {
|
| 304 |
+
const arr = JSON.parse(ev.data);
|
| 305 |
+
if (Array.isArray(arr) && typeof arr[0] === "string") {
|
| 306 |
+
latest = arr[0];
|
| 307 |
+
onDelta(latest);
|
| 308 |
+
}
|
| 309 |
+
} catch (_) { /* keep last partial */ }
|
| 310 |
+
finish(() => resolve(latest));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
});
|
| 312 |
|
| 313 |
+
// Server-side error: data is a plain string message.
|
| 314 |
es.addEventListener("error", (ev) => {
|
| 315 |
+
let msg = "Stream error.";
|
| 316 |
+
if (ev.data) {
|
| 317 |
+
try { msg = JSON.parse(ev.data) ?? msg; } catch (_) { msg = ev.data; }
|
| 318 |
+
}
|
| 319 |
+
finish(() => reject(new Error(typeof msg === "string" ? msg : "Stream error.")));
|
| 320 |
});
|
|
|
|
| 321 |
|
| 322 |
+
// EventSource's generic onerror fires both on real failures and on a
|
| 323 |
+
// clean server-side close. Treat it as terminal only if we never
|
| 324 |
+
// completed; otherwise the complete/error handlers above already ran.
|
| 325 |
+
es.onerror = () => {
|
| 326 |
+
if (settled) return;
|
| 327 |
+
finish(() => reject(new Error(
|
| 328 |
+
latest ? null : "Connection lost before any output arrived.")));
|
| 329 |
+
};
|
| 330 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
}
|
| 332 |
|
| 333 |
async function send() {
|