@smooai/chat-widget 0.13.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.
@@ -343,6 +677,10 @@ function resolveConfig(config) {
343
677
  allowChatRestore: config.allowChatRestore ?? true,
344
678
  allowAnonymous: config.allowAnonymous ?? false,
345
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
+ },
346
684
  theme: {
347
685
  text: theme.text ?? "#f8fafc",
348
686
  background: theme.background ?? "#040d30",
@@ -652,44 +990,6 @@ function createWidgetStore(agentId) {
652
990
  }));
653
991
  }
654
992
  //#endregion
655
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
656
- function _typeof(o) {
657
- "@babel/helpers - typeof";
658
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
659
- return typeof o;
660
- } : function(o) {
661
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
662
- }, _typeof(o);
663
- }
664
- //#endregion
665
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
666
- function toPrimitive(t, r) {
667
- if ("object" != _typeof(t) || !t) return t;
668
- var e = t[Symbol.toPrimitive];
669
- if (void 0 !== e) {
670
- var i = e.call(t, r || "default");
671
- if ("object" != _typeof(i)) return i;
672
- throw new TypeError("@@toPrimitive must return a primitive value.");
673
- }
674
- return ("string" === r ? String : Number)(t);
675
- }
676
- //#endregion
677
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
678
- function toPropertyKey(t) {
679
- var i = toPrimitive(t, "string");
680
- return "symbol" == _typeof(i) ? i : i + "";
681
- }
682
- //#endregion
683
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
684
- function _defineProperty(e, r, t) {
685
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
686
- value: t,
687
- enumerable: !0,
688
- configurable: !0,
689
- writable: !0
690
- }) : e[r] = t, e;
691
- }
692
- //#endregion
693
993
  //#region src/conversation.ts
694
994
  /**
695
995
  * ConversationController — the bridge between the widget UI and the
@@ -868,6 +1168,7 @@ var ConversationController = class {
868
1168
  _defineProperty(this, "store", void 0);
869
1169
  _defineProperty(this, "client", null);
870
1170
  _defineProperty(this, "sessionId", null);
1171
+ _defineProperty(this, "conversationId", null);
871
1172
  _defineProperty(this, "messages", []);
872
1173
  _defineProperty(this, "status", "idle");
873
1174
  _defineProperty(this, "seq", 0);
@@ -891,6 +1192,26 @@ var ConversationController = class {
891
1192
  get connectionStatus() {
892
1193
  return this.status;
893
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
+ }
894
1215
  /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
895
1216
  getStore() {
896
1217
  return this.store;
@@ -1154,6 +1475,7 @@ var ConversationController = class {
1154
1475
  ...metadata ? { metadata } : {}
1155
1476
  });
1156
1477
  this.sessionId = session.sessionId;
1478
+ this.conversationId = session.conversationId ?? null;
1157
1479
  this.store.getState().setSessionId(session.sessionId);
1158
1480
  }
1159
1481
  /**
@@ -1170,6 +1492,7 @@ var ConversationController = class {
1170
1492
  }
1171
1493
  if (snap.status === "ended") return false;
1172
1494
  this.sessionId = sessionId;
1495
+ this.conversationId = snap.conversationId ?? null;
1173
1496
  await this.hydrateHistory(sessionId);
1174
1497
  return true;
1175
1498
  }
@@ -1513,6 +1836,7 @@ var ConversationController = class {
1513
1836
  this.client?.disconnect("widget closed");
1514
1837
  this.client = null;
1515
1838
  this.sessionId = null;
1839
+ this.conversationId = null;
1516
1840
  this.activeRequestId = null;
1517
1841
  this.resumeAttempted = false;
1518
1842
  this.setInterrupt(null);
@@ -2063,6 +2387,47 @@ ${mode === "fullpage" ? `
2063
2387
  .send:hover { transform: translateY(-1px) scale(1.05); }
2064
2388
  .send:active { transform: scale(.94); }
2065
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
+ }
2066
2431
  .footer {
2067
2432
  text-align: center;
2068
2433
  margin-top: 9px;
@@ -2375,7 +2740,9 @@ const ICON = {
2375
2740
  /** Tool-confirmation interrupt — a shield. */
