@smooai/chat-widget 0.6.0 → 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/src/element.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
18
18
  import { needsUserInfo, resolveConfig } from './config.js';
19
- import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js';
19
+ import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
20
20
  import { SMOOTH_LOGO_SVG } from './logo.js';
21
21
  import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
22
22
  import { buildStyles } from './styles.js';
@@ -73,6 +73,12 @@ export class SmoothAgentChatElement extends HTMLElement {
73
73
  /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
74
74
  private interrupt: Interrupt | null = null;
75
75
  private interruptEl: HTMLElement | null = null;
76
+ /** Cross-device "restore my chats" flow state (ADR-048 §c). */
77
+ private identityRestore: IdentityRestore = { phase: 'idle' };
78
+ /** Whether the cross-device restore affordance is offered (config). */
79
+ private allowChatRestore = true;
80
+ /** True while the pre-chat identity gate is showing (blocks premature connect). */
81
+ private gating = false;
76
82
 
77
83
  // Cached DOM refs (populated in render()).
78
84
  private panelEl: HTMLElement | null = null;
@@ -136,7 +142,10 @@ export class SmoothAgentChatElement extends HTMLElement {
136
142
  openChat(): void {
137
143
  this.open = true;
138
144
  this.syncOpenState();
139
- void this.controller?.connect().catch(() => {});
145
+ // Don't connect while the pre-chat identity gate is unsatisfied — connecting
146
+ // here would create a session BEFORE the visitor submits their name/email/
147
+ // consent, sending an empty identity. The form's submit handler connects.
148
+ if (!this.gating) void this.controller?.connect().catch(() => {});
140
149
  }
141
150
 
142
151
  /** Collapse the chat panel back to the launcher. */
@@ -163,6 +172,7 @@ export class SmoothAgentChatElement extends HTMLElement {
163
172
  userName: this.overrides.userName,
164
173
  userEmail: this.overrides.userEmail,
165
174
  userPhone: this.overrides.userPhone,
175
+ authContext: this.overrides.authContext,
166
176
  placeholder: this.overrides.placeholder ?? this.getAttribute('placeholder') ?? undefined,
167
177
  greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
168
178
  connectionErrorMessage: this.overrides.connectionErrorMessage,
@@ -171,6 +181,9 @@ export class SmoothAgentChatElement extends HTMLElement {
171
181
  requireName: this.overrides.requireName,
172
182
  requireEmail: this.overrides.requireEmail,
173
183
  requirePhone: this.overrides.requirePhone,
184
+ collectPhone: this.overrides.collectPhone,
185
+ collectConsent: this.overrides.collectConsent,
186
+ allowChatRestore: this.overrides.allowChatRestore,
174
187
  allowAnonymous: this.overrides.allowAnonymous,
175
188
  theme,
176
189
  };
@@ -186,6 +199,8 @@ export class SmoothAgentChatElement extends HTMLElement {
186
199
  }
187
200
  const resolved = resolveConfig(config);
188
201
 
202
+ this.allowChatRestore = resolved.allowChatRestore;
203
+
189
204
  // (Re)create the controller only when there isn't one yet. Attribute churn
190
205
  // (e.g. theme tweaks) re-renders the view without dropping the session.
191
206
  if (!this.controller) {
@@ -202,8 +217,17 @@ export class SmoothAgentChatElement extends HTMLElement {
202
217
  this.interrupt = interrupt;
203
218
  this.renderInterrupt();
204
219
  },
220
+ onIdentityRestore: (state) => {
221
+ this.identityRestore = state;
222
+ this.renderInterrupt();
223
+ },
205
224
  });
206
225
  if (resolved.startOpen) this.open = true;
226
+ // Returning visitor: a persisted session or identity lets us skip the
227
+ // pre-chat gate and resume straight into the conversation (ADR-048 §b).
228
+ if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) {
229
+ this.userInfoSatisfied = true;
230
+ }
207
231
  }
208
232
 
209
233
  const fullpage = resolved.mode === 'fullpage';
