@smooai/chat-widget 0.12.0 → 0.14.0

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.
package/dist/index.js CHANGED
@@ -301,6 +301,340 @@ function cleanCitationSnippet(raw) {
301
301
  return s;
302
302
  }
303
303
  //#endregion
304
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
305
+ function _typeof(o) {
306
+ "@babel/helpers - typeof";
307
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
308
+ return typeof o;
309
+ } : function(o) {
310
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
311
+ }, _typeof(o);
312
+ }
313
+ //#endregion
314
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
315
+ function toPrimitive(t, r) {
316
+ if ("object" != _typeof(t) || !t) return t;
317
+ var e = t[Symbol.toPrimitive];
318
+ if (void 0 !== e) {
319
+ var i = e.call(t, r || "default");
320
+ if ("object" != _typeof(i)) return i;
321
+ throw new TypeError("@@toPrimitive must return a primitive value.");
322
+ }
323
+ return ("string" === r ? String : Number)(t);
324
+ }
325
+ //#endregion
326
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
327
+ function toPropertyKey(t) {
328
+ var i = toPrimitive(t, "string");
329
+ return "symbol" == _typeof(i) ? i : i + "";
330
+ }
331
+ //#endregion
332
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
333
+ function _defineProperty(e, r, t) {
334
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
335
+ value: t,
336
+ enumerable: !0,
337
+ configurable: !0,
338
+ writable: !0
339
+ }) : e[r] = t, e;
340
+ }
341
+ //#endregion
342
+ //#region src/voice-session.ts
343
+ /**
344
+ * VoiceSession — framework-free browser voice for the chat widget.
345
+ *
346
+ * Speaks the FROZEN browser-voice WebSocket protocol (SMOODEV-2534 / ADR-084):
347
+ *
348
+ * client → server:
349
+ * - first frame, JSON: `{"type":"start","agent_id":"…","conversation_id":"…?","token":"…?"}`
350
+ * (public-agent auth is the `Origin` header the browser sends automatically;
351
+ * `token` only for authenticated contexts)
352
+ * - binary frames: raw PCM linear16 mono @ 16 kHz mic chunks
353
+ * - JSON `{"type":"interrupt"}` (user barged in), `{"type":"stop"}`
354
+ * server → client:
355
+ * - JSON `transcript_partial` / `transcript_final` / `reply_text` /
356
+ * `speaking_started` / `speaking_done` / `handoff` / `error {code}`
357
+ * - binary frames: PCM linear16 mono @ 16 kHz TTS audio chunks
358
+ *
359
+ * The class owns: the WS lifecycle, mic capture (getUserMedia → AudioWorklet or
360
+ * ScriptProcessor fallback → downsample to 16 kHz mono Int16 → binary frames),
361
+ * gapless playback of incoming PCM through a 16 kHz AudioContext, and barge-in
362
+ * (RMS speech detection while the agent is speaking → `interrupt` + playback
363
+ * flush). Browser audio + WebSocket are thin injectable seams so unit tests run
364
+ * under jsdom/happy-dom with no real audio.
365
+ */
366
+ const DEFAULT_VOICE_URL = "wss://twilio-voice.smoo.ai/browser-voice/ws";
367
+ /** Target wire format: PCM linear16 mono @ 16 kHz. */
368
+ const VOICE_SAMPLE_RATE = 16e3;
369
+ /**
370
+ * Downsample Float32 mic samples at `inputRate` to 16 kHz mono Int16 (linear16).
371
+ * Bucket-averaging decimation: each output sample averages its source bucket,
372
+ * which is a cheap low-pass that's plenty for speech (and handles non-integer
373
+ * ratios like 44.1k → 16k). Values are clamped to the Int16 range.
374
+ */
375
+ function downsampleTo16k(samples, inputRate) {
376
+ const ratio = inputRate / VOICE_SAMPLE_RATE;
377
+ const outLen = ratio <= 1 ? samples.length : Math.floor(samples.length / ratio);
378
+ const out = new Int16Array(outLen);
379
+ for (let i = 0; i < outLen; i++) {
380
+ let v;
381
+ if (ratio <= 1) v = samples[i];
382
+ else {
383
+ const start = Math.floor(i * ratio);
384
+ const end = Math.min(samples.length, Math.max(start + 1, Math.floor((i + 1) * ratio)));
385
+ let sum = 0;
386
+ for (let j = start; j < end; j++) sum += samples[j];
387
+ v = sum / (end - start);
388
+ }
389
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(v * 32767)));
390
+ }
391
+ return out;
392
+ }
393
+ /** Root-mean-square level of a Float32 frame (0..1) — the barge-in speech gate. */
394
+ function rmsLevel(samples) {
395
+ if (samples.length === 0) return 0;
396
+ let sum = 0;
397
+ for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
398
+ return Math.sqrt(sum / samples.length);
399
+ }
400
+ /**
401
+ * Gapless PCM playback: each incoming linear16 chunk becomes an AudioBuffer
402
+ * scheduled back-to-back (`nextTime`) so consecutive chunks butt together with
403
+ * no gaps. `flush()` stops every scheduled source (barge-in / mic-button stop).
404
+ */
405
+ var PcmPlayer = class {
406
+ constructor(ctx) {
407
+ this.ctx = ctx;
408
+ _defineProperty(this, "nextTime", 0);
409
+ _defineProperty(this, "live", /* @__PURE__ */ new Set());
410
+ }
411
+ enqueue(chunk) {
412
+ const int16 = new Int16Array(chunk);
413
+ if (int16.length === 0) return;
414
+ const buf = this.ctx.createBuffer(1, int16.length, VOICE_SAMPLE_RATE);
415
+ const ch = buf.getChannelData(0);
416
+ for (let i = 0; i < int16.length; i++) ch[i] = int16[i] / 32768;
417
+ const src = this.ctx.createBufferSource();
418
+ src.buffer = buf;
419
+ src.connect(this.ctx.destination);
420
+ const start = Math.max(this.ctx.currentTime, this.nextTime);
421
+ src.start(start);
422
+ this.nextTime = start + int16.length / VOICE_SAMPLE_RATE;
423
+ this.live.add(src);
424
+ src.onended = () => this.live.delete(src);
425
+ }
426
+ flush() {
427
+ for (const src of this.live) try {
428
+ src.stop();
429
+ } catch {}
430
+ this.live.clear();
431
+ this.nextTime = 0;
432
+ }
433
+ close() {
434
+ this.flush();
435
+ this.ctx.close?.();
436
+ }
437
+ };
438
+ /** AudioWorklet processor source — posts each 128-sample mic block to the main thread. */
439
+ const CAPTURE_WORKLET_SRC = `
440
+ registerProcessor('sac-mic-capture', class extends AudioWorkletProcessor {
441
+ process(inputs) {
442
+ const ch = inputs[0] && inputs[0][0];
443
+ if (ch && ch.length > 0) {
444
+ const copy = new Float32Array(ch);
445
+ this.port.postMessage(copy.buffer, [copy.buffer]);
446
+ }
447
+ return true;
448
+ }
449
+ });
450
+ `;
451
+ /** Real mic capture: getUserMedia → AudioWorklet (or ScriptProcessor fallback). */
452
+ const defaultStartCapture = async (onFrame) => {
453
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: {
454
+ channelCount: 1,
455
+ echoCancellation: true,
456
+ noiseSuppression: true,
457
+ autoGainControl: true
458
+ } });
459
+ const Ctx = globalThis.AudioContext;
460
+ if (!Ctx) {
461
+ stream.getTracks().forEach((t) => t.stop());
462
+ throw new Error("AudioContext is not available");
463
+ }
464
+ const ctx = new Ctx();
465
+ const sampleRate = ctx.sampleRate;
466
+ const source = ctx.createMediaStreamSource(stream);
467
+ let disconnectNode;
468
+ if (ctx.audioWorklet && typeof AudioWorkletNode === "function") {
469
+ const url = URL.createObjectURL(new Blob([CAPTURE_WORKLET_SRC], { type: "text/javascript" }));
470
+ try {
471
+ await ctx.audioWorklet.addModule(url);
472
+ } finally {
473
+ URL.revokeObjectURL(url);
474
+ }
475
+ const node = new AudioWorkletNode(ctx, "sac-mic-capture");
476
+ node.port.onmessage = (ev) => onFrame(new Float32Array(ev.data), sampleRate);
477
+ source.connect(node);
478
+ disconnectNode = () => node.disconnect();
479
+ } else {
480
+ const node = ctx.createScriptProcessor(4096, 1, 1);
481
+ node.onaudioprocess = (ev) => onFrame(new Float32Array(ev.inputBuffer.getChannelData(0)), sampleRate);
482
+ source.connect(node);
483
+ node.connect(ctx.destination);
484
+ disconnectNode = () => node.disconnect();
485
+ }
486
+ return () => {
487
+ disconnectNode();
488
+ source.disconnect();
489
+ stream.getTracks().forEach((t) => t.stop());
490
+ ctx.close();
491
+ };
492
+ };
493
+ const defaultCreatePlayer = () => {
494
+ const Ctx = globalThis.AudioContext;
495
+ if (!Ctx) throw new Error("AudioContext is not available");
496
+ return new PcmPlayer(new Ctx({ sampleRate: VOICE_SAMPLE_RATE }));
497
+ };
498
+ var VoiceSession = class {
499
+ constructor(opts, events = {}) {
500
+ _defineProperty(this, "opts", void 0);
501
+ _defineProperty(this, "events", void 0);
502
+ _defineProperty(this, "ws", null);
503
+ _defineProperty(this, "player", null);
504
+ _defineProperty(this, "stopCapture", null);
505
+ _defineProperty(this, "speaking", false);
506
+ _defineProperty(this, "ended", false);
507
+ _defineProperty(this, "state", "idle");
508
+ this.opts = opts;
509
+ this.events = events;
510
+ }
511
+ /** Open the WS, send the start frame, and begin streaming mic audio. */
512
+ async start() {
513
+ if (this.state !== "idle") return;
514
+ this.state = "connecting";
515
+ const url = this.opts.url ?? "wss://twilio-voice.smoo.ai/browser-voice/ws";
516
+ const createWs = this.opts.seams?.createWebSocket ?? ((u) => new WebSocket(u));
517
+ const startCapture = this.opts.seams?.startCapture ?? defaultStartCapture;
518
+ const createPlayer = this.opts.seams?.createPlayer ?? defaultCreatePlayer;
519
+ this.stopCapture = await startCapture((samples, rate) => this.handleMicFrame(samples, rate));
520
+ try {
521
+ this.player = createPlayer();
522
+ const ws = createWs(url);
523
+ ws.binaryType = "arraybuffer";
524
+ this.ws = ws;
525
+ ws.addEventListener("open", () => {
526
+ const start = {
527
+ type: "start",
528
+ agent_id: this.opts.agentId
529
+ };
530
+ if (this.opts.conversationId) start.conversation_id = this.opts.conversationId;
531
+ if (this.opts.token) start.token = this.opts.token;
532
+ ws.send(JSON.stringify(start));
533
+ this.state = "active";
534
+ });
535
+ ws.addEventListener("message", (ev) => this.handleServerFrame(ev.data));
536
+ ws.addEventListener("close", () => this.teardown());
537
+ ws.addEventListener("error", () => {
538
+ this.events.onError?.("connection_error");
539
+ this.teardown();
540
+ });
541
+ } catch (err) {
542
+ this.teardown();
543
+ throw err;
544
+ }
545
+ }
546
+ /** One raw mic frame: barge-in check, then downsample → binary frame. */
547
+ handleMicFrame(samples, sampleRate) {
548
+ const ws = this.ws;
549
+ if (!ws || ws.readyState !== 1 || this.state !== "active") return;
550
+ if (this.speaking && rmsLevel(samples) > (this.opts.bargeInThreshold ?? .02)) this.interrupt();
551
+ const pcm = downsampleTo16k(samples, sampleRate);
552
+ ws.send(pcm.buffer);
553
+ }
554
+ /** Route a server frame: binary = TTS audio, string = JSON control event. */
555
+ handleServerFrame(data) {
556
+ if (typeof data !== "string") {
557
+ this.player?.enqueue(data);
558
+ return;
559
+ }
560
+ let msg;
561
+ try {
562
+ msg = JSON.parse(data);
563
+ } catch {
564
+ return;
565
+ }
566
+ switch (msg.type) {
567
+ case "transcript_partial":
568
+ this.events.onTranscriptPartial?.(msg.text ?? "");
569
+ break;
570
+ case "transcript_final":
571
+ this.events.onTranscriptFinal?.(msg.text ?? "");
572
+ break;
573
+ case "reply_text":
574
+ this.events.onReplyText?.(msg.text ?? "");
575
+ break;
576
+ case "speaking_started":
577
+ this.setSpeaking(true);
578
+ break;
579
+ case "speaking_done":
580
+ this.setSpeaking(false);
581
+ break;
582
+ case "handoff":
583
+ this.stop();
584
+ break;
585
+ case "error":
586
+ this.events.onError?.(msg.code ?? "unknown");
587
+ this.stop();
588
+ break;
589
+ default: break;
590
+ }
591
+ }
592
+ setSpeaking(speaking) {
593
+ if (this.speaking === speaking) return;
594
+ this.speaking = speaking;
595
+ this.events.onSpeaking?.(speaking);
596
+ }
597
+ /** True while agent TTS is playing (between speaking_started/done). */
598
+ get isSpeaking() {
599
+ return this.speaking;
600
+ }
601
+ /**
602
+ * Barge in: tell the server the user interrupted and flush queued TTS so the
603
+ * agent goes silent immediately. Called automatically on mic speech during
604
+ * playback; the widget also calls it when the visitor hits the mic button
605
+ * mid-playback.
606
+ */
607
+ interrupt() {
608
+ if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: "interrupt" }));
609
+ this.player?.flush();
610
+ this.setSpeaking(false);
611
+ }
612
+ /** Graceful end: send `stop`, then tear everything down. */
613
+ stop() {
614
+ if (this.ws && this.ws.readyState === 1) try {
615
+ this.ws.send(JSON.stringify({ type: "stop" }));
616
+ } catch {}
617
+ this.teardown();
618
+ }
619
+ /** Idempotent teardown: capture, playback, socket, `ended` event. */
620
+ teardown() {
621
+ if (this.ended) return;
622
+ this.ended = true;
623
+ this.state = "ended";
624
+ this.stopCapture?.();
625
+ this.stopCapture = null;
626
+ this.player?.close();
627
+ this.player = null;
628
+ const ws = this.ws;
629
+ this.ws = null;
630
+ if (ws && ws.readyState <= 1) try {
631
+ ws.close(1e3, "voice ended");
632
+ } catch {}
633
+ this.setSpeaking(false);
634
+ this.events.onEnded?.();
635
+ }
636
+ };
637
+ //#endregion
304
638
  //#region src/config.ts
