@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/chat-widget",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Embeddable AI chat as a framework-light web component — the Aurora Glass design, streaming replies, grounded sources, and per-brand theming. Speaks the smooth-operator WebSocket protocol.",
5
5
  "license": "MIT",
6
6
  "author": "SmooAI",
package/src/config.ts CHANGED
@@ -6,6 +6,19 @@
6
6
  * {@link mountChatWidget} / `element.configure(...)`).
7
7
  */
8
8
  import { safeHttpUrl } from './markdown.js';
9
+ import { DEFAULT_VOICE_URL } from './voice-session.js';
10
+
11
+ /**
12
+ * Browser voice input/output (SMOODEV-2534). OFF by default — when disabled the
13
+ * widget renders zero voice UI. When enabled, a mic toggle appears in the
14
+ * composer and speech flows over the browser-voice WebSocket.
15
+ */
16
+ export interface ChatWidgetVoiceConfig {
17
+ /** Turn the voice feature on. Default `false`. */
18
+ enabled?: boolean;
19
+ /** Browser-voice WS endpoint. Defaults to the hosted SmooAI voice service. */
20
+ url?: string;
21
+ }
9
22
 
10
23
  export interface ChatWidgetTheme {
11
24
  /** Foreground text color for the widget chrome. */
@@ -151,6 +164,8 @@ export interface ChatWidgetConfig {
151
164
  * surfaces where seeing what the agent did mid-turn is valuable.
152
165
  */
153
166
  showToolActivity?: boolean;
167
+ /** Browser voice input/output. OFF by default (zero UI when off). */
168
+ voice?: ChatWidgetVoiceConfig;
154
169
  /** Theme overrides. */
155
170
  theme?: ChatWidgetTheme;
156
171
  }
@@ -158,8 +173,9 @@ export interface ChatWidgetConfig {
158
173
  /** The fully-resolved theme (canonical keys only — aliases are folded in). */
159
174
  export type ResolvedTheme = Required<Omit<ChatWidgetTheme, 'chatBubbleInbound' | 'chatBubbleInboundText' | 'chatBubbleOutbound' | 'chatBubbleOutboundText'>>;
160
175
 
161
- export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl'>> & {
176
+ export type ResolvedConfig = Required<Omit<ChatWidgetConfig, 'theme' | 'userName' | 'userEmail' | 'userPhone' | 'authContext' | 'logoUrl' | 'voice'>> & {
162
177
  theme: ResolvedTheme;
178
+ voice: { enabled: boolean; url: string };
163
179
  userName?: string;
164
180
  userEmail?: string;
165
181
  userPhone?: string;
@@ -205,6 +221,7 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
205
221
  allowChatRestore: config.allowChatRestore ?? true,
206
222
  allowAnonymous: config.allowAnonymous ?? false,
207
223
  showToolActivity: config.showToolActivity ?? false,
224
+ voice: { enabled: config.voice?.enabled ?? false, url: config.voice?.url ?? DEFAULT_VOICE_URL },
208
225
  theme: {
209
226
  text: theme.text ?? '#f8fafc',
210
227
  background: theme.background ?? '#040d30',
@@ -328,6 +328,8 @@ export class ConversationController {
328
328
  private readonly store: StoreApi<WidgetStore>;
329
329
  private client: SmoothAgentClient | null = null;
330
330
  private sessionId: string | null = null;
331
+ /** Conversation id of the live session (create or resume) — lets voice join the same thread. */
332
+ private conversationId: string | null = null;
331
333
  private readonly messages: ChatMessage[] = [];
332
334
  private status: ConnectionStatus = 'idle';
333
335
  private seq = 0;
@@ -384,6 +386,23 @@ export class ConversationController {
384
386
  return this.status;
385
387
  }
386
388
 
389
+ /** Conversation id of the live session, or null before connect (voice passes this as `conversation_id`). */
390
+ get currentConversationId(): string | null {
391
+ return this.conversationId;
392
+ }
393
+
394
+ /**
395
+ * Append an already-finalized message to the transcript and emit — the voice
396
+ * path reuses this so `transcript_final` (user) and `reply_text` (assistant)
397
+ * turns land in the same message list / render pipeline as typed chat.
398
+ */
399
+ appendLocalMessage(role: Role, text: string): void {
400
+ const trimmed = text.trim();
401
+ if (!trimmed) return;
402
+ this.messages.push({ id: this.nextId(role === 'user' ? 'u' : 'a'), role, text: trimmed, streaming: false });
403
+ this.emitMessages();
404
+ }
405
+
387
406
  /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
388
407
  getStore(): StoreApi<WidgetStore> {
389
408
  return this.store;
@@ -690,6 +709,7 @@ export class ConversationController {
690
709
  ...(metadata ? { metadata } : {}),
691
710
  });
692
711
  this.sessionId = session.sessionId;
712
+ this.conversationId = session.conversationId ?? null;
693
713
  this.store.getState().setSessionId(session.sessionId);
694
714
  }
695
715
 
@@ -699,7 +719,7 @@ export class ConversationController {
699
719
  */
700
720
  private async tryResume(sessionId: string): Promise<boolean> {
701
721
  if (!this.client) return false;
702
- let snap: { status?: 'active' | 'idle' | 'ended' };
722
+ let snap: { status?: 'active' | 'idle' | 'ended'; conversationId?: string };
703
723
  try {
704
724
  snap = await this.client.getSession({ sessionId });
705
725
  } catch {
@@ -708,6 +728,7 @@ export class ConversationController {
708
728
  if (snap.status === 'ended') return false;
709
729
 
710
730
  this.sessionId = sessionId;
731
+ this.conversationId = snap.conversationId ?? null;
711
732
  await this.hydrateHistory(sessionId);
712
733
  return true;
713
734
  }
@@ -1052,6 +1073,7 @@ export class ConversationController {
1052
1073
  this.client?.disconnect('widget closed');
1053
1074
  this.client = null;
1054
1075
  this.sessionId = null;
1076
+ this.conversationId = null;
1055
1077
  this.activeRequestId = null;
1056
1078
  // A full teardown ends the controller lifecycle: a subsequent connect() is a
1057
1079
  // genuine re-open and may resume again, so re-arm the resume probe.
package/src/element.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import { SMOOTH_ICON_SVG } from './logo.js';
31
31
  import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
32
32
  import { buildStyles } from './styles.js';
33
+ import { VoiceSession } from './voice-session.js';
33
34
 
34
35
  export const ELEMENT_TAG = 'smooth-agent-chat';
35
36
 
@@ -86,6 +87,8 @@ const ICON = {
86
87
  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>`,
87
88
  /** Tool-activity chip — a wrench. */
88
89
  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>`,
90
+ /** Voice toggle — a microphone. */
91
+ 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>`,
89
92
  } as const;
90
93
 
91
94
  /**
@@ -277,6 +280,10 @@ export class SmoothAgentChatElement extends HTMLElement {
277
280
  private allowChatRestore = true;
278
281
  /** True while the pre-chat identity gate is showing (blocks premature connect). */
279
282
  private gating = false;
283
+ /** Voice config (SMOODEV-2534) — enabled=false renders zero voice UI. */
284
+ private voiceCfg: { enabled: boolean; url: string } = { enabled: false, url: '' };
285
+ /** Live voice session, or null when voice is off. */
286
+ private voiceSession: VoiceSession | null = null;
280
287
 
281
288
  // Cached DOM refs (populated in render()).
282
289
  private panelEl: HTMLElement | null = null;
@@ -286,6 +293,7 @@ export class SmoothAgentChatElement extends HTMLElement {
286
293
  private dotEl: HTMLElement | null = null;
287
294
  private inputEl: HTMLTextAreaElement | null = null;
288
295
  private sendBtn: HTMLButtonElement | null = null;
296
+ private micBtn: HTMLButtonElement | null = null;
289
297
  private suggestionsEl: HTMLElement | null = null;
290
298
 
291
299
  // ── Smooth streaming reveal ──
@@ -321,6 +329,7 @@ export class SmoothAgentChatElement extends HTMLElement {
321
329
  disconnectedCallback(): void {
322
330
  this.mounted = false;
323
331
  this.resetReveal();
332
+ this.stopVoice();
324
333
  this.controller?.disconnect();
325
334
  this.controller = null;
326
335
  }
@@ -392,6 +401,7 @@ export class SmoothAgentChatElement extends HTMLElement {
392
401
  allowChatRestore: this.overrides.allowChatRestore,
393
402
  allowAnonymous: this.overrides.allowAnonymous,
394
403
  showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'),
404
+ voice: this.overrides.voice,
395
405
  theme,
396
406
  };
397
407
  }
@@ -527,6 +537,11 @@ export class SmoothAgentChatElement extends HTMLElement {
527
537
  const restoreBtn = this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : '';
528
538
  const footerInner = [brandingHtml, restoreBtn].filter(Boolean).join(' · ');
529
539
  const footerHtml = footerInner ? `<div class="footer">${footerInner}</div>` : '';
540
+ // Voice (SMOODEV-2534): mic toggle in the composer, only when enabled.
541
+ this.voiceCfg = resolved.voice;
542
+ const micHtml = resolved.voice.enabled
543
+ ? `<button class="mic" type="button" aria-label="Start voice" aria-pressed="false" title="Talk to ${escapeHtml(resolved.agentName)}">${ICON.mic}</button>`
544
+ : '';
530
545
  const chatHtml = `
531
546
  <div class="messages"></div>
532
547
  <div class="reply-suggestions"></div>
@@ -534,6 +549,7 @@ export class SmoothAgentChatElement extends HTMLElement {
534
549
  <div class="composer-wrap">
535
550
  <div class="composer">
536
551
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
552
+ ${micHtml}
537
553
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
538
554
  </div>
539
555
  ${footerHtml}
@@ -555,8 +571,10 @@ export class SmoothAgentChatElement extends HTMLElement {
555
571
  if (logoSvg) logoSvg.setAttribute('class', 'logo');
556
572
 
557
573
  // A full DOM rebuild invalidates any cached streaming-bubble ref; cancel
558
- // the in-flight reveal loop so renderMessages can re-bind cleanly.
574
+ // the in-flight reveal loop so renderMessages can re-bind cleanly. A live
575
+ // voice session is bound to the old composer's mic button — end it too.
559
576
  this.resetReveal();
577
+ this.stopVoice();
560
578
  this.root.replaceChildren(style, container);
561
579
 
562
580
  this.launcherEl = container.querySelector('.launcher');
@@ -566,12 +584,14 @@ export class SmoothAgentChatElement extends HTMLElement {
566
584
  this.dotEl = container.querySelector('.dot');
567
585
  this.inputEl = container.querySelector('textarea');
568
586
  this.sendBtn = container.querySelector('.send');
587
+ this.micBtn = container.querySelector('.mic');
569
588
  this.interruptEl = container.querySelector('.interrupt');
570
589
  this.suggestionsEl = container.querySelector('.reply-suggestions');
571
590
 
572
591
  this.launcherEl?.addEventListener('click', () => this.openChat());
573
592
  container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
574
593
  this.sendBtn?.addEventListener('click', () => this.submit());
594
+ this.micBtn?.addEventListener('click', () => this.toggleVoice());
575
595
  this.inputEl?.addEventListener('input', () => this.autosize());
576
596
  this.inputEl?.addEventListener('keydown', (ev) => {
577
597
  if (ev.key === 'Enter' && !ev.shiftKey) {
@@ -1525,6 +1545,99 @@ export class SmoothAgentChatElement extends HTMLElement {
1525
1545
  if (this.inputEl) this.inputEl.disabled = busy;
1526
1546
  }
1527
1547
 
1548
+ // ───────────────────────── Voice (SMOODEV-2534) ─────────────────────────────
1549
+
1550
+ /**
1551
+ * Mic button: start a voice session, or — when one is live — end it. Hitting
1552
+ * the button while the agent's TTS is playing barges in first (interrupt +
1553
+ * playback flush) so the audio dies instantly, then the session ends.
1554
+ */
1555
+ private toggleVoice(): void {
1556
+ if (this.voiceSession) {
1557
+ if (this.voiceSession.isSpeaking) this.voiceSession.interrupt();
1558
+ this.stopVoice();
1559
+ return;
1560
+ }
1561
+ void this.startVoice();
1562
+ }
1563
+
1564
+ private async startVoice(): Promise<void> {
1565
+ if (!this.controller || this.voiceSession || !this.voiceCfg.enabled) return;
1566
+ const config = this.readConfig();
1567
+ if (!config) return;
1568
+ // Best-effort: join the text thread when one exists. If the text session
1569
+ // hasn't connected yet, voice starts a fresh thread (the frozen protocol
1570
+ // never returns the voice-created conversation id, so it can't be adopted
1571
+ // for later text turns — tracked as a follow-up on the server protocol).
1572
+ const conversationId = this.controller.currentConversationId ?? undefined;
1573
+ const controller = this.controller;
1574
+ const session = new VoiceSession(
1575
+ { url: this.voiceCfg.url, agentId: config.agentId, conversationId },
1576
+ {
1577
+ onTranscriptPartial: (text) => {
1578
+ // Live partial transcript in the input area while listening.
1579
+ if (this.inputEl) {
1580
+ this.inputEl.value = text;
1581
+ this.autosize();
1582
+ }
1583
+ },
1584
+ onTranscriptFinal: (text) => {
1585
+ if (this.inputEl) {
1586
+ this.inputEl.value = '';
1587
+ this.autosize();
1588
+ }
1589
+ // Finalized speech lands as a normal user message.
1590
+ controller.appendLocalMessage('user', text);
1591
+ },
1592
+ onReplyText: (text) => {
1593
+ // Agent replies render through the normal chat message path.
1594
+ controller.appendLocalMessage('assistant', text);
1595
+ },
1596
+ onSpeaking: (speaking) => {
1597
+ this.micBtn?.classList.toggle('speaking', speaking);
1598
+ },
1599
+ onError: () => this.stopVoice(),
1600
+ onEnded: () => {
1601
+ // Server-side end (handoff / close) — reset the UI. stopVoice()
1602
+ // is idempotent, so a local stop lands here harmlessly too.
1603
+ this.voiceSession = null;
1604
+ this.syncVoiceUi(false);
1605
+ },
1606
+ },
1607
+ );
1608
+ this.voiceSession = session;
1609
+ this.syncVoiceUi(true);
1610
+ try {
1611
+ await session.start();
1612
+ } catch {
1613
+ // Mic permission denied / audio unavailable — back to text.
1614
+ this.voiceSession = null;
1615
+ this.syncVoiceUi(false);
1616
+ }
1617
+ }
1618
+
1619
+ /** End any live voice session and reset the composer UI. Idempotent. */
1620
+ private stopVoice(): void {
1621
+ const session = this.voiceSession;
1622
+ this.voiceSession = null;
1623
+ session?.stop();
1624
+ this.syncVoiceUi(false);
1625
+ }
1626
+
1627
+ /** Toggle the mic button's listening state + clear the partial transcript. */
1628
+ private syncVoiceUi(active: boolean): void {
1629
+ this.micBtn?.classList.toggle('active', active);
1630
+ this.micBtn?.setAttribute('aria-pressed', String(active));
1631
+ this.micBtn?.setAttribute('aria-label', active ? 'Stop voice' : 'Start voice');
1632
+ if (!active) {
1633
+ this.micBtn?.classList.remove('speaking');
1634
+ if (this.inputEl && this.inputEl.value) {
1635
+ this.inputEl.value = '';
1636
+ this.autosize();
1637
+ }
1638
+ }
1639
+ }
1640
+
1528
1641
  private submit(): void {
1529
1642
  if (!this.inputEl || !this.controller) return;
1530
1643
  const text = this.inputEl.value;
package/src/index.ts CHANGED
@@ -26,7 +26,25 @@ export {
26
26
  mountChatWidget,
27
27
  mountFullPageChat,
28
28
  } from './element.js';
29
- export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
29
+ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme, ChatWidgetVoiceConfig } from './config.js';
30
+ // Browser voice input/output (SMOODEV-2534) — mic capture + TTS playback over
31
+ // the browser-voice WS. Gated by `voice.enabled` (OFF by default).
32
+ export {
33
+ DEFAULT_VOICE_URL,
34
+ downsampleTo16k,
35
+ PcmPlayer,
36
+ rmsLevel,
37
+ VOICE_SAMPLE_RATE,
38
+ VoiceSession,
39
+ type PlayerAudioContext,
40
+ type StartCapture,
41
+ type VoicePlayer,
42
+ type VoiceSessionEvents,
43
+ type VoiceSessionOptions,
44
+ type VoiceSessionSeams,
45
+ type VoiceSessionState,
46
+ type VoiceWebSocket,
47
+ } from './voice-session.js';
30
48
  // The deferred loader (also shipped as the `./loader` IIFE for `<script>` embeds):
31
49
  // installs the idle/intent/fallback scheduler that lazily injects the widget
32
50
  // module so it never competes with the host page's LCP/TBT.
package/src/styles.ts CHANGED
@@ -546,6 +546,47 @@ ${
546
546
  .send:hover { transform: translateY(-1px) scale(1.05); }
547
547
  .send:active { transform: scale(.94); }
548
548
  .send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; }
549
+
550
+ /* Voice mic toggle (SMOODEV-2534) — ghost twin of .send; lights up while live. */
551
+ .mic {
552
+ width: 38px; height: 38px;
553
+ flex: none;
554
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
555
+ border-radius: 13px;
556
+ cursor: pointer;
557
+ display: flex;
558
+ align-items: center;
559
+ justify-content: center;
560
+ background: transparent;
561
+ color: color-mix(in srgb, var(--sac-text) 65%, transparent);
562
+ transition: transform .2s var(--sac-ease), color .2s ease, background .25s ease, box-shadow .25s ease;
563
+ }
564
+ .mic svg { width: 18px; height: 18px; }
565
+ .mic:hover { color: var(--sac-text); transform: translateY(-1px) scale(1.05); }
566
+ .mic:active { transform: scale(.94); }
567
+ .mic.active {
568
+ border-color: transparent;
569
+ background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2));
570
+ color: var(--sac-primary-text);
571
+ animation: sac-mic-pulse 1.6s ease-out infinite;
572
+ }
573
+ /* Listening → soft expanding ring off the button (mirrors the presence pulse). */
574
+ @keyframes sac-mic-pulse {
575
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 45%, transparent); }
576
+ 70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--sac-primary) 0%, transparent); }
577
+ 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--sac-primary) 0%, transparent); }
578
+ }
579
+ /* Agent TTS playing → faster secondary-accent shimmer so "speaking" reads distinctly. */
580
+ .mic.active.speaking {
581
+ animation: sac-mic-speaking 0.9s ease-in-out infinite;
582
+ }
583
+ @keyframes sac-mic-speaking {
584
+ 0%, 100% { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-secondary) 35%, transparent); }
585
+ 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--sac-secondary) 12%, transparent); }
586
+ }
587
+ @media (prefers-reduced-motion: reduce) {
588
+ .mic.active, .mic.active.speaking { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--sac-primary) 40%, transparent); }
589
+ }
549
590
  .footer {
550
591
  text-align: center;
551
592
  margin-top: 9px;