@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.
@@ -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.
@@ -11782,6 +12090,11 @@ var SmoothAgentChat = (function(exports) {
11782
12090
  collectConsent: config.collectConsent ?? true,
11783
12091
  allowChatRestore: config.allowChatRestore ?? true,
11784
12092
  allowAnonymous: config.allowAnonymous ?? false,
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
+ },
11785
12098
  theme: {
11786
12099
  text: theme.text ?? "#f8fafc",
11787
12100
  background: theme.background ?? "#040d30",
@@ -11804,44 +12117,6 @@ var SmoothAgentChat = (function(exports) {
11804
12117
  return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
11805
12118
  }
11806
12119
  //#endregion
11807
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
11808
- function _typeof(o) {
11809
- "@babel/helpers - typeof";
11810
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
11811
- return typeof o;
11812
- } : function(o) {
11813
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
11814
- }, _typeof(o);
11815
- }
11816
- //#endregion
11817
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
11818
- function toPrimitive(t, r) {
11819
- if ("object" != _typeof(t) || !t) return t;
11820
- var e = t[Symbol.toPrimitive];
11821
- if (void 0 !== e) {
11822
- var i = e.call(t, r || "default");
11823
- if ("object" != _typeof(i)) return i;
11824
- throw new TypeError("@@toPrimitive must return a primitive value.");
11825
- }
11826
- return ("string" === r ? String : Number)(t);
11827
- }
11828
- //#endregion
11829
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
11830
- function toPropertyKey(t) {
11831
- var i = toPrimitive(t, "string");
11832
- return "symbol" == _typeof(i) ? i : i + "";
11833
- }
11834
- //#endregion
11835
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
11836
- function _defineProperty(e, r, t) {
11837
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
11838
- value: t,
11839
- enumerable: !0,
11840
- configurable: !0,
11841
- writable: !0
11842
- }) : e[r] = t, e;
11843
- }
11844
- //#endregion
11845
12120
  //#region node_modules/.pnpm/@smooai+smooth-operator@1.21.1/node_modules/@smooai/smooth-operator/dist/transport.js