305
639
  /**
306
640
  * Public configuration surface for the chat widget.
@@ -342,6 +676,11 @@ function resolveConfig(config) {
342
676
  collectConsent: config.collectConsent ?? true,
343
677
  allowChatRestore: config.allowChatRestore ?? true,
344
678
  allowAnonymous: config.allowAnonymous ?? false,
679
+ showToolActivity: config.showToolActivity ?? false,
680
+ voice: {
681
+ enabled: config.voice?.enabled ?? false,
682
+ url: config.voice?.url ?? "wss://twilio-voice.smoo.ai/browser-voice/ws"
683
+ },
345
684
  theme: {
346
685
  text: theme.text ?? "#f8fafc",
347
686
  background: theme.background ?? "#040d30",
@@ -651,44 +990,6 @@ function createWidgetStore(agentId) {
651
990
  }));
652
991
  }
653
992
  //#endregion
654
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
655
- function _typeof(o) {
656
- "@babel/helpers - typeof";
657
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
658
- return typeof o;
659
- } : function(o) {
660
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
661
- }, _typeof(o);
662
- }
663
- //#endregion
664
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
665
- function toPrimitive(t, r) {
666
- if ("object" != _typeof(t) || !t) return t;
667
- var e = t[Symbol.toPrimitive];
668
- if (void 0 !== e) {
669
- var i = e.call(t, r || "default");
670
- if ("object" != _typeof(i)) return i;
671
- throw new TypeError("@@toPrimitive must return a primitive value.");
672
- }
673
- return ("string" === r ? String : Number)(t);
674
- }
675
- //#endregion
676
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
677
- function toPropertyKey(t) {
678
- var i = toPrimitive(t, "string");
679
- return "symbol" == _typeof(i) ? i : i + "";
680
- }
681
- //#endregion
682
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
683
- function _defineProperty(e, r, t) {
684
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
685
- value: t,
686
- enumerable: !0,
687
- configurable: !0,
688
- writable: !0
689
- }) : e[r] = t, e;
690
- }
691
- //#endregion
692
993
  //#region src/conversation.ts
693
994
  /**
694
995
  * ConversationController — the bridge between the widget UI and the
@@ -807,6 +1108,59 @@ function wireMessageToChat(m, idx) {
807
1108
  streaming: false
808
1109
  };
809
1110
  }
1111
+ let toolSeq = 0;
1112
+ const nextToolId = () => `tool-${++toolSeq}`;
1113
+ /** Grow the trailing text block, or open a new one if the last block was a tool. */
1114
+ function growTextBlock(blocks, text) {
1115
+ if (!text) return;
1116
+ const last = blocks[blocks.length - 1];
1117
+ if (last && last.kind === "text") last.text += text;
1118
+ else blocks.push({
1119
+ kind: "text",
1120
+ text
1121
+ });
1122
+ }
1123
+ /**
1124
+ * Fold a `stream_chunk` node-state into the ordered block list, returning `true`
1125
+ * when the chunk carried tool activity.
1126
+ *
1127
+ * Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
1128
+ * — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
1129
+ * "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
1130
+ */
1131
+ function applyToolChunk(blocks, state) {
1132
+ const raw = state?.rawResponse;
1133
+ if (!raw || typeof raw !== "object") return false;
1134
+ const call = raw.toolCall;
1135
+ const res = raw.toolResult;
1136
+ if (call) {
1137
+ const args = typeof call.arguments === "string" ? call.arguments : JSON.stringify(call.arguments ?? {});
1138
+ blocks.push({
1139
+ kind: "tool",
1140
+ tool: {
1141
+ id: nextToolId(),
1142
+ name: call.name ?? "",
1143
+ args,
1144
+ done: false
1145
+ }
1146
+ });
1147
+ return true;
1148
+ }
1149
+ if (res) {
1150
+ const result = typeof res.result === "string" ? res.result : JSON.stringify(res.result ?? "");
1151
+ for (let i = blocks.length - 1; i >= 0; i--) {
1152
+ const b = blocks[i];
1153
+ if (b && b.kind === "tool" && b.tool.name === (res.name ?? "") && !b.tool.done) {
1154
+ b.tool.done = true;
1155
+ b.tool.isError = !!res.isError;
1156
+ b.tool.result = result;
1157
+ break;
1158
+ }
1159
+ }
1160
+ return true;
1161
+ }
1162
+ return false;
1163
+ }
810
1164
  var ConversationController = class {
811
1165
  constructor(config, events, store) {
812
1166
  _defineProperty(this, "config", void 0);
@@ -814,6 +1168,7 @@ var ConversationController = class {
814
1168
  _defineProperty(this, "store", void 0);
815
1169
  _defineProperty(this, "client", null);
816
1170
  _defineProperty(this, "sessionId", null);
1171
+ _defineProperty(this, "conversationId", null);
817
1172
  _defineProperty(this, "messages", []);
818
1173
  _defineProperty(this, "status", "idle");
819
1174
  _defineProperty(this, "seq", 0);
@@ -837,6 +1192,26 @@ var ConversationController = class {
837
1192
  get connectionStatus() {
838
1193
  return this.status;
839
1194
  }
1195
+ /** Conversation id of the live session, or null before connect (voice passes this as `conversation_id`). */
1196
+ get currentConversationId() {
1197
+ return this.conversationId;
1198
+ }
1199
+ /**
1200
+ * Append an already-finalized message to the transcript and emit — the voice
1201
+ * path reuses this so `transcript_final` (user) and `reply_text` (assistant)
1202
+ * turns land in the same message list / render pipeline as typed chat.
1203
+ */
1204
+ appendLocalMessage(role, text) {
1205
+ const trimmed = text.trim();
1206
+ if (!trimmed) return;
1207
+ this.messages.push({
1208
+ id: this.nextId(role === "user" ? "u" : "a"),
1209
+ role,
1210
+ text: trimmed,
1211
+ streaming: false
1212
+ });
1213
+ this.emitMessages();
1214
+ }
840
1215
  /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
841
1216
  getStore() {
842
1217
  return this.store;
@@ -1100,6 +1475,7 @@ var ConversationController = class {
1100
1475
  ...metadata ? { metadata } : {}
1101
1476
  });
1102
1477
  this.sessionId = session.sessionId;
1478
+ this.conversationId = session.conversationId ?? null;
1103
1479
  this.store.getState().setSessionId(session.sessionId);
1104
1480
  }
1105
1481
  /**
@@ -1116,6 +1492,7 @@ var ConversationController = class {
1116
1492
  }
1117
1493
  if (snap.status === "ended") return false;
1118
1494
  this.sessionId = sessionId;
1495
+ this.conversationId = snap.conversationId ?? null;
1119
1496
  await this.hydrateHistory(sessionId);
1120
1497
  return true;
1121
1498
  }
@@ -1153,11 +1530,13 @@ var ConversationController = class {
1153
1530
  text: trimmed,
1154
1531
  streaming: false
1155
1532
  });
1533
+ const showTools = this.config.showToolActivity === true;
1156
1534
  const assistant = {
1157
1535
  id: this.nextId("a"),
1158
1536
  role: "assistant",
1159
1537
  text: "",
1160
- streaming: true
1538
+ streaming: true,
1539
+ blocks: showTools ? [] : void 0
1161
1540
  };
1162
1541
  this.messages.push(assistant);
1163
1542
  this.emitMessages();
@@ -1172,8 +1551,11 @@ var ConversationController = class {
1172
1551
  const token = event.token ?? event.data?.token ?? "";
1173
1552
  if (token) {
1174
1553
  assistant.text += token;
1554
+ if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
1175
1555
  this.emitMessages();
1176
1556
  }
1557
+ } else if (showTools && event.type === "stream_chunk") {
1558
+ if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) this.emitMessages();
1177
1559
  } else this.handleTurnEvent(event);
1178
1560
  const inner = (await turn).data?.data;
1179
1561
  const finalText = extractFinalText(inner?.response);
@@ -1183,6 +1565,7 @@ var ConversationController = class {
1183
1565
  if (citations.length > 0) assistant.citations = citations;
1184
1566
  const suggestions = extractSuggestions(inner?.response);
1185
1567
  if (suggestions.length > 0) assistant.suggestions = suggestions;
1568
+ if (assistant.blocks && !assistant.blocks.some((b) => b.kind === "tool")) assistant.blocks = void 0;
1186
1569
  assistant.streaming = false;
1187
1570
  this.emitMessages();
1188
1571
  } catch (err) {
@@ -1453,6 +1836,7 @@ var ConversationController = class {
1453
1836
  this.client?.disconnect("widget closed");
1454
1837
  this.client = null;
1455
1838
  this.sessionId = null;
1839
+ this.conversationId = null;
1456
1840
  this.activeRequestId = null;
1457
1841
  this.resumeAttempted = false;
1458
1842
  this.setInterrupt(null);
@@ -1494,6 +1878,7 @@ function buildStyles(theme, mode = "popover") {
1494
1878
  --sac-bg: ${theme.background};
1495
1879
  --sac-primary: ${theme.primary};
1496
1880
  --sac-primary-text: ${theme.primaryText};
1881
+ --sac-secondary: ${theme.secondary};
1497
1882
  --sac-assistant-bubble: ${theme.assistantBubble};
1498
1883
  --sac-assistant-bubble-text: ${theme.assistantBubbleText};
1499
1884
  --sac-user-bubble: ${theme.userBubble};
@@ -1806,6 +2191,47 @@ ${mode === "fullpage" ? `
1806
2191
  }
1807
2192
  @keyframes sac-blink { to { opacity: 0 } }
1808
2193
 
2194
+ /* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles
2195
+ and inline tool chips stacked in the order the model produced them. */
2196
+ .blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
2197
+ .blocks .bubble { align-self: flex-start; }
2198
+ .toolchip {
2199
+ display: inline-flex;
2200
+ align-items: center;
2201
+ gap: 6px;
2202
+ max-width: 100%;
2203
+ padding: 5px 10px;
2204
+ border-radius: 99px;
2205
+ font-size: 12px;
2206
+ line-height: 1.3;
2207
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
2208
+ background: color-mix(in srgb, var(--sac-text) 6%, transparent);
2209
+ border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent);
2210
+ animation: sac-msg-in .3s var(--sac-ease) both;
2211
+ }
2212
+ .toolchip .ti { display: inline-flex; flex: none; opacity: .8; }
2213
+ .toolchip .ti svg { width: 13px; height: 13px; }
2214
+ .toolchip .tn { font-weight: 600; letter-spacing: .01em; }
2215
+ .toolchip .ts { opacity: .7; }
2216
+ .toolchip .ta {
2217
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2218
+ font-size: 11px;
2219
+ opacity: .6;
2220
+ overflow: hidden;
2221
+ text-overflow: ellipsis;
2222
+ white-space: nowrap;
2223
+ min-width: 0;
2224
+ }
2225
+ .toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); }
2226
+ .toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; }
2227
+ .toolchip.done .ts::before { content: '✓ '; }
2228
+ .toolchip.error {
2229
+ color: var(--sac-secondary);
2230
+ border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent);
2231
+ background: color-mix(in srgb, var(--sac-secondary) 10%, transparent);
2232
+ }
2233
+ .toolchip.error .ts::before { content: '! '; }
2234
+
1809
2235
  /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
