@smooai/chat-widget 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/chat-widget.global.js +676 -50
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +268 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +703 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +29 -1
- package/src/conversation.ts +116 -2
- package/src/element.ts +252 -15
- package/src/index.ts +21 -1
- package/src/styles.ts +83 -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.
|
|
@@ -342,6 +676,11 @@ function resolveConfig(config) {
|
|
|
342
676
|
collectConsent: config.collectConsent ?? true,
|
|
343
677
|
allowChatRestore: config.allowChatRestore ?? true,
|
|
344
678
|
allowAnonymous: config.allowAnonymous ?? false,
|
|
679
|
+
showToolActivity: config.showToolActivity ?? false,
|
|
680
|
+
voice: {
|
|
681
|
+
enabled: config.voice?.enabled ?? false,
|
|
682
|
+
url: config.voice?.url ?? "wss://twilio-voice.smoo.ai/browser-voice/ws"
|
|
683
|
+
},
|
|
345
684
|
theme: {
|
|
346
685
|
text: theme.text ?? "#f8fafc",
|
|
347
686
|
background: theme.background ?? "#040d30",
|
|
@@ -651,44 +990,6 @@ function createWidgetStore(agentId) {
|
|
|
651
990
|
}));
|
|
652
991
|
}
|
|
653
992
|
//#endregion
|
|
654
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
|
|
655
|
-
function _typeof(o) {
|
|
656
|
-
"@babel/helpers - typeof";
|
|
657
|
-
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
658
|
-
return typeof o;
|
|
659
|
-
} : function(o) {
|
|
660
|
-
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
661
|
-
}, _typeof(o);
|
|
662
|
-
}
|
|
663
|
-
//#endregion
|
|
664
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
|
|
665
|
-
function toPrimitive(t, r) {
|
|
666
|
-
if ("object" != _typeof(t) || !t) return t;
|
|
667
|
-
var e = t[Symbol.toPrimitive];
|
|
668
|
-
if (void 0 !== e) {
|
|
669
|
-
var i = e.call(t, r || "default");
|
|
670
|
-
if ("object" != _typeof(i)) return i;
|
|
671
|
-
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
672
|
-
}
|
|
673
|
-
return ("string" === r ? String : Number)(t);
|
|
674
|
-
}
|
|
675
|
-
//#endregion
|
|
676
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
|
|
677
|
-
function toPropertyKey(t) {
|
|
678
|
-
var i = toPrimitive(t, "string");
|
|
679
|
-
return "symbol" == _typeof(i) ? i : i + "";
|
|
680
|
-
}
|
|
681
|
-
//#endregion
|
|
682
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
|
|
683
|
-
function _defineProperty(e, r, t) {
|
|
684
|
-
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
685
|
-
value: t,
|
|
686
|
-
enumerable: !0,
|
|
687
|
-
configurable: !0,
|
|
688
|
-
writable: !0
|
|
689
|
-
}) : e[r] = t, e;
|
|
690
|
-
}
|
|
691
|
-
//#endregion
|
|
692
993
|
//#region src/conversation.ts
|
|
693
994
|
/**
|
|
694
995
|
* ConversationController — the bridge between the widget UI and the
|
|
@@ -807,6 +1108,59 @@ function wireMessageToChat(m, idx) {
|
|
|
807
1108
|
streaming: false
|
|
808
1109
|
};
|
|
809
1110
|
}
|
|
1111
|
+
let toolSeq = 0;
|
|
1112
|
+
const nextToolId = () => `tool-${++toolSeq}`;
|
|
1113
|
+
/** Grow the trailing text block, or open a new one if the last block was a tool. */
|
|
1114
|
+
function growTextBlock(blocks, text) {
|
|
1115
|
+
if (!text) return;
|
|
1116
|
+
const last = blocks[blocks.length - 1];
|
|
1117
|
+
if (last && last.kind === "text") last.text += text;
|
|
1118
|
+
else blocks.push({
|
|
1119
|
+
kind: "text",
|
|
1120
|
+
text
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Fold a `stream_chunk` node-state into the ordered block list, returning `true`
|
|
1125
|
+
* when the chunk carried tool activity.
|
|
1126
|
+
*
|
|
1127
|
+
* Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
|
|
1128
|
+
* — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
|
|
1129
|
+
* "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
|
|
1130
|
+
*/
|
|
1131
|
+
function applyToolChunk(blocks, state) {
|
|
1132
|
+
const raw = state?.rawResponse;
|
|
1133
|
+
if (!raw || typeof raw !== "object") return false;
|
|
1134
|
+
const call = raw.toolCall;
|
|
1135
|
+
const res = raw.toolResult;
|
|
1136
|
+
if (call) {
|
|
1137
|
+
const args = typeof call.arguments === "string" ? call.arguments : JSON.stringify(call.arguments ?? {});
|
|
1138
|
+
blocks.push({
|
|
1139
|
+
kind: "tool",
|
|
1140
|
+
tool: {
|
|
1141
|
+
id: nextToolId(),
|
|
1142
|
+
name: call.name ?? "",
|
|
1143
|
+
args,
|
|
1144
|
+
done: false
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
if (res) {
|
|
1150
|
+
const result = typeof res.result === "string" ? res.result : JSON.stringify(res.result ?? "");
|
|
1151
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
1152
|
+
const b = blocks[i];
|
|
1153
|
+
if (b && b.kind === "tool" && b.tool.name === (res.name ?? "") && !b.tool.done) {
|
|
1154
|
+
b.tool.done = true;
|
|
1155
|
+
b.tool.isError = !!res.isError;
|
|
1156
|
+
b.tool.result = result;
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
810
1164
|
var ConversationController = class {
|
|
811
1165
|
constructor(config, events, store) {
|
|
812
1166
|
_defineProperty(this, "config", void 0);
|
|
@@ -814,6 +1168,7 @@ var ConversationController = class {
|
|
|
814
1168
|
_defineProperty(this, "store", void 0);
|
|
815
1169
|
_defineProperty(this, "client", null);
|
|
816
1170
|
_defineProperty(this, "sessionId", null);
|
|
1171
|
+
_defineProperty(this, "conversationId", null);
|
|
817
1172
|
_defineProperty(this, "messages", []);
|
|
818
1173
|
_defineProperty(this, "status", "idle");
|
|
819
1174
|
_defineProperty(this, "seq", 0);
|
|
@@ -837,6 +1192,26 @@ var ConversationController = class {
|
|
|
837
1192
|
get connectionStatus() {
|
|
838
1193
|
return this.status;
|
|
839
1194
|
}
|
|
1195
|
+
/** Conversation id of the live session, or null before connect (voice passes this as `conversation_id`). */
|
|
1196
|
+
get currentConversationId() {
|
|
1197
|
+
return this.conversationId;
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Append an already-finalized message to the transcript and emit — the voice
|
|
1201
|
+
* path reuses this so `transcript_final` (user) and `reply_text` (assistant)
|
|
1202
|
+
* turns land in the same message list / render pipeline as typed chat.
|
|
1203
|
+
*/
|
|
1204
|
+
appendLocalMessage(role, text) {
|
|
1205
|
+
const trimmed = text.trim();
|
|
1206
|
+
if (!trimmed) return;
|
|
1207
|
+
this.messages.push({
|
|
1208
|
+
id: this.nextId(role === "user" ? "u" : "a"),
|
|
1209
|
+
role,
|
|
1210
|
+
text: trimmed,
|
|
1211
|
+
streaming: false
|
|
1212
|
+
});
|
|
1213
|
+
this.emitMessages();
|
|
1214
|
+
}
|
|
840
1215
|
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
841
1216
|
getStore() {
|
|
842
1217
|
return this.store;
|
|
@@ -1100,6 +1475,7 @@ var ConversationController = class {
|
|
|
1100
1475
|
...metadata ? { metadata } : {}
|
|
1101
1476
|
});
|
|
1102
1477
|
this.sessionId = session.sessionId;
|
|
1478
|
+
this.conversationId = session.conversationId ?? null;
|
|
1103
1479
|
this.store.getState().setSessionId(session.sessionId);
|
|
1104
1480
|
}
|
|
1105
1481
|
/**
|
|
@@ -1116,6 +1492,7 @@ var ConversationController = class {
|
|
|
1116
1492
|
}
|
|
1117
1493
|
if (snap.status === "ended") return false;
|
|
1118
1494
|
this.sessionId = sessionId;
|
|
1495
|
+
this.conversationId = snap.conversationId ?? null;
|
|
1119
1496
|
await this.hydrateHistory(sessionId);
|
|
1120
1497
|
return true;
|
|
1121
1498
|
}
|
|
@@ -1153,11 +1530,13 @@ var ConversationController = class {
|
|
|
1153
1530
|
text: trimmed,
|
|
1154
1531
|
streaming: false
|
|
1155
1532
|
});
|
|
1533
|
+
const showTools = this.config.showToolActivity === true;
|
|
1156
1534
|
const assistant = {
|
|
1157
1535
|
id: this.nextId("a"),
|
|
1158
1536
|
role: "assistant",
|
|
1159
1537
|
text: "",
|
|
1160
|
-
streaming: true
|
|
1538
|
+
streaming: true,
|
|
1539
|
+
blocks: showTools ? [] : void 0
|
|
1161
1540
|
};
|
|
1162
1541
|
this.messages.push(assistant);
|
|
1163
1542
|
this.emitMessages();
|
|
@@ -1172,8 +1551,11 @@ var ConversationController = class {
|
|
|
1172
1551
|
const token = event.token ?? event.data?.token ?? "";
|
|
1173
1552
|
if (token) {
|
|
1174
1553
|
assistant.text += token;
|
|
1554
|
+
if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
|
|
1175
1555
|
this.emitMessages();
|
|
1176
1556
|
}
|
|
1557
|
+
} else if (showTools && event.type === "stream_chunk") {
|
|
1558
|
+
if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) this.emitMessages();
|
|
1177
1559
|
} else this.handleTurnEvent(event);
|
|
1178
1560
|
const inner = (await turn).data?.data;
|
|
1179
1561
|
const finalText = extractFinalText(inner?.response);
|
|
@@ -1183,6 +1565,7 @@ var ConversationController = class {
|
|
|
1183
1565
|
if (citations.length > 0) assistant.citations = citations;
|
|
1184
1566
|
const suggestions = extractSuggestions(inner?.response);
|
|
1185
1567
|
if (suggestions.length > 0) assistant.suggestions = suggestions;
|
|
1568
|
+
if (assistant.blocks && !assistant.blocks.some((b) => b.kind === "tool")) assistant.blocks = void 0;
|
|
1186
1569
|
assistant.streaming = false;
|
|
1187
1570
|
this.emitMessages();
|
|
1188
1571
|
} catch (err) {
|
|
@@ -1453,6 +1836,7 @@ var ConversationController = class {
|
|
|
1453
1836
|
this.client?.disconnect("widget closed");
|
|
1454
1837
|
this.client = null;
|
|
1455
1838
|
this.sessionId = null;
|
|
1839
|
+
this.conversationId = null;
|
|
1456
1840
|
this.activeRequestId = null;
|
|
1457
1841
|
this.resumeAttempted = false;
|
|
1458
1842
|
this.setInterrupt(null);
|
|
@@ -1494,6 +1878,7 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1494
1878
|
--sac-bg: ${theme.background};
|
|
1495
1879
|
--sac-primary: ${theme.primary};
|
|
1496
1880
|
--sac-primary-text: ${theme.primaryText};
|
|
1881
|
+
--sac-secondary: ${theme.secondary};
|
|
1497
1882
|
--sac-assistant-bubble: ${theme.assistantBubble};
|
|
1498
1883
|
--sac-assistant-bubble-text: ${theme.assistantBubbleText};
|
|
1499
1884
|
--sac-user-bubble: ${theme.userBubble};
|
|
@@ -1806,6 +2191,47 @@ ${mode === "fullpage" ? `
|
|
|
1806
2191
|
}
|
|
1807
2192
|
@keyframes sac-blink { to { opacity: 0 } }
|
|
1808
2193
|
|
|
2194
|
+
/* Interleaved tool-activity strip (gated by showToolActivity): prose bubbles
|
|
2195
|
+
and inline tool chips stacked in the order the model produced them. */
|
|
2196
|
+
.blocks { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
|
|
2197
|
+
.blocks .bubble { align-self: flex-start; }
|
|
2198
|
+
.toolchip {
|
|
2199
|
+
display: inline-flex;
|
|
2200
|
+
align-items: center;
|
|
2201
|
+
gap: 6px;
|
|
2202
|
+
max-width: 100%;
|
|
2203
|
+
padding: 5px 10px;
|
|
2204
|
+
border-radius: 99px;
|
|
2205
|
+
font-size: 12px;
|
|
2206
|
+
line-height: 1.3;
|
|
2207
|
+
color: color-mix(in srgb, var(--sac-text) 78%, transparent);
|
|
2208
|
+
background: color-mix(in srgb, var(--sac-text) 6%, transparent);
|
|
2209
|
+
border: 1px solid color-mix(in srgb, var(--sac-text) 12%, transparent);
|
|
2210
|
+
animation: sac-msg-in .3s var(--sac-ease) both;
|
|
2211
|
+
}
|
|
2212
|
+
.toolchip .ti { display: inline-flex; flex: none; opacity: .8; }
|
|
2213
|
+
.toolchip .ti svg { width: 13px; height: 13px; }
|
|
2214
|
+
.toolchip .tn { font-weight: 600; letter-spacing: .01em; }
|
|
2215
|
+
.toolchip .ts { opacity: .7; }
|
|
2216
|
+
.toolchip .ta {
|
|
2217
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2218
|
+
font-size: 11px;
|
|
2219
|
+
opacity: .6;
|
|
2220
|
+
overflow: hidden;
|
|
2221
|
+
text-overflow: ellipsis;
|
|
2222
|
+
white-space: nowrap;
|
|
2223
|
+
min-width: 0;
|
|
2224
|
+
}
|
|
2225
|
+
.toolchip.running { border-color: color-mix(in srgb, var(--sac-primary) 45%, transparent); }
|
|
2226
|
+
.toolchip.running .ts { color: var(--sac-primary); opacity: 1; animation: sac-typing 1.3s ease-in-out infinite; }
|
|
2227
|
+
.toolchip.done .ts::before { content: '✓ '; }
|
|
2228
|
+
.toolchip.error {
|
|
2229
|
+
color: var(--sac-secondary);
|
|
2230
|
+
border-color: color-mix(in srgb, var(--sac-secondary) 50%, transparent);
|
|
2231
|
+
background: color-mix(in srgb, var(--sac-secondary) 10%, transparent);
|
|
2232
|
+
}
|
|
2233
|
+
.toolchip.error .ts::before { content: '! '; }
|
|
2234
|
+
|
|
1809
2235
|
/* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
|
|
1810
2236
|
/* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
|
|
1811
2237
|
keep them legible inside the tight Aurora-Glass bubble + citation card. */
|
|
@@ -1961,6 +2387,47 @@ ${mode === "fullpage" ? `
|
|
|
1961
2387
|
.send:hover { transform: translateY(-1px) scale(1.05); }
|
|
1962
2388
|
.send:active { transform: scale(.94); }
|
|
1963
2389
|
.send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
|
|
2390
|
+
|
|
2391
|
+
/* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live. */
|
|
2392
|
+
.mic {
|
|
2393
|
+
width: 38px; height: 38px;
|
|
2394
|
+
flex: none;
|
|
2395
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
2396
|
+
border-radius: 13px;
|
|
2397
|
+
cursor: pointer;
|
|
2398
|
+
display: flex;
|
|
2399
|
+
align-items: center;
|
|
2400
|
+
justify-content: center;
|
|
2401
|
+
background: transparent;
|
|
2402
|
+
color: color-mix(in srgb, var(--sac-text) 65%, transparent);
|
|
2403
|
+
transition: transform .2s var(--sac-ease), color .2s ease, background .25s ease, box-shadow .25s ease;
|
|
2404
|
+
}
|
|
2405
|
+
.mic svg { width: 18px; height: 18px; }
|
|
2406
|
+
.mic:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
|
|
2407
|
+
.mic:active { transform: scale(.94); }
|
|
2408
|
+
.mic.active {
|
|
2409
|
+
border-color: transparent;
|
|
2410
|
+
background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
|
|
2411
|
+
color: var(--sac-primary-text);
|
|
2412
|
+
animation: sac-mic-pulse 1.6s ease-out infinite;
|
|
2413
|
+
}
|
|
2414
|
+
/* Listening → soft expanding ring off the button (mirrors the presence pulse). */
|
|
2415
|
+
@keyframes sac-mic-pulse {
|
|
2416
|
+
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 45%, transparent); }
|
|
2417
|
+
70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--sac-primary) 0%, transparent); }
|
|
2418
|
+
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 0%, transparent); }
|
|
2419
|
+
}
|
|
2420
|
+
/* Agent TTS playing → faster secondary-accent shimmer so "speaking" reads distinctly. */
|
|
2421
|
+
.mic.active.speaking {
|
|
2422
|
+
animation: sac-mic-speaking 0.9s ease-in-out infinite;
|
|
2423
|
+
}
|
|
2424
|
+
@keyframes sac-mic-speaking {
|
|
2425
|
+
0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-secondary) 35%, transparent); }
|
|
2426
|
+
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--sac-secondary) 12%, transparent); }
|
|
2427
|
+
}
|
|
2428
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2429
|
+
.mic.active, .mic.active.speaking { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-primary) 40%, transparent); }
|
|
2430
|
+
}
|
|
1964
2431
|
.footer {
|
|
1965
2432
|
text-align: center;
|
|
1966
2433
|
margin-top: 9px;
|
|
@@ -2248,7 +2715,8 @@ const OBSERVED = [
|
|
|
2248
2715
|
"greeting",
|
|
2249
2716
|
"start-open",
|
|
2250
2717
|
"mode",
|
|
2251
|
-
"hide-branding"
|
|
2718
|
+
"hide-branding",
|
|
2719
|
+
"show-tool-activity"
|
|
2252
2720
|
];
|
|
2253
2721
|
/**
|
|
2254
2722
|
* Inline SVG icons (static, trusted strings — never interpolated with user data).
|
|
@@ -2270,7 +2738,11 @@ const ICON = {
|
|
|
2270
2738
|
/** Identity-intake interrupt — a person. */
|
|
2271
2739
|
user: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="8.2" r="3.4" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 19.5c.8-3.1 3.4-4.8 6.5-4.8s5.7 1.7 6.5 4.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`,
|
|
2272
2740
|
/** Tool-confirmation interrupt — a shield. */
|
|
2273
|
-
shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg
|
|
2741
|
+
shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
|
2742
|
+
/** Tool-activity chip — a wrench. */
|
|
2743
|
+
tool: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.7 6.3a3.5 3.5 0 0 0-4.6 4.3l-5 5a1.6 1.6 0 0 0 2.3 2.3l5-5a3.5 3.5 0 0 0 4.3-4.6l-2 2-1.7-.3-.3-1.7 2-2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`,
|
|
2744
|
+
/** Voice toggle — a microphone. */
|
|
2745
|
+
mic: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="9" y="3.5" width="6" height="11" rx="3" stroke="currentColor" stroke-width="1.7"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v2.5M9 20.5h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>`
|
|
2274
2746
|
};
|
|
2275
2747
|
/**
|
|
2276
2748
|
* The identity_intake card: the fields the agent requested (pre-chat form field
|
|
@@ -2423,6 +2895,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2423
2895
|
_defineProperty(this, "identityRestore", { phase: "idle" });
|
|
2424
2896
|
_defineProperty(this, "allowChatRestore", true);
|
|
2425
2897
|
_defineProperty(this, "gating", false);
|
|
2898
|
+
_defineProperty(this, "voiceCfg", {
|
|
2899
|
+
enabled: false,
|
|
2900
|
+
url: ""
|
|
2901
|
+
});
|
|
2902
|
+
_defineProperty(this, "voiceSession", null);
|
|
2426
2903
|
_defineProperty(this, "panelEl", null);
|
|
2427
2904
|
_defineProperty(this, "launcherEl", null);
|
|
2428
2905
|
_defineProperty(this, "messagesEl", null);
|
|
@@ -2430,12 +2907,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2430
2907
|
_defineProperty(this, "dotEl", null);
|
|
2431
2908
|
_defineProperty(this, "inputEl", null);
|
|
2432
2909
|
_defineProperty(this, "sendBtn", null);
|
|
2910
|
+
_defineProperty(this, "micBtn", null);
|
|
2433
2911
|
_defineProperty(this, "suggestionsEl", null);
|
|
2434
2912
|
_defineProperty(this, "streamBubbleEl", null);
|
|
2435
2913
|
_defineProperty(this, "streamMsgId", null);
|
|
2436
2914
|
_defineProperty(this, "streamTarget", "");
|
|
2437
2915
|
_defineProperty(this, "displayedLength", 0);
|
|
2438
2916
|
_defineProperty(this, "rafId", 0);
|
|
2917
|
+
_defineProperty(this, "prevBlockSig", "");
|
|
2439
2918
|
this.root = this.attachShadow({ mode: "open" });
|
|
2440
2919
|
}
|
|
2441
2920
|
connectedCallback() {
|
|
@@ -2445,6 +2924,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2445
2924
|
disconnectedCallback() {
|
|
2446
2925
|
this.mounted = false;
|
|
2447
2926
|
this.resetReveal();
|
|
2927
|
+
this.stopVoice();
|
|
2448
2928
|
this.controller?.disconnect();
|
|
2449
2929
|
this.controller = null;
|
|
2450
2930
|
}
|
|
@@ -2507,6 +2987,8 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2507
2987
|
collectConsent: this.overrides.collectConsent,
|
|
2508
2988
|
allowChatRestore: this.overrides.allowChatRestore,
|
|
2509
2989
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
2990
|
+
showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute("show-tool-activity"),
|
|
2991
|
+
voice: this.overrides.voice,
|
|
2510
2992
|
theme
|
|
2511
2993
|
};
|
|
2512
2994
|
}
|
|
@@ -2591,6 +3073,8 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2591
3073
|
</div>`;
|
|
2592
3074
|
const footerInner = [resolved.hideBranding ? "" : `<a href="${SMOOTH_OPERATOR_URL}" target="_blank" rel="noopener noreferrer">powered by <b>smooth‑operator</b></a>`, this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : ""].filter(Boolean).join(" · ");
|
|
2593
3075
|
const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : "";
|
|
3076
|
+
this.voiceCfg = resolved.voice;
|
|
3077
|
+
const micHtml = resolved.voice.enabled ? `<button class="mic" type="button" aria-label="Start voice" aria-pressed="false" title="Talk to ${escapeHtml(resolved.agentName)}">${ICON.mic}</button>` : "";
|
|
2594
3078
|
const chatHtml = `
|
|
2595
3079
|
<div class="messages"></div>
|
|
2596
3080
|
<div class="reply-suggestions"></div>
|
|
@@ -2598,6 +3082,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2598
3082
|
<div class="composer-wrap">
|
|
2599
3083
|
<div class="composer">
|
|
2600
3084
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
3085
|
+
${micHtml}
|
|
2601
3086
|
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
|
|
2602
3087
|
</div>
|
|
2603
3088
|
${footerHtml}
|
|
@@ -2615,6 +3100,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2615
3100
|
const logoSvg = container.querySelector(".logo-wrap svg");
|
|
2616
3101
|
if (logoSvg) logoSvg.setAttribute("class", "logo");
|
|
2617
3102
|
this.resetReveal();
|
|
3103
|
+
this.stopVoice();
|
|
2618
3104
|
this.root.replaceChildren(style, container);
|
|
2619
3105
|
this.launcherEl = container.querySelector(".launcher");
|
|
2620
3106
|
this.panelEl = container.querySelector(".panel");
|
|
@@ -2623,11 +3109,13 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2623
3109
|
this.dotEl = container.querySelector(".dot");
|
|
2624
3110
|
this.inputEl = container.querySelector("textarea");
|
|
2625
3111
|
this.sendBtn = container.querySelector(".send");
|
|
3112
|
+
this.micBtn = container.querySelector(".mic");
|
|
2626
3113
|
this.interruptEl = container.querySelector(".interrupt");
|
|
2627
3114
|
this.suggestionsEl = container.querySelector(".reply-suggestions");
|
|
2628
3115
|
this.launcherEl?.addEventListener("click", () => this.openChat());
|
|
2629
3116
|
container.querySelector(".close")?.addEventListener("click", () => this.closeChat());
|
|
2630
3117
|
this.sendBtn?.addEventListener("click", () => this.submit());
|
|
3118
|
+
this.micBtn?.addEventListener("click", () => this.toggleVoice());
|
|
2631
3119
|
this.inputEl?.addEventListener("input", () => this.autosize());
|
|
2632
3120
|
this.inputEl?.addEventListener("keydown", (ev) => {
|
|
2633
3121
|
if (ev.key === "Enter" && !ev.shiftKey) {
|
|
@@ -3046,15 +3534,38 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
3046
3534
|
const prev = this.messages;
|
|
3047
3535
|
const last = messages[messages.length - 1];
|
|
3048
3536
|
const prevLast = prev[prev.length - 1];
|
|
3049
|
-
const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text;
|
|
3537
|
+
const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text || this.blockSig(last) !== this.prevBlockSig;
|
|
3050
3538
|
this.messages = messages;
|
|
3051
|
-
|
|
3052
|
-
|
|
3539
|
+
this.prevBlockSig = this.blockSig(last);
|
|
3540
|
+
if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
|
|
3541
|
+
this.streamTarget = this.tailText(last);
|
|
3053
3542
|
this.ensureRevealLoop();
|
|
3054
3543
|
return;
|
|
3055
3544
|
}
|
|
3056
3545
|
this.renderMessages();
|
|
3057
3546
|
}
|
|
3547
|
+
/** True when an assistant message's turn invoked at least one tool. */
|
|
3548
|
+
hasToolBlocks(m) {
|
|
3549
|
+
return !!m?.blocks?.some((b) => b.kind === "tool");
|
|
3550
|
+
}
|
|
3551
|
+
/** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
|
|
3552
|
+
blockSig(m) {
|
|
3553
|
+
if (!m?.blocks) return "";
|
|
3554
|
+
return m.blocks.map((b) => b.kind === "tool" ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : "x").join("|");
|
|
3555
|
+
}
|
|
3556
|
+
/** The live (last) text block for a tool turn, else the whole message text. */
|
|
3557
|
+
tailText(m) {
|
|
3558
|
+
if (this.hasToolBlocks(m) && m.blocks) {
|
|
3559
|
+
const last = m.blocks[m.blocks.length - 1];
|
|
3560
|
+
return last?.kind === "text" ? last.text : "";
|
|
3561
|
+
}
|
|
3562
|
+
return m.text;
|
|
3563
|
+
}
|
|
3564
|
+
/** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
|
|
3565
|
+
tailKey(m) {
|
|
3566
|
+
if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
|
|
3567
|
+
return m.id;
|
|
3568
|
+
}
|
|
3058
3569
|
renderMessages() {
|
|
3059
3570
|
if (!this.messagesEl) return;
|
|
3060
3571
|
this.resetReveal();
|
|
@@ -3075,6 +3586,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
3075
3586
|
this.messagesEl.appendChild(chips);
|
|
3076
3587
|
}
|
|
3077
3588
|
for (const msg of this.messages) {
|
|
3589
|
+
if (msg.role === "assistant" && this.hasToolBlocks(msg)) {
|
|
3590
|
+
this.messagesEl.appendChild(this.buildRow("assistant", this.renderAssistantBlocks(msg)));
|
|
3591
|
+
if (!msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
3592
|
+
continue;
|
|
3593
|
+
}
|
|
3078
3594
|
const bubble = document.createElement("div");
|
|
3079
3595
|
bubble.className = `bubble ${msg.role}`;
|
|
3080
3596
|
if (msg.role === "assistant" && msg.streaming && !msg.text) {
|
|
@@ -3132,19 +3648,77 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
3132
3648
|
* doesn't restart the reveal from zero), then resumes the loop.
|
|
3133
3649
|
*/
|
|
3134
3650
|
bindReveal(msg, bubble) {
|
|
3135
|
-
const
|
|
3651
|
+
const key = this.tailKey(msg);
|
|
3652
|
+
const target = this.tailText(msg);
|
|
3653
|
+
const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
|
|
3136
3654
|
this.streamBubbleEl = bubble;
|
|
3137
|
-
this.streamMsgId =
|
|
3138
|
-
this.streamTarget =
|
|
3655
|
+
this.streamMsgId = key;
|
|
3656
|
+
this.streamTarget = target;
|
|
3139
3657
|
this.displayedLength = carryOver;
|
|
3140
3658
|
if (this.prefersReducedMotion()) {
|
|
3141
|
-
this.displayedLength =
|
|
3142
|
-
bubble.textContent =
|
|
3659
|
+
this.displayedLength = target.length;
|
|
3660
|
+
bubble.textContent = target;
|
|
3143
3661
|
return;
|
|
3144
3662
|
}
|
|
3145
|
-
bubble.textContent =
|
|
3663
|
+
bubble.textContent = target.slice(0, this.displayedLength);
|
|
3146
3664
|
this.ensureRevealLoop();
|
|
3147
3665
|
}
|
|
3666
|
+
/**
|
|
3667
|
+
* Render a tool-activity assistant turn as an ordered strip: prose bubbles and
|
|
3668
|
+
* inline tool chips in the order the model produced them (mirrors the daemon
|
|
3669
|
+
* SPA's `blocks`). The live trailing text block (while streaming) binds to the
|
|
3670
|
+
* rAF reveal; earlier/finalized text blocks render as sanitized markdown.
|
|
3671
|
+
*/
|
|
3672
|
+
renderAssistantBlocks(msg) {
|
|
3673
|
+
const wrap = document.createElement("div");
|
|
3674
|
+
wrap.className = "blocks";
|
|
3675
|
+
const blocks = msg.blocks ?? [];
|
|
3676
|
+
const lastIdx = blocks.length - 1;
|
|
3677
|
+
blocks.forEach((block, i) => {
|
|
3678
|
+
if (block.kind === "tool") {
|
|
3679
|
+
wrap.appendChild(this.buildToolChip(block.tool));
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
const bubble = document.createElement("div");
|
|
3683
|
+
bubble.className = "bubble assistant";
|
|
3684
|
+
if (msg.streaming && i === lastIdx) {
|
|
3685
|
+
bubble.classList.add("cursor");
|
|
3686
|
+
this.bindReveal(msg, bubble);
|
|
3687
|
+
} else {
|
|
3688
|
+
bubble.classList.add("md");
|
|
3689
|
+
bubble.innerHTML = renderMarkdown(block.text);
|
|
3690
|
+
}
|
|
3691
|
+
wrap.appendChild(bubble);
|
|
3692
|
+
});
|
|
3693
|
+
return wrap;
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* A single tool-activity chip: icon + tool name + status (running… / done / error),
|
|
3697
|
+
* with a truncated args preview. Tool name/args are set via `textContent` so a
|
|
3698
|
+
* tool payload can never inject markup.
|
|
3699
|
+
*/
|
|
3700
|
+
buildToolChip(tool) {
|
|
3701
|
+
const chip = document.createElement("div");
|
|
3702
|
+
chip.className = `toolchip ${tool.done ? tool.isError ? "error" : "done" : "running"}`;
|
|
3703
|
+
chip.setAttribute("part", "tool-chip");
|
|
3704
|
+
const icon = document.createElement("span");
|
|
3705
|
+
icon.className = "ti";
|
|
3706
|
+
icon.innerHTML = ICON.tool;
|
|
3707
|
+
const name = document.createElement("span");
|
|
3708
|
+
name.className = "tn";
|
|
3709
|
+
name.textContent = tool.name || "tool";
|
|
3710
|
+
const status = document.createElement("span");
|
|
3711
|
+
status.className = "ts";
|
|
3712
|
+
status.textContent = tool.done ? tool.isError ? "error" : "done" : "running…";
|
|
3713
|
+
chip.append(icon, name, status);
|
|
3714
|
+
if (tool.args && tool.args !== "{}" && tool.args !== "\"\"") {
|
|
3715
|
+
const args = document.createElement("span");
|
|
3716
|
+
args.className = "ta";
|
|
3717
|
+
args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
|
|
3718
|
+
chip.appendChild(args);
|
|
3719
|
+
}
|
|
3720
|
+
return chip;
|
|
3721
|
+
}
|
|
3148
3722
|
/** Start the rAF loop if it isn't already running. */
|
|
3149
3723
|
ensureRevealLoop() {
|
|
3150
3724
|
if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
|
|
@@ -3316,6 +3890,84 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
3316
3890
|
if (this.sendBtn) this.sendBtn.disabled = busy;
|
|
3317
3891
|
if (this.inputEl) this.inputEl.disabled = busy;
|
|
3318
3892
|
}
|
|
3893
|
+
/**
|
|
3894
|
+
* Mic button: start a voice session, or — when one is live — end it. Hitting
|
|
3895
|
+
* the button while the agent's TTS is playing barges in first (interrupt +
|
|
3896
|
+
* playback flush) so the audio dies instantly, then the session ends.
|
|
3897
|
+
*/
|
|
3898
|
+
toggleVoice() {
|
|
3899
|
+
if (this.voiceSession) {
|
|
3900
|
+
if (this.voiceSession.isSpeaking) this.voiceSession.interrupt();
|
|
3901
|
+
this.stopVoice();
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
this.startVoice();
|
|
3905
|
+
}
|
|
3906
|
+
async startVoice() {
|
|
3907
|
+
if (!this.controller || this.voiceSession || !this.voiceCfg.enabled) return;
|
|
3908
|
+
const config = this.readConfig();
|
|
3909
|
+
if (!config) return;
|
|
3910
|
+
const conversationId = this.controller.currentConversationId ?? void 0;
|
|
3911
|
+
const controller = this.controller;
|
|
3912
|
+
const session = new VoiceSession({
|
|
3913
|
+
url: this.voiceCfg.url,
|
|
3914
|
+
agentId: config.agentId,
|
|
3915
|
+
conversationId
|
|
3916
|
+
}, {
|
|
3917
|
+
onTranscriptPartial: (text) => {
|
|
3918
|
+
if (this.inputEl) {
|
|
3919
|
+
this.inputEl.value = text;
|
|
3920
|
+
this.autosize();
|
|
3921
|
+
}
|
|
3922
|
+
},
|
|
3923
|
+
onTranscriptFinal: (text) => {
|
|
3924
|
+
if (this.inputEl) {
|
|
3925
|
+
this.inputEl.value = "";
|
|
3926
|
+
this.autosize();
|
|
3927
|
+
}
|
|
3928
|
+
controller.appendLocalMessage("user", text);
|
|
3929
|
+
},
|
|
3930
|
+
onReplyText: (text) => {
|
|
3931
|
+
controller.appendLocalMessage("assistant", text);
|
|
3932
|
+
},
|
|
3933
|
+
onSpeaking: (speaking) => {
|
|
3934
|
+
this.micBtn?.classList.toggle("speaking", speaking);
|
|
3935
|
+
},
|
|
3936
|
+
onError: () => this.stopVoice(),
|
|
3937
|
+
onEnded: () => {
|
|
3938
|
+
this.voiceSession = null;
|
|
3939
|
+
this.syncVoiceUi(false);
|
|
3940
|
+
}
|
|
3941
|
+
});
|
|
3942
|
+
this.voiceSession = session;
|
|
3943
|
+
this.syncVoiceUi(true);
|
|
3944
|
+
try {
|
|
3945
|
+
await session.start();
|
|
3946
|
+
} catch {
|
|
3947
|
+
this.voiceSession = null;
|
|
3948
|
+
this.syncVoiceUi(false);
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
/** End any live voice session and reset the composer UI. Idempotent. */
|
|
3952
|
+
stopVoice() {
|
|
3953
|
+
const session = this.voiceSession;
|
|
3954
|
+
this.voiceSession = null;
|
|
3955
|
+
session?.stop();
|
|
3956
|
+
this.syncVoiceUi(false);
|
|
3957
|
+
}
|
|
3958
|
+
/** Toggle the mic button's listening state + clear the partial transcript. */
|
|
3959
|
+
syncVoiceUi(active) {
|
|
3960
|
+
this.micBtn?.classList.toggle("active", active);
|
|
3961
|
+
this.micBtn?.setAttribute("aria-pressed", String(active));
|
|
3962
|
+
this.micBtn?.setAttribute("aria-label", active ? "Stop voice" : "Start voice");
|
|
3963
|
+
if (!active) {
|
|
3964
|
+
this.micBtn?.classList.remove("speaking");
|
|
3965
|
+
if (this.inputEl && this.inputEl.value) {
|
|
3966
|
+
this.inputEl.value = "";
|
|
3967
|
+
this.autosize();
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3319
3971
|
submit() {
|
|
3320
3972
|
if (!this.inputEl || !this.controller) return;
|
|
3321
3973
|
const text = this.inputEl.value;
|
|
@@ -3498,6 +4150,6 @@ function initChatWidgetLoader() {
|
|
|
3498
4150
|
else window.addEventListener("load", schedule, { once: true });
|
|
3499
4151
|
}
|
|
3500
4152
|
//#endregion
|
|
3501
|
-
export { ConversationController, ELEMENT_TAG, PERSIST_VERSION, SmoothAgentChatElement, computeFingerprint, createWidgetStore, defineChatWidget, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, storageKey };
|
|
4153
|
+
export { ConversationController, DEFAULT_VOICE_URL, ELEMENT_TAG, PERSIST_VERSION, PcmPlayer, SmoothAgentChatElement, VOICE_SAMPLE_RATE, VoiceSession, computeFingerprint, createWidgetStore, defineChatWidget, downsampleTo16k, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, rmsLevel, storageKey };
|
|
3502
4154
|
|
|
3503
4155
|
//# sourceMappingURL=index.js.map
|