@smooai/chat-widget 0.5.2 → 0.6.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 +473 -40
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +473 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/element.ts +208 -47
- package/src/markdown.ts +368 -0
- package/src/styles.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/chat-widget",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/element.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config
|
|
|
18
18
|
import { needsUserInfo, resolveConfig } from './config.js';
|
|
19
19
|
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js';
|
|
20
20
|
import { SMOOTH_LOGO_SVG } from './logo.js';
|
|
21
|
+
import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
|
|
21
22
|
import { buildStyles } from './styles.js';
|
|
22
23
|
|
|
23
24
|
export const ELEMENT_TAG = 'smooth-agent-chat';
|
|
@@ -45,24 +46,9 @@ const ICON = {
|
|
|
45
46
|
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>`,
|
|
46
47
|
} as const;
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* SECURITY: citation URLs originate from indexed content (web / GitHub
|
|
52
|
-
* connectors), which can be attacker-influenceable. Assigning an arbitrary
|
|
53
|
-
* string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
|
|
54
|
-
* execute on click — a stored-XSS vector. Only http(s) links are rendered as
|
|
55
|
-
* anchors; anything else falls back to plain text.
|
|
56
|
-
*/
|
|
57
|
-
export function safeHttpUrl(url: string | undefined | null): string | null {
|
|
58
|
-
if (!url) return null;
|
|
59
|
-
try {
|
|
60
|
-
const parsed = new URL(url);
|
|
61
|
-
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
49
|
+
// `safeHttpUrl` / `escapeHtml` live in `./markdown.js` (the markdown renderer
|
|
50
|
+
// needs them too); re-exported here for back-compat with existing importers.
|
|
51
|
+
export { escapeHtml, safeHttpUrl } from './markdown.js';
|
|
66
52
|
|
|
67
53
|
export class SmoothAgentChatElement extends HTMLElement {
|
|
68
54
|
static get observedAttributes(): readonly string[] {
|
|
@@ -82,6 +68,8 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
82
68
|
private hasSent = false;
|
|
83
69
|
/** Starter prompts shown as chips in the empty state. */
|
|
84
70
|
private examplePrompts: string[] = [];
|
|
71
|
+
/** Resolved greeting text, cached so async (rAF) renders can reuse it. */
|
|
72
|
+
private greeting = '';
|
|
85
73
|
/** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
|
|
86
74
|
private interrupt: Interrupt | null = null;
|
|
87
75
|
private interruptEl: HTMLElement | null = null;
|
|
@@ -95,6 +83,22 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
95
83
|
private inputEl: HTMLTextAreaElement | null = null;
|
|
96
84
|
private sendBtn: HTMLButtonElement | null = null;
|
|
97
85
|
|
|
86
|
+
// ── Smooth streaming reveal ──
|
|
87
|
+
// Tokens arrive in variable-size bursts at uneven rates, so revealing text in
|
|
88
|
+
// lockstep with arrival looks jerky. Instead we buffer the full target text
|
|
89
|
+
// and reveal it via a requestAnimationFrame "typewriter" at an adaptive rate
|
|
90
|
+
// (chars/frame scales with the pending backlog so it never falls behind the
|
|
91
|
+
// network). State below tracks the single in-flight streaming bubble.
|
|
92
|
+
/** The live streaming assistant bubble whose textContent the rAF loop drives. */
|
|
93
|
+
private streamBubbleEl: HTMLElement | null = null;
|
|
94
|
+
/** Message id the reveal is bound to (guards against stale frames after rebuilds). */
|
|
95
|
+
private streamMsgId: string | null = null;
|
|
96
|
+
/** Full buffered target text for the streaming message (grows as tokens arrive). */
|
|
97
|
+
private streamTarget = '';
|
|
98
|
+
/** How many chars of {@link streamTarget} are currently shown. */
|
|
99
|
+
private displayedLength = 0;
|
|
100
|
+
private rafId = 0;
|
|
101
|
+
|
|
98
102
|
constructor() {
|
|
99
103
|
super();
|
|
100
104
|
this.root = this.attachShadow({ mode: 'open' });
|
|
@@ -107,6 +111,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
107
111
|
|
|
108
112
|
disconnectedCallback(): void {
|
|
109
113
|
this.mounted = false;
|
|
114
|
+
this.resetReveal();
|
|
110
115
|
this.controller?.disconnect();
|
|
111
116
|
this.controller = null;
|
|
112
117
|
}
|
|
@@ -186,8 +191,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
186
191
|
if (!this.controller) {
|
|
187
192
|
this.controller = new ConversationController(config, {
|
|
188
193
|
onMessages: (messages) => {
|
|
189
|
-
this.messages
|
|
190
|
-
this.renderMessages(resolved.greeting);
|
|
194
|
+
this.handleMessages(messages, resolved.greeting);
|
|
191
195
|
},
|
|
192
196
|
onStatus: (status) => {
|
|
193
197
|
this.status = status;
|
|
@@ -232,8 +236,9 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
232
236
|
<button class="close" aria-label="Close chat">${ICON.close}</button>
|
|
233
237
|
</div>`;
|
|
234
238
|
|
|
235
|
-
// Remember starter prompts for the empty-state chips.
|
|
239
|
+
// Remember starter prompts + greeting for the empty-state chips / async renders.
|
|
236
240
|
this.examplePrompts = resolved.examplePrompts;
|
|
241
|
+
this.greeting = resolved.greeting;
|
|
237
242
|
|
|
238
243
|
// Gate the conversation behind a pre-chat identity form when required.
|
|
239
244
|
const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
|
|
@@ -277,6 +282,9 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
277
282
|
const logoSvg = container.querySelector('.logo-wrap svg');
|
|
278
283
|
if (logoSvg) logoSvg.setAttribute('class', 'logo');
|
|
279
284
|
|
|
285
|
+
// A full DOM rebuild invalidates any cached streaming-bubble ref; cancel
|
|
286
|
+
// the in-flight reveal loop so renderMessages can re-bind cleanly.
|
|
287
|
+
this.resetReveal();
|
|
280
288
|
this.root.replaceChildren(style, container);
|
|
281
289
|
|
|
282
290
|
this.launcherEl = container.querySelector('.launcher');
|
|
@@ -310,7 +318,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
310
318
|
if (fullpage && !gating) void this.controller?.connect().catch(() => {});
|
|
311
319
|
|
|
312
320
|
this.syncOpenState();
|
|
313
|
-
if (!gating) this.renderMessages(
|
|
321
|
+
if (!gating) this.renderMessages();
|
|
314
322
|
this.renderStatus();
|
|
315
323
|
this.renderComposerState();
|
|
316
324
|
this.renderInterrupt();
|
|
@@ -449,10 +457,52 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
449
457
|
ta.style.height = `${ta.scrollHeight}px`;
|
|
450
458
|
}
|
|
451
459
|
|
|
452
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Receive a new message snapshot from the controller and update the view.
|
|
462
|
+
*
|
|
463
|
+
* `onMessages` fires on *every* `stream_token` delta. Rebuilding the whole
|
|
464
|
+
* list each frame is wasteful and fights the smooth reveal, so we take the
|
|
465
|
+
* cheap path when the only change is the trailing streaming assistant message
|
|
466
|
+
* growing: just bump the reveal *target* (the rAF loop drains it). Any
|
|
467
|
+
* structural change (new message, finalize, citations) triggers a full
|
|
468
|
+
* rebuild via {@link renderMessages}.
|
|
469
|
+
*/
|
|
470
|
+
private handleMessages(messages: ChatMessage[], greeting: string): void {
|
|
471
|
+
this.greeting = greeting;
|
|
472
|
+
const prev = this.messages;
|
|
473
|
+
const last = messages[messages.length - 1];
|
|
474
|
+
const prevLast = prev[prev.length - 1];
|
|
475
|
+
|
|
476
|
+
const structural =
|
|
477
|
+
messages.length !== prev.length ||
|
|
478
|
+
!last ||
|
|
479
|
+
!prevLast ||
|
|
480
|
+
last.id !== prevLast.id ||
|
|
481
|
+
last.role !== prevLast.role ||
|
|
482
|
+
// finalize transition (streaming → done) needs the markdown render + sources
|
|
483
|
+
last.streaming !== prevLast.streaming ||
|
|
484
|
+
// first token after the typing indicator needs the bubble swapped in
|
|
485
|
+
(!!last.streaming && !prevLast.text && !!last.text);
|
|
486
|
+
|
|
487
|
+
this.messages = messages;
|
|
488
|
+
|
|
489
|
+
if (!structural && last && last.streaming && last.id === this.streamMsgId) {
|
|
490
|
+
// Fast path: the streaming tail just grew. Bump the reveal target and
|
|
491
|
+
// let the rAF loop catch up — no DOM rebuild, no per-token reflow.
|
|
492
|
+
this.streamTarget = last.text;
|
|
493
|
+
this.ensureRevealLoop();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
this.renderMessages();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private renderMessages(): void {
|
|
453
501
|
if (!this.messagesEl) return;
|
|
502
|
+
this.resetReveal();
|
|
454
503
|
this.messagesEl.replaceChildren();
|
|
455
504
|
|
|
505
|
+
const greeting = this.greeting;
|
|
456
506
|
if (this.messages.length === 0 && greeting) {
|
|
457
507
|
this.messagesEl.appendChild(this.buildRow('assistant', this.greetingBubble(greeting)));
|
|
458
508
|
}
|
|
@@ -480,9 +530,21 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
480
530
|
bubble.classList.add('typing');
|
|
481
531
|
bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
|
|
482
532
|
} else if (msg.streaming) {
|
|
533
|
+
// Mid-stream: partial/unclosed markdown renders ugly (half-open
|
|
534
|
+
// `**`, dangling `[`), so keep plain text + the blinking cursor.
|
|
535
|
+
// The text is revealed gradually by the rAF typewriter; seed the
|
|
536
|
+
// bubble with whatever is already displayed and bind the reveal.
|
|
483
537
|
bubble.classList.add('cursor');
|
|
484
|
-
|
|
538
|
+
this.bindReveal(msg, bubble);
|
|
539
|
+
} else if (msg.role === 'assistant') {
|
|
540
|
+
// Final assistant turn → render the sanitized markdown subset.
|
|
541
|
+
// `renderMarkdown` escapes all text, drops images, gates links to
|
|
542
|
+
// http(s), and only emits an allowlisted set of tags, so this
|
|
543
|
+
// `innerHTML` cannot inject markup (see markdown.ts).
|
|
544
|
+
bubble.classList.add('md');
|
|
545
|
+
bubble.innerHTML = renderMarkdown(msg.text);
|
|
485
546
|
} else {
|
|
547
|
+
// User bubbles stay plain text.
|
|
486
548
|
bubble.textContent = msg.text;
|
|
487
549
|
}
|
|
488
550
|
this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
|
|
@@ -493,7 +555,115 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
493
555
|
this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
494
556
|
}
|
|
495
557
|
}
|
|
496
|
-
this.
|
|
558
|
+
this.scrollToBottom(true);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ─────────────────────── Smooth streaming reveal ───────────────────────────
|
|
562
|
+
|
|
563
|
+
/** True when the host/user prefers reduced motion (snap, no typewriter). */
|
|
564
|
+
private prefersReducedMotion(): boolean {
|
|
565
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Bind the rAF typewriter to a freshly built streaming bubble. Preserves the
|
|
570
|
+
* already-revealed prefix across rebuilds (so a structural rebuild mid-stream
|
|
571
|
+
* doesn't restart the reveal from zero), then resumes the loop.
|
|
572
|
+
*/
|
|
573
|
+
private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
|
|
574
|
+
const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
|
|
575
|
+
this.streamBubbleEl = bubble;
|
|
576
|
+
this.streamMsgId = msg.id;
|
|
577
|
+
this.streamTarget = msg.text;
|
|
578
|
+
this.displayedLength = carryOver;
|
|
579
|
+
|
|
580
|
+
if (this.prefersReducedMotion()) {
|
|
581
|
+
// No animation: show everything immediately.
|
|
582
|
+
this.displayedLength = msg.text.length;
|
|
583
|
+
bubble.textContent = msg.text;
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
bubble.textContent = msg.text.slice(0, this.displayedLength);
|
|
587
|
+
this.ensureRevealLoop();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Start the rAF loop if it isn't already running. */
|
|
591
|
+
private ensureRevealLoop(): void {
|
|
592
|
+
if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
|
|
593
|
+
// No animation (reduced-motion or no rAF): snap to the full buffer.
|
|
594
|
+
this.snapReveal();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (this.rafId) return; // already running
|
|
598
|
+
this.rafId = requestAnimationFrame(() => this.tickReveal());
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* One animation frame of the reveal. Advances `displayedLength` toward the
|
|
603
|
+
* buffered target at an ADAPTIVE rate — the deeper the backlog, the more
|
|
604
|
+
* chars per frame — so the reveal stays smooth yet never falls behind the
|
|
605
|
+
* network. Updates ONLY the bound bubble's textContent (no list rebuild).
|
|
606
|
+
*/
|
|
607
|
+
private tickReveal(): void {
|
|
608
|
+
this.rafId = 0;
|
|
609
|
+
const bubble = this.streamBubbleEl;
|
|
610
|
+
if (!bubble) return;
|
|
611
|
+
|
|
612
|
+
const target = this.streamTarget;
|
|
613
|
+
const remaining = target.length - this.displayedLength;
|
|
614
|
+
|
|
615
|
+
if (remaining <= 0) {
|
|
616
|
+
// Caught up to everything buffered so far → idle. A later token bump
|
|
617
|
+
// (handleMessages → ensureRevealLoop) restarts the loop; the finalize
|
|
618
|
+
// structural rebuild snaps to the full markdown render.
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Adaptive speed: ~1/8 of the backlog per frame (≈ catch up over a few
|
|
623
|
+
// frames), clamped to a readable floor so short bursts still animate and
|
|
624
|
+
// an aggressive ceiling so a deep buffer drains fast. At ~60fps this
|
|
625
|
+
// comfortably outruns the wire — the reveal is steady, never bursty.
|
|
626
|
+
const step = Math.max(2, Math.min(remaining, Math.ceil(remaining / 8)));
|
|
627
|
+
this.displayedLength = Math.min(target.length, this.displayedLength + step);
|
|
628
|
+
bubble.textContent = target.slice(0, this.displayedLength);
|
|
629
|
+
this.scrollToBottom(false);
|
|
630
|
+
|
|
631
|
+
this.rafId = requestAnimationFrame(() => this.tickReveal());
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Snap the bound bubble to the full buffered text immediately (reduced-motion / no-rAF). */
|
|
635
|
+
private snapReveal(): void {
|
|
636
|
+
if (this.streamBubbleEl) {
|
|
637
|
+
this.displayedLength = this.streamTarget.length;
|
|
638
|
+
this.streamBubbleEl.textContent = this.streamTarget;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
|
|
643
|
+
private resetReveal(): void {
|
|
644
|
+
if (this.rafId && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.rafId);
|
|
645
|
+
this.rafId = 0;
|
|
646
|
+
this.streamBubbleEl = null;
|
|
647
|
+
// streamMsgId/displayedLength are intentionally kept so bindReveal can
|
|
648
|
+
// carry the revealed prefix across a structural rebuild of the same msg;
|
|
649
|
+
// they're reset when a different message binds.
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Auto-scroll the message list to the bottom — but don't fight a visitor who
|
|
654
|
+
* has scrolled up to read history. When `force` (a structural rebuild) we
|
|
655
|
+
* always pin to bottom; during the streaming reveal we only follow if the
|
|
656
|
+
* viewport is already near the bottom.
|
|
657
|
+
*/
|
|
658
|
+
private scrollToBottom(force: boolean): void {
|
|
659
|
+
const el = this.messagesEl;
|
|
660
|
+
if (!el) return;
|
|
661
|
+
if (force) {
|
|
662
|
+
el.scrollTop = el.scrollHeight;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
666
|
+
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
497
667
|
}
|
|
498
668
|
|
|
499
669
|
/** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
|
|
@@ -563,7 +733,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
563
733
|
a.className = 'src-title';
|
|
564
734
|
a.href = safeUrl;
|
|
565
735
|
a.target = '_blank';
|
|
566
|
-
a.rel = 'noopener noreferrer';
|
|
736
|
+
a.rel = 'noopener noreferrer nofollow';
|
|
567
737
|
titleEl = a;
|
|
568
738
|
} else {
|
|
569
739
|
titleEl = document.createElement('span');
|
|
@@ -573,10 +743,18 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
573
743
|
li.appendChild(titleEl);
|
|
574
744
|
|
|
575
745
|
if (c.snippet) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
746
|
+
// The snippet is the raw scraped chunk — often led by page
|
|
747
|
+
// boilerplate (logo link, nav, whitespace). Trim it to a clean
|
|
748
|
+
// excerpt, then render the sanitized markdown subset. Both steps
|
|
749
|
+
// escape text + drop images/unsafe links (see markdown.ts), so
|
|
750
|
+
// this `innerHTML` is safe.
|
|
751
|
+
const cleaned = cleanCitationSnippet(c.snippet);
|
|
752
|
+
if (cleaned) {
|
|
753
|
+
const snip = document.createElement('span');
|
|
754
|
+
snip.className = 'src-snippet md';
|
|
755
|
+
snip.innerHTML = renderMarkdown(cleaned);
|
|
756
|
+
li.appendChild(snip);
|
|
757
|
+
}
|
|
580
758
|
}
|
|
581
759
|
list.appendChild(li);
|
|
582
760
|
}
|
|
@@ -618,23 +796,6 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
618
796
|
}
|
|
619
797
|
}
|
|
620
798
|
|
|
621
|
-
function escapeHtml(value: string): string {
|
|
622
|
-
return value.replace(/[&<>"']/g, (c) => {
|
|
623
|
-
switch (c) {
|
|
624
|
-
case '&':
|
|
625
|
-
return '&';
|
|
626
|
-
case '<':
|
|
627
|
-
return '<';
|
|
628
|
-
case '>':
|
|
629
|
-
return '>';
|
|
630
|
-
case '"':
|
|
631
|
-
return '"';
|
|
632
|
-
default:
|
|
633
|
-
return ''';
|
|
634
|
-
}
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
|
|
638
799
|
/** Register the custom element once. Safe to call multiple times. */
|
|
639
800
|
export function defineChatWidget(): void {
|
|
640
801
|
if (typeof customElements !== 'undefined' && !customElements.get(ELEMENT_TAG)) {
|