1810
2236
  /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
1811
2237
  keep them legible inside the tight Aurora-Glass bubble + citation card. */
@@ -1961,6 +2387,47 @@ ${mode === "fullpage" ? `
1961
2387
  .send:hover { transform: translateY(-1px) scale(1.05); }
1962
2388
  .send:active { transform: scale(.94); }
1963
2389
  .send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
2390
+
2391
+ /* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live. */
2392
+ .mic {
2393
+ width: 38px; height: 38px;
2394
+ flex: none;
2395
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
2396
+ border-radius: 13px;
2397
+ cursor: pointer;
2398
+ display: flex;
2399
+ align-items: center;
2400
+ justify-content: center;
2401
+ background: transparent;
2402
+ color: color-mix(in srgb, var(--sac-text) 65%, transparent);
2403
+ transition: transform .2s var(--sac-ease), color .2s ease, background .25s ease, box-shadow .25s ease;
2404
+ }
2405
+ .mic svg { width: 18px; height: 18px; }
2406
+ .mic:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
2407
+ .mic:active { transform: scale(.94); }
2408
+ .mic.active {
2409
+ border-color: transparent;
2410
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
2411
+ color: var(--sac-primary-text);
2412
+ animation: sac-mic-pulse 1.6s ease-out infinite;
2413
+ }
2414
+ /* Listening → soft expanding ring off the button (mirrors the presence pulse). */
2415
+ @keyframes sac-mic-pulse {
2416
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 45%, transparent); }
2417
+ 70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--sac-primary) 0%, transparent); }
2418
+ 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 0%, transparent); }
2419
+ }
2420
+ /* Agent TTS playing → faster secondary-accent shimmer so "speaking" reads distinctly. */
2421
+ .mic.active.speaking {
2422
+ animation: sac-mic-speaking 0.9s ease-in-out infinite;
2423
+ }
2424
+ @keyframes sac-mic-speaking {
2425
+ 0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-secondary) 35%, transparent); }
2426
+ 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--sac-secondary) 12%, transparent); }
2427
+ }
2428
+ @media (prefers-reduced-motion: reduce) {
2429
+ .mic.active, .mic.active.speaking { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-primary) 40%, transparent); }
2430
+ }
1964
2431
  .footer {
1965
2432
  text-align: center;
1966
2433
  margin-top: 9px;
@@ -2248,7 +2715,8 @@ const OBSERVED = [
2248
2715
  "greeting",
2249
2716
  "start-open",
2250
2717
  "mode",
2251
- "hide-branding"
2718
+ "hide-branding",
2719
+ "show-tool-activity"
2252
2720
  ];
2253
2721
  /**
2254
2722
  * Inline SVG icons (static, trusted strings — never interpolated with user data).
@@ -2270,7 +2738,11 @@ const ICON = {
2270
2738
  /** Identity-intake interrupt — a person. */
2271
2739
  user: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="8.2" r="3.4" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 19.5c.8-3.1 3.4-4.8 6.5-4.8s5.7 1.7 6.5 4.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`,
2272
2740
  /** Tool-confirmation interrupt — a shield. */
2273
- shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`
2741
+ shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
2742
+ /** Tool-activity chip — a wrench. */
2743
+ tool: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.7 6.3a3.5 3.5 0 0 0-4.6 4.3l-5 5a1.6 1.6 0 0 0 2.3 2.3l5-5a3.5 3.5 0 0 0 4.3-4.6l-2 2-1.7-.3-.3-1.7 2-2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`,
2744
+ /** Voice toggle — a microphone. */
2745
+ mic: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="3.5" width="6" height="11" rx="3" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v2.5M9 20.5h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`
2274
2746
  };
2275
2747
  /**
2276
2748
  * The identity_intake card: the fields the agent requested (pre-chat form field
@@ -2423,6 +2895,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
2423
2895
  _defineProperty(this, "identityRestore", { phase: "idle" });
2424
2896
  _defineProperty(this, "allowChatRestore", true);
2425
2897
  _defineProperty(this, "gating", false);
2898
+ _defineProperty(this, "voiceCfg", {
2899
+ enabled: false,
2900
+ url: ""
2901
+ });
2902
+ _defineProperty(this, "voiceSession", null);
2426
2903
  _defineProperty(this, "panelEl", null);
2427
2904
  _defineProperty(this, "launcherEl", null);
2428
2905
  _defineProperty(this, "messagesEl", null);
@@ -2430,12 +2907,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
2430
2907
  _defineProperty(this, "dotEl", null);
2431
2908
  _defineProperty(this, "inputEl", null);
2432
2909
  _defineProperty(this, "sendBtn", null);
2910
+ _defineProperty(this, "micBtn", null);
2433
2911
  _defineProperty(this, "suggestionsEl", null);
2434
2912
  _defineProperty(this, "streamBubbleEl", null);
2435
2913
  _defineProperty(this, "streamMsgId", null);
2436
2914
  _defineProperty(this, "streamTarget", "");
2437
2915
  _defineProperty(this, "displayedLength", 0);
2438
2916
  _defineProperty(this, "rafId", 0);
2917
+ _defineProperty(this, "prevBlockSig", "");
2439
2918
  this.root = this.attachShadow({ mode: "open" });
2440
2919
  }
2441
2920
  connectedCallback() {
@@ -2445,6 +2924,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2445
2924
  disconnectedCallback() {
2446
2925
  this.mounted = false;
2447
2926
  this.resetReveal();
2927
+ this.stopVoice();
2448
2928
  this.controller?.disconnect();
2449
2929
  this.controller = null;
2450
2930
  }
@@ -2507,6 +2987,8 @@ var SmoothAgentChatElement = class extends HTMLElement {
2507
2987
  collectConsent: this.overrides.collectConsent,
2508
2988
  allowChatRestore: this.overrides.allowChatRestore,
2509
2989
  allowAnonymous: this.overrides.allowAnonymous,
2990
+ showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute("show-tool-activity"),
2991
+ voice: this.overrides.voice,
2510
2992
  theme
2511
2993
  };
2512
2994
  }
@@ -2591,6 +3073,8 @@ var SmoothAgentChatElement = class extends HTMLElement {
2591
3073
  </div>`;
2592
3074
  const footerInner = [resolved.hideBranding ? "" : `<a href="${SMOOTH_OPERATOR_URL}" target="_blank" rel="noopener noreferrer">powered by <b>smooth&#8209;operator</b></a>`, this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : ""].filter(Boolean).join(" · ");
2593
3075
  const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : "";
3076
+ this.voiceCfg = resolved.voice;
3077
+ const micHtml = resolved.voice.enabled ? `<button class="mic" type="button" aria-label="Start voice" aria-pressed="false" title="Talk to ${escapeHtml(resolved.agentName)}">${ICON.mic}</button>` : "";
2594
3078
  const chatHtml = `
2595
3079
  <div class="messages"></div>
2596
3080
  <div class="reply-suggestions"></div>
@@ -2598,6 +3082,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2598
3082
  <div class="composer-wrap">
2599
3083
  <div class="composer">
2600
3084
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
3085
+ ${micHtml}
2601
3086
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
2602
3087
  </div>
2603
3088
  ${footerHtml}
@@ -2615,6 +3100,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2615
3100
  const logoSvg = container.querySelector(".logo-wrap svg");
2616
3101
  if (logoSvg) logoSvg.setAttribute("class", "logo");
2617
3102
  this.resetReveal();
3103
+ this.stopVoice();
2618
3104
  this.root.replaceChildren(style, container);
2619
3105
  this.launcherEl = container.querySelector(".launcher");
2620
3106
  this.panelEl = container.querySelector(".panel");
@@ -2623,11 +3109,13 @@ var SmoothAgentChatElement = class extends HTMLElement {
2623
3109
  this.dotEl = container.querySelector(".dot");
2624
3110
  this.inputEl = container.querySelector("textarea");
2625
3111
  this.sendBtn = container.querySelector(".send");
3112
+ this.micBtn = container.querySelector(".mic");
2626
3113
  this.interruptEl = container.querySelector(".interrupt");
2627
3114
  this.suggestionsEl = container.querySelector(".reply-suggestions");
2628
3115
  this.launcherEl?.addEventListener("click", () => this.openChat());
2629
3116
  container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
2630
3117
  this.sendBtn?.addEventListener("click", () => this.submit());
3118
+ this.micBtn?.addEventListener("click", () => this.toggleVoice());
2631
3119
  this.inputEl?.addEventListener("input", () => this.autosize());
2632
3120
  this.inputEl?.addEventListener("keydown", (ev) => {
2633
3121
  if (ev.key === "Enter" && !ev.shiftKey) {
@@ -3046,15 +3534,38 @@ var SmoothAgentChatElement = class extends HTMLElement {
3046
3534
  const prev = this.messages;
3047
3535
  const last = messages[messages.length - 1];
3048
3536
  const prevLast = prev[prev.length - 1];
3049
- const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text;
3537
+ const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text || this.blockSig(last) !== this.prevBlockSig;
3050
3538
  this.messages = messages;
3051
- if (!structural && last && last.streaming && last.id === this.streamMsgId) {
3052
- this.streamTarget = last.text;
3539
+ this.prevBlockSig = this.blockSig(last);
3540
+ if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
3541
+ this.streamTarget = this.tailText(last);
3053
3542
  this.ensureRevealLoop();
3054
3543
  return;
3055
3544
  }
3056
3545
  this.renderMessages();
3057
3546
  }
3547
+ /** True when an assistant message's turn invoked at least one tool. */
3548
+ hasToolBlocks(m) {
3549
+ return !!m?.blocks?.some((b) => b.kind === "tool");
3550
+ }
3551
+ /** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
3552
+ blockSig(m) {
3553
+ if (!m?.blocks) return "";
3554
+ return m.blocks.map((b) => b.kind === "tool" ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : "x").join("|");
3555
+ }
3556
+ /** The live (last) text block for a tool turn, else the whole message text. */
3557
+ tailText(m) {
3558
+ if (this.hasToolBlocks(m) && m.blocks) {
3559
+ const last = m.blocks[m.blocks.length - 1];
3560
+ return last?.kind === "text" ? last.text : "";
3561
+ }
3562
+ return m.text;
3563
+ }
3564
+ /** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
3565
+ tailKey(m) {
3566
+ if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
3567
+ return m.id;
3568
+ }
3058
3569
  renderMessages() {
3059
3570
  if (!this.messagesEl) return;
3060
3571
  this.resetReveal();
@@ -3075,6 +3586,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
3075
3586
  this.messagesEl.appendChild(chips);
3076
3587
  }
3077
3588
  for (const msg of this.messages) {
3589
+ if (msg.role === "assistant" && this.hasToolBlocks(msg)) {
3590
+ this.messagesEl.appendChild(this.buildRow("assistant", this.renderAssistantBlocks(msg)));
3591
+ if (!msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
3592
+ continue;
3593
+ }
3078
3594
  const bubble = document.createElement("div");
3079
3595
  bubble.className = `bubble ${msg.role}`;
3080
3596
  if (msg.role === "assistant" && msg.streaming && !msg.text) {
@@ -3132,19 +3648,77 @@ var SmoothAgentChatElement = class extends HTMLElement {
3132
3648
  * doesn't restart the reveal from zero), then resumes the loop.
3133
3649
  */
3134
3650
  bindReveal(msg, bubble) {
3135
- const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
3651
+ const key = this.tailKey(msg);
3652
+ const target = this.tailText(msg);
3653
+ const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
3136
3654
  this.streamBubbleEl = bubble;
3137
- this.streamMsgId = msg.id;
3138
- this.streamTarget = msg.text;
3655
+ this.streamMsgId = key;
3656
+ this.streamTarget = target;
3139
3657
  this.displayedLength = carryOver;
3140
3658
  if (this.prefersReducedMotion()) {
3141
- this.displayedLength = msg.text.length;
3142
- bubble.textContent = msg.text;
3659
+ this.displayedLength = target.length;
3660
+ bubble.textContent = target;
3143
3661
  return;
3144
3662
  }
3145
- bubble.textContent = msg.text.slice(0, this.displayedLength);
3663
+ bubble.textContent = target.slice(0, this.displayedLength);
3146
3664
  this.ensureRevealLoop();
3147
3665
  }
3666
+ /**
3667
+ * Render a tool-activity assistant turn as an ordered strip: prose bubbles and
3668
+ * inline tool chips in the order the model produced them (mirrors the daemon
3669
+ * SPA's `blocks`). The live trailing text block (while streaming) binds to the
3670
+ * rAF reveal; earlier/finalized text blocks render as sanitized markdown.
3671
+ */
3672
+ renderAssistantBlocks(msg) {
3673
+ const wrap = document.createElement("div");
3674
+ wrap.className = "blocks";
3675
+ const blocks = msg.blocks ?? [];
3676
+ const lastIdx = blocks.length - 1;
3677
+ blocks.forEach((block, i) => {
3678
+ if (block.kind === "tool") {
3679
+ wrap.appendChild(this.buildToolChip(block.tool));
3680
+ return;
3681
+ }
3682
+ const bubble = document.createElement("div");
3683
+ bubble.className = "bubble assistant";
3684
+ if (msg.streaming && i === lastIdx) {
3685
+ bubble.classList.add("cursor");
3686
+ this.bindReveal(msg, bubble);
3687
+ } else {
3688
+ bubble.classList.add("md");
3689
+ bubble.innerHTML = renderMarkdown(block.text);
3690
+ }
3691
+ wrap.appendChild(bubble);
3692
+ });
3693
+ return wrap;
3694
+ }
3695
+ /**
3696
+ * A single tool-activity chip: icon + tool name + status (running… / done / error),
3697
+ * with a truncated args preview. Tool name/args are set via `textContent` so a
3698
+ * tool payload can never inject markup.
3699
+ */
3700
+ buildToolChip(tool) {
3701
+ const chip = document.createElement("div");
3702
+ chip.className = `toolchip ${tool.done ? tool.isError ? "error" : "done" : "running"}`;
3703
+ chip.setAttribute("part", "tool-chip");
3704
+ const icon = document.createElement("span");
3705
+ icon.className = "ti";
3706
+ icon.innerHTML = ICON.tool;
3707
+ const name = document.createElement("span");
3708
+ name.className = "tn";
3709
+ name.textContent = tool.name || "tool";
3710
+ const status = document.createElement("span");
3711
+ status.className = "ts";
3712
+ status.textContent = tool.done ? tool.isError ? "error" : "done" : "running…";
3713
+ chip.append(icon, name, status);
3714
+ if (tool.args && tool.args !== "{}" && tool.args !== "\"\"") {
3715
+ const args = document.createElement("span");
3716
+ args.className = "ta";
3717
+ args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
3718
+ chip.appendChild(args);
3719
+ }
3720
+ return chip;
3721
+ }
3148
3722
  /** Start the rAF loop if it isn't already running. */
3149
3723
  ensureRevealLoop() {
3150
3724
  if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
@@ -3316,6 +3890,84 @@ var SmoothAgentChatElement = class extends HTMLElement {
3316
3890
  if (this.sendBtn) this.sendBtn.disabled = busy;
3317
3891
  if (this.inputEl) this.inputEl.disabled = busy;
3318
3892
  }
3893
+ /**
3894
+ * Mic button: start a voice session, or — when one is live — end it. Hitting
3895
+ * the button while the agent's TTS is playing barges in first (interrupt +
3896
+ * playback flush) so the audio dies instantly, then the session ends.
3897
+ */
3898
+ toggleVoice() {
3899
+ if (this.voiceSession) {
3900
+ if (this.voiceSession.isSpeaking) this.voiceSession.interrupt();
3901
+ this.stopVoice();
3902
+ return;
3903
+ }
3904
+ this.startVoice();
3905
+ }
3906
+ async startVoice() {
3907
+ if (!this.controller || this.voiceSession || !this.voiceCfg.enabled) return;
3908
+ const config = this.readConfig();
3909
+ if (!config) return;
3910
+ const conversationId = this.controller.currentConversationId ?? void 0;
3911
+ const controller = this.controller;
3912
+ const session = new VoiceSession({
3913
+ url: this.voiceCfg.url,
3914
+ agentId: config.agentId,
3915
+ conversationId
3916
+ }, {
3917
+ onTranscriptPartial: (text) => {
3918
+ if (this.inputEl) {
3919
+ this.inputEl.value = text;
3920
+ this.autosize();
3921
+ }
3922
+ },
3923
+ onTranscriptFinal: (text) => {
3924
+ if (this.inputEl) {
3925
+ this.inputEl.value = "";
3926
+ this.autosize();
3927
+ }
3928
+ controller.appendLocalMessage("user", text);
3929
+ },
3930
+ onReplyText: (text) => {
3931
+ controller.appendLocalMessage("assistant", text);
3932
+ },
3933
+ onSpeaking: (speaking) => {
3934
+ this.micBtn?.classList.toggle("speaking", speaking);
3935
+ },
3936
+ onError: () => this.stopVoice(),
3937
+ onEnded: () => {
3938
+ this.voiceSession = null;
3939
+ this.syncVoiceUi(false);
3940
+ }
3941
+ });
3942
+ this.voiceSession = session;
3943
+ this.syncVoiceUi(true);
3944
+ try {
3945
+ await session.start();
3946
+ } catch {
3947
+ this.voiceSession = null;
3948
+ this.syncVoiceUi(false);
3949
+ }
3950
+ }
3951
+ /** End any live voice session and reset the composer UI. Idempotent. */
3952
+ stopVoice() {
3953
+ const session = this.voiceSession;
3954
+ this.voiceSession = null;
3955
+ session?.stop();
3956
+ this.syncVoiceUi(false);
3957
+ }
3958
+ /** Toggle the mic button's listening state + clear the partial transcript. */
3959
+ syncVoiceUi(active) {
3960
+ this.micBtn?.classList.toggle("active", active);
3961
+ this.micBtn?.setAttribute("aria-pressed", String(active));
3962
+ this.micBtn?.setAttribute("aria-label", active ? "Stop voice" : "Start voice");
3963
+ if (!active) {
3964
+ this.micBtn?.classList.remove("speaking");
3965
+ if (this.inputEl && this.inputEl.value) {
3966
+ this.inputEl.value = "";
3967
+ this.autosize();
3968
+ }
3969
+ }
3970
+ }
3319
3971
  submit() {
3320
3972
  if (!this.inputEl || !this.controller) return;
3321
3973
  const text = this.inputEl.value;
@@ -3498,6 +4150,6 @@ function initChatWidgetLoader() {
3498
4150
  else window.addEventListener("load", schedule, { once: true });
3499
4151
  }
3500
4152
  //#endregion
3501
- export { ConversationController, ELEMENT_TAG, PERSIST_VERSION, SmoothAgentChatElement, computeFingerprint, createWidgetStore, defineChatWidget, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, storageKey };
4153
+ export { ConversationController, DEFAULT_VOICE_URL, ELEMENT_TAG, PERSIST_VERSION, PcmPlayer, SmoothAgentChatElement, VOICE_SAMPLE_RATE, VoiceSession, computeFingerprint, createWidgetStore, defineChatWidget, downsampleTo16k, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, rmsLevel, storageKey };
3502
4154
 
3503
4155
  //# sourceMappingURL=index.js.map