@smooai/chat-widget 0.6.0 → 0.9.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
@@ -14,15 +14,41 @@
14
14
  * <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
15
15
  * or programmatically via {@link mountChatWidget}.
16
16
  */
17
+ import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min';
17
18
  import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
18
19
  import { needsUserInfo, resolveConfig } from './config.js';
19
- import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js';
20
+ import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
20
21
  import { SMOOTH_LOGO_SVG } from './logo.js';
21
22
  import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
22
23
  import { buildStyles } from './styles.js';
23
24
 
24
25
  export const ELEMENT_TAG = 'smooth-agent-chat';
25
26
 
27
+ /**
28
+ * Default region for phone parsing/formatting on the pre-chat form. The widget
29
+ * is US-first; the backend does the authoritative E.164 normalization (SMOODEV-2153),
30
+ * so this only governs the as-you-type display + the inline validity hint and the
31
+ * best-effort E.164 we send when the number already parses as valid.
32
+ */
33
+ const PHONE_DEFAULT_REGION = 'US' as const;
34
+
35
+ /**
36
+ * Best-effort E.164 for an as-typed phone number. Returns the canonical
37
+ * `+1…` form when the value parses to a valid number in {@link PHONE_DEFAULT_REGION},
38
+ * otherwise `null` (caller falls back to sending the raw value — the backend
39
+ * re-parses and normalizes/nulls authoritatively).
40
+ */
41
+ function phoneToE164(value: string): string | null {
42
+ const v = value.trim();
43
+ if (!v) return null;
44
+ try {
45
+ if (!isValidPhoneNumber(v, PHONE_DEFAULT_REGION)) return null;
46
+ return parsePhoneNumber(v, PHONE_DEFAULT_REGION).number;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
26
52
  const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'placeholder', 'greeting', 'start-open', 'mode'] as const;
27
53
 
28
54
  /**
@@ -73,6 +99,12 @@ export class SmoothAgentChatElement extends HTMLElement {
73
99
  /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
74
100
  private interrupt: Interrupt | null = null;
75
101
  private interruptEl: HTMLElement | null = null;
102
+ /** Cross-device "restore my chats" flow state (ADR-048 §c). */
103
+ private identityRestore: IdentityRestore = { phase: 'idle' };
104
+ /** Whether the cross-device restore affordance is offered (config). */
105
+ private allowChatRestore = true;
106
+ /** True while the pre-chat identity gate is showing (blocks premature connect). */
107
+ private gating = false;
76
108
 
77
109
  // Cached DOM refs (populated in render()).
78
110
  private panelEl: HTMLElement | null = null;
@@ -136,7 +168,10 @@ export class SmoothAgentChatElement extends HTMLElement {
136
168
  openChat(): void {
137
169
  this.open = true;
138
170
  this.syncOpenState();
139
- void this.controller?.connect().catch(() => {});
171
+ // Don't connect while the pre-chat identity gate is unsatisfied — connecting
172
+ // here would create a session BEFORE the visitor submits their name/email/
173
+ // consent, sending an empty identity. The form's submit handler connects.
174
+ if (!this.gating) void this.controller?.connect().catch(() => {});
140
175
  }
141
176
 
142
177
  /** Collapse the chat panel back to the launcher. */
@@ -163,6 +198,7 @@ export class SmoothAgentChatElement extends HTMLElement {
163
198
  userName: this.overrides.userName,
164
199
  userEmail: this.overrides.userEmail,
165
200
  userPhone: this.overrides.userPhone,
201
+ authContext: this.overrides.authContext,
166
202
  placeholder: this.overrides.placeholder ?? this.getAttribute('placeholder') ?? undefined,
167
203
  greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
168
204
  connectionErrorMessage: this.overrides.connectionErrorMessage,
@@ -171,6 +207,9 @@ export class SmoothAgentChatElement extends HTMLElement {
171
207
  requireName: this.overrides.requireName,
172
208
  requireEmail: this.overrides.requireEmail,
173
209
  requirePhone: this.overrides.requirePhone,
210
+ collectPhone: this.overrides.collectPhone,
211
+ collectConsent: this.overrides.collectConsent,
212
+ allowChatRestore: this.overrides.allowChatRestore,
174
213
  allowAnonymous: this.overrides.allowAnonymous,
175
214
  theme,
176
215
  };
@@ -186,6 +225,8 @@ export class SmoothAgentChatElement extends HTMLElement {
186
225
  }
187
226
  const resolved = resolveConfig(config);
188
227
 
228
+ this.allowChatRestore = resolved.allowChatRestore;
229
+
189
230
  // (Re)create the controller only when there isn't one yet. Attribute churn
190
231
  // (e.g. theme tweaks) re-renders the view without dropping the session.
191
232
  if (!this.controller) {
@@ -202,8 +243,17 @@ export class SmoothAgentChatElement extends HTMLElement {
202
243
  this.interrupt = interrupt;
203
244
  this.renderInterrupt();
204
245
  },
246
+ onIdentityRestore: (state) => {
247
+ this.identityRestore = state;
248
+ this.renderInterrupt();
249
+ },
205
250
  });
206
251
  if (resolved.startOpen) this.open = true;
252
+ // Returning visitor: a persisted session or identity lets us skip the
253
+ // pre-chat gate and resume straight into the conversation (ADR-048 §b).
254
+ if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) {
255
+ this.userInfoSatisfied = true;
256
+ }
207
257
  }
