akhaliq HF Staff Claude commited on
Commit
16fd168
Β·
1 Parent(s): fa346c5

Switch frontend to /gradio_api/call/v2 protocol; join before SSE

Browse files

The /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>

Files changed (1) hide show
  1. index.html +63 -69
index.html CHANGED
@@ -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/queue/join</code></div>
142
  </div>
143
 
144
  <script>
@@ -224,13 +224,9 @@ function setBusy(busy, msg = "") {
224
  statusEl.textContent = msg;
225
  }
226
 
227
- // Random session hash per client. Actually the backend uses session_hash for
228
- // queue-grouping, not auth β€” a fresh UUID per chat session is fine.
229
- function uid() {
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
- * Opens an EventSource BEFORE the POST so we don't miss the "process_starts"
256
- * handler that the server emits. We cookie-cutter a Gradio fn_index of 0
257
- * because we only have one @app.api() endpoint.
 
 
 
 
 
 
 
 
 
 
 
258
  */
259
  async function streamChat(payload) {
260
- // 1) Open SSE first.
261
- const sseUrl = `/gradio_api/queue/data?session_hash=${encodeURIComponent(sessionHash)}`;
 
 
 
 
 
 
262
  const es = new EventSource(sseUrl, { withCredentials: false });
263
  liveStream = es;
264
 
265
- const done = new Promise((resolve, reject) => {
266
- let opened = false;
267
  let latest = "";
268
- let completed = false;
269
 
270
- // The 5-second EventSource handshake can race with the first POST; if
271
- // we never get an open event in reasonable time, fail loudly.
272
- es.addEventListener("open", () => { opened = true; });
 
 
 
273
 
274
- es.addEventListener("data", (ev) => {
275
- // Each "data" event JSON-parses to { data: [latest_yield...] }.
 
276
  try {
277
- const msg = JSON.parse(ev.data);
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 non-JSON heartbeats */ }
284
  });
285
 
 
286
  es.addEventListener("complete", (ev) => {
287
- completed = true;
288
- setTimeout(() => { try { es.close(); } catch (_) {} }, 0);
289
- resolve(latest);
290
- });
291
-
292
- // The backend may push an explicit "error" event before complete, so
293
- // surface those as a real message rather than the generic shutdown.
294
- es.addEventListener("error_event", (ev) => {
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
- // The generic "error" event fires in many states β€” most importantly
307
- // AFTER close on browser-side, which we don't want to flag as failure.
308
- if (completed) return;
 
 
309
  });
310
- });
311
 
312
- // 2) Actually enqueue the request.
313
- //
314
- // The queue sends your generator function's arguments as positional
315
- // entries of `data`, one per parameter β€” NOT as a single bundled dict.
316
- // Since @app.api() on `chat(messages, provider, system_prompt,
317
- // temperature, max_tokens)` registers 5 input components, we unpack the
318
- // payload here so the order matches the fn signature. (fn_index 0 is the
319
- // only @app.api() endpoint on this Space, so it's always 0.)
320
- const joinBody = {
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() {