11846
12121
  /**
11847
12122
  * Transport abstraction for the client.
@@ -12943,6 +13218,59 @@ var SmoothAgentChat = (function(exports) {
12943
13218
  streaming: false
12944
13219
  };
12945
13220
  }
13221
+ let toolSeq = 0;
13222
+ const nextToolId = () => `tool-${++toolSeq}`;
13223
+ /** Grow the trailing text block, or open a new one if the last block was a tool. */
13224
+ function growTextBlock(blocks, text) {
13225
+ if (!text) return;
13226
+ const last = blocks[blocks.length - 1];
13227
+ if (last && last.kind === "text") last.text += text;
13228
+ else blocks.push({
13229
+ kind: "text",
13230
+ text
13231
+ });
13232
+ }
13233
+ /**
13234
+ * Fold a `stream_chunk` node-state into the ordered block list, returning `true`
13235
+ * when the chunk carried tool activity.
13236
+ *
13237
+ * Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
13238
+ * — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
13239
+ * "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
13240
+ */
13241
+ function applyToolChunk(blocks, state) {
13242
+ const raw = state?.rawResponse;
13243
+ if (!raw || typeof raw !== "object") return false;
13244
+ const call = raw.toolCall;
13245
+ const res = raw.toolResult;
13246
+ if (call) {
13247
+ const args = typeof call.arguments === "string" ? call.arguments : JSON.stringify(call.arguments ?? {});
13248
+ blocks.push({
13249
+ kind: "tool",
13250
+ tool: {
13251
+ id: nextToolId(),
13252
+ name: call.name ?? "",
13253
+ args,
13254
+ done: false
13255
+ }
13256
+ });
13257
+ return true;
13258
+ }
13259
+ if (res) {
13260
+ const result = typeof res.result === "string" ? res.result : JSON.stringify(res.result ?? "");
13261
+ for (let i = blocks.length - 1; i >= 0; i--) {
13262
+ const b = blocks[i];
13263
+ if (b && b.kind === "tool" && b.tool.name === (res.name ?? "") && !b.tool.done) {
13264
+ b.tool.done = true;
13265
+ b.tool.isError = !!res.isError;
13266
+ b.tool.result = result;
13267
+ break;
13268
+ }
13269
+ }
13270
+ return true;
13271
+ }
13272
+ return false;
13273
+ }
12946
13274
  var ConversationController = class {
12947
13275
  constructor(config, events, store) {
12948
13276
  _defineProperty(this, "config", void 0);
@@ -12950,6 +13278,7 @@ var SmoothAgentChat = (function(exports) {
12950
13278
  _defineProperty(this, "store", void 0);
12951
13279
  _defineProperty(this, "client", null);
12952
13280
  _defineProperty(this, "sessionId", null);
13281
+ _defineProperty(this, "conversationId", null);
12953
13282
  _defineProperty(this, "messages", []);
12954
13283
  _defineProperty(this, "status", "idle");
12955
13284
  _defineProperty(this, "seq", 0);
@@ -12973,6 +13302,26 @@ var SmoothAgentChat = (function(exports) {
12973
13302
  get connectionStatus() {
12974
13303
  return this.status;
12975
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
+ }
12976
13325
  /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
12977
13326
  getStore() {
12978
13327
  return this.store;
@@ -13236,6 +13585,7 @@ var SmoothAgentChat = (function(exports) {
13236
13585
  ...metadata ? { metadata } : {}
13237
13586
  });
13238
13587
  this.sessionId = session.sessionId;
13588
+ this.conversationId = session.conversationId ?? null;
13239
13589
  this.store.getState().setSessionId(session.sessionId);
13240
13590
  }
13241
13591
  /**
@@ -13252,6 +13602,7 @@ var SmoothAgentChat = (function(exports) {
13252
13602
  }
13253
13603
  if (snap.status === "ended") return false;
13254
13604
  this.sessionId = sessionId;
13605
+ this.conversationId = snap.conversationId ?? null;
13255
13606
  await this.hydrateHistory(sessionId);
13256
13607
  return true;
13257
13608
  }
@@ -13289,11 +13640,13 @@ var SmoothAgentChat = (function(exports) {
13289
13640
  text: trimmed,
13290
13641
  streaming: false
13291
13642
  });
13643
+ const showTools = this.config.showToolActivity === true;
13292
13644
  const assistant = {
13293
13645
  id: this.nextId("a"),
13294
13646
  role: "assistant",
13295
13647
  text: "",
13296
- streaming: true
13648
+ streaming: true,
13649
+ blocks: showTools ? [] : void 0
13297
13650
  };
13298
13651
  this.messages.push(assistant);
13299
13652
  this.emitMessages();
@@ -13308,8 +13661,11 @@ var SmoothAgentChat = (function(exports) {
13308
13661
  const token = event.token ?? event.data?.token ?? "";
13309
13662
  if (token) {
13310
13663
  assistant.text += token;
13664
+ if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
13311
13665
  this.emitMessages();
13312
13666
  }
13667
+ } else if (showTools && event.type === "stream_chunk") {
13668
+ if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) this.emitMessages();
13313
13669
  } else this.handleTurnEvent(event);
13314
13670
  const inner = (await turn).data?.data;
13315
13671
  const finalText = extractFinalText(inner?.response);
@@ -13319,6 +13675,7 @@ var SmoothAgentChat = (function(exports) {
13319
13675
  if (citations.length > 0) assistant.citations = citations;
13320
13676
  const suggestions = extractSuggestions(inner?.response);
13321
13677
  if (suggestions.length > 0) assistant.suggestions = suggestions;
13678
+ if (assistant.blocks && !assistant.blocks.some((b) => b.kind === "tool")) assistant.blocks = void 0;
13322
13679
  assistant.streaming = false;
13323
13680
  this.emitMessages();
13324
13681
  } catch (err) {
@@ -13589,6 +13946,7 @@ var SmoothAgentChat = (function(exports) {
13589
13946
  this.client?.disconnect("widget closed");
13590
13947
  this.client = null;
13591
13948
  this.sessionId = null;
13949
+ this.conversationId = null;
13592
13950
  this.activeRequestId = null;
13593
13951
  this.resumeAttempted = false;
13594
13952
  this.setInterrupt(null);
@@ -13630,6 +13988,7 @@ var SmoothAgentChat = (function(exports) {
13630
13988
  --sac-bg: ${theme.background};
13631
13989
  --sac-primary: ${theme.primary};
13632
13990
  --sac-primary-text: ${theme.primaryText};
13991
+ --sac-secondary: ${theme.secondary};
13633
13992
  --sac-assistant-bubble: ${theme.assistantBubble};
13634
13993
  --sac-assistant-bubble-text: ${theme.assistantBubbleText};
13635
13994
  --sac-user-bubble: ${theme.userBubble};
@@ -13942,6 +14301,47 @@ ${mode === "fullpage" ? `
13942
14301
  }
13943
14302
  @keyframes sac-blink { to { opacity: 0 } }
13944
14303
 
14304
+ /* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles
14305
+ and inline tool chips stacked in the order the model produced them. */
14306
+ .blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
14307
+ .blocks .bubble { align-self: flex-start; }
14308
+ .toolchip {
14309
+ display: inline-flex;
14310
+ align-items: center;
14311
+ gap: 6px;
14312
+ max-width: 100%;
14313
+ padding: 5px 10px;
14314
+ border-radius: 99px;
14315
+ font-size: 12px;
14316
+ line-height: 1.3;
14317
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
14318
+ background: color-mix(in srgb, var(--sac-text) 6%, transparent);
14319
+ border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent);
14320
+ animation: sac-msg-in .3s var(--sac-ease) both;
14321
+ }
14322
+ .toolchip .ti { display: inline-flex; flex: none; opacity: .8; }
14323
+ .toolchip .ti svg { width: 13px; height: 13px; }
14324
+ .toolchip .tn { font-weight: 600; letter-spacing: .01em; }
14325
+ .toolchip .ts { opacity: .7; }
14326
+ .toolchip .ta {
14327
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
14328
+ font-size: 11px;
14329
+ opacity: .6;
14330
+ overflow: hidden;
14331
+ text-overflow: ellipsis;
14332
+ white-space: nowrap;
14333
+ min-width: 0;
14334
+ }
14335
+ .toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); }
14336
+ .toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; }
14337
+ .toolchip.done .ts::before { content: '✓ '; }
14338
+ .toolchip.error {
14339
+ color: var(--sac-secondary);
14340
+ border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent);
14341
+ background: color-mix(in srgb, var(--sac-secondary) 10%, transparent);
14342
+ }
14343
+ .toolchip.error .ts::before { content: '! '; }
14344
+
13945
14345
  /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
13946
14346
  /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
13947
14347
  keep them legible inside the tight Aurora-Glass bubble + citation card. */
@@ -14097,6 +14497,47 @@ ${mode === "fullpage" ? `
14097
14497
  .send:hover { transform: translateY(-1px) scale(1.05); }
14098
14498
  .send:active { transform: scale(.94); }
14099
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
+ }
14100
14541
  .footer {
14101
14542
  text-align: center;
14102
14543
  margin-top: 9px;
@@ -14384,7 +14825,8 @@ ${mode === "fullpage" ? `
14384
14825
  "greeting",
14385
14826
  "start-open",
14386
14827
  "mode",
14387
- "hide-branding"
14828
+ "hide-branding",
14829
+ "show-tool-activity"
14388
14830
  ];
14389
14831
  /**
14390
14832
  * Inline SVG icons (static, trusted strings — never interpolated with user data).
@@ -14406,7 +14848,11 @@ ${mode === "fullpage" ? `
14406
14848
  /** Identity-intake interrupt — a person. */
14407
14849
  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>`,
14408
14850
  /** Tool-confirmation interrupt — a shield. */
14409
- 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>`
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>`,
14852
+ /** Tool-activity chip — a wrench. */
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>`
14410
14856
  };