208
258
 
209
259
  const fullpage = resolved.mode === 'fullpage';
@@ -242,8 +292,22 @@ export class SmoothAgentChatElement extends HTMLElement {
242
292
 
243
293
  // Gate the conversation behind a pre-chat identity form when required.
244
294
  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>`;
295
+ this.gating = gating;
296
+ // Phone is collected by default (optional unless requirePhone). Consent
297
+ // checkboxes default to shown, explicit + unchecked (ADR-048 §a/§3).
298
+ const showPhone = resolved.requirePhone || resolved.collectPhone;
299
+ const field = (name: string, type: string, label: string, autocomplete: string, required: boolean, hint = false) =>
300
+ `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? ' required' : ''} />${
301
+ hint ? '<span class="pc-hint" aria-live="polite"></span>' : ''
302
+ }</label>`;
303
+ const consentBox = (name: string, label: string) =>
304
+ `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
305
+ const consentHtml = resolved.collectConsent
306
+ ? `<div class="pc-consents">
307
+ ${consentBox('emailOptIn', 'Email me product news and offers.')}
308
+ ${consentBox('smsOptIn', 'Text me updates by SMS. Message/data rates may apply.')}
309
+ </div>`
310
+ : '';
247
311
  const prechatHtml = `
248
312
  <div class="prechat">
249
313
  <div class="pc-head">
@@ -251,12 +315,14 @@ export class SmoothAgentChatElement extends HTMLElement {
251
315
  <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
252
316
  </div>
253
317
  <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') : ''}
318
+ ${resolved.requireName ? field('name', 'text', 'Name', 'name', true) : ''}
319
+ ${resolved.requireEmail ? field('email', 'email', 'Email', 'email', true) : ''}
320
+ ${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone, true) : ''}
321
+ ${consentHtml}
257
322
  <button type="submit" class="pc-submit">Start chat</button>
258
323
  </form>
259
324
  </div>`;
325
+ const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : '';
260
326
  const chatHtml = `
261
327
  <div class="messages"></div>
262
328
  <div class="interrupt hidden"></div>
@@ -265,7 +331,7 @@ export class SmoothAgentChatElement extends HTMLElement {
265
331
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
266
332
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
267
333
  </div>
268
- <div class="footer">powered by <b>smooth&#8209;operator</b></div>
334
+ <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
269
335
  </div>`;
270
336
 
271
337
  const container = document.createElement('div');
@@ -313,6 +379,27 @@ export class SmoothAgentChatElement extends HTMLElement {
313
379
  this.handlePrechatSubmit(pcForm as HTMLFormElement);
314
380
  });
315
381
 
