@smooai/chat-widget 0.9.0 → 0.10.1

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