Spaces:
Running
Running
Sync ordered assistant output changes
Browse files- auth.py +2 -2
- main.js +30 -23
- server.py +4 -0
- tool-call-batcher.js +59 -0
- ws/s2s-ws-client.js +26 -15
auth.py
CHANGED
|
@@ -114,10 +114,10 @@ def _oauth_token_expired(info, *, now=None) -> bool:
|
|
| 114 |
logger.warning("Unexpected OAuth expiry value; requiring a fresh login.")
|
| 115 |
return True
|
| 116 |
if expires_at.tzinfo is None:
|
| 117 |
-
expires_at = expires_at.
|
| 118 |
current = now or datetime.now(timezone.utc)
|
| 119 |
if current.tzinfo is None:
|
| 120 |
-
current = current.
|
| 121 |
return expires_at <= current + _OAUTH_EXPIRY_SKEW
|
| 122 |
|
| 123 |
|
|
|
|
| 114 |
logger.warning("Unexpected OAuth expiry value; requiring a fresh login.")
|
| 115 |
return True
|
| 116 |
if expires_at.tzinfo is None:
|
| 117 |
+
expires_at = expires_at.astimezone(timezone.utc)
|
| 118 |
current = now or datetime.now(timezone.utc)
|
| 119 |
if current.tzinfo is None:
|
| 120 |
+
current = current.astimezone(timezone.utc)
|
| 121 |
return expires_at <= current + _OAUTH_EXPIRY_SKEW
|
| 122 |
|
| 123 |
|
main.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
| 17 |
*/
|
| 18 |
|
| 19 |
import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
|
|
|
|
| 20 |
import { $, truncateError, DEBUG } from "./ui/dom.js";
|
| 21 |
import { ChatView } from "./ui/chat.js";
|
| 22 |
import { Account } from "./ui/account.js";
|
|
@@ -767,14 +768,14 @@ function flashPreview() {
|
|
| 767 |
}
|
| 768 |
|
| 769 |
// ββ Tool executor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 770 |
-
// Runs the function the model called
|
| 771 |
-
//
|
| 772 |
-
//
|
|
|
|
| 773 |
|
| 774 |
/**
|
| 775 |
-
* Run the function the model called
|
| 776 |
-
*
|
| 777 |
-
* can be shown in the conversation once the tool has actually run.
|
| 778 |
* @param {string} name @param {string} argsJson @param {string} callId
|
| 779 |
* @returns {Promise<{ output: string, image?: string }>}
|
| 780 |
*/
|
|
@@ -792,37 +793,23 @@ async function runTool(name, argsJson, callId) {
|
|
| 792 |
if (name === "web_search") {
|
| 793 |
const query = typeof args.query === "string" ? args.query : "";
|
| 794 |
result.output = await execWebSearch(query);
|
| 795 |
-
// Return the result and let the bare response.create (below) trigger the
|
| 796 |
-
// spoken answer.
|
| 797 |
-
client.sendToolOutput(callId, result.output);
|
| 798 |
} else if (name === "camera_snapshot") {
|
| 799 |
const dataUrl = captureSnapshot();
|
| 800 |
if (dataUrl) {
|
| 801 |
if (DEBUG) console.debug(`[tool] camera_snapshot captured frame (${dataUrl.length} chars), sending image + output`);
|
| 802 |
result = { output: "Snapshot captured from the webcam and attached as an image.", image: dataUrl };
|
| 803 |
-
// Return the tool output; the frame itself rides along with the
|
| 804 |
-
// response.create below (sent right before it), so the model sees the
|
| 805 |
-
// snapshot in the very response it's about to speak.
|
| 806 |
-
client.sendToolOutput(callId, result.output);
|
| 807 |
flashPreview();
|
| 808 |
} else {
|
| 809 |
console.warn("[tool] camera_snapshot: no frame β camera off or not ready");
|
| 810 |
result.output = "The camera is not available right now.";
|
| 811 |
-
client.sendToolOutput(callId, result.output);
|
| 812 |
}
|
| 813 |
} else {
|
| 814 |
result.output = `Unknown tool: ${name}`;
|
| 815 |
-
client.sendToolOutput(callId, result.output);
|
| 816 |
}
|
| 817 |
} catch (err) {
|
| 818 |
const msg = err instanceof Error ? err.message : String(err);
|
| 819 |
result.output = `Tool failed: ${msg}`;
|
| 820 |
-
client.sendToolOutput(callId, result.output);
|
| 821 |
}
|
| 822 |
-
if (DEBUG) console.debug(`[tool] requesting model response after ${name}`);
|
| 823 |
-
// Camera: the captured frame rides with the response.create (sent just before
|
| 824 |
-
// it) so it's in context for the reply. Other tools: a bare create.
|
| 825 |
-
client.requestResponse(result.image ? { image: result.image } : undefined);
|
| 826 |
return result;
|
| 827 |
}
|
| 828 |
|
|
@@ -1337,6 +1324,18 @@ async function doStart(audioContext = null) {
|
|
| 1337 |
client = c;
|
| 1338 |
c.setMuted(micMuted || userAudioReplaying);
|
| 1339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1340 |
c.addEventListener("queue", (e) => {
|
| 1341 |
const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
|
| 1342 |
if (queueId) queuedTicketId = queueId;
|
|
@@ -1381,16 +1380,24 @@ async function doStart(audioContext = null) {
|
|
| 1381 |
c.addEventListener("response-finished", (e) => {
|
| 1382 |
const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
|
| 1383 |
chat.onResponseFinished(detail);
|
|
|
|
|
|
|
| 1384 |
});
|
| 1385 |
|
| 1386 |
c.addEventListener("toolcall", (e) => {
|
| 1387 |
-
const { name, arguments: args, callId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string }>} */ (e).detail;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1388 |
chat.onToolCall(name);
|
| 1389 |
// Execute the tool, then push it to the conversation once the result is in,
|
| 1390 |
// so the toggle shows both the call input and its output together.
|
| 1391 |
-
|
| 1392 |
-
chat.onToolResult(name, args, output, image);
|
|
|
|
| 1393 |
});
|
|
|
|
| 1394 |
});
|
| 1395 |
c.addEventListener("error", (e) => {
|
| 1396 |
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
|
|
|
| 17 |
*/
|
| 18 |
|
| 19 |
import { S2sWsRealtimeClient } from "./ws/s2s-ws-client.js";
|
| 20 |
+
import { ToolCallBatcher } from "./tool-call-batcher.js";
|
| 21 |
import { $, truncateError, DEBUG } from "./ui/dom.js";
|
| 22 |
import { ChatView } from "./ui/chat.js";
|
| 23 |
import { Account } from "./ui/account.js";
|
|
|
|
| 768 |
}
|
| 769 |
|
| 770 |
// ββ Tool executor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 771 |
+
// Runs the function the model called and returns the result. The connection's
|
| 772 |
+
// ToolCallBatcher sends every result from the originating response together,
|
| 773 |
+
// then requests one follow-up. Errors come back as tool output too, so the
|
| 774 |
+
// model can recover gracefully instead of the turn stalling.
|
| 775 |
|
| 776 |
/**
|
| 777 |
+
* Run the function the model called. The caller batches the returned result
|
| 778 |
+
* with the other calls from the same response before sending it to the model.
|
|
|
|
| 779 |
* @param {string} name @param {string} argsJson @param {string} callId
|
| 780 |
* @returns {Promise<{ output: string, image?: string }>}
|
| 781 |
*/
|
|
|
|
| 793 |
if (name === "web_search") {
|
| 794 |
const query = typeof args.query === "string" ? args.query : "";
|
| 795 |
result.output = await execWebSearch(query);
|
|
|
|
|
|
|
|
|
|
| 796 |
} else if (name === "camera_snapshot") {
|
| 797 |
const dataUrl = captureSnapshot();
|
| 798 |
if (dataUrl) {
|
| 799 |
if (DEBUG) console.debug(`[tool] camera_snapshot captured frame (${dataUrl.length} chars), sending image + output`);
|
| 800 |
result = { output: "Snapshot captured from the webcam and attached as an image.", image: dataUrl };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
flashPreview();
|
| 802 |
} else {
|
| 803 |
console.warn("[tool] camera_snapshot: no frame β camera off or not ready");
|
| 804 |
result.output = "The camera is not available right now.";
|
|
|
|
| 805 |
}
|
| 806 |
} else {
|
| 807 |
result.output = `Unknown tool: ${name}`;
|
|
|
|
| 808 |
}
|
| 809 |
} catch (err) {
|
| 810 |
const msg = err instanceof Error ? err.message : String(err);
|
| 811 |
result.output = `Tool failed: ${msg}`;
|
|
|
|
| 812 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
return result;
|
| 814 |
}
|
| 815 |
|
|
|
|
| 1324 |
client = c;
|
| 1325 |
c.setMuted(micMuted || userAudioReplaying);
|
| 1326 |
|
| 1327 |
+
/** @param {{ callId: string; output: string; image?: string }[]} results */
|
| 1328 |
+
const sendToolBatch = (results) => {
|
| 1329 |
+
if (client !== c) return;
|
| 1330 |
+
for (const result of results) c.sendToolOutput(result.callId, result.output);
|
| 1331 |
+
for (const result of results) {
|
| 1332 |
+
if (result.image) c.sendUserImage(result.image);
|
| 1333 |
+
}
|
| 1334 |
+
if (DEBUG) console.debug(`[tool] requesting one follow-up after ${results.length} tool result(s)`);
|
| 1335 |
+
c.requestResponse();
|
| 1336 |
+
};
|
| 1337 |
+
const toolBatches = new ToolCallBatcher(sendToolBatch);
|
| 1338 |
+
|
| 1339 |
c.addEventListener("queue", (e) => {
|
| 1340 |
const { position, queueId } = /** @type {CustomEvent<{ position: number; queueId: string }>} */ (e).detail;
|
| 1341 |
if (queueId) queuedTicketId = queueId;
|
|
|
|
| 1380 |
c.addEventListener("response-finished", (e) => {
|
| 1381 |
const detail = /** @type {CustomEvent<{ responseId: string; status: string; audible?: boolean; transcript?: string }>} */ (e).detail;
|
| 1382 |
chat.onResponseFinished(detail);
|
| 1383 |
+
const flush = toolBatches.finish(detail.responseId, detail.status);
|
| 1384 |
+
if (flush) void flush.catch((err) => onFatalError(err));
|
| 1385 |
});
|
| 1386 |
|
| 1387 |
c.addEventListener("toolcall", (e) => {
|
| 1388 |
+
const { name, arguments: args, callId, responseId } = /** @type {CustomEvent<{ name: string; arguments: string; callId: string; responseId: string }>} */ (e).detail;
|
| 1389 |
+
if (!responseId) {
|
| 1390 |
+
console.warn(`[tool] call ${callId || "<unknown>"} has no response_id; ignoring uncorrelated tool call`);
|
| 1391 |
+
return;
|
| 1392 |
+
}
|
| 1393 |
chat.onToolCall(name);
|
| 1394 |
// Execute the tool, then push it to the conversation once the result is in,
|
| 1395 |
// so the toggle shows both the call input and its output together.
|
| 1396 |
+
const execution = runTool(name, args, callId).then(({ output, image }) => {
|
| 1397 |
+
if (client === c) chat.onToolResult(name, args, output, image);
|
| 1398 |
+
return { callId, output, ...(image ? { image } : {}) };
|
| 1399 |
});
|
| 1400 |
+
toolBatches.add(responseId, execution);
|
| 1401 |
});
|
| 1402 |
c.addEventListener("error", (e) => {
|
| 1403 |
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
|
server.py
CHANGED
|
@@ -321,6 +321,10 @@ async def session(request: Request):
|
|
| 321 |
if lb.status_code == 401:
|
| 322 |
body = _safe_json(lb)
|
| 323 |
reason = body.get("reason") or "login_required"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
logger.info("Session authentication rejected: %s", reason)
|
| 325 |
return _login_required_response(reason, set_cookie)
|
| 326 |
|
|
|
|
| 321 |
if lb.status_code == 401:
|
| 322 |
body = _safe_json(lb)
|
| 323 |
reason = body.get("reason") or "login_required"
|
| 324 |
+
if reason == "token_invalid":
|
| 325 |
+
session = getattr(request, "scope", {}).get("session")
|
| 326 |
+
if isinstance(session, dict):
|
| 327 |
+
session.pop("oauth_info", None)
|
| 328 |
logger.info("Session authentication rejected: %s", reason)
|
| 329 |
return _login_required_response(reason, set_cookie)
|
| 330 |
|
tool-call-batcher.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-check
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @typedef {Object} ToolExecutionResult
|
| 5 |
+
* @property {string} callId
|
| 6 |
+
* @property {string} output
|
| 7 |
+
* @property {string} [image]
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
/** Collect every tool execution from one response before requesting its follow-up. */
|
| 11 |
+
export class ToolCallBatcher {
|
| 12 |
+
/** @param {(results: ToolExecutionResult[]) => void | Promise<void>} onReady */
|
| 13 |
+
constructor(onReady) {
|
| 14 |
+
this._onReady = onReady;
|
| 15 |
+
/** @type {Map<string, { executions: Promise<ToolExecutionResult>[]; flush: Promise<void> | null }>} */
|
| 16 |
+
this._batches = new Map();
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Register a tool execution in the order its call appeared in the response.
|
| 21 |
+
* @param {string} responseId
|
| 22 |
+
* @param {Promise<ToolExecutionResult>} execution
|
| 23 |
+
*/
|
| 24 |
+
add(responseId, execution) {
|
| 25 |
+
let batch = this._batches.get(responseId);
|
| 26 |
+
if (!batch) {
|
| 27 |
+
batch = { executions: [], flush: null };
|
| 28 |
+
this._batches.set(responseId, batch);
|
| 29 |
+
}
|
| 30 |
+
batch.executions.push(execution);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Finish the originating response. Completed responses flush once all tools
|
| 35 |
+
* settle; unsuccessful responses discard calls the backend rolled back.
|
| 36 |
+
* @param {string} responseId
|
| 37 |
+
* @param {string} status
|
| 38 |
+
* @returns {Promise<void> | null}
|
| 39 |
+
*/
|
| 40 |
+
finish(responseId, status) {
|
| 41 |
+
const batch = this._batches.get(responseId);
|
| 42 |
+
if (!batch) return null;
|
| 43 |
+
if (status !== "completed") {
|
| 44 |
+
this._batches.delete(responseId);
|
| 45 |
+
// Executions cannot be cancelled, but a discarded rejection should not
|
| 46 |
+
// become unhandled after the response is gone.
|
| 47 |
+
for (const execution of batch.executions) void execution.catch(() => {});
|
| 48 |
+
return null;
|
| 49 |
+
}
|
| 50 |
+
if (batch.flush) return batch.flush;
|
| 51 |
+
|
| 52 |
+
batch.flush = Promise.all(batch.executions)
|
| 53 |
+
.then((results) => this._onReady(results))
|
| 54 |
+
.finally(() => {
|
| 55 |
+
if (this._batches.get(responseId) === batch) this._batches.delete(responseId);
|
| 56 |
+
});
|
| 57 |
+
return batch.flush;
|
| 58 |
+
}
|
| 59 |
+
}
|
ws/s2s-ws-client.js
CHANGED
|
@@ -177,12 +177,12 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 177 |
* UI can tell a barge-in cut (keep it) from a never-heard speculative
|
| 178 |
* response (drop it). */
|
| 179 |
this._audibleResponses = new Set();
|
| 180 |
-
/** @type {Map<string, string>} The
|
| 181 |
-
* response, accumulated from streamed deltas
|
| 182 |
this._asstTranscriptByResp = new Map();
|
| 183 |
-
/** @type {Map<string, string>}
|
| 184 |
-
*
|
| 185 |
-
*
|
| 186 |
this._asstFullByResp = new Map();
|
| 187 |
this._muted = false;
|
| 188 |
// ββ Response lock ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -222,8 +222,8 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 222 |
this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
|
| 223 |
}
|
| 224 |
|
| 225 |
-
/** Full assistant transcript so far for a response:
|
| 226 |
-
* plus
|
| 227 |
* @param {string} rid @returns {string} */
|
| 228 |
_asstDisplay(rid) {
|
| 229 |
const full = this._asstFullByResp.get(rid) || "";
|
|
@@ -775,9 +775,9 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 775 |
const audible = responseId ? this._audibleResponses.has(responseId) : false;
|
| 776 |
this._audibleResponses.delete(responseId);
|
| 777 |
// Pull whatever transcript the response carries, falling back to the
|
| 778 |
-
//
|
| 779 |
-
//
|
| 780 |
-
// be empty, so this is the last chance to capture the text.
|
| 781 |
const transcript =
|
| 782 |
extractResponseTranscript(event.response) ||
|
| 783 |
this._asstDisplay(responseId) ||
|
|
@@ -798,9 +798,10 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 798 |
const name = typeof event.name === "string" ? event.name : "";
|
| 799 |
const args = typeof event.arguments === "string" ? event.arguments : "{}";
|
| 800 |
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
|
|
|
| 801 |
if (name) {
|
| 802 |
this.dispatchEvent(new CustomEvent("toolcall", {
|
| 803 |
-
detail: { name, arguments: args, callId },
|
| 804 |
}));
|
| 805 |
} else {
|
| 806 |
// A nameless call can't be executed, so no function_call_output is
|
|
@@ -844,6 +845,10 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 844 |
},
|
| 845 |
}),
|
| 846 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 847 |
}
|
| 848 |
break;
|
| 849 |
}
|
|
@@ -859,7 +864,7 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 859 |
const delta = typeof event.delta === "string" ? event.delta : "";
|
| 860 |
if (delta) {
|
| 861 |
this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta);
|
| 862 |
-
// Show
|
| 863 |
this.dispatchEvent(
|
| 864 |
new CustomEvent("transcript", {
|
| 865 |
detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid },
|
|
@@ -872,13 +877,14 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 872 |
case "response.audio_transcript.done":
|
| 873 |
case "response.output_audio_transcript.done": {
|
| 874 |
const rid = typeof event.response_id === "string" ? event.response_id : "";
|
| 875 |
-
// This
|
| 876 |
-
//
|
|
|
|
| 877 |
const segment =
|
| 878 |
(typeof event.transcript === "string" && event.transcript) ||
|
| 879 |
this._asstTranscriptByResp.get(rid) ||
|
| 880 |
"";
|
| 881 |
-
this._asstTranscriptByResp.delete(rid);
|
| 882 |
if (segment) {
|
| 883 |
const prev = this._asstFullByResp.get(rid) || "";
|
| 884 |
this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment);
|
|
@@ -1083,6 +1089,11 @@ export class S2sWsRealtimeClient extends EventTarget {
|
|
| 1083 |
return this._openResponses > 0 || this._createInFlight;
|
| 1084 |
}
|
| 1085 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1086 |
/** Send a response.create immediately and arm the in-flight guard. Any image
|
| 1087 |
* on the payload is added as user content right before the create.
|
| 1088 |
* @param {{ image?: string }} [opts] */
|
|
|
|
| 177 |
* UI can tell a barge-in cut (keep it) from a never-heard speculative
|
| 178 |
* response (drop it). */
|
| 179 |
this._audibleResponses = new Set();
|
| 180 |
+
/** @type {Map<string, string>} The in-progress assistant transcript per
|
| 181 |
+
* response, accumulated from streamed deltas until the terminal done. */
|
| 182 |
this._asstTranscriptByResp = new Map();
|
| 183 |
+
/** @type {Map<string, string>} Terminal assistant transcript per response.
|
| 184 |
+
* Realtime emits one `*.transcript.done`; appending remains for compatibility
|
| 185 |
+
* with legacy endpoints that emitted a done event for every text segment. */
|
| 186 |
this._asstFullByResp = new Map();
|
| 187 |
this._muted = false;
|
| 188 |
// ββ Response lock ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 222 |
this.dispatchEvent(new CustomEvent("status", { detail: { status } }));
|
| 223 |
}
|
| 224 |
|
| 225 |
+
/** Full assistant transcript so far for a response: a terminal transcript,
|
| 226 |
+
* when present, plus any in-progress deltas from a legacy segmented stream.
|
| 227 |
* @param {string} rid @returns {string} */
|
| 228 |
_asstDisplay(rid) {
|
| 229 |
const full = this._asstFullByResp.get(rid) || "";
|
|
|
|
| 775 |
const audible = responseId ? this._audibleResponses.has(responseId) : false;
|
| 776 |
this._audibleResponses.delete(responseId);
|
| 777 |
// Pull whatever transcript the response carries, falling back to the
|
| 778 |
+
// terminal `*.transcript.done` value (plus any in-progress delta from a
|
| 779 |
+
// legacy segmented stream). For an interrupted reply the response
|
| 780 |
+
// payload may be empty, so this is the last chance to capture the text.
|
| 781 |
const transcript =
|
| 782 |
extractResponseTranscript(event.response) ||
|
| 783 |
this._asstDisplay(responseId) ||
|
|
|
|
| 798 |
const name = typeof event.name === "string" ? event.name : "";
|
| 799 |
const args = typeof event.arguments === "string" ? event.arguments : "{}";
|
| 800 |
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
| 801 |
+
const responseId = typeof event.response_id === "string" ? event.response_id : "";
|
| 802 |
if (name) {
|
| 803 |
this.dispatchEvent(new CustomEvent("toolcall", {
|
| 804 |
+
detail: { name, arguments: args, callId, responseId },
|
| 805 |
}));
|
| 806 |
} else {
|
| 807 |
// A nameless call can't be executed, so no function_call_output is
|
|
|
|
| 845 |
},
|
| 846 |
}),
|
| 847 |
);
|
| 848 |
+
} else if (this._status === "processing" && !this._responsePending()) {
|
| 849 |
+
// Empty STT results intentionally do not create a response, so there
|
| 850 |
+
// will be no response.done event to return the UI to listening.
|
| 851 |
+
this._setStatus("connected");
|
| 852 |
}
|
| 853 |
break;
|
| 854 |
}
|
|
|
|
| 864 |
const delta = typeof event.delta === "string" ? event.delta : "";
|
| 865 |
if (delta) {
|
| 866 |
this._asstTranscriptByResp.set(rid, (this._asstTranscriptByResp.get(rid) || "") + delta);
|
| 867 |
+
// Show the complete transcript accumulated from deltas so far.
|
| 868 |
this.dispatchEvent(
|
| 869 |
new CustomEvent("transcript", {
|
| 870 |
detail: { role: "assistant", text: this._asstDisplay(rid), partial: true, responseId: rid },
|
|
|
|
| 877 |
case "response.audio_transcript.done":
|
| 878 |
case "response.output_audio_transcript.done": {
|
| 879 |
const rid = typeof event.response_id === "string" ? event.response_id : "";
|
| 880 |
+
// This terminalizes the assistant transcript for the response. Realtime
|
| 881 |
+
// emits it once after all deltas; retain space-joining only for legacy
|
| 882 |
+
// endpoints that emitted a done event for every text segment.
|
| 883 |
const segment =
|
| 884 |
(typeof event.transcript === "string" && event.transcript) ||
|
| 885 |
this._asstTranscriptByResp.get(rid) ||
|
| 886 |
"";
|
| 887 |
+
this._asstTranscriptByResp.delete(rid);
|
| 888 |
if (segment) {
|
| 889 |
const prev = this._asstFullByResp.get(rid) || "";
|
| 890 |
this._asstFullByResp.set(rid, prev ? `${prev} ${segment}` : segment);
|
|
|
|
| 1089 |
return this._openResponses > 0 || this._createInFlight;
|
| 1090 |
}
|
| 1091 |
|
| 1092 |
+
/** True while a response is active, awaiting confirmation, or queued. */
|
| 1093 |
+
_responsePending() {
|
| 1094 |
+
return this._responseActive() || this._createQueue.length > 0;
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
/** Send a response.create immediately and arm the in-flight guard. Any image
|
| 1098 |
* on the payload is added as user content right before the create.
|
| 1099 |
* @param {{ image?: string }} [opts] */
|