@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.
@@ -11741,6 +11741,314 @@ var SmoothAgentChat = (function(exports) {
11741
11741
  return s;
11742
11742
  }
11743
11743
  //#endregion
11744
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
11745
+ function _typeof(o) {
11746
+ "@babel/helpers - typeof";
11747
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
11748
+ return typeof o;
11749
+ } : function(o) {
11750
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
11751
+ }, _typeof(o);
11752
+ }
11753
+ //#endregion
11754
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
11755
+ function toPrimitive(t, r) {
11756
+ if ("object" != _typeof(t) || !t) return t;
11757
+ var e = t[Symbol.toPrimitive];
11758
+ if (void 0 !== e) {
11759
+ var i = e.call(t, r || "default");
11760
+ if ("object" != _typeof(i)) return i;
11761
+ throw new TypeError("@@toPrimitive must return a primitive value.");
11762
+ }
11763
+ return ("string" === r ? String : Number)(t);
11764
+ }
11765
+ //#endregion
11766
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
11767
+ function toPropertyKey(t) {
11768
+ var i = toPrimitive(t, "string");
11769
+ return "symbol" == _typeof(i) ? i : i + "";
11770
+ }
11771
+ //#endregion
11772
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
11773
+ function _defineProperty(e, r, t) {
11774
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
11775
+ value: t,
11776
+ enumerable: !0,
11777
+ configurable: !0,
11778
+ writable: !0
11779
+ }) : e[r] = t, e;
11780
+ }
11781
+ /** Target wire format: PCM linear16 mono @ 16 kHz. */
11782
+ const VOICE_SAMPLE_RATE = 16e3;
11783
+ /**
11784
+ * Downsample Float32 mic samples at `inputRate` to 16 kHz mono Int16 (linear16).
11785
+ * Bucket-averaging decimation: each output sample averages its source bucket,
11786
+ * which is a cheap low-pass that's plenty for speech (and handles non-integer
11787
+ * ratios like 44.1k → 16k). Values are clamped to the Int16 range.
11788
+ */
11789
+ function downsampleTo16k(samples, inputRate) {
11790
+ const ratio = inputRate / VOICE_SAMPLE_RATE;
11791
+ const outLen = ratio <= 1 ? samples.length : Math.floor(samples.length / ratio);
11792
+ const out = new Int16Array(outLen);
11793
+ for (let i = 0; i < outLen; i++) {
11794
+ let v;
11795
+ if (ratio <= 1) v = samples[i];
11796
+ else {
11797
+ const start = Math.floor(i * ratio);
11798
+ const end = Math.min(samples.length, Math.max(start + 1, Math.floor((i + 1) * ratio)));
11799
+ let sum = 0;
11800
+ for (let j = start; j < end; j++) sum += samples[j];
11801
+ v = sum / (end - start);
11802
+ }
11803
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(v * 32767)));
11804
+ }
11805
+ return out;
11806
+ }
11807
+ /** Root-mean-square level of a Float32 frame (0..1) — the barge-in speech gate. */
11808
+ function rmsLevel(samples) {
11809
+ if (samples.length === 0) return 0;
11810
+ let sum = 0;
11811
+ for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
11812
+ return Math.sqrt(sum / samples.length);
11813
+ }
11814
+ /**
11815
+ * Gapless PCM playback: each incoming linear16 chunk becomes an AudioBuffer
11816
+ * scheduled back-to-back (`nextTime`) so consecutive chunks butt together with
11817
+ * no gaps. `flush()` stops every scheduled source (barge-in / mic-button stop).
11818
+ */
11819
+ var PcmPlayer = class {
11820
+ constructor(ctx) {
11821
+ this.ctx = ctx;
11822
+ _defineProperty(this, "nextTime", 0);
11823
+ _defineProperty(this, "live", /* @__PURE__ */ new Set());
11824
+ }
11825
+ enqueue(chunk) {
11826
+ const int16 = new Int16Array(chunk);
11827
+ if (int16.length === 0) return;
11828
+ const buf = this.ctx.createBuffer(1, int16.length, VOICE_SAMPLE_RATE);
11829
+ const ch = buf.getChannelData(0);
11830
+ for (let i = 0; i < int16.length; i++) ch[i] = int16[i] / 32768;
11831
+ const src = this.ctx.createBufferSource();
11832
+ src.buffer = buf;
11833
+ src.connect(this.ctx.destination);
11834
+ const start = Math.max(this.ctx.currentTime, this.nextTime);
11835
+ src.start(start);
11836
+ this.nextTime = start + int16.length / VOICE_SAMPLE_RATE;
11837
+ this.live.add(src);
11838
+ src.onended = () => this.live.delete(src);
11839
+ }
11840
+ flush() {
11841
+ for (const src of this.live) try {
11842
+ src.stop();
11843
+ } catch {}
11844
+ this.live.clear();
11845
+ this.nextTime = 0;
11846
+ }
11847
+ close() {
11848
+ this.flush();
11849
+ this.ctx.close?.();
11850
+ }
11851
+ };
11852
+ /** AudioWorklet processor source — posts each 128-sample mic block to the main thread. */
11853
+ const CAPTURE_WORKLET_SRC = `
11854
+ registerProcessor('sac-mic-capture', class extends AudioWorkletProcessor {
11855
+ process(inputs) {
11856
+ const ch = inputs[0] && inputs[0][0];
11857
+ if (ch && ch.length > 0) {
11858
+ const copy = new Float32Array(ch);
11859
+ this.port.postMessage(copy.buffer, [copy.buffer]);
11860
+ }
11861
+ return true;
11862
+ }
11863
+ });
11864
+ `;
11865
+ /** Real mic capture: getUserMedia → AudioWorklet (or ScriptProcessor fallback). */
11866
+ const defaultStartCapture = async (onFrame) => {
11867
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: {
11868
+ channelCount: 1,
11869
+ echoCancellation: true,
11870
+ noiseSuppression: true,
11871
+ autoGainControl: true
11872
+ } });
11873
+ const Ctx = globalThis.AudioContext;
11874
+ if (!Ctx) {
11875
+ stream.getTracks().forEach((t) => t.stop());
11876
+ throw new Error("AudioContext is not available");
11877
+ }
11878
+ const ctx = new Ctx();
11879
+ const sampleRate = ctx.sampleRate;
11880
+ const source = ctx.createMediaStreamSource(stream);
11881
+ let disconnectNode;
11882
+ if (ctx.audioWorklet && typeof AudioWorkletNode === "function") {
11883
+ const url = URL.createObjectURL(new Blob([CAPTURE_WORKLET_SRC], { type: "text/javascript" }));
11884
+ try {
11885
+ await ctx.audioWorklet.addModule(url);
11886
+ } finally {
11887
+ URL.revokeObjectURL(url);
11888
+ }
11889
+ const node = new AudioWorkletNode(ctx, "sac-mic-capture");
11890
+ node.port.onmessage = (ev) => onFrame(new Float32Array(ev.data), sampleRate);
11891
+ source.connect(node);
11892
+ disconnectNode = () => node.disconnect();
11893
+ } else {
11894
+ const node = ctx.createScriptProcessor(4096, 1, 1);
11895
+ node.onaudioprocess = (ev) => onFrame(new Float32Array(ev.inputBuffer.getChannelData(0)), sampleRate);
11896
+ source.connect(node);
11897
+ node.connect(ctx.destination);
11898
+ disconnectNode = () => node.disconnect();
11899
+ }
11900
+ return () => {
11901
+ disconnectNode();
11902
+ source.disconnect();
11903
+ stream.getTracks().forEach((t) => t.stop());
11904
+ ctx.close();
11905
+ };
11906
+ };
11907
+ const defaultCreatePlayer = () => {
11908
+ const Ctx = globalThis.AudioContext;
11909
+ if (!Ctx) throw new Error("AudioContext is not available");
11910
+ return new PcmPlayer(new Ctx({ sampleRate: VOICE_SAMPLE_RATE }));
11911
+ };
11912
+ var VoiceSession = class {
11913
+ constructor(opts, events = {}) {
11914
+ _defineProperty(this, "opts", void 0);
11915
+ _defineProperty(this, "events", void 0);
11916
+ _defineProperty(this, "ws", null);
11917
+ _defineProperty(this, "player", null);
11918
+ _defineProperty(this, "stopCapture", null);
11919
+ _defineProperty(this, "speaking", false);
11920
+ _defineProperty(this, "ended", false);
11921
+ _defineProperty(this, "state", "idle");
11922
+ this.opts = opts;
11923
+ this.events = events;
11924
+ }
11925
+ /** Open the WS, send the start frame, and begin streaming mic audio. */
11926
+ async start() {
11927
+ if (this.state !== "idle") return;
11928
+ this.state = "connecting";
11929
+ const url = this.opts.url ?? "wss://twilio-voice.smoo.ai/browser-voice/ws";
11930
+ const createWs = this.opts.seams?.createWebSocket ?? ((u) => new WebSocket(u));
11931
+ const startCapture = this.opts.seams?.startCapture ?? defaultStartCapture;
11932
+ const createPlayer = this.opts.seams?.createPlayer ?? defaultCreatePlayer;
11933
+ this.stopCapture = await startCapture((samples, rate) => this.handleMicFrame(samples, rate));
11934
+ try {
11935
+ this.player = createPlayer();
11936
+ const ws = createWs(url);
11937
+ ws.binaryType = "arraybuffer";
11938
+ this.ws = ws;
11939
+ ws.addEventListener("open", () => {
11940
+ const start = {
11941
+ type: "start",
11942
+ agent_id: this.opts.agentId
11943
+ };
11944
+ if (this.opts.conversationId) start.conversation_id = this.opts.conversationId;
11945
+ if (this.opts.token) start.token = this.opts.token;
11946
+ ws.send(JSON.stringify(start));
11947
+ this.state = "active";
11948
+ });
11949
+ ws.addEventListener("message", (ev) => this.handleServerFrame(ev.data));
11950
+ ws.addEventListener("close", () => this.teardown());
11951
+ ws.addEventListener("error", () => {
11952
+ this.events.onError?.("connection_error");
11953
+ this.teardown();
11954
+ });
11955
+ } catch (err) {
11956
+ this.teardown();
11957
+ throw err;
11958
+ }
11959
+ }
11960
+ /** One raw mic frame: barge-in check, then downsample → binary frame. */
11961
+ handleMicFrame(samples, sampleRate) {
11962
+ const ws = this.ws;
11963
+ if (!ws || ws.readyState !== 1 || this.state !== "active") return;
11964
+ if (this.speaking && rmsLevel(samples) > (this.opts.bargeInThreshold ?? .02)) this.interrupt();
11965
+ const pcm = downsampleTo16k(samples, sampleRate);
11966
+ ws.send(pcm.buffer);
11967
+ }
11968
+ /** Route a server frame: binary = TTS audio, string = JSON control event. */
11969
+ handleServerFrame(data) {
11970
+ if (typeof data !== "string") {
11971
+ this.player?.enqueue(data);
11972
+ return;
11973
+ }
11974
+ let msg;
11975
+ try {
11976
+ msg = JSON.parse(data);
11977
+ } catch {
11978
+ return;
11979
+ }
11980
+ switch (msg.type) {
11981
+ case "transcript_partial":
11982
+ this.events.onTranscriptPartial?.(msg.text ?? "");
11983
+ break;
11984
+ case "transcript_final":
11985
+ this.events.onTranscriptFinal?.(msg.text ?? "");
11986
+ break;
11987
+ case "reply_text":
11988
+ this.events.onReplyText?.(msg.text ?? "");
11989
+ break;
11990
+ case "speaking_started":
11991
+ this.setSpeaking(true);
11992
+ break;
11993
+ case "speaking_done":
11994
+ this.setSpeaking(false);
11995
+ break;
11996
+ case "handoff":
11997
+ this.stop();
11998
+ break;
11999
+ case "error":
12000
+ this.events.onError?.(msg.code ?? "unknown");
12001
+ this.stop();
12002
+ break;
12003
+ default: break;
12004
+ }
12005
+ }
12006
+ setSpeaking(speaking) {
12007
+ if (this.speaking === speaking) return;
12008
+ this.speaking = speaking;
12009
+ this.events.onSpeaking?.(speaking);
12010
+ }
12011
+ /** True while agent TTS is playing (between speaking_started/done). */
12012
+ get isSpeaking() {
12013
+ return this.speaking;
12014
+ }
12015
+ /**
12016
+ * Barge in: tell the server the user interrupted and flush queued TTS so the
12017
+ * agent goes silent immediately. Called automatically on mic speech during
12018
+ * playback; the widget also calls it when the visitor hits the mic button
12019
+ * mid-playback.
12020
+ */
12021
+ interrupt() {
12022
+ if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: "interrupt" }));
12023
+ this.player?.flush();
12024
+ this.setSpeaking(false);
12025
+ }
12026
+ /** Graceful end: send `stop`, then tear everything down. */
12027
+ stop() {
12028
+ if (this.ws && this.ws.readyState === 1) try {
12029
+ this.ws.send(JSON.stringify({ type: "stop" }));
12030
+ } catch {}
12031
+ this.teardown();
12032
+ }
12033
+ /** Idempotent teardown: capture, playback, socket, `ended` event. */
12034
+ teardown() {
12035
+ if (this.ended) return;
12036
+ this.ended = true;
12037
+ this.state = "ended";
12038
+ this.stopCapture?.();
12039
+ this.stopCapture = null;
12040
+ this.player?.close();
12041
+ this.player = null;
12042
+ const ws = this.ws;
12043
+ this.ws = null;
12044
+ if (ws && ws.readyState <= 1) try {
12045
+ ws.close(1e3, "voice ended");
12046
+ } catch {}
12047
+ this.setSpeaking(false);
12048
+ this.events.onEnded?.();
12049
+ }
12050
+ };
12051
+ //#endregion
11744
12052
  //#region src/config.ts
