@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.
@@ -874,6 +874,305 @@ var SmoothAgentChat = (function(exports) {
874
874
  */
875
875
  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>";
876
876
  //#endregion
877
+ //#region src/markdown.ts
878
+ /**
879
+ * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
880
+ *
881
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
882
+ *
883
+ * The widget renders **untrusted** text in two places: the assistant's reply
884
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
885
+ * (raw scraped page chunks). Today both are written via `textContent`, so
886
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
887
+ * them rendered — without re-opening the XSS hole that `textContent` was
888
+ * guarding against.
889
+ *
890
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
891
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
892
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
893
+ * require bolting on a separate sanitizer. Instead, this renderer is
894
+ * **safe-by-construction**:
895
+ *
896
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
897
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
898
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
899
+ * tag out of the input — a literal `<script>` in the input is treated as
900
+ * plain text.
901
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
902
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
903
+ * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
904
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
905
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
906
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
907
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
908
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
909
+ * `<h1>` is far too large inside a chat bubble or citation card.
910
+ *
911
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
912
+ * an element the caller controls; because of (1)–(4) it can only contain the
913
+ * allowlisted, attribute-sanitized tags.
914
+ *
915
+ * Supported subset (deliberately small):
916
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
917
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
918
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
919
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
920
+ * - `> ` blockquotes
921
+ * - `[text](http(s)://url)` links (images dropped to alt text)
922
+ * - `#`..`######` headings → bold line
923
+ */
924
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
925
+ function escapeHtml(value) {
926
+ return value.replace(/[&<>"']/g, (c) => {
927
+ switch (c) {
928
+ case "&": return "&amp;";
929
+ case "<": return "&lt;";
930
+ case ">": return "&gt;";
931
+ case "\"": return "&quot;";
932
+ default: return "&#39;";
933
+ }
934
+ });
935
+ }
936
+ /**
937
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
938
+ *
939
+ * SECURITY: link targets here originate from untrusted content (LLM output /
940
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
941
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
942
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
943
+ * falls back to plain text upstream.
944
+ */
945
+ function safeHttpUrl(url) {
946
+ if (!url) return null;
947
+ try {
948
+ const parsed = new URL(url);
949
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
950
+ } catch {
951
+ return null;
952
+ }
953
+ }
954
+ /**
955
+ * Render the inline span grammar of a single line/segment to safe HTML.
956
+ *
957
+ * Order matters: code spans are extracted first (their contents are *not*
958
+ * further parsed), then images are stripped to alt text, then links, then
959
+ * emphasis. Every literal text run is escaped on the way out.
960
+ */
961
+ function renderInline(input) {
962
+ let out = "";
963
+ let i = 0;
964
+ const n = input.length;
965
+ let buf = "";
966
+ const flush = () => {
967
+ if (buf) {
968
+ out += escapeHtml(buf);
969
+ buf = "";
970
+ }
971
+ };
972
+ while (i < n) {
973
+ const ch = input[i];
974
+ if (ch === "`") {
975
+ const end = input.indexOf("`", i + 1);
976
+ if (end > i) {
977
+ flush();
978
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
979
+ i = end + 1;
980
+ continue;
981
+ }
982
+ }
983
+ if (ch === "!" && input[i + 1] === "[") {
984
+ const m = imageAt(input, i);
985
+ if (m) {
986
+ flush();
987
+ out += renderInline(m.alt);
988
+ i = m.end;
989
+ continue;
990
+ }
991
+ }
992
+ if (ch === "[") {
993
+ const m = linkAt(input, i);
994
+ if (m) {
995
+ flush();
996
+ const safe = safeHttpUrl(m.href);
997
+ const inner = renderInline(m.text);
998
+ if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
999
+ else out += inner;
1000
+ i = m.end;
1001
+ continue;
1002
+ }
1003
+ }
1004
+ if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
1005
+ const marker = ch + ch;
1006
+ const end = input.indexOf(marker, i + 2);
1007
+ if (end > i + 1) {
1008
+ flush();
1009
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
1010
+ i = end + 2;
1011
+ continue;
1012
+ }
1013
+ }
1014
+ if (ch === "*" || ch === "_") {
1015
+ const end = input.indexOf(ch, i + 1);
1016
+ if (end > i + 1 && input[i + 1] !== ch) {
1017
+ flush();
1018
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
1019
+ i = end + 1;
1020
+ continue;
1021
+ }
1022
+ }
1023
+ buf += ch;
1024
+ i++;
1025
+ }
1026
+ flush();
1027
+ return out;
1028
+ }
1029
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
1030
+ function linkAt(input, start) {
1031
+ const close = matchBracket(input, start);
1032
+ if (close < 0 || input[close + 1] !== "(") return null;
1033
+ const paren = input.indexOf(")", close + 2);
1034
+ if (paren < 0) return null;
1035
+ return {
1036
+ text: input.slice(start + 1, close),
1037
+ href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
1038
+ end: paren + 1
1039
+ };
1040
+ }
1041
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
1042
+ function imageAt(input, start) {
1043
+ const link = linkAt(input, start + 1);
1044
+ if (!link) return null;
1045
+ return {
1046
+ alt: link.text,
1047
+ end: link.end
1048
+ };
1049
+ }
1050
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
1051
+ function matchBracket(input, open) {
1052
+ let depth = 0;
1053
+ for (let i = open; i < input.length; i++) {
1054
+ const c = input[i];
1055
+ if (c === "[") depth++;
1056
+ else if (c === "]") {
1057
+ depth--;
1058
+ if (depth === 0) return i;
1059
+ }
1060
+ }
1061
+ return -1;
1062
+ }
1063
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
1064
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
1065
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
1066
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
1067
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
1068
+ /**
1069
+ * Render a full Markdown string to safe HTML.
1070
+ *
1071
+ * @returns a string containing only the allowlisted tags described in the
1072
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
1073
+ */
1074
+ function renderMarkdown(src) {
1075
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
1076
+ const out = [];
1077
+ let i = 0;
1078
+ while (i < lines.length) {
1079
+ const line = lines[i];
1080
+ const fence = FENCE_RE.exec(line);
1081
+ if (fence) {
1082
+ const marker = fence[1];
1083
+ const body = [];
1084
+ i++;
1085
+ while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
1086
+ body.push(lines[i]);
1087
+ i++;
1088
+ }
1089
+ if (i < lines.length) i++;
1090
+ out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
1091
+ continue;
1092
+ }
1093
+ if (line.trim() === "") {
1094
+ i++;
1095
+ continue;
1096
+ }
1097
+ const heading = HEADING_RE.exec(line);
1098
+ if (heading) {
1099
+ out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
1100
+ i++;
1101
+ continue;
1102
+ }
1103
+ if (UL_RE.test(line) || OL_RE.test(line)) {
1104
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
1105
+ const re = ordered ? OL_RE : UL_RE;
1106
+ const items = [];
1107
+ while (i < lines.length) {
1108
+ const m = re.exec(lines[i]);
1109
+ if (!m) break;
1110
+ items.push(`<li>${renderInline(m[1])}</li>`);
1111
+ i++;
1112
+ }
1113
+ out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
1114
+ continue;
1115
+ }
1116
+ if (QUOTE_RE.test(line)) {
1117
+ const quoted = [];
1118
+ while (i < lines.length) {
1119
+ const m = QUOTE_RE.exec(lines[i]);
1120
+ if (!m) break;
1121
+ quoted.push(m[1]);
1122
+ i++;
1123
+ }
1124
+ out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
1125
+ continue;
1126
+ }
1127
+ const para = [];
1128
+ while (i < lines.length) {
1129
+ const l = lines[i];
1130
+ if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
1131
+ para.push(l);
1132
+ i++;
1133
+ }
1134
+ out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
1135
+ }
1136
+ return out.join("");
1137
+ }
1138
+ const SNIPPET_MAX = 260;
1139
+ /**
1140
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
1141
+ *
1142
+ * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
1143
+ * in a link, standalone nav, repeated whitespace — e.g.
1144
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
1145
+ * from the citation card, so the snippet only needs to be a clean teaser.
1146
+ *
1147
+ * Steps:
1148
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
1149
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
1150
+ * 3. Collapse all runs of whitespace to single spaces.
1151
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
1152
+ *
1153
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
1154
+ * remaining inline markup (bold/links) stays safe.
1155
+ */
1156
+ function cleanCitationSnippet(raw) {
1157
+ let s = raw ?? "";
1158
+ let changed = true;
1159
+ while (changed) {
1160
+ changed = false;
1161
+ const before = s;
1162
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
1163
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
1164
+ s = s.replace(/^\s*#{1,6}\s+/, "");
1165
+ if (s !== before) changed = true;
1166
+ }
1167
+ s = s.replace(/\s+/g, " ").trim();
1168
+ if (s.length > SNIPPET_MAX) {
1169
+ const cut = s.slice(0, SNIPPET_MAX);
1170
+ const lastSpace = cut.lastIndexOf(" ");
1171
+ s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
1172
+ }
1173
+ return s;
1174
+ }
1175
+ //#endregion
877
1176
  //#region src/styles.ts
878
1177
  /**
879
1178
  * Render the widget's scoped stylesheet — the "Aurora Glass" design system.
@@ -1198,6 +1497,50 @@ var SmoothAgentChat = (function(exports) {
1198
1497
  }
1199
1498
  @keyframes sac-blink { to { opacity: 0 } }
1200
1499
 
1500
+ /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
1501
+ /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
1502
+ keep them legible inside the tight Aurora-Glass bubble + citation card. */
1503
+ /* Block-level markdown drives its own spacing/wrapping, so opt out of the
1504
+ bubble's pre-wrap (which would otherwise add stray blank lines). */
1505
+ .bubble.md { white-space: normal; }
1506
+ .md > :first-child { margin-top: 0; }
1507
+ .md > :last-child { margin-bottom: 0; }
1508
+ .md p { margin: 0 0 8px; }
1509
+ .md ul, .md ol { margin: 6px 0 8px; padding-left: 20px; }
1510
+ .md li { margin: 2px 0; }
1511
+ .md li::marker { color: color-mix(in srgb, var(--sac-primary) 75%, transparent); }
1512
+ .md a {
1513
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
1514
+ text-decoration: underline;
1515
+ text-underline-offset: 2px;
1516
+ word-break: break-word;
1517
+ }
1518
+ .md a:hover { text-decoration: none; }
1519
+ .md strong { font-weight: 700; }
1520
+ .md em { font-style: italic; }
1521
+ .md code {
1522
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1523
+ font-size: .9em;
1524
+ padding: 1px 5px;
1525
+ border-radius: 5px;
1526
+ background: color-mix(in srgb, var(--sac-text) 10%, transparent);
1527
+ }
1528
+ .md pre {
1529
+ margin: 6px 0 8px;
1530
+ padding: 9px 11px;
1531
+ border-radius: 9px;
1532
+ overflow-x: auto;
1533
+ background: color-mix(in srgb, var(--sac-text) 9%, transparent);
1534
+ border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
1535
+ }
1536
+ .md pre code { padding: 0; background: none; font-size: 12px; line-height: 1.45; }
1537
+ .md blockquote {
1538
+ margin: 6px 0;
1539
+ padding: 2px 0 2px 11px;
1540
+ border-left: 2px solid color-mix(in srgb, var(--sac-primary) 55%, transparent);
1541
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
1542
+ }
1543
+
1201
1544
  /* Full-page: center the conversation in a readable column. */
1202
1545
  .panel.fullpage .messages { padding: 26px 20px; }
1203
1546
  .panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
@@ -1476,24 +1819,6 @@ var SmoothAgentChat = (function(exports) {
1476
1819
  /** Tool-confirmation interrupt — a shield. */
1477
1820
  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>`
1478
1821
  };
1479
- /**
1480
- * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
1481
- *
1482
- * SECURITY: citation URLs originate from indexed content (web / GitHub
1483
- * connectors), which can be attacker-influenceable. Assigning an arbitrary
1484
- * string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
1485
- * execute on click — a stored-XSS vector. Only http(s) links are rendered as
1486
- * anchors; anything else falls back to plain text.
1487
- */
1488
- function safeHttpUrl(url) {
1489
- if (!url) return null;
1490
- try {
1491
- const parsed = new URL(url);
1492
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
1493
- } catch {
1494
- return null;
1495
- }
1496
- }
1497
1822
  var SmoothAgentChatElement = class extends HTMLElement {
1498
1823
  static get observedAttributes() {
1499
1824
  return OBSERVED;
@@ -1510,6 +1835,7 @@ var SmoothAgentChat = (function(exports) {
1510
1835
  _defineProperty(this, "userInfoSatisfied", false);
1511
1836
  _defineProperty(this, "hasSent", false);
1512
1837
  _defineProperty(this, "examplePrompts", []);
1838
+ _defineProperty(this, "greeting", "");
1513
1839
  _defineProperty(this, "interrupt", null);
1514
1840
  _defineProperty(this, "interruptEl", null);
1515
1841
  _defineProperty(this, "panelEl", null);
@@ -1519,6 +1845,11 @@ var SmoothAgentChat = (function(exports) {
1519
1845
  _defineProperty(this, "dotEl", null);
1520
1846
  _defineProperty(this, "inputEl", null);
1521
1847
  _defineProperty(this, "sendBtn", null);
1848
+ _defineProperty(this, "streamBubbleEl", null);
1849
+ _defineProperty(this, "streamMsgId", null);
1850
+ _defineProperty(this, "streamTarget", "");
1851
+ _defineProperty(this, "displayedLength", 0);
1852
+ _defineProperty(this, "rafId", 0);
1522
1853
  this.root = this.attachShadow({ mode: "open" });
1523
1854
  }
1524
1855
  connectedCallback() {
@@ -1527,6 +1858,7 @@ var SmoothAgentChat = (function(exports) {
1527
1858
  }
1528
1859
  disconnectedCallback() {
1529
1860
  this.mounted = false;
1861
+ this.resetReveal();
1530
1862
  this.controller?.disconnect();
1531
1863
  this.controller = null;
1532
1864
  }
@@ -1595,8 +1927,7 @@ var SmoothAgentChat = (function(exports) {
1595
1927
  if (!this.controller) {
1596
1928
  this.controller = new ConversationController(config, {
1597
1929
  onMessages: (messages) => {
1598
- this.messages = messages;
1599
- this.renderMessages(resolved.greeting);
1930
+ this.handleMessages(messages, resolved.greeting);
1600
1931
  },
1601
1932
  onStatus: (status) => {
1602
1933
  this.status = status;
@@ -1631,6 +1962,7 @@ var SmoothAgentChat = (function(exports) {
1631
1962
  <button class="close" aria-label="Close chat">${ICON.close}</button>
1632
1963
  </div>`;
1633
1964
  this.examplePrompts = resolved.examplePrompts;
1965
+ this.greeting = resolved.greeting;
1634
1966
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1635
1967
  const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
1636
1968
  const prechatHtml = `
@@ -1667,6 +1999,7 @@ var SmoothAgentChat = (function(exports) {
1667
1999
  `;
1668
2000
  const logoSvg = container.querySelector(".logo-wrap svg");
1669
2001
  if (logoSvg) logoSvg.setAttribute("class", "logo");
2002
+ this.resetReveal();
1670
2003
  this.root.replaceChildren(style, container);
1671
2004
  this.launcherEl = container.querySelector(".launcher");
1672
2005
  this.panelEl = container.querySelector(".panel");
@@ -1693,7 +2026,7 @@ var SmoothAgentChat = (function(exports) {
1693
2026
  });
1694
2027
  if (fullpage && !gating) this.controller?.connect().catch(() => {});
1695
2028
  this.syncOpenState();
1696
- if (!gating) this.renderMessages(resolved.greeting);
2029
+ if (!gating) this.renderMessages();
1697
2030
  this.renderStatus();
1698
2031
  this.renderComposerState();
1699
2032
  this.renderInterrupt();
@@ -1824,9 +2157,35 @@ var SmoothAgentChat = (function(exports) {
1824
2157
  ta.style.height = "auto";
1825
2158
  ta.style.height = `${ta.scrollHeight}px`;
1826
2159
  }
1827
- renderMessages(greeting) {
2160
+ /**
2161
+ * Receive a new message snapshot from the controller and update the view.
2162
+ *
2163
+ * `onMessages` fires on *every* `stream_token` delta. Rebuilding the whole
2164
+ * list each frame is wasteful and fights the smooth reveal, so we take the
2165
+ * cheap path when the only change is the trailing streaming assistant message
2166
+ * growing: just bump the reveal *target* (the rAF loop drains it). Any
2167
+ * structural change (new message, finalize, citations) triggers a full
2168
+ * rebuild via {@link renderMessages}.
2169
+ */
2170
+ handleMessages(messages, greeting) {
2171
+ this.greeting = greeting;
2172
+ const prev = this.messages;
2173
+ const last = messages[messages.length - 1];
2174
+ const prevLast = prev[prev.length - 1];
2175
+ 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;
2176
+ this.messages = messages;
2177
+ if (!structural && last && last.streaming && last.id === this.streamMsgId) {
2178
+ this.streamTarget = last.text;
2179
+ this.ensureRevealLoop();
2180
+ return;
2181
+ }
2182
+ this.renderMessages();
2183
+ }
2184
+ renderMessages() {
1828
2185
  if (!this.messagesEl) return;
2186
+ this.resetReveal();
1829
2187
  this.messagesEl.replaceChildren();
2188
+ const greeting = this.greeting;
1830
2189
  if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting)));
1831
2190
  if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
1832
2191
  const chips = document.createElement("div");
@@ -1849,12 +2208,94 @@ var SmoothAgentChat = (function(exports) {
1849
2208
  bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
1850
2209
  } else if (msg.streaming) {
1851
2210
  bubble.classList.add("cursor");
1852
- bubble.textContent = msg.text;
2211
+ this.bindReveal(msg, bubble);
2212
+ } else if (msg.role === "assistant") {
2213
+ bubble.classList.add("md");
2214
+ bubble.innerHTML = renderMarkdown(msg.text);
1853
2215
  } else bubble.textContent = msg.text;
1854
2216
  this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
1855
2217
  if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
1856
2218
  }
1857
- this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
2219
+ this.scrollToBottom(true);
2220
+ }
2221
+ /** True when the host/user prefers reduced motion (snap, no typewriter). */
2222
+ prefersReducedMotion() {
2223
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2224
+ }
2225
+ /**
2226
+ * Bind the rAF typewriter to a freshly built streaming bubble. Preserves the
2227
+ * already-revealed prefix across rebuilds (so a structural rebuild mid-stream
2228
+ * doesn't restart the reveal from zero), then resumes the loop.
2229
+ */
2230
+ bindReveal(msg, bubble) {
2231
+ const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
2232
+ this.streamBubbleEl = bubble;
2233
+ this.streamMsgId = msg.id;
2234
+ this.streamTarget = msg.text;
2235
+ this.displayedLength = carryOver;
2236
+ if (this.prefersReducedMotion()) {
2237
+ this.displayedLength = msg.text.length;
2238
+ bubble.textContent = msg.text;
2239
+ return;
2240
+ }
2241
+ bubble.textContent = msg.text.slice(0, this.displayedLength);
2242
+ this.ensureRevealLoop();
2243
+ }
2244
+ /** Start the rAF loop if it isn't already running. */
2245
+ ensureRevealLoop() {
2246
+ if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
2247
+ this.snapReveal();
2248
+ return;
2249
+ }
2250
+ if (this.rafId) return;
2251
+ this.rafId = requestAnimationFrame(() => this.tickReveal());
2252
+ }
2253
+ /**
2254
+ * One animation frame of the reveal. Advances `displayedLength` toward the
2255
+ * buffered target at an ADAPTIVE rate — the deeper the backlog, the more
2256
+ * chars per frame — so the reveal stays smooth yet never falls behind the
2257
+ * network. Updates ONLY the bound bubble's textContent (no list rebuild).
2258
+ */
2259
+ tickReveal() {
2260
+ this.rafId = 0;
2261
+ const bubble = this.streamBubbleEl;
2262
+ if (!bubble) return;
2263
+ const target = this.streamTarget;
2264
+ const remaining = target.length - this.displayedLength;
2265
+ if (remaining <= 0) return;
2266
+ const step = Math.max(2, Math.min(remaining, Math.ceil(remaining / 8)));
2267
+ this.displayedLength = Math.min(target.length, this.displayedLength + step);
2268
+ bubble.textContent = target.slice(0, this.displayedLength);
2269
+ this.scrollToBottom(false);
2270
+ this.rafId = requestAnimationFrame(() => this.tickReveal());
2271
+ }
2272
+ /** Snap the bound bubble to the full buffered text immediately (reduced-motion / no-rAF). */
2273
+ snapReveal() {
2274
+ if (this.streamBubbleEl) {
2275
+ this.displayedLength = this.streamTarget.length;
2276
+ this.streamBubbleEl.textContent = this.streamTarget;
2277
+ }
2278
+ }
2279
+ /** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
2280
+ resetReveal() {
2281
+ if (this.rafId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafId);
2282
+ this.rafId = 0;
2283
+ this.streamBubbleEl = null;
2284
+ }
2285
+ /**
2286
+ * Auto-scroll the message list to the bottom — but don't fight a visitor who
2287
+ * has scrolled up to read history. When `force` (a structural rebuild) we
2288
+ * always pin to bottom; during the streaming reveal we only follow if the
2289
+ * viewport is already near the bottom.
2290
+ */
2291
+ scrollToBottom(force) {
2292
+ const el = this.messagesEl;
2293
+ if (!el) return;
2294
+ if (force) {
2295
+ el.scrollTop = el.scrollHeight;
2296
+ return;
2297
+ }
2298
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) el.scrollTop = el.scrollHeight;
1858
2299
  }
