@voicethere/client 0.16.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,734 @@
1
+ const START_FALLBACK_MS = 400;
2
+ /**
3
+ * A completed spoken turn whose audio never starts shows its full text after this wait.
4
+ * Internal: spoken turns never use START_FALLBACK_MS, they wait for `agent_speaking_start`.
5
+ */
6
+ const SPOKEN_START_FALLBACK_MS = 5000;
7
+ const DEFAULT_MS_PER_WORD = 330;
8
+ /**
9
+ * speech_event carries no turnId, and the agent sends each sentence's assistant_delta before
10
+ * its speak, so a turn's first delta trails its own audio start only by transport skew. A cut
11
+ * start (agent_speaking_start then barge_in) not claimed by a turn within this window belonged
12
+ * to an earlier turn.
13
+ */
14
+ const UNCLAIMED_CUT_MS = 1500;
15
+ const COMPLETED_IDS_MAX = 32;
16
+ function splitWords(text) {
17
+ return text.split(/\s+/).filter(Boolean);
18
+ }
19
+ function incomingAgentTextFromControlMessage(payload, options) {
20
+ const type = typeof payload.type === "string" ? payload.type : "";
21
+ if (type === "session_error" ||
22
+ type === "agent_error" ||
23
+ type === "session_close" ||
24
+ type === "session_reconnect_token") {
25
+ return null;
26
+ }
27
+ if (type === "speech_event") {
28
+ if (options.ignoreSpeechEventAgentText) {
29
+ return null;
30
+ }
31
+ const event = typeof payload.event === "string" ? payload.event : "";
32
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
33
+ if (!text)
34
+ return null;
35
+ if (event.includes("final") || event.includes("agent")) {
36
+ return text;
37
+ }
38
+ return null;
39
+ }
40
+ if (type === "chat_reply") {
41
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
42
+ return text || null;
43
+ }
44
+ if (type === "chat_broadcast") {
45
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
46
+ return text || null;
47
+ }
48
+ if (type === "chat") {
49
+ const role = typeof payload.role === "string" ? payload.role : "";
50
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
51
+ if (!text)
52
+ return null;
53
+ if (role === "agent" || role === "assistant") {
54
+ return text;
55
+ }
56
+ return null;
57
+ }
58
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
59
+ if (text &&
60
+ (type === "agent_message" || type === "message" || type === "assistant")) {
61
+ return text;
62
+ }
63
+ return null;
64
+ }
65
+ export function createSpokenChatCaption(opts) {
66
+ let enabled = opts.enabled;
67
+ let state = null;
68
+ let utteranceCounter = 0;
69
+ const completed = new Map();
70
+ const spokenTurns = new Map();
71
+ // The caption follows one utterance at a time: a new turnId drops the previous state and
72
+ // leaves its line at a prefix. A finished, unfrozen turn is settled to its full text first.
73
+ let currentSpoken = null;
74
+ // agent_speaking_start seen while no unfinished spoken turn was current: its audio belongs to
75
+ // the next turn (the start can arrive before that turn's first delta).
76
+ let unclaimedStart = false;
77
+ // A barge_in landed while that start was unclaimed: the turn that claims it is already cut
78
+ // off, so it is frozen from its first delta.
79
+ let unclaimedBargeIn = false;
80
+ let unclaimedCutTimer;
81
+ /** Single send path for every visible change; spoken turns carry `info`. */
82
+ const emit = (utteranceId, visibleText) => {
83
+ const turn = spokenTurns.get(utteranceId);
84
+ if (!turn) {
85
+ opts.onUpsert(utteranceId, visibleText);
86
+ return;
87
+ }
88
+ turn.lastEmitted = visibleText;
89
+ const final = turn.done && (turn.frozen || visibleText === turn.text);
90
+ if (final)
91
+ turn.finalReported = true;
92
+ opts.onUpsert(utteranceId, visibleText, {
93
+ final,
94
+ ...(turn.inReplyTo !== undefined ? { inReplyTo: turn.inReplyTo } : {}),
95
+ });
96
+ };
97
+ const setTimeoutFn = opts.setTimeout ?? setTimeout;
98
+ const clearTimeoutFn = opts.clearTimeout ?? clearTimeout;
99
+ const newUtteranceId = () => {
100
+ utteranceCounter += 1;
101
+ return `utterance-${utteranceCounter}`;
102
+ };
103
+ const clearUtteranceTimers = (utterance) => {
104
+ if (utterance.startTimeoutId !== undefined) {
105
+ clearTimeoutFn(utterance.startTimeoutId);
106
+ utterance.startTimeoutId = undefined;
107
+ }
108
+ if (utterance.tickTimeoutId !== undefined) {
109
+ clearTimeoutFn(utterance.tickTimeoutId);
110
+ utterance.tickTimeoutId = undefined;
111
+ }
112
+ };
113
+ const visibleTextFor = (utterance) => {
114
+ if (utterance.visibleWordCount <= 0)
115
+ return "";
116
+ return utterance.words.slice(0, utterance.visibleWordCount).join(" ");
117
+ };
118
+ const upsertCurrent = () => {
119
+ if (!state)
120
+ return;
121
+ emit(state.utteranceId, visibleTextFor(state));
122
+ };
123
+ const markCompleted = (utterance) => {
124
+ completed.delete(utterance.utteranceId);
125
+ completed.set(utterance.utteranceId, utterance.words.join(" "));
126
+ while (completed.size > COMPLETED_IDS_MAX) {
127
+ const oldest = completed.keys().next().value;
128
+ if (oldest === undefined)
129
+ break;
130
+ completed.delete(oldest);
131
+ }
132
+ };
133
+ /** A streamed utterance is done when its text is final and fully shown. */
134
+ const finishStreamedIfComplete = (utterance) => {
135
+ if (!utterance.streamed ||
136
+ !utterance.final ||
137
+ utterance.frozen ||
138
+ utterance.visibleWordCount < utterance.words.length) {
139
+ return;
140
+ }
141
+ clearUtteranceTimers(utterance);
142
+ markCompleted(utterance);
143
+ if (state === utterance) {
144
+ state = null;
145
+ }
146
+ };
147
+ const scheduleNextWord = (utterance) => {
148
+ if (utterance.frozen ||
149
+ utterance.paused ||
150
+ utterance.tickTimeoutId !== undefined ||
151
+ utterance.visibleWordCount >= utterance.words.length) {
152
+ return;
153
+ }
154
+ utterance.tickTimeoutId = setTimeoutFn(() => {
155
+ utterance.tickTimeoutId = undefined;
156
+ if (!state || state !== utterance || utterance.frozen)
157
+ return;
158
+ if (utterance.paused)
159
+ return;
160
+ utterance.visibleWordCount += 1;
161
+ upsertCurrent();
162
+ scheduleNextWord(utterance);
163
+ finishStreamedIfComplete(utterance);
164
+ }, utterance.intervalMs);
165
+ };
166
+ const startReveal = (utterance) => {
167
+ if (utterance.revealStarted || utterance.frozen)
168
+ return;
169
+ utterance.revealStarted = true;
170
+ utterance.waitingForStart = false;
171
+ if (utterance.startTimeoutId !== undefined) {
172
+ clearTimeoutFn(utterance.startTimeoutId);
173
+ utterance.startTimeoutId = undefined;
174
+ }
175
+ if (utterance.words.length === 0) {
176
+ upsertCurrent();
177
+ return;
178
+ }
179
+ if (utterance.visibleWordCount === 0) {
180
+ utterance.visibleWordCount = 1;
181
+ }
182
+ upsertCurrent();
183
+ if (utterance.visibleWordCount < utterance.words.length) {
184
+ scheduleNextWord(utterance);
185
+ }
186
+ finishStreamedIfComplete(utterance);
187
+ };
188
+ const snapToFull = (utterance) => {
189
+ if (utterance.frozen)
190
+ return;
191
+ clearUtteranceTimers(utterance);
192
+ utterance.revealStarted = true;
193
+ utterance.waitingForStart = false;
194
+ utterance.visibleWordCount = utterance.words.length;
195
+ emit(utterance.utteranceId, utterance.words.join(" "));
196
+ if (state === utterance) {
197
+ state = null;
198
+ }
199
+ };
200
+ const resetUtteranceState = () => {
201
+ if (state) {
202
+ clearUtteranceTimers(state);
203
+ state = null;
204
+ }
205
+ };
206
+ const updateStreamed = (utterance, text, partial) => {
207
+ if (utterance.frozen)
208
+ return;
209
+ const words = splitWords(text);
210
+ const isPrefix = utterance.words.length <= words.length &&
211
+ utterance.words.every((word, i) => word === words[i]);
212
+ utterance.words = words;
213
+ if (!partial) {
214
+ utterance.final = true;
215
+ // Once final, the line is never paused: assistant_done can arrive after
216
+ // the last chunk's agent_speaking_end.
217
+ utterance.paused = false;
218
+ }
219
+ if (!isPrefix) {
220
+ utterance.visibleWordCount = Math.min(utterance.visibleWordCount, words.length);
221
+ if (utterance.revealStarted)
222
+ upsertCurrent();
223
+ }
224
+ if (utterance.revealStarted) {
225
+ scheduleNextWord(utterance);
226
+ }
227
+ finishStreamedIfComplete(utterance);
228
+ };
229
+ const handleChatReply = (payload, spoken = false) => {
230
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
231
+ if (!text)
232
+ return;
233
+ const utteranceId = typeof payload.utteranceId === "string" && payload.utteranceId.trim()
234
+ ? payload.utteranceId.trim()
235
+ : newUtteranceId();
236
+ const durationMs = typeof payload.durationMs === "number" && payload.durationMs > 0
237
+ ? payload.durationMs
238
+ : undefined;
239
+ const streamExplicit = payload.stream === true;
240
+ const partial = payload.partial === true;
241
+ if (!enabled) {
242
+ resetUtteranceState();
243
+ emit(utteranceId, text);
244
+ return;
245
+ }
246
+ if (!spoken && opts.paceChatReply === false) {
247
+ // Shown at once; a spoken turn's reveal in progress is left alone.
248
+ emit(utteranceId, text);
249
+ return;
250
+ }
251
+ if (state && state.utteranceId === utteranceId && state.streamed) {
252
+ updateStreamed(state, text, partial);
253
+ return;
254
+ }
255
+ if (completed.get(utteranceId) === splitWords(text).join(" ")) {
256
+ return;
257
+ }
258
+ if (state &&
259
+ state.utteranceId !== utteranceId &&
260
+ state.revealStarted &&
261
+ !state.frozen &&
262
+ state.visibleWordCount < state.words.length) {
263
+ clearUtteranceTimers(state);
264
+ state = null;
265
+ }
266
+ else if (state && state.utteranceId !== utteranceId) {
267
+ resetUtteranceState();
268
+ }
269
+ else if (state && state.utteranceId === utteranceId) {
270
+ clearUtteranceTimers(state);
271
+ }
272
+ const words = splitWords(text);
273
+ const wordCount = Math.max(words.length, 1);
274
+ const intervalMs = durationMs !== undefined ? durationMs / wordCount : DEFAULT_MS_PER_WORD;
275
+ state = {
276
+ utteranceId,
277
+ words,
278
+ visibleWordCount: 0,
279
+ intervalMs,
280
+ frozen: false,
281
+ revealStarted: false,
282
+ waitingForStart: true,
283
+ streamExplicit,
284
+ streamed: partial,
285
+ final: !partial,
286
+ paused: false,
287
+ };
288
+ // A spoken turn is never shown before its audio starts.
289
+ if (spoken)
290
+ return;
291
+ state.startTimeoutId = setTimeoutFn(() => {
292
+ if (!state?.waitingForStart || state.revealStarted)
293
+ return;
294
+ if (state.streamExplicit || state.streamed) {
295
+ startReveal(state);
296
+ }
297
+ else {
298
+ snapToFull(state);
299
+ }
300
+ }, START_FALLBACK_MS);
301
+ };
302
+ const handleSpeechEvent = (payload) => {
303
+ const event = typeof payload.event === "string" ? payload.event : "";
304
+ if (event === "agent_speaking_start") {
305
+ if (state?.streamed && !state.frozen) {
306
+ state.paused = false;
307
+ if (!state.revealStarted) {
308
+ startReveal(state);
309
+ }
310
+ else {
311
+ scheduleNextWord(state);
312
+ }
313
+ return;
314
+ }
315
+ if (state && (state.waitingForStart || !state.revealStarted)) {
316
+ startReveal(state);
317
+ }
318
+ return;
319
+ }
320
+ if (event === "agent_speaking_end") {
321
+ if (state?.streamed) {
322
+ if (state.final)
323
+ return;
324
+ state.paused = true;
325
+ if (state.tickTimeoutId !== undefined) {
326
+ clearTimeoutFn(state.tickTimeoutId);
327
+ state.tickTimeoutId = undefined;
328
+ }
329
+ return;
330
+ }
331
+ if (state) {
332
+ clearUtteranceTimers(state);
333
+ state.visibleWordCount = state.words.length;
334
+ state.waitingForStart = false;
335
+ upsertCurrent();
336
+ state = null;
337
+ }
338
+ return;
339
+ }
340
+ if (event === "barge_in") {
341
+ if (currentSpoken)
342
+ currentSpoken.frozen = true;
343
+ if (state) {
344
+ clearUtteranceTimers(state);
345
+ state.frozen = true;
346
+ state.waitingForStart = false;
347
+ }
348
+ }
349
+ };
350
+ const clearUnclaimedCutTimer = () => {
351
+ if (unclaimedCutTimer === undefined)
352
+ return;
353
+ clearTimeoutFn(unclaimedCutTimer);
354
+ unclaimedCutTimer = undefined;
355
+ };
356
+ /** A turn whose audio started never ends without a line: show its first sentence. */
357
+ const commitRevealed = (turn) => {
358
+ if (turn.lastEmitted || !turn.audioStarted || !turn.firstChunk)
359
+ return;
360
+ emit(turn.turnId, turn.firstChunk);
361
+ };
362
+ /**
363
+ * A claimed start (it came before this turn's first delta) begins pacing after a 400 ms grace,
364
+ * so a barge-in inside it still shows the first sentence.
365
+ */
366
+ const scheduleClaimedStart = (turn) => {
367
+ const pending = state;
368
+ if (!turn.audioStarted ||
369
+ turn.frozen ||
370
+ !pending ||
371
+ pending.utteranceId !== turn.turnId ||
372
+ pending.revealStarted ||
373
+ pending.frozen ||
374
+ pending.startTimeoutId !== undefined) {
375
+ return;
376
+ }
377
+ pending.startTimeoutId = setTimeoutFn(() => {
378
+ pending.startTimeoutId = undefined;
379
+ if (state !== pending || pending.revealStarted || pending.frozen)
380
+ return;
381
+ startReveal(pending);
382
+ }, START_FALLBACK_MS);
383
+ };
384
+ /** Spoken-turn bookkeeping for speech events; the caption's own pacing runs separately. */
385
+ const trackSpokenSpeechEvent = (event) => {
386
+ if (event === "agent_speaking_start") {
387
+ const current = currentSpoken;
388
+ const waitingForStart = current !== null &&
389
+ state !== null &&
390
+ state.utteranceId === current.turnId &&
391
+ !state.revealStarted &&
392
+ !state.frozen;
393
+ if (current && (!current.done || waitingForStart)) {
394
+ current.audioStarted = true;
395
+ }
396
+ else {
397
+ clearUnclaimedCutTimer();
398
+ unclaimedBargeIn = false;
399
+ unclaimedStart = true;
400
+ }
401
+ return;
402
+ }
403
+ if (event === "agent_speaking_end") {
404
+ // An end right after a barge_in is the cut-off playback of the same, unclaimed turn.
405
+ if (!unclaimedBargeIn)
406
+ unclaimedStart = false;
407
+ return;
408
+ }
409
+ if (event === "barge_in") {
410
+ if (currentSpoken)
411
+ commitRevealed(currentSpoken);
412
+ if (unclaimedStart) {
413
+ unclaimedBargeIn = true;
414
+ clearUnclaimedCutTimer();
415
+ unclaimedCutTimer = setTimeoutFn(() => {
416
+ unclaimedCutTimer = undefined;
417
+ unclaimedStart = false;
418
+ unclaimedBargeIn = false;
419
+ }, UNCLAIMED_CUT_MS);
420
+ }
421
+ }
422
+ };
423
+ /** Makes `turn` the current spoken turn; it claims an unclaimed audio start. */
424
+ const claimSpoken = (turn) => {
425
+ if (currentSpoken !== turn) {
426
+ currentSpoken = turn;
427
+ if (unclaimedStart) {
428
+ turn.audioStarted = true;
429
+ if (unclaimedBargeIn)
430
+ turn.frozen = true;
431
+ }
432
+ }
433
+ unclaimedStart = false;
434
+ unclaimedBargeIn = false;
435
+ clearUnclaimedCutTimer();
436
+ };
437
+ const settleOtherSpoken = (turnId) => {
438
+ const previous = currentSpoken;
439
+ if (!previous || previous.turnId === turnId)
440
+ return;
441
+ if (previous.done && !previous.frozen) {
442
+ if (previous.lastEmitted !== previous.text) {
443
+ emit(previous.turnId, previous.text);
444
+ }
445
+ }
446
+ else {
447
+ commitRevealed(previous);
448
+ }
449
+ currentSpoken = null;
450
+ };
451
+ /** Spoken `assistant_delta` / `assistant_done`, paired by `turnId` only. */
452
+ const handleSpokenAssistant = (type, payload, turnId) => {
453
+ let turn = spokenTurns.get(turnId);
454
+ const isNew = !turn;
455
+ if (!turn) {
456
+ turn = {
457
+ turnId,
458
+ text: "",
459
+ done: false,
460
+ frozen: false,
461
+ lastEmitted: "",
462
+ finalReported: false,
463
+ firstChunk: "",
464
+ audioStarted: false,
465
+ };
466
+ spokenTurns.set(turnId, turn);
467
+ while (spokenTurns.size > COMPLETED_IDS_MAX) {
468
+ const oldest = spokenTurns.keys().next().value;
469
+ if (oldest === undefined || oldest === turnId)
470
+ break;
471
+ spokenTurns.delete(oldest);
472
+ }
473
+ }
474
+ if (typeof payload.inReplyTo === "string" && payload.inReplyTo) {
475
+ turn.inReplyTo = payload.inReplyTo;
476
+ }
477
+ const chunk = typeof payload.text === "string" ? payload.text.trim() : "";
478
+ // A message for an earlier turn only settles its own line; the current reveal is untouched.
479
+ if (enabled && !isNew && currentSpoken?.turnId !== turnId) {
480
+ if (type === "assistant_delta") {
481
+ if (chunk && !turn.done) {
482
+ turn.text = turn.text ? `${turn.text} ${chunk}` : chunk;
483
+ }
484
+ return;
485
+ }
486
+ const lateStatus = typeof payload.status === "string" ? payload.status : "";
487
+ if (lateStatus === "interrupted" ||
488
+ lateStatus === "cancelled" ||
489
+ lateStatus === "failed") {
490
+ turn.frozen = true;
491
+ }
492
+ if (chunk)
493
+ turn.text = chunk;
494
+ turn.done = true;
495
+ const shown = turn.frozen ? turn.lastEmitted : turn.text;
496
+ if (shown && !(turn.finalReported && turn.lastEmitted === shown)) {
497
+ emit(turnId, shown);
498
+ }
499
+ return;
500
+ }
501
+ if (type === "assistant_delta") {
502
+ if (!chunk || turn.done)
503
+ return;
504
+ turn.text = turn.text ? `${turn.text} ${chunk}` : chunk;
505
+ if (!enabled) {
506
+ emit(turnId, turn.text);
507
+ return;
508
+ }
509
+ settleOtherSpoken(turnId);
510
+ claimSpoken(turn);
511
+ if (!turn.firstChunk)
512
+ turn.firstChunk = chunk;
513
+ if (turn.frozen) {
514
+ // Cut off before this delta: only the first sentence is shown.
515
+ commitRevealed(turn);
516
+ return;
517
+ }
518
+ handleChatReply({
519
+ type: "chat_reply",
520
+ utteranceId: turnId,
521
+ stream: true,
522
+ partial: true,
523
+ text: turn.text,
524
+ }, true);
525
+ scheduleClaimedStart(turn);
526
+ return;
527
+ }
528
+ const status = typeof payload.status === "string" ? payload.status : "";
529
+ const stopped = status === "interrupted" || status === "cancelled" || status === "failed";
530
+ const text = chunk || turn.text;
531
+ if (text)
532
+ turn.text = text;
533
+ turn.done = true;
534
+ if (!enabled) {
535
+ if (turn.text)
536
+ emit(turnId, turn.text);
537
+ return;
538
+ }
539
+ settleOtherSpoken(turnId);
540
+ claimSpoken(turn);
541
+ if (!turn.firstChunk)
542
+ turn.firstChunk = turn.text;
543
+ const wasFrozen = turn.frozen;
544
+ if (stopped)
545
+ turn.frozen = true;
546
+ if (turn.text && !wasFrozen) {
547
+ handleChatReply({
548
+ type: "chat_reply",
549
+ utteranceId: turnId,
550
+ stream: true,
551
+ text: turn.text,
552
+ }, true);
553
+ }
554
+ if (stopped)
555
+ handleSpeechEvent({ event: "barge_in" });
556
+ if (turn.frozen)
557
+ commitRevealed(turn);
558
+ const pending = state;
559
+ if (!stopped &&
560
+ pending &&
561
+ pending.utteranceId === turnId &&
562
+ !pending.revealStarted &&
563
+ !pending.frozen &&
564
+ !turn.audioStarted) {
565
+ // Audio that never starts must not hide a completed reply forever.
566
+ clearUtteranceTimers(pending);
567
+ pending.startTimeoutId = setTimeoutFn(() => {
568
+ pending.startTimeoutId = undefined;
569
+ if (state !== pending || pending.revealStarted || pending.frozen)
570
+ return;
571
+ snapToFull(pending);
572
+ }, SPOKEN_START_FALLBACK_MS);
573
+ }
574
+ if (!stopped)
575
+ scheduleClaimedStart(turn);
576
+ // Nothing visible changed since the last emit but the turn is now final.
577
+ if (!turn.finalReported &&
578
+ turn.lastEmitted &&
579
+ (turn.frozen || turn.lastEmitted === turn.text)) {
580
+ emit(turnId, turn.lastEmitted);
581
+ }
582
+ };
583
+ const handleControlMessage = (payload) => {
584
+ const type = typeof payload.type === "string" ? payload.type : "";
585
+ if (payload.spoken === true &&
586
+ (type === "assistant_delta" || type === "assistant_done") &&
587
+ typeof payload.turnId === "string" &&
588
+ payload.turnId) {
589
+ handleSpokenAssistant(type, payload, payload.turnId);
590
+ return;
591
+ }
592
+ if (type === "chat_reply") {
593
+ handleChatReply(payload);
594
+ return;
595
+ }
596
+ if (type === "speech_event") {
597
+ if (enabled) {
598
+ const event = typeof payload.event === "string" ? payload.event : "";
599
+ if (event !== "barge_in")
600
+ trackSpokenSpeechEvent(event);
601
+ handleSpeechEvent(payload);
602
+ if (event === "barge_in")
603
+ trackSpokenSpeechEvent(event);
604
+ return;
605
+ }
606
+ }
607
+ const incoming = incomingAgentTextFromControlMessage(payload, {
608
+ ignoreSpeechEventAgentText: enabled,
609
+ });
610
+ if (incoming) {
611
+ resetUtteranceState();
612
+ emit(newUtteranceId(), incoming);
613
+ }
614
+ };
615
+ const dispose = () => {
616
+ // Disconnect or reconnect: a finished turn still pacing shows its full text, and a turn
617
+ // whose audio started keeps at least its first sentence.
618
+ const current = currentSpoken;
619
+ if (current && !current.frozen) {
620
+ if (current.done) {
621
+ if (current.lastEmitted !== current.text) {
622
+ emit(current.turnId, current.text);
623
+ }
624
+ }
625
+ else {
626
+ commitRevealed(current);
627
+ }
628
+ }
629
+ resetUtteranceState();
630
+ spokenTurns.clear();
631
+ currentSpoken = null;
632
+ unclaimedStart = false;
633
+ unclaimedBargeIn = false;
634
+ clearUnclaimedCutTimer();
635
+ };
636
+ return {
637
+ handleControlMessage,
638
+ setEnabled: (next) => {
639
+ enabled = next;
640
+ if (!enabled) {
641
+ dispose();
642
+ }
643
+ },
644
+ dispose,
645
+ };
646
+ }
647
+ const DEFAULT_SPOKEN_TEXT_REVEAL = "playback";
648
+ /**
649
+ * Maps the agent's `assistant_delta` / `assistant_done` messages onto `onAgentText`, one call per
650
+ * visible change. Spoken turns go through {@link createSpokenChatCaption} (paced when `mode` is
651
+ * `playback`); typed turns are passed through at once. The `chat_reply` that follows a typed
652
+ * `assistant_done` carries the same text and is never reported.
653
+ */
654
+ export function createAgentTextReveal(opts) {
655
+ const playback = (opts.mode ?? DEFAULT_SPOKEN_TEXT_REVEAL) === "playback";
656
+ // A customer callback that throws must not break the control path or a timer tick.
657
+ const report = (update) => {
658
+ try {
659
+ opts.onAgentText(update);
660
+ }
661
+ catch {
662
+ /* customer callback error */
663
+ }
664
+ };
665
+ const caption = createSpokenChatCaption({
666
+ enabled: playback,
667
+ onUpsert: (turnId, text, info) => {
668
+ if (!info)
669
+ return;
670
+ report({
671
+ turnId,
672
+ text,
673
+ spoken: true,
674
+ final: info.final,
675
+ ...(info.inReplyTo !== undefined ? { inReplyTo: info.inReplyTo } : {}),
676
+ });
677
+ },
678
+ setTimeout: opts.setTimeout,
679
+ clearTimeout: opts.clearTimeout,
680
+ });
681
+ const typed = new Map();
682
+ const handleTyped = (type, payload, turnId) => {
683
+ const chunk = typeof payload.text === "string" ? payload.text.trim() : "";
684
+ const known = typed.get(turnId) ?? "";
685
+ const inReplyTo = typeof payload.inReplyTo === "string" && payload.inReplyTo
686
+ ? { inReplyTo: payload.inReplyTo }
687
+ : {};
688
+ if (type === "assistant_delta") {
689
+ if (!chunk)
690
+ return;
691
+ const text = known ? `${known} ${chunk}` : chunk;
692
+ typed.set(turnId, text);
693
+ report({
694
+ turnId,
695
+ text,
696
+ spoken: false,
697
+ final: false,
698
+ ...inReplyTo,
699
+ });
700
+ return;
701
+ }
702
+ const text = chunk || known;
703
+ typed.delete(turnId);
704
+ if (!text)
705
+ return;
706
+ report({ turnId, text, spoken: false, final: true, ...inReplyTo });
707
+ };
708
+ return {
709
+ handleControlMessage(payload) {
710
+ const type = typeof payload.type === "string" ? payload.type : "";
711
+ if (type === "speech_event") {
712
+ // Speech events pace the reveal; without playback there is nothing to pace.
713
+ if (playback)
714
+ caption.handleControlMessage(payload);
715
+ return;
716
+ }
717
+ if (type !== "assistant_delta" && type !== "assistant_done")
718
+ return;
719
+ const turnId = typeof payload.turnId === "string" ? payload.turnId : "";
720
+ if (!turnId)
721
+ return;
722
+ if (payload.spoken === true) {
723
+ caption.handleControlMessage(payload);
724
+ return;
725
+ }
726
+ handleTyped(type, payload, turnId);
727
+ },
728
+ dispose() {
729
+ caption.dispose();
730
+ typed.clear();
731
+ },
732
+ };
733
+ }
734
+ //# sourceMappingURL=spoken-text-reveal.js.map