@smooai/chat-widget 0.7.0 → 0.10.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
@@ -1,1356 +1,1359 @@
1
+ import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js/min";
1
2
  import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
2
3
  import { createStore } from "zustand/vanilla";
3
4
  import { persist } from "zustand/middleware";
4
- //#region src/config.ts
5
- /** Resolve a partial config against the built-in defaults. */
6
- function resolveConfig(config) {
7
- const theme = config.theme ?? {};
8
- const primary = theme.primary ?? "#00a6a6";
9
- const primaryText = theme.primaryText ?? "#f8fafc";
10
- const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
11
- const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
12
- const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
13
- const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
14
- return {
15
- endpoint: config.endpoint,
16
- mode: config.mode ?? "popover",
17
- agentId: config.agentId,
18
- agentName: config.agentName ?? "Assistant",
19
- userName: config.userName,
20
- userEmail: config.userEmail,
21
- userPhone: config.userPhone,
22
- authContext: config.authContext,
23
- placeholder: config.placeholder ?? "Type a message…",
24
- greeting: config.greeting ?? "Hi! How can I help you today?",
25
- connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
26
- startOpen: config.startOpen ?? false,
27
- examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
28
- requireName: config.requireName ?? false,
29
- requireEmail: config.requireEmail ?? false,
30
- requirePhone: config.requirePhone ?? false,
31
- collectPhone: config.collectPhone ?? true,
32
- collectConsent: config.collectConsent ?? true,
33
- allowChatRestore: config.allowChatRestore ?? true,
34
- allowAnonymous: config.allowAnonymous ?? false,
35
- theme: {
36
- text: theme.text ?? "#f8fafc",
37
- background: theme.background ?? "#040d30",
38
- primary,
39
- primaryText,
40
- secondary: theme.secondary ?? "#ff6b6c",
41
- assistantBubble,
42
- assistantBubbleText,
43
- userBubble,
44
- userBubbleText,
45
- border: theme.border ?? "rgba(255, 255, 255, 0.1)"
46
- }
47
- };
48
- }
49
- /**
50
- * Whether the pre-chat identity form should gate the conversation: at least one
51
- * field is required and anonymous chat is not allowed.
52
- */
53
- function needsUserInfo(resolved) {
54
- return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
55
- }
56
- //#endregion
57
- //#region src/fingerprint.ts
5
+ //#region src/markdown.ts
58
6
  /**
59
- * Browser fingerprint a stable, privacy-light identifier used by the
60
- * smooth-operator server to correlate an anonymous visitor's sessions across
61
- * page loads (and, server-side, to match an anonymous fingerprint to a known
62
- * CRM contact). It rides every `create_conversation_session` as
63
- * `browserFingerprint` (see ADR-048).
7
+ * A tiny, safe-by-default Markdown HTML renderer for the chat widget.
64
8
  *
65
- * ## Why not ThumbmarkJS
9
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
66
10
  *
67
- * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
68
- * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
69
- * full build pulls in extensive device-detection tables and async
70
- * component collection, adding tens of kilobytes (and async surface) to a
71
- * bundle whose whole selling point is staying out of the host page's
72
- * LCP/TBT budget. The fingerprint here is a few hundred bytes.
11
+ * The widget renders **untrusted** text in two places: the assistant's reply
12
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
13
+ * (raw scraped page chunks). Today both are written via `textContent`, so
14
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
15
+ * them rendered without re-opening the XSS hole that `textContent` was
16
+ * guarding against.
73
17
  *
74
- * ## What this is (and the tradeoff)
18
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
19
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
20
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
21
+ * require bolting on a separate sanitizer. Instead, this renderer is
22
+ * **safe-by-construction**:
75
23
  *
76
- * The correlation that actually matters "is this the same browser as last
77
- * time?" is carried by a **persisted random UUID** (the `browserFingerprint`
78
- * field in the persisted store). That UUID is generated once and reused for
79
- * the life of the localStorage entry, so same-browser resume is exact and
80
- * deterministic. The signal hash below is a best-effort entropy *supplement*
81
- * mixed into that UUID's derivation seed on first generation it gives the
82
- * server-side resolver a soft signal for the (rare) cross-storage case where
83
- * the UUID was cleared but the device is unchanged. It is intentionally NOT a
84
- * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
85
- * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
86
- * cross-storage matching in exchange for a tiny, transparent, no-network,
87
- * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
88
- * of truth for any fuzzy matching; the client just supplies a stable token.
24
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
25
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
26
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
27
+ * tag out of the input a literal `<script>` in the input is treated as
28
+ * plain text.
29
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
30
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
31
+ * 3. **Images are dropped entirely** `![alt](src)` renders as its alt text,
32
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
33
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
34
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
35
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
36
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines a full
37
+ * `<h1>` is far too large inside a chat bubble or citation card.
38
+ *
39
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
40
+ * an element the caller controls; because of (1)–(4) it can only contain the
41
+ * allowlisted, attribute-sanitized tags.
42
+ *
43
+ * Supported subset (deliberately small):
44
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
45
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
46
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
47
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
48
+ * - `> ` blockquotes
49
+ * - `[text](http(s)://url)` links (images dropped to alt text)
50
+ * - `#`..`######` headings → bold line
89
51
  */