1859
2300
  /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
1860
2301
  buildRow(role, bubble) {
@@ -1911,7 +2352,7 @@ var SmoothAgentChat = (function(exports) {
1911
2352
  a.className = "src-title";
1912
2353
  a.href = safeUrl;
1913
2354
  a.target = "_blank";
1914
- a.rel = "noopener noreferrer";
2355
+ a.rel = "noopener noreferrer nofollow";
1915
2356
  titleEl = a;
1916
2357
  } else {
1917
2358
  titleEl = document.createElement("span");
@@ -1920,10 +2361,13 @@ var SmoothAgentChat = (function(exports) {
1920
2361
  titleEl.textContent = c.title || c.id || "Source";
1921
2362
  li.appendChild(titleEl);
1922
2363
  if (c.snippet) {
1923
- const snip = document.createElement("span");
1924
- snip.className = "src-snippet";
1925
- snip.textContent = c.snippet;
1926
- li.appendChild(snip);
2364
+ const cleaned = cleanCitationSnippet(c.snippet);
2365
+ if (cleaned) {
2366
+ const snip = document.createElement("span");
2367
+ snip.className = "src-snippet md";
2368
+ snip.innerHTML = renderMarkdown(cleaned);
2369
+ li.appendChild(snip);
2370
+ }
1927
2371
  }
1928
2372
  list.appendChild(li);
1929
2373
  }
@@ -1960,17 +2404,6 @@ var SmoothAgentChat = (function(exports) {
1960
2404
  this.controller.send(text);
1961
2405
  }
1962
2406
  };
1963
- function escapeHtml(value) {
1964
- return value.replace(/[&<>"']/g, (c) => {
1965
- switch (c) {
1966
- case "&": return "&amp;";
1967
- case "<": return "&lt;";
1968
- case ">": return "&gt;";
1969
- case "\"": return "&quot;";
1970
- default: return "&#39;";
1971
- }
1972
- });
1973
- }
1974
2407
  /** Register the custom element once. Safe to call multiple times. */
1975
2408
  function defineChatWidget() {
1976
2409
  if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);