@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/dist/index.js CHANGED
@@ -48,6 +48,44 @@ function needsUserInfo(resolved) {
48
48
  return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
49
49
  }
50
50
  //#endregion
51
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
52
+ function _typeof(o) {
53
+ "@babel/helpers - typeof";
54
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
55
+ return typeof o;
56
+ } : function(o) {
57
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
58
+ }, _typeof(o);
59
+ }
60
+ //#endregion
61
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
62
+ function toPrimitive(t, r) {
63
+ if ("object" != _typeof(t) || !t) return t;
64
+ var e = t[Symbol.toPrimitive];
65
+ if (void 0 !== e) {
66
+ var i = e.call(t, r || "default");
67
+ if ("object" != _typeof(i)) return i;
68
+ throw new TypeError("@@toPrimitive must return a primitive value.");
69
+ }
70
+ return ("string" === r ? String : Number)(t);
71
+ }
72
+ //#endregion
73
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
74
+ function toPropertyKey(t) {
75
+ var i = toPrimitive(t, "string");
76
+ return "symbol" == _typeof(i) ? i : i + "";
77
+ }
78
+ //#endregion
79
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
80
+ function _defineProperty(e, r, t) {
81
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
82
+ value: t,
83
+ enumerable: !0,
84
+ configurable: !0,
85
+ writable: !0
86
+ }) : e[r] = t, e;
87
+ }
88
+ //#endregion
51
89
  //#region src/conversation.ts