382
+ // Live phone formatting + validity hint (libphonenumber-js, US default).
383
+ // The implicit <label>, type="tel", and autocomplete="tel" from field()
384
+ // are preserved — autofill keeps working — and we also reformat on
385
+ // `change` so a browser-autofilled value gets formatted/validated too.
386
+ this.wirePhoneField(pcForm as HTMLFormElement | null);
387
+
388
+ // Cross-device "Restore my chats": open the panel + start the email entry.
389
+ // AWAIT connect() before showing the email step so a `sessionId` exists by
390
+ // the time the visitor submits — otherwise request-otp could go out with no
391
+ // session and verify-otp would then hard-error "No active session." The
392
+ // request-otp/verify-otp paths in the controller also require a session, so
393
+ // gating here keeps the affordance race-free.
394
+ container.querySelector('.restore-link')?.addEventListener('click', () => {
395
+ void (async () => {
396
+ this.identityRestore = { phase: 'awaiting_email' };
397
+ this.renderInterrupt();
398
+ // Establish a live session before the email entry can fire request-otp.
399
+ await this.controller?.connect().catch(() => {});
400
+ })();
401
+ });
402
+
316
403
  // Full-page mode connects eagerly (there's no launcher click to trigger it) —
317
404
  // but only once any identity gate is cleared.
318
405
  if (fullpage && !gating) void this.controller?.connect().catch(() => {});
