@smooai/chat-widget 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
2
+ import { createStore } from "zustand/vanilla";
3
+ import { persist } from "zustand/middleware";
2
4
  //#region src/config.ts
3
5
  /** Resolve a partial config against the built-in defaults. */
4
6
  function resolveConfig(config) {
@@ -17,6 +19,7 @@ function resolveConfig(config) {
17
19
  userName: config.userName,
18
20
  userEmail: config.userEmail,
19
21
  userPhone: config.userPhone,
22
+ authContext: config.authContext,
20
23
  placeholder: config.placeholder ?? "Type a message…",
21
24
  greeting: config.greeting ?? "Hi! How can I help you today?",
22
25
  connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
@@ -25,6 +28,9 @@ function resolveConfig(config) {
25
28
  requireName: config.requireName ?? false,
26
29
  requireEmail: config.requireEmail ?? false,
27
30
  requirePhone: config.requirePhone ?? false,
31
+ collectPhone: config.collectPhone ?? true,
32
+ collectConsent: config.collectConsent ?? true,
33
+ allowChatRestore: config.allowChatRestore ?? true,
28
34
  allowAnonymous: config.allowAnonymous ?? false,
29
35
  theme: {
30
36
  text: theme.text ?? "#f8fafc",
@@ -48,6 +54,293 @@ function needsUserInfo(resolved) {
48
54
  return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
49
55
  }
50
56
  //#endregion
57
+ //#region src/fingerprint.ts
58
+ /**
59
+ * Browser fingerprint — a stable, privacy-light identifier used by the
60
+ * smooth-operator server to correlate an anonymous visitor's sessions across
61
+ * page loads (and, server-side, to match an anonymous fingerprint to a known
62
+ * CRM contact). It rides every `create_conversation_session` as
63
+ * `browserFingerprint` (see ADR-048).
64
+ *
65
+ * ## Why not ThumbmarkJS
66
+ *
67
+ * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
68
+ * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
69
+ * full build pulls in extensive device-detection tables and async
70
+ * component collection, adding tens of kilobytes (and async surface) to a
71
+ * bundle whose whole selling point is staying out of the host page's
72
+ * LCP/TBT budget. The fingerprint here is a few hundred bytes.
73
+ *
74
+ * ## What this is (and the tradeoff)
75
+ *
76
+ * The correlation that actually matters — "is this the same browser as last
77
+ * time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
78
+ * field in the persisted store). That UUID is generated once and reused for
79
+ * the life of the localStorage entry, so same-browser resume is exact and
80
+ * deterministic. The signal hash below is a best-effort entropy *supplement*
81
+ * mixed into that UUID's derivation seed on first generation — it gives the
82
+ * server-side resolver a soft signal for the (rare) cross-storage case where
83
+ * the UUID was cleared but the device is unchanged. It is intentionally NOT a
84
+ * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
85
+ * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
86
+ * cross-storage matching in exchange for a tiny, transparent, no-network,
87
+ * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
88
+ * of truth for any fuzzy matching; the client just supplies a stable token.
89
+ */
90
+ /** Collect a small set of stable, non-invasive browser signals. */
91
+ function collectSignals() {
92
+ const parts = [];
93
+ try {
94
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
95
+ if (nav) {
96
+ parts.push(`ua:${nav.userAgent ?? ""}`);
97
+ parts.push(`lang:${nav.language ?? ""}`);
98
+ parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
99
+ parts.push(`plat:${nav.platform ?? ""}`);
100
+ parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
101
+ parts.push(`dm:${nav.deviceMemory ?? ""}`);
102
+ }
103
+ if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
104
+ if (typeof Intl !== "undefined") try {
105
+ parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
106
+ } catch {}
107
+ parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
108
+ } catch {}
109
+ return parts.join("|");
110
+ }
111
+ /**
112
+ * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
113
+ * an unsigned hex string. Stable across runs for the same input. Not used for
114
+ * any security decision — only to derive a deterministic seed from the signal
115
+ * string above.
116
+ */
117
+ function fnv1a(input) {
118
+ let h = 2166136261;
119
+ for (let i = 0; i < input.length; i++) {
120
+ h ^= input.charCodeAt(i);
121
+ h = Math.imul(h, 16777619);
122
+ }
123
+ return (h >>> 0).toString(16).padStart(8, "0");
124
+ }
125
+ /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
126
+ function generateUuid() {
127
+ try {
128
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
129
+ } catch {}
130
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
131
+ const r = Math.random() * 16 | 0;
132
+ return (c === "x" ? r : r & 3 | 8).toString(16);
133
+ });
134
+ }
135
+ /**
136
+ * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
137
+ * FNV hash of the device signals so the server can recover a soft device
138
+ * signal even if it only has the token. Called ONCE per browser (the result is
139
+ * persisted and reused) — see {@link getOrCreateFingerprint}.
140
+ */
141
+ function computeFingerprint() {
142
+ const signalHash = fnv1a(collectSignals());
143
+ return `${generateUuid()}.${signalHash}`;
144
+ }
145
+ /**
146
+ * Return the cached fingerprint if one was already computed for this browser,
147
+ * otherwise compute + persist a new one via the provided accessors. The
148
+ * persistence layer owns storage; this function owns the "compute once" policy.
149
+ */
150
+ function getOrCreateFingerprint(get, set) {
151
+ const existing = get();
152
+ if (existing) return existing;
153
+ const fp = computeFingerprint();
154
+ set(fp);
155
+ return fp;
156
+ }
157
+ //#endregion
158
+ //#region src/persistence.ts
159
+ /**
160
+ * Persisted widget state — the identity / consent / session-pointer client layer
161
+ * (ADR-048, SMOODEV-2129e).
162
+ *
163
+ * Built on Zustand's framework-agnostic `vanilla` store + the `persist`
164
+ * middleware (the user explicitly chose Zustand; it's ~1KB and has no React
165
+ * dependency, so it works inside the web component). The store is keyed per
166
+ * agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
167
+ * on the same origin don't clobber each other.
168
+ *
169
+ * ## What persists (and, deliberately, what does NOT)
170
+ *
171
+ * Only a **pointer** to the conversation plus the visitor's identity + marketing
172
+ * consent + verified email + browser fingerprint. The transcript is **never**
173
+ * persisted: the smooth-operator server is the source of truth and the widget
174
+ * re-hydrates history via `getMessages` on resume. This keeps localStorage small
175
+ * and avoids stale/divergent transcripts.
176
+ *
177
+ * `version` drives `persist.migrate` so future shape changes can upgrade old
178
+ * blobs in place instead of silently dropping them.
179
+ */
180
+ /** Current persisted-shape version. Bump when the shape below changes incompatibly. */
181
+ const PERSIST_VERSION = 1;
182
+ const EMPTY_CONSENT = {
183
+ emailOptIn: false,
184
+ smsOptIn: false
185
+ };
186
+ function initialPersisted() {
187
+ return {
188
+ version: 1,
189
+ sessionId: null,
190
+ identity: {},
191
+ consent: { ...EMPTY_CONSENT },
192
+ verifiedEmail: null,
193
+ verifiedEmailSessionId: null,
194
+ browserFingerprint: null
195
+ };
196
+ }
197
+ /** localStorage key for an agent's persisted widget state. */
198
+ function storageKey(agentId) {
199
+ return `smoo-chat-widget:${agentId}`;
200
+ }
201
+ /**
202
+ * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
203
+ * real localStorage is unavailable.
204
+ *
205
+ * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
206
+ * zustand v5's `persist` treats a missing `storage` option by falling back to its
207
+ * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
208
+ * localStorage the guard was trying to avoid (throwing again in privacy mode).
209
+ * Handing it this no-op storage keeps the store working purely in memory and
210
+ * guarantees the fallback can't touch real localStorage.
211
+ */
212
+ function memoryStorage() {
213
+ const mem = /* @__PURE__ */ new Map();
214
+ return {
215
+ getItem: (name) => {
216
+ const raw = mem.get(name);
217
+ if (!raw) return null;
218
+ try {
219
+ return JSON.parse(raw);
220
+ } catch {
221
+ return null;
222
+ }
223
+ },
224
+ setItem: (name, value) => {
225
+ mem.set(name, JSON.stringify(value));
226
+ },
227
+ removeItem: (name) => {
228
+ mem.delete(name);
229
+ }
230
+ };
231
+ }
232
+ /**
233
+ * A `persist` storage adapter that tolerates the *absence* of localStorage
234
+ * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
235
+ * unavailable the store still works in-memory; nothing is persisted, but the
236
+ * widget never throws on boot.
237
+ */
238
+ function safeStorage() {
239
+ let ls = null;
240
+ try {
241
+ ls = typeof localStorage !== "undefined" ? localStorage : null;
242
+ if (ls) {
243
+ const probe = "__smoo_probe__";
244
+ ls.setItem(probe, "1");
245
+ ls.removeItem(probe);
246
+ }
247
+ } catch {
248
+ ls = null;
249
+ }
250
+ if (!ls) return memoryStorage();
251
+ const storage = ls;
252
+ return {
253
+ getItem: (name) => {
254
+ try {
255
+ const raw = storage.getItem(name);
256
+ return raw ? JSON.parse(raw) : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ },
261
+ setItem: (name, value) => {
262
+ try {
263
+ storage.setItem(name, JSON.stringify(value));
264
+ } catch {}
265
+ },
266
+ removeItem: (name) => {
267
+ try {
268
+ storage.removeItem(name);
269
+ } catch {}
270
+ }
271
+ };
272
+ }
273
+ /**
274
+ * Migrate a persisted blob from an older `version` to the current shape. Today
275
+ * v1 is the only version, so this just backfills any missing fields onto an
276
+ * unknown old blob; future versions add `case` branches here.
277
+ */
278
+ function migrate(persisted) {
279
+ const base = initialPersisted();
280
+ if (!persisted || typeof persisted !== "object") return base;
281
+ const p = persisted;
282
+ return {
283
+ version: 1,
284
+ sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
285
+ identity: {
286
+ name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
287
+ email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
288
+ phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
289
+ },
290
+ consent: {
291
+ emailOptIn: p.consent?.emailOptIn === true,
292
+ smsOptIn: p.consent?.smsOptIn === true,
293
+ consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
294
+ consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
295
+ },
296
+ verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
297
+ verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
298
+ browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
299
+ };
300
+ }
301
+ /**
302
+ * Create the per-agent persisted Zustand store. The `partialize` step ensures
303
+ * only the persisted shape (never any future transient action/UI state) is
304
+ * written to localStorage.
305
+ */
306
+ function createWidgetStore(agentId) {
307
+ return createStore()(persist((set) => ({
308
+ ...initialPersisted(),
309
+ setSessionId: (sessionId) => set({ sessionId }),
310
+ mergeIdentity: (identity) => set((state) => ({ identity: {
311
+ ...state.identity,
312
+ ...identity.name !== void 0 ? { name: identity.name } : {},
313
+ ...identity.email !== void 0 ? { email: identity.email } : {},
314
+ ...identity.phone !== void 0 ? { phone: identity.phone } : {}
315
+ } })),
316
+ setConsent: (consent) => set({ consent: { ...consent } }),
317
+ setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
318
+ verifiedEmail,
319
+ verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
320
+ })),
321
+ setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
322
+ clearSession: () => set({
323
+ sessionId: null,
324
+ verifiedEmail: null,
325
+ verifiedEmailSessionId: null
326
+ })
327
+ }), {
328
+ name: storageKey(agentId),
329
+ version: 1,
330
+ storage: safeStorage(),
331
+ migrate,
332
+ partialize: (state) => ({
333
+ version: 1,
334
+ sessionId: state.sessionId,
335
+ identity: state.identity,
336
+ consent: state.consent,
337
+ verifiedEmail: state.verifiedEmail,
338
+ verifiedEmailSessionId: state.verifiedEmailSessionId,
339
+ browserFingerprint: state.browserFingerprint
340
+ })
341
+ }));
342
+ }
343
+ //#endregion
51
344
  //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