52
90
  /**
53
91
  * ConversationController — the bridge between the widget UI and the
@@ -107,19 +145,17 @@ function extractCitations(inner) {
107
145
  return out;
108
146
  }
109
147
  var ConversationController = class {
110
- config;
111
- events;
112
- client = null;
113
- sessionId = null;
114
- messages = [];
115
- status = "idle";
116
- seq = 0;
117
- /** Visitor identity, seeded from config and updated by the pre-chat form. */
118
- identity;
119
- /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
120
- activeRequestId = null;
121
- interrupt = null;
122
148
  constructor(config, events) {
149
+ _defineProperty(this, "config", void 0);
150
+ _defineProperty(this, "events", void 0);
151
+ _defineProperty(this, "client", null);
152
+ _defineProperty(this, "sessionId", null);
153
+ _defineProperty(this, "messages", []);
154
+ _defineProperty(this, "status", "idle");
155
+ _defineProperty(this, "seq", 0);
156
+ _defineProperty(this, "identity", void 0);
157
+ _defineProperty(this, "activeRequestId", null);
158
+ _defineProperty(this, "interrupt", null);
123
159
  this.config = config;
124
160
  this.events = events;
125
161
  this.identity = {
@@ -315,6 +351,305 @@ var ConversationController = class {
315
351
  */
316
352
  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>";
317
353
  //#endregion
354
+ //#region src/markdown.ts
355
+ /**
356
+ * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
357
+ *
358
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
359
+ *
360
+ * The widget renders **untrusted** text in two places: the assistant's reply
361
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
362
+ * (raw scraped page chunks). Today both are written via `textContent`, so
363
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
364
+ * them rendered — without re-opening the XSS hole that `textContent` was
365
+ * guarding against.
366
+ *
367
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
368
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
369
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
370
+ * require bolting on a separate sanitizer. Instead, this renderer is
371
+ * **safe-by-construction**:
372
+ *
373
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
374
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
375
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
376
+ * tag out of the input — a literal `<script>` in the input is treated as
377
+ * plain text.
378
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
379
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
380
+ * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
381
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
382
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
383
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
384
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
385
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
386
+ * `<h1>` is far too large inside a chat bubble or citation card.
387
+ *
388
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
389
+ * an element the caller controls; because of (1)–(4) it can only contain the
390
+ * allowlisted, attribute-sanitized tags.
391
+ *
392
+ * Supported subset (deliberately small):
393
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
394
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
395
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
396
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
397
+ * - `> ` blockquotes
398
+ * - `[text](http(s)://url)` links (images dropped to alt text)
399
+ * - `#`..`######` headings → bold line
400
+ */
401
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
402
+ function escapeHtml(value) {
403
+ return value.replace(/[&<>"']/g, (c) => {
404
+ switch (c) {
405
+ case "&": return "&amp;";
406
+ case "<": return "&lt;";
407
+ case ">": return "&gt;";
408
+ case "\"": return "&quot;";
409
+ default: return "&#39;";
410
+ }
411
+ });
412
+ }
413
+ /**
414
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
415
+ *
416
+ * SECURITY: link targets here originate from untrusted content (LLM output /
417
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
418
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
419
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
420
+ * falls back to plain text upstream.
421
+ */
422
+ function safeHttpUrl(url) {
423
+ if (!url) return null;
424
+ try {
425
+ const parsed = new URL(url);
426
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
431
+ /**
432
+ * Render the inline span grammar of a single line/segment to safe HTML.
433
+ *
434
+ * Order matters: code spans are extracted first (their contents are *not*
435
+ * further parsed), then images are stripped to alt text, then links, then
436
+ * emphasis. Every literal text run is escaped on the way out.
437
+ */
438
+ function renderInline(input) {
439
+ let out = "";
440
+ let i = 0;
441
+ const n = input.length;
442
+ let buf = "";
443
+ const flush = () => {
444
+ if (buf) {
445
+ out += escapeHtml(buf);
446
+ buf = "";
447
+ }
448
+ };
449
+ while (i < n) {
450
+ const ch = input[i];
451
+ if (ch === "`") {
452
+ const end = input.indexOf("`", i + 1);
453
+ if (end > i) {
454
+ flush();
455
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
456
+ i = end + 1;
457
+ continue;
458
+ }
459
+ }
460
+ if (ch === "!" && input[i + 1] === "[") {
461
+ const m = imageAt(input, i);
462
+ if (m) {
463
+ flush();
464
+ out += renderInline(m.alt);
465
+ i = m.end;
466
+ continue;
467
+ }
468
+ }
469
+ if (ch === "[") {
470
+ const m = linkAt(input, i);
471
+ if (m) {
472
+ flush();
473
+ const safe = safeHttpUrl(m.href);
474
+ const inner = renderInline(m.text);
475
+ if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
476
+ else out += inner;
477
+ i = m.end;
478
+ continue;
479
+ }
480
+ }
481
+ if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
482
+ const marker = ch + ch;
483
+ const end = input.indexOf(marker, i + 2);
484
+ if (end > i + 1) {
485
+ flush();
486
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
487
+ i = end + 2;
488
+ continue;
489
+ }
490
+ }
491
+ if (ch === "*" || ch === "_") {
492
+ const end = input.indexOf(ch, i + 1);
493
+ if (end > i + 1 && input[i + 1] !== ch) {
494
+ flush();
495
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
496
+ i = end + 1;
497
+ continue;
498
+ }
499
+ }
500
+ buf += ch;
501
+ i++;
502
+ }
503
+ flush();
504
+ return out;
505
+ }
506
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
507
+ function linkAt(input, start) {
508
+ const close = matchBracket(input, start);
509
+ if (close < 0 || input[close + 1] !== "(") return null;
510
+ const paren = input.indexOf(")", close + 2);
511
+ if (paren < 0) return null;
512
+ return {
513
+ text: input.slice(start + 1, close),
514
+ href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
515
+ end: paren + 1
516
+ };
517
+ }
518
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
519
+ function imageAt(input, start) {
520
+ const link = linkAt(input, start + 1);
521
+ if (!link) return null;
522
+ return {
523
+ alt: link.text,
524
+ end: link.end
525
+ };
526
+ }
527
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
528
+ function matchBracket(input, open) {
529
+ let depth = 0;
530
+ for (let i = open; i < input.length; i++) {
531
+ const c = input[i];
532
+ if (c === "[") depth++;
533
+ else if (c === "]") {
534
+ depth--;
535
+ if (depth === 0) return i;
536
+ }
537
+ }
538
+ return -1;
539
+ }
540
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
541
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
542
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
543
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
544
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
545
+ /**
546
+ * Render a full Markdown string to safe HTML.
547
+ *
548
+ * @returns a string containing only the allowlisted tags described in the
549
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
550
+ */
551
+ function renderMarkdown(src) {
552
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
553
+ const out = [];
554
+ let i = 0;
555
+ while (i < lines.length) {
556
+ const line = lines[i];
557
+ const fence = FENCE_RE.exec(line);
558
+ if (fence) {
559
+ const marker = fence[1];
560
+ const body = [];
561
+ i++;
562
+ while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
563
+ body.push(lines[i]);
564
+ i++;
565
+ }
566
+ if (i < lines.length) i++;
567
+ out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
568
+ continue;
569
+ }
570
+ if (line.trim() === "") {
571
+ i++;
572
+ continue;
573
+ }
574
+ const heading = HEADING_RE.exec(line);
575
+ if (heading) {
576
+ out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
577
+ i++;
578
+ continue;
579
+ }
580
+ if (UL_RE.test(line) || OL_RE.test(line)) {
581
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
582
+ const re = ordered ? OL_RE : UL_RE;
583
+ const items = [];
584
+ while (i < lines.length) {
585
+ const m = re.exec(lines[i]);
586
+ if (!m) break;
587
+ items.push(`<li>${renderInline(m[1])}</li>`);
588
+ i++;
589
+ }
590
+ out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
591
+ continue;
592
+ }
593
+ if (QUOTE_RE.test(line)) {
594
+ const quoted = [];
595
+ while (i < lines.length) {
596
+ const m = QUOTE_RE.exec(lines[i]);
597
+ if (!m) break;
598
+ quoted.push(m[1]);
599
+ i++;
600
+ }
601
+ out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
602
+ continue;
603
+ }
604
+ const para = [];
605
+ while (i < lines.length) {
606
+ const l = lines[i];
607
+ if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
608
+ para.push(l);
609
+ i++;
610
+ }
611
+ out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
612
+ }
613
+ return out.join("");
614
+ }
615
+ const SNIPPET_MAX = 260;
616
+ /**
617
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
618
+ *
619
+ * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
620
+ * in a link, standalone nav, repeated whitespace — e.g.
621
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
622
+ * from the citation card, so the snippet only needs to be a clean teaser.
623
+ *
624
+ * Steps:
625
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
626
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
627
+ * 3. Collapse all runs of whitespace to single spaces.
628
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
629
+ *
630
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
631
+ * remaining inline markup (bold/links) stays safe.
632
+ */
633
+ function cleanCitationSnippet(raw) {
634
+ let s = raw ?? "";
635
+ let changed = true;
636
+ while (changed) {
637
+ changed = false;
638
+ const before = s;
639
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
640
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
641
+ s = s.replace(/^\s*#{1,6}\s+/, "");
642
+ if (s !== before) changed = true;
643
+ }
644
+ s = s.replace(/\s+/g, " ").trim();
645
+ if (s.length > SNIPPET_MAX) {
646
+ const cut = s.slice(0, SNIPPET_MAX);
647
+ const lastSpace = cut.lastIndexOf(" ");
648
+ s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
649
+ }
650
+ return s;
651
+ }
652
+ //#endregion
318
653
  //#region src/styles.ts
319
654
  /**
320
655
  * Render the widget's scoped stylesheet — the "Aurora Glass" design system.
@@ -639,6 +974,50 @@ function buildStyles(theme, mode = "popover") {
639
974
  }
640
975
  @keyframes sac-blink { to { opacity: 0 } }
641
976
 
977
+ /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
978
+ /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
979
+ keep them legible inside the tight Aurora-Glass bubble + citation card. */
980
+ /* Block-level markdown drives its own spacing/wrapping, so opt out of the
981
+ bubble's pre-wrap (which would otherwise add stray blank lines). */
982
+ .bubble.md { white-space: normal; }
983
+ .md > :first-child { margin-top: 0; }
984
+ .md > :last-child { margin-bottom: 0; }
985
+ .md p { margin: 0 0 8px; }
986
+ .md ul, .md ol { margin: 6px 0 8px; padding-left: 20px; }
987
+ .md li { margin: 2px 0; }
988
+ .md li::marker { color: color-mix(in srgb, var(--sac-primary) 75%, transparent); }
989
+ .md a {
990
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
991
+ text-decoration: underline;
992
+ text-underline-offset: 2px;
993
+ word-break: break-word;
994
+ }
995
+ .md a:hover { text-decoration: none; }
996
+ .md strong { font-weight: 700; }
997
+ .md em { font-style: italic; }
998
+ .md code {
999
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1000
+ font-size: .9em;
1001
+ padding: 1px 5px;
1002
+ border-radius: 5px;
1003
+ background: color-mix(in srgb, var(--sac-text) 10%, transparent);
1004
+ }
1005
+ .md pre {
1006
+ margin: 6px 0 8px;
1007
+ padding: 9px 11px;
1008
+ border-radius: 9px;
1009
+ overflow-x: auto;
1010
+ background: color-mix(in srgb, var(--sac-text) 9%, transparent);
1011
+ border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
1012
+ }
1013
+ .md pre code { padding: 0; background: none; font-size: 12px; line-height: 1.45; }
1014
+ .md blockquote {
1015
+ margin: 6px 0;
1016
+ padding: 2px 0 2px 11px;
1017
+ border-left: 2px solid color-mix(in srgb, var(--sac-primary) 55%, transparent);
1018
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
1019
+ }
1020
+
642
1021
  /* Full-page: center the conversation in a readable column. */
643
1022
  .panel.fullpage .messages { padding: 26px 20px; }
644
1023
  .panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
@@ -917,53 +1296,37 @@ const ICON = {
917
1296
  /** Tool-confirmation interrupt — a shield. */
918
1297
  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>`
919
1298
  };
920
- /**
921
- * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
922
- *
923
- * SECURITY: citation URLs originate from indexed content (web / GitHub
924
- * connectors), which can be attacker-influenceable. Assigning an arbitrary
925
- * string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
926
- * execute on click — a stored-XSS vector. Only http(s) links are rendered as
927
- * anchors; anything else falls back to plain text.
928
- */
929
- function safeHttpUrl(url) {
930
- if (!url) return null;
931
- try {
932
- const parsed = new URL(url);
933
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
934
- } catch {
935
- return null;
936
- }
937
- }
938
1299
  var SmoothAgentChatElement = class extends HTMLElement {
939
1300
  static get observedAttributes() {
940
1301
  return OBSERVED;
941
1302
  }
942
- root;
943
- controller = null;
944
- overrides = {};
945
- open = false;
946
- messages = [];
947
- status = "idle";
948
- mounted = false;
949
- /** True once the visitor has cleared the pre-chat identity gate (or it's not needed). */
950
- userInfoSatisfied = false;
951
- /** True after the visitor has sent their first message (hides starter chips). */
952
- hasSent = false;
953
- /** Starter prompts shown as chips in the empty state. */
954
- examplePrompts = [];
955
- /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
956
- interrupt = null;
957
- interruptEl = null;
958
- panelEl = null;
959
- launcherEl = null;
960
- messagesEl = null;
961
- statusEl = null;
962
- dotEl = null;
963
- inputEl = null;
964
- sendBtn = null;
965
1303
  constructor() {
966
1304
  super();
1305
+ _defineProperty(this, "root", void 0);
1306
+ _defineProperty(this, "controller", null);
1307
+ _defineProperty(this, "overrides", {});
1308
+ _defineProperty(this, "open", false);
1309
+ _defineProperty(this, "messages", []);
1310
+ _defineProperty(this, "status", "idle");
1311
+ _defineProperty(this, "mounted", false);
1312
+ _defineProperty(this, "userInfoSatisfied", false);
1313
+ _defineProperty(this, "hasSent", false);
1314
+ _defineProperty(this, "examplePrompts", []);
1315
+ _defineProperty(this, "greeting", "");
1316
+ _defineProperty(this, "interrupt", null);
1317
+ _defineProperty(this, "interruptEl", null);
1318
+ _defineProperty(this, "panelEl", null);
1319
+ _defineProperty(this, "launcherEl", null);
1320
+ _defineProperty(this, "messagesEl", null);
1321
+ _defineProperty(this, "statusEl", null);
1322
+ _defineProperty(this, "dotEl", null);
1323
+ _defineProperty(this, "inputEl", null);
1324
+ _defineProperty(this, "sendBtn", null);
1325
+ _defineProperty(this, "streamBubbleEl", null);
1326
+ _defineProperty(this, "streamMsgId", null);
1327
+ _defineProperty(this, "streamTarget", "");
1328
+ _defineProperty(this, "displayedLength", 0);
1329
+ _defineProperty(this, "rafId", 0);
967
1330
  this.root = this.attachShadow({ mode: "open" });
968
1331
  }
969
1332
  connectedCallback() {
@@ -972,6 +1335,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
972
1335
  }
973
1336
  disconnectedCallback() {
974
1337
  this.mounted = false;
1338
+ this.resetReveal();
975
1339
  this.controller?.disconnect();
976
1340
  this.controller = null;
977
1341
  }
@@ -1040,8 +1404,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1040
1404
  if (!this.controller) {
1041
1405
  this.controller = new ConversationController(config, {
1042
1406
  onMessages: (messages) => {
1043
- this.messages = messages;
1044
- this.renderMessages(resolved.greeting);
1407
+ this.handleMessages(messages, resolved.greeting);
1045
1408
  },
1046
1409
  onStatus: (status) => {
1047
1410
  this.status = status;
@@ -1076,6 +1439,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1076
1439
  <button class="close" aria-label="Close chat">${ICON.close}</button>
1077
1440
  </div>`;
1078
1441
  this.examplePrompts = resolved.examplePrompts;
1442
+ this.greeting = resolved.greeting;
1079
1443
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1080
1444
  const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
1081
1445
  const prechatHtml = `
@@ -1112,6 +1476,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1112
1476
  `;
1113
1477
  const logoSvg = container.querySelector(".logo-wrap svg");
1114
1478
  if (logoSvg) logoSvg.setAttribute("class", "logo");
1479
+ this.resetReveal();
1115
1480
  this.root.replaceChildren(style, container);
1116
1481
  this.launcherEl = container.querySelector(".launcher");
1117
1482
  this.panelEl = container.querySelector(".panel");
@@ -1138,7 +1503,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1138
1503
  });
1139
1504
  if (fullpage && !gating) this.controller?.connect().catch(() => {});
1140
1505
  this.syncOpenState();
1141
- if (!gating) this.renderMessages(resolved.greeting);
1506
+ if (!gating) this.renderMessages();
1142
1507
  this.renderStatus();
1143
1508
  this.renderComposerState();
1144
1509
  this.renderInterrupt();
@@ -1269,9 +1634,35 @@ var SmoothAgentChatElement = class extends HTMLElement {
1269
1634
  ta.style.height = "auto";
1270
1635
  ta.style.height = `${ta.scrollHeight}px`;
1271
1636
  }
1272
- renderMessages(greeting) {
1637
+ /**
1638
+ * Receive a new message snapshot from the controller and update the view.
1639
+ *
1640
+ * `onMessages` fires on *every* `stream_token` delta. Rebuilding the whole
1641
+ * list each frame is wasteful and fights the smooth reveal, so we take the
1642
+ * cheap path when the only change is the trailing streaming assistant message
1643
+ * growing: just bump the reveal *target* (the rAF loop drains it). Any
1644
+ * structural change (new message, finalize, citations) triggers a full
1645
+ * rebuild via {@link renderMessages}.
1646
+ */
1647
+ handleMessages(messages, greeting) {
1648
+ this.greeting = greeting;
1649
+ const prev = this.messages;
1650
+ const last = messages[messages.length - 1];
1651
+ const prevLast = prev[prev.length - 1];
1652
+ const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text;
1653
+ this.messages = messages;
1654
+ if (!structural && last && last.streaming && last.id === this.streamMsgId) {
1655
+ this.streamTarget = last.text;
1656
+ this.ensureRevealLoop();
1657
+ return;
1658
+ }
1659
+ this.renderMessages();
1660
+ }
1661
+ renderMessages() {
1273
1662
  if (!this.messagesEl) return;
1663
+ this.resetReveal();
1274
1664
  this.messagesEl.replaceChildren();
1665
+ const greeting = this.greeting;
1275
1666
  if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting)));
1276
1667
  if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
1277
1668
  const chips = document.createElement("div");
@@ -1294,12 +1685,94 @@ var SmoothAgentChatElement = class extends HTMLElement {
1294
1685
  bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
1295
1686
  } else if (msg.streaming) {
1296
1687
  bubble.classList.add("cursor");
1297
- bubble.textContent = msg.text;
1688
+ this.bindReveal(msg, bubble);
1689
+ } else if (msg.role === "assistant") {
1690
+ bubble.classList.add("md");
1691
+ bubble.innerHTML = renderMarkdown(msg.text);
1298
1692
  } else bubble.textContent = msg.text;
1299
1693
  this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
1300
1694
  if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
1301
1695
  }
1302
- this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
1696
+ this.scrollToBottom(true);
1697
+ }
1698
+ /** True when the host/user prefers reduced motion (snap, no typewriter). */
1699
+ prefersReducedMotion() {
1700
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
1701
+ }
1702
+ /**
1703
+ * Bind the rAF typewriter to a freshly built streaming bubble. Preserves the
1704
+ * already-revealed prefix across rebuilds (so a structural rebuild mid-stream
1705
+ * doesn't restart the reveal from zero), then resumes the loop.
1706
+ */
1707
+ bindReveal(msg, bubble) {
1708
+ const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
1709
+ this.streamBubbleEl = bubble;
1710
+ this.streamMsgId = msg.id;
1711
+ this.streamTarget = msg.text;
1712
+ this.displayedLength = carryOver;
1713
+ if (this.prefersReducedMotion()) {
1714
+ this.displayedLength = msg.text.length;
1715
+ bubble.textContent = msg.text;
1716
+ return;
1717
+ }
1718
+ bubble.textContent = msg.text.slice(0, this.displayedLength);
1719
+ this.ensureRevealLoop();
1720
+ }
1721
+ /** Start the rAF loop if it isn't already running. */
1722
+ ensureRevealLoop() {
1723
+ if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
1724
+ this.snapReveal();
1725
+ return;
1726
+ }
1727
+ if (this.rafId) return;
1728
+ this.rafId = requestAnimationFrame(() => this.tickReveal());
1729
+ }
1730
+ /**
1731
+ * One animation frame of the reveal. Advances `displayedLength` toward the
1732
+ * buffered target at an ADAPTIVE rate — the deeper the backlog, the more
1733
+ * chars per frame — so the reveal stays smooth yet never falls behind the
1734
+ * network. Updates ONLY the bound bubble's textContent (no list rebuild).
1735
+ */
1736
+ tickReveal() {
1737
+ this.rafId = 0;
1738
+ const bubble = this.streamBubbleEl;
1739
+ if (!bubble) return;
1740
+ const target = this.streamTarget;
1741
+ const remaining = target.length - this.displayedLength;
1742
+ if (remaining <= 0) return;
1743
+ const step = Math.max(2, Math.min(remaining, Math.ceil(remaining / 8)));
1744
+ this.displayedLength = Math.min(target.length, this.displayedLength + step);
1745
+ bubble.textContent = target.slice(0, this.displayedLength);
1746
+ this.scrollToBottom(false);
1747
+ this.rafId = requestAnimationFrame(() => this.tickReveal());
1748
+ }
1749
+ /** Snap the bound bubble to the full buffered text immediately (reduced-motion / no-rAF). */
1750
+ snapReveal() {
1751
+ if (this.streamBubbleEl) {
1752
+ this.displayedLength = this.streamTarget.length;
1753
+ this.streamBubbleEl.textContent = this.streamTarget;
1754
+ }
1755
+ }
1756
+ /** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
1757
+ resetReveal() {
1758
+ if (this.rafId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafId);
1759
+ this.rafId = 0;
1760
+ this.streamBubbleEl = null;
1761
+ }
1762
+ /**
1763
+ * Auto-scroll the message list to the bottom — but don't fight a visitor who
1764
+ * has scrolled up to read history. When `force` (a structural rebuild) we
1765
+ * always pin to bottom; during the streaming reveal we only follow if the
1766
+ * viewport is already near the bottom.
1767
+ */
1768
+ scrollToBottom(force) {
1769
+ const el = this.messagesEl;
1770
+ if (!el) return;
1771
+ if (force) {
1772
+ el.scrollTop = el.scrollHeight;
1773
+ return;
1774
+ }
1775
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) el.scrollTop = el.scrollHeight;
1303
1776
  }