11745
12053
  /**
11746
12054
  * Public configuration surface for the chat widget.
@@ -11783,6 +12091,10 @@ var SmoothAgentChat = (function(exports) {
11783
12091
  allowChatRestore: config.allowChatRestore ?? true,
11784
12092
  allowAnonymous: config.allowAnonymous ?? false,
11785
12093
  showToolActivity: config.showToolActivity ?? false,
12094
+ voice: {
12095
+ enabled: config.voice?.enabled ?? false,
12096
+ url: config.voice?.url ?? "wss://twilio-voice.smoo.ai/browser-voice/ws"
12097
+ },
11786
12098
  theme: {
11787
12099
  text: theme.text ?? "#f8fafc",
11788
12100
  background: theme.background ?? "#040d30",
@@ -11805,44 +12117,6 @@ var SmoothAgentChat = (function(exports) {
11805
12117
  return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
11806
12118
  }
11807
12119
  //#endregion
11808
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
11809
- function _typeof(o) {
11810
- "@babel/helpers - typeof";
11811
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
11812
- return typeof o;
11813
- } : function(o) {
11814
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
11815
- }, _typeof(o);
11816
- }
11817
- //#endregion
11818
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
11819
- function toPrimitive(t, r) {
11820
- if ("object" != _typeof(t) || !t) return t;
11821
- var e = t[Symbol.toPrimitive];
11822
- if (void 0 !== e) {
11823
- var i = e.call(t, r || "default");
11824
- if ("object" != _typeof(i)) return i;
11825
- throw new TypeError("@@toPrimitive must return a primitive value.");
11826
- }
11827
- return ("string" === r ? String : Number)(t);
11828
- }
11829
- //#endregion
11830
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
11831
- function toPropertyKey(t) {
11832
- var i = toPrimitive(t, "string");
11833
- return "symbol" == _typeof(i) ? i : i + "";
11834
- }
11835
- //#endregion
11836
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
11837
- function _defineProperty(e, r, t) {
11838
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
11839
- value: t,
11840
- enumerable: !0,
11841
- configurable: !0,
11842
- writable: !0
11843
- }) : e[r] = t, e;
11844
- }
11845
- //#endregion
11846
12120
  //#region node_modules/.pnpm/@smooai+smooth-operator@1.21.1/node_modules/@smooai/smooth-operator/dist/transport.js
11847
12121
  /**
11848
12122
  * Transport abstraction for the client.
@@ -13004,6 +13278,7 @@ var SmoothAgentChat = (function(exports) {
13004
13278
  _defineProperty(this, "store", void 0);
13005
13279
  _defineProperty(this, "client", null);
13006
13280
  _defineProperty(this, "sessionId", null);
13281
+ _defineProperty(this, "conversationId", null);
13007
13282
  _defineProperty(this, "messages", []);
13008
13283
  _defineProperty(this, "status", "idle");
13009
13284
  _defineProperty(this, "seq", 0);
@@ -13027,6 +13302,26 @@ var SmoothAgentChat = (function(exports) {
13027
13302
  get connectionStatus() {
13028
13303
  return this.status;
13029
13304
  }
13305
+ /** Conversation id of the live session, or null before connect (voice passes this as `conversation_id`). */
13306
+ get currentConversationId() {
13307
+ return this.conversationId;
13308
+ }
13309
+ /**
13310
+ * Append an already-finalized message to the transcript and emit — the voice
13311
+ * path reuses this so `transcript_final` (user) and `reply_text` (assistant)
13312
+ * turns land in the same message list / render pipeline as typed chat.
13313
+ */
13314
+ appendLocalMessage(role, text) {
13315
+ const trimmed = text.trim();
13316
+ if (!trimmed) return;
13317
+ this.messages.push({
13318
+ id: this.nextId(role === "user" ? "u" : "a"),
13319
+ role,
13320
+ text: trimmed,
13321
+ streaming: false
13322
+ });
13323
+ this.emitMessages();
13324
+ }
13030
13325
  /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