52
345
  function _typeof(o) {
53
346
  "@babel/helpers - typeof";
@@ -97,13 +390,41 @@ function _defineProperty(e, r, t) {
97
390
  * so the swap is purely at the client-library boundary.
98
391
  *
99
392
  * Flow:
100
- * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
393
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
394
+ * (or RESUMES a persisted session via `get_session`/`get_messages`).
101
395
  * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
102
396
  * in-progress assistant message, then the terminal
103
397
  * `eventual_response`.
104
398
  *
399
+ * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
400
+ * - `browserFingerprint` computed once + sent on every `create_conversation_session`.
401
+ * - identity + marketing consent threaded into the session `metadata`.
402
+ * - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
403
+ * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
404
+ * cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
405
+ * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
406
+ * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
407
+ * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
408
+ * NOT WS frames.
409
+ *
105
410
  * The controller is UI-agnostic: it emits typed events and the view renders them.
106
411
  */
412
+ /**
413
+ * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
414
+ * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
415
+ * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
416
+ * the cross-device identity flow + fingerprint resume are HTTP POST routes on the
417
+ * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
418
+ */
419
+ function httpBaseFromWsEndpoint(endpoint) {
420
+ try {
421
+ const u = new URL(endpoint);
422
+ u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
423
+ return `${u.protocol}//${u.host}`;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
107
428
  /** Pull the final assistant text out of an `eventual_response` data payload. */
108
429
  function extractFinalText(response) {
109
430
  if (!response || typeof response !== "object") return null;
@@ -144,40 +465,89 @@ function extractCitations(inner) {
144
465
  }
145
466
  return out;
146
467
  }
468
+ /** Convert a server message row into a finalized {@link ChatMessage}. */
469
+ function wireMessageToChat(m, idx) {
470
+ const text = typeof m.content?.text === "string" ? m.content.text : "";
471
+ if (!text) return null;
472
+ const role = m.direction === "outbound" ? "assistant" : "user";
473
+ return {
474
+ id: typeof m.id === "string" ? m.id : `hist-${idx}`,
475
+ role,
476
+ text,
477
+ streaming: false
478
+ };
479
+ }
147
480
  var ConversationController = class {
148
- constructor(config, events) {
481
+ constructor(config, events, store) {
149
482
  _defineProperty(this, "config", void 0);
150
483
  _defineProperty(this, "events", void 0);
484
+ _defineProperty(this, "store", void 0);
151
485
  _defineProperty(this, "client", null);
152
486
  _defineProperty(this, "sessionId", null);
153
487
  _defineProperty(this, "messages", []);
154
488
  _defineProperty(this, "status", "idle");
155
489
  _defineProperty(this, "seq", 0);
156
- _defineProperty(this, "identity", void 0);
157
490
  _defineProperty(this, "activeRequestId", null);
158
491
  _defineProperty(this, "interrupt", null);
492
+ _defineProperty(this, "identityRestore", { phase: "idle" });
493
+ _defineProperty(this, "resumeAttempted", false);
494
+ _defineProperty(this, "httpBase", void 0);
159
495
  this.config = config;
160
496
  this.events = events;
161
- this.identity = {
162
- name: config.userName,
163
- email: config.userEmail,
164
- phone: config.userPhone
165
- };
497
+ this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
498
+ if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
499
+ this.store = store ?? createWidgetStore(config.agentId);
500
+ const seed = {};
501
+ if (config.userName) seed.name = config.userName;
502
+ if (config.userEmail) seed.email = config.userEmail;
503
+ if (config.userPhone) seed.phone = config.userPhone;
504
+ if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
166
505
  }
167
506
  get connectionStatus() {
168
507
  return this.status;
169
508
  }
170
- /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
509
+ /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
510
+ getStore() {
511
+ return this.store;
512
+ }
513
+ /** True when a persisted session pointer exists (drives the resume path). */
514
+ hasPersistedSession() {
515
+ return !!this.store.getState().sessionId;
516
+ }
517
+ /** True when persisted identity exists (lets the view skip the pre-chat form). */
518
+ hasPersistedIdentity() {
519
+ const id = this.store.getState().identity;
520
+ return !!(id.name || id.email || id.phone);
521
+ }
522
+ /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
171
523
  setUserInfo(info) {
172
- this.identity = {
173
- ...this.identity,
174
- ...info
175
- };
524
+ const { name, email, phone, consent } = info;
525
+ this.store.getState().mergeIdentity({
526
+ name,
527
+ email,
528
+ phone
529
+ });
530
+ if (consent) {
531
+ const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
532
+ this.store.getState().setConsent({
533
+ emailOptIn: consent.emailOptIn,
534
+ smsOptIn: consent.smsOptIn,
535
+ consentSource: "chat-widget-prechat",
536
+ consentAt
537
+ });
538
+ }
176
539
  }
177
540
  setInterrupt(interrupt) {
178
541
  this.interrupt = interrupt;
179
542
  this.events.onInterrupt?.(interrupt);
180
543
  }
544
+ setIdentityRestore(state) {
545
+ this.identityRestore = state;
546
+ this.events.onIdentityRestore?.(state);
547
+ }
548
+ get currentIdentityRestore() {
549
+ return this.identityRestore;
550
+ }
181
551
  /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
182
552
  verifyOtp(code) {
183
553
  if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
@@ -208,20 +578,95 @@ var ConversationController = class {
208
578
  emitMessages() {
209
579
  this.events.onMessages(this.messages.map((m) => ({ ...m })));
210
580
  }
211
- /** Open the transport and create a conversation session. Idempotent. */
581
+ /** Compute (once) + return the persisted browser fingerprint. */
582
+ fingerprint() {
583
+ const state = this.store.getState();
584
+ return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
585
+ }
586
+ /**
587
+ * Build the `metadata` payload threaded into `create_conversation_session`:
588
+ * phone (no first-class engine field) and consent.
589
+ *
590
+ * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
591
+ * OTP proof bound to the session it was verified against
592
+ * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
593
+ * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
594
+ * every brand-new `create_conversation_session` would mislabel a fresh
595
+ * visitor's session with a prior (possibly different) visitor's email on a
596
+ * shared browser. The verified email is only used when RESUMING the exact
597
+ * session it was proven for — see {@link verifiedEmailForSession}.
598
+ */
599
+ sessionMetadata() {
600
+ const state = this.store.getState();
601
+ const meta = {};
602
+ if (state.identity.phone) meta.userPhone = state.identity.phone;
603
+ const consent = state.consent;
604
+ if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
605
+ const c = {
606
+ emailOptIn: consent.emailOptIn,
607
+ smsOptIn: consent.smsOptIn,
608
+ consentSource: consent.consentSource ?? "chat-widget-prechat"
609
+ };
610
+ if (consent.consentAt) c.consentAt = consent.consentAt;
611
+ meta.consent = c;
612
+ }
613
+ return Object.keys(meta).length > 0 ? meta : void 0;
614
+ }
615
+ /**
616
+ * The verified-email hint, but ONLY when the OTP proof is bound to the session
617
+ * being resumed (`verifiedEmailSessionId === sessionId`). Returns null
618
+ * otherwise so a stale/cross-visitor proof is never threaded onto a session it
619
+ * wasn't proven for.
620
+ */
621
+ verifiedEmailForSession(sessionId) {
622
+ const state = this.store.getState();
623
+ if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
624
+ return null;
625
+ }
626
+ /** Lazily open the WS client (default transport). Idempotent within a connect. */
627
+ async ensureClient() {
628
+ if (this.client) return;
629
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
630
+ await this.client.connect();
631
+ }
632
+ /**
633
+ * Open the connection and either RESUME or create a session.
634
+ *
635
+ * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
636
+ * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
637
+ * ONLY the pointer (identity/consent survive).
638
+ * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
639
+ * `resumable`, adopt the returned session (the wrapper has primed the
640
+ * operator registry), reuse the sessionId, and hydrate via get_session/
641
+ * get_messages — rather than relying on createConversationSession to resume.
642
+ * 3. Otherwise create a fresh session.
643
+ */
212
644
  async connect() {
213
645
  if (this.status === "connecting" || this.status === "ready") return;
214
646
  this.setStatus("connecting");
215
647
  try {
216
- this.client = new SmoothAgentClient({ url: this.config.endpoint });
217
- await this.client.connect();
218
- const session = await this.client.createConversationSession({
219
- agentId: this.config.agentId,
220
- userName: this.identity.name,
221
- userEmail: this.identity.email,
222
- ...this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}
223
- });
224
- this.sessionId = session.sessionId;
648
+ await this.ensureClient();
649
+ if (!this.resumeAttempted) {
650
+ this.resumeAttempted = true;
651
+ const persistedSessionId = this.store.getState().sessionId;
652
+ if (persistedSessionId) {
653
+ if (await this.tryResume(persistedSessionId)) {
654
+ this.setStatus("ready");
655
+ return;
656
+ }
657
+ this.store.getState().clearSession();
658
+ } else {
659
+ const fpSessionId = await this.resumeByFingerprint();
660
+ if (fpSessionId) {
661
+ if (await this.tryResume(fpSessionId)) {
662
+ this.store.getState().setSessionId(fpSessionId);
663
+ this.setStatus("ready");
664
+ return;
665
+ }
666
+ }
667
+ }
668
+ }
669
+ await this.createSession();
225
670
  this.setStatus("ready");
226
671
  } catch (err) {
227
672
  this.setStatus("error", err instanceof Error ? err.message : String(err));
@@ -229,6 +674,105 @@ var ConversationController = class {
229
674
  }
230
675
  }
231
676
  /**
677
+ * Build the auth fields every `/internal/*` route shares: `agentId` (required
678
+ * for the agent-policy lookup), `agentName` (used as the OTP email sender), and
679
+ * the optional pre-auth `authContext` the host page may have configured. The
680
+ * `Origin` header is sent automatically by the browser and checked server-side.
681
+ */
682
+ authBody() {
683
+ const body = { agentId: this.config.agentId };
684
+ if (this.config.agentName) body.agentName = this.config.agentName;
685
+ if (this.config.authContext) body.authContext = this.config.authContext;
686
+ return body;
687
+ }
688
+ /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
689
+ async postInternal(path, payload) {
690
+ if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
691
+ const res = await fetch(`${this.httpBase}${path}`, {
692
+ method: "POST",
693
+ headers: { "content-type": "application/json" },
694
+ credentials: "omit",
695
+ body: JSON.stringify({
696
+ ...this.authBody(),
697
+ ...payload
698
+ })
699
+ });
700
+ let json = {};
701
+ try {
702
+ json = await res.json();
703
+ } catch {
704
+ json = {};
705
+ }
706
+ if (!res.ok) {
707
+ const err = json.error;
708
+ throw new Error(err?.message ?? `${path} failed (${res.status})`);
709
+ }
710
+ return json;
711
+ }
712
+ /**
713
+ * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
714
+ * the wrapper found (and primed) a recent session for this fingerprint, else
715
+ * null. Network/route failures are swallowed → null (fall through to create).
716
+ */
717
+ async resumeByFingerprint() {
718
+ try {
719
+ const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
720
+ if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
721
+ } catch {}
722
+ return null;
723
+ }
724
+ /** `create_conversation_session` with fingerprint + identity + consent metadata. */
725
+ async createSession() {
726
+ if (!this.client) throw new Error("Conversation is not connected");
727
+ const state = this.store.getState();
728
+ const metadata = this.sessionMetadata();
729
+ const session = await this.client.createConversationSession({
730
+ agentId: this.config.agentId,
731
+ userName: state.identity.name,
732
+ userEmail: state.identity.email,
733
+ browserFingerprint: this.fingerprint(),
734
+ ...metadata ? { metadata } : {}
735
+ });
736
+ this.sessionId = session.sessionId;
737
+ this.store.getState().setSessionId(session.sessionId);
738
+ }
739
+ /**
740
+ * Attempt to resume `sessionId`: returns true and hydrates the transcript when
741
+ * the session is live, false when it has ended / can't be fetched.
742
+ */
743
+ async tryResume(sessionId) {
744
+ if (!this.client) return false;
745
+ let snap;
746
+ try {
747
+ snap = await this.client.getSession({ sessionId });
748
+ } catch {
749
+ return false;
750
+ }
751
+ if (snap.status === "ended") return false;
752
+ this.sessionId = sessionId;
753
+ await this.hydrateHistory(sessionId);
754
+ return true;
755
+ }
756
+ /** Page recent history (newest-first), reverse to chronological, and render. */
757
+ async hydrateHistory(sessionId) {
758
+ if (!this.client) return;
759
+ try {
760
+ const page = await this.client.getMessages({
761
+ sessionId,
762
+ limit: 50
763
+ });
764
+ const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
765
+ const hydrated = [];
766
+ chronological.forEach((m, i) => {
767
+ const chat = wireMessageToChat(m, i);
768
+ if (chat) hydrated.push(chat);
769
+ });
770
+ this.messages.length = 0;
771
+ this.messages.push(...hydrated);
772
+ this.emitMessages();
773
+ } catch {}
774
+ }
775
+ /**
232
776
  * Submit a user message. Appends the user bubble immediately, then streams the
233
777
  * assistant reply token-by-token, finalizing on `eventual_response`.
234
778
  */
@@ -330,12 +874,170 @@ var ConversationController = class {
330
874
  default: break;
331
875
  }
332
876
  }
877
+ /**
878
+ * Begin the cross-device restore: POST `/internal/identity/request-otp` for
879
+ * `email` over `channel`. The view collects the email via an explicit affordance.
880
+ */
881
+ async requestIdentityOtp(email, channel = "email") {
882
+ const trimmed = email.trim();
883
+ if (!trimmed) return;
884
+ this.setIdentityRestore({
885
+ phase: "requesting",
886
+ email: trimmed,
887
+ channel
888
+ });
889
+ if (!this.sessionId) try {
890
+ await this.connect();
891
+ } catch {}
892
+ if (!this.sessionId) {
893
+ this.setIdentityRestore({
894
+ phase: "error",
895
+ message: "No active session to verify against."
896
+ });
897
+ return;
898
+ }
899
+ try {
900
+ const json = await this.postInternal("/internal/identity/request-otp", {
901
+ sessionId: this.sessionId,
902
+ email: trimmed,
903
+ channel
904
+ });
905
+ const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
906
+ this.setIdentityRestore({
907
+ phase: "awaiting_code",
908
+ email: trimmed,
909
+ channel,
910
+ maskedDestination: masked
911
+ });
912
+ } catch (err) {
913
+ this.setIdentityRestore({
914
+ phase: "error",
915
+ message: err instanceof Error ? err.message : "Could not send a verification code."
916
+ });
917
+ }
918
+ }
919
+ /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
920
+ async verifyIdentityOtp(code) {
921
+ const state = this.identityRestore;
922
+ const trimmed = code.trim();
923
+ if (!trimmed || state.phase !== "awaiting_code") return;
924
+ const { email, channel } = state;
925
+ if (!this.sessionId) {
926
+ this.setIdentityRestore({
927
+ phase: "error",
928
+ message: "No active session to verify against."
929
+ });
930
+ return;
931
+ }
932
+ this.setIdentityRestore({
933
+ phase: "verifying",
934
+ email,
935
+ channel
936
+ });
937
+ try {
938
+ const json = await this.postInternal("/internal/identity/verify-otp", {
939
+ sessionId: this.sessionId,
940
+ email,
941
+ code: trimmed
942
+ });
943
+ if (json.event === "otp_verified") {
944
+ this.store.getState().setVerifiedEmail(email, this.sessionId);
945
+ await this.resolveIdentity(email);
946
+ } else if (json.event === "otp_invalid") {
947
+ const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
948
+ this.setIdentityRestore({
949
+ phase: "awaiting_code",
950
+ email,
951
+ channel,
952
+ error: "That code was incorrect.",
953
+ attemptsRemaining: remaining
954
+ });
955
+ } else this.setIdentityRestore({
956
+ phase: "error",
957
+ message: "Verification failed."
958
+ });
959
+ } catch (err) {
960
+ this.setIdentityRestore({
961
+ phase: "error",
962
+ message: err instanceof Error ? err.message : "Verification failed."
963
+ });
964
+ }
965
+ }
966
+ /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
967
+ async resolveIdentity(email) {
968
+ if (!this.sessionId) return;
969
+ this.setIdentityRestore({
970
+ phase: "resolving",
971
+ email
972
+ });
973
+ try {
974
+ const json = await this.postInternal("/internal/identity/resolve", {
975
+ sessionId: this.sessionId,
976
+ email
977
+ });
978
+ if (json.resolved !== true) {
979
+ this.setIdentityRestore({
980
+ phase: "resolved",
981
+ email,
982
+ conversations: []
983
+ });
984
+ return;
985
+ }
986
+ const raw = json.conversations;
987
+ const conversations = Array.isArray(raw) ? raw.map((c) => {
988
+ if (!c || typeof c !== "object") return null;
989
+ const o = c;
990
+ const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
991
+ const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
992
+ if (!sessionId) return null;
993
+ return {
994
+ conversationId,
995
+ sessionId,
996
+ lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
997
+ preview: typeof o.preview === "string" ? o.preview : void 0
998
+ };
999
+ }).filter((c) => c !== null) : [];
1000
+ this.setIdentityRestore({
1001
+ phase: "resolved",
1002
+ email,
1003
+ conversations
1004
+ });
1005
+ } catch (err) {
1006
+ this.setIdentityRestore({
1007
+ phase: "error",
1008
+ message: err instanceof Error ? err.message : "Could not load your chats."
1009
+ });
1010
+ }
1011
+ }
1012
+ /**
1013
+ * Replay a chosen restorable conversation: point the live session at its
1014
+ * sessionId, hydrate its transcript (get_session + get_messages), and persist
1015
+ * the new pointer so the next `sendMessage` continues it.
1016
+ */
1017
+ async restoreConversation(sessionId) {
1018
+ if (!this.client) await this.ensureClient();
1019
+ const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
1020
+ if (await this.tryResume(sessionId)) {
1021
+ this.store.getState().setSessionId(sessionId);
1022
+ if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
1023
+ this.setIdentityRestore({ phase: "idle" });
1024
+ this.setStatus("ready");
1025
+ } else this.setIdentityRestore({
1026
+ phase: "error",
1027
+ message: "That conversation is no longer available."
1028
+ });
1029
+ }
1030
+ /** Dismiss the cross-device restore panel. */
1031
+ cancelIdentityRestore() {
1032
+ this.setIdentityRestore({ phase: "idle" });
1033
+ }
333
1034
  /** Tear down the underlying client. */
334
1035
  disconnect() {
335
1036
  this.client?.disconnect("widget closed");
336
1037
  this.client = null;
337
1038
  this.sessionId = null;
338
1039
  this.activeRequestId = null;
1040
+ this.resumeAttempted = false;
339
1041
  this.setInterrupt(null);
340
1042
  this.setStatus("closed");
341
1043
  }
@@ -351,6 +1053,305 @@ var ConversationController = class {
351
1053
  */
352
1054
  const SMOOTH_LOGO_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 550 135\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient-3);\n }\n\n .cls-2 {\n fill: url(#linear-gradient-2);\n }\n\n .cls-3 {\n fill: url(#linear-gradient);\n fill-rule: evenodd;\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"115.59\" y1=\"112.81\" x2=\"25.08\" y2=\"22.3\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".3\" stop-color=\"#f49f0a\"/>\n <stop offset=\".79\" stop-color=\"#fb7a4d\"/>\n <stop offset=\"1\" stop-color=\"#ff6b6c\"/>\n </linearGradient>\n <linearGradient id=\"linear-gradient-2\" x1=\"360.91\" y1=\"152.01\" x2=\"202.32\" y2=\"-6.59\" xlink:href=\"#linear-gradient\"/>\n <linearGradient id=\"linear-gradient-3\" x1=\"443.91\" y1=\"30.15\" x2=\"531.36\" y2=\"117.59\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-3\" d=\"M48.28,14.96c-12.39,5.21-22.54,14.64-28.65,26.61-6.12,11.97-7.8,25.72-4.77,38.81,3.04,13.09,10.6,24.69,21.36,32.75,10.76,8.06,24.02,12.05,37.44,11.28,13.42-.77,26.13-6.26,35.9-15.5,9.76-9.24,15.95-21.63,17.46-34.99,1.51-13.36-1.74-26.82-9.19-38.01-1.07-1.61-.64-3.78.97-4.85,1.61-1.07,3.78-.64,4.85.97,8.36,12.56,12.02,27.68,10.32,42.67-1.7,15-8.64,28.91-19.61,39.28-10.96,10.37-25.24,16.54-40.31,17.4-15.07.87-29.96-3.62-42.04-12.66-12.08-9.05-20.58-22.07-23.99-36.77-3.41-14.7-1.51-30.14,5.35-43.58,6.87-13.44,18.26-24.02,32.17-29.87,13.91-5.85,29.44-6.6,43.85-2.11,1.85.57,2.88,2.54,2.3,4.38-.57,1.85-2.54,2.88-4.38,2.3-12.83-4-26.67-3.33-39.06,1.88ZM111.39,19.75c0,2.07-1.68,3.75-3.75,3.75s-3.75-1.68-3.75-3.75,1.68-3.75,3.75-3.75,3.75,1.68,3.75,3.75ZM64.64,59.93c0,1.91,2.39,2.56,7.69,3.88,3.89.97,6.6,2.18,8.15,3.63,1.53,1.45,2.29,3.53,2.29,6.25,0,3.57-1.03,6.26-3.11,8.08-2.07,1.82-5.09,2.73-9.09,2.73h-9.6c-1.97,0-3.57-1.6-3.59-3.57-.01-1.99,1.6-3.61,3.59-3.61h9.41c3.15-.12,4.79-.95,4.91-2.47,0-1.3-1.03-2.21-3.07-2.73-6.91-1.72-11.11-3.44-12.6-5.15-1.48-1.71-2.23-3.77-2.23-6.19,0-6.59,3.2-9.85,9.59-9.8h10.77c1.99,0,3.6,1.61,3.6,3.59s-1.61,3.59-3.6,3.59h-9.69c-1.83,0-3.43.06-3.43,1.77Z\"/>\n <path class=\"cls-2\" d=\"M205.52,48.44h-8.86c-.44-3.75-2.23-6.65-5.38-8.72-3.16-2.07-7.03-3.1-11.6-3.1h0c-3.35,0-6.27.54-8.78,1.62-2.49,1.09-4.44,2.59-5.84,4.48-1.39,1.89-2.08,4.05-2.08,6.46h0c0,2.01.49,3.75,1.46,5.2.97,1.44,2.22,2.63,3.74,3.58,1.53.95,3.13,1.72,4.8,2.32,1.68.6,3.22,1.09,4.62,1.46h0l7.68,2.06c1.97.52,4.17,1.23,6.6,2.14,2.43.92,4.75,2.16,6.98,3.72,2.23,1.56,4.07,3.56,5.52,6,1.45,2.44,2.18,5.43,2.18,8.98h0c0,4.08-1.07,7.77-3.2,11.08-2.12,3.29-5.22,5.91-9.3,7.86-4.08,1.95-9.02,2.92-14.82,2.92h0c-5.43,0-10.11-.87-14.06-2.62-3.95-1.75-7.05-4.19-9.3-7.32-2.25-3.12-3.53-6.75-3.84-10.88h9.46c.25,2.85,1.22,5.21,2.9,7.06,1.69,1.87,3.83,3.25,6.42,4.14,2.6.89,5.41,1.34,8.42,1.34h0c3.49,0,6.63-.57,9.4-1.72,2.79-1.13,4.99-2.73,6.62-4.8,1.63-2.05,2.44-4.46,2.44-7.22h0c0-2.51-.7-4.55-2.1-6.12-1.41-1.57-3.26-2.85-5.54-3.84-2.29-.99-4.77-1.85-7.44-2.58h0l-9.3-2.66c-5.91-1.71-10.59-4.13-14.04-7.28-3.44-3.16-5.16-7.29-5.16-12.38h0c0-4.23,1.15-7.93,3.46-11.1,2.29-3.16,5.39-5.62,9.3-7.38,3.91-1.76,8.27-2.64,13.08-2.64h0c4.88,0,9.21.87,13,2.6,3.8,1.73,6.81,4.11,9.04,7.12,2.23,3,3.4,6.41,3.52,10.22h0ZM229.16,105.18h-8.72v-56.74h8.42v8.86h.74c1.19-3.03,3.1-5.38,5.74-7.06,2.63-1.69,5.79-2.54,9.48-2.54h0c3.75,0,6.87.85,9.36,2.54,2.51,1.68,4.46,4.03,5.86,7.06h.58c1.45-2.92,3.63-5.25,6.54-7,2.91-1.73,6.39-2.6,10.46-2.6h0c5.07,0,9.21,1.58,12.44,4.74,3.23,3.17,4.84,8.09,4.84,14.76h0v37.98h-8.72v-37.98c0-4.19-1.14-7.18-3.42-8.98-2.29-1.79-4.99-2.68-8.1-2.68h0c-3.99,0-7.07,1.2-9.26,3.6-2.2,2.4-3.3,5.43-3.3,9.1h0v36.94h-8.86v-38.86c0-3.23-1.05-5.83-3.14-7.82-2.09-1.97-4.79-2.96-8.08-2.96h0c-2.27,0-4.38.6-6.34,1.8-1.96,1.21-3.53,2.88-4.72,5-1.2,2.13-1.8,4.59-1.8,7.38h0v35.46ZM333.9,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.86-5.85-9.02-10.24-2.15-4.37-3.22-9.49-3.22-15.36h0c0-5.91,1.07-11.07,3.22-15.48,2.16-4.4,5.17-7.82,9.02-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.15,4.41,3.22,9.57,3.22,15.48h0c0,5.87-1.07,10.99-3.22,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM333.9,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.89,0-7.09,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.51,1.99,5.71,2.98,9.6,2.98ZM395.94,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.85-5.85-9-10.24-2.16-4.37-3.24-9.49-3.24-15.36h0c0-5.91,1.08-11.07,3.24-15.48,2.15-4.4,5.15-7.82,9-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.16,4.41,3.24,9.57,3.24,15.48h0c0,5.87-1.08,10.99-3.24,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM395.94,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.88,0-7.08,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.52,1.99,5.72,2.98,9.6,2.98Z\"/>\n <path class=\"cls-1\" d=\"M467.88,48.02v13.28h-35.79v-13.28h35.79ZM439.68,34.38h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM506.59,72.63v32.71h-17.89V28.95h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
353
1055
  //#endregion
1056
+ //#region src/markdown.ts
1057
+ /**
1058
+ * A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
1059
+ *
1060
+ * ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
1061
+ *
1062
+ * The widget renders **untrusted** text in two places: the assistant's reply
1063
+ * (LLM output, which can echo attacker-supplied content) and citation snippets
1064
+ * (raw scraped page chunks). Today both are written via `textContent`, so
1065
+ * `**bold**`, numbered lists, and `[links](url)` show up literally. We want
1066
+ * them rendered — without re-opening the XSS hole that `textContent` was
1067
+ * guarding against.
1068
+ *
1069
+ * markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
1070
+ * what is an embeddable **global** bundle, where every kilobyte is on the host
1071
+ * page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
1072
+ * require bolting on a separate sanitizer. Instead, this renderer is
1073
+ * **safe-by-construction**:
1074
+ *
1075
+ * 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
1076
+ * fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
1077
+ * `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
1078
+ * tag out of the input — a literal `<script>` in the input is treated as
1079
+ * plain text.
1080
+ * 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
1081
+ * reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
1082
+ * 3. **Images are dropped entirely** — `![alt](src)` renders as its alt text,
1083
+ * no `<img>` is ever produced (a scraped tracking pixel must not load).
1084
+ * 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
1085
+ * URLs become anchors (with `target="_blank"` + a hardened `rel`);
1086
+ * `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
1087
+ * 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
1088
+ * `<h1>` is far too large inside a chat bubble or citation card.
1089
+ *
1090
+ * The output is a string of HTML that is only ever assigned to `innerHTML` of
1091
+ * an element the caller controls; because of (1)–(4) it can only contain the
1092
+ * allowlisted, attribute-sanitized tags.
1093
+ *
1094
+ * Supported subset (deliberately small):
1095
+ * - Paragraphs (blank-line separated) and hard/soft line breaks
1096
+ * - `**bold**` / `__bold__`, `*italic*` / `_italic_`
1097
+ * - `` `inline code` `` and fenced ``` ```code blocks``` ```
1098
+ * - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
1099
+ * - `> ` blockquotes
1100
+ * - `[text](http(s)://url)` links (images dropped to alt text)
1101
+ * - `#`..`######` headings → bold line
1102
+ */
1103
+ /** Escape the five HTML-significant characters so a text run can never be markup. */
1104
+ function escapeHtml(value) {
1105
+ return value.replace(/[&<>"']/g, (c) => {
1106
+ switch (c) {
1107
+ case "&": return "&amp;";
1108
+ case "<": return "&lt;";
1109
+ case ">": return "&gt;";
1110
+ case "\"": return "&quot;";
1111
+ default: return "&#39;";
1112
+ }
1113
+ });
1114
+ }
1115
+ /**
1116
+ * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
1117
+ *
1118
+ * SECURITY: link targets here originate from untrusted content (LLM output /
1119
+ * scraped citation chunks). Allowing an arbitrary string as an `href` permits
1120
+ * `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
1121
+ * vector. Only absolute http(s) links are rendered as anchors; anything else
1122
+ * falls back to plain text upstream.
1123
+ */
1124
+ function safeHttpUrl(url) {
1125
+ if (!url) return null;
1126
+ try {
1127
+ const parsed = new URL(url);
1128
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
1129
+ } catch {
1130
+ return null;
1131
+ }
1132
+ }
1133
+ /**
1134
+ * Render the inline span grammar of a single line/segment to safe HTML.
1135
+ *
1136
+ * Order matters: code spans are extracted first (their contents are *not*
1137
+ * further parsed), then images are stripped to alt text, then links, then
1138
+ * emphasis. Every literal text run is escaped on the way out.
1139
+ */
1140
+ function renderInline(input) {
1141
+ let out = "";
1142
+ let i = 0;
1143
+ const n = input.length;
1144
+ let buf = "";
1145
+ const flush = () => {
1146
+ if (buf) {
1147
+ out += escapeHtml(buf);
1148
+ buf = "";
1149
+ }
1150
+ };
1151
+ while (i < n) {
1152
+ const ch = input[i];
1153
+ if (ch === "`") {
1154
+ const end = input.indexOf("`", i + 1);
1155
+ if (end > i) {
1156
+ flush();
1157
+ out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
1158
+ i = end + 1;
1159
+ continue;
1160
+ }
1161
+ }
1162
+ if (ch === "!" && input[i + 1] === "[") {
1163
+ const m = imageAt(input, i);
1164
+ if (m) {
1165
+ flush();
1166
+ out += renderInline(m.alt);
1167
+ i = m.end;
1168
+ continue;
1169
+ }
1170
+ }
1171
+ if (ch === "[") {
1172
+ const m = linkAt(input, i);
1173
+ if (m) {
1174
+ flush();
1175
+ const safe = safeHttpUrl(m.href);
1176
+ const inner = renderInline(m.text);
1177
+ if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
1178
+ else out += inner;
1179
+ i = m.end;
1180
+ continue;
1181
+ }
1182
+ }
1183
+ if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
1184
+ const marker = ch + ch;
1185
+ const end = input.indexOf(marker, i + 2);
1186
+ if (end > i + 1) {
1187
+ flush();
1188
+ out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
1189
+ i = end + 2;
1190
+ continue;
1191
+ }
1192
+ }
1193
+ if (ch === "*" || ch === "_") {
1194
+ const end = input.indexOf(ch, i + 1);
1195
+ if (end > i + 1 && input[i + 1] !== ch) {
1196
+ flush();
1197
+ out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
1198
+ i = end + 1;
1199
+ continue;
1200
+ }
1201
+ }
1202
+ buf += ch;
1203
+ i++;
1204
+ }
1205
+ flush();
1206
+ return out;
1207
+ }
1208
+ /** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
1209
+ function linkAt(input, start) {
1210
+ const close = matchBracket(input, start);
1211
+ if (close < 0 || input[close + 1] !== "(") return null;
1212
+ const paren = input.indexOf(")", close + 2);
1213
+ if (paren < 0) return null;
1214
+ return {
1215
+ text: input.slice(start + 1, close),
1216
+ href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
1217
+ end: paren + 1
1218
+ };
1219
+ }
1220
+ /** Parse a `![alt](src)` image starting at `start` (`input[start] === '!'`). */
1221
+ function imageAt(input, start) {
1222
+ const link = linkAt(input, start + 1);
1223
+ if (!link) return null;
1224
+ return {
1225
+ alt: link.text,
1226
+ end: link.end
1227
+ };
1228
+ }
1229
+ /** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
1230
+ function matchBracket(input, open) {
1231
+ let depth = 0;
1232
+ for (let i = open; i < input.length; i++) {
1233
+ const c = input[i];
1234
+ if (c === "[") depth++;
1235
+ else if (c === "]") {
1236
+ depth--;
1237
+ if (depth === 0) return i;
1238
+ }
1239
+ }
1240
+ return -1;
1241
+ }
1242
+ const UL_RE = /^\s*[-*+]\s+(.*)$/;
1243
+ const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
1244
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
1245
+ const QUOTE_RE = /^\s*>\s?(.*)$/;
1246
+ const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
1247
+ /**
1248
+ * Render a full Markdown string to safe HTML.
1249
+ *
1250
+ * @returns a string containing only the allowlisted tags described in the
1251
+ * module doc. Safe to assign to `innerHTML` of a caller-owned element.
1252
+ */
1253
+ function renderMarkdown(src) {
1254
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
1255
+ const out = [];
1256
+ let i = 0;
1257
+ while (i < lines.length) {
1258
+ const line = lines[i];
1259
+ const fence = FENCE_RE.exec(line);
1260
+ if (fence) {
1261
+ const marker = fence[1];
1262
+ const body = [];
1263
+ i++;
1264
+ while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
1265
+ body.push(lines[i]);
1266
+ i++;
1267
+ }
1268
+ if (i < lines.length) i++;
1269
+ out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
1270
+ continue;
1271
+ }
1272
+ if (line.trim() === "") {
1273
+ i++;
1274
+ continue;
1275
+ }
1276
+ const heading = HEADING_RE.exec(line);
1277
+ if (heading) {
1278
+ out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
1279
+ i++;
1280
+ continue;
1281
+ }
1282
+ if (UL_RE.test(line) || OL_RE.test(line)) {
1283
+ const ordered = OL_RE.test(line) && !UL_RE.test(line);
1284
+ const re = ordered ? OL_RE : UL_RE;
1285
+ const items = [];
1286
+ while (i < lines.length) {
1287
+ const m = re.exec(lines[i]);
1288
+ if (!m) break;
1289
+ items.push(`<li>${renderInline(m[1])}</li>`);
1290
+ i++;
1291
+ }
1292
+ out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
1293
+ continue;
1294
+ }
1295
+ if (QUOTE_RE.test(line)) {
1296
+ const quoted = [];
1297
+ while (i < lines.length) {
1298
+ const m = QUOTE_RE.exec(lines[i]);
1299
+ if (!m) break;
1300
+ quoted.push(m[1]);
1301
+ i++;
1302
+ }
1303
+ out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
1304
+ continue;
1305
+ }
1306
+ const para = [];
1307
+ while (i < lines.length) {
1308
+ const l = lines[i];
1309
+ if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
1310
+ para.push(l);
1311
+ i++;
1312
+ }
1313
+ out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
1314
+ }
1315
+ return out.join("");
1316
+ }
1317
+ const SNIPPET_MAX = 260;
1318
+ /**
1319
+ * Clean a raw scraped citation snippet into a short, readable excerpt.
1320
+ *
1321
+ * Scraped chunks frequently begin with page boilerplate — a logo image wrapped
1322
+ * in a link, standalone nav, repeated whitespace — e.g.
1323
+ * `[![Logo](…)](…) # Our Work We build…`. The source itself is already linked
1324
+ * from the citation card, so the snippet only needs to be a clean teaser.
1325
+ *
1326
+ * Steps:
1327
+ * 1. Strip a leading image / logo-link (`[![…](…)](…)` or `![…](…)`).
1328
+ * 2. Drop a leading standalone heading marker (`#`/`##`).
1329
+ * 3. Collapse all runs of whitespace to single spaces.
1330
+ * 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
1331
+ *
1332
+ * The result is still rendered through {@link renderMarkdown} downstream, so any
1333
+ * remaining inline markup (bold/links) stays safe.
1334
+ */
1335
+ function cleanCitationSnippet(raw) {
1336
+ let s = raw ?? "";
1337
+ let changed = true;
1338
+ while (changed) {
1339
+ changed = false;
1340
+ const before = s;
1341
+ s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
1342
+ s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
1343
+ s = s.replace(/^\s*#{1,6}\s+/, "");
1344
+ if (s !== before) changed = true;
1345
+ }
1346
+ s = s.replace(/\s+/g, " ").trim();
1347
+ if (s.length > SNIPPET_MAX) {
1348
+ const cut = s.slice(0, SNIPPET_MAX);
1349
+ const lastSpace = cut.lastIndexOf(" ");
1350
+ s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
1351
+ }
1352
+ return s;
1353
+ }
1354
+ //#endregion
354
1355
  //#region src/styles.ts
355
1356
  /**
356
1357
  * Render the widget's scoped stylesheet — the "Aurora Glass" design system.
@@ -675,6 +1676,50 @@ function buildStyles(theme, mode = "popover") {
675
1676
  }
676
1677
  @keyframes sac-blink { to { opacity: 0 } }
677
1678
 
1679
+ /* ─────────────── Rendered markdown (assistant bubbles / snippets) ─────────── */
1680
+ /* The renderer (markdown.ts) emits a small allowlisted set of tags; these rules
1681
+ keep them legible inside the tight Aurora-Glass bubble + citation card. */
1682
+ /* Block-level markdown drives its own spacing/wrapping, so opt out of the
1683
+ bubble's pre-wrap (which would otherwise add stray blank lines). */
1684
+ .bubble.md { white-space: normal; }
1685
+ .md > :first-child { margin-top: 0; }
1686
+ .md > :last-child { margin-bottom: 0; }
1687
+ .md p { margin: 0 0 8px; }
1688
+ .md ul, .md ol { margin: 6px 0 8px; padding-left: 20px; }
1689
+ .md li { margin: 2px 0; }
1690
+ .md li::marker { color: color-mix(in srgb, var(--sac-primary) 75%, transparent); }
1691
+ .md a {
1692
+ color: color-mix(in srgb, var(--sac-primary) 92%, #fff);
1693
+ text-decoration: underline;
1694
+ text-underline-offset: 2px;
1695
+ word-break: break-word;
1696
+ }
1697
+ .md a:hover { text-decoration: none; }
1698
+ .md strong { font-weight: 700; }
1699
+ .md em { font-style: italic; }
1700
+ .md code {
1701
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1702
+ font-size: .9em;
1703
+ padding: 1px 5px;
1704
+ border-radius: 5px;
1705
+ background: color-mix(in srgb, var(--sac-text) 10%, transparent);
1706
+ }
1707
+ .md pre {
1708
+ margin: 6px 0 8px;
1709
+ padding: 9px 11px;
1710
+ border-radius: 9px;
1711
+ overflow-x: auto;
1712
+ background: color-mix(in srgb, var(--sac-text) 9%, transparent);
1713
+ border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent);
1714
+ }
1715
+ .md pre code { padding: 0; background: none; font-size: 12px; line-height: 1.45; }
1716
+ .md blockquote {
1717
+ margin: 6px 0;
1718
+ padding: 2px 0 2px 11px;
1719
+ border-left: 2px solid color-mix(in srgb, var(--sac-primary) 55%, transparent);
1720
+ color: color-mix(in srgb, var(--sac-text) 78%, transparent);
1721
+ }
1722
+
678
1723
  /* Full-page: center the conversation in a readable column. */
679
1724
  .panel.fullpage .messages { padding: 26px 20px; }
680
1725
  .panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; }
@@ -834,6 +1879,17 @@ function buildStyles(theme, mode = "popover") {
834
1879
  }
835
1880
  .pc-submit:hover { transform: translateY(-1px); }
836
1881
  .pc-submit:active { transform: scale(.98); }
1882
+ .pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
1883
+ .pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
1884
+ .pc-consent input {
1885
+ margin-top: 2px;
1886
+ width: 16px;
1887
+ height: 16px;
1888
+ flex: 0 0 auto;
1889
+ accent-color: var(--sac-primary);
1890
+ cursor: pointer;
1891
+ }
1892
+ .pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
837
1893
 
838
1894
  /* ─────────────────── Starter-prompt chips ─────────────────────────── */
839
1895
  .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
@@ -912,6 +1968,61 @@ function buildStyles(theme, mode = "popover") {
912
1968
  .int-row .int-btn { flex: 1; }
913
1969
  .int-row .int-input + .int-btn { flex: 0 0 auto; }
914
1970
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
1971
+ .int-card { position: relative; }
1972
+ .int-close {
1973
+ position: absolute;
1974
+ top: 8px;
1975
+ right: 9px;
1976
+ border: none;
1977
+ background: transparent;
1978
+ color: color-mix(in srgb, var(--sac-text) 55%, transparent);
1979
+ font-size: 18px;
1980
+ line-height: 1;
1981
+ cursor: pointer;
1982
+ padding: 2px 4px;
1983
+ border-radius: 6px;
1984
+ transition: color .2s ease, background .2s ease;
1985
+ }
1986
+ .int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
1987
+
1988
+ /* ─────────────── Cross-device "Restore my chats" ──────────────────── */
1989
+ .restore-link {
1990
+ border: none;
1991
+ background: none;
1992
+ padding: 0;
1993
+ font: inherit;
1994
+ font-size: 10.5px;
1995
+ letter-spacing: .04em;
1996
+ color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
1997
+ cursor: pointer;
1998
+ text-decoration: underline;
1999
+ text-underline-offset: 2px;
2000
+ }
2001
+ .restore-link:hover { color: var(--sac-primary); }
2002
+ .restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
2003
+ .restore-item {
2004
+ display: flex;
2005
+ align-items: center;
2006
+ justify-content: space-between;
2007
+ gap: 10px;
2008
+ text-align: left;
2009
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
2010
+ background: var(--sac-bg);
2011
+ color: var(--sac-text);
2012
+ border-radius: 10px;
2013
+ padding: 9px 11px;
2014
+ font-family: inherit;
2015
+ font-size: 12.5px;
2016
+ cursor: pointer;
2017
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
2018
+ }
2019
+ .restore-item:hover {
2020
+ border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
2021
+ background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
2022
+ transform: translateY(-1px);
2023
+ }
2024
+ .restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2025
+ .restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
915
2026
 
916
2027
  .hidden { display: none !important; }
917
2028
 
@@ -953,24 +2064,6 @@ const ICON = {
953
2064
  /** Tool-confirmation interrupt — a shield. */
954
2065
  shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`
955
2066
  };
956
- /**
957
- * Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
958
- *
959
- * SECURITY: citation URLs originate from indexed content (web / GitHub
960
- * connectors), which can be attacker-influenceable. Assigning an arbitrary
961
- * string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
962
- * execute on click — a stored-XSS vector. Only http(s) links are rendered as
963
- * anchors; anything else falls back to plain text.
964
- */
965
- function safeHttpUrl(url) {
966
- if (!url) return null;
967
- try {
968
- const parsed = new URL(url);
969
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
970
- } catch {
971
- return null;
972
- }
973
- }
974
2067
  var SmoothAgentChatElement = class extends HTMLElement {
975
2068
  static get observedAttributes() {
976
2069
  return OBSERVED;
@@ -987,8 +2080,12 @@ var SmoothAgentChatElement = class extends HTMLElement {
987
2080
  _defineProperty(this, "userInfoSatisfied", false);
988
2081
  _defineProperty(this, "hasSent", false);
989
2082
  _defineProperty(this, "examplePrompts", []);
2083
+ _defineProperty(this, "greeting", "");
990
2084
  _defineProperty(this, "interrupt", null);
991
2085
  _defineProperty(this, "interruptEl", null);
2086
+ _defineProperty(this, "identityRestore", { phase: "idle" });
2087
+ _defineProperty(this, "allowChatRestore", true);
2088
+ _defineProperty(this, "gating", false);
992
2089
  _defineProperty(this, "panelEl", null);
993
2090
  _defineProperty(this, "launcherEl", null);
994
2091
  _defineProperty(this, "messagesEl", null);
@@ -996,6 +2093,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
996
2093
  _defineProperty(this, "dotEl", null);
997
2094
  _defineProperty(this, "inputEl", null);
998
2095
  _defineProperty(this, "sendBtn", null);
2096
+ _defineProperty(this, "streamBubbleEl", null);
2097
+ _defineProperty(this, "streamMsgId", null);
2098
+ _defineProperty(this, "streamTarget", "");
2099
+ _defineProperty(this, "displayedLength", 0);
2100
+ _defineProperty(this, "rafId", 0);
999
2101
  this.root = this.attachShadow({ mode: "open" });
1000
2102
  }
1001
2103
  connectedCallback() {
@@ -1004,6 +2106,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1004
2106
  }
1005
2107
  disconnectedCallback() {
1006
2108
  this.mounted = false;
2109
+ this.resetReveal();
1007
2110
  this.controller?.disconnect();
1008
2111
  this.controller = null;
1009
2112
  }
@@ -1029,7 +2132,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1029
2132
  openChat() {
1030
2133
  this.open = true;
1031
2134
  this.syncOpenState();
1032
- this.controller?.connect().catch(() => {});
2135
+ if (!this.gating) this.controller?.connect().catch(() => {});
1033
2136
  }
1034
2137
  /** Collapse the chat panel back to the launcher. */
1035
2138
  closeChat() {
@@ -1050,6 +2153,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1050
2153
  userName: this.overrides.userName,
1051
2154
  userEmail: this.overrides.userEmail,
1052
2155
  userPhone: this.overrides.userPhone,
2156
+ authContext: this.overrides.authContext,
1053
2157
  placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
1054
2158
  greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
1055
2159
  connectionErrorMessage: this.overrides.connectionErrorMessage,
@@ -1058,6 +2162,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
1058
2162
  requireName: this.overrides.requireName,
1059
2163
  requireEmail: this.overrides.requireEmail,
1060
2164
  requirePhone: this.overrides.requirePhone,
2165
+ collectPhone: this.overrides.collectPhone,
2166
+ collectConsent: this.overrides.collectConsent,
2167
+ allowChatRestore: this.overrides.allowChatRestore,
1061
2168
  allowAnonymous: this.overrides.allowAnonymous,
1062
2169
  theme
1063
2170
  };
@@ -1069,11 +2176,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
1069
2176
  return;
1070
2177
  }
1071
2178
  const resolved = resolveConfig(config);
2179
+ this.allowChatRestore = resolved.allowChatRestore;
1072
2180
  if (!this.controller) {
1073
2181
  this.controller = new ConversationController(config, {
1074
2182
  onMessages: (messages) => {
1075
- this.messages = messages;
1076
- this.renderMessages(resolved.greeting);
2183
+ this.handleMessages(messages, resolved.greeting);
1077
2184
  },
1078
2185
  onStatus: (status) => {
1079
2186
  this.status = status;
@@ -1083,9 +2190,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
1083
2190
  onInterrupt: (interrupt) => {
1084
2191
  this.interrupt = interrupt;
1085
2192
  this.renderInterrupt();
2193
+ },
2194
+ onIdentityRestore: (state) => {
2195
+ this.identityRestore = state;
2196
+ this.renderInterrupt();
1086
2197
  }
1087
2198
  });
1088
2199
  if (resolved.startOpen) this.open = true;
2200
+ if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) this.userInfoSatisfied = true;
1089
2201
  }
1090
2202
  const fullpage = resolved.mode === "fullpage";
1091
2203
  if (fullpage) this.open = true;
@@ -1108,8 +2220,16 @@ var SmoothAgentChatElement = class extends HTMLElement {
1108
2220
  <button class="close" aria-label="Close chat">${ICON.close}</button>
1109
2221
  </div>`;
1110
2222
  this.examplePrompts = resolved.examplePrompts;
2223
+ this.greeting = resolved.greeting;
1111
2224
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1112
- const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
2225
+ this.gating = gating;
2226
+ const showPhone = resolved.requirePhone || resolved.collectPhone;
2227
+ const field = (name, type, label, autocomplete, required) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} /></label>`;
2228
+ const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
2229
+ const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
2230
+ ${consentBox("emailOptIn", "Email me product news and offers.")}
2231
+ ${consentBox("smsOptIn", "Text me updates by SMS. Message/data rates may apply.")}
2232
+ </div>` : "";
1113
2233
  const prechatHtml = `
1114
2234
  <div class="prechat">
1115
2235
  <div class="pc-head">
@@ -1117,12 +2237,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
1117
2237
  <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
1118
2238
  </div>
1119
2239
  <form class="pc-form" novalidate>
1120
- ${resolved.requireName ? field("name", "text", "Name", "name") : ""}
1121
- ${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
1122
- ${resolved.requirePhone ? field("phone", "tel", "Phone", "tel") : ""}
2240
+ ${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
2241
+ ${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
2242
+ ${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone) : ""}
2243
+ ${consentHtml}
1123
2244
  <button type="submit" class="pc-submit">Start chat</button>
1124
2245
  </form>
1125
2246
  </div>`;
2247
+ const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : "";
1126
2248
  const chatHtml = `
1127
2249
  <div class="messages"></div>
1128
2250
  <div class="interrupt hidden"></div>
@@ -1131,7 +2253,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1131
2253
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
1132
2254
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
1133
2255
  </div>
1134
- <div class="footer">powered by <b>smooth&#8209;operator</b></div>
2256
+ <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
1135
2257
  </div>`;
1136
2258
  const container = document.createElement("div");
1137
2259
  container.innerHTML = `
@@ -1144,6 +2266,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1144
2266
  `;
1145
2267
  const logoSvg = container.querySelector(".logo-wrap svg");
1146
2268
  if (logoSvg) logoSvg.setAttribute("class", "logo");
2269
+ this.resetReveal();
1147
2270
  this.root.replaceChildren(style, container);
1148
2271
  this.launcherEl = container.querySelector(".launcher");
1149
2272
  this.panelEl = container.querySelector(".panel");
@@ -1168,9 +2291,16 @@ var SmoothAgentChatElement = class extends HTMLElement {
1168
2291
  ev.preventDefault();
1169
2292
  this.handlePrechatSubmit(pcForm);
1170
2293
  });
2294
+ container.querySelector(".restore-link")?.addEventListener("click", () => {
2295
+ (async () => {
2296
+ this.identityRestore = { phase: "awaiting_email" };
2297
+ this.renderInterrupt();
2298
+ await this.controller?.connect().catch(() => {});
2299
+ })();
2300
+ });
1171
2301
  if (fullpage && !gating) this.controller?.connect().catch(() => {});
1172
2302
  this.syncOpenState();
1173
- if (!gating) this.renderMessages(resolved.greeting);
2303
+ if (!gating) this.renderMessages();
1174
2304
  this.renderStatus();
1175
2305
  this.renderComposerState();
1176
2306
  this.renderInterrupt();
@@ -1186,6 +2316,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
1186
2316
  el.replaceChildren();
1187
2317
  const it = this.interrupt;
1188
2318
  if (!it) {
2319
+ if (this.identityRestore.phase !== "idle") {
2320
+ el.classList.remove("hidden");
2321
+ el.appendChild(this.buildRestoreCard());
2322
+ return;
2323
+ }
1189
2324
  el.classList.add("hidden");
1190
2325
  return;
1191
2326
  }
@@ -1265,15 +2400,186 @@ var SmoothAgentChatElement = class extends HTMLElement {
1265
2400
  }
1266
2401
  el.appendChild(card);
1267
2402
  }
1268
- /** Collect identity from the pre-chat form, then drop into the chat view. */
2403
+ /**
2404
+ * Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
2405
+ * same overlay slot + visual language as the OTP interrupt. All server-supplied
2406
+ * strings (masked destination, conversation previews) are set via `textContent`
2407
+ * — never innerHTML — so they can't inject markup; only the static lock icon
2408
+ * uses innerHTML.
2409
+ */
2410
+ buildRestoreCard() {
2411
+ const state = this.identityRestore;
2412
+ const card = document.createElement("div");
2413
+ card.className = "int-card";
2414
+ const head = document.createElement("div");
2415
+ head.className = "int-head";
2416
+ const ico = document.createElement("span");
2417
+ ico.className = "int-ico";
2418
+ ico.innerHTML = ICON.lock;
2419
+ const title = document.createElement("span");
2420
+ title.className = "int-title";
2421
+ title.textContent = "Restore your chats";
2422
+ head.append(ico, title);
2423
+ card.appendChild(head);
2424
+ const close = document.createElement("button");
2425
+ close.className = "int-close";
2426
+ close.type = "button";
2427
+ close.setAttribute("aria-label", "Cancel");
2428
+ close.textContent = "×";
2429
+ close.addEventListener("click", () => {
2430
+ this.controller?.cancelIdentityRestore();
2431
+ this.identityRestore = { phase: "idle" };
2432
+ this.renderInterrupt();
2433
+ });
2434
+ card.appendChild(close);
2435
+ if (state.phase === "awaiting_email") {
2436
+ const desc = document.createElement("div");
2437
+ desc.className = "int-desc";
2438
+ desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
2439
+ card.appendChild(desc);
2440
+ const row = document.createElement("div");
2441
+ row.className = "int-row";
2442
+ const input = document.createElement("input");
2443
+ input.className = "int-input";
2444
+ input.type = "email";
2445
+ input.autocomplete = "email";
2446
+ input.placeholder = "you@example.com";
2447
+ const go = () => {
2448
+ const email = input.value.trim();
2449
+ if (email) this.controller?.requestIdentityOtp(email, "email");
2450
+ };
2451
+ input.addEventListener("keydown", (ev) => {
2452
+ if (ev.key === "Enter") {
2453
+ ev.preventDefault();
2454
+ go();
2455
+ }
2456
+ });
2457
+ const send = document.createElement("button");
2458
+ send.className = "int-btn primary";
2459
+ send.type = "button";
2460
+ send.textContent = "Send code";
2461
+ send.addEventListener("click", go);
2462
+ row.append(input, send);
2463
+ card.appendChild(row);
2464
+ if (state.error) {
2465
+ const err = document.createElement("div");
2466
+ err.className = "int-error";
2467
+ err.textContent = state.error;
2468
+ card.appendChild(err);
2469
+ }
2470
+ queueMicrotask(() => input.focus());
2471
+ } else if (state.phase === "requesting" || state.phase === "verifying" || state.phase === "resolving") {
2472
+ const msg = document.createElement("div");
2473
+ msg.className = "int-sent";
2474
+ msg.textContent = state.phase === "requesting" ? "Sending a code…" : state.phase === "verifying" ? "Verifying…" : "Finding your chats…";
2475
+ card.appendChild(msg);
2476
+ } else if (state.phase === "awaiting_code") {
2477
+ if (state.maskedDestination) {
2478
+ const sent = document.createElement("div");
2479
+ sent.className = "int-sent";
2480
+ sent.textContent = `Code sent to ${state.maskedDestination}.`;
2481
+ card.appendChild(sent);
2482
+ }
2483
+ const row = document.createElement("div");
2484
+ row.className = "int-row";
2485
+ const input = document.createElement("input");
2486
+ input.className = "int-input";
2487
+ input.type = "text";
2488
+ input.inputMode = "numeric";
2489
+ input.autocomplete = "one-time-code";
2490
+ input.placeholder = "Enter code";
2491
+ const submit = () => {
2492
+ const code = input.value.trim();
2493
+ if (code) this.controller?.verifyIdentityOtp(code);
2494
+ };
2495
+ input.addEventListener("keydown", (ev) => {
2496
+ if (ev.key === "Enter") {
2497
+ ev.preventDefault();
2498
+ submit();
2499
+ }
2500
+ });
2501
+ const verify = document.createElement("button");
2502
+ verify.className = "int-btn primary";
2503
+ verify.type = "button";
2504
+ verify.textContent = "Verify";
2505
+ verify.addEventListener("click", submit);
2506
+ row.append(input, verify);
2507
+ card.appendChild(row);
2508
+ if (state.error) {
2509
+ const err = document.createElement("div");
2510
+ err.className = "int-error";
2511
+ err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
2512
+ card.appendChild(err);
2513
+ }
2514
+ queueMicrotask(() => input.focus());
2515
+ } else if (state.phase === "resolved") if (state.conversations.length === 0) {
2516
+ const none = document.createElement("div");
2517
+ none.className = "int-desc";
2518
+ none.textContent = "No previous chats found for that email.";
2519
+ card.appendChild(none);
2520
+ } else {
2521
+ const pick = document.createElement("div");
2522
+ pick.className = "int-desc";
2523
+ pick.textContent = "Pick a conversation to continue:";
2524
+ card.appendChild(pick);
2525
+ const list = document.createElement("div");
2526
+ list.className = "restore-list";
2527
+ for (const conv of state.conversations) {
2528
+ const btn = document.createElement("button");
2529
+ btn.type = "button";
2530
+ btn.className = "restore-item";
2531
+ const preview = document.createElement("span");
2532
+ preview.className = "restore-preview";
2533
+ preview.textContent = conv.preview || "Conversation";
2534
+ btn.appendChild(preview);
2535
+ if (conv.lastActivityAt) {
2536
+ const when = document.createElement("span");
2537
+ when.className = "restore-when";
2538
+ when.textContent = this.formatWhen(conv.lastActivityAt);
2539
+ btn.appendChild(when);
2540
+ }
2541
+ btn.addEventListener("click", () => {
2542
+ this.controller?.restoreConversation(conv.sessionId);
2543
+ });
2544
+ list.appendChild(btn);
2545
+ }
2546
+ card.appendChild(list);
2547
+ }
2548
+ else if (state.phase === "error") {
2549
+ const err = document.createElement("div");
2550
+ err.className = "int-error";
2551
+ err.textContent = state.message;
2552
+ card.appendChild(err);
2553
+ }
2554
+ return card;
2555
+ }
2556
+ /** Format an ISO timestamp as a short, locale-aware label (best-effort). */
2557
+ formatWhen(iso) {
2558
+ const d = new Date(iso);
2559
+ if (Number.isNaN(d.getTime())) return "";
2560
+ try {
2561
+ return d.toLocaleDateString(void 0, {
2562
+ month: "short",
2563
+ day: "numeric"
2564
+ });
2565
+ } catch {
2566
+ return "";
2567
+ }
2568
+ }
2569
+ /** Collect identity + consent from the pre-chat form, then drop into the chat view. */
1269
2570
  handlePrechatSubmit(form) {
1270
2571
  if (!form.reportValidity()) return;
1271
2572
  const data = new FormData(form);
1272
2573
  const val = (k) => data.get(k)?.trim() || void 0;
2574
+ const checked = (k) => data.get(k) === "on";
1273
2575
  this.controller?.setUserInfo({
1274
2576
  name: val("name"),
1275
2577
  email: val("email"),
1276
- phone: val("phone")
2578
+ phone: val("phone"),
2579
+ consent: {
2580
+ emailOptIn: checked("emailOptIn"),
2581
+ smsOptIn: checked("smsOptIn")
2582
+ }
1277
2583
  });
1278
2584
  this.userInfoSatisfied = true;
1279
2585
  this.render();
@@ -1301,9 +2607,35 @@ var SmoothAgentChatElement = class extends HTMLElement {
1301
2607
  ta.style.height = "auto";
1302
2608
  ta.style.height = `${ta.scrollHeight}px`;
1303
2609
  }
1304
- renderMessages(greeting) {
2610
+ /**
2611
+ * Receive a new message snapshot from the controller and update the view.
2612
+ *
2613
+ * `onMessages` fires on *every* `stream_token` delta. Rebuilding the whole
2614
+ * list each frame is wasteful and fights the smooth reveal, so we take the
2615
+ * cheap path when the only change is the trailing streaming assistant message
2616
+ * growing: just bump the reveal *target* (the rAF loop drains it). Any
2617
+ * structural change (new message, finalize, citations) triggers a full
2618
+ * rebuild via {@link renderMessages}.
2619
+ */
2620
+ handleMessages(messages, greeting) {
2621
+ this.greeting = greeting;
2622
+ const prev = this.messages;
2623
+ const last = messages[messages.length - 1];
2624
+ const prevLast = prev[prev.length - 1];
2625
+ const structural = messages.length !== prev.length || !last || !prevLast || last.id !== prevLast.id || last.role !== prevLast.role || last.streaming !== prevLast.streaming || !!last.streaming && !prevLast.text && !!last.text;
2626
+ this.messages = messages;
2627
+ if (!structural && last && last.streaming && last.id === this.streamMsgId) {
2628
+ this.streamTarget = last.text;
2629
+ this.ensureRevealLoop();
2630
+ return;
2631
+ }
2632
+ this.renderMessages();
2633
+ }
2634
+ renderMessages() {
1305
2635
  if (!this.messagesEl) return;
2636
+ this.resetReveal();
1306
2637
  this.messagesEl.replaceChildren();
2638
+ const greeting = this.greeting;
1307
2639
  if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting)));
1308
2640
  if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) {
1309
2641
  const chips = document.createElement("div");
@@ -1326,12 +2658,94 @@ var SmoothAgentChatElement = class extends HTMLElement {
1326
2658
  bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
1327
2659
  } else if (msg.streaming) {
1328
2660
  bubble.classList.add("cursor");
1329
- bubble.textContent = msg.text;
2661
+ this.bindReveal(msg, bubble);
2662
+ } else if (msg.role === "assistant") {
2663
+ bubble.classList.add("md");
2664
+ bubble.innerHTML = renderMarkdown(msg.text);
1330
2665
  } else bubble.textContent = msg.text;
1331
2666
  this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
1332
2667
  if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations));
1333
2668
  }
1334
- this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
2669
+ this.scrollToBottom(true);
2670
+ }
2671
+ /** True when the host/user prefers reduced motion (snap, no typewriter). */
2672
+ prefersReducedMotion() {
2673
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2674
+ }
2675
+ /**
2676
+ * Bind the rAF typewriter to a freshly built streaming bubble. Preserves the
2677
+ * already-revealed prefix across rebuilds (so a structural rebuild mid-stream
2678
+ * doesn't restart the reveal from zero), then resumes the loop.
2679
+ */
2680
+ bindReveal(msg, bubble) {
2681
+ const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
2682
+ this.streamBubbleEl = bubble;
2683
+ this.streamMsgId = msg.id;
2684
+ this.streamTarget = msg.text;
2685
+ this.displayedLength = carryOver;
2686
+ if (this.prefersReducedMotion()) {
2687
+ this.displayedLength = msg.text.length;
2688
+ bubble.textContent = msg.text;
2689
+ return;
2690
+ }
2691
+ bubble.textContent = msg.text.slice(0, this.displayedLength);
2692
+ this.ensureRevealLoop();
2693
+ }
2694
+ /** Start the rAF loop if it isn't already running. */
2695
+ ensureRevealLoop() {
2696
+ if (this.prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
2697
+ this.snapReveal();
2698
+ return;
2699
+ }
2700
+ if (this.rafId) return;
2701
+ this.rafId = requestAnimationFrame(() => this.tickReveal());
2702
+ }
2703
+ /**
2704
+ * One animation frame of the reveal. Advances `displayedLength` toward the
2705
+ * buffered target at an ADAPTIVE rate — the deeper the backlog, the more
2706
+ * chars per frame — so the reveal stays smooth yet never falls behind the
2707
+ * network. Updates ONLY the bound bubble's textContent (no list rebuild).
2708
+ */
2709
+ tickReveal() {
2710
+ this.rafId = 0;
2711
+ const bubble = this.streamBubbleEl;
2712
+ if (!bubble) return;
2713
+ const target = this.streamTarget;
2714
+ const remaining = target.length - this.displayedLength;
2715
+ if (remaining <= 0) return;
2716
+ const step = Math.max(2, Math.min(remaining, Math.ceil(remaining / 8)));
2717
+ this.displayedLength = Math.min(target.length, this.displayedLength + step);
2718
+ bubble.textContent = target.slice(0, this.displayedLength);
2719
+ this.scrollToBottom(false);
2720
+ this.rafId = requestAnimationFrame(() => this.tickReveal());
2721
+ }
2722
+ /** Snap the bound bubble to the full buffered text immediately (reduced-motion / no-rAF). */
2723
+ snapReveal() {
2724
+ if (this.streamBubbleEl) {
2725
+ this.displayedLength = this.streamTarget.length;
2726
+ this.streamBubbleEl.textContent = this.streamTarget;
2727
+ }
2728
+ }
2729
+ /** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
2730
+ resetReveal() {
2731
+ if (this.rafId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafId);
2732
+ this.rafId = 0;
2733
+ this.streamBubbleEl = null;
2734
+ }
2735
+ /**
2736
+ * Auto-scroll the message list to the bottom — but don't fight a visitor who
2737
+ * has scrolled up to read history. When `force` (a structural rebuild) we
2738
+ * always pin to bottom; during the streaming reveal we only follow if the
2739
+ * viewport is already near the bottom.
2740
+ */
2741
+ scrollToBottom(force) {
2742
+ const el = this.messagesEl;
2743
+ if (!el) return;
2744
+ if (force) {
2745
+ el.scrollTop = el.scrollHeight;
2746
+ return;
2747
+ }
2748
+ if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) el.scrollTop = el.scrollHeight;
1335
2749
  }