14411
14857
  /**
14412
14858
  * The identity_intake card: the fields the agent requested (pre-chat form field
@@ -14559,6 +15005,11 @@ ${mode === "fullpage" ? `
14559
15005
  _defineProperty(this, "identityRestore", { phase: "idle" });
14560
15006
  _defineProperty(this, "allowChatRestore", true);
14561
15007
  _defineProperty(this, "gating", false);
15008
+ _defineProperty(this, "voiceCfg", {
15009
+ enabled: false,
15010
+ url: ""
15011
+ });
15012
+ _defineProperty(this, "voiceSession", null);
14562
15013
  _defineProperty(this, "panelEl", null);
14563
15014
  _defineProperty(this, "launcherEl", null);
14564
15015
  _defineProperty(this, "messagesEl", null);
@@ -14566,12 +15017,14 @@ ${mode === "fullpage" ? `
14566
15017
  _defineProperty(this, "dotEl", null);
14567
15018
  _defineProperty(this, "inputEl", null);
14568
15019
  _defineProperty(this, "sendBtn", null);
15020
+ _defineProperty(this, "micBtn", null);
14569
15021
  _defineProperty(this, "suggestionsEl", null);
14570
15022
  _defineProperty(this, "streamBubbleEl", null);
14571
15023
  _defineProperty(this, "streamMsgId", null);
14572
15024
  _defineProperty(this, "streamTarget", "");
14573
15025
  _defineProperty(this, "displayedLength", 0);
14574
15026
  _defineProperty(this, "rafId", 0);
15027
+ _defineProperty(this, "prevBlockSig", "");
14575
15028
  this.root = this.attachShadow({ mode: "open" });
14576
15029
  }
14577
15030
  connectedCallback() {
@@ -14581,6 +15034,7 @@ ${mode === "fullpage" ? `
14581
15034
  disconnectedCallback() {
14582
15035
  this.mounted = false;
14583
15036
  this.resetReveal();
15037
+ this.stopVoice();
14584
15038
  this.controller?.disconnect();
14585
15039
  this.controller = null;
14586
15040
  }
@@ -14643,6 +15097,8 @@ ${mode === "fullpage" ? `
14643
15097
  collectConsent: this.overrides.collectConsent,
14644
15098
  allowChatRestore: this.overrides.allowChatRestore,
14645
15099
  allowAnonymous: this.overrides.allowAnonymous,
15100
+ showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute("show-tool-activity"),
15101
+ voice: this.overrides.voice,
14646
15102
  theme
14647
15103
  };
14648
15104
  }
@@ -14727,6 +15183,8 @@ ${mode === "fullpage" ? `
14727
15183
  </div>`;
14728
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(" · ");
14729
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>` : "";
14730
15188
  const chatHtml = `
14731
15189
  <div class="messages"></div>
14732
15190
  <div class="reply-suggestions"></div>
@@ -14734,6 +15192,7 @@ ${mode === "fullpage" ? `
14734
15192
  <div class="composer-wrap">
14735
15193
  <div class="composer">
14736
15194
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
15195
+ ${micHtml}
14737
15196
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
14738
15197
  </div>
14739
15198
  ${footerHtml}
@@ -14751,6 +15210,7 @@ ${mode === "fullpage" ? `
14751
15210
  const logoSvg = container.querySelector(".logo-wrap svg");
14752
15211
  if (logoSvg) logoSvg.setAttribute("class", "logo");
14753
15212
  this.resetReveal();
15213
+ this.stopVoice();
14754
15214
  this.root.replaceChildren(style, container);
14755
15215
  this.launcherEl = container.querySelector(".launcher");
14756
15216
  this.panelEl = container.querySelector(".panel");
@@ -14759,11 +15219,13 @@ ${mode === "fullpage" ? `
14759
15219
  this.dotEl = container.querySelector(".dot");
14760
15220
  this.inputEl = container.querySelector("textarea");
14761
15221
  this.sendBtn = container.querySelector(".send");
15222
+ this.micBtn = container.querySelector(".mic");
14762
15223
  this.interruptEl = container.querySelector(".interrupt");
14763
15224
  this.suggestionsEl = container.querySelector(".reply-suggestions");
14764
15225
  this.launcherEl?.addEventListener("click", () => this.openChat());
14765
15226
  container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
14766
15227
  this.sendBtn?.addEventListener("click", () => this.submit());
15228
+ this.micBtn?.addEventListener("click", () => this.toggleVoice());
14767
15229
  this.inputEl?.addEventListener("input", () => this.autosize());
14768
15230
  this.inputEl?.addEventListener("keydown", (ev) => {
14769
15231
  if (ev.key === "Enter" && !ev.shiftKey) {
@@ -15182,15 +15644,38 @@ ${mode === "fullpage" ? `
15182
15644
  const prev = this.messages;
15183
15645
  const last = messages[messages.length - 1];
15184
15646
  const prevLast = prev[prev.length - 1];
15185
- 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;
15647
+ 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;
15186
15648
  this.messages = messages;
15187
- if (!structural && last && last.streaming && last.id === this.streamMsgId) {
15188
- this.streamTarget = last.text;
15649
+ this.prevBlockSig = this.blockSig(last);
15650
+ if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
15651
+ this.streamTarget = this.tailText(last);
15189
15652
  this.ensureRevealLoop();
15190
15653
  return;
15191
15654
  }
15192
15655
  this.renderMessages();
15193
15656
  }
15657
+ /** True when an assistant message's turn invoked at least one tool. */
15658
+ hasToolBlocks(m) {
15659
+ return !!m?.blocks?.some((b) => b.kind === "tool");
15660
+ }
15661
+ /** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
15662
+ blockSig(m) {
15663
+ if (!m?.blocks) return "";
15664
+ return m.blocks.map((b) => b.kind === "tool" ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : "x").join("|");
15665
+ }
15666
+ /** The live (last) text block for a tool turn, else the whole message text. */
15667
+ tailText(m) {
15668
+ if (this.hasToolBlocks(m) && m.blocks) {
15669
+ const last = m.blocks[m.blocks.length - 1];
15670
+ return last?.kind === "text" ? last.text : "";
15671
+ }
15672
+ return m.text;
15673
+ }
15674
+ /** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
15675
+ tailKey(m) {
15676
+ if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
15677
+ return m.id;
15678
+ }
15194
15679
  renderMessages() {
15195
15680
  if (!this.messagesEl) return;
15196
15681
  this.resetReveal();
@@ -15211,6 +15696,11 @@ ${mode === "fullpage" ? `
15211
15696
  this.messagesEl.appendChild(chips);
15212
15697
  }
15213
15698
  for (const msg of this.messages) {
15699
+ if (msg.role === "assistant" && this.hasToolBlocks(msg)) {
15700
+ this.messagesEl.appendChild(this.buildRow("assistant", this.renderAssistantBlocks(msg)));
15701
+ if (!msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
15702
+ continue;
15703
+ }
15214
15704
  const bubble = document.createElement("div");
15215
15705
  bubble.className = `bubble ${msg.role}`;
15216
15706
  if (msg.role === "assistant" && msg.streaming && !msg.text) {
@@ -15268,19 +15758,77 @@ ${mode === "fullpage" ? `
15268
15758
  * doesn't restart the reveal from zero), then resumes the loop.
15269
15759
  */
