Dismiss completed voice turn indicator
Browse files- main.js +1 -0
- ui/chat.js +38 -1
main.js
CHANGED
|
@@ -1493,6 +1493,7 @@ async function doStart(audioContext = null) {
|
|
| 1493 |
c.addEventListener("status", (e) => {
|
| 1494 |
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
| 1495 |
onClientStatus(detail.status);
|
|
|
|
| 1496 |
});
|
| 1497 |
c.addEventListener("transcript", (e) => {
|
| 1498 |
const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
|
|
|
|
| 1493 |
c.addEventListener("status", (e) => {
|
| 1494 |
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
|
| 1495 |
onClientStatus(detail.status);
|
| 1496 |
+
if (detail.status === "ai-speaking") chat.onAssistantActivity();
|
| 1497 |
});
|
| 1498 |
c.addEventListener("transcript", (e) => {
|
| 1499 |
const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
|
ui/chat.js
CHANGED
|
@@ -245,6 +245,25 @@ export class ChatView {
|
|
| 245 |
}
|
| 246 |
}
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
/**
|
| 249 |
* (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
|
| 250 |
* Calling it again resets the countdown β so a bubble that keeps updating
|
|
@@ -254,7 +273,20 @@ export class ChatView {
|
|
| 254 |
*/
|
| 255 |
_bumpDismiss(el, delay = 4000) {
|
| 256 |
this._bubbleExpiry.set(el, Date.now() + delay);
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
|
| 260 |
// ββ History βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -403,6 +435,9 @@ export class ChatView {
|
|
| 403 |
&& this._activeUserBubble?.isConnected
|
| 404 |
&& !this._activeUserBubble.classList.contains("out");
|
| 405 |
if (!reusable) {
|
|
|
|
|
|
|
|
|
|
| 406 |
this._activeUserBubble = this._spawnVoiceBubble("listening");
|
| 407 |
this._activeUserItemId = id;
|
| 408 |
} else if (this._activeUserBubble.classList.contains("voice")) {
|
|
@@ -467,6 +502,7 @@ export class ChatView {
|
|
| 467 |
this._bumpDismiss(this._activeUserBubble, 6000);
|
| 468 |
this._markUnread();
|
| 469 |
} else if (d.role === "assistant") {
|
|
|
|
| 470 |
// Assistant transcript arrives once, as the full text, keyed by
|
| 471 |
// response_id so a cancelled speculative response can be removed later. A
|
| 472 |
// missing id gets a unique key so two id-less replies never collide.
|
|
@@ -557,6 +593,7 @@ export class ChatView {
|
|
| 557 |
onResponseFinished(detail) {
|
| 558 |
const { responseId, status, audible, transcript } = detail;
|
| 559 |
if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
|
|
|
|
| 560 |
// Without an id we can't target a specific response; the bubble will
|
| 561 |
// auto-dismiss on its own timer regardless.
|
| 562 |
if (!responseId) return;
|
|
|
|
| 245 |
}
|
| 246 |
}
|
| 247 |
|
| 248 |
+
/**
|
| 249 |
+
* Point the shared reaper at the current oldest bubble. This must run when an
|
| 250 |
+
* expiry moves earlier as well as later: a listening bubble starts with a
|
| 251 |
+
* long fail-safe, then gets a much shorter deadline once speech stops.
|
| 252 |
+
*/
|
| 253 |
+
_scheduleBubbleReaper() {
|
| 254 |
+
if (this._reaperHandle) clearTimeout(this._reaperHandle);
|
| 255 |
+
this._reaperHandle = 0;
|
| 256 |
+
const oldest = /** @type {HTMLElement | null} */ (
|
| 257 |
+
this._bubbleStack.querySelector(".bubble:not(.out)")
|
| 258 |
+
);
|
| 259 |
+
if (!oldest) return;
|
| 260 |
+
const expiry = this._bubbleExpiry.get(oldest) ?? Date.now();
|
| 261 |
+
this._reaperHandle = setTimeout(
|
| 262 |
+
() => this._reapBubbles(),
|
| 263 |
+
Math.max(50, expiry - Date.now()),
|
| 264 |
+
);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
/**
|
| 268 |
* (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
|
| 269 |
* Calling it again resets the countdown β so a bubble that keeps updating
|
|
|
|
| 273 |
*/
|
| 274 |
_bumpDismiss(el, delay = 4000) {
|
| 275 |
this._bubbleExpiry.set(el, Date.now() + delay);
|
| 276 |
+
this._scheduleBubbleReaper();
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/**
|
| 280 |
+
* Remove a pending voice placeholder once the assistant has visibly started
|
| 281 |
+
* answering. A transcript can arrive before audible output, while direct
|
| 282 |
+
* audio models may only trigger the ai-speaking status, so both call here.
|
| 283 |
+
*/
|
| 284 |
+
onAssistantActivity() {
|
| 285 |
+
const bubble = this._activeUserBubble;
|
| 286 |
+
if (!bubble?.isConnected || !bubble.classList.contains("voice")) return;
|
| 287 |
+
this._dismissBubble(bubble);
|
| 288 |
+
this._activeUserBubble = null;
|
| 289 |
+
this._scheduleBubbleReaper();
|
| 290 |
}
|
| 291 |
|
| 292 |
// ββ History βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 435 |
&& this._activeUserBubble?.isConnected
|
| 436 |
&& !this._activeUserBubble.classList.contains("out");
|
| 437 |
if (!reusable) {
|
| 438 |
+
if (this._activeUserBubble?.classList.contains("voice")) {
|
| 439 |
+
this._dismissBubble(this._activeUserBubble);
|
| 440 |
+
}
|
| 441 |
this._activeUserBubble = this._spawnVoiceBubble("listening");
|
| 442 |
this._activeUserItemId = id;
|
| 443 |
} else if (this._activeUserBubble.classList.contains("voice")) {
|
|
|
|
| 502 |
this._bumpDismiss(this._activeUserBubble, 6000);
|
| 503 |
this._markUnread();
|
| 504 |
} else if (d.role === "assistant") {
|
| 505 |
+
this.onAssistantActivity();
|
| 506 |
// Assistant transcript arrives once, as the full text, keyed by
|
| 507 |
// response_id so a cancelled speculative response can be removed later. A
|
| 508 |
// missing id gets a unique key so two id-less replies never collide.
|
|
|
|
| 593 |
onResponseFinished(detail) {
|
| 594 |
const { responseId, status, audible, transcript } = detail;
|
| 595 |
if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
|
| 596 |
+
this.onAssistantActivity();
|
| 597 |
// Without an id we can't target a specific response; the bubble will
|
| 598 |
// auto-dismiss on its own timer regardless.
|
| 599 |
if (!responseId) return;
|