@@ -242,8 +266,20 @@ export class SmoothAgentChatElement extends HTMLElement {
242
266
 
243
267
  // Gate the conversation behind a pre-chat identity form when required.
244
268
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
245
- const field = (name: string, type: string, label: string, autocomplete: string) =>
246
- `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
269
+ this.gating = gating;
270
+ // Phone is collected by default (optional unless requirePhone). Consent
271
+ // checkboxes default to shown, explicit + unchecked (ADR-048 §a/§3).
272
+ const showPhone = resolved.requirePhone || resolved.collectPhone;
273
+ const field = (name: string, type: string, label: string, autocomplete: string, required: boolean) =>
274
+ `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? ' required' : ''} /></label>`;
275
+ const consentBox = (name: string, label: string) =>
276
+ `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
277
+ const consentHtml = resolved.collectConsent
278
+ ? `<div class="pc-consents">
279
+ ${consentBox('emailOptIn', 'Email me product news and offers.')}
280
+ ${consentBox('smsOptIn', 'Text me updates by SMS. Message/data rates may apply.')}
281
+ </div>`
282
+ : '';
247
283
  const prechatHtml = `
248
284
  <div class="prechat">
249
285
  <div class="pc-head">
@@ -251,12 +287,14 @@ export class SmoothAgentChatElement extends HTMLElement {
251
287
  <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
252
288
  </div>
253
289
  <form class="pc-form" novalidate>
254
- ${resolved.requireName ? field('name', 'text', 'Name', 'name') : ''}
255
- ${resolved.requireEmail ? field('email', 'email', 'Email', 'email') : ''}
256
- ${resolved.requirePhone ? field('phone', 'tel', 'Phone', 'tel') : ''}
290
+ ${resolved.requireName ? field('name', 'text', 'Name', 'name', true) : ''}
291
+ ${resolved.requireEmail ? field('email', 'email', 'Email', 'email', true) : ''}
292
+ ${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone) : ''}
293
+ ${consentHtml}
257
294
  <button type="submit" class="pc-submit">Start chat</button>
258
295
  </form>
259
296
  </div>`;
297
+ const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : '';
260
298
  const chatHtml = `
261
299
  <div class="messages"></div>
262
300
  <div class="interrupt hidden"></div>
@@ -265,7 +303,7 @@ export class SmoothAgentChatElement extends HTMLElement {
265
303
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
266
304
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
267
305
  </div>
268
- <div class="footer">powered by <b>smooth&#8209;operator</b></div>
306
+ <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
269
307
  </div>`;
270
308
 
271
309
  const container = document.createElement('div');
@@ -313,6 +351,21 @@ export class SmoothAgentChatElement extends HTMLElement {
313
351
  this.handlePrechatSubmit(pcForm as HTMLFormElement);
314
352
  });
315
353
 
354
+ // Cross-device "Restore my chats": open the panel + start the email entry.
355
+ // AWAIT connect() before showing the email step so a `sessionId` exists by
356
+ // the time the visitor submits — otherwise request-otp could go out with no
357
+ // session and verify-otp would then hard-error "No active session." The
358
+ // request-otp/verify-otp paths in the controller also require a session, so
359
+ // gating here keeps the affordance race-free.
360
+ container.querySelector('.restore-link')?.addEventListener('click', () => {
361
+ void (async () => {
362
+ this.identityRestore = { phase: 'awaiting_email' };
363
+ this.renderInterrupt();
364
+ // Establish a live session before the email entry can fire request-otp.
365
+ await this.controller?.connect().catch(() => {});
366
+ })();
367
+ });
368
+
316
369
  // Full-page mode connects eagerly (there's no launcher click to trigger it) —
317
370
  // but only once any identity gate is cleared.
318
371
  if (fullpage && !gating) void this.controller?.connect().catch(() => {});