13031
13326
  getStore() {
13032
13327
  return this.store;
@@ -13290,6 +13585,7 @@ var SmoothAgentChat = (function(exports) {
13290
13585
  ...metadata ? { metadata } : {}
13291
13586
  });
13292
13587
  this.sessionId = session.sessionId;
13588
+ this.conversationId = session.conversationId ?? null;
13293
13589
  this.store.getState().setSessionId(session.sessionId);
13294
13590
  }
13295
13591
  /**
@@ -13306,6 +13602,7 @@ var SmoothAgentChat = (function(exports) {
13306
13602
  }
13307
13603
  if (snap.status === "ended") return false;
13308
13604
  this.sessionId = sessionId;
13605
+ this.conversationId = snap.conversationId ?? null;
13309
13606
  await this.hydrateHistory(sessionId);
13310
13607
  return true;
13311
13608
  }
@@ -13649,6 +13946,7 @@ var SmoothAgentChat = (function(exports) {
13649
13946
  this.client?.disconnect("widget closed");
13650
13947
  this.client = null;
13651
13948
  this.sessionId = null;
13949
+ this.conversationId = null;
13652
13950
  this.activeRequestId = null;
13653
13951
  this.resumeAttempted = false;
13654
13952
  this.setInterrupt(null);
@@ -14199,6 +14497,47 @@ ${mode === "fullpage" ? `
14199
14497
  .send:hover { transform: translateY(-1px) scale(1.05); }
14200
14498
  .send:active { transform: scale(.94); }
14201
14499
  .send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
14500
+
14501
+ /* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live. */
14502
+ .mic {
14503
+ width: 38px; height: 38px;
14504
+ flex: none;
14505
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
14506
+ border-radius: 13px;
14507
+ cursor: pointer;
14508
+ display: flex;
14509
+ align-items: center;
14510
+ justify-content: center;
14511
+ background: transparent;
14512
+ color: color-mix(in srgb, var(--sac-text) 65%, transparent);
14513
+ transition: transform .2s var(--sac-ease), color .2s ease, background .25s ease, box-shadow .25s ease;
14514
+ }
14515
+ .mic svg { width: 18px; height: 18px; }
14516
+ .mic:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
14517
+ .mic:active { transform: scale(.94); }
14518
+ .mic.active {
14519
+ border-color: transparent;
14520
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
14521
+ color: var(--sac-primary-text);
14522
+ animation: sac-mic-pulse 1.6s ease-out infinite;
14523
+ }
14524
+ /* Listening → soft expanding ring off the button (mirrors the presence pulse). */
14525
+ @keyframes sac-mic-pulse {
14526
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 45%, transparent); }
14527
+ 70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--sac-primary) 0%, transparent); }
14528
+ 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 0%, transparent); }
14529
+ }
14530
+ /* Agent TTS playing → faster secondary-accent shimmer so "speaking" reads distinctly. */
14531
+ .mic.active.speaking {
14532
+ animation: sac-mic-speaking 0.9s ease-in-out infinite;
14533
+ }
14534
+ @keyframes sac-mic-speaking {
14535
+ 0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-secondary) 35%, transparent); }
14536
+ 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--sac-secondary) 12%, transparent); }
14537
+ }
14538
+ @media (prefers-reduced-motion: reduce) {
14539
+ .mic.active, .mic.active.speaking { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-primary) 40%, transparent); }
14540
+ }
14202
14541
  .footer {
14203
14542
  text-align: center;
14204
14543
  margin-top: 9px;
@@ -14511,7 +14850,9 @@ ${mode === "fullpage" ? `
14511
14850
  /** Tool-confirmation interrupt — a shield. */
14512
14851
  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>`,
14513
14852
  /** Tool-activity chip — a wrench. */
14514
- 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>`
14853
+ 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>`,
14854
+ /** Voice toggle — a microphone. */
14855
+ 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>`
14515
14856
  };
14516
14857
  /**
14517
14858
  * The identity_intake card: the fields the agent requested (pre-chat form field
@@ -14664,6 +15005,11 @@ ${mode === "fullpage" ? `
14664
15005
  _defineProperty(this, "identityRestore", { phase: "idle" });
14665
15006
  _defineProperty(this, "allowChatRestore", true);
14666
15007
  _defineProperty(this, "gating", false);
15008
+ _defineProperty(this, "voiceCfg", {
15009
+ enabled: false,
15010
+ url: ""
15011
+ });
15012
+ _defineProperty(this, "voiceSession", null);
14667
15013
  _defineProperty(this, "panelEl", null);
14668
15014
  _defineProperty(this, "launcherEl", null);
14669
15015
  _defineProperty(this, "messagesEl", null);
@@ -14671,6 +15017,7 @@ ${mode === "fullpage" ? `
14671
15017
  _defineProperty(this, "dotEl", null);
14672
15018
  _defineProperty(this, "inputEl", null);
14673
15019
  _defineProperty(this, "sendBtn", null);
15020
+ _defineProperty(this, "micBtn", null);
14674
15021
  _defineProperty(this, "suggestionsEl", null);
14675
15022
  _defineProperty(this, "streamBubbleEl", null);
14676
15023
  _defineProperty(this, "streamMsgId", null);
@@ -14687,6 +15034,7 @@ ${mode === "fullpage" ? `
14687
15034
  disconnectedCallback() {
14688
15035
  this.mounted = false;
14689
15036
  this.resetReveal();
15037
+ this.stopVoice();
14690
15038
  this.controller?.disconnect();
14691
15039
  this.controller = null;
14692
15040
  }
@@ -14750,6 +15098,7 @@ ${mode === "fullpage" ? `
14750
15098
  allowChatRestore: this.overrides.allowChatRestore,
14751
15099
  allowAnonymous: this.overrides.allowAnonymous,
14752
15100
  showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute("show-tool-activity"),
15101
+ voice: this.overrides.voice,
14753
15102
  theme
14754
15103
  };
14755
15104
  }
@@ -14834,6 +15183,8 @@ ${mode === "fullpage" ? `
14834
15183
  </div>`;
14835
15184
  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(" · ");
14836
15185
  const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : "";
15186
+ this.voiceCfg = resolved.voice;
15187
+ 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>` : "";
14837
15188
  const chatHtml = `
14838
15189
  <div class="messages"></div>
14839
15190
  <div class="reply-suggestions"></div>
@@ -14841,6 +15192,7 @@ ${mode === "fullpage" ? `
14841
15192
  <div class="composer-wrap">
14842
15193
  <div class="composer">
14843
15194
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
15195
+ ${micHtml}
14844
15196
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
14845
15197
  </div>
14846
15198
  ${footerHtml}
@@ -14858,6 +15210,7 @@ ${mode === "fullpage" ? `
14858
15210
  const logoSvg = container.querySelector(".logo-wrap svg");
14859
15211
  if (logoSvg) logoSvg.setAttribute("class", "logo");
14860
15212
  this.resetReveal();
15213
+ this.stopVoice();
14861
15214
  this.root.replaceChildren(style, container);
14862
15215
  this.launcherEl = container.querySelector(".launcher");
14863
15216
  this.panelEl = container.querySelector(".panel");
@@ -14866,11 +15219,13 @@ ${mode === "fullpage" ? `
14866
15219
  this.dotEl = container.querySelector(".dot");
14867
15220
  this.inputEl = container.querySelector("textarea");
14868
15221
  this.sendBtn = container.querySelector(".send");
15222
+ this.micBtn = container.querySelector(".mic");
14869
15223
  this.interruptEl = container.querySelector(".interrupt");
14870
15224
  this.suggestionsEl = container.querySelector(".reply-suggestions");
14871
15225
  this.launcherEl?.addEventListener("click", () => this.openChat());
14872
15226
  container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
14873
15227
  this.sendBtn?.addEventListener("click", () => this.submit());
15228
+ this.micBtn?.addEventListener("click", () => this.toggleVoice());
14874
15229
  this.inputEl?.addEventListener("input", () => this.autosize());
14875
15230
  this.inputEl?.addEventListener("keydown", (ev) => {
14876
15231
  if (ev.key === "Enter" && !ev.shiftKey) {
@@ -15645,6 +16000,84 @@ ${mode === "fullpage" ? `
15645
16000
  if (this.sendBtn) this.sendBtn.disabled = busy;
15646
16001
  if (this.inputEl) this.inputEl.disabled = busy;
15647
16002
  }
16003
+ /**
16004
+ * Mic button: start a voice session, or — when one is live — end it. Hitting
16005
+ * the button while the agent's TTS is playing barges in first (interrupt +
16006
+ * playback flush) so the audio dies instantly, then the session ends.
16007
+ */
16008
+ toggleVoice() {
16009
+ if (this.voiceSession) {
16010
+ if (this.voiceSession.isSpeaking) this.voiceSession.interrupt();
16011
+ this.stopVoice();
16012
+ return;
16013
+ }
16014
+ this.startVoice();
16015
+ }
16016
+ async startVoice() {
16017
+ if (!this.controller || this.voiceSession || !this.voiceCfg.enabled) return;
16018
+ const config = this.readConfig();
16019
+ if (!config) return;
16020
+ const conversationId = this.controller.currentConversationId ?? void 0;
16021
+ const controller = this.controller;
16022
+ const session = new VoiceSession({
16023
+ url: this.voiceCfg.url,
16024
+ agentId: config.agentId,
16025
+ conversationId
16026
+ }, {
16027
+ onTranscriptPartial: (text) => {
16028
+ if (this.inputEl) {
16029
+ this.inputEl.value = text;
16030
+ this.autosize();
16031
+ }
16032
+ },
16033
+ onTranscriptFinal: (text) => {
16034
+ if (this.inputEl) {
16035
+ this.inputEl.value = "";
16036
+ this.autosize();
16037
+ }
16038
+ controller.appendLocalMessage("user", text);
16039
+ },
16040
+ onReplyText: (text) => {
16041
+ controller.appendLocalMessage("assistant", text);
16042
+ },
16043
+ onSpeaking: (speaking) => {
16044
+ this.micBtn?.classList.toggle("speaking", speaking);
16045
+ },
16046
+ onError: () => this.stopVoice(),
16047
+ onEnded: () => {
16048
+ this.voiceSession = null;
16049
+ this.syncVoiceUi(false);
16050
+ }
16051
+ });
16052
+ this.voiceSession = session;
16053
+ this.syncVoiceUi(true);
16054
+ try {
16055
+ await session.start();
16056
+ } catch {
16057
+ this.voiceSession = null;
16058
+ this.syncVoiceUi(false);
16059
+ }
16060
+ }
16061
+ /** End any live voice session and reset the composer UI. Idempotent. */
16062
+ stopVoice() {
16063
+ const session = this.voiceSession;
16064
+ this.voiceSession = null;
16065
+ session?.stop();
16066
+ this.syncVoiceUi(false);
16067
+ }
16068
+ /** Toggle the mic button's listening state + clear the partial transcript. */
16069
+ syncVoiceUi(active) {
16070
+ this.micBtn?.classList.toggle("active", active);
16071
+ this.micBtn?.setAttribute("aria-pressed", String(active));
16072
+ this.micBtn?.setAttribute("aria-label", active ? "Stop voice" : "Start voice");
16073
+ if (!active) {
16074
+ this.micBtn?.classList.remove("speaking");
16075
+ if (this.inputEl && this.inputEl.value) {
16076
+ this.inputEl.value = "";
16077
+ this.autosize();
16078
+ }
16079
+ }
16080
+ }
15648
16081
  submit() {
15649
16082
  if (!this.inputEl || !this.controller) return;
15650
16083
  const text = this.inputEl.value;