@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/dist/chat-widget.global.js +1160 -31
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +331 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1005 -32
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +247 -10
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +66 -0
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
${
|
|
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‑operator</b
|
|
306
|
+
<div class="footer">powered by <b>smooth‑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
|
-
/**
|
|
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
|
-
|
|
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';
|