@smooai/chat-widget 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-widget.global.js +1633 -71
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +381 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1478 -72
- 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 +455 -57
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/markdown.ts +368 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +110 -0
package/src/element.ts
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
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
|
+
import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
|
|
21
22
|
import { buildStyles } from './styles.js';
|
|
22
23
|
|
|
23
24
|
export const ELEMENT_TAG = 'smooth-agent-chat';
|
|
@@ -45,24 +46,9 @@ const ICON = {
|
|
|
45
46
|
shield: `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 3 5 6v5c0 4.4 3 7.2 7 8.5 4-1.3 7-4.1 7-8.5V6l-7-3Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m9 11.5 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
|
|
46
47
|
} as const;
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* SECURITY: citation URLs originate from indexed content (web / GitHub
|
|
52
|
-
* connectors), which can be attacker-influenceable. Assigning an arbitrary
|
|
53
|
-
* string to `<a>.href` allows `javascript:`/`data:`/`vbscript:` URLs that
|
|
54
|
-
* execute on click — a stored-XSS vector. Only http(s) links are rendered as
|
|
55
|
-
* anchors; anything else falls back to plain text.
|
|
56
|
-
*/
|
|
57
|
-
export function safeHttpUrl(url: string | undefined | null): string | null {
|
|
58
|
-
if (!url) return null;
|
|
59
|
-
try {
|
|
60
|
-
const parsed = new URL(url);
|
|
61
|
-
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
49
|
+
// `safeHttpUrl` / `escapeHtml` live in `./markdown.js` (the markdown renderer
|
|
50
|
+
// needs them too); re-exported here for back-compat with existing importers.
|
|
51
|
+
export { escapeHtml, safeHttpUrl } from './markdown.js';
|
|
66
52
|
|
|
67
53
|
export class SmoothAgentChatElement extends HTMLElement {
|
|
68
54
|
static get observedAttributes(): readonly string[] {
|
|
@@ -82,9 +68,17 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
82
68
|
private hasSent = false;
|
|
83
69
|
/** Starter prompts shown as chips in the empty state. */
|
|
84
70
|
private examplePrompts: string[] = [];
|
|
71
|
+
/** Resolved greeting text, cached so async (rAF) renders can reuse it. */
|
|
72
|
+
private greeting = '';
|
|
85
73
|
/** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
|
|
86
74
|
private interrupt: Interrupt | null = null;
|
|
87
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;
|
|
88
82
|
|
|
89
83
|
// Cached DOM refs (populated in render()).
|
|
90
84
|
private panelEl: HTMLElement | null = null;
|
|
@@ -95,6 +89,22 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
95
89
|
private inputEl: HTMLTextAreaElement | null = null;
|
|
96
90
|
private sendBtn: HTMLButtonElement | null = null;
|
|
97
91
|
|
|
92
|
+
// ── Smooth streaming reveal ──
|
|
93
|
+
// Tokens arrive in variable-size bursts at uneven rates, so revealing text in
|
|
94
|
+
// lockstep with arrival looks jerky. Instead we buffer the full target text
|
|
95
|
+
// and reveal it via a requestAnimationFrame "typewriter" at an adaptive rate
|
|
96
|
+
// (chars/frame scales with the pending backlog so it never falls behind the
|
|
97
|
+
// network). State below tracks the single in-flight streaming bubble.
|
|
98
|
+
/** The live streaming assistant bubble whose textContent the rAF loop drives. */
|
|
99
|
+
private streamBubbleEl: HTMLElement | null = null;
|
|
100
|
+
/** Message id the reveal is bound to (guards against stale frames after rebuilds). */
|
|
101
|
+
private streamMsgId: string | null = null;
|
|
102
|
+
/** Full buffered target text for the streaming message (grows as tokens arrive). */
|
|
103
|
+
private streamTarget = '';
|
|
104
|
+
/** How many chars of {@link streamTarget} are currently shown. */
|
|
105
|
+
private displayedLength = 0;
|
|
106
|
+
private rafId = 0;
|
|
107
|
+
|
|
98
108
|
constructor() {
|
|
99
109
|
super();
|
|
100
110
|
this.root = this.attachShadow({ mode: 'open' });
|
|
@@ -107,6 +117,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
107
117
|
|
|
108
118
|
disconnectedCallback(): void {
|
|
109
119
|
this.mounted = false;
|
|
120
|
+
this.resetReveal();
|
|
110
121
|
this.controller?.disconnect();
|
|
111
122
|
this.controller = null;
|
|
112
123
|
}
|
|
@@ -131,7 +142,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
131
142
|
openChat(): void {
|
|
132
143
|
this.open = true;
|
|
133
144
|
this.syncOpenState();
|
|
134
|
-
|
|
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(() => {});
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
/** Collapse the chat panel back to the launcher. */
|
|
@@ -158,6 +172,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
158
172
|
userName: this.overrides.userName,
|
|
159
173
|
userEmail: this.overrides.userEmail,
|
|
160
174
|
userPhone: this.overrides.userPhone,
|
|
175
|
+
authContext: this.overrides.authContext,
|
|
161
176
|
placeholder: this.overrides.placeholder ?? this.getAttribute('placeholder') ?? undefined,
|
|
162
177
|
greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
|
|
163
178
|
connectionErrorMessage: this.overrides.connectionErrorMessage,
|
|
@@ -166,6 +181,9 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
166
181
|
requireName: this.overrides.requireName,
|
|
167
182
|
requireEmail: this.overrides.requireEmail,
|
|
168
183
|
requirePhone: this.overrides.requirePhone,
|
|
184
|
+
collectPhone: this.overrides.collectPhone,
|
|
185
|
+
collectConsent: this.overrides.collectConsent,
|
|
186
|
+
allowChatRestore: this.overrides.allowChatRestore,
|
|
169
187
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
170
188
|
theme,
|
|
171
189
|
};
|
|
@@ -181,13 +199,14 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
181
199
|
}
|
|
182
200
|
const resolved = resolveConfig(config);
|
|
183
201
|
|
|
202
|
+
this.allowChatRestore = resolved.allowChatRestore;
|
|
203
|
+
|
|
184
204
|
// (Re)create the controller only when there isn't one yet. Attribute churn
|
|
185
205
|
// (e.g. theme tweaks) re-renders the view without dropping the session.
|
|
186
206
|
if (!this.controller) {
|
|
187
207
|
this.controller = new ConversationController(config, {
|
|
188
208
|
onMessages: (messages) => {
|
|
189
|
-
this.messages
|
|
190
|
-
this.renderMessages(resolved.greeting);
|
|
209
|
+
this.handleMessages(messages, resolved.greeting);
|
|
191
210
|
},
|
|
192
211
|
onStatus: (status) => {
|
|
193
212
|
this.status = status;
|
|
@@ -198,8 +217,17 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
198
217
|
this.interrupt = interrupt;
|
|
199
218
|
this.renderInterrupt();
|
|
200
219
|
},
|
|
220
|
+
onIdentityRestore: (state) => {
|
|
221
|
+
this.identityRestore = state;
|
|
222
|
+
this.renderInterrupt();
|
|
223
|
+
},
|
|
201
224
|
});
|
|
202
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
|
+
}
|
|
203
231
|
}
|
|
204
232
|
|
|
205
233
|
const fullpage = resolved.mode === 'fullpage';
|
|
@@ -232,13 +260,26 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
232
260
|
<button class="close" aria-label="Close chat">${ICON.close}</button>
|
|
233
261
|
</div>`;
|
|
234
262
|
|
|
235
|
-
// Remember starter prompts for the empty-state chips.
|
|
263
|
+
// Remember starter prompts + greeting for the empty-state chips / async renders.
|
|
236
264
|
this.examplePrompts = resolved.examplePrompts;
|
|
265
|
+
this.greeting = resolved.greeting;
|
|
237
266
|
|
|
238
267
|
// Gate the conversation behind a pre-chat identity form when required.
|
|
239
268
|
const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
: '';
|
|
242
283
|
const prechatHtml = `
|
|
243
284
|
<div class="prechat">
|
|
244
285
|
<div class="pc-head">
|
|
@@ -246,12 +287,14 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
246
287
|
<div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
|
|
247
288
|
</div>
|
|
248
289
|
<form class="pc-form" novalidate>
|
|
249
|
-
${resolved.requireName ? field('name', 'text', 'Name', 'name') : ''}
|
|
250
|
-
${resolved.requireEmail ? field('email', 'email', 'Email', 'email') : ''}
|
|
251
|
-
${
|
|
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}
|
|
252
294
|
<button type="submit" class="pc-submit">Start chat</button>
|
|
253
295
|
</form>
|
|
254
296
|
</div>`;
|
|
297
|
+
const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : '';
|
|
255
298
|
const chatHtml = `
|
|
256
299
|
<div class="messages"></div>
|
|
257
300
|
<div class="interrupt hidden"></div>
|
|
@@ -260,7 +303,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
260
303
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
261
304
|
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
|
|
262
305
|
</div>
|
|
263
|
-
<div class="footer">powered by <b>smooth‑operator</b
|
|
306
|
+
<div class="footer">powered by <b>smooth‑operator</b>${restoreLink}</div>
|
|
264
307
|
</div>`;
|
|
265
308
|
|
|
266
309
|
const container = document.createElement('div');
|
|
@@ -277,6 +320,9 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
277
320
|
const logoSvg = container.querySelector('.logo-wrap svg');
|
|
278
321
|
if (logoSvg) logoSvg.setAttribute('class', 'logo');
|
|
279
322
|
|
|
323
|
+
// A full DOM rebuild invalidates any cached streaming-bubble ref; cancel
|
|
324
|
+
// the in-flight reveal loop so renderMessages can re-bind cleanly.
|
|
325
|
+
this.resetReveal();
|
|
280
326
|
this.root.replaceChildren(style, container);
|
|
281
327
|
|
|
282
328
|
this.launcherEl = container.querySelector('.launcher');
|
|
@@ -305,12 +351,27 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
305
351
|
this.handlePrechatSubmit(pcForm as HTMLFormElement);
|
|
306
352
|
});
|
|
307
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
|
+
|
|
308
369
|
// Full-page mode connects eagerly (there's no launcher click to trigger it) —
|
|
309
370
|
// but only once any identity gate is cleared.
|
|
310
371
|
if (fullpage && !gating) void this.controller?.connect().catch(() => {});
|
|
311
372
|
|
|
312
373
|
this.syncOpenState();
|
|
313
|
-
if (!gating) this.renderMessages(
|
|
374
|
+
if (!gating) this.renderMessages();
|
|
314
375
|
this.renderStatus();
|
|
315
376
|
this.renderComposerState();
|
|
316
377
|
this.renderInterrupt();
|
|
@@ -327,6 +388,13 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
327
388
|
el.replaceChildren();
|
|
328
389
|
const it = this.interrupt;
|
|
329
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
|
+
}
|
|
330
398
|
el.classList.add('hidden');
|
|
331
399
|
return;
|
|
332
400
|
}
|
|
@@ -412,12 +480,189 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
412
480
|
el.appendChild(card);
|
|
413
481
|
}
|
|
414
482
|
|
|
415
|
-
/**
|
|
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. */
|
|
416
655
|
private handlePrechatSubmit(form: HTMLFormElement): void {
|
|
417
656
|
if (!form.reportValidity()) return;
|
|
418
657
|
const data = new FormData(form);
|
|
419
658
|
const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
|
|
420
|
-
|
|
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
|
+
});
|
|
421
666
|
this.userInfoSatisfied = true;
|
|
422
667
|
this.render();
|
|
423
668
|
void this.controller?.connect().catch(() => {});
|
|
@@ -449,10 +694,52 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
449
694
|
ta.style.height = `${ta.scrollHeight}px`;
|
|
450
695
|
}
|
|
451
696
|
|
|
452
|
-
|
|
697
|
+
/**
|
|
698
|
+
* Receive a new message snapshot from the controller and update the view.
|
|
699
|
+
*
|
|
700
|
+
* `onMessages` fires on *every* `stream_token` delta. Rebuilding the whole
|
|
701
|
+
* list each frame is wasteful and fights the smooth reveal, so we take the
|
|
702
|
+
* cheap path when the only change is the trailing streaming assistant message
|
|
703
|
+
* growing: just bump the reveal *target* (the rAF loop drains it). Any
|
|
704
|
+
* structural change (new message, finalize, citations) triggers a full
|
|
705
|
+
* rebuild via {@link renderMessages}.
|
|
706
|
+
*/
|
|
707
|
+
private handleMessages(messages: ChatMessage[], greeting: string): void {
|
|
708
|
+
this.greeting = greeting;
|
|
709
|
+
const prev = this.messages;
|
|
710
|
+
const last = messages[messages.length - 1];
|
|
711
|
+
const prevLast = prev[prev.length - 1];
|
|
712
|
+
|
|
713
|
+
const structural =
|
|
714
|
+
messages.length !== prev.length ||
|
|
715
|
+
!last ||
|
|
716
|
+
!prevLast ||
|
|
717
|
+
last.id !== prevLast.id ||
|
|
718
|
+
last.role !== prevLast.role ||
|
|
719
|
+
// finalize transition (streaming → done) needs the markdown render + sources
|
|
720
|
+
last.streaming !== prevLast.streaming ||
|
|
721
|
+
// first token after the typing indicator needs the bubble swapped in
|
|
722
|
+
(!!last.streaming && !prevLast.text && !!last.text);
|
|
723
|
+
|
|
724
|
+
this.messages = messages;
|
|
725
|
+
|
|
726
|
+
if (!structural && last && last.streaming && last.id === this.streamMsgId) {
|
|
727
|
+
// Fast path: the streaming tail just grew. Bump the reveal target and
|
|
728
|
+
// let the rAF loop catch up — no DOM rebuild, no per-token reflow.
|
|
729
|
+
this.streamTarget = last.text;
|
|
730
|
+
this.ensureRevealLoop();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
this.renderMessages();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private renderMessages(): void {
|
|
453
738
|
if (!this.messagesEl) return;
|
|
739
|
+
this.resetReveal();
|
|
454
740
|
this.messagesEl.replaceChildren();
|
|
455
741
|
|
|
742
|
+
const greeting = this.greeting;
|
|
456
743
|
if (this.messages.length === 0 && greeting) {
|
|
457
744
|
this.messagesEl.appendChild(this.buildRow('assistant', this.greetingBubble(greeting)));
|
|
458
745
|
}
|
|
@@ -480,9 +767,21 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
480
767
|
bubble.classList.add('typing');
|
|
481
768
|
bubble.append(this.typingDot(), this.typingDot(), this.typingDot());
|
|
482
769
|
} else if (msg.streaming) {
|
|
770
|
+
// Mid-stream: partial/unclosed markdown renders ugly (half-open
|
|
771
|
+
// `**`, dangling `[`), so keep plain text + the blinking cursor.
|
|
772
|
+
// The text is revealed gradually by the rAF typewriter; seed the
|
|
773
|
+
// bubble with whatever is already displayed and bind the reveal.
|
|
483
774
|
bubble.classList.add('cursor');
|
|
484
|
-
|
|
775
|
+
this.bindReveal(msg, bubble);
|
|
776
|
+
} else if (msg.role === 'assistant') {
|
|
777
|
+
// Final assistant turn → render the sanitized markdown subset.
|
|
778
|
+
// `renderMarkdown` escapes all text, drops images, gates links to
|
|
779
|
+
// http(s), and only emits an allowlisted set of tags, so this
|
|
780
|
+
// `innerHTML` cannot inject markup (see markdown.ts).
|
|
781
|
+
bubble.classList.add('md');
|
|
782
|
+
bubble.innerHTML = renderMarkdown(msg.text);
|
|
485
783
|
} else {
|
|
784
|
+
// User bubbles stay plain text.
|
|
486
785
|
bubble.textContent = msg.text;
|
|
487
786
|
}
|
|
488
787
|
this.messagesEl.appendChild(this.buildRow(msg.role, bubble));
|
|
@@ -493,7 +792,115 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
493
792
|
this.messagesEl.appendChild(this.renderSources(msg.citations));
|
|
494
793
|
}
|
|
495
794
|
}
|
|
496
|
-
this.
|
|
795
|
+
this.scrollToBottom(true);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ─────────────────────── Smooth streaming reveal ───────────────────────────
|
|
799
|
+
|
|
800
|
+
/** True when the host/user prefers reduced motion (snap, no typewriter). */
|
|
801
|
+
private prefersReducedMotion(): boolean {
|
|
802
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Bind the rAF typewriter to a freshly built streaming bubble. Preserves the
|
|
807
|
+
* already-revealed prefix across rebuilds (so a structural rebuild mid-stream
|
|
808
|
+
* doesn't restart the reveal from zero), then resumes the loop.
|
|
809
|
+
*/
|
|
810
|
+
private bindReveal(msg: ChatMessage, bubble: HTMLElement): void {
|
|
811
|
+
const carryOver = msg.id === this.streamMsgId ? Math.min(this.displayedLength, msg.text.length) : 0;
|
|
812
|
+
this.streamBubbleEl = bubble;
|
|
813
|
+
this.streamMsgId = msg.id;
|
|
814
|
+
this.streamTarget = msg.text;
|
|
815
|
+
this.displayedLength = carryOver;
|
|
816
|
+
|
|
817
|
+
if (this.prefersReducedMotion()) {
|
|
818
|
+
// No animation: show everything immediately.
|
|
819
|
+
this.displayedLength = msg.text.length;
|
|
820
|
+
bubble.textContent = msg.text;
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
bubble.textContent = msg.text.slice(0, this.displayedLength);
|
|
824
|
+
this.ensureRevealLoop();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Start the rAF loop if it isn't already running. */
|
|
828
|
+
private ensureRevealLoop(): void {
|
|
829
|
+
if (this.prefersReducedMotion() || typeof requestAnimationFrame !== 'function') {
|
|
830
|
+
// No animation (reduced-motion or no rAF): snap to the full buffer.
|
|
831
|
+
this.snapReveal();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (this.rafId) return; // already running
|
|
835
|
+
this.rafId = requestAnimationFrame(() => this.tickReveal());
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* One animation frame of the reveal. Advances `displayedLength` toward the
|
|
840
|
+
* buffered target at an ADAPTIVE rate — the deeper the backlog, the more
|
|
841
|
+
* chars per frame — so the reveal stays smooth yet never falls behind the
|
|
842
|
+
* network. Updates ONLY the bound bubble's textContent (no list rebuild).
|
|
843
|
+
*/
|
|
844
|
+
private tickReveal(): void {
|
|
845
|
+
this.rafId = 0;
|
|
846
|
+
const bubble = this.streamBubbleEl;
|
|
847
|
+
if (!bubble) return;
|
|
848
|
+
|
|
849
|
+
const target = this.streamTarget;
|
|
850
|
+
const remaining = target.length - this.displayedLength;
|
|
851
|
+
|
|
852
|
+
if (remaining <= 0) {
|
|
853
|
+
// Caught up to everything buffered so far → idle. A later token bump
|
|
854
|
+
// (handleMessages → ensureRevealLoop) restarts the loop; the finalize
|
|
855
|
+
// structural rebuild snaps to the full markdown render.
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Adaptive speed: ~1/8 of the backlog per frame (≈ catch up over a few
|
|
860
|
+
// frames), clamped to a readable floor so short bursts still animate and
|
|
861
|
+
// an aggressive ceiling so a deep buffer drains fast. At ~60fps this
|
|
862
|
+
// comfortably outruns the wire — the reveal is steady, never bursty.
|
|
863
|
+
const step = Math.max(2, Math.min(remaining, Math.ceil(remaining / 8)));
|
|
864
|
+
this.displayedLength = Math.min(target.length, this.displayedLength + step);
|
|
865
|
+
bubble.textContent = target.slice(0, this.displayedLength);
|
|
866
|
+
this.scrollToBottom(false);
|
|
867
|
+
|
|
868
|
+
this.rafId = requestAnimationFrame(() => this.tickReveal());
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Snap the bound bubble to the full buffered text immediately (reduced-motion / no-rAF). */
|
|
872
|
+
private snapReveal(): void {
|
|
873
|
+
if (this.streamBubbleEl) {
|
|
874
|
+
this.displayedLength = this.streamTarget.length;
|
|
875
|
+
this.streamBubbleEl.textContent = this.streamTarget;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
|
|
880
|
+
private resetReveal(): void {
|
|
881
|
+
if (this.rafId && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.rafId);
|
|
882
|
+
this.rafId = 0;
|
|
883
|
+
this.streamBubbleEl = null;
|
|
884
|
+
// streamMsgId/displayedLength are intentionally kept so bindReveal can
|
|
885
|
+
// carry the revealed prefix across a structural rebuild of the same msg;
|
|
886
|
+
// they're reset when a different message binds.
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Auto-scroll the message list to the bottom — but don't fight a visitor who
|
|
891
|
+
* has scrolled up to read history. When `force` (a structural rebuild) we
|
|
892
|
+
* always pin to bottom; during the streaming reveal we only follow if the
|
|
893
|
+
* viewport is already near the bottom.
|
|
894
|
+
*/
|
|
895
|
+
private scrollToBottom(force: boolean): void {
|
|
896
|
+
const el = this.messagesEl;
|
|
897
|
+
if (!el) return;
|
|
898
|
+
if (force) {
|
|
899
|
+
el.scrollTop = el.scrollHeight;
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
903
|
+
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
497
904
|
}
|
|
498
905
|
|
|
499
906
|
/** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */
|
|
@@ -563,7 +970,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
563
970
|
a.className = 'src-title';
|
|
564
971
|
a.href = safeUrl;
|
|
565
972
|
a.target = '_blank';
|
|
566
|
-
a.rel = 'noopener noreferrer';
|
|
973
|
+
a.rel = 'noopener noreferrer nofollow';
|
|
567
974
|
titleEl = a;
|
|
568
975
|
} else {
|
|
569
976
|
titleEl = document.createElement('span');
|
|
@@ -573,10 +980,18 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
573
980
|
li.appendChild(titleEl);
|
|
574
981
|
|
|
575
982
|
if (c.snippet) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
983
|
+
// The snippet is the raw scraped chunk — often led by page
|
|
984
|
+
// boilerplate (logo link, nav, whitespace). Trim it to a clean
|
|
985
|
+
// excerpt, then render the sanitized markdown subset. Both steps
|
|
986
|
+
// escape text + drop images/unsafe links (see markdown.ts), so
|
|
987
|
+
// this `innerHTML` is safe.
|
|
988
|
+
const cleaned = cleanCitationSnippet(c.snippet);
|
|
989
|
+
if (cleaned) {
|
|
990
|
+
const snip = document.createElement('span');
|
|
991
|
+
snip.className = 'src-snippet md';
|
|
992
|
+
snip.innerHTML = renderMarkdown(cleaned);
|
|
993
|
+
li.appendChild(snip);
|
|
994
|
+
}
|
|
580
995
|
}
|
|
581
996
|
list.appendChild(li);
|
|
582
997
|
}
|
|
@@ -618,23 +1033,6 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
618
1033
|
}
|
|
619
1034
|
}
|
|
620
1035
|
|
|
621
|
-
function escapeHtml(value: string): string {
|
|
622
|
-
return value.replace(/[&<>"']/g, (c) => {
|
|
623
|
-
switch (c) {
|
|
624
|
-
case '&':
|
|
625
|
-
return '&';
|
|
626
|
-
case '<':
|
|
627
|
-
return '<';
|
|
628
|
-
case '>':
|
|
629
|
-
return '>';
|
|
630
|
-
case '"':
|
|
631
|
-
return '"';
|
|
632
|
-
default:
|
|
633
|
-
return ''';
|
|
634
|
-
}
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
|
|
638
1036
|
/** Register the custom element once. Safe to call multiple times. */
|
|
639
1037
|
export function defineChatWidget(): void {
|
|
640
1038
|
if (typeof customElements !== 'undefined' && !customElements.get(ELEMENT_TAG)) {
|