@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/chat-widget.global.js +472 -39
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +197 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +499 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +18 -1
- package/src/conversation.ts +23 -1
- package/src/element.ts +114 -1
- package/src/index.ts +19 -1
- package/src/styles.ts +41 -0
- package/src/voice-session.ts +420 -0
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‑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
|