90
- /** Collect a small set of stable, non-invasive browser signals. */
91
- function collectSignals() {
92
- const parts = [];
93
- try {
94
- const nav = typeof navigator !== "undefined" ? navigator : void 0;
95
- if (nav) {
96
- parts.push(`ua:${nav.userAgent ?? ""}`);
97
- parts.push(`lang:${nav.language ?? ""}`);
98
- parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
99
- parts.push(`plat:${nav.platform ?? ""}`);
100
- parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
101
- parts.push(`dm:${nav.deviceMemory ?? ""}`);
52
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
53
+ function escapeHtml(value) {
54
+ return value.replace(/[&<>"']/g, (c) => {
55
+ switch (c) {
56
+ case "&": return "&amp;";
57
+ case "<": return "&lt;";
58
+ case ">": return "&gt;";
59
+ case "\"": return "&quot;";
60
+ default: return "&#39;";
102
61
  }
103
- if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
104
- if (typeof Intl !== "undefined") try {
105
- parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
106
- } catch {}
107
- parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
108
- } catch {}
109
- return parts.join("|");
110
- }
111
- /**
112
- * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
113
- * an unsigned hex string. Stable across runs for the same input. Not used for
114
- * any security decision — only to derive a deterministic seed from the signal
115
- * string above.
116
- */
117
- function fnv1a(input) {
118
- let h = 2166136261;
119
- for (let i = 0; i < input.length; i++) {
120
- h ^= input.charCodeAt(i);
121
- h = Math.imul(h, 16777619);
122
- }
123
- return (h >>> 0).toString(16).padStart(8, "0");
124
- }
125
- /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
126
- function generateUuid() {
127
- try {
128
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
129
- } catch {}
130
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
131
- const r = Math.random() * 16 | 0;
132
- return (c === "x" ? r : r & 3 | 8).toString(16);
133
62
  });
134
63
  }
135
64
  /**
136
- * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
137
- * FNV hash of the device signals so the server can recover a soft device
138
- * signal even if it only has the token. Called ONCE per browser (the result is
139
- * persisted and reused) see {@link getOrCreateFingerprint}.
140
- */
141
- function computeFingerprint() {
142
- const signalHash = fnv1a(collectSignals());
143
- return `${generateUuid()}.${signalHash}`;
144
- }
145
- /**
146
- * Return the cached fingerprint if one was already computed for this browser,
147
- * otherwise compute + persist a new one via the provided accessors. The
148
- * persistence layer owns storage; this function owns the "compute once" policy.
65
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
66
+ *
67
+ * SECURITY: link targets here originate from untrusted content (LLM output /
68
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
69
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
70
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
71
+ * falls back to plain text upstream.
149
72
  */
150
- function getOrCreateFingerprint(get, set) {
151
- const existing = get();
152
- if (existing) return existing;
153
- const fp = computeFingerprint();
154
- set(fp);
155
- return fp;
73
+ function safeHttpUrl(url) {
74
+ if (!url) return null;
75
+ try {
76
+ const parsed = new URL(url);
77
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
78
+ } catch {
79
+ return null;
80
+ }
156
81
  }
157
- //#endregion
158
- //#region src/persistence.ts
159
82
  /**
160
- * Persisted widget state the identity / consent / session-pointer client layer
161
- * (ADR-048, SMOODEV-2129e).
162
- *
163
- * Built on Zustand's framework-agnostic `vanilla` store + the `persist`
164
- * middleware (the user explicitly chose Zustand; it's ~1KB and has no React
165
- * dependency, so it works inside the web component). The store is keyed per
166
- * agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
167
- * on the same origin don't clobber each other.
168
- *
169
- * ## What persists (and, deliberately, what does NOT)
170
- *
171
- * Only a **pointer** to the conversation plus the visitor's identity + marketing
172
- * consent + verified email + browser fingerprint. The transcript is **never**
173
- * persisted: the smooth-operator server is the source of truth and the widget
174
- * re-hydrates history via `getMessages` on resume. This keeps localStorage small
175
- * and avoids stale/divergent transcripts.
83
+ * Render the inline span grammar of a single line/segment to safe HTML.
176
84
  *
177
- * `version` drives `persist.migrate` so future shape changes can upgrade old
178
- * blobs in place instead of silently dropping them.
85
+ * Order matters: code spans are extracted first (their contents are *not*
86
+ * further parsed), then images are stripped to alt text, then links, then
87
+ * emphasis. Every literal text run is escaped on the way out.
179
88
  */
180
- /** Current persisted-shape version. Bump when the shape below changes incompatibly. */
181
- const PERSIST_VERSION = 1;
182
- const EMPTY_CONSENT = {
183
- emailOptIn: false,
184
- smsOptIn: false
185
- };
186
- function initialPersisted() {
187
- return {
188
- version: 1,
189
- sessionId: null,
190
- identity: {},
191
- consent: { ...EMPTY_CONSENT },
192
- verifiedEmail: null,
193
- verifiedEmailSessionId: null,
194
- browserFingerprint: null
89
+ function renderInline(input) {
90
+ let out = "";
91
+ let i = 0;
92
+ const n = input.length;
93
+ let buf = "";
94
+ const flush = () => {
95
+ if (buf) {
96
+ out += escapeHtml(buf);
97
+ buf = "";
98
+ }
195
99
  };
196
- }
197
- /** localStorage key for an agent's persisted widget state. */
198
- function storageKey(agentId) {
199
- return `smoo-chat-widget:${agentId}`;
200
- }
201
- /**
202
- * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
203
- * real localStorage is unavailable.
204
- *
205
- * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
206
- * zustand v5's `persist` treats a missing `storage` option by falling back to its
207
- * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
208
- * localStorage the guard was trying to avoid (throwing again in privacy mode).
209
- * Handing it this no-op storage keeps the store working purely in memory and
210
- * guarantees the fallback can't touch real localStorage.
211
- */
212
- function memoryStorage() {
213
- const mem = /* @__PURE__ */ new Map();
214
- return {
215
- getItem: (name) => {
216
- const raw = mem.get(name);
217
- if (!raw) return null;
218
- try {
219
- return JSON.parse(raw);
220
- } catch {
221
- return null;
100
+ while (i < n) {
101
+ const ch = input[i];
102
+ if (ch === "`") {
103
+ const end = input.indexOf("`", i + 1);
104
+ if (end > i) {
105
+ flush();
106
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
107
+ i = end + 1;
108
+ continue;
222
109
  }
223
- },
224
- setItem: (name, value) => {
225
- mem.set(name, JSON.stringify(value));
226
- },
227
- removeItem: (name) => {
228
- mem.delete(name);
229
110
  }
230
- };
231
- }
232
- /**
233
- * A `persist` storage adapter that tolerates the *absence* of localStorage
234
- * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
235
- * unavailable the store still works in-memory; nothing is persisted, but the
236
- * widget never throws on boot.
237
- */
238
- function safeStorage() {
239
- let ls = null;
240
- try {
241
- ls = typeof localStorage !== "undefined" ? localStorage : null;
242
- if (ls) {
243
- const probe = "__smoo_probe__";
244
- ls.setItem(probe, "1");
245
- ls.removeItem(probe);
111
+ if (ch === "!" && input[i + 1] === "[") {
112
+ const m = imageAt(input, i);
113
+ if (m) {
114
+ flush();
115
+ out += renderInline(m.alt);
116
+ i = m.end;
117
+ continue;
118
+ }
246
119
  }
247
- } catch {
248
- ls = null;
249
- }
250
- if (!ls) return memoryStorage();
251
- const storage = ls;
252
- return {
253
- getItem: (name) => {
254
- try {
255
- const raw = storage.getItem(name);
256
- return raw ? JSON.parse(raw) : null;
257
- } catch {
258
- return null;
120
+ if (ch === "[") {
121
+ const m = linkAt(input, i);
122
+ if (m) {
123
+ flush();
124
+ const safe = safeHttpUrl(m.href);
125
+ const inner = renderInline(m.text);
126
+ if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
127
+ else out += inner;
128
+ i = m.end;
129
+ continue;
259
130
  }
260
- },
261
- setItem: (name, value) => {
262
- try {
263
- storage.setItem(name, JSON.stringify(value));
264
- } catch {}
265
- },
266
- removeItem: (name) => {
267
- try {
268
- storage.removeItem(name);
269
- } catch {}
270
131
  }
271
- };
132
+ if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
133
+ const marker = ch + ch;
134
+ const end = input.indexOf(marker, i + 2);
135
+ if (end > i + 1) {
136
+ flush();
137
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
138
+ i = end + 2;
139
+ continue;
140
+ }
141
+ }
142
+ if (ch === "*" || ch === "_") {
143
+ const end = input.indexOf(ch, i + 1);
144
+ if (end > i + 1 && input[i + 1] !== ch) {
145
+ flush();
146
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
147
+ i = end + 1;
148
+ continue;
149
+ }
150
+ }
151
+ buf += ch;
152
+ i++;
153
+ }
154
+ flush();
155
+ return out;
272
156
  }
273
- /**
274
- * Migrate a persisted blob from an older `version` to the current shape. Today
275
- * v1 is the only version, so this just backfills any missing fields onto an
276
- * unknown old blob; future versions add `case` branches here.
277
- */
278
- function migrate(persisted) {
279
- const base = initialPersisted();
280
- if (!persisted || typeof persisted !== "object") return base;
281
- const p = persisted;
157
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
158
+ function linkAt(input, start) {
159
+ const close = matchBracket(input, start);
160
+ if (close < 0 || input[close + 1] !== "(") return null;
161
+ const paren = input.indexOf(")", close + 2);
162
+ if (paren < 0) return null;
282
163
  return {
283
- version: 1,
284
- sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
285
- identity: {
286
- name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
287
- email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
288
- phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
289
- },
290
- consent: {
291
- emailOptIn: p.consent?.emailOptIn === true,
292
- smsOptIn: p.consent?.smsOptIn === true,
293
- consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
294
- consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
295
- },
296
- verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
297
- verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
298
- browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
164
+ text: input.slice(start + 1, close),
165
+ href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
166
+ end: paren + 1
299
167
  };
300
168
  }
301
- /**
302
- * Create the per-agent persisted Zustand store. The `partialize` step ensures
303
- * only the persisted shape (never any future transient action/UI state) is
304
- * written to localStorage.
305
- */
306
- function createWidgetStore(agentId) {
307
- return createStore()(persist((set) => ({
308
- ...initialPersisted(),
309
- setSessionId: (sessionId) => set({ sessionId }),
310
- mergeIdentity: (identity) => set((state) => ({ identity: {
311
- ...state.identity,
312
- ...identity.name !== void 0 ? { name: identity.name } : {},
313
- ...identity.email !== void 0 ? { email: identity.email } : {},
314
- ...identity.phone !== void 0 ? { phone: identity.phone } : {}
315
- } })),
316
- setConsent: (consent) => set({ consent: { ...consent } }),
317
- setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
318
- verifiedEmail,
319
- verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
320
- })),
321
- setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
322
- clearSession: () => set({
323
- sessionId: null,
324
- verifiedEmail: null,
325
- verifiedEmailSessionId: null
326
- })
327
- }), {
328
- name: storageKey(agentId),
329
- version: 1,
330
- storage: safeStorage(),
331
- migrate,
332
- partialize: (state) => ({
333
- version: 1,
334
- sessionId: state.sessionId,
335
- identity: state.identity,
336
- consent: state.consent,
337
- verifiedEmail: state.verifiedEmail,
338
- verifiedEmailSessionId: state.verifiedEmailSessionId,
339
- browserFingerprint: state.browserFingerprint
340
- })
341
- }));
342
- }
343
- //#endregion
344
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
345
- function _typeof(o) {
346
- "@babel/helpers - typeof";
347
- return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
348
- return typeof o;
349
- } : function(o) {
350
- return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
351
- }, _typeof(o);
169
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
170
+ function imageAt(input, start) {
171
+ const link = linkAt(input, start + 1);
172
+ if (!link) return null;
173
+ return {
174
+ alt: link.text,
175
+ end: link.end
176
+ };
352
177
  }
353
- //#endregion
354
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
355
- function toPrimitive(t, r) {
356
- if ("object" != _typeof(t) || !t) return t;
357
- var e = t[Symbol.toPrimitive];
358
- if (void 0 !== e) {
359
- var i = e.call(t, r || "default");
360
- if ("object" != _typeof(i)) return i;
361
- throw new TypeError("@@toPrimitive must return a primitive value.");
178
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
179
+ function matchBracket(input, open) {
180
+ let depth = 0;
181
+ for (let i = open; i < input.length; i++) {
182
+ const c = input[i];
183
+ if (c === "[") depth++;
184
+ else if (c === "]") {
185
+ depth--;
186
+ if (depth === 0) return i;
187
+ }
362
188
  }
363
- return ("string" === r ? String : Number)(t);
364
- }
365
- //#endregion
366
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
367
- function toPropertyKey(t) {
368
- var i = toPrimitive(t, "string");
369
- return "symbol" == _typeof(i) ? i : i + "";
370
- }
371
- //#endregion
372
- //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
373
- function _defineProperty(e, r, t) {
374
- return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
375
- value: t,
376
- enumerable: !0,
377
- configurable: !0,
378
- writable: !0
379
- }) : e[r] = t, e;
189
+ return -1;
380
190
  }
381
- //#endregion
382
- //#region src/conversation.ts
383
- /**
384
- * ConversationController the bridge between the widget UI and the
385
- * `@smooai/smooth-operator` protocol client.
191
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
192
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
193
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
194
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
195
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
196
+ /**
197
+ * Render a full Markdown string to safe HTML.
386
198
  *
387
- * This is the piece that was rewired: the original smooai widget spoke to
388
- * `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
389
- * The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
390
- * so the swap is purely at the client-library boundary.
199
+ * @returns a string containing only the allowlisted tags described in the
200
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
201
+ */
202
+ function renderMarkdown(src) {
203
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
204
+ const out = [];
205
+ let i = 0;
206
+ while (i < lines.length) {
207
+ const line = lines[i];
208
+ const fence = FENCE_RE.exec(line);
209
+ if (fence) {
210
+ const marker = fence[1];
211
+ const body = [];
212
+ i++;
213
+ while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
214
+ body.push(lines[i]);
215
+ i++;
216
+ }
217
+ if (i < lines.length) i++;
218
+ out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
219
+ continue;
220
+ }
221
+ if (line.trim() === "") {
222
+ i++;
223
+ continue;
224
+ }
225
+ const heading = HEADING_RE.exec(line);
226
+ if (heading) {
227
+ out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
228
+ i++;
229
+ continue;
230
+ }
231
+ if (UL_RE.test(line) || OL_RE.test(line)) {
232
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
233
+ const re = ordered ? OL_RE : UL_RE;
234
+ const items = [];
235
+ while (i < lines.length) {
236
+ const m = re.exec(lines[i]);
237
+ if (!m) break;
238
+ items.push(`<li>${renderInline(m[1])}</li>`);
239
+ i++;
240
+ }
241
+ out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
242
+ continue;
243
+ }
244
+ if (QUOTE_RE.test(line)) {
245
+ const quoted = [];
246
+ while (i < lines.length) {
247
+ const m = QUOTE_RE.exec(lines[i]);
248
+ if (!m) break;
249
+ quoted.push(m[1]);
250
+ i++;
251
+ }
252
+ out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
253
+ continue;
254
+ }
255
+ const para = [];
256
+ while (i < lines.length) {
257
+ const l = lines[i];
258
+ if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
259
+ para.push(l);
260
+ i++;
261
+ }
262
+ out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
263
+ }
264
+ return out.join("");
265
+ }
266
+ const SNIPPET_MAX = 260;
267
+ /**
268
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
391
269
  *
392
- * Flow:
393
- * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
394
- * (or RESUMES a persisted session via `get_session`/`get_messages`).
395
- * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
396
- * in-progress assistant message, then the terminal
397
- * `eventual_response`.
270
+ * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
271
+ * in a link, standalone nav, repeated whitespace — e.g.
272
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
273
+ * from the citation card, so the snippet only needs to be a clean teaser.
398
274
  *
399
- * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
400
- * - `browserFingerprint` computed once + sent on every `create_conversation_session`.
401
- * - identity + marketing consent threaded into the session `metadata`.
402
- * - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
403
- * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
404
- * cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
405
- * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
406
- * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
407
- * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
408
- * NOT WS frames.
275
+ * Steps:
276
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
277
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
278
+ * 3. Collapse all runs of whitespace to single spaces.
279
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
409
280
  *
410
- * The controller is UI-agnostic: it emits typed events and the view renders them.
411
- */
412
- /**
413
- * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
414
- * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
415
- * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
416
- * the cross-device identity flow + fingerprint resume are HTTP POST routes on the
417
- * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
281
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
282
+ * remaining inline markup (bold/links) stays safe.
418
283
  */
419
- function httpBaseFromWsEndpoint(endpoint) {
420
- try {
421
- const u = new URL(endpoint);
422
- u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
423
- return `${u.protocol}//${u.host}`;
424
- } catch {
425
- return null;
284
+ function cleanCitationSnippet(raw) {
285
+ let s = raw ?? "";
286
+ let changed = true;
287
+ while (changed) {
288
+ changed = false;
289
+ const before = s;
290
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
291
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
292
+ s = s.replace(/^\s*#{1,6}\s+/, "");
293
+ if (s !== before) changed = true;
426
294
  }
295
+ s = s.replace(/\s+/g, " ").trim();
296
+ if (s.length > SNIPPET_MAX) {
297
+ const cut = s.slice(0, SNIPPET_MAX);
298
+ const lastSpace = cut.lastIndexOf(" ");
299
+ s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
300
+ }
301
+ return s;
427
302
  }
428
- /** Pull the final assistant text out of an `eventual_response` data payload. */
429
- function extractFinalText(response) {
430
- if (!response || typeof response !== "object") return null;
431
- const r = response;
432
- if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
433
- return null;
434
- }
303
+ //#endregion
304
+ //#region src/config.ts
435
305
  /**
436
- * Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
306
+ * Public configuration surface for the chat widget.
437
307
  *
438
- * The protocol client types these (`eventual_response.data.data.citations`),
439
- * but they're optional and back-compatible absent when the turn used no
440
- * knowledge sources. We read them defensively (tolerating their total absence,
441
- * non-array shapes, and missing fields) so a server that doesn't emit them, or
442
- * an older client, can't break rendering. Each citation always carries
443
- * `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
308
+ * A host page configures the widget either declaratively (HTML attributes on the
309
+ * `<smooth-agent-chat>` element) or programmatically (passing this object to
310
+ * {@link mountChatWidget} / `element.configure(...)`).
444
311
  */
445
- function extractCitations(inner) {
446
- if (!inner || typeof inner !== "object") return [];
447
- const raw = inner.citations;
448
- if (!Array.isArray(raw)) return [];
449
- const out = [];
450
- for (const c of raw) {
451
- if (!c || typeof c !== "object") continue;
452
- const obj = c;
453
- const id = typeof obj.id === "string" ? obj.id : "";
454
- const title = typeof obj.title === "string" ? obj.title : id || "Source";
455
- const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
456
- const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
457
- const score = typeof obj.score === "number" ? obj.score : 0;
458
- out.push({
459
- id,
460
- title,
461
- snippet,
462
- score,
463
- url
464
- });
465
- }
466
- return out;
467
- }
468
- /** Convert a server message row into a finalized {@link ChatMessage}. */
469
- function wireMessageToChat(m, idx) {
470
- const text = typeof m.content?.text === "string" ? m.content.text : "";
471
- if (!text) return null;
472
- const role = m.direction === "outbound" ? "assistant" : "user";
312
+ /** Resolve a partial config against the built-in defaults. */
313
+ function resolveConfig(config) {
314
+ const theme = config.theme ?? {};
315
+ const primary = theme.primary ?? "#00a6a6";
316
+ const primaryText = theme.primaryText ?? "#f8fafc";
317
+ const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
318
+ const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
319
+ const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
320
+ const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
473
321
  return {
474
- id: typeof m.id === "string" ? m.id : `hist-${idx}`,
475
- role,
476
- text,
477
- streaming: false
322
+ endpoint: config.endpoint,
323
+ mode: config.mode ?? "popover",
324
+ agentId: config.agentId,
325
+ agentName: config.agentName ?? "Assistant",
326
+ logoUrl: safeHttpUrl(config.logoUrl) ?? void 0,
327
+ userName: config.userName,
328
+ userEmail: config.userEmail,
329
+ userPhone: config.userPhone,
330
+ authContext: config.authContext,
331
+ placeholder: config.placeholder ?? "Type a message…",
332
+ greeting: config.greeting ?? "Hi! How can I help you today?",
333
+ connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
334
+ startOpen: config.startOpen ?? false,
335
+ examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
336
+ requireName: config.requireName ?? false,
337
+ requireEmail: config.requireEmail ?? false,
338
+ requirePhone: config.requirePhone ?? false,
339
+ collectPhone: config.collectPhone ?? true,
340
+ collectConsent: config.collectConsent ?? true,
341
+ allowChatRestore: config.allowChatRestore ?? true,
342
+ allowAnonymous: config.allowAnonymous ?? false,
343
+ theme: {
344
+ text: theme.text ?? "#f8fafc",
345
+ background: theme.background ?? "#040d30",
346
+ primary,
347
+ primaryText,
348
+ secondary: theme.secondary ?? "#ff6b6c",
349
+ assistantBubble,
350
+ assistantBubbleText,
351
+ userBubble,
352
+ userBubbleText,
353
+ border: theme.border ?? "rgba(255, 255, 255, 0.1)"
354
+ }
478
355
  };
479
356
  }
480
- var ConversationController = class {
481
- constructor(config, events, store) {
482
- _defineProperty(this, "config", void 0);
483
- _defineProperty(this, "events", void 0);
484
- _defineProperty(this, "store", void 0);
485
- _defineProperty(this, "client", null);
486
- _defineProperty(this, "sessionId", null);
487
- _defineProperty(this, "messages", []);
488
- _defineProperty(this, "status", "idle");
489
- _defineProperty(this, "seq", 0);
490
- _defineProperty(this, "activeRequestId", null);
491
- _defineProperty(this, "interrupt", null);
492
- _defineProperty(this, "identityRestore", { phase: "idle" });
493
- _defineProperty(this, "resumeAttempted", false);
494
- _defineProperty(this, "httpBase", void 0);
495
- this.config = config;
496
- this.events = events;
497
- this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
498
- if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
499
- this.store = store ?? createWidgetStore(config.agentId);
500
- const seed = {};
501
- if (config.userName) seed.name = config.userName;
502
- if (config.userEmail) seed.email = config.userEmail;
503
- if (config.userPhone) seed.phone = config.userPhone;
504
- if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
505
- }
506
- get connectionStatus() {
507
- return this.status;
508
- }
509
- /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
510
- getStore() {
511
- return this.store;
512
- }
513
- /** True when a persisted session pointer exists (drives the resume path). */
514
- hasPersistedSession() {
515
- return !!this.store.getState().sessionId;
516
- }
517
- /** True when persisted identity exists (lets the view skip the pre-chat form). */
518
- hasPersistedIdentity() {
519
- const id = this.store.getState().identity;
520
- return !!(id.name || id.email || id.phone);
521
- }
522
- /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
523
- setUserInfo(info) {
524
- const { name, email, phone, consent } = info;
525
- this.store.getState().mergeIdentity({
526
- name,
527
- email,
528
- phone
529
- });
530
- if (consent) {
531
- const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
532
- this.store.getState().setConsent({
533
- emailOptIn: consent.emailOptIn,
534
- smsOptIn: consent.smsOptIn,
535
- consentSource: "chat-widget-prechat",
536
- consentAt
537
- });
538
- }
539
- }
540
- setInterrupt(interrupt) {
541
- this.interrupt = interrupt;
542
- this.events.onInterrupt?.(interrupt);
543
- }
544
- setIdentityRestore(state) {
545
- this.identityRestore = state;
546
- this.events.onIdentityRestore?.(state);
547
- }
548
- get currentIdentityRestore() {
549
- return this.identityRestore;
550
- }
551
- /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
552
- verifyOtp(code) {
553
- if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
554
- this.client.verifyOtp({
555
- sessionId: this.sessionId,
556
- requestId: this.activeRequestId,
557
- code
558
- });
559
- }
560
- /** Approve or reject a pending tool write to resume the paused turn. */
561
- confirmTool(approved) {
562
- if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
563
- this.client.confirmToolAction({
564
- sessionId: this.sessionId,
565
- requestId: this.activeRequestId,
566
- approved
567
- });
568
- this.setInterrupt(null);
569
- }
570
- nextId(prefix) {
571
- this.seq += 1;
572
- return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
573
- }
574
- setStatus(status, detail) {
575
- this.status = status;
576
- this.events.onStatus(status, detail);
577
- }
578
- emitMessages() {
579
- this.events.onMessages(this.messages.map((m) => ({ ...m })));
580
- }
581
- /** Compute (once) + return the persisted browser fingerprint. */
582
- fingerprint() {
583
- const state = this.store.getState();
584
- return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
585
- }
586
- /**
587
- * Build the `metadata` payload threaded into `create_conversation_session`:
588
- * phone (no first-class engine field) and consent.
589
- *
590
- * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
591
- * OTP proof bound to the session it was verified against
592
- * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
593
- * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
594
- * every brand-new `create_conversation_session` would mislabel a fresh
595
- * visitor's session with a prior (possibly different) visitor's email on a
596
- * shared browser. The verified email is only used when RESUMING the exact
597
- * session it was proven for — see {@link verifiedEmailForSession}.
598
- */
599
- sessionMetadata() {
600
- const state = this.store.getState();
601
- const meta = {};
602
- if (state.identity.phone) meta.userPhone = state.identity.phone;
603
- const consent = state.consent;
604
- if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
605
- const c = {
606
- emailOptIn: consent.emailOptIn,
607
- smsOptIn: consent.smsOptIn,
608
- consentSource: consent.consentSource ?? "chat-widget-prechat"
609
- };
610
- if (consent.consentAt) c.consentAt = consent.consentAt;
611
- meta.consent = c;
357
+ /**
358
+ * Whether the pre-chat identity form should gate the conversation: at least one
359
+ * field is required and anonymous chat is not allowed.
360
+ */
361
+ function needsUserInfo(resolved) {
362
+ return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
363
+ }
364
+ //#endregion
365
+ //#region src/fingerprint.ts
366
+ /**
367
+ * Browser fingerprint — a stable, privacy-light identifier used by the
368
+ * smooth-operator server to correlate an anonymous visitor's sessions across
369
+ * page loads (and, server-side, to match an anonymous fingerprint to a known
370
+ * CRM contact). It rides every `create_conversation_session` as
371
+ * `browserFingerprint` (see ADR-048).
372
+ *
373
+ * ## Why not ThumbmarkJS
374
+ *
375
+ * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
376
+ * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
377
+ * full build pulls in extensive device-detection tables and async
378
+ * component collection, adding tens of kilobytes (and async surface) to a
379
+ * bundle whose whole selling point is staying out of the host page's
380
+ * LCP/TBT budget. The fingerprint here is a few hundred bytes.
381
+ *
382
+ * ## What this is (and the tradeoff)
383
+ *
384
+ * The correlation that actually matters — "is this the same browser as last
385
+ * time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
386
+ * field in the persisted store). That UUID is generated once and reused for
387
+ * the life of the localStorage entry, so same-browser resume is exact and
388
+ * deterministic. The signal hash below is a best-effort entropy *supplement*
389
+ * mixed into that UUID's derivation seed on first generation — it gives the
390
+ * server-side resolver a soft signal for the (rare) cross-storage case where
391
+ * the UUID was cleared but the device is unchanged. It is intentionally NOT a
392
+ * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
393
+ * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
394
+ * cross-storage matching in exchange for a tiny, transparent, no-network,
395
+ * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
396
+ * of truth for any fuzzy matching; the client just supplies a stable token.
397
+ */
398
+ /** Collect a small set of stable, non-invasive browser signals. */
399
+ function collectSignals() {
400
+ const parts = [];
401
+ try {
402
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
403
+ if (nav) {
404
+ parts.push(`ua:${nav.userAgent ?? ""}`);
405
+ parts.push(`lang:${nav.language ?? ""}`);
406
+ parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
407
+ parts.push(`plat:${nav.platform ?? ""}`);
408
+ parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
409
+ parts.push(`dm:${nav.deviceMemory ?? ""}`);
612
410
  }
613
- return Object.keys(meta).length > 0 ? meta : void 0;
614
- }
615
- /**
616
- * The verified-email hint, but ONLY when the OTP proof is bound to the session
617
- * being resumed (`verifiedEmailSessionId === sessionId`). Returns null
618
- * otherwise so a stale/cross-visitor proof is never threaded onto a session it
619
- * wasn't proven for.
620
- */
621
- verifiedEmailForSession(sessionId) {
622
- const state = this.store.getState();
623
- if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
624
- return null;
625
- }
626
- /** Lazily open the WS client (default transport). Idempotent within a connect. */
627
- async ensureClient() {
628
- if (this.client) return;
629
- this.client = new SmoothAgentClient({ url: this.config.endpoint });
630
- await this.client.connect();
411
+ if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
412
+ if (typeof Intl !== "undefined") try {
413
+ parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
414
+ } catch {}
415
+ parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
416
+ } catch {}
417
+ return parts.join("|");
418
+ }
419
+ /**
420
+ * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
421
+ * an unsigned hex string. Stable across runs for the same input. Not used for
422
+ * any security decision — only to derive a deterministic seed from the signal
423
+ * string above.
424
+ */
425
+ function fnv1a(input) {
426
+ let h = 2166136261;
427
+ for (let i = 0; i < input.length; i++) {
428
+ h ^= input.charCodeAt(i);
429
+ h = Math.imul(h, 16777619);
631
430
  }
632
- /**
633
- * Open the connection and either RESUME or create a session.
634
- *
635
- * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
636
- * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
637
- * ONLY the pointer (identity/consent survive).
638
- * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
639
- * `resumable`, adopt the returned session (the wrapper has primed the
640
- * operator registry), reuse the sessionId, and hydrate via get_session/
641
- * get_messages rather than relying on createConversationSession to resume.
642
- * 3. Otherwise create a fresh session.
643
- */
644
- async connect() {
645
- if (this.status === "connecting" || this.status === "ready") return;
646
- this.setStatus("connecting");
647
- try {
648
- await this.ensureClient();
649
- if (!this.resumeAttempted) {
650
- this.resumeAttempted = true;
651
- const persistedSessionId = this.store.getState().sessionId;
652
- if (persistedSessionId) {
653
- if (await this.tryResume(persistedSessionId)) {
654
- this.setStatus("ready");
655
- return;
656
- }
657
- this.store.getState().clearSession();
658
- } else {
659
- const fpSessionId = await this.resumeByFingerprint();
660
- if (fpSessionId) {
661
- if (await this.tryResume(fpSessionId)) {
662
- this.store.getState().setSessionId(fpSessionId);
663
- this.setStatus("ready");
664
- return;
665
- }
666
- }
667
- }
431
+ return (h >>> 0).toString(16).padStart(8, "0");
432
+ }
433
+ /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
434
+ function generateUuid() {
435
+ try {
436
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
437
+ } catch {}
438
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
439
+ const r = Math.random() * 16 | 0;
440
+ return (c === "x" ? r : r & 3 | 8).toString(16);
441
+ });
442
+ }
443
+ /**
444
+ * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
445
+ * FNV hash of the device signals so the server can recover a soft device
446
+ * signal even if it only has the token. Called ONCE per browser (the result is
447
+ * persisted and reused) — see {@link getOrCreateFingerprint}.
448
+ */
449
+ function computeFingerprint() {
450
+ const signalHash = fnv1a(collectSignals());
451
+ return `${generateUuid()}.${signalHash}`;
452
+ }
453
+ /**
454
+ * Return the cached fingerprint if one was already computed for this browser,
455
+ * otherwise compute + persist a new one via the provided accessors. The
456
+ * persistence layer owns storage; this function owns the "compute once" policy.
457
+ */
458
+ function getOrCreateFingerprint(get, set) {
459
+ const existing = get();
460
+ if (existing) return existing;
461
+ const fp = computeFingerprint();
462
+ set(fp);
463
+ return fp;
464
+ }
465
+ //#endregion
466
+ //#region src/persistence.ts
467
+ /**
468
+ * Persisted widget state — the identity / consent / session-pointer client layer
469
+ * (ADR-048, SMOODEV-2129e).
470
+ *
471
+ * Built on Zustand's framework-agnostic `vanilla` store + the `persist`
472
+ * middleware (the user explicitly chose Zustand; it's ~1KB and has no React
473
+ * dependency, so it works inside the web component). The store is keyed per
474
+ * agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
475
+ * on the same origin don't clobber each other.
476
+ *
477
+ * ## What persists (and, deliberately, what does NOT)
478
+ *
479
+ * Only a **pointer** to the conversation plus the visitor's identity + marketing
480
+ * consent + verified email + browser fingerprint. The transcript is **never**
481
+ * persisted: the smooth-operator server is the source of truth and the widget
482
+ * re-hydrates history via `getMessages` on resume. This keeps localStorage small
483
+ * and avoids stale/divergent transcripts.
484
+ *
485
+ * `version` drives `persist.migrate` so future shape changes can upgrade old
486
+ * blobs in place instead of silently dropping them.
487
+ */
488
+ /** Current persisted-shape version. Bump when the shape below changes incompatibly. */
489
+ const PERSIST_VERSION = 1;
490
+ const EMPTY_CONSENT = {
491
+ emailOptIn: false,
492
+ smsOptIn: false
493
+ };
494
+ function initialPersisted() {
495
+ return {
496
+ version: 1,
497
+ sessionId: null,
498
+ identity: {},
499
+ consent: { ...EMPTY_CONSENT },
500
+ verifiedEmail: null,
501
+ verifiedEmailSessionId: null,
502
+ browserFingerprint: null
503
+ };
504
+ }
505
+ /** localStorage key for an agent's persisted widget state. */
506
+ function storageKey(agentId) {
507
+ return `smoo-chat-widget:${agentId}`;
508
+ }
509
+ /**
510
+ * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
511
+ * real localStorage is unavailable.
512
+ *
513
+ * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
514
+ * zustand v5's `persist` treats a missing `storage` option by falling back to its
515
+ * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
516
+ * localStorage the guard was trying to avoid (throwing again in privacy mode).
517
+ * Handing it this no-op storage keeps the store working purely in memory and
518
+ * guarantees the fallback can't touch real localStorage.
519
+ */
520
+ function memoryStorage() {
521
+ const mem = /* @__PURE__ */ new Map();
522
+ return {
523
+ getItem: (name) => {
524
+ const raw = mem.get(name);
525
+ if (!raw) return null;
526
+ try {
527
+ return JSON.parse(raw);
528
+ } catch {
529
+ return null;
668
530
  }
669
- await this.createSession();
670
- this.setStatus("ready");
671
- } catch (err) {
672
- this.setStatus("error", err instanceof Error ? err.message : String(err));
673
- throw err;
531
+ },
532
+ setItem: (name, value) => {
533
+ mem.set(name, JSON.stringify(value));
534
+ },
535
+ removeItem: (name) => {
536
+ mem.delete(name);
674
537
  }
675
- }
676
- /**
677
- * Build the auth fields every `/internal/*` route shares: `agentId` (required
678
- * for the agent-policy lookup), `agentName` (used as the OTP email sender), and
679
- * the optional pre-auth `authContext` the host page may have configured. The
680
- * `Origin` header is sent automatically by the browser and checked server-side.
681
- */
682
- authBody() {
683
- const body = { agentId: this.config.agentId };
684
- if (this.config.agentName) body.agentName = this.config.agentName;
685
- if (this.config.authContext) body.authContext = this.config.authContext;
686
- return body;
687
- }
688
- /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
689
- async postInternal(path, payload) {
690
- if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
691
- const res = await fetch(`${this.httpBase}${path}`, {
692
- method: "POST",
693
- headers: { "content-type": "application/json" },
694
- credentials: "omit",
695
- body: JSON.stringify({
696
- ...this.authBody(),
697
- ...payload
698
- })
699
- });
700
- let json = {};
701
- try {
702
- json = await res.json();
703
- } catch {
704
- json = {};
538
+ };
539
+ }
540
+ /**
541
+ * A `persist` storage adapter that tolerates the *absence* of localStorage
542
+ * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
543
+ * unavailable the store still works in-memory; nothing is persisted, but the
544
+ * widget never throws on boot.
545
+ */
546
+ function safeStorage() {
547
+ let ls = null;
548
+ try {
549
+ ls = typeof localStorage !== "undefined" ? localStorage : null;
550
+ if (ls) {
551
+ const probe = "__smoo_probe__";
552
+ ls.setItem(probe, "1");
553
+ ls.removeItem(probe);
705
554
  }
706
- if (!res.ok) {
707
- const err = json.error;
708
- throw new Error(err?.message ?? `${path} failed (${res.status})`);
555
+ } catch {
556
+ ls = null;
557
+ }
558
+ if (!ls) return memoryStorage();
559
+ const storage = ls;
560
+ return {
561
+ getItem: (name) => {
562
+ try {
563
+ const raw = storage.getItem(name);
564
+ return raw ? JSON.parse(raw) : null;
565
+ } catch {
566
+ return null;
567
+ }
568
+ },
569
+ setItem: (name, value) => {
570
+ try {
571
+ storage.setItem(name, JSON.stringify(value));
572
+ } catch {}
573
+ },
574
+ removeItem: (name) => {
575
+ try {
576
+ storage.removeItem(name);
577
+ } catch {}
709
578
  }
710
- return json;
579
+ };
580
+ }
581
+ /**
582
+ * Migrate a persisted blob from an older `version` to the current shape. Today
583
+ * v1 is the only version, so this just backfills any missing fields onto an
584
+ * unknown old blob; future versions add `case` branches here.
585
+ */
586
+ function migrate(persisted) {
587
+ const base = initialPersisted();
588
+ if (!persisted || typeof persisted !== "object") return base;
589
+ const p = persisted;
590
+ return {
591
+ version: 1,
592
+ sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
593
+ identity: {
594
+ name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
595
+ email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
596
+ phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
597
+ },
598
+ consent: {
599
+ emailOptIn: p.consent?.emailOptIn === true,
600
+ smsOptIn: p.consent?.smsOptIn === true,
601
+ consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
602
+ consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
603
+ },
604
+ verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
605
+ verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
606
+ browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
607
+ };
608
+ }
609
+ /**
610
+ * Create the per-agent persisted Zustand store. The `partialize` step ensures
611
+ * only the persisted shape (never any future transient action/UI state) is
612
+ * written to localStorage.
613
+ */
614
+ function createWidgetStore(agentId) {
615
+ return createStore()(persist((set) => ({
616
+ ...initialPersisted(),
617
+ setSessionId: (sessionId) => set({ sessionId }),
618
+ mergeIdentity: (identity) => set((state) => ({ identity: {
619
+ ...state.identity,
620
+ ...identity.name !== void 0 ? { name: identity.name } : {},
621
+ ...identity.email !== void 0 ? { email: identity.email } : {},
622
+ ...identity.phone !== void 0 ? { phone: identity.phone } : {}
623
+ } })),
624
+ setConsent: (consent) => set({ consent: { ...consent } }),
625
+ setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
626
+ verifiedEmail,
627
+ verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
628
+ })),
629
+ setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
630
+ clearSession: () => set({
631
+ sessionId: null,
632
+ verifiedEmail: null,
633
+ verifiedEmailSessionId: null
634
+ })
635
+ }), {
636
+ name: storageKey(agentId),
637
+ version: 1,
638
+ storage: safeStorage(),
639
+ migrate,
640
+ partialize: (state) => ({
641
+ version: 1,
642
+ sessionId: state.sessionId,
643
+ identity: state.identity,
644
+ consent: state.consent,
645
+ verifiedEmail: state.verifiedEmail,
646
+ verifiedEmailSessionId: state.verifiedEmailSessionId,
647
+ browserFingerprint: state.browserFingerprint
648
+ })
649
+ }));
650
+ }
651
+ //#endregion
652
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
653
+ function _typeof(o) {
654
+ "@babel/helpers - typeof";
655
+ return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
656
+ return typeof o;
657
+ } : function(o) {
658
+ return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
659
+ }, _typeof(o);
660
+ }
661
+ //#endregion
662
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
663
+ function toPrimitive(t, r) {
664
+ if ("object" != _typeof(t) || !t) return t;
665
+ var e = t[Symbol.toPrimitive];
666
+ if (void 0 !== e) {
667
+ var i = e.call(t, r || "default");
668
+ if ("object" != _typeof(i)) return i;
669
+ throw new TypeError("@@toPrimitive must return a primitive value.");
711
670
  }
712
- /**
713
- * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
714
- * the wrapper found (and primed) a recent session for this fingerprint, else
715
- * null. Network/route failures are swallowed → null (fall through to create).
716
- */
717
- async resumeByFingerprint() {
718
- try {
719
- const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
720
- if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
721
- } catch {}
671
+ return ("string" === r ? String : Number)(t);
672
+ }
673
+ //#endregion
674
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
675
+ function toPropertyKey(t) {
676
+ var i = toPrimitive(t, "string");
677
+ return "symbol" == _typeof(i) ? i : i + "";
678
+ }
679
+ //#endregion
680
+ //#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
681
+ function _defineProperty(e, r, t) {
682
+ return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
683
+ value: t,
684
+ enumerable: !0,
685
+ configurable: !0,
686
+ writable: !0
687
+ }) : e[r] = t, e;
688
+ }
689
+ //#endregion
690
+ //#region src/conversation.ts
691
+ /**
692
+ * ConversationController — the bridge between the widget UI and the
693
+ * `@smooai/smooth-operator` protocol client.
694
+ *
695
+ * This is the piece that was rewired: the original smooai widget spoke to
696
+ * `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
697
+ * The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
698
+ * so the swap is purely at the client-library boundary.
699
+ *
700
+ * Flow:
701
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
702
+ * (or RESUMES a persisted session via `get_session`/`get_messages`).
703
+ * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
704
+ * in-progress assistant message, then the terminal
705
+ * `eventual_response`.
706
+ *
707
+ * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
708
+ * - `browserFingerprint` computed once + sent on every `create_conversation_session`.
709
+ * - identity + marketing consent threaded into the session `metadata`.
710
+ * - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
711
+ * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
712
+ * cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
713
+ * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
714
+ * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
715
+ * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
716
+ * NOT WS frames.
717
+ *
718
+ * The controller is UI-agnostic: it emits typed events and the view renders them.
719
+ */
720
+ /**
721
+ * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
722
+ * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
723
+ * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
724
+ * the cross-device identity flow + fingerprint resume are HTTP POST routes on the
725
+ * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
726
+ */
727
+ function httpBaseFromWsEndpoint(endpoint) {
728
+ try {
729
+ const u = new URL(endpoint);
730
+ u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
731
+ return `${u.protocol}//${u.host}`;
732
+ } catch {
722
733
  return null;
723
734
  }
724
- /** `create_conversation_session` with fingerprint + identity + consent metadata. */
725
- async createSession() {
726
- if (!this.client) throw new Error("Conversation is not connected");
727
- const state = this.store.getState();
728
- const metadata = this.sessionMetadata();
729
- const session = await this.client.createConversationSession({
730
- agentId: this.config.agentId,
731
- userName: state.identity.name,
732
- userEmail: state.identity.email,
733
- browserFingerprint: this.fingerprint(),
734
- ...metadata ? { metadata } : {}
735
+ }
736
+ /** Pull the final assistant text out of an `eventual_response` data payload. */
737
+ function extractFinalText(response) {
738
+ if (!response || typeof response !== "object") return null;
739
+ const r = response;
740
+ if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
741
+ return null;
742
+ }
743
+ /**
744
+ * Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
745
+ *
746
+ * The protocol client types these (`eventual_response.data.data.citations`),
747
+ * but they're optional and back-compatible — absent when the turn used no
748
+ * knowledge sources. We read them defensively (tolerating their total absence,
749
+ * non-array shapes, and missing fields) so a server that doesn't emit them, or
750
+ * an older client, can't break rendering. Each citation always carries
751
+ * `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
752
+ */
753
+ function extractCitations(inner) {
754
+ if (!inner || typeof inner !== "object") return [];
755
+ const raw = inner.citations;
756
+ if (!Array.isArray(raw)) return [];
757
+ const out = [];
758
+ for (const c of raw) {
759
+ if (!c || typeof c !== "object") continue;
760
+ const obj = c;
761
+ const id = typeof obj.id === "string" ? obj.id : "";
762
+ const title = typeof obj.title === "string" ? obj.title : id || "Source";
763
+ const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
764
+ const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
765
+ const score = typeof obj.score === "number" ? obj.score : 0;
766
+ out.push({
767
+ id,
768
+ title,
769
+ snippet,
770
+ score,
771
+ url
735
772
  });
736
- this.sessionId = session.sessionId;
737
- this.store.getState().setSessionId(session.sessionId);
738
773
  }
739
- /**
740
- * Attempt to resume `sessionId`: returns true and hydrates the transcript when
741
- * the session is live, false when it has ended / can't be fetched.
742
- */
743
- async tryResume(sessionId) {
744
- if (!this.client) return false;
745
- let snap;
746
- try {
747
- snap = await this.client.getSession({ sessionId });
748
- } catch {
749
- return false;
750
- }
751
- if (snap.status === "ended") return false;
752
- this.sessionId = sessionId;
753
- await this.hydrateHistory(sessionId);
754
- return true;
774
+ return out;
775
+ }
776
+ /** Convert a server message row into a finalized {@link ChatMessage}. */
777
+ function wireMessageToChat(m, idx) {
778
+ const text = typeof m.content?.text === "string" ? m.content.text : "";
779
+ if (!text) return null;
780
+ const role = m.direction === "outbound" ? "assistant" : "user";
781
+ return {
782
+ id: typeof m.id === "string" ? m.id : `hist-${idx}`,
783
+ role,
784
+ text,
785
+ streaming: false
786
+ };
787
+ }
788
+ var ConversationController = class {
789
+ constructor(config, events, store) {
790
+ _defineProperty(this, "config", void 0);
791
+ _defineProperty(this, "events", void 0);
792
+ _defineProperty(this, "store", void 0);
793
+ _defineProperty(this, "client", null);
794
+ _defineProperty(this, "sessionId", null);
795
+ _defineProperty(this, "messages", []);
796
+ _defineProperty(this, "status", "idle");
797
+ _defineProperty(this, "seq", 0);
798
+ _defineProperty(this, "activeRequestId", null);
799
+ _defineProperty(this, "interrupt", null);
800
+ _defineProperty(this, "identityRestore", { phase: "idle" });
801
+ _defineProperty(this, "resumeAttempted", false);
802
+ _defineProperty(this, "httpBase", void 0);
803
+ this.config = config;
804
+ this.events = events;
805
+ this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
806
+ if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
807
+ this.store = store ?? createWidgetStore(config.agentId);
808
+ const seed = {};
809
+ if (config.userName) seed.name = config.userName;
810
+ if (config.userEmail) seed.email = config.userEmail;
811
+ if (config.userPhone) seed.phone = config.userPhone;
812
+ if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
755
813
  }
756
- /** Page recent history (newest-first), reverse to chronological, and render. */
757
- async hydrateHistory(sessionId) {
758
- if (!this.client) return;
759
- try {
760
- const page = await this.client.getMessages({
761
- sessionId,
762
- limit: 50
763
- });
764
- const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
765
- const hydrated = [];
766
- chronological.forEach((m, i) => {
767
- const chat = wireMessageToChat(m, i);
768
- if (chat) hydrated.push(chat);
769
- });
770
- this.messages.length = 0;
771
- this.messages.push(...hydrated);
772
- this.emitMessages();
773
- } catch {}
814
+ get connectionStatus() {
815
+ return this.status;
774
816
  }
775
- /**
776
- * Submit a user message. Appends the user bubble immediately, then streams the
777
- * assistant reply token-by-token, finalizing on `eventual_response`.
778
- */
779
- async send(text) {
780
- const trimmed = text.trim();
781
- if (!trimmed) return;
782
- if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
783
- if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
784
- this.messages.push({
785
- id: this.nextId("u"),
786
- role: "user",
787
- text: trimmed,
788
- streaming: false
789
- });
790
- const assistant = {
791
- id: this.nextId("a"),
792
- role: "assistant",
793
- text: "",
794
- streaming: true
795
- };
796
- this.messages.push(assistant);
797
- this.emitMessages();
798
- try {
799
- const turn = this.client.sendMessage({
800
- sessionId: this.sessionId,
801
- message: trimmed,
802
- stream: true
803
- });
804
- this.activeRequestId = turn.requestId;
805
- for await (const event of turn) if (event.type === "stream_token") {
806
- const token = event.token ?? event.data?.token ?? "";
807
- if (token) {
808
- assistant.text += token;
809
- this.emitMessages();
810
- }
811
- } else this.handleTurnEvent(event);
812
- const inner = (await turn).data?.data;
813
- const finalText = extractFinalText(inner?.response);
814
- if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
815
- if (!assistant.text) assistant.text = "(no response)";
816
- const citations = extractCitations(inner);
817
- if (citations.length > 0) assistant.citations = citations;
818
- assistant.streaming = false;
819
- this.emitMessages();
820
- } catch (err) {
821
- assistant.streaming = false;
822
- const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
823
- assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
824
- this.emitMessages();
825
- this.setStatus("error", err instanceof Error ? err.message : String(err));
826
- } finally {
827
- this.activeRequestId = null;
828
- this.setInterrupt(null);
829
- }
817
+ /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
818
+ getStore() {
819
+ return this.store;
830
820
  }
831
- /** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */
832
- handleTurnEvent(event) {
833
- const inner = event.data?.data ?? {};
834
- const str = (v) => typeof v === "string" ? v : void 0;
835
- const num = (v) => typeof v === "number" ? v : void 0;
836
- switch (event.type) {
837
- case "otp_verification_required": {
838
- const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
839
- this.setInterrupt({
840
- kind: "otp",
841
- toolId: str(inner.toolId),
842
- actionDescription: str(inner.actionDescription),
843
- availableChannels: channels.length > 0 ? channels : ["email"]
844
- });
845
- break;
846
- }
847
- case "otp_sent":
848
- if (this.interrupt?.kind === "otp") this.setInterrupt({
849
- ...this.interrupt,
850
- sent: {
851
- channel: str(inner.channel),
852
- maskedDestination: str(inner.maskedDestination)
853
- },
854
- error: void 0
855
- });
856
- break;
857
- case "otp_verified":
858
- if (this.interrupt?.kind === "otp") this.setInterrupt(null);
859
- break;
860
- case "otp_invalid":
861
- if (this.interrupt?.kind === "otp") this.setInterrupt({
862
- ...this.interrupt,
863
- error: str(inner.message) ?? "That code was incorrect.",
864
- attemptsRemaining: num(inner.attemptsRemaining)
865
- });
866
- break;
867
- case "write_confirmation_required":
868
- this.setInterrupt({
869
- kind: "confirm",
870
- toolId: str(inner.toolId),
871
- actionDescription: str(inner.actionDescription)
872
- });
873
- break;
874
- default: break;
875
- }
821
+ /** True when a persisted session pointer exists (drives the resume path). */
822
+ hasPersistedSession() {
823
+ return !!this.store.getState().sessionId;
876
824
  }
877
- /**
878
- * Begin the cross-device restore: POST `/internal/identity/request-otp` for
879
- * `email` over `channel`. The view collects the email via an explicit affordance.
880
- */
881
- async requestIdentityOtp(email, channel = "email") {
882
- const trimmed = email.trim();
883
- if (!trimmed) return;
884
- this.setIdentityRestore({
885
- phase: "requesting",
886
- email: trimmed,
887
- channel
888
- });
889
- if (!this.sessionId) try {
890
- await this.connect();
891
- } catch {}
892
- if (!this.sessionId) {
893
- this.setIdentityRestore({
894
- phase: "error",
895
- message: "No active session to verify against."
896
- });
897
- return;
898
- }
899
- try {
900
- const json = await this.postInternal("/internal/identity/request-otp", {
901
- sessionId: this.sessionId,
902
- email: trimmed,
903
- channel
904
- });
905
- const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
906
- this.setIdentityRestore({
907
- phase: "awaiting_code",
908
- email: trimmed,
909
- channel,
910
- maskedDestination: masked
911
- });
912
- } catch (err) {
913
- this.setIdentityRestore({
914
- phase: "error",
915
- message: err instanceof Error ? err.message : "Could not send a verification code."
916
- });
917
- }
825
+ /** True when persisted identity exists (lets the view skip the pre-chat form). */
826
+ hasPersistedIdentity() {
827
+ const id = this.store.getState().identity;
828
+ return !!(id.name || id.email || id.phone);
918
829
  }
919
- /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
920
- async verifyIdentityOtp(code) {
921
- const state = this.identityRestore;
922
- const trimmed = code.trim();
923
- if (!trimmed || state.phase !== "awaiting_code") return;
924
- const { email, channel } = state;
925
- if (!this.sessionId) {
926
- this.setIdentityRestore({
927
- phase: "error",
928
- message: "No active session to verify against."
929
- });
930
- return;
931
- }
932
- this.setIdentityRestore({
933
- phase: "verifying",
830
+ /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
831
+ setUserInfo(info) {
832
+ const { name, email, phone, consent } = info;
833
+ this.store.getState().mergeIdentity({
834
+ name,
934
835
  email,
935
- channel
836
+ phone
936
837
  });
937
- try {
938
- const json = await this.postInternal("/internal/identity/verify-otp", {
939
- sessionId: this.sessionId,
940
- email,
941
- code: trimmed
942
- });
943
- if (json.event === "otp_verified") {
944
- this.store.getState().setVerifiedEmail(email, this.sessionId);
945
- await this.resolveIdentity(email);
946
- } else if (json.event === "otp_invalid") {
947
- const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
948
- this.setIdentityRestore({
949
- phase: "awaiting_code",
950
- email,
951
- channel,
952
- error: "That code was incorrect.",
953
- attemptsRemaining: remaining
954
- });
955
- } else this.setIdentityRestore({
956
- phase: "error",
957
- message: "Verification failed."
958
- });
959
- } catch (err) {
960
- this.setIdentityRestore({
961
- phase: "error",
962
- message: err instanceof Error ? err.message : "Verification failed."
838
+ if (consent) {
839
+ const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
840
+ this.store.getState().setConsent({
841
+ emailOptIn: consent.emailOptIn,
842
+ smsOptIn: consent.smsOptIn,
843
+ consentSource: "chat-widget-prechat",
844
+ consentAt
963
845
  });
964
846
  }
965
847
  }
966
- /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
967
- async resolveIdentity(email) {
968
- if (!this.sessionId) return;
969
- this.setIdentityRestore({
970
- phase: "resolving",
971
- email
848
+ setInterrupt(interrupt) {
849
+ this.interrupt = interrupt;
850
+ this.events.onInterrupt?.(interrupt);
851
+ }
852
+ setIdentityRestore(state) {
853
+ this.identityRestore = state;
854
+ this.events.onIdentityRestore?.(state);
855
+ }
856
+ get currentIdentityRestore() {
857
+ return this.identityRestore;
858
+ }
859
+ /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
860
+ verifyOtp(code) {
861
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
862
+ this.client.verifyOtp({
863
+ sessionId: this.sessionId,
864
+ requestId: this.activeRequestId,
865
+ code
972
866
  });
973
- try {
974
- const json = await this.postInternal("/internal/identity/resolve", {
975
- sessionId: this.sessionId,
976
- email
977
- });
978
- if (json.resolved !== true) {
979
- this.setIdentityRestore({
980
- phase: "resolved",
981
- email,
982
- conversations: []
983
- });
984
- return;
985
- }
986
- const raw = json.conversations;
987
- const conversations = Array.isArray(raw) ? raw.map((c) => {
988
- if (!c || typeof c !== "object") return null;
989
- const o = c;
990
- const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
991
- const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
992
- if (!sessionId) return null;
993
- return {
994
- conversationId,
995
- sessionId,
996
- lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
997
- preview: typeof o.preview === "string" ? o.preview : void 0
998
- };
999
- }).filter((c) => c !== null) : [];
1000
- this.setIdentityRestore({
1001
- phase: "resolved",
1002
- email,
1003
- conversations
1004
- });
1005
- } catch (err) {
1006
- this.setIdentityRestore({
1007
- phase: "error",
1008
- message: err instanceof Error ? err.message : "Could not load your chats."
1009
- });
867
+ }
868
+ /** Approve or reject a pending tool write to resume the paused turn. */
869
+ confirmTool(approved) {
870
+ if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
871
+ this.client.confirmToolAction({
872
+ sessionId: this.sessionId,
873
+ requestId: this.activeRequestId,
874
+ approved
875
+ });
876
+ this.setInterrupt(null);
877
+ }
878
+ nextId(prefix) {
879
+ this.seq += 1;
880
+ return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
881
+ }
882
+ setStatus(status, detail) {
883
+ this.status = status;
884
+ this.events.onStatus(status, detail);
885
+ }
886
+ emitMessages() {
887
+ this.events.onMessages(this.messages.map((m) => ({ ...m })));
888
+ }
889
+ /** Compute (once) + return the persisted browser fingerprint. */
890
+ fingerprint() {
891
+ const state = this.store.getState();
892
+ return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
893
+ }
894
+ /**
895
+ * Build the `metadata` payload threaded into `create_conversation_session`:
896
+ * phone (no first-class engine field) and consent.
897
+ *
898
+ * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
899
+ * OTP proof bound to the session it was verified against
900
+ * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
901
+ * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
902
+ * every brand-new `create_conversation_session` would mislabel a fresh
903
+ * visitor's session with a prior (possibly different) visitor's email on a
904
+ * shared browser. The verified email is only used when RESUMING the exact
905
+ * session it was proven for — see {@link verifiedEmailForSession}.
906
+ */
907
+ sessionMetadata() {
908
+ const state = this.store.getState();
909
+ const meta = {};
910
+ if (state.identity.phone) meta.userPhone = state.identity.phone;
911
+ const consent = state.consent;
912
+ if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
913
+ const c = {
914
+ emailOptIn: consent.emailOptIn,
915
+ smsOptIn: consent.smsOptIn,
916
+ consentSource: consent.consentSource ?? "chat-widget-prechat"
917
+ };
918
+ if (consent.consentAt) c.consentAt = consent.consentAt;
919
+ meta.consent = c;
1010
920
  }
921
+ return Object.keys(meta).length > 0 ? meta : void 0;
1011
922
  }
1012
923
  /**
1013
- * Replay a chosen restorable conversation: point the live session at its
1014
- * sessionId, hydrate its transcript (get_session + get_messages), and persist
1015
- * the new pointer so the next `sendMessage` continues it.
924
+ * The verified-email hint, but ONLY when the OTP proof is bound to the session
925
+ * being resumed (`verifiedEmailSessionId === sessionId`). Returns null
926
+ * otherwise so a stale/cross-visitor proof is never threaded onto a session it
927
+ * wasn't proven for.
1016
928
  */
1017
- async restoreConversation(sessionId) {
1018
- if (!this.client) await this.ensureClient();
1019
- const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
1020
- if (await this.tryResume(sessionId)) {
1021
- this.store.getState().setSessionId(sessionId);
1022
- if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
1023
- this.setIdentityRestore({ phase: "idle" });
1024
- this.setStatus("ready");
1025
- } else this.setIdentityRestore({
1026
- phase: "error",
1027
- message: "That conversation is no longer available."
1028
- });
1029
- }
1030
- /** Dismiss the cross-device restore panel. */
1031
- cancelIdentityRestore() {
1032
- this.setIdentityRestore({ phase: "idle" });
1033
- }
1034
- /** Tear down the underlying client. */
1035
- disconnect() {
1036
- this.client?.disconnect("widget closed");
1037
- this.client = null;
1038
- this.sessionId = null;
1039
- this.activeRequestId = null;
1040
- this.resumeAttempted = false;
1041
- this.setInterrupt(null);
1042
- this.setStatus("closed");
1043
- }
1044
- };
1045
- //#endregion
1046
- //#region src/logo.ts
1047
- /**
1048
- * The Smooth logo, inlined as an SVG string so the full-page header can render
1049
- * it without a separate network fetch (the IIFE bundle is self-contained).
1050
- *
1051
- * GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
1052
- * node -e ... (see the commit that added this file)
1053
- */
1054
- 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>";
1055
- //#endregion
1056
- //#region src/markdown.ts
1057
- /**
1058
- * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
1059
- *
1060
- * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
1061
- *
1062
- * The widget renders **untrusted** text in two places: the assistant's reply
1063
- * (LLM output, which can echo attacker-supplied content) and citation snippets
1064
- * (raw scraped page chunks). Today both are written via `textContent`, so
1065
- * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
1066
- * them rendered — without re-opening the XSS hole that `textContent` was
1067
- * guarding against.
1068
- *
1069
- * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
1070
- * what is an embeddable **global** bundle, where every kilobyte is on the host
1071
- * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
1072
- * require bolting on a separate sanitizer. Instead, this renderer is
1073
- * **safe-by-construction**:
1074
- *
1075
- * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
1076
- * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
1077
- * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
1078
- * tag out of the input — a literal `<script>` in the input is treated as
1079
- * plain text.
1080
- * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
1081
- * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
1082
- * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
1083
- * no `<img>` is ever produced (a scraped tracking pixel must not load).
1084
- * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
1085
- * URLs become anchors (with `target="_blank"` + a hardened `rel`);
1086
- * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
1087
- * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
1088
- * `<h1>` is far too large inside a chat bubble or citation card.
1089
- *
1090
- * The output is a string of HTML that is only ever assigned to `innerHTML` of
1091
- * an element the caller controls; because of (1)–(4) it can only contain the
1092
- * allowlisted, attribute-sanitized tags.
1093
- *
1094
- * Supported subset (deliberately small):
1095
- * - Paragraphs (blank-line separated) and hard/soft line breaks
1096
- * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
1097
- * - `` `inline code` `` and fenced ``` ```code blocks``` ```
1098
- * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
1099
- * - `> ` blockquotes
1100
- * - `[text](http(s)://url)` links (images dropped to alt text)
1101
- * - `#`..`######` headings → bold line
1102
- */
1103
- /** Escape the five HTML-significant characters so a text run can never be markup. */
1104
- function escapeHtml(value) {
1105
- return value.replace(/[&<>"']/g, (c) => {
1106
- switch (c) {
1107
- case "&": return "&amp;";
1108
- case "<": return "&lt;";
1109
- case ">": return "&gt;";
1110
- case "\"": return "&quot;";
1111
- default: return "&#39;";
1112
- }
1113
- });
1114
- }
1115
- /**
1116
- * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
1117
- *
1118
- * SECURITY: link targets here originate from untrusted content (LLM output /
1119
- * scraped citation chunks). Allowing an arbitrary string as an `href` permits
1120
- * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
1121
- * vector. Only absolute http(s) links are rendered as anchors; anything else
1122
- * falls back to plain text upstream.
1123
- */
1124
- function safeHttpUrl(url) {
1125
- if (!url) return null;
1126
- try {
1127
- const parsed = new URL(url);
1128
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
1129
- } catch {
929
+ verifiedEmailForSession(sessionId) {
930
+ const state = this.store.getState();
931
+ if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
1130
932
  return null;
1131
933
  }
1132
- }
1133
- /**
1134
- * Render the inline span grammar of a single line/segment to safe HTML.
1135
- *
1136
- * Order matters: code spans are extracted first (their contents are *not*
1137
- * further parsed), then images are stripped to alt text, then links, then
1138
- * emphasis. Every literal text run is escaped on the way out.
1139
- */
1140
- function renderInline(input) {
1141
- let out = "";
1142
- let i = 0;
1143
- const n = input.length;
1144
- let buf = "";
1145
- const flush = () => {
1146
- if (buf) {
1147
- out += escapeHtml(buf);
1148
- buf = "";
1149
- }
1150
- };
1151
- while (i < n) {
1152
- const ch = input[i];
1153
- if (ch === "`") {
1154
- const end = input.indexOf("`", i + 1);
1155
- if (end > i) {
1156
- flush();
1157
- out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
1158
- i = end + 1;
1159
- continue;
1160
- }
1161
- }
1162
- if (ch === "!" && input[i + 1] === "[") {
1163
- const m = imageAt(input, i);
1164
- if (m) {
1165
- flush();
1166
- out += renderInline(m.alt);
1167
- i = m.end;
1168
- continue;
1169
- }
1170
- }
1171
- if (ch === "[") {
1172
- const m = linkAt(input, i);
1173
- if (m) {
1174
- flush();
1175
- const safe = safeHttpUrl(m.href);
1176
- const inner = renderInline(m.text);
1177
- if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
1178
- else out += inner;
1179
- i = m.end;
1180
- continue;
934
+ /** Lazily open the WS client (default transport). Idempotent within a connect. */
935
+ async ensureClient() {
936
+ if (this.client) return;
937
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
938
+ await this.client.connect();
939
+ }
940
+ /**
941
+ * Open the connection and either RESUME or create a session.
942
+ *
943
+ * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
944
+ * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
945
+ * ONLY the pointer (identity/consent survive).
946
+ * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
947
+ * `resumable`, adopt the returned session (the wrapper has primed the
948
+ * operator registry), reuse the sessionId, and hydrate via get_session/
949
+ * get_messages rather than relying on createConversationSession to resume.
950
+ * 3. Otherwise create a fresh session.
951
+ */
952
+ async connect() {
953
+ if (this.status === "connecting" || this.status === "ready") return;
954
+ this.setStatus("connecting");
955
+ try {
956
+ await this.ensureClient();
957
+ if (!this.resumeAttempted) {
958
+ this.resumeAttempted = true;
959
+ const persistedSessionId = this.store.getState().sessionId;
960
+ if (persistedSessionId) {
961
+ if (await this.tryResume(persistedSessionId)) {
962
+ this.setStatus("ready");
963
+ return;
964
+ }
965
+ this.store.getState().clearSession();
966
+ } else {
967
+ const fpSessionId = await this.resumeByFingerprint();
968
+ if (fpSessionId) {
969
+ if (await this.tryResume(fpSessionId)) {
970
+ this.store.getState().setSessionId(fpSessionId);
971
+ this.setStatus("ready");
972
+ return;
973
+ }
974
+ }
975
+ }
1181
976
  }
977
+ await this.createSession();
978
+ this.setStatus("ready");
979
+ } catch (err) {
980
+ this.setStatus("error", err instanceof Error ? err.message : String(err));
981
+ throw err;
1182
982
  }
1183
- if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
1184
- const marker = ch + ch;
1185
- const end = input.indexOf(marker, i + 2);
1186
- if (end > i + 1) {
1187
- flush();
1188
- out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
1189
- i = end + 2;
1190
- continue;
1191
- }
983
+ }
984
+ /**
985
+ * Build the auth fields every `/internal/*` route shares: `agentId` (required
986
+ * for the agent-policy lookup), `agentName` (used as the OTP email sender), and
987
+ * the optional pre-auth `authContext` the host page may have configured. The
988
+ * `Origin` header is sent automatically by the browser and checked server-side.
989
+ */
990
+ authBody() {
991
+ const body = { agentId: this.config.agentId };
992
+ if (this.config.agentName) body.agentName = this.config.agentName;
993
+ if (this.config.authContext) body.authContext = this.config.authContext;
994
+ return body;
995
+ }
996
+ /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
997
+ async postInternal(path, payload) {
998
+ if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
999
+ const res = await fetch(`${this.httpBase}${path}`, {
1000
+ method: "POST",
1001
+ headers: { "content-type": "application/json" },
1002
+ credentials: "omit",
1003
+ body: JSON.stringify({
1004
+ ...this.authBody(),
1005
+ ...payload
1006
+ })
1007
+ });
1008
+ let json = {};
1009
+ try {
1010
+ json = await res.json();
1011
+ } catch {
1012
+ json = {};
1192
1013
  }
1193
- if (ch === "*" || ch === "_") {
1194
- const end = input.indexOf(ch, i + 1);
1195
- if (end > i + 1 && input[i + 1] !== ch) {
1196
- flush();
1197
- out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
1198
- i = end + 1;
1199
- continue;
1200
- }
1014
+ if (!res.ok) {
1015
+ const err = json.error;
1016
+ throw new Error(err?.message ?? `${path} failed (${res.status})`);
1201
1017
  }
1202
- buf += ch;
1203
- i++;
1018
+ return json;
1204
1019
  }
1205
- flush();
1206
- return out;
1207
- }
1208
- /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
1209
- function linkAt(input, start) {
1210
- const close = matchBracket(input, start);
1211
- if (close < 0 || input[close + 1] !== "(") return null;
1212
- const paren = input.indexOf(")", close + 2);
1213
- if (paren < 0) return null;
1214
- return {
1215
- text: input.slice(start + 1, close),
1216
- href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
1217
- end: paren + 1
1218
- };
1219
- }
1220
- /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
1221
- function imageAt(input, start) {
1222
- const link = linkAt(input, start + 1);
1223
- if (!link) return null;
1224
- return {
1225
- alt: link.text,
1226
- end: link.end
1227
- };
1228
- }
1229
- /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
1230
- function matchBracket(input, open) {
1231
- let depth = 0;
1232
- for (let i = open; i < input.length; i++) {
1233
- const c = input[i];
1234
- if (c === "[") depth++;
1235
- else if (c === "]") {
1236
- depth--;
1237
- if (depth === 0) return i;
1020
+ /**
1021
+ * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
1022
+ * the wrapper found (and primed) a recent session for this fingerprint, else
1023
+ * null. Network/route failures are swallowed null (fall through to create).
1024
+ */
1025
+ async resumeByFingerprint() {
1026
+ try {
1027
+ const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
1028
+ if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
1029
+ } catch {}
1030
+ return null;
1031
+ }
1032
+ /** `create_conversation_session` with fingerprint + identity + consent metadata. */
1033
+ async createSession() {
1034
+ if (!this.client) throw new Error("Conversation is not connected");
1035
+ const state = this.store.getState();
1036
+ const metadata = this.sessionMetadata();
1037
+ const session = await this.client.createConversationSession({
1038
+ agentId: this.config.agentId,
1039
+ userName: state.identity.name,
1040
+ userEmail: state.identity.email,
1041
+ browserFingerprint: this.fingerprint(),
1042
+ ...metadata ? { metadata } : {}
1043
+ });
1044
+ this.sessionId = session.sessionId;
1045
+ this.store.getState().setSessionId(session.sessionId);
1046
+ }
1047
+ /**
1048
+ * Attempt to resume `sessionId`: returns true and hydrates the transcript when
1049
+ * the session is live, false when it has ended / can't be fetched.
1050
+ */
1051
+ async tryResume(sessionId) {
1052
+ if (!this.client) return false;
1053
+ let snap;
1054
+ try {
1055
+ snap = await this.client.getSession({ sessionId });
1056
+ } catch {
1057
+ return false;
1238
1058
  }
1059
+ if (snap.status === "ended") return false;
1060
+ this.sessionId = sessionId;
1061
+ await this.hydrateHistory(sessionId);
1062
+ return true;
1239
1063
  }
1240
- return -1;
1241
- }
1242
- const UL_RE = /^\s*[-*+]\s+(.*)$/;
1243
- const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
1244
- const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
1245
- const QUOTE_RE = /^\s*>\s?(.*)$/;
1246
- const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
1247
- /**
1248
- * Render a full Markdown string to safe HTML.
1249
- *
1250
- * @returns a string containing only the allowlisted tags described in the
1251
- * module doc. Safe to assign to `innerHTML` of a caller-owned element.
1252
- */
1253
- function renderMarkdown(src) {
1254
- const lines = src.replace(/\r\n?/g, "\n").split("\n");
1255
- const out = [];
1256
- let i = 0;
1257
- while (i < lines.length) {
1258
- const line = lines[i];
1259
- const fence = FENCE_RE.exec(line);
1260
- if (fence) {
1261
- const marker = fence[1];
1262
- const body = [];
1263
- i++;
1264
- while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
1265
- body.push(lines[i]);
1266
- i++;
1064
+ /** Page recent history (newest-first), reverse to chronological, and render. */
1065
+ async hydrateHistory(sessionId) {
1066
+ if (!this.client) return;
1067
+ try {
1068
+ const page = await this.client.getMessages({
1069
+ sessionId,
1070
+ limit: 50
1071
+ });
1072
+ const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
1073
+ const hydrated = [];
1074
+ chronological.forEach((m, i) => {
1075
+ const chat = wireMessageToChat(m, i);
1076
+ if (chat) hydrated.push(chat);
1077
+ });
1078
+ this.messages.length = 0;
1079
+ this.messages.push(...hydrated);
1080
+ this.emitMessages();
1081
+ } catch {}
1082
+ }
1083
+ /**
1084
+ * Submit a user message. Appends the user bubble immediately, then streams the
1085
+ * assistant reply token-by-token, finalizing on `eventual_response`.
1086
+ */
1087
+ async send(text) {
1088
+ const trimmed = text.trim();
1089
+ if (!trimmed) return;
1090
+ if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
1091
+ if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
1092
+ this.messages.push({
1093
+ id: this.nextId("u"),
1094
+ role: "user",
1095
+ text: trimmed,
1096
+ streaming: false
1097
+ });
1098
+ const assistant = {
1099
+ id: this.nextId("a"),
1100
+ role: "assistant",
1101
+ text: "",
1102
+ streaming: true
1103
+ };
1104
+ this.messages.push(assistant);
1105
+ this.emitMessages();
1106
+ try {
1107
+ const turn = this.client.sendMessage({
1108
+ sessionId: this.sessionId,
1109
+ message: trimmed,
1110
+ stream: true
1111
+ });
1112
+ this.activeRequestId = turn.requestId;
1113
+ for await (const event of turn) if (event.type === "stream_token") {
1114
+ const token = event.token ?? event.data?.token ?? "";
1115
+ if (token) {
1116
+ assistant.text += token;
1117
+ this.emitMessages();
1118
+ }
1119
+ } else this.handleTurnEvent(event);
1120
+ const inner = (await turn).data?.data;
1121
+ const finalText = extractFinalText(inner?.response);
1122
+ if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
1123
+ if (!assistant.text) assistant.text = "(no response)";
1124
+ const citations = extractCitations(inner);
1125
+ if (citations.length > 0) assistant.citations = citations;
1126
+ assistant.streaming = false;
1127
+ this.emitMessages();
1128
+ } catch (err) {
1129
+ assistant.streaming = false;
1130
+ const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
1131
+ assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
1132
+ this.emitMessages();
1133
+ this.setStatus("error", err instanceof Error ? err.message : String(err));
1134
+ } finally {
1135
+ this.activeRequestId = null;
1136
+ this.setInterrupt(null);
1137
+ }
1138
+ }
1139
+ /** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */
1140
+ handleTurnEvent(event) {
1141
+ const inner = event.data?.data ?? {};
1142
+ const str = (v) => typeof v === "string" ? v : void 0;
1143
+ const num = (v) => typeof v === "number" ? v : void 0;
1144
+ switch (event.type) {
1145
+ case "otp_verification_required": {
1146
+ const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
1147
+ this.setInterrupt({
1148
+ kind: "otp",
1149
+ toolId: str(inner.toolId),
1150
+ actionDescription: str(inner.actionDescription),
1151
+ availableChannels: channels.length > 0 ? channels : ["email"]
1152
+ });
1153
+ break;
1267
1154
  }
1268
- if (i < lines.length) i++;
1269
- out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
1270
- continue;
1155
+ case "otp_sent":
1156
+ if (this.interrupt?.kind === "otp") this.setInterrupt({
1157
+ ...this.interrupt,
1158
+ sent: {
1159
+ channel: str(inner.channel),
1160
+ maskedDestination: str(inner.maskedDestination)
1161
+ },
1162
+ error: void 0
1163
+ });
1164
+ break;
1165
+ case "otp_verified":
1166
+ if (this.interrupt?.kind === "otp") this.setInterrupt(null);
1167
+ break;
1168
+ case "otp_invalid":
1169
+ if (this.interrupt?.kind === "otp") this.setInterrupt({
1170
+ ...this.interrupt,
1171
+ error: str(inner.message) ?? "That code was incorrect.",
1172
+ attemptsRemaining: num(inner.attemptsRemaining)
1173
+ });
1174
+ break;
1175
+ case "write_confirmation_required":
1176
+ this.setInterrupt({
1177
+ kind: "confirm",
1178
+ toolId: str(inner.toolId),
1179
+ actionDescription: str(inner.actionDescription)
1180
+ });
1181
+ break;
1182
+ default: break;
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Begin the cross-device restore: POST `/internal/identity/request-otp` for
1187
+ * `email` over `channel`. The view collects the email via an explicit affordance.
1188
+ */
1189
+ async requestIdentityOtp(email, channel = "email") {
1190
+ const trimmed = email.trim();
1191
+ if (!trimmed) return;
1192
+ this.setIdentityRestore({
1193
+ phase: "requesting",
1194
+ email: trimmed,
1195
+ channel
1196
+ });
1197
+ if (!this.sessionId) try {
1198
+ await this.connect();
1199
+ } catch {}
1200
+ if (!this.sessionId) {
1201
+ this.setIdentityRestore({
1202
+ phase: "error",
1203
+ message: "No active session to verify against."
1204
+ });
1205
+ return;
1271
1206
  }
1272
- if (line.trim() === "") {
1273
- i++;
1274
- continue;
1207
+ try {
1208
+ const json = await this.postInternal("/internal/identity/request-otp", {
1209
+ sessionId: this.sessionId,
1210
+ email: trimmed,
1211
+ channel
1212
+ });
1213
+ const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
1214
+ this.setIdentityRestore({
1215
+ phase: "awaiting_code",
1216
+ email: trimmed,
1217
+ channel,
1218
+ maskedDestination: masked
1219
+ });
1220
+ } catch (err) {
1221
+ this.setIdentityRestore({
1222
+ phase: "error",
1223
+ message: err instanceof Error ? err.message : "Could not send a verification code."
1224
+ });
1275
1225
  }
1276
- const heading = HEADING_RE.exec(line);
1277
- if (heading) {
1278
- out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
1279
- i++;
1280
- continue;
1226
+ }
1227
+ /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
1228
+ async verifyIdentityOtp(code) {
1229
+ const state = this.identityRestore;
1230
+ const trimmed = code.trim();
1231
+ if (!trimmed || state.phase !== "awaiting_code") return;
1232
+ const { email, channel } = state;
1233
+ if (!this.sessionId) {
1234
+ this.setIdentityRestore({
1235
+ phase: "error",
1236
+ message: "No active session to verify against."
1237
+ });
1238
+ return;
1281
1239
  }
1282
- if (UL_RE.test(line) || OL_RE.test(line)) {
1283
- const ordered = OL_RE.test(line) && !UL_RE.test(line);
1284
- const re = ordered ? OL_RE : UL_RE;
1285
- const items = [];
1286
- while (i < lines.length) {
1287
- const m = re.exec(lines[i]);
1288
- if (!m) break;
1289
- items.push(`<li>${renderInline(m[1])}</li>`);
1290
- i++;
1291
- }
1292
- out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
1293
- continue;
1240
+ this.setIdentityRestore({
1241
+ phase: "verifying",
1242
+ email,
1243
+ channel
1244
+ });
1245
+ try {
1246
+ const json = await this.postInternal("/internal/identity/verify-otp", {
1247
+ sessionId: this.sessionId,
1248
+ email,
1249
+ code: trimmed
1250
+ });
1251
+ if (json.event === "otp_verified") {
1252
+ this.store.getState().setVerifiedEmail(email, this.sessionId);
1253
+ await this.resolveIdentity(email);
1254
+ } else if (json.event === "otp_invalid") {
1255
+ const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
1256
+ this.setIdentityRestore({
1257
+ phase: "awaiting_code",
1258
+ email,
1259
+ channel,
1260
+ error: "That code was incorrect.",
1261
+ attemptsRemaining: remaining
1262
+ });
1263
+ } else this.setIdentityRestore({
1264
+ phase: "error",
1265
+ message: "Verification failed."
1266
+ });
1267
+ } catch (err) {
1268
+ this.setIdentityRestore({
1269
+ phase: "error",
1270
+ message: err instanceof Error ? err.message : "Verification failed."
1271
+ });
1294
1272
  }
1295
- if (QUOTE_RE.test(line)) {
1296
- const quoted = [];
1297
- while (i < lines.length) {
1298
- const m = QUOTE_RE.exec(lines[i]);
1299
- if (!m) break;
1300
- quoted.push(m[1]);
1301
- i++;
1273
+ }
1274
+ /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
1275
+ async resolveIdentity(email) {
1276
+ if (!this.sessionId) return;
1277
+ this.setIdentityRestore({
1278
+ phase: "resolving",
1279
+ email
1280
+ });
1281
+ try {
1282
+ const json = await this.postInternal("/internal/identity/resolve", {
1283
+ sessionId: this.sessionId,
1284
+ email
1285
+ });
1286
+ if (json.resolved !== true) {
1287
+ this.setIdentityRestore({
1288
+ phase: "resolved",
1289
+ email,
1290
+ conversations: []
1291
+ });
1292
+ return;
1302
1293
  }
1303
- out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
1304
- continue;
1305
- }
1306
- const para = [];
1307
- while (i < lines.length) {
1308
- const l = lines[i];
1309
- if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
1310
- para.push(l);
1311
- i++;
1294
+ const raw = json.conversations;
1295
+ const conversations = Array.isArray(raw) ? raw.map((c) => {
1296
+ if (!c || typeof c !== "object") return null;
1297
+ const o = c;
1298
+ const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
1299
+ const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
1300
+ if (!sessionId) return null;
1301
+ return {
1302
+ conversationId,
1303
+ sessionId,
1304
+ lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
1305
+ preview: typeof o.preview === "string" ? o.preview : void 0
1306
+ };
1307
+ }).filter((c) => c !== null) : [];
1308
+ this.setIdentityRestore({
1309
+ phase: "resolved",
1310
+ email,
1311
+ conversations
1312
+ });
1313
+ } catch (err) {
1314
+ this.setIdentityRestore({
1315
+ phase: "error",
1316
+ message: err instanceof Error ? err.message : "Could not load your chats."
1317
+ });
1312
1318
  }
1313
- out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
1314
1319
  }
1315
- return out.join("");
1316
- }
1317
- const SNIPPET_MAX = 260;
1318
- /**
1319
- * Clean a raw scraped citation snippet into a short, readable excerpt.
1320
- *
1321
- * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
1322
- * in a link, standalone nav, repeated whitespace — e.g.
1323
- * `[![Logo](…)]() # Our Work We build…`. The source itself is already linked
1324
- * from the citation card, so the snippet only needs to be a clean teaser.
1325
- *
1326
- * Steps:
1327
- * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
1328
- * 2. Drop a leading standalone heading marker (`#`/`##`).
1329
- * 3. Collapse all runs of whitespace to single spaces.
1330
- * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
1331
- *
1332
- * The result is still rendered through {@link renderMarkdown} downstream, so any
1333
- * remaining inline markup (bold/links) stays safe.
1334
- */
1335
- function cleanCitationSnippet(raw) {
1336
- let s = raw ?? "";
1337
- let changed = true;
1338
- while (changed) {
1339
- changed = false;
1340
- const before = s;
1341
- s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
1342
- s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
1343
- s = s.replace(/^\s*#{1,6}\s+/, "");
1344
- if (s !== before) changed = true;
1320
+ /**
1321
+ * Replay a chosen restorable conversation: point the live session at its
1322
+ * sessionId, hydrate its transcript (get_session + get_messages), and persist
1323
+ * the new pointer so the next `sendMessage` continues it.
1324
+ */
1325
+ async restoreConversation(sessionId) {
1326
+ if (!this.client) await this.ensureClient();
1327
+ const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
1328
+ if (await this.tryResume(sessionId)) {
1329
+ this.store.getState().setSessionId(sessionId);
1330
+ if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
1331
+ this.setIdentityRestore({ phase: "idle" });
1332
+ this.setStatus("ready");
1333
+ } else this.setIdentityRestore({
1334
+ phase: "error",
1335
+ message: "That conversation is no longer available."
1336
+ });
1345
1337
  }
1346
- s = s.replace(/\s+/g, " ").trim();
1347
- if (s.length > SNIPPET_MAX) {
1348
- const cut = s.slice(0, SNIPPET_MAX);
1349
- const lastSpace = cut.lastIndexOf(" ");
1350
- s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
1338
+ /** Dismiss the cross-device restore panel. */
1339
+ cancelIdentityRestore() {
1340
+ this.setIdentityRestore({ phase: "idle" });
1351
1341
  }
1352
- return s;
1353
- }
1342
+ /** Tear down the underlying client. */
1343
+ disconnect() {
1344
+ this.client?.disconnect("widget closed");
1345
+ this.client = null;
1346
+ this.sessionId = null;
1347
+ this.activeRequestId = null;
1348
+ this.resumeAttempted = false;
1349
+ this.setInterrupt(null);
1350
+ this.setStatus("closed");
1351
+ }
1352
+ };
1353
+ //#endregion
1354
+ //#region src/logo.ts
1355
+ /** The square Smooth icon (the stylized `th` glyph) — used as the default full-page header avatar. */
1356
+ const SMOOTH_ICON_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 150 150\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient);\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"31.06\" y1=\"37.6\" x2=\"118.5\" y2=\"125.04\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-1\" d=\"M55.03,55.47v13.28H19.24v-13.28h35.79ZM26.83,41.83h17.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.01ZM93.74,80.08v32.71h-17.89V36.39h17.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>";
1354
1357
  //#endregion
1355
1358
  //#region src/styles.ts
1356
1359
  /**
@@ -1525,8 +1528,9 @@ function buildStyles(theme, mode = "popover") {
1525
1528
  0 1px 0 rgba(255, 255, 255, .25) inset;
1526
1529
  }
1527
1530
  .avatar svg { width: 22px; height: 22px; }
1528
- .avatar .logo-wrap { display: flex; }
1531
+ .avatar .logo-wrap { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
1529
1532
  .avatar .logo { height: 22px; width: auto; display: block; }
1533
+ .avatar .logo-img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; display: block; border-radius: 9px; }
1530
1534
  .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
1531
1535
  .title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; }
1532
1536
  .status {
@@ -1575,6 +1579,7 @@ function buildStyles(theme, mode = "popover") {
1575
1579
  .panel.fullpage .header { padding: 18px 22px; }
1576
1580
  .panel.fullpage .avatar { width: 44px; height: 44px; }
1577
1581
  .panel.fullpage .avatar .logo { height: 26px; }
1582
+ .panel.fullpage .avatar svg { width: 28px; height: 28px; }
1578
1583
 
1579
1584
  /* ────────────────────────────── Messages ──────────────────────────── */
1580
1585
  .messages {
@@ -1864,6 +1869,24 @@ function buildStyles(theme, mode = "popover") {
1864
1869
  border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
1865
1870
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
1866
1871
  }
1872
+ /* Inline phone validity — subtle, themed. Empty stays neutral (optional field). */
1873
+ .pc-field.valid input {
1874
+ border-color: color-mix(in srgb, var(--sac-primary) 55%, #2faa6a 45%);
1875
+ }
1876
+ .pc-field.invalid input {
1877
+ border-color: color-mix(in srgb, #e2566b 62%, var(--sac-border) 38%);
1878
+ }
1879
+ .pc-field.invalid input:focus {
1880
+ box-shadow: 0 0 0 4px color-mix(in srgb, #e2566b 16%, transparent);
1881
+ }
1882
+ .pc-field .pc-hint {
1883
+ min-height: 13px;
1884
+ margin-top: 1px;
1885
+ font-size: 11.5px;
1886
+ font-weight: 500;
1887
+ line-height: 1.2;
1888
+ color: color-mix(in srgb, #e2566b 78%, var(--sac-text) 22%);
1889
+ }
1867
1890
  .pc-submit {
1868
1891
  margin-top: 4px;
1869
1892
  border: none;
@@ -2034,11 +2057,51 @@ function buildStyles(theme, mode = "popover") {
2034
2057
  }
2035
2058
  //#endregion
2036
2059
  //#region src/element.ts
2060
+ /**
2061
+ * `<smooth-agent-chat>` — a framework-light embeddable chat web component.
2062
+ *
2063
+ * A clean, dependency-light web component that preserves a familiar embedding
2064
+ * model — a launcher + popover panel, declarative HTML attributes, and a
2065
+ * programmatic API — while talking to the `@smooai/smooth-operator` protocol
2066
+ * client. The visual layer is the "Aurora Glass" design system (see
2067
+ * {@link buildStyles}): a spring launcher with a live presence pulse, a
2068
+ * glass-depth panel, a gradient brand avatar + status dot, an animated typing
2069
+ * indicator, message rise-in, refined source cards, and an icon composer. Every
2070
+ * color is driven by `--sac-*` custom properties so a host's brand flows through.
2071
+ *
2072
+ * Embedding model:
2073
+ * <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
2074
+ * or programmatically via {@link mountChatWidget}.
2075
+ */
2037
2076
  const ELEMENT_TAG = "smooth-agent-chat";
2077
+ /**
2078
+ * Default region for phone parsing/formatting on the pre-chat form. The widget
2079
+ * is US-first; the backend does the authoritative E.164 normalization (SMOODEV-2153),
2080
+ * so this only governs the as-you-type display + the inline validity hint and the
2081
+ * best-effort E.164 we send when the number already parses as valid.
2082
+ */
2083
+ const PHONE_DEFAULT_REGION = "US";
2084
+ /**
2085
+ * Best-effort E.164 for an as-typed phone number. Returns the canonical
2086
+ * `+1…` form when the value parses to a valid number in {@link PHONE_DEFAULT_REGION},
2087
+ * otherwise `null` (caller falls back to sending the raw value — the backend
2088
+ * re-parses and normalizes/nulls authoritatively).
2089
+ */
2090
+ function phoneToE164(value) {
2091
+ const v = value.trim();
2092
+ if (!v) return null;
2093
+ try {
2094
+ if (!isValidPhoneNumber(v, PHONE_DEFAULT_REGION)) return null;
2095
+ return parsePhoneNumber(v, PHONE_DEFAULT_REGION).number;
2096
+ } catch {
2097
+ return null;
2098
+ }
2099
+ }
2038
2100
  const OBSERVED = [
2039
2101
  "endpoint",
2040
2102
  "agent-id",
2041
2103
  "agent-name",
2104
+ "logo-url",
2042
2105
  "placeholder",
2043
2106
  "greeting",
2044
2107
  "start-open",
@@ -2150,6 +2213,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2150
2213
  mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
2151
2214
  agentId,
2152
2215
  agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
2216
+ logoUrl: this.overrides.logoUrl ?? this.getAttribute("logo-url") ?? void 0,
2153
2217
  userName: this.overrides.userName,
2154
2218
  userEmail: this.overrides.userEmail,
2155
2219
  userPhone: this.overrides.userPhone,
@@ -2204,8 +2268,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
2204
2268
  const style = document.createElement("style");
2205
2269
  style.textContent = buildStyles(resolved.theme, resolved.mode);
2206
2270
  const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase());
2271
+ const headerLogo = resolved.logoUrl ? `<img src="${escapeHtml(resolved.logoUrl)}" alt="" class="logo-img" />` : SMOOTH_ICON_SVG;
2207
2272
  const header = fullpage ? `<div class="header">
2208
- <div class="avatar"><span class="logo-wrap">${SMOOTH_LOGO_SVG}</span></div>
2273
+ <div class="avatar"><span class="logo-wrap">${headerLogo}</span></div>
2209
2274
  <div class="meta">
2210
2275
  <span class="title">${escapeHtml(resolved.agentName)}</span>
2211
2276
  <span class="status"><span class="dot off"></span><span class="status-text"></span></span>
@@ -2224,7 +2289,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2224
2289
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
2225
2290
  this.gating = gating;
2226
2291
  const showPhone = resolved.requirePhone || resolved.collectPhone;
2227
- const field = (name, type, label, autocomplete, required) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} /></label>`;
2292
+ const field = (name, type, label, autocomplete, required, hint = false) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} />${hint ? "<span class=\"pc-hint\" aria-live=\"polite\"></span>" : ""}</label>`;
2228
2293
  const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
2229
2294
  const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
2230
2295
  ${consentBox("emailOptIn", "Email me product news and offers.")}
@@ -2239,7 +2304,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2239
2304
  <form class="pc-form" novalidate>
2240
2305
  ${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
2241
2306
  ${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
2242
- ${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone) : ""}
2307
+ ${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone, true) : ""}
2243
2308
  ${consentHtml}
2244
2309
  <button type="submit" class="pc-submit">Start chat</button>
2245
2310
  </form>
@@ -2291,6 +2356,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
2291
2356
  ev.preventDefault();
2292
2357
  this.handlePrechatSubmit(pcForm);
2293
2358
  });
2359
+ this.wirePhoneField(pcForm);
2294
2360
  container.querySelector(".restore-link")?.addEventListener("click", () => {
2295
2361
  (async () => {
2296
2362
  this.identityRestore = { phase: "awaiting_email" };
@@ -2566,16 +2632,77 @@ var SmoothAgentChatElement = class extends HTMLElement {
2566
2632
  return "";
2567
2633
  }
2568
2634
  }
2635
+ /**
2636
+ * Wire as-you-type formatting + an inline validity hint onto the pre-chat
2637
+ * phone input (libphonenumber-js, US default region). Autofill is preserved:
2638
+ * the input keeps its `type="tel"` + `autocomplete="tel"` + implicit <label>,
2639
+ * and we also reformat on `change` so a browser-autofilled value gets
2640
+ * formatted/validated too.
2641
+ *
2642
+ * As-you-type caret note: `AsYouType` reformats the entire string, which
2643
+ * moves the caret to the end on a mid-string edit. To avoid fighting the
2644
+ * user, we only rewrite the value when the caret is at the end (the typical
2645
+ * append-a-digit case) and never on a deletion — so backspacing the
2646
+ * formatting characters works naturally.
2647
+ */
2648
+ wirePhoneField(form) {
2649
+ const input = form?.querySelector("input[name=\"phone\"]");
2650
+ if (!input) return;
2651
+ const hint = input.parentElement?.querySelector(".pc-hint");
2652
+ const updateHint = () => {
2653
+ const v = input.value.trim();
2654
+ const field = input.closest(".pc-field");
2655
+ if (!v) {
2656
+ field?.classList.remove("valid", "invalid");
2657
+ if (hint) hint.textContent = "";
2658
+ return;
2659
+ }
2660
+ const ok = isValidPhoneNumber(v, PHONE_DEFAULT_REGION);
2661
+ field?.classList.toggle("valid", ok);
2662
+ field?.classList.toggle("invalid", !ok);
2663
+ if (hint) hint.textContent = ok ? "" : "Enter a valid phone number";
2664
+ };
2665
+ const reformat = () => {
2666
+ if (input.selectionStart === input.value.length && input.selectionEnd === input.value.length) {
2667
+ const formatted = new AsYouType(PHONE_DEFAULT_REGION).input(input.value);
2668
+ if (formatted.length >= input.value.length) input.value = formatted;
2669
+ }
2670
+ updateHint();
2671
+ };
2672
+ input.addEventListener("input", (ev) => {
2673
+ const ie = ev;
2674
+ if (typeof ie.inputType === "string" && ie.inputType.startsWith("delete")) {
2675
+ updateHint();
2676
+ return;
2677
+ }
2678
+ reformat();
2679
+ });
2680
+ input.addEventListener("change", reformat);
2681
+ }
2569
2682
  /** Collect identity + consent from the pre-chat form, then drop into the chat view. */
2570
2683
  handlePrechatSubmit(form) {
2571
2684
  if (!form.reportValidity()) return;
2572
2685
  const data = new FormData(form);
2573
2686
  const val = (k) => data.get(k)?.trim() || void 0;
2574
2687
  const checked = (k) => data.get(k) === "on";
2688
+ const rawPhone = val("phone");
2689
+ const phoneInput = form.querySelector("input[name=\"phone\"]");
2690
+ if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
2691
+ const required = phoneInput.hasAttribute("required");
2692
+ const field = phoneInput.closest(".pc-field");
2693
+ field?.classList.add("invalid");
2694
+ const hint = field?.querySelector(".pc-hint");
2695
+ if (hint) hint.textContent = "Enter a valid phone number";
2696
+ if (required) {
2697
+ phoneInput.focus();
2698
+ return;
2699
+ }
2700
+ }
2701
+ const phone = rawPhone ? phoneToE164(rawPhone) ?? rawPhone : void 0;
2575
2702
  this.controller?.setUserInfo({
2576
2703
  name: val("name"),
2577
2704
  email: val("email"),
2578
- phone: val("phone"),
2705
+ phone,
2579
2706
  consent: {
2580
2707
  emailOptIn: checked("emailOptIn"),
2581
2708
  smsOptIn: checked("smsOptIn")