15270
15760
  bindReveal(msg, bubble) {
15271
- const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
15761
+ const key = this.tailKey(msg);
15762
+ const target = this.tailText(msg);
15763
+ const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
15272
15764
  this.streamBubbleEl = bubble;
15273
- this.streamMsgId = msg.id;
15274
- this.streamTarget = msg.text;
15765
+ this.streamMsgId = key;
15766
+ this.streamTarget = target;
15275
15767
  this.displayedLength = carryOver;
15276
15768
  if (this.prefersReducedMotion()) {
15277
- this.displayedLength = msg.text.length;
15278
- bubble.textContent = msg.text;
15769
+ this.displayedLength = target.length;
15770
+ bubble.textContent = target;
15279
15771
  return;
15280
15772
  }
15281
- bubble.textContent = msg.text.slice(0, this.displayedLength);
15773
+ bubble.textContent = target.slice(0, this.displayedLength);
15282
15774
  this.ensureRevealLoop();
15283
15775
  }
15776
+ /**
15777
+ * Render a tool-activity assistant turn as an ordered strip: prose bubbles and
15778
+ * inline tool chips in the order the model produced them (mirrors the daemon
15779
+ * SPA's `blocks`). The live trailing text block (while streaming) binds to the
15780
+ * rAF reveal; earlier/finalized text blocks render as sanitized markdown.
15781
+ */
15782
+ renderAssistantBlocks(msg) {
15783
+ const wrap = document.createElement("div");
15784
+ wrap.className = "blocks";
15785
+ const blocks = msg.blocks ?? [];
15786
+ const lastIdx = blocks.length - 1;
15787
+ blocks.forEach((block, i) => {
15788
+ if (block.kind === "tool") {
15789
+ wrap.appendChild(this.buildToolChip(block.tool));
15790
+ return;
15791
+ }
15792
+ const bubble = document.createElement("div");
15793
+ bubble.className = "bubble assistant";
15794
+ if (msg.streaming && i === lastIdx) {
15795
+ bubble.classList.add("cursor");
15796
+ this.bindReveal(msg, bubble);
15797
+ } else {
15798
+ bubble.classList.add("md");
15799
+ bubble.innerHTML = renderMarkdown(block.text);
15800
+ }
15801
+ wrap.appendChild(bubble);
15802
+ });
15803
+ return wrap;
15804
+ }
15805
+ /**
15806
+ * A single tool-activity chip: icon + tool name + status (running… / done / error),
15807
+ * with a truncated args preview. Tool name/args are set via `textContent` so a
15808
+ * tool payload can never inject markup.
15809
+ */
15810
+ buildToolChip(tool) {
15811
+ const chip = document.createElement("div");
15812
+ chip.className = `toolchip ${tool.done ? tool.isError ? "error" : "done" : "running"}`;
15813
+ chip.setAttribute("part", "tool-chip");
15814
+ const icon = document.createElement("span");
15815
+ icon.className = "ti";
15816
+ icon.innerHTML = ICON.tool;
15817
+ const name = document.createElement("span");
15818
+ name.className = "tn";
15819
+ name.textContent = tool.name || "tool";
15820
+ const status = document.createElement("span");
15821
+ status.className = "ts";
15822
+ status.textContent = tool.done ? tool.isError ? "error" : "done" : "running…";
15823
+ chip.append(icon, name, status);
15824
+ if (tool.args && tool.args !== "{}" && tool.args !== "\"\"") {
15825
+ const args = document.createElement("span");
15826
+ args.className = "ta";
15827
+ args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
15828
+ chip.appendChild(args);
15829
+ }
15830
+ return chip;
15831
+ }
15284
15832
  /** Start the rAF loop if it isn't already running. */
15285
15833
  ensureRevealLoop() {
15286
15834
  if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
@@ -15452,6 +16000,84 @@ ${mode === "fullpage" ? `
15452
16000
  if (this.sendBtn) this.sendBtn.disabled = busy;
15453
16001
  if (this.inputEl) this.inputEl.disabled = busy;
15454
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
+ }
15455
16081
  submit() {
15456
16082
  if (!this.inputEl || !this.controller) return;
15457
16083
  const text = this.inputEl.value;