@@ -335,6 +388,13 @@ export class SmoothAgentChatElement extends HTMLElement {
335
388
  el.replaceChildren();
336
389
  const it = this.interrupt;
337
390
  if (!it) {
391
+ // No mid-turn interrupt — but the cross-device restore flow may be
392
+ // active, which reuses this same overlay slot.
393
+ if (this.identityRestore.phase !== 'idle') {
394
+ el.classList.remove('hidden');
395
+ el.appendChild(this.buildRestoreCard());
396
+ return;
397
+ }
338
398
  el.classList.add('hidden');
339
399
  return;
340
400
  }
@@ -420,12 +480,189 @@ export class SmoothAgentChatElement extends HTMLElement {
420
480
  el.appendChild(card);
421
481
  }
422
482
 
423
- /** Collect identity from the pre-chat form, then drop into the chat view. */
483
+ /**
484
+ * Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
485
+ * same overlay slot + visual language as the OTP interrupt. All server-supplied
486
+ * strings (masked destination, conversation previews) are set via `textContent`
487
+ * — never innerHTML — so they can't inject markup; only the static lock icon
488
+ * uses innerHTML.
489
+ */
490
+ private buildRestoreCard(): HTMLElement {
491
+ const state = this.identityRestore;
492
+ const card = document.createElement('div');
493
+ card.className = 'int-card';
494
+
495
+ const head = document.createElement('div');
496
+ head.className = 'int-head';
497
+ const ico = document.createElement('span');
498
+ ico.className = 'int-ico';
499
+ ico.innerHTML = ICON.lock; // static, trusted
500
+ const title = document.createElement('span');
501
+ title.className = 'int-title';
502
+ title.textContent = 'Restore your chats';
503
+ head.append(ico, title);
504
+ card.appendChild(head);
505
+
506
+ const close = document.createElement('button');
507
+ close.className = 'int-close';
508
+ close.type = 'button';
509
+ close.setAttribute('aria-label', 'Cancel');
510
+ close.textContent = '×';
511
+ close.addEventListener('click', () => {
512
+ this.controller?.cancelIdentityRestore();
513
+ this.identityRestore = { phase: 'idle' };
514
+ this.renderInterrupt();
515
+ });
516
+ card.appendChild(close);
517
+
518
+ if (state.phase === 'awaiting_email') {
519
+ const desc = document.createElement('div');
520
+ desc.className = 'int-desc';
521
+ desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
522
+ card.appendChild(desc);
523
+
524
+ const row = document.createElement('div');
525
+ row.className = 'int-row';
526
+ const input = document.createElement('input');
527
+ input.className = 'int-input';
528
+ input.type = 'email';
529
+ input.autocomplete = 'email';
530
+ input.placeholder = 'you@example.com';
531
+ const go = () => {
532
+ const email = input.value.trim();
533
+ if (email) void this.controller?.requestIdentityOtp(email, 'email');
534
+ };
535
+ input.addEventListener('keydown', (ev) => {
536
+ if (ev.key === 'Enter') {
537
+ ev.preventDefault();
538
+ go();
539
+ }
540
+ });
541
+ const send = document.createElement('button');
542
+ send.className = 'int-btn primary';
543
+ send.type = 'button';
544
+ send.textContent = 'Send code';
545
+ send.addEventListener('click', go);
546
+ row.append(input, send);
547
+ card.appendChild(row);
548
+ if (state.error) {
549
+ const err = document.createElement('div');
550
+ err.className = 'int-error';
551
+ err.textContent = state.error;
552
+ card.appendChild(err);
553
+ }
554
+ queueMicrotask(() => input.focus());
555
+ } else if (state.phase === 'requesting' || state.phase === 'verifying' || state.phase === 'resolving') {
556
+ const msg = document.createElement('div');
557
+ msg.className = 'int-sent';
558
+ msg.textContent = state.phase === 'requesting' ? 'Sending a code…' : state.phase === 'verifying' ? 'Verifying…' : 'Finding your chats…';
559
+ card.appendChild(msg);
560
+ } else if (state.phase === 'awaiting_code') {
561
+ if (state.maskedDestination) {
562
+ const sent = document.createElement('div');
563
+ sent.className = 'int-sent';
564
+ sent.textContent = `Code sent to ${state.maskedDestination}.`;
565
+ card.appendChild(sent);
566
+ }
567
+ const row = document.createElement('div');
568
+ row.className = 'int-row';
569
+ const input = document.createElement('input');
570
+ input.className = 'int-input';
571
+ input.type = 'text';
572
+ input.inputMode = 'numeric';
573
+ input.autocomplete = 'one-time-code';
574
+ input.placeholder = 'Enter code';
575
+ const submit = () => {
576
+ const code = input.value.trim();
577
+ if (code) void this.controller?.verifyIdentityOtp(code);
578
+ };
579
+ input.addEventListener('keydown', (ev) => {
580
+ if (ev.key === 'Enter') {
581
+ ev.preventDefault();
582
+ submit();
583
+ }
584
+ });
585
+ const verify = document.createElement('button');
586
+ verify.className = 'int-btn primary';
587
+ verify.type = 'button';
588
+ verify.textContent = 'Verify';
589
+ verify.addEventListener('click', submit);
590
+ row.append(input, verify);
591
+ card.appendChild(row);
592
+ if (state.error) {
593
+ const err = document.createElement('div');
594
+ err.className = 'int-error';
595
+ err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
596
+ card.appendChild(err);
597
+ }
598
+ queueMicrotask(() => input.focus());
599
+ } else if (state.phase === 'resolved') {
600
+ if (state.conversations.length === 0) {
601
+ const none = document.createElement('div');
602
+ none.className = 'int-desc';
603
+ none.textContent = 'No previous chats found for that email.';
604
+ card.appendChild(none);
605
+ } else {
606
+ const pick = document.createElement('div');
607
+ pick.className = 'int-desc';
608
+ pick.textContent = 'Pick a conversation to continue:';
609
+ card.appendChild(pick);
610
+ const list = document.createElement('div');
611
+ list.className = 'restore-list';
612
+ for (const conv of state.conversations) {
613
+ const btn = document.createElement('button');
614
+ btn.type = 'button';
615
+ btn.className = 'restore-item';
616
+ const preview = document.createElement('span');
617
+ preview.className = 'restore-preview';
618
+ preview.textContent = conv.preview || 'Conversation';
619
+ btn.appendChild(preview);
620
+ if (conv.lastActivityAt) {
621
+ const when = document.createElement('span');
622
+ when.className = 'restore-when';
623
+ when.textContent = this.formatWhen(conv.lastActivityAt);
624
+ btn.appendChild(when);
625
+ }
626
+ btn.addEventListener('click', () => {
627
+ void this.controller?.restoreConversation(conv.sessionId);
628
+ });
629
+ list.appendChild(btn);
630
+ }
631
+ card.appendChild(list);
632
+ }
633
+ } else if (state.phase === 'error') {
634
+ const err = document.createElement('div');
635
+ err.className = 'int-error';
636
+ err.textContent = state.message;
637
+ card.appendChild(err);
638
+ }
639
+
640
+ return card;
641
+ }
642
+
643
+ /** Format an ISO timestamp as a short, locale-aware label (best-effort). */
644
+ private formatWhen(iso: string): string {
645
+ const d = new Date(iso);
646
+ if (Number.isNaN(d.getTime())) return '';
647
+ try {
648
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
649
+ } catch {
650
+ return '';
651
+ }
652
+ }
653
+
654
+ /** Collect identity + consent from the pre-chat form, then drop into the chat view. */
424
655
  private handlePrechatSubmit(form: HTMLFormElement): void {
425
656
  if (!form.reportValidity()) return;
426
657
  const data = new FormData(form);
427
658
  const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
428
- this.controller?.setUserInfo({ name: val('name'), email: val('email'), phone: val('phone') });
659
+ const checked = (k: string) => data.get(k) === 'on';
660
+ this.controller?.setUserInfo({
661
+ name: val('name'),
662
+ email: val('email'),
663
+ phone: val('phone'),
664
+ consent: { emailOptIn: checked('emailOptIn'), smsOptIn: checked('smsOptIn') },
665
+ });
429
666
  this.userInfoSatisfied = true;
430
667
  this.render();
431
668
  void this.controller?.connect().catch(() => {});
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Browser fingerprint — a stable, privacy-light identifier used by the
3
+ * smooth-operator server to correlate an anonymous visitor's sessions across
4
+ * page loads (and, server-side, to match an anonymous fingerprint to a known
5
+ * CRM contact). It rides every `create_conversation_session` as
6
+ * `browserFingerprint` (see ADR-048).
7
+ *
8
+ * ## Why not ThumbmarkJS
9
+ *
10
+ * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
11
+ * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
12
+ * full build pulls in extensive device-detection tables and async
13
+ * component collection, adding tens of kilobytes (and async surface) to a
14
+ * bundle whose whole selling point is staying out of the host page's
15
+ * LCP/TBT budget. The fingerprint here is a few hundred bytes.
16
+ *
17
+ * ## What this is (and the tradeoff)
18
+ *
19
+ * The correlation that actually matters — "is this the same browser as last
20
+ * time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
21
+ * field in the persisted store). That UUID is generated once and reused for
22
+ * the life of the localStorage entry, so same-browser resume is exact and
23
+ * deterministic. The signal hash below is a best-effort entropy *supplement*
24
+ * mixed into that UUID's derivation seed on first generation — it gives the
25
+ * server-side resolver a soft signal for the (rare) cross-storage case where
26
+ * the UUID was cleared but the device is unchanged. It is intentionally NOT a
27
+ * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
28
+ * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
29
+ * cross-storage matching in exchange for a tiny, transparent, no-network,
30
+ * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
31
+ * of truth for any fuzzy matching; the client just supplies a stable token.
32
+ */
33
+
34
+ /** Collect a small set of stable, non-invasive browser signals. */
35
+ function collectSignals(): string {
36
+ const parts: string[] = [];
37
+ try {
38
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
39
+ if (nav) {
40
+ parts.push(`ua:${nav.userAgent ?? ''}`);
41
+ parts.push(`lang:${nav.language ?? ''}`);
42
+ parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(',') : ''}`);
43
+ // `platform` is deprecated but still widely present and stable.
44
+ parts.push(`plat:${(nav as Navigator & { platform?: string }).platform ?? ''}`);
45
+ parts.push(`hc:${(nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency ?? ''}`);
46
+ parts.push(`dm:${(nav as Navigator & { deviceMemory?: number }).deviceMemory ?? ''}`);
47
+ }
48
+ if (typeof screen !== 'undefined') {
49
+ parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
50
+ }
51
+ if (typeof Intl !== 'undefined') {
52
+ try {
53
+ parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''}`);
54
+ } catch {
55
+ /* resolvedOptions can throw in locked-down environments */
56
+ }
57
+ }
58
+ parts.push(`tzo:${new Date().getTimezoneOffset()}`);
59
+ } catch {
60
+ /* a hostile/locked-down navigator must never break widget boot */
61
+ }
62
+ return parts.join('|');
63
+ }
64
+
65
+ /**
66
+ * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
67
+ * an unsigned hex string. Stable across runs for the same input. Not used for
68
+ * any security decision — only to derive a deterministic seed from the signal
69
+ * string above.
70
+ */
71
+ function fnv1a(input: string): string {
72
+ let h = 0x811c9dc5;
73
+ for (let i = 0; i < input.length; i++) {
74
+ h ^= input.charCodeAt(i);
75
+ // 32-bit FNV prime multiply via shifts to stay in int range.
76
+ h = Math.imul(h, 0x01000193);
77
+ }
78
+ // >>> 0 → unsigned; pad to 8 hex chars.
79
+ return (h >>> 0).toString(16).padStart(8, '0');
80
+ }
81
+
82
+ /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
83
+ function generateUuid(): string {
84
+ try {
85
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
86
+ return crypto.randomUUID();
87
+ }
88
+ } catch {
89
+ /* fall through to the manual generator */
90
+ }
91
+ // RFC 4122 v4 shape from Math.random — only reached when crypto.randomUUID
92
+ // is unavailable (very old engines). Sufficient for a correlation token.
93
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
94
+ const r = (Math.random() * 16) | 0;
95
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
96
+ return v.toString(16);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
102
+ * FNV hash of the device signals so the server can recover a soft device
103
+ * signal even if it only has the token. Called ONCE per browser (the result is
104
+ * persisted and reused) — see {@link getOrCreateFingerprint}.
105
+ */
106
+ export function computeFingerprint(): string {
107
+ const signalHash = fnv1a(collectSignals());
108
+ return `${generateUuid()}.${signalHash}`;
109
+ }
110
+
111
+ /**
112
+ * Return the cached fingerprint if one was already computed for this browser,
113
+ * otherwise compute + persist a new one via the provided accessors. The
114
+ * persistence layer owns storage; this function owns the "compute once" policy.
115
+ */
116
+ export function getOrCreateFingerprint(get: () => string | null, set: (fp: string) => void): string {
117
+ const existing = get();
118
+ if (existing) return existing;
119
+ const fp = computeFingerprint();
120
+ set(fp);
121
+ return fp;
122
+ }
package/src/index.ts CHANGED
@@ -33,9 +33,23 @@ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config
33
33
  export { initChatWidgetLoader } from './loader-core.js';
34
34
  export {
35
35
  ConversationController,
36
+ httpBaseFromWsEndpoint,
36
37
  type ChatMessage,
37
38
  type Citation,
38
39
  type ConnectionStatus,
39
40
  type ConversationEvents,
41
+ type IdentityRestore,
42
+ type RestorableConversation,
40
43
  type Role,
44
+ type UserInfo,
41
45
  } from './conversation.js';
46
+ export {
47
+ createWidgetStore,
48
+ PERSIST_VERSION,
49
+ storageKey,
50
+ type ConsentState,
51
+ type IdentityState,
52
+ type PersistedWidgetState,
53
+ type WidgetStore,
54
+ } from './persistence.js';
55
+ export { computeFingerprint, getOrCreateFingerprint } from './fingerprint.js';