2376
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>`,
2377
2742
  /** Tool-activity chip — a wrench. */
2378
- 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>`
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>`
2379
2746
  };
2380
2747
  /**
2381
2748
  * The identity_intake card: the fields the agent requested (pre-chat form field
@@ -2528,6 +2895,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
2528
2895
  _defineProperty(this, "identityRestore", { phase: "idle" });
2529
2896
  _defineProperty(this, "allowChatRestore", true);
2530
2897
  _defineProperty(this, "gating", false);
2898
+ _defineProperty(this, "voiceCfg", {
2899
+ enabled: false,
2900
+ url: ""
2901
+ });
2902
+ _defineProperty(this, "voiceSession", null);
2531
2903
  _defineProperty(this, "panelEl", null);
2532
2904
  _defineProperty(this, "launcherEl", null);
2533
2905
  _defineProperty(this, "messagesEl", null);
@@ -2535,6 +2907,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2535
2907
  _defineProperty(this, "dotEl", null);
2536
2908
  _defineProperty(this, "inputEl", null);
2537
2909
  _defineProperty(this, "sendBtn", null);
2910
+ _defineProperty(this, "micBtn", null);
2538
2911
  _defineProperty(this, "suggestionsEl", null);
2539
2912
  _defineProperty(this, "streamBubbleEl", null);
2540
2913
  _defineProperty(this, "streamMsgId", null);
@@ -2551,6 +2924,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2551
2924
  disconnectedCallback() {
2552
2925
  this.mounted = false;
2553
2926
  this.resetReveal();
2927
+ this.stopVoice();
2554
2928
  this.controller?.disconnect();
2555
2929
  this.controller = null;
2556
2930
  }
@@ -2614,6 +2988,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2614
2988
  allowChatRestore: this.overrides.allowChatRestore,
2615
2989
  allowAnonymous: this.overrides.allowAnonymous,
2616
2990
  showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute("show-tool-activity"),
2991
+ voice: this.overrides.voice,
2617
2992
  theme
2618
2993
  };
2619
2994
  }
@@ -2698,6 +3073,8 @@ var SmoothAgentChatElement = class extends HTMLElement {
2698
3073
  </div>`;
2699
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(" · ");
2700
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>` : "";
2701
3078
  const chatHtml = `
2702
3079
  <div class="messages"></div>
2703
3080
  <div class="reply-suggestions"></div>
@@ -2705,6 +3082,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2705
3082
  <div class="composer-wrap">
2706
3083
  <div class="composer">
2707
3084
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
3085
+ ${micHtml}
2708
3086
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
2709
3087
  </div>
2710
3088
  ${footerHtml}
@@ -2722,6 +3100,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2722
3100
  const logoSvg = container.querySelector(".logo-wrap svg");
2723
3101
  if (logoSvg) logoSvg.setAttribute("class", "logo");
2724
3102
  this.resetReveal();
3103
+ this.stopVoice();
2725
3104
  this.root.replaceChildren(style, container);
2726
3105
  this.launcherEl = container.querySelector(".launcher");
2727
3106
  this.panelEl = container.querySelector(".panel");
@@ -2730,11 +3109,13 @@ var SmoothAgentChatElement = class extends HTMLElement {
2730
3109
  this.dotEl = container.querySelector(".dot");
2731
3110
  this.inputEl = container.querySelector("textarea");
2732
3111
  this.sendBtn = container.querySelector(".send");
3112
+ this.micBtn = container.querySelector(".mic");
2733
3113
  this.interruptEl = container.querySelector(".interrupt");
2734
3114
  this.suggestionsEl = container.querySelector(".reply-suggestions");
2735
3115
  this.launcherEl?.addEventListener("click", () => this.openChat());
2736
3116
  container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
2737
3117
  this.sendBtn?.addEventListener("click", () => this.submit());
3118
+ this.micBtn?.addEventListener("click", () => this.toggleVoice());
2738
3119
  this.inputEl?.addEventListener("input", () => this.autosize());
2739
3120
  this.inputEl?.addEventListener("keydown", (ev) => {
2740
3121
  if (ev.key === "Enter" && !ev.shiftKey) {
@@ -3509,6 +3890,84 @@ var SmoothAgentChatElement = class extends HTMLElement {
3509
3890
  if (this.sendBtn) this.sendBtn.disabled = busy;
3510
3891
  if (this.inputEl) this.inputEl.disabled = busy;
3511
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
+ }
3512
3971
  submit() {
3513
3972
  if (!this.inputEl || !this.controller) return;
3514
3973
  const text = this.inputEl.value;
@@ -3691,6 +4150,6 @@ function initChatWidgetLoader() {
3691
4150
  else window.addEventListener("load", schedule, { once: true });
3692
4151
  }
3693
4152
  //#endregion
3694
- 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 };
3695
4154
 
3696
4155
  //# sourceMappingURL=index.js.map