@@ -335,6 +422,13 @@ export class SmoothAgentChatElement extends HTMLElement {
335
422
  el.replaceChildren();
336
423
  const it = this.interrupt;
337
424
  if (!it) {
425
+ // No mid-turn interrupt — but the cross-device restore flow may be
426
+ // active, which reuses this same overlay slot.
427
+ if (this.identityRestore.phase !== 'idle') {
428
+ el.classList.remove('hidden');
429
+ el.appendChild(this.buildRestoreCard());
430
+ return;
431
+ }
338
432
  el.classList.add('hidden');
339
433
  return;
340
434
  }
@@ -420,12 +514,270 @@ export class SmoothAgentChatElement extends HTMLElement {
420
514
  el.appendChild(card);
421
515
  }
422
516
 
423
- /** Collect identity from the pre-chat form, then drop into the chat view. */
517
+ /**
518
+ * Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
519
+ * same overlay slot + visual language as the OTP interrupt. All server-supplied
520
+ * strings (masked destination, conversation previews) are set via `textContent`
521
+ * — never innerHTML — so they can't inject markup; only the static lock icon
522
+ * uses innerHTML.
523
+ */
524
+ private buildRestoreCard(): HTMLElement {
525
+ const state = this.identityRestore;
526
+ const card = document.createElement('div');
527
+ card.className = 'int-card';
528
+
529
+ const head = document.createElement('div');
530
+ head.className = 'int-head';
531
+ const ico = document.createElement('span');
532
+ ico.className = 'int-ico';
533
+ ico.innerHTML = ICON.lock; // static, trusted
534
+ const title = document.createElement('span');
535
+ title.className = 'int-title';
536
+ title.textContent = 'Restore your chats';
537
+ head.append(ico, title);
538
+ card.appendChild(head);
539
+
540
+ const close = document.createElement('button');
541
+ close.className = 'int-close';
542
+ close.type = 'button';
543
+ close.setAttribute('aria-label', 'Cancel');
544
+ close.textContent = '×';
545
+ close.addEventListener('click', () => {
546
+ this.controller?.cancelIdentityRestore();
547
+ this.identityRestore = { phase: 'idle' };
548
+ this.renderInterrupt();
549
+ });
550
+ card.appendChild(close);
551
+
552
+ if (state.phase === 'awaiting_email') {
553
+ const desc = document.createElement('div');
554
+ desc.className = 'int-desc';
555
+ desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
556
+ card.appendChild(desc);
557
+
558
+ const row = document.createElement('div');
559
+ row.className = 'int-row';
560
+ const input = document.createElement('input');
561
+ input.className = 'int-input';
562
+ input.type = 'email';
563
+ input.autocomplete = 'email';
564
+ input.placeholder = 'you@example.com';
565
+ const go = () => {
566
+ const email = input.value.trim();
567
+ if (email) void this.controller?.requestIdentityOtp(email, 'email');
568
+ };
569
+ input.addEventListener('keydown', (ev) => {
570
+ if (ev.key === 'Enter') {
571
+ ev.preventDefault();
572
+ go();
573
+ }
574
+ });
575
+ const send = document.createElement('button');
576
+ send.className = 'int-btn primary';
577
+ send.type = 'button';
578
+ send.textContent = 'Send code';
579
+ send.addEventListener('click', go);
580
+ row.append(input, send);
581
+ card.appendChild(row);
582
+ if (state.error) {
583
+ const err = document.createElement('div');
584
+ err.className = 'int-error';
585
+ err.textContent = state.error;
586
+ card.appendChild(err);
587
+ }
588
+ queueMicrotask(() => input.focus());
589
+ } else if (state.phase === 'requesting' || state.phase === 'verifying' || state.phase === 'resolving') {
590
+ const msg = document.createElement('div');
591
+ msg.className = 'int-sent';
592
+ msg.textContent = state.phase === 'requesting' ? 'Sending a code…' : state.phase === 'verifying' ? 'Verifying…' : 'Finding your chats…';
593
+ card.appendChild(msg);
594
+ } else if (state.phase === 'awaiting_code') {
595
+ if (state.maskedDestination) {
596
+ const sent = document.createElement('div');
597
+ sent.className = 'int-sent';
598
+ sent.textContent = `Code sent to ${state.maskedDestination}.`;
599
+ card.appendChild(sent);
600
+ }
601
+ const row = document.createElement('div');
602
+ row.className = 'int-row';
603
+ const input = document.createElement('input');
604
+ input.className = 'int-input';
605
+ input.type = 'text';
606
+ input.inputMode = 'numeric';
607
+ input.autocomplete = 'one-time-code';
608
+ input.placeholder = 'Enter code';
609
+ const submit = () => {
610
+ const code = input.value.trim();
611
+ if (code) void this.controller?.verifyIdentityOtp(code);
612
+ };
613
+ input.addEventListener('keydown', (ev) => {
614
+ if (ev.key === 'Enter') {
615
+ ev.preventDefault();
616
+ submit();
617
+ }
618
+ });
619
+ const verify = document.createElement('button');
620
+ verify.className = 'int-btn primary';
621
+ verify.type = 'button';
622
+ verify.textContent = 'Verify';
623
+ verify.addEventListener('click', submit);
624
+ row.append(input, verify);
625
+ card.appendChild(row);
626
+ if (state.error) {
627
+ const err = document.createElement('div');
628
+ err.className = 'int-error';
629
+ err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
630
+ card.appendChild(err);
631
+ }
632
+ queueMicrotask(() => input.focus());
633
+ } else if (state.phase === 'resolved') {
634
+ if (state.conversations.length === 0) {
635
+ const none = document.createElement('div');
636
+ none.className = 'int-desc';
637
+ none.textContent = 'No previous chats found for that email.';
638
+ card.appendChild(none);
639
+ } else {
640
+ const pick = document.createElement('div');
641
+ pick.className = 'int-desc';
642
+ pick.textContent = 'Pick a conversation to continue:';
643
+ card.appendChild(pick);
644
+ const list = document.createElement('div');
645
+ list.className = 'restore-list';
646
+ for (const conv of state.conversations) {
647
+ const btn = document.createElement('button');
648
+ btn.type = 'button';
649
+ btn.className = 'restore-item';
650
+ const preview = document.createElement('span');
651
+ preview.className = 'restore-preview';
652
+ preview.textContent = conv.preview || 'Conversation';
653
+ btn.appendChild(preview);
654
+ if (conv.lastActivityAt) {
655
+ const when = document.createElement('span');
656
+ when.className = 'restore-when';
657
+ when.textContent = this.formatWhen(conv.lastActivityAt);
658
+ btn.appendChild(when);
659
+ }
660
+ btn.addEventListener('click', () => {
661
+ void this.controller?.restoreConversation(conv.sessionId);
662
+ });
663
+ list.appendChild(btn);
664
+ }
665
+ card.appendChild(list);
666
+ }
667
+ } else if (state.phase === 'error') {
668
+ const err = document.createElement('div');
669
+ err.className = 'int-error';
670
+ err.textContent = state.message;
671
+ card.appendChild(err);
672
+ }
673
+
674
+ return card;
675
+ }
676
+
677
+ /** Format an ISO timestamp as a short, locale-aware label (best-effort). */
678
+ private formatWhen(iso: string): string {
679
+ const d = new Date(iso);
680
+ if (Number.isNaN(d.getTime())) return '';
681
+ try {
682
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
683
+ } catch {
684
+ return '';
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Wire as-you-type formatting + an inline validity hint onto the pre-chat
690
+ * phone input (libphonenumber-js, US default region). Autofill is preserved:
691
+ * the input keeps its `type="tel"` + `autocomplete="tel"` + implicit <label>,
692
+ * and we also reformat on `change` so a browser-autofilled value gets
693
+ * formatted/validated too.
694
+ *
695
+ * As-you-type caret note: `AsYouType` reformats the entire string, which
696
+ * moves the caret to the end on a mid-string edit. To avoid fighting the
697
+ * user, we only rewrite the value when the caret is at the end (the typical
698
+ * append-a-digit case) and never on a deletion — so backspacing the
699
+ * formatting characters works naturally.
700
+ */
701
+ private wirePhoneField(form: HTMLFormElement | null): void {
702
+ const input = form?.querySelector('input[name="phone"]') as HTMLInputElement | null;
703
+ if (!input) return;
704
+ const hint = input.parentElement?.querySelector('.pc-hint') as HTMLElement | null;
705
+
706
+ const updateHint = () => {
707
+ const v = input.value.trim();
708
+ const field = input.closest('.pc-field');
709
+ if (!v) {
710
+ // Empty is neutral — the field is optional unless requirePhone.
711
+ field?.classList.remove('valid', 'invalid');
712
+ if (hint) hint.textContent = '';
713
+ return;
714
+ }
715
+ const ok = isValidPhoneNumber(v, PHONE_DEFAULT_REGION);
716
+ field?.classList.toggle('valid', ok);
717
+ field?.classList.toggle('invalid', !ok);
718
+ if (hint) hint.textContent = ok ? '' : 'Enter a valid phone number';
719
+ };
720
+
721
+ const reformat = () => {
722
+ const atEnd = input.selectionStart === input.value.length && input.selectionEnd === input.value.length;
723
+ // Only reformat when appending at the end; never fight a mid-string
724
+ // edit or a deletion (see the caret note above).
725
+ if (atEnd) {
726
+ const formatted = new AsYouType(PHONE_DEFAULT_REGION).input(input.value);
727
+ // Avoid clobbering when the user is deleting: only grow/normalize,
728
+ // not when the formatter would re-add a character they just removed.
729
+ if (formatted.length >= input.value.length) {
730
+ input.value = formatted;
731
+ }
732
+ }
733
+ updateHint();
734
+ };
735
+
736
+ input.addEventListener('input', (ev) => {
737
+ const ie = ev as InputEvent;
738
+ // Don't reformat while deleting — let the user clear characters freely.
739
+ if (typeof ie.inputType === 'string' && ie.inputType.startsWith('delete')) {
740
+ updateHint();
741
+ return;
742
+ }
743
+ reformat();
744
+ });
745
+ // Browser autofill / paste-then-blur lands here; format + validate it too.
746
+ input.addEventListener('change', reformat);
747
+ }
748
+
749
+ /** Collect identity + consent from the pre-chat form, then drop into the chat view. */
424
750
  private handlePrechatSubmit(form: HTMLFormElement): void {
425
751
  if (!form.reportValidity()) return;
426
752
  const data = new FormData(form);
427
753
  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') });
754
+ const checked = (k: string) => data.get(k) === 'on';
755
+
756
+ // Phone: when required, block on an invalid number and surface the hint.
757
+ // When optional, allow submit — the backend normalizes/nulls authoritatively.
758
+ const rawPhone = val('phone');
759
+ const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
760
+ if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
761
+ const required = phoneInput.hasAttribute('required');
762
+ const field = phoneInput.closest('.pc-field');
763
+ field?.classList.add('invalid');
764
+ const hint = field?.querySelector('.pc-hint');
765
+ if (hint) hint.textContent = 'Enter a valid phone number';
766
+ if (required) {
767
+ phoneInput.focus();
768
+ return;
769
+ }
770
+ }
771
+ // Prefer canonical E.164 when it parses; fall back to the raw value
772
+ // otherwise (the backend re-parses + normalizes either way).
773
+ const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;
774
+
775
+ this.controller?.setUserInfo({
776
+ name: val('name'),
777
+ email: val('email'),
778
+ phone,
779
+ consent: { emailOptIn: checked('emailOptIn'), smsOptIn: checked('smsOptIn') },
780
+ });
429
781
  this.userInfoSatisfied = true;
430
782
  this.render();
431
783
  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';