@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/chat-widget",
|
|
3
|
-
"version": "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',
|
package/src/conversation.ts
CHANGED
|
@@ -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;
|