@smooai/chat-widget 0.5.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/chat-widget",
3
- "version": "0.5.1",
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
- * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
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 = 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(resolved.greeting);
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
- private renderMessages(greeting: string): void {
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
- bubble.textContent = msg.text;
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.messagesEl.scrollTop = this.messagesEl.scrollHeight;
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
- const snip = document.createElement('span');
577
- snip.className = 'src-snippet';
578
- snip.textContent = c.snippet;
579
- li.appendChild(snip);
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 '&amp;';
626
- case '<':
627
- return '&lt;';
628
- case '>':
629
- return '&gt;';
630
- case '"':
631
- return '&quot;';
632
- default:
633
- return '&#39;';
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)) {