1336
2750
  /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
1337
2751
  buildRow(role, bubble) {
@@ -1388,7 +2802,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1388
2802
  a.className = "src-title";
1389
2803
  a.href = safeUrl;
1390
2804
  a.target = "_blank";
1391
- a.rel = "noopener noreferrer";
2805
+ a.rel = "noopener noreferrer nofollow";
1392
2806
  titleEl = a;
1393
2807
  } else {
1394
2808
  titleEl = document.createElement("span");
@@ -1397,10 +2811,13 @@ var SmoothAgentChatElement = class extends HTMLElement {
1397
2811
  titleEl.textContent = c.title || c.id || "Source";
1398
2812
  li.appendChild(titleEl);
1399
2813
  if (c.snippet) {
1400
- const snip = document.createElement("span");
1401
- snip.className = "src-snippet";
1402
- snip.textContent = c.snippet;
1403
- li.appendChild(snip);
2814
+ const cleaned = cleanCitationSnippet(c.snippet);
2815
+ if (cleaned) {
2816
+ const snip = document.createElement("span");
2817
+ snip.className = "src-snippet md";
2818
+ snip.innerHTML = renderMarkdown(cleaned);
2819
+ li.appendChild(snip);
2820
+ }
1404
2821
  }
1405
2822
  list.appendChild(li);
1406
2823
  }
@@ -1437,17 +2854,6 @@ var SmoothAgentChatElement = class extends HTMLElement {
1437
2854
  this.controller.send(text);
1438
2855
  }
1439
2856
  };