1304
1777
  /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
1305
1778
  buildRow(role, bubble) {
@@ -1356,7 +1829,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1356
1829
  a.className = "src-title";
1357
1830
  a.href = safeUrl;
1358
1831
  a.target = "_blank";
1359
- a.rel = "noopener noreferrer";
1832
+ a.rel = "noopener noreferrer nofollow";
1360
1833
  titleEl = a;
1361
1834
  } else {
1362
1835
  titleEl = document.createElement("span");
@@ -1365,10 +1838,13 @@ var SmoothAgentChatElement = class extends HTMLElement {
1365
1838
  titleEl.textContent = c.title || c.id || "Source";
1366
1839
  li.appendChild(titleEl);
1367
1840
  if (c.snippet) {
1368
- const snip = document.createElement("span");
1369
- snip.className = "src-snippet";
1370
- snip.textContent = c.snippet;
1371
- li.appendChild(snip);
1841
+ const cleaned = cleanCitationSnippet(c.snippet);
1842
+ if (cleaned) {
1843
+ const snip = document.createElement("span");
1844
+ snip.className = "src-snippet md";
1845
+ snip.innerHTML = renderMarkdown(cleaned);
1846
+ li.appendChild(snip);
1847
+ }
1372
1848
  }
1373
1849
  list.appendChild(li);
1374
1850
  }
@@ -1405,17 +1881,6 @@ var SmoothAgentChatElement = class extends HTMLElement {
1405
1881
  this.controller.send(text);
1406
1882
  }
1407
1883
  };
1408
- function escapeHtml(value) {
1409
- return value.replace(/[&<>"']/g, (c) => {
1410
- switch (c) {
1411
- case "&": return "&amp;";
1412
- case "<": return "&lt;";
1413
- case ">": return "&gt;";
1414
- case "\"": return "&quot;";
1415
- default: return "&#39;";
1416
- }
1417
- });
1418
- }
1419
1884
  /** Register the custom element once. Safe to call multiple times. */
1420
1885
  function defineChatWidget() {
1421
1886
  if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);