@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/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. */
|
|
@@ -141,6 +154,18 @@ export interface ChatWidgetConfig {
|
|
|
141
154
|
* `require*` flags are ignored and the pre-chat form is skipped.
|
|
142
155
|
*/
|
|
143
156
|
allowAnonymous?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* Show the agent's tool activity (grep / read_file / bash / knowledge_search…)
|
|
159
|
+
* as inline chips interleaved with its prose, mirroring the smooth daemon SPA.
|
|
160
|
+
*
|
|
161
|
+
* Defaults to **`false`**: for a customer-facing support widget, surfacing raw
|
|
162
|
+
* tool calls to an end-user is usually undesirable, so tool activity is hidden
|
|
163
|
+
* and only the assistant's prose renders. Enable it for internal / power-user
|
|
164
|
+
* surfaces where seeing what the agent did mid-turn is valuable.
|
|
165
|
+
*/
|
|
166
|
+
showToolActivity?: boolean;
|
|
167
|
+
/** Browser voice input/output. OFF by default (zero UI when off). */
|
|
168
|
+
voice?: ChatWidgetVoiceConfig;
|
|
144
169
|
/** Theme overrides. */
|
|
145
170
|
theme?: ChatWidgetTheme;
|
|
146
171
|
}
|
|
@@ -148,8 +173,9 @@ export interface ChatWidgetConfig {
|
|
|
148
173
|
/** The fully-resolved theme (canonical keys only — aliases are folded in). */
|
|
149
174
|
export type ResolvedTheme = Required<Omit<ChatWidgetTheme, 'chatBubbleInbound' | 'chatBubbleInboundText' | 'chatBubbleOutbound' | 'chatBubbleOutboundText'>>;
|
|
150
175
|
|
|
151
|
-
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'>> & {
|
|
152
177
|
theme: ResolvedTheme;
|
|
178
|
+
voice: { enabled: boolean; url: string };
|
|
153
179
|
userName?: string;
|
|
154
180
|
userEmail?: string;
|
|
155
181
|
userPhone?: string;
|
|
@@ -194,6 +220,8 @@ export function resolveConfig(config: ChatWidgetConfig): ResolvedConfig {
|
|
|
194
220
|
collectConsent: config.collectConsent ?? true,
|
|
195
221
|
allowChatRestore: config.allowChatRestore ?? true,
|
|
196
222
|
allowAnonymous: config.allowAnonymous ?? false,
|
|
223
|
+
showToolActivity: config.showToolActivity ?? false,
|
|
224
|
+
voice: { enabled: config.voice?.enabled ?? false, url: config.voice?.url ?? DEFAULT_VOICE_URL },
|
|
197
225
|
theme: {
|
|
198
226
|
text: theme.text ?? '#f8fafc',
|
|
199
227
|
background: theme.background ?? '#040d30',
|
package/src/conversation.ts
CHANGED
|
@@ -61,6 +61,32 @@ export type { Citation };
|
|
|
61
61
|
|
|
62
62
|
export type Role = 'user' | 'assistant';
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* One tool invocation within an assistant turn. Mirrors the smooth daemon SPA's
|
|
66
|
+
* `ToolCall` (`crates/smooth-web/web/src/operator.ts`): opens `done: false` on the
|
|
67
|
+
* tool call and resolves on the tool result.
|
|
68
|
+
*/
|
|
69
|
+
export interface ToolCall {
|
|
70
|
+
/** Stable id for keyed rendering (assigned when the call opens). */
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
/** Raw arguments, JSON-stringified. */
|
|
74
|
+
args: string;
|
|
75
|
+
/** Present once the tool resolves. */
|
|
76
|
+
result?: string;
|
|
77
|
+
isError?: boolean;
|
|
78
|
+
done: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One ordered segment of an assistant turn: a run of prose, or a tool call.
|
|
83
|
+
* Preserves the interleave order the model produced (say a bit → call a tool →
|
|
84
|
+
* say a bit → …) so the UI can render tool chips INLINE where the model called
|
|
85
|
+
* them. Mirrors the daemon SPA's `MessageBlock`. Only populated when the widget
|
|
86
|
+
* is configured with `showToolActivity: true`.
|
|
87
|
+
*/
|
|
88
|
+
export type MessageBlock = { kind: 'text'; text: string } | { kind: 'tool'; tool: ToolCall };
|
|
89
|
+
|
|
64
90
|
export interface ChatMessage {
|
|
65
91
|
id: string;
|
|
66
92
|
role: Role;
|
|
@@ -68,6 +94,12 @@ export interface ChatMessage {
|
|
|
68
94
|
text: string;
|
|
69
95
|
/** True while an assistant message is still streaming. */
|
|
70
96
|
streaming: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Ordered text + tool segments, interleaved as the model produced them. Present
|
|
99
|
+
* only on assistant messages when `showToolActivity` is enabled (absent
|
|
100
|
+
* otherwise — the default popover renders `text` alone, byte-for-byte unchanged).
|
|
101
|
+
*/
|
|
102
|
+
blocks?: MessageBlock[];
|
|
71
103
|
/**
|
|
72
104
|
* Sources that grounded an assistant answer, when the terminal
|
|
73
105
|
* `eventual_response` carried any. Optional + back-compatible: absent when
|
|
@@ -244,12 +276,60 @@ function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null {
|
|
|
244
276
|
return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false };
|
|
245
277
|
}
|
|
246
278
|
|
|
279
|
+
let toolSeq = 0;
|
|
280
|
+
const nextToolId = (): string => `tool-${++toolSeq}`;
|
|
281
|
+
|
|
282
|
+
/** Grow the trailing text block, or open a new one if the last block was a tool. */
|
|
283
|
+
function growTextBlock(blocks: MessageBlock[], text: string): void {
|
|
284
|
+
if (!text) return;
|
|
285
|
+
const last = blocks[blocks.length - 1];
|
|
286
|
+
if (last && last.kind === 'text') last.text += text;
|
|
287
|
+
else blocks.push({ kind: 'text', text });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fold a `stream_chunk` node-state into the ordered block list, returning `true`
|
|
292
|
+
* when the chunk carried tool activity.
|
|
293
|
+
*
|
|
294
|
+
* Tool activity rides `state.rawResponse.toolCall` / `state.rawResponse.toolResult`
|
|
295
|
+
* — **NOT** `state.toolResult`. Reading the wrong path leaves every chip stuck on
|
|
296
|
+
* "running…" forever (the exact bug the daemon SPA hit and this mirror avoids).
|
|
297
|
+
*/
|
|
298
|
+
function applyToolChunk(blocks: MessageBlock[], state: unknown): boolean {
|
|
299
|
+
const raw = (state as { rawResponse?: unknown } | null | undefined)?.rawResponse;
|
|
300
|
+
if (!raw || typeof raw !== 'object') return false;
|
|
301
|
+
const call = (raw as { toolCall?: { name?: string; arguments?: unknown } }).toolCall;
|
|
302
|
+
const res = (raw as { toolResult?: { name?: string; isError?: boolean; result?: unknown } }).toolResult;
|
|
303
|
+
if (call) {
|
|
304
|
+
const args = typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {});
|
|
305
|
+
blocks.push({ kind: 'tool', tool: { id: nextToolId(), name: call.name ?? '', args, done: false } });
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
if (res) {
|
|
309
|
+
const result = typeof res.result === 'string' ? res.result : JSON.stringify(res.result ?? '');
|
|
310
|
+
// Complete the most-recent still-open tool block matching this name.
|
|
311
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
312
|
+
const b = blocks[i];
|
|
313
|
+
if (b && b.kind === 'tool' && b.tool.name === (res.name ?? '') && !b.tool.done) {
|
|
314
|
+
b.tool.done = true;
|
|
315
|
+
b.tool.isError = !!res.isError;
|
|
316
|
+
b.tool.result = result;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
247
325
|
export class ConversationController {
|
|
248
326
|
private readonly config: ChatWidgetConfig;
|
|
249
327
|
private readonly events: ConversationEvents;
|
|
250
328
|
private readonly store: StoreApi<WidgetStore>;
|
|
251
329
|
private client: SmoothAgentClient | null = null;
|
|
252
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;
|
|
253
333
|
private readonly messages: ChatMessage[] = [];
|
|
254
334
|
private status: ConnectionStatus = 'idle';
|
|
255
335
|
private seq = 0;
|
|
@@ -306,6 +386,23 @@ export class ConversationController {
|
|
|
306
386
|
return this.status;
|
|
307
387
|
}
|
|
308
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
|
+
|
|
309
406
|
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
310
407
|
getStore(): StoreApi<WidgetStore> {
|
|
311
408
|
return this.store;
|
|
@@ -612,6 +709,7 @@ export class ConversationController {
|
|
|
612
709
|
...(metadata ? { metadata } : {}),
|
|
613
710
|
});
|
|
614
711
|
this.sessionId = session.sessionId;
|
|
712
|
+
this.conversationId = session.conversationId ?? null;
|
|
615
713
|
this.store.getState().setSessionId(session.sessionId);
|
|
616
714
|
}
|
|
617
715
|
|
|
@@ -621,7 +719,7 @@ export class ConversationController {
|
|
|
621
719
|
*/
|
|
622
720
|
private async tryResume(sessionId: string): Promise<boolean> {
|
|
623
721
|
if (!this.client) return false;
|
|
624
|
-
let snap: { status?: 'active' | 'idle' | 'ended' };
|
|
722
|
+
let snap: { status?: 'active' | 'idle' | 'ended'; conversationId?: string };
|
|
625
723
|
try {
|
|
626
724
|
snap = await this.client.getSession({ sessionId });
|
|
627
725
|
} catch {
|
|
@@ -630,6 +728,7 @@ export class ConversationController {
|
|
|
630
728
|
if (snap.status === 'ended') return false;
|
|
631
729
|
|
|
632
730
|
this.sessionId = sessionId;
|
|
731
|
+
this.conversationId = snap.conversationId ?? null;
|
|
633
732
|
await this.hydrateHistory(sessionId);
|
|
634
733
|
return true;
|
|
635
734
|
}
|
|
@@ -674,7 +773,8 @@ export class ConversationController {
|
|
|
674
773
|
this.messages.push({ id: this.nextId('u'), role: 'user', text: trimmed, streaming: false });
|
|
675
774
|
|
|
676
775
|
// 2. Placeholder assistant bubble we grow as tokens arrive.
|
|
677
|
-
const
|
|
776
|
+
const showTools = this.config.showToolActivity === true;
|
|
777
|
+
const assistant: ChatMessage = { id: this.nextId('a'), role: 'assistant', text: '', streaming: true, blocks: showTools ? [] : undefined };
|
|
678
778
|
this.messages.push(assistant);
|
|
679
779
|
this.emitMessages();
|
|
680
780
|
|
|
@@ -687,6 +787,14 @@ export class ConversationController {
|
|
|
687
787
|
const token = event.token ?? event.data?.token ?? '';
|
|
688
788
|
if (token) {
|
|
689
789
|
assistant.text += token;
|
|
790
|
+
// Grow the trailing text block so prose interleaves with any
|
|
791
|
+
// tool chips in the order the model produced them.
|
|
792
|
+
if (showTools && assistant.blocks) growTextBlock(assistant.blocks, token);
|
|
793
|
+
this.emitMessages();
|
|
794
|
+
}
|
|
795
|
+
} else if (showTools && event.type === 'stream_chunk') {
|
|
796
|
+
// Tool activity (gated). Read state.rawResponse.toolCall/.toolResult.
|
|
797
|
+
if (assistant.blocks && applyToolChunk(assistant.blocks, event.data?.state)) {
|
|
690
798
|
this.emitMessages();
|
|
691
799
|
}
|
|
692
800
|
} else {
|
|
@@ -715,6 +823,11 @@ export class ConversationController {
|
|
|
715
823
|
if (suggestions.length > 0) {
|
|
716
824
|
assistant.suggestions = suggestions;
|
|
717
825
|
}
|
|
826
|
+
// Only keep blocks for turns that actually invoked a tool — a prose-only
|
|
827
|
+
// turn drops back to the normal markdown text path (with the final text).
|
|
828
|
+
if (assistant.blocks && !assistant.blocks.some((b) => b.kind === 'tool')) {
|
|
829
|
+
assistant.blocks = undefined;
|
|
830
|
+
}
|
|
718
831
|
assistant.streaming = false;
|
|
719
832
|
this.emitMessages();
|
|
720
833
|
} catch (err) {
|
|
@@ -960,6 +1073,7 @@ export class ConversationController {
|
|
|
960
1073
|
this.client?.disconnect('widget closed');
|
|
961
1074
|
this.client = null;
|
|
962
1075
|
this.sessionId = null;
|
|
1076
|
+
this.conversationId = null;
|
|
963
1077
|
this.activeRequestId = null;
|
|
964
1078
|
// A full teardown ends the controller lifecycle: a subsequent connect() is a
|
|
965
1079
|
// genuine re-open and may resume again, so re-arm the resume probe.
|
package/src/element.ts
CHANGED
|
@@ -17,10 +17,20 @@
|
|
|
17
17
|
import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min';
|
|
18
18
|
import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
|
|
19
19
|
import { needsUserInfo, resolveConfig } from './config.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
type ChatMessage,
|
|
22
|
+
type Citation,
|
|
23
|
+
type ConnectionStatus,
|
|
24
|
+
ConversationController,
|
|
25
|
+
type IdentityRestore,
|
|
26
|
+
type Interrupt,
|
|
27
|
+
SUPPORTED_INTERACTION_CAPABILITIES,
|
|
28
|
+
type ToolCall,
|
|
29
|
+
} from './conversation.js';
|
|
21
30
|
import { SMOOTH_ICON_SVG } from './logo.js';
|
|
22
31
|
import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
|
|
23
32
|
import { buildStyles } from './styles.js';
|
|
33
|
+
import { VoiceSession } from './voice-session.js';
|
|
24
34
|
|
|
25
35
|
export const ELEMENT_TAG = 'smooth-agent-chat';
|
|
26
36
|
|
|
@@ -52,7 +62,7 @@ function phoneToE164(value: string): string | null {
|
|
|
52
62
|
/** Public smooth-operator repo — the "powered by" header tag + footer link here. */
|
|
53
63
|
const SMOOTH_OPERATOR_URL = 'https://github.com/SmooAI/smooth-operator';
|
|
54
64
|
|
|
55
|
-
const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding'] as const;
|
|
65
|
+
const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'logo-url', 'placeholder', 'greeting', 'start-open', 'mode', 'hide-branding', 'show-tool-activity'] as const;
|
|
56
66
|
|
|
57
67
|
/**
|
|
58
68
|
* Inline SVG icons (static, trusted strings — never interpolated with user data).
|
|
@@ -75,6 +85,10 @@ const ICON = {
|
|
|
75
85
|
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>`,
|
|
76
86
|
/** Tool-confirmation interrupt — a shield. */
|
|
77
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>`,
|
|
88
|
+
/** Tool-activity chip — a wrench. */
|
|
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>`,
|
|
78
92
|
} as const;
|
|
79
93
|
|
|
80
94
|
/**
|
|
@@ -266,6 +280,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
266
280
|
private allowChatRestore = true;
|
|
267
281
|
/** True while the pre-chat identity gate is showing (blocks premature connect). */
|
|
268
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;
|
|
269
287
|
|
|
270
288
|
// Cached DOM refs (populated in render()).
|
|
271
289
|
private panelEl: HTMLElement | null = null;
|
|
@@ -275,6 +293,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
275
293
|
private dotEl: HTMLElement | null = null;
|
|
276
294
|
private inputEl: HTMLTextAreaElement | null = null;
|
|
277
295
|
private sendBtn: HTMLButtonElement | null = null;
|
|
296
|
+
private micBtn: HTMLButtonElement | null = null;
|
|
278
297
|
private suggestionsEl: HTMLElement | null = null;
|
|
279
298
|
|
|
280
299
|
// ── Smooth streaming reveal ──
|
|
@@ -292,6 +311,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
292
311
|
/** How many chars of {@link streamTarget} are currently shown. */
|
|
293
312
|
private displayedLength = 0;
|
|
294
313
|
private rafId = 0;
|
|
314
|
+
/** Block structure signature of the last-rendered streaming message (tool chips
|
|
315
|
+
* only — text growth doesn't change it), so a chip add/resolve forces a rebuild
|
|
316
|
+
* while plain trailing-text growth stays on the fast reveal path. */
|
|
317
|
+
private prevBlockSig = '';
|
|
295
318
|
|
|
296
319
|
constructor() {
|
|
297
320
|
super();
|
|
@@ -306,6 +329,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
306
329
|
disconnectedCallback(): void {
|
|
307
330
|
this.mounted = false;
|
|
308
331
|
this.resetReveal();
|
|
332
|
+
this.stopVoice();
|
|
309
333
|
this.controller?.disconnect();
|
|
310
334
|
this.controller = null;
|
|
311
335
|
}
|
|
@@ -376,6 +400,8 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
376
400
|
collectConsent: this.overrides.collectConsent,
|
|
377
401
|
allowChatRestore: this.overrides.allowChatRestore,
|
|
378
402
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
403
|
+
showToolActivity: this.overrides.showToolActivity ?? this.hasAttribute('show-tool-activity'),
|
|
404
|
+
voice: this.overrides.voice,
|
|
379
405
|
theme,
|
|
380
406
|
};
|
|
381
407
|
}
|
|
@@ -511,6 +537,11 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
511
537
|
const restoreBtn = this.allowChatRestore ? `<button type="button" class="restore-link">Restore my chats</button>` : '';
|
|
512
538
|
const footerInner = [brandingHtml, restoreBtn].filter(Boolean).join(' · ');
|
|
513
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
|
+
: '';
|
|
514
545
|
const chatHtml = `
|
|
515
546
|
<div class="messages"></div>
|
|
516
547
|
<div class="reply-suggestions"></div>
|
|
@@ -518,6 +549,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
518
549
|
<div class="composer-wrap">
|
|
519
550
|
<div class="composer">
|
|
520
551
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
552
|
+
${micHtml}
|
|
521
553
|
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
|
|
522
554
|
</div>
|
|
523
555
|
${footerHtml}
|
|
@@ -539,8 +571,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
539
571
|
if (logoSvg) logoSvg.setAttribute('class', 'logo');
|
|
540
572
|
|
|
541
573
|
// A full DOM rebuild invalidates any cached streaming-bubble ref; cancel
|
|
542
|
-
// 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.
|
|
543
576
|
this.resetReveal();
|
|
577
|
+
this.stopVoice();
|
|
544
578
|
this.root.replaceChildren(style, container);
|
|
545
579
|
|
|
546
580
|
this.launcherEl = container.querySelector('.launcher');
|
|
@@ -550,12 +584,14 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
550
584
|
this.dotEl = container.querySelector('.dot');
|
|
551
585
|
this.inputEl = container.querySelector('textarea');
|
|
552
586
|
this.sendBtn = container.querySelector('.send');
|
|
587
|
+
this.micBtn = container.querySelector('.mic');
|
|
553
588
|
this.interruptEl = container.querySelector('.interrupt');
|
|
554
589
|
this.suggestionsEl = container.querySelector('.reply-suggestions');
|
|
555
590
|
|
|
556
591
|
this.launcherEl?.addEventListener('click', () => this.openChat());
|
|
557
592
|
container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
|
|
558
593
|
this.sendBtn?.addEventListener('click', () => this.submit());
|
|
594
|
+
this.micBtn?.addEventListener('click', () => this.toggleVoice());
|
|
559
595
|
this.inputEl?.addEventListener('input', () => this.autosize());
|
|
560
596
|
this.inputEl?.addEventListener('keydown', (ev) => {
|
|
561
597
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
@@ -1046,14 +1082,19 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
1046
1082
|
// finalize transition (streaming → done) needs the markdown render + sources
|
|
1047
1083
|
last.streaming !== prevLast.streaming ||
|
|
1048
1084
|
// first token after the typing indicator needs the bubble swapped in
|
|
1049
|
-
(!!last.streaming && !prevLast.text && !!last.text)
|
|
1085
|
+
(!!last.streaming && !prevLast.text && !!last.text) ||
|
|
1086
|
+
// a tool chip was added or resolved (text growth alone doesn't change this)
|
|
1087
|
+
this.blockSig(last) !== this.prevBlockSig;
|
|
1050
1088
|
|
|
1051
1089
|
this.messages = messages;
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
//
|
|
1056
|
-
|
|
1090
|
+
this.prevBlockSig = this.blockSig(last);
|
|
1091
|
+
|
|
1092
|
+
if (!structural && last && last.streaming && this.tailKey(last) === this.streamMsgId) {
|
|
1093
|
+
// Fast path: the streaming (trailing) text just grew. Bump the reveal
|
|
1094
|
+
// target and let the rAF loop catch up — no DOM rebuild, no per-token
|
|
1095
|
+
// reflow. `tailText` is the live trailing text block for a tool turn, or
|
|
1096
|
+
// the whole message for a plain-prose turn.
|
|
1097
|
+
this.streamTarget = this.tailText(last);
|
|
1057
1098
|
this.ensureRevealLoop();
|
|
1058
1099
|
return;
|
|
1059
1100
|
}
|
|
@@ -1061,6 +1102,32 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
1061
1102
|
this.renderMessages();
|
|
1062
1103
|
}
|
|
1063
1104
|
|
|
1105
|
+
/** True when an assistant message's turn invoked at least one tool. */
|
|
1106
|
+
private hasToolBlocks(m?: ChatMessage): boolean {
|
|
1107
|
+
return !!m?.blocks?.some((b) => b.kind === 'tool');
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Signature that changes only when a tool chip is added or resolved (text growth is 'x'). */
|
|
1111
|
+
private blockSig(m?: ChatMessage): string {
|
|
1112
|
+
if (!m?.blocks) return '';
|
|
1113
|
+
return m.blocks.map((b) => (b.kind === 'tool' ? `t:${b.tool.id}:${b.tool.done ? 1 : 0}` : 'x')).join('|');
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The live (last) text block for a tool turn, else the whole message text. */
|
|
1117
|
+
private tailText(m: ChatMessage): string {
|
|
1118
|
+
if (this.hasToolBlocks(m) && m.blocks) {
|
|
1119
|
+
const last = m.blocks[m.blocks.length - 1];
|
|
1120
|
+
return last?.kind === 'text' ? last.text : '';
|
|
1121
|
+
}
|
|
1122
|
+
return m.text;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** Reveal-binding key — composite for a tool-turn tail (so a new trailing block rebinds), else msg id. */
|
|
1126
|
+
private tailKey(m: ChatMessage): string {
|
|
1127
|
+
if (this.hasToolBlocks(m) && m.blocks) return `${m.id}#${m.blocks.length - 1}`;
|
|
1128
|
+
return m.id;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1064
1131
|
private renderMessages(): void {
|
|
1065
1132
|
if (!this.messagesEl) return;
|
|
1066
1133
|
this.resetReveal();
|
|
@@ -1087,6 +1154,16 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
1087
1154
|
}
|
|
1088
1155
|
|
|
1089
1156
|
for (const msg of this.messages) {
|
|
1157
|
+
// Tool-activity turns (gated by `showToolActivity`) render as an ordered
|
|
1158
|
+
// strip of prose bubbles + inline tool chips instead of one bubble.
|
|
1159
|
+
if (msg.role === 'assistant' && this.hasToolBlocks(msg)) {
|
|
1160
|
+
this.messagesEl.appendChild(this.buildRow('assistant', this.renderAssistantBlocks(msg)));
|
|
1161
|
+
if (!msg.streaming && msg.citations && msg.citations.length > 0) {
|
|
1162
|
+
this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
1163
|
+
}
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1090
1167
|
const bubble = document.createElement('div');
|
|
1091
1168
|
bubble.className = `bubble ${msg.role}`;
|
|
1092
1169
|
if (msg.role === 'assistant' && msg.streaming && !msg.text) {
|
|
@@ -1168,22 +1245,89 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
1168
1245
|
* doesn't restart the reveal from zero), then resumes the loop.
|
|
1169
1246
|
*/
|
|
1170
1247
|
private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
|
|
1171
|
-
|
|
1248
|
+
// `tailKey`/`tailText` collapse to the message id + full text for a plain
|
|
1249
|
+
// prose turn, and to the live trailing text block for a tool turn — so both
|
|
1250
|
+
// the single streaming bubble and a tool turn's trailing prose share this path.
|
|
1251
|
+
const key = this.tailKey(msg);
|
|
1252
|
+
const target = this.tailText(msg);
|
|
1253
|
+
const carryOver = key === this.streamMsgId ? Math.min(this.displayedLength, target.length) : 0;
|
|
1172
1254
|
this.streamBubbleEl = bubble;
|
|
1173
|
-
this.streamMsgId =
|
|
1174
|
-
this.streamTarget =
|
|
1255
|
+
this.streamMsgId = key;
|
|
1256
|
+
this.streamTarget = target;
|
|
1175
1257
|
this.displayedLength = carryOver;
|
|
1176
1258
|
|
|
1177
1259
|
if (this.prefersReducedMotion()) {
|
|
1178
1260
|
// No animation: show everything immediately.
|
|
1179
|
-
this.displayedLength =
|
|
1180
|
-
bubble.textContent =
|
|
1261
|
+
this.displayedLength = target.length;
|
|
1262
|
+
bubble.textContent = target;
|
|
1181
1263
|
return;
|
|
1182
1264
|
}
|
|
1183
|
-
bubble.textContent =
|
|
1265
|
+
bubble.textContent = target.slice(0, this.displayedLength);
|
|
1184
1266
|
this.ensureRevealLoop();
|
|
1185
1267
|
}
|
|
1186
1268
|
|
|
1269
|
+
/**
|
|
1270
|
+
* Render a tool-activity assistant turn as an ordered strip: prose bubbles and
|
|
1271
|
+
* inline tool chips in the order the model produced them (mirrors the daemon
|
|
1272
|
+
* SPA's `blocks`). The live trailing text block (while streaming) binds to the
|
|
1273
|
+
* rAF reveal; earlier/finalized text blocks render as sanitized markdown.
|
|
1274
|
+
*/
|
|
1275
|
+
private renderAssistantBlocks(msg: ChatMessage): HTMLElement {
|
|
1276
|
+
const wrap = document.createElement('div');
|
|
1277
|
+
wrap.className = 'blocks';
|
|
1278
|
+
const blocks = msg.blocks ?? [];
|
|
1279
|
+
const lastIdx = blocks.length - 1;
|
|
1280
|
+
blocks.forEach((block, i) => {
|
|
1281
|
+
if (block.kind === 'tool') {
|
|
1282
|
+
wrap.appendChild(this.buildToolChip(block.tool));
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const bubble = document.createElement('div');
|
|
1286
|
+
bubble.className = 'bubble assistant';
|
|
1287
|
+
if (msg.streaming && i === lastIdx) {
|
|
1288
|
+
// Live trailing prose → plain text + cursor, driven by the reveal loop.
|
|
1289
|
+
bubble.classList.add('cursor');
|
|
1290
|
+
this.bindReveal(msg, bubble);
|
|
1291
|
+
} else {
|
|
1292
|
+
// Settled prose → sanitized markdown (same allowlisted renderer as bubbles).
|
|
1293
|
+
bubble.classList.add('md');
|
|
1294
|
+
bubble.innerHTML = renderMarkdown(block.text);
|
|
1295
|
+
}
|
|
1296
|
+
wrap.appendChild(bubble);
|
|
1297
|
+
});
|
|
1298
|
+
return wrap;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* A single tool-activity chip: icon + tool name + status (running… / done / error),
|
|
1303
|
+
* with a truncated args preview. Tool name/args are set via `textContent` so a
|
|
1304
|
+
* tool payload can never inject markup.
|
|
1305
|
+
*/
|
|
1306
|
+
private buildToolChip(tool: ToolCall): HTMLElement {
|
|
1307
|
+
const chip = document.createElement('div');
|
|
1308
|
+
chip.className = `toolchip ${tool.done ? (tool.isError ? 'error' : 'done') : 'running'}`;
|
|
1309
|
+
chip.setAttribute('part', 'tool-chip');
|
|
1310
|
+
|
|
1311
|
+
const icon = document.createElement('span');
|
|
1312
|
+
icon.className = 'ti';
|
|
1313
|
+
icon.innerHTML = ICON.tool; // static, trusted
|
|
1314
|
+
const name = document.createElement('span');
|
|
1315
|
+
name.className = 'tn';
|
|
1316
|
+
name.textContent = tool.name || 'tool';
|
|
1317
|
+
const status = document.createElement('span');
|
|
1318
|
+
status.className = 'ts';
|
|
1319
|
+
status.textContent = tool.done ? (tool.isError ? 'error' : 'done') : 'running…';
|
|
1320
|
+
chip.append(icon, name, status);
|
|
1321
|
+
|
|
1322
|
+
if (tool.args && tool.args !== '{}' && tool.args !== '""') {
|
|
1323
|
+
const args = document.createElement('span');
|
|
1324
|
+
args.className = 'ta';
|
|
1325
|
+
args.textContent = tool.args.length > 80 ? `${tool.args.slice(0, 80)}…` : tool.args;
|
|
1326
|
+
chip.appendChild(args);
|
|
1327
|
+
}
|
|
1328
|
+
return chip;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1187
1331
|
/** Start the rAF loop if it isn't already running. */
|
|
1188
1332
|
private ensureRevealLoop(): void {
|
|
1189
1333
|
if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
|
|
@@ -1401,6 +1545,99 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
1401
1545
|
if (this.inputEl) this.inputEl.disabled = busy;
|
|
1402
1546
|
}
|
|
1403
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
|
+
|
|
1404
1641
|
private submit(): void {
|
|
1405
1642
|
if (!this.inputEl || !this.controller) return;
|
|
1406
1643
|
const text = this.inputEl.value;
|