1440
- function escapeHtml(value) {
1441
- return value.replace(/[&<>"']/g, (c) => {
1442
- switch (c) {
1443
- case "&": return "&amp;";
1444
- case "<": return "&lt;";
1445
- case ">": return "&gt;";
1446
- case "\"": return "&quot;";
1447
- default: return "&#39;";
1448
- }
1449
- });
1450
- }
1451
2857
  /** Register the custom element once. Safe to call multiple times. */
1452
2858
  function defineChatWidget() {
1453
2859
  if (typeof customElements !== "undefined" && !customElements.get("smooth-agent-chat")) customElements.define(ELEMENT_TAG, SmoothAgentChatElement);
@@ -1620,6 +3026,6 @@ function initChatWidgetLoader() {
1620
3026
  else window.addEventListener("load", schedule, { once: true });
1621
3027
  }
1622
3028
  //#endregion
1623
- export { ConversationController, ELEMENT_TAG, SmoothAgentChatElement, defineChatWidget, initChatWidgetLoader, mountChatWidget, mountFullPageChat };
3029
+ export { ConversationController, ELEMENT_TAG, PERSIST_VERSION, SmoothAgentChatElement, computeFingerprint, createWidgetStore, defineChatWidget, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, storageKey };
1624
3030
 
1625
3031
  //# sourceMappingURL=index.js.map