@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/dist/chat-widget.global.js +12721 -31
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +345 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1125 -32
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +362 -10
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +84 -0
package/src/conversation.ts
CHANGED
|
@@ -8,15 +8,54 @@
|
|
|
8
8
|
* so the swap is purely at the client-library boundary.
|
|
9
9
|
*
|
|
10
10
|
* Flow:
|
|
11
|
-
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session
|
|
11
|
+
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
|
|
12
|
+
* (or RESUMES a persisted session via `get_session`/`get_messages`).
|
|
12
13
|
* 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
|
|
13
14
|
* in-progress assistant message, then the terminal
|
|
14
15
|
* `eventual_response`.
|
|
15
16
|
*
|
|
17
|
+
* 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
|
|
18
|
+
* - `browserFingerprint` computed once + sent on every `create_conversation_session`.
|
|
19
|
+
* - identity + marketing consent threaded into the session `metadata`.
|
|
20
|
+
* - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
|
|
21
|
+
* - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
|
|
22
|
+
* cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
|
|
23
|
+
* verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
|
|
24
|
+
* 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
|
|
25
|
+
* `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
|
|
26
|
+
* NOT WS frames.
|
|
27
|
+
*
|
|
16
28
|
* The controller is UI-agnostic: it emits typed events and the view renders them.
|
|
17
29
|
*/
|
|
18
30
|
import { type Citation, ProtocolError, type ServerEvent, SmoothAgentClient } from '@smooai/smooth-operator';
|
|
31
|
+
import type { StoreApi } from 'zustand/vanilla';
|
|
19
32
|
import type { ChatWidgetConfig } from './config.js';
|
|
33
|
+
import { getOrCreateFingerprint } from './fingerprint.js';
|
|
34
|
+
import { type ConsentState, createWidgetStore, type WidgetStore } from './persistence.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
|
|
38
|
+
* the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
|
|
39
|
+
* (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
|
|
40
|
+
* the cross-device identity flow + fingerprint resume are HTTP POST routes on the
|
|
41
|
+
* wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
|
|
42
|
+
*/
|
|
43
|
+
export function httpBaseFromWsEndpoint(endpoint: string): string | null {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(endpoint);
|
|
46
|
+
u.protocol = u.protocol === 'ws:' ? 'http:' : u.protocol === 'wss:' ? 'https:' : u.protocol;
|
|
47
|
+
// The REST routes live at the host root (`/internal/*`), not under `/ws`.
|
|
48
|
+
return `${u.protocol}//${u.host}`;
|
|
49
|
+
} catch {
|
|
50
|
+
// FAIL LOUD on a non-absolute endpoint. A relative fallback (e.g.
|
|
51
|
+
// `string.replace`) would yield a relative base, and `fetch(\`${base}/internal/...\`)`
|
|
52
|
+
// would then POST identity/OTP data to the HOST page origin (e.g. smoo.ai)
|
|
53
|
+
// instead of the operator host (ai.smoo.ai) — leaking it to the wrong origin.
|
|
54
|
+
// Returning null forces the controller into an error state and refuses the
|
|
55
|
+
// `/internal/*` calls rather than mis-targeting them.
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
20
59
|
|
|
21
60
|
export type { Citation };
|
|
22
61
|
|
|
@@ -66,8 +105,35 @@ export interface UserInfo {
|
|
|
66
105
|
name?: string;
|
|
67
106
|
email?: string;
|
|
68
107
|
phone?: string;
|
|
108
|
+
/** Marketing-consent opt-ins captured at the pre-chat form (ADR-048). */
|
|
109
|
+
consent?: { emailOptIn: boolean; smsOptIn: boolean };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One conversation surfaced by `resolve_identity` for the cross-device picker. */
|
|
113
|
+
export interface RestorableConversation {
|
|
114
|
+
conversationId: string;
|
|
115
|
+
sessionId: string;
|
|
116
|
+
lastActivityAt?: string;
|
|
117
|
+
preview?: string;
|
|
69
118
|
}
|
|
70
119
|
|
|
120
|
+
/**
|
|
121
|
+
* State machine for the cross-device "restore my chats" flow. Driven by the three
|
|
122
|
+
* HTTP POST routes on the chat-ws wrapper — `/internal/identity/request-otp` →
|
|
123
|
+
* `/internal/identity/verify-otp` → `/internal/identity/resolve` (ADR-048 §c).
|
|
124
|
+
* The view renders a panel off this.
|
|
125
|
+
*/
|
|
126
|
+
export type IdentityRestore =
|
|
127
|
+
| { phase: 'idle' }
|
|
128
|
+
/** UI-local: the email-entry step before any request is sent. */
|
|
129
|
+
| { phase: 'awaiting_email'; error?: string }
|
|
130
|
+
| { phase: 'requesting'; email: string; channel: 'email' | 'sms' }
|
|
131
|
+
| { phase: 'awaiting_code'; email: string; channel: 'email' | 'sms'; maskedDestination?: string; error?: string; attemptsRemaining?: number }
|
|
132
|
+
| { phase: 'verifying'; email: string; channel: 'email' | 'sms' }
|
|
133
|
+
| { phase: 'resolving'; email: string }
|
|
134
|
+
| { phase: 'resolved'; email: string; conversations: RestorableConversation[] }
|
|
135
|
+
| { phase: 'error'; message: string };
|
|
136
|
+
|
|
71
137
|
export interface ConversationEvents {
|
|
72
138
|
/** Fired whenever the message list changes (append, token delta, finalize). */
|
|
73
139
|
onMessages: (messages: ChatMessage[]) => void;
|
|
@@ -75,6 +141,8 @@ export interface ConversationEvents {
|
|
|
75
141
|
onStatus: (status: ConnectionStatus, detail?: string) => void;
|
|
76
142
|
/** Fired when a turn pauses for OTP / tool-confirmation, and `null` when it clears. */
|
|
77
143
|
onInterrupt?: (interrupt: Interrupt | null) => void;
|
|
144
|
+
/** Fired on cross-device identity-restore state transitions. */
|
|
145
|
+
onIdentityRestore?: (state: IdentityRestore) => void;
|
|
78
146
|
}
|
|
79
147
|
|
|
80
148
|
/** Pull the final assistant text out of an `eventual_response` data payload. */
|
|
@@ -115,33 +183,110 @@ function extractCitations(inner: unknown): Citation[] {
|
|
|
115
183
|
return out;
|
|
116
184
|
}
|
|
117
185
|
|
|
186
|
+
/** A `get_conversation_messages` row, narrowed defensively off the wire. */
|
|
187
|
+
interface WireMessage {
|
|
188
|
+
id?: string;
|
|
189
|
+
direction?: 'inbound' | 'outbound';
|
|
190
|
+
content?: { text?: string };
|
|
191
|
+
createdAt?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Convert a server message row into a finalized {@link ChatMessage}. */
|
|
195
|
+
function wireMessageToChat(m: WireMessage, idx: number): ChatMessage | null {
|
|
196
|
+
const text = typeof m.content?.text === 'string' ? m.content.text : '';
|
|
197
|
+
if (!text) return null;
|
|
198
|
+
const role: Role = m.direction === 'outbound' ? 'assistant' : 'user';
|
|
199
|
+
return { id: typeof m.id === 'string' ? m.id : `hist-${idx}`, role, text, streaming: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
118
202
|
export class ConversationController {
|
|
119
203
|
private readonly config: ChatWidgetConfig;
|
|
120
204
|
private readonly events: ConversationEvents;
|
|
205
|
+
private readonly store: StoreApi<WidgetStore>;
|
|
121
206
|
private client: SmoothAgentClient | null = null;
|
|
122
207
|
private sessionId: string | null = null;
|
|
123
208
|
private readonly messages: ChatMessage[] = [];
|
|
124
209
|
private status: ConnectionStatus = 'idle';
|
|
125
210
|
private seq = 0;
|
|
126
|
-
/** Visitor identity, seeded from config and updated by the pre-chat form. */
|
|
127
|
-
private identity: UserInfo;
|
|
128
211
|
/** requestId of the in-flight turn — used to resume OTP / tool confirmations. */
|
|
129
212
|
private activeRequestId: string | null = null;
|
|
130
213
|
private interrupt: Interrupt | null = null;
|
|
214
|
+
private identityRestore: IdentityRestore = { phase: 'idle' };
|
|
215
|
+
/**
|
|
216
|
+
* True once the resume probe (persisted-pointer get_session OR the
|
|
217
|
+
* `/internal/resume-by-fingerprint` POST) has run for this controller. Makes
|
|
218
|
+
* `connect()` idempotent: re-entering after a transient `error` status (e.g. a
|
|
219
|
+
* retried `send()`) creates a fresh session rather than re-running the resume
|
|
220
|
+
* probe — which would fire another `resumeByFingerprint` POST and could adopt a
|
|
221
|
+
* session we already decided not to resume.
|
|
222
|
+
*/
|
|
223
|
+
private resumeAttempted = false;
|
|
224
|
+
/**
|
|
225
|
+
* HTTP base for the chat-ws wrapper's `/internal/*` REST routes. `null` when
|
|
226
|
+
* the configured WS endpoint could not be parsed into an absolute origin — in
|
|
227
|
+
* that case the `/internal/*` routes are refused (rather than mis-targeted at
|
|
228
|
+
* the host page origin). See {@link httpBaseFromWsEndpoint}.
|
|
229
|
+
*/
|
|
230
|
+
private readonly httpBase: string | null;
|
|
131
231
|
|
|
132
|
-
constructor(config: ChatWidgetConfig, events: ConversationEvents) {
|
|
232
|
+
constructor(config: ChatWidgetConfig, events: ConversationEvents, store?: StoreApi<WidgetStore>) {
|
|
133
233
|
this.config = config;
|
|
134
234
|
this.events = events;
|
|
135
|
-
this.
|
|
235
|
+
this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
|
|
236
|
+
if (this.httpBase === null) {
|
|
237
|
+
// A non-absolute endpoint means the identity / resume `/internal/*` routes
|
|
238
|
+
// have no safe target. Flag the controller in error so the UI surfaces it
|
|
239
|
+
// and the routes refuse (see postInternal) rather than mis-targeting the
|
|
240
|
+
// host page origin. Deferred to a microtask so listeners attached after
|
|
241
|
+
// construction still observe the transition.
|
|
242
|
+
queueMicrotask(() => this.setStatus('error', `Invalid chat endpoint: ${config.endpoint}`));
|
|
243
|
+
}
|
|
244
|
+
this.store = store ?? createWidgetStore(config.agentId);
|
|
245
|
+
// Seed identity from config into the persisted store. `mergeIdentity` is
|
|
246
|
+
// applied on EVERY construct, so a config-provided field always wins over
|
|
247
|
+
// the persisted value (config is authoritative when present). Fields the
|
|
248
|
+
// config does NOT provide keep their persisted value — those survive across
|
|
249
|
+
// reloads; explicitly-configured ones are re-applied each load.
|
|
250
|
+
const seed: { name?: string; email?: string; phone?: string } = {};
|
|
251
|
+
if (config.userName) seed.name = config.userName;
|
|
252
|
+
if (config.userEmail) seed.email = config.userEmail;
|
|
253
|
+
if (config.userPhone) seed.phone = config.userPhone;
|
|
254
|
+
if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
|
|
136
255
|
}
|
|
137
256
|
|
|
138
257
|
get connectionStatus(): ConnectionStatus {
|
|
139
258
|
return this.status;
|
|
140
259
|
}
|
|
141
260
|
|
|
142
|
-
/**
|
|
261
|
+
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
262
|
+
getStore(): StoreApi<WidgetStore> {
|
|
263
|
+
return this.store;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** True when a persisted session pointer exists (drives the resume path). */
|
|
267
|
+
hasPersistedSession(): boolean {
|
|
268
|
+
return !!this.store.getState().sessionId;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** True when persisted identity exists (lets the view skip the pre-chat form). */
|
|
272
|
+
hasPersistedIdentity(): boolean {
|
|
273
|
+
const id = this.store.getState().identity;
|
|
274
|
+
return !!(id.name || id.email || id.phone);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
|
|
143
278
|
setUserInfo(info: UserInfo): void {
|
|
144
|
-
|
|
279
|
+
const { name, email, phone, consent } = info;
|
|
280
|
+
this.store.getState().mergeIdentity({ name, email, phone });
|
|
281
|
+
if (consent) {
|
|
282
|
+
const consentAt = consent.emailOptIn || consent.smsOptIn ? new Date().toISOString() : undefined;
|
|
283
|
+
this.store.getState().setConsent({
|
|
284
|
+
emailOptIn: consent.emailOptIn,
|
|
285
|
+
smsOptIn: consent.smsOptIn,
|
|
286
|
+
consentSource: 'chat-widget-prechat',
|
|
287
|
+
consentAt,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
145
290
|
}
|
|
146
291
|
|
|
147
292
|
private setInterrupt(interrupt: Interrupt | null): void {
|
|
@@ -149,6 +294,15 @@ export class ConversationController {
|
|
|
149
294
|
this.events.onInterrupt?.(interrupt);
|
|
150
295
|
}
|
|
151
296
|
|
|
297
|
+
private setIdentityRestore(state: IdentityRestore): void {
|
|
298
|
+
this.identityRestore = state;
|
|
299
|
+
this.events.onIdentityRestore?.(state);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
get currentIdentityRestore(): IdentityRestore {
|
|
303
|
+
return this.identityRestore;
|
|
304
|
+
}
|
|
305
|
+
|
|
152
306
|
/** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
|
|
153
307
|
verifyOtp(code: string): void {
|
|
154
308
|
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== 'otp') return;
|
|
@@ -177,21 +331,115 @@ export class ConversationController {
|
|
|
177
331
|
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
178
332
|
}
|
|
179
333
|
|
|
180
|
-
/**
|
|
334
|
+
/** Compute (once) + return the persisted browser fingerprint. */
|
|
335
|
+
private fingerprint(): string {
|
|
336
|
+
const state = this.store.getState();
|
|
337
|
+
return getOrCreateFingerprint(
|
|
338
|
+
() => state.browserFingerprint,
|
|
339
|
+
(fp) => this.store.getState().setBrowserFingerprint(fp),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Build the `metadata` payload threaded into `create_conversation_session`:
|
|
345
|
+
* phone (no first-class engine field) and consent.
|
|
346
|
+
*
|
|
347
|
+
* NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
|
|
348
|
+
* OTP proof bound to the session it was verified against
|
|
349
|
+
* (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
|
|
350
|
+
* as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
|
|
351
|
+
* every brand-new `create_conversation_session` would mislabel a fresh
|
|
352
|
+
* visitor's session with a prior (possibly different) visitor's email on a
|
|
353
|
+
* shared browser. The verified email is only used when RESUMING the exact
|
|
354
|
+
* session it was proven for — see {@link verifiedEmailForSession}.
|
|
355
|
+
*/
|
|
356
|
+
private sessionMetadata(): Record<string, unknown> | undefined {
|
|
357
|
+
const state = this.store.getState();
|
|
358
|
+
const meta: Record<string, unknown> = {};
|
|
359
|
+
if (state.identity.phone) meta.userPhone = state.identity.phone;
|
|
360
|
+
const consent = state.consent;
|
|
361
|
+
if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
|
|
362
|
+
const c: ConsentState = {
|
|
363
|
+
emailOptIn: consent.emailOptIn,
|
|
364
|
+
smsOptIn: consent.smsOptIn,
|
|
365
|
+
consentSource: consent.consentSource ?? 'chat-widget-prechat',
|
|
366
|
+
};
|
|
367
|
+
if (consent.consentAt) c.consentAt = consent.consentAt;
|
|
368
|
+
meta.consent = c;
|
|
369
|
+
}
|
|
370
|
+
return Object.keys(meta).length > 0 ? meta : undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The verified-email hint, but ONLY when the OTP proof is bound to the session
|
|
375
|
+
* being resumed (`verifiedEmailSessionId === sessionId`). Returns null
|
|
376
|
+
* otherwise so a stale/cross-visitor proof is never threaded onto a session it
|
|
377
|
+
* wasn't proven for.
|
|
378
|
+
*/
|
|
379
|
+
private verifiedEmailForSession(sessionId: string): string | null {
|
|
380
|
+
const state = this.store.getState();
|
|
381
|
+
if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) {
|
|
382
|
+
return state.verifiedEmail;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Lazily open the WS client (default transport). Idempotent within a connect. */
|
|
388
|
+
private async ensureClient(): Promise<void> {
|
|
389
|
+
if (this.client) return;
|
|
390
|
+
this.client = new SmoothAgentClient({ url: this.config.endpoint });
|
|
391
|
+
await this.client.connect();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Open the connection and either RESUME or create a session.
|
|
396
|
+
*
|
|
397
|
+
* 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
|
|
398
|
+
* hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
|
|
399
|
+
* ONLY the pointer (identity/consent survive).
|
|
400
|
+
* 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
|
|
401
|
+
* `resumable`, adopt the returned session (the wrapper has primed the
|
|
402
|
+
* operator registry), reuse the sessionId, and hydrate via get_session/
|
|
403
|
+
* get_messages — rather than relying on createConversationSession to resume.
|
|
404
|
+
* 3. Otherwise create a fresh session.
|
|
405
|
+
*/
|
|
181
406
|
async connect(): Promise<void> {
|
|
182
407
|
if (this.status === 'connecting' || this.status === 'ready') return;
|
|
183
408
|
this.setStatus('connecting');
|
|
184
409
|
try {
|
|
185
|
-
this.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
410
|
+
await this.ensureClient();
|
|
411
|
+
// The resume probe (persisted-pointer get_session OR the fingerprint
|
|
412
|
+
// resume POST) runs AT MOST ONCE per controller lifecycle. Re-entering
|
|
413
|
+
// connect() after a transient error (e.g. a retried send()) must not
|
|
414
|
+
// re-run the probe — that would re-fire resumeByFingerprint and could
|
|
415
|
+
// adopt a session we already chose not to resume. After the first
|
|
416
|
+
// attempt, fall straight through to creating a fresh session.
|
|
417
|
+
if (!this.resumeAttempted) {
|
|
418
|
+
this.resumeAttempted = true;
|
|
419
|
+
const persistedSessionId = this.store.getState().sessionId;
|
|
420
|
+
if (persistedSessionId) {
|
|
421
|
+
const resumed = await this.tryResume(persistedSessionId);
|
|
422
|
+
if (resumed) {
|
|
423
|
+
this.setStatus('ready');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Resume failed (ended/404/gone) — clear the pointer, keep identity.
|
|
427
|
+
this.store.getState().clearSession();
|
|
428
|
+
} else {
|
|
429
|
+
// Returning anonymous visitor with no stored pointer: ask the
|
|
430
|
+
// wrapper to resolve a recent session for this fingerprint.
|
|
431
|
+
const fpSessionId = await this.resumeByFingerprint();
|
|
432
|
+
if (fpSessionId) {
|
|
433
|
+
const resumed = await this.tryResume(fpSessionId);
|
|
434
|
+
if (resumed) {
|
|
435
|
+
this.store.getState().setSessionId(fpSessionId);
|
|
436
|
+
this.setStatus('ready');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
await this.createSession();
|
|
195
443
|
this.setStatus('ready');
|
|
196
444
|
} catch (err) {
|
|
197
445
|
this.setStatus('error', err instanceof Error ? err.message : String(err));
|
|
@@ -199,6 +447,128 @@ export class ConversationController {
|
|
|
199
447
|
}
|
|
200
448
|
}
|
|
201
449
|
|
|
450
|
+
// ─────────────────────── chat-ws `/internal/*` HTTP ─────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Build the auth fields every `/internal/*` route shares: `agentId` (required
|
|
454
|
+
* for the agent-policy lookup), `agentName` (used as the OTP email sender), and
|
|
455
|
+
* the optional pre-auth `authContext` the host page may have configured. The
|
|
456
|
+
* `Origin` header is sent automatically by the browser and checked server-side.
|
|
457
|
+
*/
|
|
458
|
+
private authBody(): Record<string, unknown> {
|
|
459
|
+
const body: Record<string, unknown> = { agentId: this.config.agentId };
|
|
460
|
+
if (this.config.agentName) body.agentName = this.config.agentName;
|
|
461
|
+
if (this.config.authContext) body.authContext = this.config.authContext;
|
|
462
|
+
return body;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
|
|
466
|
+
private async postInternal(path: string, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
467
|
+
if (this.httpBase === null) {
|
|
468
|
+
// No absolute origin could be derived from the WS endpoint — refuse the
|
|
469
|
+
// call loudly rather than POST identity data to a relative (host-page) URL.
|
|
470
|
+
throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
|
|
471
|
+
}
|
|
472
|
+
const res = await fetch(`${this.httpBase}${path}`, {
|
|
473
|
+
method: 'POST',
|
|
474
|
+
headers: { 'content-type': 'application/json' },
|
|
475
|
+
// Auth is the `Origin` allowlist + the `authContext` body field — NOT
|
|
476
|
+
// cookies. `credentials: 'include'` would force the server to reply with
|
|
477
|
+
// `Access-Control-Allow-Credentials: true` AND a reflected origin (a
|
|
478
|
+
// wildcard `*` is illegal with credentials), so a plain origin-allowlisted
|
|
479
|
+
// CORS config would fail the preflight and break EVERY `/internal/*` call.
|
|
480
|
+
// Omit credentials so the cross-origin POST works against an allowlist
|
|
481
|
+
// that doesn't (and shouldn't need to) opt into credentialed CORS.
|
|
482
|
+
credentials: 'omit',
|
|
483
|
+
body: JSON.stringify({ ...this.authBody(), ...payload }),
|
|
484
|
+
});
|
|
485
|
+
let json: Record<string, unknown> = {};
|
|
486
|
+
try {
|
|
487
|
+
json = (await res.json()) as Record<string, unknown>;
|
|
488
|
+
} catch {
|
|
489
|
+
json = {};
|
|
490
|
+
}
|
|
491
|
+
if (!res.ok) {
|
|
492
|
+
const err = json.error as { code?: string; message?: string } | undefined;
|
|
493
|
+
throw new Error(err?.message ?? `${path} failed (${res.status})`);
|
|
494
|
+
}
|
|
495
|
+
return json;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
|
|
500
|
+
* the wrapper found (and primed) a recent session for this fingerprint, else
|
|
501
|
+
* null. Network/route failures are swallowed → null (fall through to create).
|
|
502
|
+
*/
|
|
503
|
+
private async resumeByFingerprint(): Promise<string | null> {
|
|
504
|
+
try {
|
|
505
|
+
const json = await this.postInternal('/internal/resume-by-fingerprint', { browserFingerprint: this.fingerprint() });
|
|
506
|
+
if (json.resumable === true && typeof json.sessionId === 'string') {
|
|
507
|
+
return json.sessionId;
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// Resume is best-effort; any failure just means a fresh session.
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** `create_conversation_session` with fingerprint + identity + consent metadata. */
|
|
516
|
+
private async createSession(): Promise<void> {
|
|
517
|
+
if (!this.client) throw new Error('Conversation is not connected');
|
|
518
|
+
const state = this.store.getState();
|
|
519
|
+
const metadata = this.sessionMetadata();
|
|
520
|
+
const session = await this.client.createConversationSession({
|
|
521
|
+
agentId: this.config.agentId,
|
|
522
|
+
userName: state.identity.name,
|
|
523
|
+
userEmail: state.identity.email,
|
|
524
|
+
browserFingerprint: this.fingerprint(),
|
|
525
|
+
...(metadata ? { metadata } : {}),
|
|
526
|
+
});
|
|
527
|
+
this.sessionId = session.sessionId;
|
|
528
|
+
this.store.getState().setSessionId(session.sessionId);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Attempt to resume `sessionId`: returns true and hydrates the transcript when
|
|
533
|
+
* the session is live, false when it has ended / can't be fetched.
|
|
534
|
+
*/
|
|
535
|
+
private async tryResume(sessionId: string): Promise<boolean> {
|
|
536
|
+
if (!this.client) return false;
|
|
537
|
+
let snap: { status?: 'active' | 'idle' | 'ended' };
|
|
538
|
+
try {
|
|
539
|
+
snap = await this.client.getSession({ sessionId });
|
|
540
|
+
} catch {
|
|
541
|
+
return false; // 404 / SESSION_NOT_FOUND / network — start fresh.
|
|
542
|
+
}
|
|
543
|
+
if (snap.status === 'ended') return false;
|
|
544
|
+
|
|
545
|
+
this.sessionId = sessionId;
|
|
546
|
+
await this.hydrateHistory(sessionId);
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Page recent history (newest-first), reverse to chronological, and render. */
|
|
551
|
+
private async hydrateHistory(sessionId: string): Promise<void> {
|
|
552
|
+
if (!this.client) return;
|
|
553
|
+
try {
|
|
554
|
+
const page = await this.client.getMessages({ sessionId, limit: 50 });
|
|
555
|
+
const rows = Array.isArray(page.messages) ? page.messages : [];
|
|
556
|
+
// The server returns newest-first; reverse to chronological for the UI.
|
|
557
|
+
const chronological = [...rows].reverse();
|
|
558
|
+
const hydrated: ChatMessage[] = [];
|
|
559
|
+
chronological.forEach((m, i) => {
|
|
560
|
+
const chat = wireMessageToChat(m as WireMessage, i);
|
|
561
|
+
if (chat) hydrated.push(chat);
|
|
562
|
+
});
|
|
563
|
+
this.messages.length = 0;
|
|
564
|
+
this.messages.push(...hydrated);
|
|
565
|
+
this.emitMessages();
|
|
566
|
+
} catch {
|
|
567
|
+
// History fetch is best-effort: a resumable session with no fetchable
|
|
568
|
+
// history just shows an empty transcript rather than failing the resume.
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
202
572
|
/**
|
|
203
573
|
* Submit a user message. Appends the user bubble immediately, then streams the
|
|
204
574
|
* assistant reply token-by-token, finalizing on `eventual_response`.
|
|
@@ -309,12 +679,155 @@ export class ConversationController {
|
|
|
309
679
|
}
|
|
310
680
|
}
|
|
311
681
|
|
|
682
|
+
// ─────────────────── Cross-device "restore my chats" (§c) ───────────────────
|
|
683
|
+
//
|
|
684
|
+
// Three HTTP POST routes on the chat-ws wrapper (the engine `/ws` dispatch
|
|
685
|
+
// rejects unknown verbs): request-otp → verify-otp → resolve. ALL THREE are
|
|
686
|
+
// session-scoped — they require a live `sessionId` (a uuid). request-otp
|
|
687
|
+
// establishes the session itself (idempotent connect) before sending, so the
|
|
688
|
+
// whole flow shares one session and verify-otp can't hit "No active session"
|
|
689
|
+
// even if the email was submitted before the initial connect() resolved.
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Begin the cross-device restore: POST `/internal/identity/request-otp` for
|
|
693
|
+
* `email` over `channel`. The view collects the email via an explicit affordance.
|
|
694
|
+
*/
|
|
695
|
+
async requestIdentityOtp(email: string, channel: 'email' | 'sms' = 'email'): Promise<void> {
|
|
696
|
+
const trimmed = email.trim();
|
|
697
|
+
if (!trimmed) return;
|
|
698
|
+
this.setIdentityRestore({ phase: 'requesting', email: trimmed, channel });
|
|
699
|
+
// request-otp must be SESSION-CONSISTENT with verify-otp (which hard-requires
|
|
700
|
+
// a sessionId). If the restore affordance fired request-otp before connect()
|
|
701
|
+
// resolved, there'd be no sessionId here and verify-otp would later error
|
|
702
|
+
// "No active session." Establish a session first (idempotent connect), then
|
|
703
|
+
// require it — so the whole request → verify flow shares one live session.
|
|
704
|
+
if (!this.sessionId) {
|
|
705
|
+
try {
|
|
706
|
+
await this.connect();
|
|
707
|
+
} catch {
|
|
708
|
+
/* fall through: handled by the sessionId check below */
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (!this.sessionId) {
|
|
712
|
+
this.setIdentityRestore({ phase: 'error', message: 'No active session to verify against.' });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const json = await this.postInternal('/internal/identity/request-otp', {
|
|
717
|
+
sessionId: this.sessionId,
|
|
718
|
+
email: trimmed,
|
|
719
|
+
channel,
|
|
720
|
+
});
|
|
721
|
+
const masked = typeof json.maskedDestination === 'string' ? json.maskedDestination : undefined;
|
|
722
|
+
this.setIdentityRestore({ phase: 'awaiting_code', email: trimmed, channel, maskedDestination: masked });
|
|
723
|
+
} catch (err) {
|
|
724
|
+
this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Could not send a verification code.' });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
|
|
729
|
+
async verifyIdentityOtp(code: string): Promise<void> {
|
|
730
|
+
const state = this.identityRestore;
|
|
731
|
+
const trimmed = code.trim();
|
|
732
|
+
if (!trimmed || state.phase !== 'awaiting_code') return;
|
|
733
|
+
const { email, channel } = state;
|
|
734
|
+
if (!this.sessionId) {
|
|
735
|
+
this.setIdentityRestore({ phase: 'error', message: 'No active session to verify against.' });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
this.setIdentityRestore({ phase: 'verifying', email, channel });
|
|
739
|
+
try {
|
|
740
|
+
const json = await this.postInternal('/internal/identity/verify-otp', { sessionId: this.sessionId, email, code: trimmed });
|
|
741
|
+
if (json.event === 'otp_verified') {
|
|
742
|
+
// Bind the OTP proof to the session it was verified against, so it
|
|
743
|
+
// can't leak onto a different visitor's session on a shared browser.
|
|
744
|
+
this.store.getState().setVerifiedEmail(email, this.sessionId);
|
|
745
|
+
await this.resolveIdentity(email);
|
|
746
|
+
} else if (json.event === 'otp_invalid') {
|
|
747
|
+
const remaining = typeof json.attemptsRemaining === 'number' ? json.attemptsRemaining : undefined;
|
|
748
|
+
this.setIdentityRestore({ phase: 'awaiting_code', email, channel, error: 'That code was incorrect.', attemptsRemaining: remaining });
|
|
749
|
+
} else {
|
|
750
|
+
this.setIdentityRestore({ phase: 'error', message: 'Verification failed.' });
|
|
751
|
+
}
|
|
752
|
+
} catch (err) {
|
|
753
|
+
this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Verification failed.' });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
|
|
758
|
+
private async resolveIdentity(email: string): Promise<void> {
|
|
759
|
+
if (!this.sessionId) return;
|
|
760
|
+
this.setIdentityRestore({ phase: 'resolving', email });
|
|
761
|
+
try {
|
|
762
|
+
const json = await this.postInternal('/internal/identity/resolve', { sessionId: this.sessionId, email });
|
|
763
|
+
if (json.resolved !== true) {
|
|
764
|
+
this.setIdentityRestore({ phase: 'resolved', email, conversations: [] });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const raw = json.conversations;
|
|
768
|
+
const conversations: RestorableConversation[] = Array.isArray(raw)
|
|
769
|
+
? raw
|
|
770
|
+
.map((c): RestorableConversation | null => {
|
|
771
|
+
if (!c || typeof c !== 'object') return null;
|
|
772
|
+
const o = c as Record<string, unknown>;
|
|
773
|
+
const conversationId = typeof o.conversationId === 'string' ? o.conversationId : '';
|
|
774
|
+
const sessionId = typeof o.sessionId === 'string' ? o.sessionId : '';
|
|
775
|
+
if (!sessionId) return null;
|
|
776
|
+
return {
|
|
777
|
+
conversationId,
|
|
778
|
+
sessionId,
|
|
779
|
+
lastActivityAt: typeof o.lastActivityAt === 'string' ? o.lastActivityAt : undefined,
|
|
780
|
+
preview: typeof o.preview === 'string' ? o.preview : undefined,
|
|
781
|
+
};
|
|
782
|
+
})
|
|
783
|
+
.filter((c): c is RestorableConversation => c !== null)
|
|
784
|
+
: [];
|
|
785
|
+
this.setIdentityRestore({ phase: 'resolved', email, conversations });
|
|
786
|
+
} catch (err) {
|
|
787
|
+
this.setIdentityRestore({ phase: 'error', message: err instanceof Error ? err.message : 'Could not load your chats.' });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Replay a chosen restorable conversation: point the live session at its
|
|
793
|
+
* sessionId, hydrate its transcript (get_session + get_messages), and persist
|
|
794
|
+
* the new pointer so the next `sendMessage` continues it.
|
|
795
|
+
*/
|
|
796
|
+
async restoreConversation(sessionId: string): Promise<void> {
|
|
797
|
+
if (!this.client) await this.ensureClient();
|
|
798
|
+
// Capture the OTP proof bound to the CURRENT live session BEFORE tryResume
|
|
799
|
+
// repoints this.sessionId to the restored one. The visitor proved ownership
|
|
800
|
+
// of this email in this very flow, so the proof legitimately follows the
|
|
801
|
+
// conversation they chose to restore.
|
|
802
|
+
const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
|
|
803
|
+
const resumed = await this.tryResume(sessionId);
|
|
804
|
+
if (resumed) {
|
|
805
|
+
this.store.getState().setSessionId(sessionId);
|
|
806
|
+
// Rebind the proof to the restored session (keeps verifiedEmail
|
|
807
|
+
// session-scoped, just now to the session it's actually used on). Only a
|
|
808
|
+
// proof from the just-verified session follows — never an unrelated stale one.
|
|
809
|
+
if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
|
|
810
|
+
this.setIdentityRestore({ phase: 'idle' });
|
|
811
|
+
this.setStatus('ready');
|
|
812
|
+
} else {
|
|
813
|
+
this.setIdentityRestore({ phase: 'error', message: 'That conversation is no longer available.' });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Dismiss the cross-device restore panel. */
|
|
818
|
+
cancelIdentityRestore(): void {
|
|
819
|
+
this.setIdentityRestore({ phase: 'idle' });
|
|
820
|
+
}
|
|
821
|
+
|
|
312
822
|
/** Tear down the underlying client. */
|
|
313
823
|
disconnect(): void {
|
|
314
824
|
this.client?.disconnect('widget closed');
|
|
315
825
|
this.client = null;
|
|
316
826
|
this.sessionId = null;
|
|
317
827
|
this.activeRequestId = null;
|
|
828
|
+
// A full teardown ends the controller lifecycle: a subsequent connect() is a
|
|
829
|
+
// genuine re-open and may resume again, so re-arm the resume probe.
|
|
830
|
+
this.resumeAttempted = false;
|
|
318
831
|
this.setInterrupt(null);
|
|
319
832
|
this.setStatus('closed');
|
|
320
833
|
}
|