@smooai/chat-widget 0.3.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/src/element.ts ADDED
@@ -0,0 +1,563 @@
1
+ /**
2
+ * `<smooth-agent-chat>` — a framework-light embeddable chat web component.
3
+ *
4
+ * A clean, dependency-light web component that preserves a familiar embedding
5
+ * model — a launcher + popover panel, declarative HTML attributes, and a
6
+ * programmatic API — while talking to the `@smooai/smooth-operator` protocol
7
+ * client. The visual layer is the "Aurora Glass" design system (see
8
+ * {@link buildStyles}): a spring launcher with a live presence pulse, a
9
+ * glass-depth panel, a gradient brand avatar + status dot, an animated typing
10
+ * indicator, message rise-in, refined source cards, and an icon composer. Every
11
+ * color is driven by `--sac-*` custom properties so a host's brand flows through.
12
+ *
13
+ * Embedding model:
14
+ * <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
15
+ * or programmatically via {@link mountChatWidget}.
16
+ */
17
+ import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
18
+ import { needsUserInfo, resolveConfig } from './config.js';
19
+ import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController } from './conversation.js';
20
+ import { SMOOTH_LOGO_SVG } from './logo.js';
21
+ import { buildStyles } from './styles.js';
22
+
23
+ export const ELEMENT_TAG = 'smooth-agent-chat';
24
+
25
+ const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'placeholder', 'greeting', 'start-open', 'mode'] as const;
26
+
27
+ /**
28
+ * Inline SVG icons (static, trusted strings — never interpolated with user data).
29
+ * Kept here so the IIFE bundle is self-contained: no icon-font or network fetch.
30
+ */
31
+ const ICON = {
32
+ /** Launcher — a speech bubble carrying a spark (chat + AI). */
33
+ spark: `<svg class="ico" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3.5c-4.7 0-8.5 3.2-8.5 7.2 0 2.2 1.2 4.2 3 5.5v3.3l3.2-1.7c.7.1 1.5.2 2.3.2 4.7 0 8.5-3.2 8.5-7.3S16.7 3.5 12 3.5Z" fill="currentColor" opacity=".22"/><path d="M13.4 7.2 9 12.6h2.6l-1 4.2 4.4-5.4h-2.6l1-4.2Z" fill="currentColor"/></svg>`,
34
+ /** Small assistant avatar used beside each assistant message. */
35
+ bot: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="4.5" y="7.5" width="15" height="11" rx="3.5" stroke="currentColor" stroke-width="1.6"/><path d="M12 4.5v3M8.5 12.2h.01M15.5 12.2h.01" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9.5 15.4c.7.6 1.5.9 2.5.9s1.8-.3 2.5-.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
36
+ /** Close (collapse panel) — a downward chevron. */
37
+ close: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m7 10 5 5 5-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
38
+ /** Send — an upward arrow. */
39
+ send: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V6M12 6l-5.5 5.5M12 6l5.5 5.5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
40
+ /** Sources disclosure caret. */
41
+ chev: `<svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="m9 6 6 6-6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
42
+ } as const;
43
+
44
+ /**
45
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
46
+ *
47
+ * SECURITY: citation URLs originate from indexed content (web / GitHub
48
+ * connectors), which can be attacker-influenceable. Assigning an arbitrary
49
+ * string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
50
+ * execute on click — a stored-XSS vector. Only http(s) links are rendered as
51
+ * anchors; anything else falls back to plain text.
52
+ */
53
+ export function safeHttpUrl(url: string | undefined | null): string | null {
54
+ if (!url) return null;
55
+ try {
56
+ const parsed = new URL(url);
57
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export class SmoothAgentChatElement extends HTMLElement {
64
+ static get observedAttributes(): readonly string[] {
65
+ return OBSERVED;
66
+ }
67
+
68
+ private readonly root: ShadowRoot;
69
+ private controller: ConversationController | null = null;
70
+ private overrides: Partial<ChatWidgetConfig> = {};
71
+ private open = false;
72
+ private messages: ChatMessage[] = [];
73
+ private status: ConnectionStatus = 'idle';
74
+ private mounted = false;
75
+ /** True once the visitor has cleared the pre-chat identity gate (or it's not needed). */
76
+ private userInfoSatisfied = false;
77
+ /** True after the visitor has sent their first message (hides starter chips). */
78
+ private hasSent = false;
79
+ /** Starter prompts shown as chips in the empty state. */
80
+ private examplePrompts: string[] = [];
81
+
82
+ // Cached DOM refs (populated in render()).
83
+ private panelEl: HTMLElement | null = null;
84
+ private launcherEl: HTMLElement | null = null;
85
+ private messagesEl: HTMLElement | null = null;
86
+ private statusEl: HTMLElement | null = null;
87
+ private dotEl: HTMLElement | null = null;
88
+ private inputEl: HTMLTextAreaElement | null = null;
89
+ private sendBtn: HTMLButtonElement | null = null;
90
+
91
+ constructor() {
92
+ super();
93
+ this.root = this.attachShadow({ mode: 'open' });
94
+ }
95
+
96
+ connectedCallback(): void {
97
+ this.mounted = true;
98
+ this.render();
99
+ }
100
+
101
+ disconnectedCallback(): void {
102
+ this.mounted = false;
103
+ this.controller?.disconnect();
104
+ this.controller = null;
105
+ }
106
+
107
+ attributeChangedCallback(): void {
108
+ if (this.mounted) this.render();
109
+ }
110
+
111
+ /**
112
+ * Programmatically merge config overrides (endpoint, agentId, theme, …). Values
113
+ * set here take precedence over HTML attributes. Re-renders the widget.
114
+ */
115
+ configure(config: Partial<ChatWidgetConfig>): void {
116
+ this.overrides = { ...this.overrides, ...config };
117
+ if (config.theme) {
118
+ this.overrides.theme = { ...(this.overrides.theme ?? {}), ...config.theme };
119
+ }
120
+ if (this.mounted) this.render();
121
+ }
122
+
123
+ /** Open the chat panel. */
124
+ openChat(): void {
125
+ this.open = true;
126
+ this.syncOpenState();
127
+ void this.controller?.connect().catch(() => {});
128
+ }
129
+
130
+ /** Collapse the chat panel back to the launcher. */
131
+ closeChat(): void {
132
+ this.open = false;
133
+ this.syncOpenState();
134
+ }
135
+
136
+ // ─────────────────────────── Config resolution ─────────────────────────────
137
+
138
+ private readConfig(): ChatWidgetConfig | null {
139
+ const endpoint = this.overrides.endpoint ?? this.getAttribute('endpoint') ?? '';
140
+ const agentId = this.overrides.agentId ?? this.getAttribute('agent-id') ?? '';
141
+ if (!endpoint || !agentId) return null;
142
+
143
+ const theme: ChatWidgetTheme | undefined = this.overrides.theme;
144
+ const modeAttr = this.getAttribute('mode');
145
+ const mode: ChatWidgetMode = this.overrides.mode ?? (modeAttr === 'fullpage' ? 'fullpage' : modeAttr === 'popover' ? 'popover' : undefined) ?? 'popover';
146
+ return {
147
+ endpoint,
148
+ mode,
149
+ agentId,
150
+ agentName: this.overrides.agentName ?? this.getAttribute('agent-name') ?? undefined,
151
+ userName: this.overrides.userName,
152
+ userEmail: this.overrides.userEmail,
153
+ userPhone: this.overrides.userPhone,
154
+ placeholder: this.overrides.placeholder ?? this.getAttribute('placeholder') ?? undefined,
155
+ greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
156
+ connectionErrorMessage: this.overrides.connectionErrorMessage,
157
+ startOpen: this.overrides.startOpen ?? this.hasAttribute('start-open'),
158
+ examplePrompts: this.overrides.examplePrompts,
159
+ requireName: this.overrides.requireName,
160
+ requireEmail: this.overrides.requireEmail,
161
+ requirePhone: this.overrides.requirePhone,
162
+ allowAnonymous: this.overrides.allowAnonymous,
163
+ theme,
164
+ };
165
+ }
166
+
167
+ // ───────────────────────────────── Render ──────────────────────────────────
168
+
169
+ private render(): void {
170
+ const config = this.readConfig();
171
+ if (!config) {
172
+ this.root.innerHTML = '';
173
+ return;
174
+ }
175
+ const resolved = resolveConfig(config);
176
+
177
+ // (Re)create the controller only when there isn't one yet. Attribute churn
178
+ // (e.g. theme tweaks) re-renders the view without dropping the session.
179
+ if (!this.controller) {
180
+ this.controller = new ConversationController(config, {
181
+ onMessages: (messages) => {
182
+ this.messages = messages;
183
+ this.renderMessages(resolved.greeting);
184
+ },
185
+ onStatus: (status) => {
186
+ this.status = status;
187
+ this.renderStatus();
188
+ this.renderComposerState();
189
+ },
190
+ });
191
+ if (resolved.startOpen) this.open = true;
192
+ }
193
+
194
+ const fullpage = resolved.mode === 'fullpage';
195
+ // Full-page mode is always "open" — it fills its container and has no
196
+ // launcher to toggle.
197
+ if (fullpage) this.open = true;
198
+
199
+ const style = document.createElement('style');
200
+ style.textContent = buildStyles(resolved.theme, resolved.mode);
201
+
202
+ // Header: in full-page mode lead with the Smooth logo in the avatar tile
203
+ // and a subtle "powered by" tag; in popover mode show a brand-colored
204
+ // monogram avatar + a compact close (collapse) button.
205
+ const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || 'A').toUpperCase());
206
+ const header = fullpage
207
+ ? `<div class="header">
208
+ <div class="avatar"><span class="logo-wrap">${SMOOTH_LOGO_SVG}</span></div>
209
+ <div class="meta">
210
+ <span class="title">${escapeHtml(resolved.agentName)}</span>
211
+ <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
212
+ </div>
213
+ <span class="powered">powered by smooth-operator</span>
214
+ </div>`
215
+ : `<div class="header">
216
+ <div class="avatar">${monogram}</div>
217
+ <div class="meta">
218
+ <span class="title">${escapeHtml(resolved.agentName)}</span>
219
+ <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
220
+ </div>
221
+ <button class="close" aria-label="Close chat">${ICON.close}</button>
222
+ </div>`;
223
+
224
+ // Remember starter prompts for the empty-state chips.
225
+ this.examplePrompts = resolved.examplePrompts;
226
+
227
+ // Gate the conversation behind a pre-chat identity form when required.
228
+ const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
229
+ const field = (name: string, type: string, label: string, autocomplete: string) =>
230
+ `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
231
+ const prechatHtml = `
232
+ <div class="prechat">
233
+ <div class="pc-head">
234
+ <div class="pc-title">Before we chat</div>
235
+ <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
236
+ </div>
237
+ <form class="pc-form" novalidate>
238
+ ${resolved.requireName ? field('name', 'text', 'Name', 'name') : ''}
239
+ ${resolved.requireEmail ? field('email', 'email', 'Email', 'email') : ''}
240
+ ${resolved.requirePhone ? field('phone', 'tel', 'Phone', 'tel') : ''}
241
+ <button type="submit" class="pc-submit">Start chat</button>
242
+ </form>
243
+ </div>`;
244
+ const chatHtml = `
245
+ <div class="messages"></div>
246
+ <div class="composer-wrap">
247
+ <div class="composer">
248
+ <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
249
+ <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
250
+ </div>
251
+ <div class="footer">powered by <b>smooth&#8209;operator</b></div>
252
+ </div>`;
253
+
254
+ const container = document.createElement('div');
255
+ container.innerHTML = `
256
+ ${fullpage ? '' : `<button class="launcher" part="launcher" aria-label="Open chat">${ICON.spark}</button>`}
257
+ <div class="panel${fullpage ? ' fullpage' : ' hidden'}" part="panel" role="${fullpage ? 'region' : 'dialog'}" aria-label="${escapeHtml(resolved.agentName)} chat">
258
+ ${header}
259
+ <div class="header-sep"></div>
260
+ ${gating ? prechatHtml : chatHtml}
261
+ </div>
262
+ `;
263
+
264
+ // Tag the logo <svg> so styles can size it (the inlined SVG has its own id).
265
+ const logoSvg = container.querySelector('.logo-wrap svg');
266
+ if (logoSvg) logoSvg.setAttribute('class', 'logo');
267
+
268
+ this.root.replaceChildren(style, container);
269
+
270
+ this.launcherEl = container.querySelector('.launcher');
271
+ this.panelEl = container.querySelector('.panel');
272
+ this.messagesEl = container.querySelector('.messages');
273
+ this.statusEl = container.querySelector('.status-text');
274
+ this.dotEl = container.querySelector('.dot');
275
+ this.inputEl = container.querySelector('textarea');
276
+ this.sendBtn = container.querySelector('.send');
277
+
278
+ this.launcherEl?.addEventListener('click', () => this.openChat());
279
+ container.querySelector('.close')?.addEventListener('click', () => this.closeChat());
280
+ this.sendBtn?.addEventListener('click', () => this.submit());
281
+ this.inputEl?.addEventListener('input', () => this.autosize());
282
+ this.inputEl?.addEventListener('keydown', (ev) => {
283
+ if (ev.key === 'Enter' && !ev.shiftKey) {
284
+ ev.preventDefault();
285
+ this.submit();
286
+ }
287
+ });
288
+
289
+ const pcForm = container.querySelector('.pc-form');
290
+ pcForm?.addEventListener('submit', (ev) => {
291
+ ev.preventDefault();
292
+ this.handlePrechatSubmit(pcForm as HTMLFormElement);
293
+ });
294
+
295
+ // Full-page mode connects eagerly (there's no launcher click to trigger it) —
296
+ // but only once any identity gate is cleared.
297
+ if (fullpage && !gating) void this.controller?.connect().catch(() => {});
298
+
299
+ this.syncOpenState();
300
+ if (!gating) this.renderMessages(resolved.greeting);
301
+ this.renderStatus();
302
+ this.renderComposerState();
303
+ }
304
+
305
+ /** Collect identity from the pre-chat form, then drop into the chat view. */
306
+ private handlePrechatSubmit(form: HTMLFormElement): void {
307
+ if (!form.reportValidity()) return;
308
+ const data = new FormData(form);
309
+ const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
310
+ this.controller?.setUserInfo({ name: val('name'), email: val('email'), phone: val('phone') });
311
+ this.userInfoSatisfied = true;
312
+ this.render();
313
+ void this.controller?.connect().catch(() => {});
314
+ }
315
+
316
+ /** Send a starter prompt (from a chip click). */
317
+ private submitPrompt(text: string): void {
318
+ if (!this.inputEl) return;
319
+ this.inputEl.value = text;
320
+ this.submit();
321
+ }
322
+
323
+ private syncOpenState(): void {
324
+ // In full-page mode the panel always fills the host; nothing to toggle.
325
+ if (this.panelEl?.classList.contains('fullpage')) {
326
+ this.inputEl?.focus();
327
+ return;
328
+ }
329
+ this.panelEl?.classList.toggle('hidden', !this.open);
330
+ this.launcherEl?.classList.toggle('hidden', this.open);
331
+ if (this.open) this.inputEl?.focus();
332
+ }
333
+
334
+ /** Grow the textarea with its content, up to the CSS max-height. */
335
+ private autosize(): void {
336
+ const ta = this.inputEl;
337
+ if (!ta) return;
338
+ ta.style.height = 'auto';
339
+ ta.style.height = `${ta.scrollHeight}px`;
340
+ }
341
+
342
+ private renderMessages(greeting: string): void {
343
+ if (!this.messagesEl) return;
344
+ this.messagesEl.replaceChildren();
345
+
346
+ if (this.messages.length === 0 && greeting) {
347
+ this.messagesEl.appendChild(this.buildRow('assistant', this.greetingBubble(greeting)));
348
+ }
349
+
350
+ // Starter-prompt chips: shown until the visitor sends their first message.
351
+ if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
352
+ const chips = document.createElement('div');
353
+ chips.className = 'prompts';
354
+ for (const prompt of this.examplePrompts) {
355
+ const chip = document.createElement('button');
356
+ chip.type = 'button';
357
+ chip.className = 'chip';
358
+ chip.textContent = prompt;
359
+ chip.addEventListener('click', () => this.submitPrompt(prompt));
360
+ chips.appendChild(chip);
361
+ }
362
+ this.messagesEl.appendChild(chips);
363
+ }
364
+
365
+ for (const msg of this.messages) {
366
+ const bubble = document.createElement('div');
367
+ bubble.className = `bubble ${msg.role}`;
368
+ if (msg.role === 'assistant' && msg.streaming && !msg.text) {
369
+ // No text yet → animated typing indicator.
370
+ bubble.classList.add('typing');
371
+ bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
372
+ } else if (msg.streaming) {
373
+ bubble.classList.add('cursor');
374
+ bubble.textContent = msg.text;
375
+ } else {
376
+ bubble.textContent = msg.text;
377
+ }
378
+ this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
379
+
380
+ // Render a "Sources (N)" section under any assistant message whose
381
+ // terminal eventual_response carried citations.
382
+ if (msg.role === 'assistant' && !msg.streaming && msg.citations && msg.citations.length > 0) {
383
+ this.messagesEl.appendChild(this.renderSources(msg.citations));
384
+ }
385
+ }
386
+ this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
387
+ }
388
+
389
+ /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
390
+ private buildRow(role: 'user' | 'assistant', bubble: HTMLElement): HTMLElement {
391
+ const row = document.createElement('div');
392
+ row.className = `row ${role}`;
393
+ if (role === 'assistant') {
394
+ const mini = document.createElement('div');
395
+ mini.className = 'mini';
396
+ mini.innerHTML = ICON.bot; // static, trusted
397
+ row.appendChild(mini);
398
+ }
399
+ row.appendChild(bubble);
400
+ return row;
401
+ }
402
+
403
+ private greetingBubble(greeting: string): HTMLElement {
404
+ const b = document.createElement('div');
405
+ b.className = 'bubble assistant greeting';
406
+ b.textContent = greeting;
407
+ return b;
408
+ }
409
+
410
+ private typingDot(): HTMLElement {
411
+ return document.createElement('i');
412
+ }
413
+
414
+ /**
415
+ * Build the collapsible "Sources (N)" block for an assistant message's
416
+ * citations. Title/snippet are set via `textContent` (never innerHTML) so
417
+ * citation text can't inject markup; only the static chevron + numeric count
418
+ * use innerHTML.
419
+ */
420
+ private renderSources(citations: Citation[]): HTMLElement {
421
+ const wrap = document.createElement('div');
422
+ wrap.className = 'sources';
423
+ wrap.setAttribute('part', 'sources');
424
+
425
+ const details = document.createElement('details');
426
+ details.open = true;
427
+
428
+ const summary = document.createElement('summary');
429
+ const chev = document.createElement('span');
430
+ chev.className = 'chev';
431
+ chev.innerHTML = ICON.chev; // static, trusted
432
+ const label = document.createElement('span');
433
+ label.textContent = 'Sources';
434
+ const count = document.createElement('span');
435
+ count.className = 'count';
436
+ count.textContent = String(citations.length);
437
+ summary.append(chev, label, count);
438
+ details.appendChild(summary);
439
+
440
+ const list = document.createElement('ol');
441
+ for (const c of citations) {
442
+ const li = document.createElement('li');
443
+
444
+ let titleEl: HTMLElement;
445
+ // SECURITY: only absolute http(s) URLs may become a link href. A
446
+ // citation URL comes from indexed content (web/GitHub connectors), so
447
+ // an attacker-influenceable doc could carry `javascript:`/`data:`/
448
+ // `vbscript:` — assigning those to `a.href` is a one-click XSS. Anything
449
+ // that isn't a valid absolute http(s) URL renders as plain text.
450
+ const safeUrl = safeHttpUrl(c.url);
451
+ if (safeUrl) {
452
+ const a = document.createElement('a');
453
+ a.className = 'src-title';
454
+ a.href = safeUrl;
455
+ a.target = '_blank';
456
+ a.rel = 'noopener noreferrer';
457
+ titleEl = a;
458
+ } else {
459
+ titleEl = document.createElement('span');
460
+ titleEl.className = 'src-title';
461
+ }
462
+ titleEl.textContent = c.title || c.id || 'Source';
463
+ li.appendChild(titleEl);
464
+
465
+ if (c.snippet) {
466
+ const snip = document.createElement('span');
467
+ snip.className = 'src-snippet';
468
+ snip.textContent = c.snippet;
469
+ li.appendChild(snip);
470
+ }
471
+ list.appendChild(li);
472
+ }
473
+ details.appendChild(list);
474
+ wrap.appendChild(details);
475
+ return wrap;
476
+ }
477
+
478
+ private renderStatus(): void {
479
+ const label: Record<ConnectionStatus, string> = {
480
+ idle: '',
481
+ connecting: 'Connecting…',
482
+ ready: 'Online',
483
+ error: 'Connection issue',
484
+ closed: 'Disconnected',
485
+ };
486
+ if (this.statusEl) this.statusEl.textContent = label[this.status];
487
+ if (this.dotEl) {
488
+ // ready → green (no modifier); connecting → amber; error → red; else grey.
489
+ const mod = this.status === 'ready' ? '' : this.status === 'connecting' ? ' connecting' : this.status === 'error' ? ' error' : ' off';
490
+ this.dotEl.className = `dot${mod}`;
491
+ }
492
+ }
493
+
494
+ private renderComposerState(): void {
495
+ const busy = this.status === 'connecting';
496
+ if (this.sendBtn) this.sendBtn.disabled = busy;
497
+ if (this.inputEl) this.inputEl.disabled = busy;
498
+ }
499
+
500
+ private submit(): void {
501
+ if (!this.inputEl || !this.controller) return;
502
+ const text = this.inputEl.value;
503
+ if (!text.trim()) return;
504
+ this.inputEl.value = '';
505
+ this.hasSent = true;
506
+ this.autosize();
507
+ void this.controller.send(text);
508
+ }
509
+ }
510
+
511
+ function escapeHtml(value: string): string {
512
+ return value.replace(/[&<>"']/g, (c) => {
513
+ switch (c) {
514
+ case '&':
515
+ return '&amp;';
516
+ case '<':
517
+ return '&lt;';
518
+ case '>':
519
+ return '&gt;';
520
+ case '"':
521
+ return '&quot;';
522
+ default:
523
+ return '&#39;';
524
+ }
525
+ });
526
+ }
527
+
528
+ /** Register the custom element once. Safe to call multiple times. */
529
+ export function defineChatWidget(): void {
530
+ if (typeof customElements !== 'undefined' && !customElements.get(ELEMENT_TAG)) {
531
+ customElements.define(ELEMENT_TAG, SmoothAgentChatElement);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Programmatically create, configure, and append a widget to the page.
537
+ * Returns the element so the host can drive `openChat()` / `closeChat()`.
538
+ */
539
+ export function mountChatWidget(config: ChatWidgetConfig, target: HTMLElement = document.body): SmoothAgentChatElement {
540
+ defineChatWidget();
541
+ const el = document.createElement(ELEMENT_TAG) as SmoothAgentChatElement;
542
+ el.configure(config);
543
+ target.appendChild(el);
544
+ return el;
545
+ }
546
+
547
+ /**
548
+ * Ergonomic helper for the full-page layout: mounts a `<smooth-agent-chat>` in
549
+ * `mode: "fullpage"` (no launcher — the chat fills its container/viewport with a
550
+ * Smooth-branded header, a scrollable message list, and an input bar) and
551
+ * returns the element.
552
+ *
553
+ * `target` defaults to `document.body`; pass a sized container to embed the
554
+ * full-page chat inside a layout region (e.g. a `/chat` route shell or an
555
+ * iframe). The `mode` is forced to `"fullpage"` regardless of the passed config.
556
+ *
557
+ * ```ts
558
+ * mountFullPageChat({ endpoint: 'wss://…/ws', agentId: '…', agentName: 'Support' });
559
+ * ```
560
+ */
561
+ export function mountFullPageChat(config: Omit<ChatWidgetConfig, 'mode'>, target: HTMLElement = document.body): SmoothAgentChatElement {
562
+ return mountChatWidget({ ...config, mode: 'fullpage' }, target);
563
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @smooai/chat-widget — an embeddable chat widget for the smooth-operator
3
+ * protocol. Framework-light web component that speaks the schema-driven WebSocket
4
+ * protocol via `@smooai/smooth-operator`.
5
+ *
6
+ * ESM library entry. For bundler-based hosts:
7
+ *
8
+ * ```ts
9
+ * import { defineChatWidget, mountChatWidget } from '@smooai/chat-widget';
10
+ *
11
+ * // Declarative: register the element, then drop <smooth-agent-chat …> in markup.
12
+ * defineChatWidget();
13
+ *
14
+ * // Or programmatic:
15
+ * const widget = mountChatWidget({ endpoint: 'wss://…/ws', agentId: '…' });
16
+ * widget.openChat();
17
+ * ```
18
+ *
19
+ * For a plain `<script>` embed, use the standalone IIFE bundle
20
+ * (`dist/chat-widget.global.js`), which auto-registers the element on load.
21
+ */
22
+ export {
23
+ SmoothAgentChatElement,
24
+ ELEMENT_TAG,
25
+ defineChatWidget,
26
+ mountChatWidget,
27
+ mountFullPageChat,
28
+ } from './element.js';
29
+ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
30
+ export {
31
+ ConversationController,
32
+ type ChatMessage,
33
+ type Citation,
34
+ type ConnectionStatus,
35
+ type ConversationEvents,
36
+ type Role,
37
+ } from './conversation.js';
package/src/logo.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The Smooth logo, inlined as an SVG string so the full-page header can render
3
+ * it without a separate network fetch (the IIFE bundle is self-contained).
4
+ *
5
+ * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
6
+ * node -e ... (see the commit that added this file)
7
+ */
8
+ /* eslint-disable */
9
+ export const SMOOTH_LOGO_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 550 135\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient-3);\n }\n\n .cls-2 {\n fill: url(#linear-gradient-2);\n }\n\n .cls-3 {\n fill: url(#linear-gradient);\n fill-rule: evenodd;\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"115.59\" y1=\"112.81\" x2=\"25.08\" y2=\"22.3\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".3\" stop-color=\"#f49f0a\"/>\n <stop offset=\".79\" stop-color=\"#fb7a4d\"/>\n <stop offset=\"1\" stop-color=\"#ff6b6c\"/>\n </linearGradient>\n <linearGradient id=\"linear-gradient-2\" x1=\"360.91\" y1=\"152.01\" x2=\"202.32\" y2=\"-6.59\" xlink:href=\"#linear-gradient\"/>\n <linearGradient id=\"linear-gradient-3\" x1=\"443.91\" y1=\"30.15\" x2=\"531.36\" y2=\"117.59\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-3\" d=\"M48.28,14.96c-12.39,5.21-22.54,14.64-28.65,26.61-6.12,11.97-7.8,25.72-4.77,38.81,3.04,13.09,10.6,24.69,21.36,32.75,10.76,8.06,24.02,12.05,37.44,11.28,13.42-.77,26.13-6.26,35.9-15.5,9.76-9.24,15.95-21.63,17.46-34.99,1.51-13.36-1.74-26.82-9.19-38.01-1.07-1.61-.64-3.78.97-4.85,1.61-1.07,3.78-.64,4.85.97,8.36,12.56,12.02,27.68,10.32,42.67-1.7,15-8.64,28.91-19.61,39.28-10.96,10.37-25.24,16.54-40.31,17.4-15.07.87-29.96-3.62-42.04-12.66-12.08-9.05-20.58-22.07-23.99-36.77-3.41-14.7-1.51-30.14,5.35-43.58,6.87-13.44,18.26-24.02,32.17-29.87,13.91-5.85,29.44-6.6,43.85-2.11,1.85.57,2.88,2.54,2.3,4.38-.57,1.85-2.54,2.88-4.38,2.3-12.83-4-26.67-3.33-39.06,1.88ZM111.39,19.75c0,2.07-1.68,3.75-3.75,3.75s-3.75-1.68-3.75-3.75,1.68-3.75,3.75-3.75,3.75,1.68,3.75,3.75ZM64.64,59.93c0,1.91,2.39,2.56,7.69,3.88,3.89.97,6.6,2.18,8.15,3.63,1.53,1.45,2.29,3.53,2.29,6.25,0,3.57-1.03,6.26-3.11,8.08-2.07,1.82-5.09,2.73-9.09,2.73h-9.6c-1.97,0-3.57-1.6-3.59-3.57-.01-1.99,1.6-3.61,3.59-3.61h9.41c3.15-.12,4.79-.95,4.91-2.47,0-1.3-1.03-2.21-3.07-2.73-6.91-1.72-11.11-3.44-12.6-5.15-1.48-1.71-2.23-3.77-2.23-6.19,0-6.59,3.2-9.85,9.59-9.8h10.77c1.99,0,3.6,1.61,3.6,3.59s-1.61,3.59-3.6,3.59h-9.69c-1.83,0-3.43.06-3.43,1.77Z\"/>\n <path class=\"cls-2\" d=\"M205.52,48.44h-8.86c-.44-3.75-2.23-6.65-5.38-8.72-3.16-2.07-7.03-3.1-11.6-3.1h0c-3.35,0-6.27.54-8.78,1.62-2.49,1.09-4.44,2.59-5.84,4.48-1.39,1.89-2.08,4.05-2.08,6.46h0c0,2.01.49,3.75,1.46,5.2.97,1.44,2.22,2.63,3.74,3.58,1.53.95,3.13,1.72,4.8,2.32,1.68.6,3.22,1.09,4.62,1.46h0l7.68,2.06c1.97.52,4.17,1.23,6.6,2.14,2.43.92,4.75,2.16,6.98,3.72,2.23,1.56,4.07,3.56,5.52,6,1.45,2.44,2.18,5.43,2.18,8.98h0c0,4.08-1.07,7.77-3.2,11.08-2.12,3.29-5.22,5.91-9.3,7.86-4.08,1.95-9.02,2.92-14.82,2.92h0c-5.43,0-10.11-.87-14.06-2.62-3.95-1.75-7.05-4.19-9.3-7.32-2.25-3.12-3.53-6.75-3.84-10.88h9.46c.25,2.85,1.22,5.21,2.9,7.06,1.69,1.87,3.83,3.25,6.42,4.14,2.6.89,5.41,1.34,8.42,1.34h0c3.49,0,6.63-.57,9.4-1.72,2.79-1.13,4.99-2.73,6.62-4.8,1.63-2.05,2.44-4.46,2.44-7.22h0c0-2.51-.7-4.55-2.1-6.12-1.41-1.57-3.26-2.85-5.54-3.84-2.29-.99-4.77-1.85-7.44-2.58h0l-9.3-2.66c-5.91-1.71-10.59-4.13-14.04-7.28-3.44-3.16-5.16-7.29-5.16-12.38h0c0-4.23,1.15-7.93,3.46-11.1,2.29-3.16,5.39-5.62,9.3-7.38,3.91-1.76,8.27-2.64,13.08-2.64h0c4.88,0,9.21.87,13,2.6,3.8,1.73,6.81,4.11,9.04,7.12,2.23,3,3.4,6.41,3.52,10.22h0ZM229.16,105.18h-8.72v-56.74h8.42v8.86h.74c1.19-3.03,3.1-5.38,5.74-7.06,2.63-1.69,5.79-2.54,9.48-2.54h0c3.75,0,6.87.85,9.36,2.54,2.51,1.68,4.46,4.03,5.86,7.06h.58c1.45-2.92,3.63-5.25,6.54-7,2.91-1.73,6.39-2.6,10.46-2.6h0c5.07,0,9.21,1.58,12.44,4.74,3.23,3.17,4.84,8.09,4.84,14.76h0v37.98h-8.72v-37.98c0-4.19-1.14-7.18-3.42-8.98-2.29-1.79-4.99-2.68-8.1-2.68h0c-3.99,0-7.07,1.2-9.26,3.6-2.2,2.4-3.3,5.43-3.3,9.1h0v36.94h-8.86v-38.86c0-3.23-1.05-5.83-3.14-7.82-2.09-1.97-4.79-2.96-8.08-2.96h0c-2.27,0-4.38.6-6.34,1.8-1.96,1.21-3.53,2.88-4.72,5-1.2,2.13-1.8,4.59-1.8,7.38h0v35.46ZM333.9,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.86-5.85-9.02-10.24-2.15-4.37-3.22-9.49-3.22-15.36h0c0-5.91,1.07-11.07,3.22-15.48,2.16-4.4,5.17-7.82,9.02-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.15,4.41,3.22,9.57,3.22,15.48h0c0,5.87-1.07,10.99-3.22,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM333.9,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.89,0-7.09,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.51,1.99,5.71,2.98,9.6,2.98ZM395.94,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.85-5.85-9-10.24-2.16-4.37-3.24-9.49-3.24-15.36h0c0-5.91,1.08-11.07,3.24-15.48,2.15-4.4,5.15-7.82,9-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.16,4.41,3.24,9.57,3.24,15.48h0c0,5.87-1.08,10.99-3.24,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM395.94,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.88,0-7.08,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.52,1.99,5.72,2.98,9.6,2.98Z\"/>\n <path class=\"cls-1\" d=\"M467.88,48.02v13.28h-35.79v-13.28h35.79ZM439.68,34.38h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM506.59,72.63v32.71h-17.89V28.95h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Standalone IIFE entry. Bundled (with the protocol client inlined) into
3
+ * `dist/chat-widget.global.js` for a plain `<script src="…">` embed.
4
+ *
5
+ * On load it:
6
+ * - registers the `<smooth-agent-chat>` custom element, and
7
+ * - exposes the programmatic API on the IIFE global `SmoothAgentChat`
8
+ * (`window.SmoothAgentChat.mount({ endpoint, agentId })`).
9
+ *
10
+ * A host page can then either drop the element in markup:
11
+ * <smooth-agent-chat endpoint="wss://…/ws" agent-id="…"></smooth-agent-chat>
12
+ * or mount it programmatically:
13
+ * SmoothAgentChat.mount({ endpoint: 'wss://…/ws', agentId: '…' });
14
+ */
15
+ import type { ChatWidgetConfig } from './config.js';
16
+ import { defineChatWidget, mountChatWidget, mountFullPageChat, SmoothAgentChatElement } from './element.js';
17
+
18
+ defineChatWidget();
19
+
20
+ export { defineChatWidget, mountChatWidget, mountFullPageChat, SmoothAgentChatElement };
21
+
22
+ /** Convenience alias matching the global API surface (`SmoothAgentChat.mount`). */
23
+ export function mount(config: ChatWidgetConfig, target?: HTMLElement): SmoothAgentChatElement {
24
+ return mountChatWidget(config, target);
25
+ }
26
+
27
+ /**
28
+ * Full-page convenience alias (`SmoothAgentChat.mountFullPage`): mounts the chat
29
+ * in `mode: "fullpage"` so it fills its container/viewport with no launcher.
30
+ */
31
+ export function mountFullPage(config: Omit<ChatWidgetConfig, 'mode'>, target?: HTMLElement): SmoothAgentChatElement {
32
+ return mountFullPageChat(config, target);
33
+ }