@smooai/chat-widget 0.7.0 → 0.10.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/README.md +1 -0
- package/dist/chat-widget.global.js +13407 -1839
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +21 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1396 -1269
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/config.ts +15 -1
- package/src/element.ts +131 -9
- package/src/logo.ts +9 -4
- package/src/styles.ts +21 -1
package/dist/index.js
CHANGED
|
@@ -1,1356 +1,1359 @@
|
|
|
1
|
+
import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js/min";
|
|
1
2
|
import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
|
|
2
3
|
import { createStore } from "zustand/vanilla";
|
|
3
4
|
import { persist } from "zustand/middleware";
|
|
4
|
-
//#region src/
|
|
5
|
-
/** Resolve a partial config against the built-in defaults. */
|
|
6
|
-
function resolveConfig(config) {
|
|
7
|
-
const theme = config.theme ?? {};
|
|
8
|
-
const primary = theme.primary ?? "#00a6a6";
|
|
9
|
-
const primaryText = theme.primaryText ?? "#f8fafc";
|
|
10
|
-
const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
|
|
11
|
-
const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
|
|
12
|
-
const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
|
|
13
|
-
const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
|
|
14
|
-
return {
|
|
15
|
-
endpoint: config.endpoint,
|
|
16
|
-
mode: config.mode ?? "popover",
|
|
17
|
-
agentId: config.agentId,
|
|
18
|
-
agentName: config.agentName ?? "Assistant",
|
|
19
|
-
userName: config.userName,
|
|
20
|
-
userEmail: config.userEmail,
|
|
21
|
-
userPhone: config.userPhone,
|
|
22
|
-
authContext: config.authContext,
|
|
23
|
-
placeholder: config.placeholder ?? "Type a message…",
|
|
24
|
-
greeting: config.greeting ?? "Hi! How can I help you today?",
|
|
25
|
-
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
26
|
-
startOpen: config.startOpen ?? false,
|
|
27
|
-
examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
|
|
28
|
-
requireName: config.requireName ?? false,
|
|
29
|
-
requireEmail: config.requireEmail ?? false,
|
|
30
|
-
requirePhone: config.requirePhone ?? false,
|
|
31
|
-
collectPhone: config.collectPhone ?? true,
|
|
32
|
-
collectConsent: config.collectConsent ?? true,
|
|
33
|
-
allowChatRestore: config.allowChatRestore ?? true,
|
|
34
|
-
allowAnonymous: config.allowAnonymous ?? false,
|
|
35
|
-
theme: {
|
|
36
|
-
text: theme.text ?? "#f8fafc",
|
|
37
|
-
background: theme.background ?? "#040d30",
|
|
38
|
-
primary,
|
|
39
|
-
primaryText,
|
|
40
|
-
secondary: theme.secondary ?? "#ff6b6c",
|
|
41
|
-
assistantBubble,
|
|
42
|
-
assistantBubbleText,
|
|
43
|
-
userBubble,
|
|
44
|
-
userBubbleText,
|
|
45
|
-
border: theme.border ?? "rgba(255, 255, 255, 0.1)"
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Whether the pre-chat identity form should gate the conversation: at least one
|
|
51
|
-
* field is required and anonymous chat is not allowed.
|
|
52
|
-
*/
|
|
53
|
-
function needsUserInfo(resolved) {
|
|
54
|
-
return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
|
|
55
|
-
}
|
|
56
|
-
//#endregion
|
|
57
|
-
//#region src/fingerprint.ts
|
|
5
|
+
//#region src/markdown.ts
|
|
58
6
|
/**
|
|
59
|
-
*
|
|
60
|
-
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
61
|
-
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
62
|
-
* CRM contact). It rides every `create_conversation_session` as
|
|
63
|
-
* `browserFingerprint` (see ADR-048).
|
|
7
|
+
* A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
|
|
64
8
|
*
|
|
65
|
-
* ## Why not
|
|
9
|
+
* ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
|
|
66
10
|
*
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
11
|
+
* The widget renders **untrusted** text in two places: the assistant's reply
|
|
12
|
+
* (LLM output, which can echo attacker-supplied content) and citation snippets
|
|
13
|
+
* (raw scraped page chunks). Today both are written via `textContent`, so
|
|
14
|
+
* `**bold**`, numbered lists, and `[links](url)` show up literally. We want
|
|
15
|
+
* them rendered — without re-opening the XSS hole that `textContent` was
|
|
16
|
+
* guarding against.
|
|
73
17
|
*
|
|
74
|
-
*
|
|
18
|
+
* markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
|
|
19
|
+
* what is an embeddable **global** bundle, where every kilobyte is on the host
|
|
20
|
+
* page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
|
|
21
|
+
* require bolting on a separate sanitizer. Instead, this renderer is
|
|
22
|
+
* **safe-by-construction**:
|
|
75
23
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
24
|
+
* 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
|
|
25
|
+
* fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
|
|
26
|
+
* `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
|
|
27
|
+
* tag out of the input — a literal `<script>` in the input is treated as
|
|
28
|
+
* plain text.
|
|
29
|
+
* 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
|
|
30
|
+
* reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
|
|
31
|
+
* 3. **Images are dropped entirely** — `` renders as its alt text,
|
|
32
|
+
* no `<img>` is ever produced (a scraped tracking pixel must not load).
|
|
33
|
+
* 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
|
|
34
|
+
* URLs become anchors (with `target="_blank"` + a hardened `rel`);
|
|
35
|
+
* `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
|
|
36
|
+
* 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
|
|
37
|
+
* `<h1>` is far too large inside a chat bubble or citation card.
|
|
38
|
+
*
|
|
39
|
+
* The output is a string of HTML that is only ever assigned to `innerHTML` of
|
|
40
|
+
* an element the caller controls; because of (1)–(4) it can only contain the
|
|
41
|
+
* allowlisted, attribute-sanitized tags.
|
|
42
|
+
*
|
|
43
|
+
* Supported subset (deliberately small):
|
|
44
|
+
* - Paragraphs (blank-line separated) and hard/soft line breaks
|
|
45
|
+
* - `**bold**` / `__bold__`, `*italic*` / `_italic_`
|
|
46
|
+
* - `` `inline code` `` and fenced ``` ```code blocks``` ```
|
|
47
|
+
* - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
|
|
48
|
+
* - `> ` blockquotes
|
|
49
|
+
* - `[text](http(s)://url)` links (images dropped to alt text)
|
|
50
|
+
* - `#`..`######` headings → bold line
|
|
89
51
|
*/
|
|
90
|
-
/**
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
parts.push(`plat:${nav.platform ?? ""}`);
|
|
100
|
-
parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
|
|
101
|
-
parts.push(`dm:${nav.deviceMemory ?? ""}`);
|
|
52
|
+
/** Escape the five HTML-significant characters so a text run can never be markup. */
|
|
53
|
+
function escapeHtml(value) {
|
|
54
|
+
return value.replace(/[&<>"']/g, (c) => {
|
|
55
|
+
switch (c) {
|
|
56
|
+
case "&": return "&";
|
|
57
|
+
case "<": return "<";
|
|
58
|
+
case ">": return ">";
|
|
59
|
+
case "\"": return """;
|
|
60
|
+
default: return "'";
|
|
102
61
|
}
|
|
103
|
-
if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
104
|
-
if (typeof Intl !== "undefined") try {
|
|
105
|
-
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
|
|
106
|
-
} catch {}
|
|
107
|
-
parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
|
|
108
|
-
} catch {}
|
|
109
|
-
return parts.join("|");
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
113
|
-
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
114
|
-
* any security decision — only to derive a deterministic seed from the signal
|
|
115
|
-
* string above.
|
|
116
|
-
*/
|
|
117
|
-
function fnv1a(input) {
|
|
118
|
-
let h = 2166136261;
|
|
119
|
-
for (let i = 0; i < input.length; i++) {
|
|
120
|
-
h ^= input.charCodeAt(i);
|
|
121
|
-
h = Math.imul(h, 16777619);
|
|
122
|
-
}
|
|
123
|
-
return (h >>> 0).toString(16).padStart(8, "0");
|
|
124
|
-
}
|
|
125
|
-
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
126
|
-
function generateUuid() {
|
|
127
|
-
try {
|
|
128
|
-
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
129
|
-
} catch {}
|
|
130
|
-
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
131
|
-
const r = Math.random() * 16 | 0;
|
|
132
|
-
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
133
62
|
});
|
|
134
63
|
}
|
|
135
64
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return `${generateUuid()}.${signalHash}`;
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Return the cached fingerprint if one was already computed for this browser,
|
|
147
|
-
* otherwise compute + persist a new one via the provided accessors. The
|
|
148
|
-
* persistence layer owns storage; this function owns the "compute once" policy.
|
|
65
|
+
* Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
|
|
66
|
+
*
|
|
67
|
+
* SECURITY: link targets here originate from untrusted content (LLM output /
|
|
68
|
+
* scraped citation chunks). Allowing an arbitrary string as an `href` permits
|
|
69
|
+
* `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
|
|
70
|
+
* vector. Only absolute http(s) links are rendered as anchors; anything else
|
|
71
|
+
* falls back to plain text upstream.
|
|
149
72
|
*/
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
73
|
+
function safeHttpUrl(url) {
|
|
74
|
+
if (!url) return null;
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(url);
|
|
77
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
156
81
|
}
|
|
157
|
-
//#endregion
|
|
158
|
-
//#region src/persistence.ts
|
|
159
82
|
/**
|
|
160
|
-
*
|
|
161
|
-
* (ADR-048, SMOODEV-2129e).
|
|
162
|
-
*
|
|
163
|
-
* Built on Zustand's framework-agnostic `vanilla` store + the `persist`
|
|
164
|
-
* middleware (the user explicitly chose Zustand; it's ~1KB and has no React
|
|
165
|
-
* dependency, so it works inside the web component). The store is keyed per
|
|
166
|
-
* agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
|
|
167
|
-
* on the same origin don't clobber each other.
|
|
168
|
-
*
|
|
169
|
-
* ## What persists (and, deliberately, what does NOT)
|
|
170
|
-
*
|
|
171
|
-
* Only a **pointer** to the conversation plus the visitor's identity + marketing
|
|
172
|
-
* consent + verified email + browser fingerprint. The transcript is **never**
|
|
173
|
-
* persisted: the smooth-operator server is the source of truth and the widget
|
|
174
|
-
* re-hydrates history via `getMessages` on resume. This keeps localStorage small
|
|
175
|
-
* and avoids stale/divergent transcripts.
|
|
83
|
+
* Render the inline span grammar of a single line/segment to safe HTML.
|
|
176
84
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
85
|
+
* Order matters: code spans are extracted first (their contents are *not*
|
|
86
|
+
* further parsed), then images are stripped to alt text, then links, then
|
|
87
|
+
* emphasis. Every literal text run is escaped on the way out.
|
|
179
88
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
identity: {},
|
|
191
|
-
consent: { ...EMPTY_CONSENT },
|
|
192
|
-
verifiedEmail: null,
|
|
193
|
-
verifiedEmailSessionId: null,
|
|
194
|
-
browserFingerprint: null
|
|
89
|
+
function renderInline(input) {
|
|
90
|
+
let out = "";
|
|
91
|
+
let i = 0;
|
|
92
|
+
const n = input.length;
|
|
93
|
+
let buf = "";
|
|
94
|
+
const flush = () => {
|
|
95
|
+
if (buf) {
|
|
96
|
+
out += escapeHtml(buf);
|
|
97
|
+
buf = "";
|
|
98
|
+
}
|
|
195
99
|
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
* IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
|
|
206
|
-
* zustand v5's `persist` treats a missing `storage` option by falling back to its
|
|
207
|
-
* OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
|
|
208
|
-
* localStorage the guard was trying to avoid (throwing again in privacy mode).
|
|
209
|
-
* Handing it this no-op storage keeps the store working purely in memory and
|
|
210
|
-
* guarantees the fallback can't touch real localStorage.
|
|
211
|
-
*/
|
|
212
|
-
function memoryStorage() {
|
|
213
|
-
const mem = /* @__PURE__ */ new Map();
|
|
214
|
-
return {
|
|
215
|
-
getItem: (name) => {
|
|
216
|
-
const raw = mem.get(name);
|
|
217
|
-
if (!raw) return null;
|
|
218
|
-
try {
|
|
219
|
-
return JSON.parse(raw);
|
|
220
|
-
} catch {
|
|
221
|
-
return null;
|
|
100
|
+
while (i < n) {
|
|
101
|
+
const ch = input[i];
|
|
102
|
+
if (ch === "`") {
|
|
103
|
+
const end = input.indexOf("`", i + 1);
|
|
104
|
+
if (end > i) {
|
|
105
|
+
flush();
|
|
106
|
+
out += `<code>${escapeHtml(input.slice(i + 1, end))}</code>`;
|
|
107
|
+
i = end + 1;
|
|
108
|
+
continue;
|
|
222
109
|
}
|
|
223
|
-
},
|
|
224
|
-
setItem: (name, value) => {
|
|
225
|
-
mem.set(name, JSON.stringify(value));
|
|
226
|
-
},
|
|
227
|
-
removeItem: (name) => {
|
|
228
|
-
mem.delete(name);
|
|
229
110
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
function safeStorage() {
|
|
239
|
-
let ls = null;
|
|
240
|
-
try {
|
|
241
|
-
ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
242
|
-
if (ls) {
|
|
243
|
-
const probe = "__smoo_probe__";
|
|
244
|
-
ls.setItem(probe, "1");
|
|
245
|
-
ls.removeItem(probe);
|
|
111
|
+
if (ch === "!" && input[i + 1] === "[") {
|
|
112
|
+
const m = imageAt(input, i);
|
|
113
|
+
if (m) {
|
|
114
|
+
flush();
|
|
115
|
+
out += renderInline(m.alt);
|
|
116
|
+
i = m.end;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
246
119
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
} catch {
|
|
258
|
-
return null;
|
|
120
|
+
if (ch === "[") {
|
|
121
|
+
const m = linkAt(input, i);
|
|
122
|
+
if (m) {
|
|
123
|
+
flush();
|
|
124
|
+
const safe = safeHttpUrl(m.href);
|
|
125
|
+
const inner = renderInline(m.text);
|
|
126
|
+
if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
|
|
127
|
+
else out += inner;
|
|
128
|
+
i = m.end;
|
|
129
|
+
continue;
|
|
259
130
|
}
|
|
260
|
-
},
|
|
261
|
-
setItem: (name, value) => {
|
|
262
|
-
try {
|
|
263
|
-
storage.setItem(name, JSON.stringify(value));
|
|
264
|
-
} catch {}
|
|
265
|
-
},
|
|
266
|
-
removeItem: (name) => {
|
|
267
|
-
try {
|
|
268
|
-
storage.removeItem(name);
|
|
269
|
-
} catch {}
|
|
270
131
|
}
|
|
271
|
-
|
|
132
|
+
if (ch === "*" && input[i + 1] === "*" || ch === "_" && input[i + 1] === "_") {
|
|
133
|
+
const marker = ch + ch;
|
|
134
|
+
const end = input.indexOf(marker, i + 2);
|
|
135
|
+
if (end > i + 1) {
|
|
136
|
+
flush();
|
|
137
|
+
out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
|
|
138
|
+
i = end + 2;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (ch === "*" || ch === "_") {
|
|
143
|
+
const end = input.indexOf(ch, i + 1);
|
|
144
|
+
if (end > i + 1 && input[i + 1] !== ch) {
|
|
145
|
+
flush();
|
|
146
|
+
out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
|
|
147
|
+
i = end + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
buf += ch;
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
flush();
|
|
155
|
+
return out;
|
|
272
156
|
}
|
|
273
|
-
/**
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const base = initialPersisted();
|
|
280
|
-
if (!persisted || typeof persisted !== "object") return base;
|
|
281
|
-
const p = persisted;
|
|
157
|
+
/** Parse a `[text](href)` link starting at `start` (`input[start] === '['`). */
|
|
158
|
+
function linkAt(input, start) {
|
|
159
|
+
const close = matchBracket(input, start);
|
|
160
|
+
if (close < 0 || input[close + 1] !== "(") return null;
|
|
161
|
+
const paren = input.indexOf(")", close + 2);
|
|
162
|
+
if (paren < 0) return null;
|
|
282
163
|
return {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
|
|
287
|
-
email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
|
|
288
|
-
phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
|
|
289
|
-
},
|
|
290
|
-
consent: {
|
|
291
|
-
emailOptIn: p.consent?.emailOptIn === true,
|
|
292
|
-
smsOptIn: p.consent?.smsOptIn === true,
|
|
293
|
-
consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
|
|
294
|
-
consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
|
|
295
|
-
},
|
|
296
|
-
verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
|
|
297
|
-
verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
|
|
298
|
-
browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
|
|
164
|
+
text: input.slice(start + 1, close),
|
|
165
|
+
href: input.slice(close + 2, paren).trim().split(/\s+/)[0] ?? "",
|
|
166
|
+
end: paren + 1
|
|
299
167
|
};
|
|
300
168
|
}
|
|
301
|
-
/**
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
setSessionId: (sessionId) => set({ sessionId }),
|
|
310
|
-
mergeIdentity: (identity) => set((state) => ({ identity: {
|
|
311
|
-
...state.identity,
|
|
312
|
-
...identity.name !== void 0 ? { name: identity.name } : {},
|
|
313
|
-
...identity.email !== void 0 ? { email: identity.email } : {},
|
|
314
|
-
...identity.phone !== void 0 ? { phone: identity.phone } : {}
|
|
315
|
-
} })),
|
|
316
|
-
setConsent: (consent) => set({ consent: { ...consent } }),
|
|
317
|
-
setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
|
|
318
|
-
verifiedEmail,
|
|
319
|
-
verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
|
|
320
|
-
})),
|
|
321
|
-
setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
|
|
322
|
-
clearSession: () => set({
|
|
323
|
-
sessionId: null,
|
|
324
|
-
verifiedEmail: null,
|
|
325
|
-
verifiedEmailSessionId: null
|
|
326
|
-
})
|
|
327
|
-
}), {
|
|
328
|
-
name: storageKey(agentId),
|
|
329
|
-
version: 1,
|
|
330
|
-
storage: safeStorage(),
|
|
331
|
-
migrate,
|
|
332
|
-
partialize: (state) => ({
|
|
333
|
-
version: 1,
|
|
334
|
-
sessionId: state.sessionId,
|
|
335
|
-
identity: state.identity,
|
|
336
|
-
consent: state.consent,
|
|
337
|
-
verifiedEmail: state.verifiedEmail,
|
|
338
|
-
verifiedEmailSessionId: state.verifiedEmailSessionId,
|
|
339
|
-
browserFingerprint: state.browserFingerprint
|
|
340
|
-
})
|
|
341
|
-
}));
|
|
342
|
-
}
|
|
343
|
-
//#endregion
|
|
344
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
|
|
345
|
-
function _typeof(o) {
|
|
346
|
-
"@babel/helpers - typeof";
|
|
347
|
-
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
348
|
-
return typeof o;
|
|
349
|
-
} : function(o) {
|
|
350
|
-
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
351
|
-
}, _typeof(o);
|
|
169
|
+
/** Parse a `` image starting at `start` (`input[start] === '!'`). */
|
|
170
|
+
function imageAt(input, start) {
|
|
171
|
+
const link = linkAt(input, start + 1);
|
|
172
|
+
if (!link) return null;
|
|
173
|
+
return {
|
|
174
|
+
alt: link.text,
|
|
175
|
+
end: link.end
|
|
176
|
+
};
|
|
352
177
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
178
|
+
/** Find the matching `]` for a `[` at `open`, honoring one level of nesting. */
|
|
179
|
+
function matchBracket(input, open) {
|
|
180
|
+
let depth = 0;
|
|
181
|
+
for (let i = open; i < input.length; i++) {
|
|
182
|
+
const c = input[i];
|
|
183
|
+
if (c === "[") depth++;
|
|
184
|
+
else if (c === "]") {
|
|
185
|
+
depth--;
|
|
186
|
+
if (depth === 0) return i;
|
|
187
|
+
}
|
|
362
188
|
}
|
|
363
|
-
return
|
|
364
|
-
}
|
|
365
|
-
//#endregion
|
|
366
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
|
|
367
|
-
function toPropertyKey(t) {
|
|
368
|
-
var i = toPrimitive(t, "string");
|
|
369
|
-
return "symbol" == _typeof(i) ? i : i + "";
|
|
370
|
-
}
|
|
371
|
-
//#endregion
|
|
372
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
|
|
373
|
-
function _defineProperty(e, r, t) {
|
|
374
|
-
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
375
|
-
value: t,
|
|
376
|
-
enumerable: !0,
|
|
377
|
-
configurable: !0,
|
|
378
|
-
writable: !0
|
|
379
|
-
}) : e[r] = t, e;
|
|
189
|
+
return -1;
|
|
380
190
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
191
|
+
const UL_RE = /^\s*[-*+]\s+(.*)$/;
|
|
192
|
+
const OL_RE = /^\s*\d+[.)]\s+(.*)$/;
|
|
193
|
+
const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*)$/;
|
|
194
|
+
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
|
195
|
+
const FENCE_RE = /^\s*(`{3,}|~{3,})\s*(.*)$/;
|
|
196
|
+
/**
|
|
197
|
+
* Render a full Markdown string to safe HTML.
|
|
386
198
|
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
|
|
390
|
-
|
|
199
|
+
* @returns a string containing only the allowlisted tags described in the
|
|
200
|
+
* module doc. Safe to assign to `innerHTML` of a caller-owned element.
|
|
201
|
+
*/
|
|
202
|
+
function renderMarkdown(src) {
|
|
203
|
+
const lines = src.replace(/\r\n?/g, "\n").split("\n");
|
|
204
|
+
const out = [];
|
|
205
|
+
let i = 0;
|
|
206
|
+
while (i < lines.length) {
|
|
207
|
+
const line = lines[i];
|
|
208
|
+
const fence = FENCE_RE.exec(line);
|
|
209
|
+
if (fence) {
|
|
210
|
+
const marker = fence[1];
|
|
211
|
+
const body = [];
|
|
212
|
+
i++;
|
|
213
|
+
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
|
214
|
+
body.push(lines[i]);
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
if (i < lines.length) i++;
|
|
218
|
+
out.push(`<pre><code>${escapeHtml(body.join("\n"))}</code></pre>`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (line.trim() === "") {
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const heading = HEADING_RE.exec(line);
|
|
226
|
+
if (heading) {
|
|
227
|
+
out.push(`<p><strong>${renderInline(heading[2])}</strong></p>`);
|
|
228
|
+
i++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (UL_RE.test(line) || OL_RE.test(line)) {
|
|
232
|
+
const ordered = OL_RE.test(line) && !UL_RE.test(line);
|
|
233
|
+
const re = ordered ? OL_RE : UL_RE;
|
|
234
|
+
const items = [];
|
|
235
|
+
while (i < lines.length) {
|
|
236
|
+
const m = re.exec(lines[i]);
|
|
237
|
+
if (!m) break;
|
|
238
|
+
items.push(`<li>${renderInline(m[1])}</li>`);
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
out.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (QUOTE_RE.test(line)) {
|
|
245
|
+
const quoted = [];
|
|
246
|
+
while (i < lines.length) {
|
|
247
|
+
const m = QUOTE_RE.exec(lines[i]);
|
|
248
|
+
if (!m) break;
|
|
249
|
+
quoted.push(m[1]);
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
out.push(`<blockquote>${renderInline(quoted.join("\n")).replace(/\n/g, "<br>")}</blockquote>`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const para = [];
|
|
256
|
+
while (i < lines.length) {
|
|
257
|
+
const l = lines[i];
|
|
258
|
+
if (l.trim() === "" || FENCE_RE.test(l) || HEADING_RE.test(l) || UL_RE.test(l) || OL_RE.test(l) || QUOTE_RE.test(l)) break;
|
|
259
|
+
para.push(l);
|
|
260
|
+
i++;
|
|
261
|
+
}
|
|
262
|
+
out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
|
|
263
|
+
}
|
|
264
|
+
return out.join("");
|
|
265
|
+
}
|
|
266
|
+
const SNIPPET_MAX = 260;
|
|
267
|
+
/**
|
|
268
|
+
* Clean a raw scraped citation snippet into a short, readable excerpt.
|
|
391
269
|
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* in-progress assistant message, then the terminal
|
|
397
|
-
* `eventual_response`.
|
|
270
|
+
* Scraped chunks frequently begin with page boilerplate — a logo image wrapped
|
|
271
|
+
* in a link, standalone nav, repeated whitespace — e.g.
|
|
272
|
+
* `[](…) # Our Work We build…`. The source itself is already linked
|
|
273
|
+
* from the citation card, so the snippet only needs to be a clean teaser.
|
|
398
274
|
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
|
|
405
|
-
* verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
|
|
406
|
-
* 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
|
|
407
|
-
* `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
|
|
408
|
-
* NOT WS frames.
|
|
275
|
+
* Steps:
|
|
276
|
+
* 1. Strip a leading image / logo-link (`[](…)` or ``).
|
|
277
|
+
* 2. Drop a leading standalone heading marker (`#`/`##`).
|
|
278
|
+
* 3. Collapse all runs of whitespace to single spaces.
|
|
279
|
+
* 4. Truncate to ~{@link SNIPPET_MAX} chars at a word boundary, adding `…`.
|
|
409
280
|
*
|
|
410
|
-
* The
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
|
|
414
|
-
* the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
|
|
415
|
-
* (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
|
|
416
|
-
* the cross-device identity flow + fingerprint resume are HTTP POST routes on the
|
|
417
|
-
* wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
|
|
281
|
+
* The result is still rendered through {@link renderMarkdown} downstream, so any
|
|
282
|
+
* remaining inline markup (bold/links) stays safe.
|
|
418
283
|
*/
|
|
419
|
-
function
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
284
|
+
function cleanCitationSnippet(raw) {
|
|
285
|
+
let s = raw ?? "";
|
|
286
|
+
let changed = true;
|
|
287
|
+
while (changed) {
|
|
288
|
+
changed = false;
|
|
289
|
+
const before = s;
|
|
290
|
+
s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
|
|
291
|
+
s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
|
|
292
|
+
s = s.replace(/^\s*#{1,6}\s+/, "");
|
|
293
|
+
if (s !== before) changed = true;
|
|
426
294
|
}
|
|
295
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
296
|
+
if (s.length > SNIPPET_MAX) {
|
|
297
|
+
const cut = s.slice(0, SNIPPET_MAX);
|
|
298
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
299
|
+
s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
|
|
300
|
+
}
|
|
301
|
+
return s;
|
|
427
302
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (!response || typeof response !== "object") return null;
|
|
431
|
-
const r = response;
|
|
432
|
-
if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
|
|
433
|
-
return null;
|
|
434
|
-
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/config.ts
|
|
435
305
|
/**
|
|
436
|
-
*
|
|
306
|
+
* Public configuration surface for the chat widget.
|
|
437
307
|
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* non-array shapes, and missing fields) so a server that doesn't emit them, or
|
|
442
|
-
* an older client, can't break rendering. Each citation always carries
|
|
443
|
-
* `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
|
|
308
|
+
* A host page configures the widget either declaratively (HTML attributes on the
|
|
309
|
+
* `<smooth-agent-chat>` element) or programmatically (passing this object to
|
|
310
|
+
* {@link mountChatWidget} / `element.configure(...)`).
|
|
444
311
|
*/
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const title = typeof obj.title === "string" ? obj.title : id || "Source";
|
|
455
|
-
const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
|
|
456
|
-
const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
|
|
457
|
-
const score = typeof obj.score === "number" ? obj.score : 0;
|
|
458
|
-
out.push({
|
|
459
|
-
id,
|
|
460
|
-
title,
|
|
461
|
-
snippet,
|
|
462
|
-
score,
|
|
463
|
-
url
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
return out;
|
|
467
|
-
}
|
|
468
|
-
/** Convert a server message row into a finalized {@link ChatMessage}. */
|
|
469
|
-
function wireMessageToChat(m, idx) {
|
|
470
|
-
const text = typeof m.content?.text === "string" ? m.content.text : "";
|
|
471
|
-
if (!text) return null;
|
|
472
|
-
const role = m.direction === "outbound" ? "assistant" : "user";
|
|
312
|
+
/** Resolve a partial config against the built-in defaults. */
|
|
313
|
+
function resolveConfig(config) {
|
|
314
|
+
const theme = config.theme ?? {};
|
|
315
|
+
const primary = theme.primary ?? "#00a6a6";
|
|
316
|
+
const primaryText = theme.primaryText ?? "#f8fafc";
|
|
317
|
+
const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
|
|
318
|
+
const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
|
|
319
|
+
const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
|
|
320
|
+
const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
|
|
473
321
|
return {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
322
|
+
endpoint: config.endpoint,
|
|
323
|
+
mode: config.mode ?? "popover",
|
|
324
|
+
agentId: config.agentId,
|
|
325
|
+
agentName: config.agentName ?? "Assistant",
|
|
326
|
+
logoUrl: safeHttpUrl(config.logoUrl) ?? void 0,
|
|
327
|
+
userName: config.userName,
|
|
328
|
+
userEmail: config.userEmail,
|
|
329
|
+
userPhone: config.userPhone,
|
|
330
|
+
authContext: config.authContext,
|
|
331
|
+
placeholder: config.placeholder ?? "Type a message…",
|
|
332
|
+
greeting: config.greeting ?? "Hi! How can I help you today?",
|
|
333
|
+
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
334
|
+
startOpen: config.startOpen ?? false,
|
|
335
|
+
examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
|
|
336
|
+
requireName: config.requireName ?? false,
|
|
337
|
+
requireEmail: config.requireEmail ?? false,
|
|
338
|
+
requirePhone: config.requirePhone ?? false,
|
|
339
|
+
collectPhone: config.collectPhone ?? true,
|
|
340
|
+
collectConsent: config.collectConsent ?? true,
|
|
341
|
+
allowChatRestore: config.allowChatRestore ?? true,
|
|
342
|
+
allowAnonymous: config.allowAnonymous ?? false,
|
|
343
|
+
theme: {
|
|
344
|
+
text: theme.text ?? "#f8fafc",
|
|
345
|
+
background: theme.background ?? "#040d30",
|
|
346
|
+
primary,
|
|
347
|
+
primaryText,
|
|
348
|
+
secondary: theme.secondary ?? "#ff6b6c",
|
|
349
|
+
assistantBubble,
|
|
350
|
+
assistantBubbleText,
|
|
351
|
+
userBubble,
|
|
352
|
+
userBubbleText,
|
|
353
|
+
border: theme.border ?? "rgba(255, 255, 255, 0.1)"
|
|
354
|
+
}
|
|
478
355
|
};
|
|
479
356
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
emailOptIn: consent.emailOptIn,
|
|
534
|
-
smsOptIn: consent.smsOptIn,
|
|
535
|
-
consentSource: "chat-widget-prechat",
|
|
536
|
-
consentAt
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
setInterrupt(interrupt) {
|
|
541
|
-
this.interrupt = interrupt;
|
|
542
|
-
this.events.onInterrupt?.(interrupt);
|
|
543
|
-
}
|
|
544
|
-
setIdentityRestore(state) {
|
|
545
|
-
this.identityRestore = state;
|
|
546
|
-
this.events.onIdentityRestore?.(state);
|
|
547
|
-
}
|
|
548
|
-
get currentIdentityRestore() {
|
|
549
|
-
return this.identityRestore;
|
|
550
|
-
}
|
|
551
|
-
/** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
|
|
552
|
-
verifyOtp(code) {
|
|
553
|
-
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
|
|
554
|
-
this.client.verifyOtp({
|
|
555
|
-
sessionId: this.sessionId,
|
|
556
|
-
requestId: this.activeRequestId,
|
|
557
|
-
code
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
/** Approve or reject a pending tool write to resume the paused turn. */
|
|
561
|
-
confirmTool(approved) {
|
|
562
|
-
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
|
|
563
|
-
this.client.confirmToolAction({
|
|
564
|
-
sessionId: this.sessionId,
|
|
565
|
-
requestId: this.activeRequestId,
|
|
566
|
-
approved
|
|
567
|
-
});
|
|
568
|
-
this.setInterrupt(null);
|
|
569
|
-
}
|
|
570
|
-
nextId(prefix) {
|
|
571
|
-
this.seq += 1;
|
|
572
|
-
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
573
|
-
}
|
|
574
|
-
setStatus(status, detail) {
|
|
575
|
-
this.status = status;
|
|
576
|
-
this.events.onStatus(status, detail);
|
|
577
|
-
}
|
|
578
|
-
emitMessages() {
|
|
579
|
-
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
580
|
-
}
|
|
581
|
-
/** Compute (once) + return the persisted browser fingerprint. */
|
|
582
|
-
fingerprint() {
|
|
583
|
-
const state = this.store.getState();
|
|
584
|
-
return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
|
|
585
|
-
}
|
|
586
|
-
/**
|
|
587
|
-
* Build the `metadata` payload threaded into `create_conversation_session`:
|
|
588
|
-
* phone (no first-class engine field) and consent.
|
|
589
|
-
*
|
|
590
|
-
* NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
|
|
591
|
-
* OTP proof bound to the session it was verified against
|
|
592
|
-
* (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
|
|
593
|
-
* as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
|
|
594
|
-
* every brand-new `create_conversation_session` would mislabel a fresh
|
|
595
|
-
* visitor's session with a prior (possibly different) visitor's email on a
|
|
596
|
-
* shared browser. The verified email is only used when RESUMING the exact
|
|
597
|
-
* session it was proven for — see {@link verifiedEmailForSession}.
|
|
598
|
-
*/
|
|
599
|
-
sessionMetadata() {
|
|
600
|
-
const state = this.store.getState();
|
|
601
|
-
const meta = {};
|
|
602
|
-
if (state.identity.phone) meta.userPhone = state.identity.phone;
|
|
603
|
-
const consent = state.consent;
|
|
604
|
-
if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
|
|
605
|
-
const c = {
|
|
606
|
-
emailOptIn: consent.emailOptIn,
|
|
607
|
-
smsOptIn: consent.smsOptIn,
|
|
608
|
-
consentSource: consent.consentSource ?? "chat-widget-prechat"
|
|
609
|
-
};
|
|
610
|
-
if (consent.consentAt) c.consentAt = consent.consentAt;
|
|
611
|
-
meta.consent = c;
|
|
357
|
+
/**
|
|
358
|
+
* Whether the pre-chat identity form should gate the conversation: at least one
|
|
359
|
+
* field is required and anonymous chat is not allowed.
|
|
360
|
+
*/
|
|
361
|
+
function needsUserInfo(resolved) {
|
|
362
|
+
return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/fingerprint.ts
|
|
366
|
+
/**
|
|
367
|
+
* Browser fingerprint — a stable, privacy-light identifier used by the
|
|
368
|
+
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
369
|
+
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
370
|
+
* CRM contact). It rides every `create_conversation_session` as
|
|
371
|
+
* `browserFingerprint` (see ADR-048).
|
|
372
|
+
*
|
|
373
|
+
* ## Why not ThumbmarkJS
|
|
374
|
+
*
|
|
375
|
+
* The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
|
|
376
|
+
* that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
|
|
377
|
+
* full build pulls in extensive device-detection tables and async
|
|
378
|
+
* component collection, adding tens of kilobytes (and async surface) to a
|
|
379
|
+
* bundle whose whole selling point is staying out of the host page's
|
|
380
|
+
* LCP/TBT budget. The fingerprint here is a few hundred bytes.
|
|
381
|
+
*
|
|
382
|
+
* ## What this is (and the tradeoff)
|
|
383
|
+
*
|
|
384
|
+
* The correlation that actually matters — "is this the same browser as last
|
|
385
|
+
* time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
|
|
386
|
+
* field in the persisted store). That UUID is generated once and reused for
|
|
387
|
+
* the life of the localStorage entry, so same-browser resume is exact and
|
|
388
|
+
* deterministic. The signal hash below is a best-effort entropy *supplement*
|
|
389
|
+
* mixed into that UUID's derivation seed on first generation — it gives the
|
|
390
|
+
* server-side resolver a soft signal for the (rare) cross-storage case where
|
|
391
|
+
* the UUID was cleared but the device is unchanged. It is intentionally NOT a
|
|
392
|
+
* high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
|
|
393
|
+
* enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
|
|
394
|
+
* cross-storage matching in exchange for a tiny, transparent, no-network,
|
|
395
|
+
* XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
|
|
396
|
+
* of truth for any fuzzy matching; the client just supplies a stable token.
|
|
397
|
+
*/
|
|
398
|
+
/** Collect a small set of stable, non-invasive browser signals. */
|
|
399
|
+
function collectSignals() {
|
|
400
|
+
const parts = [];
|
|
401
|
+
try {
|
|
402
|
+
const nav = typeof navigator !== "undefined" ? navigator : void 0;
|
|
403
|
+
if (nav) {
|
|
404
|
+
parts.push(`ua:${nav.userAgent ?? ""}`);
|
|
405
|
+
parts.push(`lang:${nav.language ?? ""}`);
|
|
406
|
+
parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
|
|
407
|
+
parts.push(`plat:${nav.platform ?? ""}`);
|
|
408
|
+
parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
|
|
409
|
+
parts.push(`dm:${nav.deviceMemory ?? ""}`);
|
|
612
410
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
411
|
+
if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
412
|
+
if (typeof Intl !== "undefined") try {
|
|
413
|
+
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
|
|
414
|
+
} catch {}
|
|
415
|
+
parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
|
|
416
|
+
} catch {}
|
|
417
|
+
return parts.join("|");
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
421
|
+
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
422
|
+
* any security decision — only to derive a deterministic seed from the signal
|
|
423
|
+
* string above.
|
|
424
|
+
*/
|
|
425
|
+
function fnv1a(input) {
|
|
426
|
+
let h = 2166136261;
|
|
427
|
+
for (let i = 0; i < input.length; i++) {
|
|
428
|
+
h ^= input.charCodeAt(i);
|
|
429
|
+
h = Math.imul(h, 16777619);
|
|
631
430
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
431
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
432
|
+
}
|
|
433
|
+
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
434
|
+
function generateUuid() {
|
|
435
|
+
try {
|
|
436
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
437
|
+
} catch {}
|
|
438
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
439
|
+
const r = Math.random() * 16 | 0;
|
|
440
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Compute a fresh fingerprint token: a stable random UUID, suffixed with the
|
|
445
|
+
* FNV hash of the device signals so the server can recover a soft device
|
|
446
|
+
* signal even if it only has the token. Called ONCE per browser (the result is
|
|
447
|
+
* persisted and reused) — see {@link getOrCreateFingerprint}.
|
|
448
|
+
*/
|
|
449
|
+
function computeFingerprint() {
|
|
450
|
+
const signalHash = fnv1a(collectSignals());
|
|
451
|
+
return `${generateUuid()}.${signalHash}`;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Return the cached fingerprint if one was already computed for this browser,
|
|
455
|
+
* otherwise compute + persist a new one via the provided accessors. The
|
|
456
|
+
* persistence layer owns storage; this function owns the "compute once" policy.
|
|
457
|
+
*/
|
|
458
|
+
function getOrCreateFingerprint(get, set) {
|
|
459
|
+
const existing = get();
|
|
460
|
+
if (existing) return existing;
|
|
461
|
+
const fp = computeFingerprint();
|
|
462
|
+
set(fp);
|
|
463
|
+
return fp;
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/persistence.ts
|
|
467
|
+
/**
|
|
468
|
+
* Persisted widget state — the identity / consent / session-pointer client layer
|
|
469
|
+
* (ADR-048, SMOODEV-2129e).
|
|
470
|
+
*
|
|
471
|
+
* Built on Zustand's framework-agnostic `vanilla` store + the `persist`
|
|
472
|
+
* middleware (the user explicitly chose Zustand; it's ~1KB and has no React
|
|
473
|
+
* dependency, so it works inside the web component). The store is keyed per
|
|
474
|
+
* agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
|
|
475
|
+
* on the same origin don't clobber each other.
|
|
476
|
+
*
|
|
477
|
+
* ## What persists (and, deliberately, what does NOT)
|
|
478
|
+
*
|
|
479
|
+
* Only a **pointer** to the conversation plus the visitor's identity + marketing
|
|
480
|
+
* consent + verified email + browser fingerprint. The transcript is **never**
|
|
481
|
+
* persisted: the smooth-operator server is the source of truth and the widget
|
|
482
|
+
* re-hydrates history via `getMessages` on resume. This keeps localStorage small
|
|
483
|
+
* and avoids stale/divergent transcripts.
|
|
484
|
+
*
|
|
485
|
+
* `version` drives `persist.migrate` so future shape changes can upgrade old
|
|
486
|
+
* blobs in place instead of silently dropping them.
|
|
487
|
+
*/
|
|
488
|
+
/** Current persisted-shape version. Bump when the shape below changes incompatibly. */
|
|
489
|
+
const PERSIST_VERSION = 1;
|
|
490
|
+
const EMPTY_CONSENT = {
|
|
491
|
+
emailOptIn: false,
|
|
492
|
+
smsOptIn: false
|
|
493
|
+
};
|
|
494
|
+
function initialPersisted() {
|
|
495
|
+
return {
|
|
496
|
+
version: 1,
|
|
497
|
+
sessionId: null,
|
|
498
|
+
identity: {},
|
|
499
|
+
consent: { ...EMPTY_CONSENT },
|
|
500
|
+
verifiedEmail: null,
|
|
501
|
+
verifiedEmailSessionId: null,
|
|
502
|
+
browserFingerprint: null
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/** localStorage key for an agent's persisted widget state. */
|
|
506
|
+
function storageKey(agentId) {
|
|
507
|
+
return `smoo-chat-widget:${agentId}`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
|
|
511
|
+
* real localStorage is unavailable.
|
|
512
|
+
*
|
|
513
|
+
* IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
|
|
514
|
+
* zustand v5's `persist` treats a missing `storage` option by falling back to its
|
|
515
|
+
* OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
|
|
516
|
+
* localStorage the guard was trying to avoid (throwing again in privacy mode).
|
|
517
|
+
* Handing it this no-op storage keeps the store working purely in memory and
|
|
518
|
+
* guarantees the fallback can't touch real localStorage.
|
|
519
|
+
*/
|
|
520
|
+
function memoryStorage() {
|
|
521
|
+
const mem = /* @__PURE__ */ new Map();
|
|
522
|
+
return {
|
|
523
|
+
getItem: (name) => {
|
|
524
|
+
const raw = mem.get(name);
|
|
525
|
+
if (!raw) return null;
|
|
526
|
+
try {
|
|
527
|
+
return JSON.parse(raw);
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
668
530
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
531
|
+
},
|
|
532
|
+
setItem: (name, value) => {
|
|
533
|
+
mem.set(name, JSON.stringify(value));
|
|
534
|
+
},
|
|
535
|
+
removeItem: (name) => {
|
|
536
|
+
mem.delete(name);
|
|
674
537
|
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const res = await fetch(`${this.httpBase}${path}`, {
|
|
692
|
-
method: "POST",
|
|
693
|
-
headers: { "content-type": "application/json" },
|
|
694
|
-
credentials: "omit",
|
|
695
|
-
body: JSON.stringify({
|
|
696
|
-
...this.authBody(),
|
|
697
|
-
...payload
|
|
698
|
-
})
|
|
699
|
-
});
|
|
700
|
-
let json = {};
|
|
701
|
-
try {
|
|
702
|
-
json = await res.json();
|
|
703
|
-
} catch {
|
|
704
|
-
json = {};
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* A `persist` storage adapter that tolerates the *absence* of localStorage
|
|
542
|
+
* (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
|
|
543
|
+
* unavailable the store still works in-memory; nothing is persisted, but the
|
|
544
|
+
* widget never throws on boot.
|
|
545
|
+
*/
|
|
546
|
+
function safeStorage() {
|
|
547
|
+
let ls = null;
|
|
548
|
+
try {
|
|
549
|
+
ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
550
|
+
if (ls) {
|
|
551
|
+
const probe = "__smoo_probe__";
|
|
552
|
+
ls.setItem(probe, "1");
|
|
553
|
+
ls.removeItem(probe);
|
|
705
554
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
555
|
+
} catch {
|
|
556
|
+
ls = null;
|
|
557
|
+
}
|
|
558
|
+
if (!ls) return memoryStorage();
|
|
559
|
+
const storage = ls;
|
|
560
|
+
return {
|
|
561
|
+
getItem: (name) => {
|
|
562
|
+
try {
|
|
563
|
+
const raw = storage.getItem(name);
|
|
564
|
+
return raw ? JSON.parse(raw) : null;
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
setItem: (name, value) => {
|
|
570
|
+
try {
|
|
571
|
+
storage.setItem(name, JSON.stringify(value));
|
|
572
|
+
} catch {}
|
|
573
|
+
},
|
|
574
|
+
removeItem: (name) => {
|
|
575
|
+
try {
|
|
576
|
+
storage.removeItem(name);
|
|
577
|
+
} catch {}
|
|
709
578
|
}
|
|
710
|
-
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Migrate a persisted blob from an older `version` to the current shape. Today
|
|
583
|
+
* v1 is the only version, so this just backfills any missing fields onto an
|
|
584
|
+
* unknown old blob; future versions add `case` branches here.
|
|
585
|
+
*/
|
|
586
|
+
function migrate(persisted) {
|
|
587
|
+
const base = initialPersisted();
|
|
588
|
+
if (!persisted || typeof persisted !== "object") return base;
|
|
589
|
+
const p = persisted;
|
|
590
|
+
return {
|
|
591
|
+
version: 1,
|
|
592
|
+
sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
|
|
593
|
+
identity: {
|
|
594
|
+
name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
|
|
595
|
+
email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
|
|
596
|
+
phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
|
|
597
|
+
},
|
|
598
|
+
consent: {
|
|
599
|
+
emailOptIn: p.consent?.emailOptIn === true,
|
|
600
|
+
smsOptIn: p.consent?.smsOptIn === true,
|
|
601
|
+
consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
|
|
602
|
+
consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
|
|
603
|
+
},
|
|
604
|
+
verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
|
|
605
|
+
verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
|
|
606
|
+
browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Create the per-agent persisted Zustand store. The `partialize` step ensures
|
|
611
|
+
* only the persisted shape (never any future transient action/UI state) is
|
|
612
|
+
* written to localStorage.
|
|
613
|
+
*/
|
|
614
|
+
function createWidgetStore(agentId) {
|
|
615
|
+
return createStore()(persist((set) => ({
|
|
616
|
+
...initialPersisted(),
|
|
617
|
+
setSessionId: (sessionId) => set({ sessionId }),
|
|
618
|
+
mergeIdentity: (identity) => set((state) => ({ identity: {
|
|
619
|
+
...state.identity,
|
|
620
|
+
...identity.name !== void 0 ? { name: identity.name } : {},
|
|
621
|
+
...identity.email !== void 0 ? { email: identity.email } : {},
|
|
622
|
+
...identity.phone !== void 0 ? { phone: identity.phone } : {}
|
|
623
|
+
} })),
|
|
624
|
+
setConsent: (consent) => set({ consent: { ...consent } }),
|
|
625
|
+
setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
|
|
626
|
+
verifiedEmail,
|
|
627
|
+
verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
|
|
628
|
+
})),
|
|
629
|
+
setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
|
|
630
|
+
clearSession: () => set({
|
|
631
|
+
sessionId: null,
|
|
632
|
+
verifiedEmail: null,
|
|
633
|
+
verifiedEmailSessionId: null
|
|
634
|
+
})
|
|
635
|
+
}), {
|
|
636
|
+
name: storageKey(agentId),
|
|
637
|
+
version: 1,
|
|
638
|
+
storage: safeStorage(),
|
|
639
|
+
migrate,
|
|
640
|
+
partialize: (state) => ({
|
|
641
|
+
version: 1,
|
|
642
|
+
sessionId: state.sessionId,
|
|
643
|
+
identity: state.identity,
|
|
644
|
+
consent: state.consent,
|
|
645
|
+
verifiedEmail: state.verifiedEmail,
|
|
646
|
+
verifiedEmailSessionId: state.verifiedEmailSessionId,
|
|
647
|
+
browserFingerprint: state.browserFingerprint
|
|
648
|
+
})
|
|
649
|
+
}));
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
|
|
653
|
+
function _typeof(o) {
|
|
654
|
+
"@babel/helpers - typeof";
|
|
655
|
+
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
656
|
+
return typeof o;
|
|
657
|
+
} : function(o) {
|
|
658
|
+
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
659
|
+
}, _typeof(o);
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPrimitive.js
|
|
663
|
+
function toPrimitive(t, r) {
|
|
664
|
+
if ("object" != _typeof(t) || !t) return t;
|
|
665
|
+
var e = t[Symbol.toPrimitive];
|
|
666
|
+
if (void 0 !== e) {
|
|
667
|
+
var i = e.call(t, r || "default");
|
|
668
|
+
if ("object" != _typeof(i)) return i;
|
|
669
|
+
throw new TypeError("@@toPrimitive must return a primitive value.");
|
|
711
670
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
671
|
+
return ("string" === r ? String : Number)(t);
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
|
|
675
|
+
function toPropertyKey(t) {
|
|
676
|
+
var i = toPrimitive(t, "string");
|
|
677
|
+
return "symbol" == _typeof(i) ? i : i + "";
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
|
|
681
|
+
function _defineProperty(e, r, t) {
|
|
682
|
+
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
683
|
+
value: t,
|
|
684
|
+
enumerable: !0,
|
|
685
|
+
configurable: !0,
|
|
686
|
+
writable: !0
|
|
687
|
+
}) : e[r] = t, e;
|
|
688
|
+
}
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/conversation.ts
|
|
691
|
+
/**
|
|
692
|
+
* ConversationController — the bridge between the widget UI and the
|
|
693
|
+
* `@smooai/smooth-operator` protocol client.
|
|
694
|
+
*
|
|
695
|
+
* This is the piece that was rewired: the original smooai widget spoke to
|
|
696
|
+
* `@smooai/realtime`; here every protocol action goes through {@link SmoothAgentClient}.
|
|
697
|
+
* The wire shapes are identical (the protocol was lifted from `@smooai/realtime`),
|
|
698
|
+
* so the swap is purely at the client-library boundary.
|
|
699
|
+
*
|
|
700
|
+
* Flow:
|
|
701
|
+
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
|
|
702
|
+
* (or RESUMES a persisted session via `get_session`/`get_messages`).
|
|
703
|
+
* 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
|
|
704
|
+
* in-progress assistant message, then the terminal
|
|
705
|
+
* `eventual_response`.
|
|
706
|
+
*
|
|
707
|
+
* 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
|
|
708
|
+
* - `browserFingerprint` computed once + sent on every `create_conversation_session`.
|
|
709
|
+
* - identity + marketing consent threaded into the session `metadata`.
|
|
710
|
+
* - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
|
|
711
|
+
* - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
|
|
712
|
+
* cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
|
|
713
|
+
* verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
|
|
714
|
+
* 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
|
|
715
|
+
* `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
|
|
716
|
+
* NOT WS frames.
|
|
717
|
+
*
|
|
718
|
+
* The controller is UI-agnostic: it emits typed events and the view renders them.
|
|
719
|
+
*/
|
|
720
|
+
/**
|
|
721
|
+
* Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
|
|
722
|
+
* the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
|
|
723
|
+
* (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
|
|
724
|
+
* the cross-device identity flow + fingerprint resume are HTTP POST routes on the
|
|
725
|
+
* wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
|
|
726
|
+
*/
|
|
727
|
+
function httpBaseFromWsEndpoint(endpoint) {
|
|
728
|
+
try {
|
|
729
|
+
const u = new URL(endpoint);
|
|
730
|
+
u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
|
|
731
|
+
return `${u.protocol}//${u.host}`;
|
|
732
|
+
} catch {
|
|
722
733
|
return null;
|
|
723
734
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
+
}
|
|
736
|
+
/** Pull the final assistant text out of an `eventual_response` data payload. */
|
|
737
|
+
function extractFinalText(response) {
|
|
738
|
+
if (!response || typeof response !== "object") return null;
|
|
739
|
+
const r = response;
|
|
740
|
+
if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Pull the grounding {@link Citation}s out of a terminal `eventual_response`.
|
|
745
|
+
*
|
|
746
|
+
* The protocol client types these (`eventual_response.data.data.citations`),
|
|
747
|
+
* but they're optional and back-compatible — absent when the turn used no
|
|
748
|
+
* knowledge sources. We read them defensively (tolerating their total absence,
|
|
749
|
+
* non-array shapes, and missing fields) so a server that doesn't emit them, or
|
|
750
|
+
* an older client, can't break rendering. Each citation always carries
|
|
751
|
+
* `id`/`title`/`snippet`/`score`; `url` is present only for web-sourced docs.
|
|
752
|
+
*/
|
|
753
|
+
function extractCitations(inner) {
|
|
754
|
+
if (!inner || typeof inner !== "object") return [];
|
|
755
|
+
const raw = inner.citations;
|
|
756
|
+
if (!Array.isArray(raw)) return [];
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const c of raw) {
|
|
759
|
+
if (!c || typeof c !== "object") continue;
|
|
760
|
+
const obj = c;
|
|
761
|
+
const id = typeof obj.id === "string" ? obj.id : "";
|
|
762
|
+
const title = typeof obj.title === "string" ? obj.title : id || "Source";
|
|
763
|
+
const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
|
|
764
|
+
const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
|
|
765
|
+
const score = typeof obj.score === "number" ? obj.score : 0;
|
|
766
|
+
out.push({
|
|
767
|
+
id,
|
|
768
|
+
title,
|
|
769
|
+
snippet,
|
|
770
|
+
score,
|
|
771
|
+
url
|
|
735
772
|
});
|
|
736
|
-
this.sessionId = session.sessionId;
|
|
737
|
-
this.store.getState().setSessionId(session.sessionId);
|
|
738
773
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
774
|
+
return out;
|
|
775
|
+
}
|
|
776
|
+
/** Convert a server message row into a finalized {@link ChatMessage}. */
|
|
777
|
+
function wireMessageToChat(m, idx) {
|
|
778
|
+
const text = typeof m.content?.text === "string" ? m.content.text : "";
|
|
779
|
+
if (!text) return null;
|
|
780
|
+
const role = m.direction === "outbound" ? "assistant" : "user";
|
|
781
|
+
return {
|
|
782
|
+
id: typeof m.id === "string" ? m.id : `hist-${idx}`,
|
|
783
|
+
role,
|
|
784
|
+
text,
|
|
785
|
+
streaming: false
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
var ConversationController = class {
|
|
789
|
+
constructor(config, events, store) {
|
|
790
|
+
_defineProperty(this, "config", void 0);
|
|
791
|
+
_defineProperty(this, "events", void 0);
|
|
792
|
+
_defineProperty(this, "store", void 0);
|
|
793
|
+
_defineProperty(this, "client", null);
|
|
794
|
+
_defineProperty(this, "sessionId", null);
|
|
795
|
+
_defineProperty(this, "messages", []);
|
|
796
|
+
_defineProperty(this, "status", "idle");
|
|
797
|
+
_defineProperty(this, "seq", 0);
|
|
798
|
+
_defineProperty(this, "activeRequestId", null);
|
|
799
|
+
_defineProperty(this, "interrupt", null);
|
|
800
|
+
_defineProperty(this, "identityRestore", { phase: "idle" });
|
|
801
|
+
_defineProperty(this, "resumeAttempted", false);
|
|
802
|
+
_defineProperty(this, "httpBase", void 0);
|
|
803
|
+
this.config = config;
|
|
804
|
+
this.events = events;
|
|
805
|
+
this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
|
|
806
|
+
if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
|
|
807
|
+
this.store = store ?? createWidgetStore(config.agentId);
|
|
808
|
+
const seed = {};
|
|
809
|
+
if (config.userName) seed.name = config.userName;
|
|
810
|
+
if (config.userEmail) seed.email = config.userEmail;
|
|
811
|
+
if (config.userPhone) seed.phone = config.userPhone;
|
|
812
|
+
if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
|
|
755
813
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
if (!this.client) return;
|
|
759
|
-
try {
|
|
760
|
-
const page = await this.client.getMessages({
|
|
761
|
-
sessionId,
|
|
762
|
-
limit: 50
|
|
763
|
-
});
|
|
764
|
-
const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
|
|
765
|
-
const hydrated = [];
|
|
766
|
-
chronological.forEach((m, i) => {
|
|
767
|
-
const chat = wireMessageToChat(m, i);
|
|
768
|
-
if (chat) hydrated.push(chat);
|
|
769
|
-
});
|
|
770
|
-
this.messages.length = 0;
|
|
771
|
-
this.messages.push(...hydrated);
|
|
772
|
-
this.emitMessages();
|
|
773
|
-
} catch {}
|
|
814
|
+
get connectionStatus() {
|
|
815
|
+
return this.status;
|
|
774
816
|
}
|
|
775
|
-
/**
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
*/
|
|
779
|
-
async send(text) {
|
|
780
|
-
const trimmed = text.trim();
|
|
781
|
-
if (!trimmed) return;
|
|
782
|
-
if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
|
|
783
|
-
if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
|
|
784
|
-
this.messages.push({
|
|
785
|
-
id: this.nextId("u"),
|
|
786
|
-
role: "user",
|
|
787
|
-
text: trimmed,
|
|
788
|
-
streaming: false
|
|
789
|
-
});
|
|
790
|
-
const assistant = {
|
|
791
|
-
id: this.nextId("a"),
|
|
792
|
-
role: "assistant",
|
|
793
|
-
text: "",
|
|
794
|
-
streaming: true
|
|
795
|
-
};
|
|
796
|
-
this.messages.push(assistant);
|
|
797
|
-
this.emitMessages();
|
|
798
|
-
try {
|
|
799
|
-
const turn = this.client.sendMessage({
|
|
800
|
-
sessionId: this.sessionId,
|
|
801
|
-
message: trimmed,
|
|
802
|
-
stream: true
|
|
803
|
-
});
|
|
804
|
-
this.activeRequestId = turn.requestId;
|
|
805
|
-
for await (const event of turn) if (event.type === "stream_token") {
|
|
806
|
-
const token = event.token ?? event.data?.token ?? "";
|
|
807
|
-
if (token) {
|
|
808
|
-
assistant.text += token;
|
|
809
|
-
this.emitMessages();
|
|
810
|
-
}
|
|
811
|
-
} else this.handleTurnEvent(event);
|
|
812
|
-
const inner = (await turn).data?.data;
|
|
813
|
-
const finalText = extractFinalText(inner?.response);
|
|
814
|
-
if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
|
|
815
|
-
if (!assistant.text) assistant.text = "(no response)";
|
|
816
|
-
const citations = extractCitations(inner);
|
|
817
|
-
if (citations.length > 0) assistant.citations = citations;
|
|
818
|
-
assistant.streaming = false;
|
|
819
|
-
this.emitMessages();
|
|
820
|
-
} catch (err) {
|
|
821
|
-
assistant.streaming = false;
|
|
822
|
-
const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
|
|
823
|
-
assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
|
|
824
|
-
this.emitMessages();
|
|
825
|
-
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
826
|
-
} finally {
|
|
827
|
-
this.activeRequestId = null;
|
|
828
|
-
this.setInterrupt(null);
|
|
829
|
-
}
|
|
817
|
+
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
818
|
+
getStore() {
|
|
819
|
+
return this.store;
|
|
830
820
|
}
|
|
831
|
-
/**
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const str = (v) => typeof v === "string" ? v : void 0;
|
|
835
|
-
const num = (v) => typeof v === "number" ? v : void 0;
|
|
836
|
-
switch (event.type) {
|
|
837
|
-
case "otp_verification_required": {
|
|
838
|
-
const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
|
|
839
|
-
this.setInterrupt({
|
|
840
|
-
kind: "otp",
|
|
841
|
-
toolId: str(inner.toolId),
|
|
842
|
-
actionDescription: str(inner.actionDescription),
|
|
843
|
-
availableChannels: channels.length > 0 ? channels : ["email"]
|
|
844
|
-
});
|
|
845
|
-
break;
|
|
846
|
-
}
|
|
847
|
-
case "otp_sent":
|
|
848
|
-
if (this.interrupt?.kind === "otp") this.setInterrupt({
|
|
849
|
-
...this.interrupt,
|
|
850
|
-
sent: {
|
|
851
|
-
channel: str(inner.channel),
|
|
852
|
-
maskedDestination: str(inner.maskedDestination)
|
|
853
|
-
},
|
|
854
|
-
error: void 0
|
|
855
|
-
});
|
|
856
|
-
break;
|
|
857
|
-
case "otp_verified":
|
|
858
|
-
if (this.interrupt?.kind === "otp") this.setInterrupt(null);
|
|
859
|
-
break;
|
|
860
|
-
case "otp_invalid":
|
|
861
|
-
if (this.interrupt?.kind === "otp") this.setInterrupt({
|
|
862
|
-
...this.interrupt,
|
|
863
|
-
error: str(inner.message) ?? "That code was incorrect.",
|
|
864
|
-
attemptsRemaining: num(inner.attemptsRemaining)
|
|
865
|
-
});
|
|
866
|
-
break;
|
|
867
|
-
case "write_confirmation_required":
|
|
868
|
-
this.setInterrupt({
|
|
869
|
-
kind: "confirm",
|
|
870
|
-
toolId: str(inner.toolId),
|
|
871
|
-
actionDescription: str(inner.actionDescription)
|
|
872
|
-
});
|
|
873
|
-
break;
|
|
874
|
-
default: break;
|
|
875
|
-
}
|
|
821
|
+
/** True when a persisted session pointer exists (drives the resume path). */
|
|
822
|
+
hasPersistedSession() {
|
|
823
|
+
return !!this.store.getState().sessionId;
|
|
876
824
|
}
|
|
877
|
-
/**
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
async requestIdentityOtp(email, channel = "email") {
|
|
882
|
-
const trimmed = email.trim();
|
|
883
|
-
if (!trimmed) return;
|
|
884
|
-
this.setIdentityRestore({
|
|
885
|
-
phase: "requesting",
|
|
886
|
-
email: trimmed,
|
|
887
|
-
channel
|
|
888
|
-
});
|
|
889
|
-
if (!this.sessionId) try {
|
|
890
|
-
await this.connect();
|
|
891
|
-
} catch {}
|
|
892
|
-
if (!this.sessionId) {
|
|
893
|
-
this.setIdentityRestore({
|
|
894
|
-
phase: "error",
|
|
895
|
-
message: "No active session to verify against."
|
|
896
|
-
});
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
try {
|
|
900
|
-
const json = await this.postInternal("/internal/identity/request-otp", {
|
|
901
|
-
sessionId: this.sessionId,
|
|
902
|
-
email: trimmed,
|
|
903
|
-
channel
|
|
904
|
-
});
|
|
905
|
-
const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
|
|
906
|
-
this.setIdentityRestore({
|
|
907
|
-
phase: "awaiting_code",
|
|
908
|
-
email: trimmed,
|
|
909
|
-
channel,
|
|
910
|
-
maskedDestination: masked
|
|
911
|
-
});
|
|
912
|
-
} catch (err) {
|
|
913
|
-
this.setIdentityRestore({
|
|
914
|
-
phase: "error",
|
|
915
|
-
message: err instanceof Error ? err.message : "Could not send a verification code."
|
|
916
|
-
});
|
|
917
|
-
}
|
|
825
|
+
/** True when persisted identity exists (lets the view skip the pre-chat form). */
|
|
826
|
+
hasPersistedIdentity() {
|
|
827
|
+
const id = this.store.getState().identity;
|
|
828
|
+
return !!(id.name || id.email || id.phone);
|
|
918
829
|
}
|
|
919
|
-
/**
|
|
920
|
-
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
const { email, channel } = state;
|
|
925
|
-
if (!this.sessionId) {
|
|
926
|
-
this.setIdentityRestore({
|
|
927
|
-
phase: "error",
|
|
928
|
-
message: "No active session to verify against."
|
|
929
|
-
});
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
932
|
-
this.setIdentityRestore({
|
|
933
|
-
phase: "verifying",
|
|
830
|
+
/** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
|
|
831
|
+
setUserInfo(info) {
|
|
832
|
+
const { name, email, phone, consent } = info;
|
|
833
|
+
this.store.getState().mergeIdentity({
|
|
834
|
+
name,
|
|
934
835
|
email,
|
|
935
|
-
|
|
836
|
+
phone
|
|
936
837
|
});
|
|
937
|
-
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
this.store.getState().setVerifiedEmail(email, this.sessionId);
|
|
945
|
-
await this.resolveIdentity(email);
|
|
946
|
-
} else if (json.event === "otp_invalid") {
|
|
947
|
-
const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
|
|
948
|
-
this.setIdentityRestore({
|
|
949
|
-
phase: "awaiting_code",
|
|
950
|
-
email,
|
|
951
|
-
channel,
|
|
952
|
-
error: "That code was incorrect.",
|
|
953
|
-
attemptsRemaining: remaining
|
|
954
|
-
});
|
|
955
|
-
} else this.setIdentityRestore({
|
|
956
|
-
phase: "error",
|
|
957
|
-
message: "Verification failed."
|
|
958
|
-
});
|
|
959
|
-
} catch (err) {
|
|
960
|
-
this.setIdentityRestore({
|
|
961
|
-
phase: "error",
|
|
962
|
-
message: err instanceof Error ? err.message : "Verification failed."
|
|
838
|
+
if (consent) {
|
|
839
|
+
const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
|
|
840
|
+
this.store.getState().setConsent({
|
|
841
|
+
emailOptIn: consent.emailOptIn,
|
|
842
|
+
smsOptIn: consent.smsOptIn,
|
|
843
|
+
consentSource: "chat-widget-prechat",
|
|
844
|
+
consentAt
|
|
963
845
|
});
|
|
964
846
|
}
|
|
965
847
|
}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
848
|
+
setInterrupt(interrupt) {
|
|
849
|
+
this.interrupt = interrupt;
|
|
850
|
+
this.events.onInterrupt?.(interrupt);
|
|
851
|
+
}
|
|
852
|
+
setIdentityRestore(state) {
|
|
853
|
+
this.identityRestore = state;
|
|
854
|
+
this.events.onIdentityRestore?.(state);
|
|
855
|
+
}
|
|
856
|
+
get currentIdentityRestore() {
|
|
857
|
+
return this.identityRestore;
|
|
858
|
+
}
|
|
859
|
+
/** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
|
|
860
|
+
verifyOtp(code) {
|
|
861
|
+
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
|
|
862
|
+
this.client.verifyOtp({
|
|
863
|
+
sessionId: this.sessionId,
|
|
864
|
+
requestId: this.activeRequestId,
|
|
865
|
+
code
|
|
972
866
|
});
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
867
|
+
}
|
|
868
|
+
/** Approve or reject a pending tool write to resume the paused turn. */
|
|
869
|
+
confirmTool(approved) {
|
|
870
|
+
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
|
|
871
|
+
this.client.confirmToolAction({
|
|
872
|
+
sessionId: this.sessionId,
|
|
873
|
+
requestId: this.activeRequestId,
|
|
874
|
+
approved
|
|
875
|
+
});
|
|
876
|
+
this.setInterrupt(null);
|
|
877
|
+
}
|
|
878
|
+
nextId(prefix) {
|
|
879
|
+
this.seq += 1;
|
|
880
|
+
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
881
|
+
}
|
|
882
|
+
setStatus(status, detail) {
|
|
883
|
+
this.status = status;
|
|
884
|
+
this.events.onStatus(status, detail);
|
|
885
|
+
}
|
|
886
|
+
emitMessages() {
|
|
887
|
+
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
888
|
+
}
|
|
889
|
+
/** Compute (once) + return the persisted browser fingerprint. */
|
|
890
|
+
fingerprint() {
|
|
891
|
+
const state = this.store.getState();
|
|
892
|
+
return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Build the `metadata` payload threaded into `create_conversation_session`:
|
|
896
|
+
* phone (no first-class engine field) and consent.
|
|
897
|
+
*
|
|
898
|
+
* NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
|
|
899
|
+
* OTP proof bound to the session it was verified against
|
|
900
|
+
* (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
|
|
901
|
+
* as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
|
|
902
|
+
* every brand-new `create_conversation_session` would mislabel a fresh
|
|
903
|
+
* visitor's session with a prior (possibly different) visitor's email on a
|
|
904
|
+
* shared browser. The verified email is only used when RESUMING the exact
|
|
905
|
+
* session it was proven for — see {@link verifiedEmailForSession}.
|
|
906
|
+
*/
|
|
907
|
+
sessionMetadata() {
|
|
908
|
+
const state = this.store.getState();
|
|
909
|
+
const meta = {};
|
|
910
|
+
if (state.identity.phone) meta.userPhone = state.identity.phone;
|
|
911
|
+
const consent = state.consent;
|
|
912
|
+
if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
|
|
913
|
+
const c = {
|
|
914
|
+
emailOptIn: consent.emailOptIn,
|
|
915
|
+
smsOptIn: consent.smsOptIn,
|
|
916
|
+
consentSource: consent.consentSource ?? "chat-widget-prechat"
|
|
917
|
+
};
|
|
918
|
+
if (consent.consentAt) c.consentAt = consent.consentAt;
|
|
919
|
+
meta.consent = c;
|
|
1010
920
|
}
|
|
921
|
+
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
1011
922
|
}
|
|
1012
923
|
/**
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
924
|
+
* The verified-email hint, but ONLY when the OTP proof is bound to the session
|
|
925
|
+
* being resumed (`verifiedEmailSessionId === sessionId`). Returns null
|
|
926
|
+
* otherwise so a stale/cross-visitor proof is never threaded onto a session it
|
|
927
|
+
* wasn't proven for.
|
|
1016
928
|
*/
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (await this.tryResume(sessionId)) {
|
|
1021
|
-
this.store.getState().setSessionId(sessionId);
|
|
1022
|
-
if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
|
|
1023
|
-
this.setIdentityRestore({ phase: "idle" });
|
|
1024
|
-
this.setStatus("ready");
|
|
1025
|
-
} else this.setIdentityRestore({
|
|
1026
|
-
phase: "error",
|
|
1027
|
-
message: "That conversation is no longer available."
|
|
1028
|
-
});
|
|
1029
|
-
}
|
|
1030
|
-
/** Dismiss the cross-device restore panel. */
|
|
1031
|
-
cancelIdentityRestore() {
|
|
1032
|
-
this.setIdentityRestore({ phase: "idle" });
|
|
1033
|
-
}
|
|
1034
|
-
/** Tear down the underlying client. */
|
|
1035
|
-
disconnect() {
|
|
1036
|
-
this.client?.disconnect("widget closed");
|
|
1037
|
-
this.client = null;
|
|
1038
|
-
this.sessionId = null;
|
|
1039
|
-
this.activeRequestId = null;
|
|
1040
|
-
this.resumeAttempted = false;
|
|
1041
|
-
this.setInterrupt(null);
|
|
1042
|
-
this.setStatus("closed");
|
|
1043
|
-
}
|
|
1044
|
-
};
|
|
1045
|
-
//#endregion
|
|
1046
|
-
//#region src/logo.ts
|
|
1047
|
-
/**
|
|
1048
|
-
* The Smooth logo, inlined as an SVG string so the full-page header can render
|
|
1049
|
-
* it without a separate network fetch (the IIFE bundle is self-contained).
|
|
1050
|
-
*
|
|
1051
|
-
* GENERATED from `assets/smooth-logo.svg` — do not edit by hand. Regenerate with:
|
|
1052
|
-
* node -e ... (see the commit that added this file)
|
|
1053
|
-
*/
|
|
1054
|
-
const SMOOTH_LOGO_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 550 135\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient-3);\n }\n\n .cls-2 {\n fill: url(#linear-gradient-2);\n }\n\n .cls-3 {\n fill: url(#linear-gradient);\n fill-rule: evenodd;\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"115.59\" y1=\"112.81\" x2=\"25.08\" y2=\"22.3\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".3\" stop-color=\"#f49f0a\"/>\n <stop offset=\".79\" stop-color=\"#fb7a4d\"/>\n <stop offset=\"1\" stop-color=\"#ff6b6c\"/>\n </linearGradient>\n <linearGradient id=\"linear-gradient-2\" x1=\"360.91\" y1=\"152.01\" x2=\"202.32\" y2=\"-6.59\" xlink:href=\"#linear-gradient\"/>\n <linearGradient id=\"linear-gradient-3\" x1=\"443.91\" y1=\"30.15\" x2=\"531.36\" y2=\"117.59\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-3\" d=\"M48.28,14.96c-12.39,5.21-22.54,14.64-28.65,26.61-6.12,11.97-7.8,25.72-4.77,38.81,3.04,13.09,10.6,24.69,21.36,32.75,10.76,8.06,24.02,12.05,37.44,11.28,13.42-.77,26.13-6.26,35.9-15.5,9.76-9.24,15.95-21.63,17.46-34.99,1.51-13.36-1.74-26.82-9.19-38.01-1.07-1.61-.64-3.78.97-4.85,1.61-1.07,3.78-.64,4.85.97,8.36,12.56,12.02,27.68,10.32,42.67-1.7,15-8.64,28.91-19.61,39.28-10.96,10.37-25.24,16.54-40.31,17.4-15.07.87-29.96-3.62-42.04-12.66-12.08-9.05-20.58-22.07-23.99-36.77-3.41-14.7-1.51-30.14,5.35-43.58,6.87-13.44,18.26-24.02,32.17-29.87,13.91-5.85,29.44-6.6,43.85-2.11,1.85.57,2.88,2.54,2.3,4.38-.57,1.85-2.54,2.88-4.38,2.3-12.83-4-26.67-3.33-39.06,1.88ZM111.39,19.75c0,2.07-1.68,3.75-3.75,3.75s-3.75-1.68-3.75-3.75,1.68-3.75,3.75-3.75,3.75,1.68,3.75,3.75ZM64.64,59.93c0,1.91,2.39,2.56,7.69,3.88,3.89.97,6.6,2.18,8.15,3.63,1.53,1.45,2.29,3.53,2.29,6.25,0,3.57-1.03,6.26-3.11,8.08-2.07,1.82-5.09,2.73-9.09,2.73h-9.6c-1.97,0-3.57-1.6-3.59-3.57-.01-1.99,1.6-3.61,3.59-3.61h9.41c3.15-.12,4.79-.95,4.91-2.47,0-1.3-1.03-2.21-3.07-2.73-6.91-1.72-11.11-3.44-12.6-5.15-1.48-1.71-2.23-3.77-2.23-6.19,0-6.59,3.2-9.85,9.59-9.8h10.77c1.99,0,3.6,1.61,3.6,3.59s-1.61,3.59-3.6,3.59h-9.69c-1.83,0-3.43.06-3.43,1.77Z\"/>\n <path class=\"cls-2\" d=\"M205.52,48.44h-8.86c-.44-3.75-2.23-6.65-5.38-8.72-3.16-2.07-7.03-3.1-11.6-3.1h0c-3.35,0-6.27.54-8.78,1.62-2.49,1.09-4.44,2.59-5.84,4.48-1.39,1.89-2.08,4.05-2.08,6.46h0c0,2.01.49,3.75,1.46,5.2.97,1.44,2.22,2.63,3.74,3.58,1.53.95,3.13,1.72,4.8,2.32,1.68.6,3.22,1.09,4.62,1.46h0l7.68,2.06c1.97.52,4.17,1.23,6.6,2.14,2.43.92,4.75,2.16,6.98,3.72,2.23,1.56,4.07,3.56,5.52,6,1.45,2.44,2.18,5.43,2.18,8.98h0c0,4.08-1.07,7.77-3.2,11.08-2.12,3.29-5.22,5.91-9.3,7.86-4.08,1.95-9.02,2.92-14.82,2.92h0c-5.43,0-10.11-.87-14.06-2.62-3.95-1.75-7.05-4.19-9.3-7.32-2.25-3.12-3.53-6.75-3.84-10.88h9.46c.25,2.85,1.22,5.21,2.9,7.06,1.69,1.87,3.83,3.25,6.42,4.14,2.6.89,5.41,1.34,8.42,1.34h0c3.49,0,6.63-.57,9.4-1.72,2.79-1.13,4.99-2.73,6.62-4.8,1.63-2.05,2.44-4.46,2.44-7.22h0c0-2.51-.7-4.55-2.1-6.12-1.41-1.57-3.26-2.85-5.54-3.84-2.29-.99-4.77-1.85-7.44-2.58h0l-9.3-2.66c-5.91-1.71-10.59-4.13-14.04-7.28-3.44-3.16-5.16-7.29-5.16-12.38h0c0-4.23,1.15-7.93,3.46-11.1,2.29-3.16,5.39-5.62,9.3-7.38,3.91-1.76,8.27-2.64,13.08-2.64h0c4.88,0,9.21.87,13,2.6,3.8,1.73,6.81,4.11,9.04,7.12,2.23,3,3.4,6.41,3.52,10.22h0ZM229.16,105.18h-8.72v-56.74h8.42v8.86h.74c1.19-3.03,3.1-5.38,5.74-7.06,2.63-1.69,5.79-2.54,9.48-2.54h0c3.75,0,6.87.85,9.36,2.54,2.51,1.68,4.46,4.03,5.86,7.06h.58c1.45-2.92,3.63-5.25,6.54-7,2.91-1.73,6.39-2.6,10.46-2.6h0c5.07,0,9.21,1.58,12.44,4.74,3.23,3.17,4.84,8.09,4.84,14.76h0v37.98h-8.72v-37.98c0-4.19-1.14-7.18-3.42-8.98-2.29-1.79-4.99-2.68-8.1-2.68h0c-3.99,0-7.07,1.2-9.26,3.6-2.2,2.4-3.3,5.43-3.3,9.1h0v36.94h-8.86v-38.86c0-3.23-1.05-5.83-3.14-7.82-2.09-1.97-4.79-2.96-8.08-2.96h0c-2.27,0-4.38.6-6.34,1.8-1.96,1.21-3.53,2.88-4.72,5-1.2,2.13-1.8,4.59-1.8,7.38h0v35.46ZM333.9,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.86-5.85-9.02-10.24-2.15-4.37-3.22-9.49-3.22-15.36h0c0-5.91,1.07-11.07,3.22-15.48,2.16-4.4,5.17-7.82,9.02-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.15,4.41,3.22,9.57,3.22,15.48h0c0,5.87-1.07,10.99-3.22,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM333.9,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.89,0-7.09,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.51,1.99,5.71,2.98,9.6,2.98ZM395.94,106.36h0c-5.12,0-9.61-1.22-13.46-3.66-3.85-2.44-6.85-5.85-9-10.24-2.16-4.37-3.24-9.49-3.24-15.36h0c0-5.91,1.08-11.07,3.24-15.48,2.15-4.4,5.15-7.82,9-10.26,3.85-2.44,8.34-3.66,13.46-3.66h0c5.12,0,9.61,1.22,13.46,3.66,3.85,2.44,6.86,5.86,9.02,10.26,2.16,4.41,3.24,9.57,3.24,15.48h0c0,5.87-1.08,10.99-3.24,15.36-2.16,4.39-5.17,7.8-9.02,10.24-3.85,2.44-8.34,3.66-13.46,3.66ZM395.94,98.52h0c3.89,0,7.09-.99,9.6-2.98,2.52-2,4.38-4.63,5.58-7.88,1.21-3.25,1.82-6.77,1.82-10.56h0c0-3.79-.61-7.32-1.82-10.6-1.2-3.27-3.06-5.91-5.58-7.94-2.51-2.01-5.71-3.02-9.6-3.02h0c-3.88,0-7.08,1.01-9.6,3.02-2.51,2.03-4.37,4.67-5.58,7.94-1.2,3.28-1.8,6.81-1.8,10.6h0c0,3.79.6,7.31,1.8,10.56,1.21,3.25,3.07,5.88,5.58,7.88,2.52,1.99,5.72,2.98,9.6,2.98Z\"/>\n <path class=\"cls-1\" d=\"M467.88,48.02v13.28h-35.79v-13.28h35.79ZM439.68,34.38h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM506.59,72.63v32.71h-17.89V28.95h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
|
|
1055
|
-
//#endregion
|
|
1056
|
-
//#region src/markdown.ts
|
|
1057
|
-
/**
|
|
1058
|
-
* A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
|
|
1059
|
-
*
|
|
1060
|
-
* ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
|
|
1061
|
-
*
|
|
1062
|
-
* The widget renders **untrusted** text in two places: the assistant's reply
|
|
1063
|
-
* (LLM output, which can echo attacker-supplied content) and citation snippets
|
|
1064
|
-
* (raw scraped page chunks). Today both are written via `textContent`, so
|
|
1065
|
-
* `**bold**`, numbered lists, and `[links](url)` show up literally. We want
|
|
1066
|
-
* them rendered — without re-opening the XSS hole that `textContent` was
|
|
1067
|
-
* guarding against.
|
|
1068
|
-
*
|
|
1069
|
-
* markdown-it with `html:false` is safe-by-default but ships ~30 kB min into
|
|
1070
|
-
* what is an embeddable **global** bundle, where every kilobyte is on the host
|
|
1071
|
-
* page's critical path. snarkdown is ~1 kB but emits raw HTML, so it would
|
|
1072
|
-
* require bolting on a separate sanitizer. Instead, this renderer is
|
|
1073
|
-
* **safe-by-construction**:
|
|
1074
|
-
*
|
|
1075
|
-
* 1. It is a *tokenizer*, not an HTML passthrough. It only ever emits a
|
|
1076
|
-
* fixed allowlist of tags (`p`, `br`, `strong`, `em`, `ul`/`ol`/`li`,
|
|
1077
|
-
* `code`/`pre`, `a`, `blockquote`). There is no code path that copies a
|
|
1078
|
-
* tag out of the input — a literal `<script>` in the input is treated as
|
|
1079
|
-
* plain text.
|
|
1080
|
-
* 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
|
|
1081
|
-
* reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
|
|
1082
|
-
* 3. **Images are dropped entirely** — `` renders as its alt text,
|
|
1083
|
-
* no `<img>` is ever produced (a scraped tracking pixel must not load).
|
|
1084
|
-
* 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
|
|
1085
|
-
* URLs become anchors (with `target="_blank"` + a hardened `rel`);
|
|
1086
|
-
* `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
|
|
1087
|
-
* 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
|
|
1088
|
-
* `<h1>` is far too large inside a chat bubble or citation card.
|
|
1089
|
-
*
|
|
1090
|
-
* The output is a string of HTML that is only ever assigned to `innerHTML` of
|
|
1091
|
-
* an element the caller controls; because of (1)–(4) it can only contain the
|
|
1092
|
-
* allowlisted, attribute-sanitized tags.
|
|
1093
|
-
*
|
|
1094
|
-
* Supported subset (deliberately small):
|
|
1095
|
-
* - Paragraphs (blank-line separated) and hard/soft line breaks
|
|
1096
|
-
* - `**bold**` / `__bold__`, `*italic*` / `_italic_`
|
|
1097
|
-
* - `` `inline code` `` and fenced ``` ```code blocks``` ```
|
|
1098
|
-
* - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
|
|
1099
|
-
* - `> ` blockquotes
|
|
1100
|
-
* - `[text](http(s)://url)` links (images dropped to alt text)
|
|
1101
|
-
* - `#`..`######` headings → bold line
|
|
1102
|
-
*/
|
|
1103
|
-
/** Escape the five HTML-significant characters so a text run can never be markup. */
|
|
1104
|
-
function escapeHtml(value) {
|
|
1105
|
-
return value.replace(/[&<>"']/g, (c) => {
|
|
1106
|
-
switch (c) {
|
|
1107
|
-
case "&": return "&";
|
|
1108
|
-
case "<": return "<";
|
|
1109
|
-
case ">": return ">";
|
|
1110
|
-
case "\"": return """;
|
|
1111
|
-
default: return "'";
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
}
|
|
1115
|
-
/**
|
|
1116
|
-
* Return `url` only if it is a valid absolute `http(s)` URL, else `null`.
|
|
1117
|
-
*
|
|
1118
|
-
* SECURITY: link targets here originate from untrusted content (LLM output /
|
|
1119
|
-
* scraped citation chunks). Allowing an arbitrary string as an `href` permits
|
|
1120
|
-
* `javascript:`/`data:`/`vbscript:` URLs that execute on click — a stored-XSS
|
|
1121
|
-
* vector. Only absolute http(s) links are rendered as anchors; anything else
|
|
1122
|
-
* falls back to plain text upstream.
|
|
1123
|
-
*/
|
|
1124
|
-
function safeHttpUrl(url) {
|
|
1125
|
-
if (!url) return null;
|
|
1126
|
-
try {
|
|
1127
|
-
const parsed = new URL(url);
|
|
1128
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
1129
|
-
} catch {
|
|
929
|
+
verifiedEmailForSession(sessionId) {
|
|
930
|
+
const state = this.store.getState();
|
|
931
|
+
if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
|
|
1130
932
|
return null;
|
|
1131
933
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
if (
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
flush();
|
|
1175
|
-
const safe = safeHttpUrl(m.href);
|
|
1176
|
-
const inner = renderInline(m.text);
|
|
1177
|
-
if (safe) out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer nofollow">${inner}</a>`;
|
|
1178
|
-
else out += inner;
|
|
1179
|
-
i = m.end;
|
|
1180
|
-
continue;
|
|
934
|
+
/** Lazily open the WS client (default transport). Idempotent within a connect. */
|
|
935
|
+
async ensureClient() {
|
|
936
|
+
if (this.client) return;
|
|
937
|
+
this.client = new SmoothAgentClient({ url: this.config.endpoint });
|
|
938
|
+
await this.client.connect();
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Open the connection and either RESUME or create a session.
|
|
942
|
+
*
|
|
943
|
+
* 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
|
|
944
|
+
* hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
|
|
945
|
+
* ONLY the pointer (identity/consent survive).
|
|
946
|
+
* 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
|
|
947
|
+
* `resumable`, adopt the returned session (the wrapper has primed the
|
|
948
|
+
* operator registry), reuse the sessionId, and hydrate via get_session/
|
|
949
|
+
* get_messages — rather than relying on createConversationSession to resume.
|
|
950
|
+
* 3. Otherwise create a fresh session.
|
|
951
|
+
*/
|
|
952
|
+
async connect() {
|
|
953
|
+
if (this.status === "connecting" || this.status === "ready") return;
|
|
954
|
+
this.setStatus("connecting");
|
|
955
|
+
try {
|
|
956
|
+
await this.ensureClient();
|
|
957
|
+
if (!this.resumeAttempted) {
|
|
958
|
+
this.resumeAttempted = true;
|
|
959
|
+
const persistedSessionId = this.store.getState().sessionId;
|
|
960
|
+
if (persistedSessionId) {
|
|
961
|
+
if (await this.tryResume(persistedSessionId)) {
|
|
962
|
+
this.setStatus("ready");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
this.store.getState().clearSession();
|
|
966
|
+
} else {
|
|
967
|
+
const fpSessionId = await this.resumeByFingerprint();
|
|
968
|
+
if (fpSessionId) {
|
|
969
|
+
if (await this.tryResume(fpSessionId)) {
|
|
970
|
+
this.store.getState().setSessionId(fpSessionId);
|
|
971
|
+
this.setStatus("ready");
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
1181
976
|
}
|
|
977
|
+
await this.createSession();
|
|
978
|
+
this.setStatus("ready");
|
|
979
|
+
} catch (err) {
|
|
980
|
+
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
981
|
+
throw err;
|
|
1182
982
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Build the auth fields every `/internal/*` route shares: `agentId` (required
|
|
986
|
+
* for the agent-policy lookup), `agentName` (used as the OTP email sender), and
|
|
987
|
+
* the optional pre-auth `authContext` the host page may have configured. The
|
|
988
|
+
* `Origin` header is sent automatically by the browser and checked server-side.
|
|
989
|
+
*/
|
|
990
|
+
authBody() {
|
|
991
|
+
const body = { agentId: this.config.agentId };
|
|
992
|
+
if (this.config.agentName) body.agentName = this.config.agentName;
|
|
993
|
+
if (this.config.authContext) body.authContext = this.config.authContext;
|
|
994
|
+
return body;
|
|
995
|
+
}
|
|
996
|
+
/** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
|
|
997
|
+
async postInternal(path, payload) {
|
|
998
|
+
if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
|
|
999
|
+
const res = await fetch(`${this.httpBase}${path}`, {
|
|
1000
|
+
method: "POST",
|
|
1001
|
+
headers: { "content-type": "application/json" },
|
|
1002
|
+
credentials: "omit",
|
|
1003
|
+
body: JSON.stringify({
|
|
1004
|
+
...this.authBody(),
|
|
1005
|
+
...payload
|
|
1006
|
+
})
|
|
1007
|
+
});
|
|
1008
|
+
let json = {};
|
|
1009
|
+
try {
|
|
1010
|
+
json = await res.json();
|
|
1011
|
+
} catch {
|
|
1012
|
+
json = {};
|
|
1192
1013
|
}
|
|
1193
|
-
if (
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
flush();
|
|
1197
|
-
out += `<em>${renderInline(input.slice(i + 1, end))}</em>`;
|
|
1198
|
-
i = end + 1;
|
|
1199
|
-
continue;
|
|
1200
|
-
}
|
|
1014
|
+
if (!res.ok) {
|
|
1015
|
+
const err = json.error;
|
|
1016
|
+
throw new Error(err?.message ?? `${path} failed (${res.status})`);
|
|
1201
1017
|
}
|
|
1202
|
-
|
|
1203
|
-
i++;
|
|
1018
|
+
return json;
|
|
1204
1019
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1020
|
+
/**
|
|
1021
|
+
* POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
|
|
1022
|
+
* the wrapper found (and primed) a recent session for this fingerprint, else
|
|
1023
|
+
* null. Network/route failures are swallowed → null (fall through to create).
|
|
1024
|
+
*/
|
|
1025
|
+
async resumeByFingerprint() {
|
|
1026
|
+
try {
|
|
1027
|
+
const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
|
|
1028
|
+
if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
|
|
1029
|
+
} catch {}
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
/** `create_conversation_session` with fingerprint + identity + consent metadata. */
|
|
1033
|
+
async createSession() {
|
|
1034
|
+
if (!this.client) throw new Error("Conversation is not connected");
|
|
1035
|
+
const state = this.store.getState();
|
|
1036
|
+
const metadata = this.sessionMetadata();
|
|
1037
|
+
const session = await this.client.createConversationSession({
|
|
1038
|
+
agentId: this.config.agentId,
|
|
1039
|
+
userName: state.identity.name,
|
|
1040
|
+
userEmail: state.identity.email,
|
|
1041
|
+
browserFingerprint: this.fingerprint(),
|
|
1042
|
+
...metadata ? { metadata } : {}
|
|
1043
|
+
});
|
|
1044
|
+
this.sessionId = session.sessionId;
|
|
1045
|
+
this.store.getState().setSessionId(session.sessionId);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Attempt to resume `sessionId`: returns true and hydrates the transcript when
|
|
1049
|
+
* the session is live, false when it has ended / can't be fetched.
|
|
1050
|
+
*/
|
|
1051
|
+
async tryResume(sessionId) {
|
|
1052
|
+
if (!this.client) return false;
|
|
1053
|
+
let snap;
|
|
1054
|
+
try {
|
|
1055
|
+
snap = await this.client.getSession({ sessionId });
|
|
1056
|
+
} catch {
|
|
1057
|
+
return false;
|
|
1238
1058
|
}
|
|
1059
|
+
if (snap.status === "ended") return false;
|
|
1060
|
+
this.sessionId = sessionId;
|
|
1061
|
+
await this.hydrateHistory(sessionId);
|
|
1062
|
+
return true;
|
|
1239
1063
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1064
|
+
/** Page recent history (newest-first), reverse to chronological, and render. */
|
|
1065
|
+
async hydrateHistory(sessionId) {
|
|
1066
|
+
if (!this.client) return;
|
|
1067
|
+
try {
|
|
1068
|
+
const page = await this.client.getMessages({
|
|
1069
|
+
sessionId,
|
|
1070
|
+
limit: 50
|
|
1071
|
+
});
|
|
1072
|
+
const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
|
|
1073
|
+
const hydrated = [];
|
|
1074
|
+
chronological.forEach((m, i) => {
|
|
1075
|
+
const chat = wireMessageToChat(m, i);
|
|
1076
|
+
if (chat) hydrated.push(chat);
|
|
1077
|
+
});
|
|
1078
|
+
this.messages.length = 0;
|
|
1079
|
+
this.messages.push(...hydrated);
|
|
1080
|
+
this.emitMessages();
|
|
1081
|
+
} catch {}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Submit a user message. Appends the user bubble immediately, then streams the
|
|
1085
|
+
* assistant reply token-by-token, finalizing on `eventual_response`.
|
|
1086
|
+
*/
|
|
1087
|
+
async send(text) {
|
|
1088
|
+
const trimmed = text.trim();
|
|
1089
|
+
if (!trimmed) return;
|
|
1090
|
+
if (!this.client || !this.sessionId || this.status !== "ready") await this.connect();
|
|
1091
|
+
if (!this.client || !this.sessionId) throw new Error("Conversation is not connected");
|
|
1092
|
+
this.messages.push({
|
|
1093
|
+
id: this.nextId("u"),
|
|
1094
|
+
role: "user",
|
|
1095
|
+
text: trimmed,
|
|
1096
|
+
streaming: false
|
|
1097
|
+
});
|
|
1098
|
+
const assistant = {
|
|
1099
|
+
id: this.nextId("a"),
|
|
1100
|
+
role: "assistant",
|
|
1101
|
+
text: "",
|
|
1102
|
+
streaming: true
|
|
1103
|
+
};
|
|
1104
|
+
this.messages.push(assistant);
|
|
1105
|
+
this.emitMessages();
|
|
1106
|
+
try {
|
|
1107
|
+
const turn = this.client.sendMessage({
|
|
1108
|
+
sessionId: this.sessionId,
|
|
1109
|
+
message: trimmed,
|
|
1110
|
+
stream: true
|
|
1111
|
+
});
|
|
1112
|
+
this.activeRequestId = turn.requestId;
|
|
1113
|
+
for await (const event of turn) if (event.type === "stream_token") {
|
|
1114
|
+
const token = event.token ?? event.data?.token ?? "";
|
|
1115
|
+
if (token) {
|
|
1116
|
+
assistant.text += token;
|
|
1117
|
+
this.emitMessages();
|
|
1118
|
+
}
|
|
1119
|
+
} else this.handleTurnEvent(event);
|
|
1120
|
+
const inner = (await turn).data?.data;
|
|
1121
|
+
const finalText = extractFinalText(inner?.response);
|
|
1122
|
+
if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
|
|
1123
|
+
if (!assistant.text) assistant.text = "(no response)";
|
|
1124
|
+
const citations = extractCitations(inner);
|
|
1125
|
+
if (citations.length > 0) assistant.citations = citations;
|
|
1126
|
+
assistant.streaming = false;
|
|
1127
|
+
this.emitMessages();
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
assistant.streaming = false;
|
|
1130
|
+
const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
|
|
1131
|
+
assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
|
|
1132
|
+
this.emitMessages();
|
|
1133
|
+
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
1134
|
+
} finally {
|
|
1135
|
+
this.activeRequestId = null;
|
|
1136
|
+
this.setInterrupt(null);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */
|
|
1140
|
+
handleTurnEvent(event) {
|
|
1141
|
+
const inner = event.data?.data ?? {};
|
|
1142
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
1143
|
+
const num = (v) => typeof v === "number" ? v : void 0;
|
|
1144
|
+
switch (event.type) {
|
|
1145
|
+
case "otp_verification_required": {
|
|
1146
|
+
const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
|
|
1147
|
+
this.setInterrupt({
|
|
1148
|
+
kind: "otp",
|
|
1149
|
+
toolId: str(inner.toolId),
|
|
1150
|
+
actionDescription: str(inner.actionDescription),
|
|
1151
|
+
availableChannels: channels.length > 0 ? channels : ["email"]
|
|
1152
|
+
});
|
|
1153
|
+
break;
|
|
1267
1154
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1155
|
+
case "otp_sent":
|
|
1156
|
+
if (this.interrupt?.kind === "otp") this.setInterrupt({
|
|
1157
|
+
...this.interrupt,
|
|
1158
|
+
sent: {
|
|
1159
|
+
channel: str(inner.channel),
|
|
1160
|
+
maskedDestination: str(inner.maskedDestination)
|
|
1161
|
+
},
|
|
1162
|
+
error: void 0
|
|
1163
|
+
});
|
|
1164
|
+
break;
|
|
1165
|
+
case "otp_verified":
|
|
1166
|
+
if (this.interrupt?.kind === "otp") this.setInterrupt(null);
|
|
1167
|
+
break;
|
|
1168
|
+
case "otp_invalid":
|
|
1169
|
+
if (this.interrupt?.kind === "otp") this.setInterrupt({
|
|
1170
|
+
...this.interrupt,
|
|
1171
|
+
error: str(inner.message) ?? "That code was incorrect.",
|
|
1172
|
+
attemptsRemaining: num(inner.attemptsRemaining)
|
|
1173
|
+
});
|
|
1174
|
+
break;
|
|
1175
|
+
case "write_confirmation_required":
|
|
1176
|
+
this.setInterrupt({
|
|
1177
|
+
kind: "confirm",
|
|
1178
|
+
toolId: str(inner.toolId),
|
|
1179
|
+
actionDescription: str(inner.actionDescription)
|
|
1180
|
+
});
|
|
1181
|
+
break;
|
|
1182
|
+
default: break;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Begin the cross-device restore: POST `/internal/identity/request-otp` for
|
|
1187
|
+
* `email` over `channel`. The view collects the email via an explicit affordance.
|
|
1188
|
+
*/
|
|
1189
|
+
async requestIdentityOtp(email, channel = "email") {
|
|
1190
|
+
const trimmed = email.trim();
|
|
1191
|
+
if (!trimmed) return;
|
|
1192
|
+
this.setIdentityRestore({
|
|
1193
|
+
phase: "requesting",
|
|
1194
|
+
email: trimmed,
|
|
1195
|
+
channel
|
|
1196
|
+
});
|
|
1197
|
+
if (!this.sessionId) try {
|
|
1198
|
+
await this.connect();
|
|
1199
|
+
} catch {}
|
|
1200
|
+
if (!this.sessionId) {
|
|
1201
|
+
this.setIdentityRestore({
|
|
1202
|
+
phase: "error",
|
|
1203
|
+
message: "No active session to verify against."
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1271
1206
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1207
|
+
try {
|
|
1208
|
+
const json = await this.postInternal("/internal/identity/request-otp", {
|
|
1209
|
+
sessionId: this.sessionId,
|
|
1210
|
+
email: trimmed,
|
|
1211
|
+
channel
|
|
1212
|
+
});
|
|
1213
|
+
const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
|
|
1214
|
+
this.setIdentityRestore({
|
|
1215
|
+
phase: "awaiting_code",
|
|
1216
|
+
email: trimmed,
|
|
1217
|
+
channel,
|
|
1218
|
+
maskedDestination: masked
|
|
1219
|
+
});
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
this.setIdentityRestore({
|
|
1222
|
+
phase: "error",
|
|
1223
|
+
message: err instanceof Error ? err.message : "Could not send a verification code."
|
|
1224
|
+
});
|
|
1275
1225
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1226
|
+
}
|
|
1227
|
+
/** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
|
|
1228
|
+
async verifyIdentityOtp(code) {
|
|
1229
|
+
const state = this.identityRestore;
|
|
1230
|
+
const trimmed = code.trim();
|
|
1231
|
+
if (!trimmed || state.phase !== "awaiting_code") return;
|
|
1232
|
+
const { email, channel } = state;
|
|
1233
|
+
if (!this.sessionId) {
|
|
1234
|
+
this.setIdentityRestore({
|
|
1235
|
+
phase: "error",
|
|
1236
|
+
message: "No active session to verify against."
|
|
1237
|
+
});
|
|
1238
|
+
return;
|
|
1281
1239
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1240
|
+
this.setIdentityRestore({
|
|
1241
|
+
phase: "verifying",
|
|
1242
|
+
email,
|
|
1243
|
+
channel
|
|
1244
|
+
});
|
|
1245
|
+
try {
|
|
1246
|
+
const json = await this.postInternal("/internal/identity/verify-otp", {
|
|
1247
|
+
sessionId: this.sessionId,
|
|
1248
|
+
email,
|
|
1249
|
+
code: trimmed
|
|
1250
|
+
});
|
|
1251
|
+
if (json.event === "otp_verified") {
|
|
1252
|
+
this.store.getState().setVerifiedEmail(email, this.sessionId);
|
|
1253
|
+
await this.resolveIdentity(email);
|
|
1254
|
+
} else if (json.event === "otp_invalid") {
|
|
1255
|
+
const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
|
|
1256
|
+
this.setIdentityRestore({
|
|
1257
|
+
phase: "awaiting_code",
|
|
1258
|
+
email,
|
|
1259
|
+
channel,
|
|
1260
|
+
error: "That code was incorrect.",
|
|
1261
|
+
attemptsRemaining: remaining
|
|
1262
|
+
});
|
|
1263
|
+
} else this.setIdentityRestore({
|
|
1264
|
+
phase: "error",
|
|
1265
|
+
message: "Verification failed."
|
|
1266
|
+
});
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
this.setIdentityRestore({
|
|
1269
|
+
phase: "error",
|
|
1270
|
+
message: err instanceof Error ? err.message : "Verification failed."
|
|
1271
|
+
});
|
|
1294
1272
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1273
|
+
}
|
|
1274
|
+
/** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
|
|
1275
|
+
async resolveIdentity(email) {
|
|
1276
|
+
if (!this.sessionId) return;
|
|
1277
|
+
this.setIdentityRestore({
|
|
1278
|
+
phase: "resolving",
|
|
1279
|
+
email
|
|
1280
|
+
});
|
|
1281
|
+
try {
|
|
1282
|
+
const json = await this.postInternal("/internal/identity/resolve", {
|
|
1283
|
+
sessionId: this.sessionId,
|
|
1284
|
+
email
|
|
1285
|
+
});
|
|
1286
|
+
if (json.resolved !== true) {
|
|
1287
|
+
this.setIdentityRestore({
|
|
1288
|
+
phase: "resolved",
|
|
1289
|
+
email,
|
|
1290
|
+
conversations: []
|
|
1291
|
+
});
|
|
1292
|
+
return;
|
|
1302
1293
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1294
|
+
const raw = json.conversations;
|
|
1295
|
+
const conversations = Array.isArray(raw) ? raw.map((c) => {
|
|
1296
|
+
if (!c || typeof c !== "object") return null;
|
|
1297
|
+
const o = c;
|
|
1298
|
+
const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
|
|
1299
|
+
const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
|
|
1300
|
+
if (!sessionId) return null;
|
|
1301
|
+
return {
|
|
1302
|
+
conversationId,
|
|
1303
|
+
sessionId,
|
|
1304
|
+
lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
|
|
1305
|
+
preview: typeof o.preview === "string" ? o.preview : void 0
|
|
1306
|
+
};
|
|
1307
|
+
}).filter((c) => c !== null) : [];
|
|
1308
|
+
this.setIdentityRestore({
|
|
1309
|
+
phase: "resolved",
|
|
1310
|
+
email,
|
|
1311
|
+
conversations
|
|
1312
|
+
});
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
this.setIdentityRestore({
|
|
1315
|
+
phase: "error",
|
|
1316
|
+
message: err instanceof Error ? err.message : "Could not load your chats."
|
|
1317
|
+
});
|
|
1312
1318
|
}
|
|
1313
|
-
out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
|
|
1314
1319
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
* The result is still rendered through {@link renderMarkdown} downstream, so any
|
|
1333
|
-
* remaining inline markup (bold/links) stays safe.
|
|
1334
|
-
*/
|
|
1335
|
-
function cleanCitationSnippet(raw) {
|
|
1336
|
-
let s = raw ?? "";
|
|
1337
|
-
let changed = true;
|
|
1338
|
-
while (changed) {
|
|
1339
|
-
changed = false;
|
|
1340
|
-
const before = s;
|
|
1341
|
-
s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
|
|
1342
|
-
s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
|
|
1343
|
-
s = s.replace(/^\s*#{1,6}\s+/, "");
|
|
1344
|
-
if (s !== before) changed = true;
|
|
1320
|
+
/**
|
|
1321
|
+
* Replay a chosen restorable conversation: point the live session at its
|
|
1322
|
+
* sessionId, hydrate its transcript (get_session + get_messages), and persist
|
|
1323
|
+
* the new pointer so the next `sendMessage` continues it.
|
|
1324
|
+
*/
|
|
1325
|
+
async restoreConversation(sessionId) {
|
|
1326
|
+
if (!this.client) await this.ensureClient();
|
|
1327
|
+
const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
|
|
1328
|
+
if (await this.tryResume(sessionId)) {
|
|
1329
|
+
this.store.getState().setSessionId(sessionId);
|
|
1330
|
+
if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
|
|
1331
|
+
this.setIdentityRestore({ phase: "idle" });
|
|
1332
|
+
this.setStatus("ready");
|
|
1333
|
+
} else this.setIdentityRestore({
|
|
1334
|
+
phase: "error",
|
|
1335
|
+
message: "That conversation is no longer available."
|
|
1336
|
+
});
|
|
1345
1337
|
}
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
const lastSpace = cut.lastIndexOf(" ");
|
|
1350
|
-
s = (lastSpace > SNIPPET_MAX * .6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
|
|
1338
|
+
/** Dismiss the cross-device restore panel. */
|
|
1339
|
+
cancelIdentityRestore() {
|
|
1340
|
+
this.setIdentityRestore({ phase: "idle" });
|
|
1351
1341
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1342
|
+
/** Tear down the underlying client. */
|
|
1343
|
+
disconnect() {
|
|
1344
|
+
this.client?.disconnect("widget closed");
|
|
1345
|
+
this.client = null;
|
|
1346
|
+
this.sessionId = null;
|
|
1347
|
+
this.activeRequestId = null;
|
|
1348
|
+
this.resumeAttempted = false;
|
|
1349
|
+
this.setInterrupt(null);
|
|
1350
|
+
this.setStatus("closed");
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/logo.ts
|
|
1355
|
+
/** The square Smooth icon (the stylized `th` glyph) — used as the default full-page header avatar. */
|
|
1356
|
+
const SMOOTH_ICON_SVG = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg id=\"Layer_1\" data-name=\"Layer 1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 150 150\">\n <defs>\n <style>\n .cls-1 {\n fill: url(#linear-gradient);\n }\n </style>\n <linearGradient id=\"linear-gradient\" x1=\"31.06\" y1=\"37.6\" x2=\"118.5\" y2=\"125.04\" gradientUnits=\"userSpaceOnUse\">\n <stop offset=\".43\" stop-color=\"#00a6a6\"/>\n <stop offset=\"1\" stop-color=\"#1238dd\"/>\n </linearGradient>\n </defs>\n <path class=\"cls-1\" d=\"M55.03,55.47v13.28H19.24v-13.28h35.79ZM26.83,41.83h17.89v53.42c0,1.5.36,2.62,1.08,3.36.72.74,1.88,1.1,3.49,1.1.62,0,1.48-.07,2.59-.21,1.11-.14,1.91-.27,2.38-.41l2.31,13.02c-2.02.58-3.97.97-5.84,1.18-1.88.21-3.66.31-5.33.31-6.08,0-10.7-1.43-13.84-4.28-3.15-2.85-4.72-7.01-4.72-12.48v-55.01ZM93.74,80.08v32.71h-17.89V36.39h17.53v33.53h-1.13c1.4-4.55,3.6-8.21,6.59-11,2.99-2.79,7.01-4.18,12.07-4.18,4,0,7.48.89,10.46,2.67,2.97,1.78,5.28,4.29,6.92,7.54,1.64,3.25,2.46,7.02,2.46,11.33v36.5h-17.89v-33.02c0-3.21-.82-5.73-2.46-7.56-1.64-1.83-3.93-2.74-6.87-2.74-1.92,0-3.62.42-5.1,1.26-1.49.84-2.64,2.04-3.46,3.61-.82,1.57-1.23,3.49-1.23,5.74Z\"/>\n</svg>";
|
|
1354
1357
|
//#endregion
|
|
1355
1358
|
//#region src/styles.ts
|
|
1356
1359
|
/**
|
|
@@ -1525,8 +1528,9 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1525
1528
|
0 1px 0 rgba(255, 255, 255, .25) inset;
|
|
1526
1529
|
}
|
|
1527
1530
|
.avatar svg { width: 22px; height: 22px; }
|
|
1528
|
-
.avatar .logo-wrap { display: flex; }
|
|
1531
|
+
.avatar .logo-wrap { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
|
1529
1532
|
.avatar .logo { height: 22px; width: auto; display: block; }
|
|
1533
|
+
.avatar .logo-img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; display: block; border-radius: 9px; }
|
|
1530
1534
|
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
|
1531
1535
|
.title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; }
|
|
1532
1536
|
.status {
|
|
@@ -1575,6 +1579,7 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1575
1579
|
.panel.fullpage .header { padding: 18px 22px; }
|
|
1576
1580
|
.panel.fullpage .avatar { width: 44px; height: 44px; }
|
|
1577
1581
|
.panel.fullpage .avatar .logo { height: 26px; }
|
|
1582
|
+
.panel.fullpage .avatar svg { width: 28px; height: 28px; }
|
|
1578
1583
|
|
|
1579
1584
|
/* ────────────────────────────── Messages ──────────────────────────── */
|
|
1580
1585
|
.messages {
|
|
@@ -1864,6 +1869,24 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1864
1869
|
border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
|
|
1865
1870
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
|
|
1866
1871
|
}
|
|
1872
|
+
/* Inline phone validity — subtle, themed. Empty stays neutral (optional field). */
|
|
1873
|
+
.pc-field.valid input {
|
|
1874
|
+
border-color: color-mix(in srgb, var(--sac-primary) 55%, #2faa6a 45%);
|
|
1875
|
+
}
|
|
1876
|
+
.pc-field.invalid input {
|
|
1877
|
+
border-color: color-mix(in srgb, #e2566b 62%, var(--sac-border) 38%);
|
|
1878
|
+
}
|
|
1879
|
+
.pc-field.invalid input:focus {
|
|
1880
|
+
box-shadow: 0 0 0 4px color-mix(in srgb, #e2566b 16%, transparent);
|
|
1881
|
+
}
|
|
1882
|
+
.pc-field .pc-hint {
|
|
1883
|
+
min-height: 13px;
|
|
1884
|
+
margin-top: 1px;
|
|
1885
|
+
font-size: 11.5px;
|
|
1886
|
+
font-weight: 500;
|
|
1887
|
+
line-height: 1.2;
|
|
1888
|
+
color: color-mix(in srgb, #e2566b 78%, var(--sac-text) 22%);
|
|
1889
|
+
}
|
|
1867
1890
|
.pc-submit {
|
|
1868
1891
|
margin-top: 4px;
|
|
1869
1892
|
border: none;
|
|
@@ -2034,11 +2057,51 @@ function buildStyles(theme, mode = "popover") {
|
|
|
2034
2057
|
}
|
|
2035
2058
|
//#endregion
|
|
2036
2059
|
//#region src/element.ts
|
|
2060
|
+
/**
|
|
2061
|
+
* `<smooth-agent-chat>` — a framework-light embeddable chat web component.
|
|
2062
|
+
*
|
|
2063
|
+
* A clean, dependency-light web component that preserves a familiar embedding
|
|
2064
|
+
* model — a launcher + popover panel, declarative HTML attributes, and a
|
|
2065
|
+
* programmatic API — while talking to the `@smooai/smooth-operator` protocol
|
|
2066
|
+
* client. The visual layer is the "Aurora Glass" design system (see
|
|
2067
|
+
* {@link buildStyles}): a spring launcher with a live presence pulse, a
|
|
2068
|
+
* glass-depth panel, a gradient brand avatar + status dot, an animated typing
|
|
2069
|
+
* indicator, message rise-in, refined source cards, and an icon composer. Every
|
|
2070
|
+
* color is driven by `--sac-*` custom properties so a host's brand flows through.
|
|
2071
|
+
*
|
|
2072
|
+
* Embedding model:
|
|
2073
|
+
* <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
|
|
2074
|
+
* or programmatically via {@link mountChatWidget}.
|
|
2075
|
+
*/
|
|
2037
2076
|
const ELEMENT_TAG = "smooth-agent-chat";
|
|
2077
|
+
/**
|
|
2078
|
+
* Default region for phone parsing/formatting on the pre-chat form. The widget
|
|
2079
|
+
* is US-first; the backend does the authoritative E.164 normalization (SMOODEV-2153),
|
|
2080
|
+
* so this only governs the as-you-type display + the inline validity hint and the
|
|
2081
|
+
* best-effort E.164 we send when the number already parses as valid.
|
|
2082
|
+
*/
|
|
2083
|
+
const PHONE_DEFAULT_REGION = "US";
|
|
2084
|
+
/**
|
|
2085
|
+
* Best-effort E.164 for an as-typed phone number. Returns the canonical
|
|
2086
|
+
* `+1…` form when the value parses to a valid number in {@link PHONE_DEFAULT_REGION},
|
|
2087
|
+
* otherwise `null` (caller falls back to sending the raw value — the backend
|
|
2088
|
+
* re-parses and normalizes/nulls authoritatively).
|
|
2089
|
+
*/
|
|
2090
|
+
function phoneToE164(value) {
|
|
2091
|
+
const v = value.trim();
|
|
2092
|
+
if (!v) return null;
|
|
2093
|
+
try {
|
|
2094
|
+
if (!isValidPhoneNumber(v, PHONE_DEFAULT_REGION)) return null;
|
|
2095
|
+
return parsePhoneNumber(v, PHONE_DEFAULT_REGION).number;
|
|
2096
|
+
} catch {
|
|
2097
|
+
return null;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2038
2100
|
const OBSERVED = [
|
|
2039
2101
|
"endpoint",
|
|
2040
2102
|
"agent-id",
|
|
2041
2103
|
"agent-name",
|
|
2104
|
+
"logo-url",
|
|
2042
2105
|
"placeholder",
|
|
2043
2106
|
"greeting",
|
|
2044
2107
|
"start-open",
|
|
@@ -2150,6 +2213,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2150
2213
|
mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
|
|
2151
2214
|
agentId,
|
|
2152
2215
|
agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
|
|
2216
|
+
logoUrl: this.overrides.logoUrl ?? this.getAttribute("logo-url") ?? void 0,
|
|
2153
2217
|
userName: this.overrides.userName,
|
|
2154
2218
|
userEmail: this.overrides.userEmail,
|
|
2155
2219
|
userPhone: this.overrides.userPhone,
|
|
@@ -2204,8 +2268,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2204
2268
|
const style = document.createElement("style");
|
|
2205
2269
|
style.textContent = buildStyles(resolved.theme, resolved.mode);
|
|
2206
2270
|
const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase());
|
|
2271
|
+
const headerLogo = resolved.logoUrl ? `<img src="${escapeHtml(resolved.logoUrl)}" alt="" class="logo-img" />` : SMOOTH_ICON_SVG;
|
|
2207
2272
|
const header = fullpage ? `<div class="header">
|
|
2208
|
-
<div class="avatar"><span class="logo-wrap">${
|
|
2273
|
+
<div class="avatar"><span class="logo-wrap">${headerLogo}</span></div>
|
|
2209
2274
|
<div class="meta">
|
|
2210
2275
|
<span class="title">${escapeHtml(resolved.agentName)}</span>
|
|
2211
2276
|
<span class="status"><span class="dot off"></span><span class="status-text"></span></span>
|
|
@@ -2224,7 +2289,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2224
2289
|
const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
|
|
2225
2290
|
this.gating = gating;
|
|
2226
2291
|
const showPhone = resolved.requirePhone || resolved.collectPhone;
|
|
2227
|
-
const field = (name, type, label, autocomplete, required) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""}
|
|
2292
|
+
const field = (name, type, label, autocomplete, required, hint = false) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} />${hint ? "<span class=\"pc-hint\" aria-live=\"polite\"></span>" : ""}</label>`;
|
|
2228
2293
|
const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
|
|
2229
2294
|
const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
|
|
2230
2295
|
${consentBox("emailOptIn", "Email me product news and offers.")}
|
|
@@ -2239,7 +2304,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2239
2304
|
<form class="pc-form" novalidate>
|
|
2240
2305
|
${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
|
|
2241
2306
|
${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
|
|
2242
|
-
${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone) : ""}
|
|
2307
|
+
${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone, true) : ""}
|
|
2243
2308
|
${consentHtml}
|
|
2244
2309
|
<button type="submit" class="pc-submit">Start chat</button>
|
|
2245
2310
|
</form>
|
|
@@ -2291,6 +2356,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2291
2356
|
ev.preventDefault();
|
|
2292
2357
|
this.handlePrechatSubmit(pcForm);
|
|
2293
2358
|
});
|
|
2359
|
+
this.wirePhoneField(pcForm);
|
|
2294
2360
|
container.querySelector(".restore-link")?.addEventListener("click", () => {
|
|
2295
2361
|
(async () => {
|
|
2296
2362
|
this.identityRestore = { phase: "awaiting_email" };
|
|
@@ -2566,16 +2632,77 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2566
2632
|
return "";
|
|
2567
2633
|
}
|
|
2568
2634
|
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Wire as-you-type formatting + an inline validity hint onto the pre-chat
|
|
2637
|
+
* phone input (libphonenumber-js, US default region). Autofill is preserved:
|
|
2638
|
+
* the input keeps its `type="tel"` + `autocomplete="tel"` + implicit <label>,
|
|
2639
|
+
* and we also reformat on `change` so a browser-autofilled value gets
|
|
2640
|
+
* formatted/validated too.
|
|
2641
|
+
*
|
|
2642
|
+
* As-you-type caret note: `AsYouType` reformats the entire string, which
|
|
2643
|
+
* moves the caret to the end on a mid-string edit. To avoid fighting the
|
|
2644
|
+
* user, we only rewrite the value when the caret is at the end (the typical
|
|
2645
|
+
* append-a-digit case) and never on a deletion — so backspacing the
|
|
2646
|
+
* formatting characters works naturally.
|
|
2647
|
+
*/
|
|
2648
|
+
wirePhoneField(form) {
|
|
2649
|
+
const input = form?.querySelector("input[name=\"phone\"]");
|
|
2650
|
+
if (!input) return;
|
|
2651
|
+
const hint = input.parentElement?.querySelector(".pc-hint");
|
|
2652
|
+
const updateHint = () => {
|
|
2653
|
+
const v = input.value.trim();
|
|
2654
|
+
const field = input.closest(".pc-field");
|
|
2655
|
+
if (!v) {
|
|
2656
|
+
field?.classList.remove("valid", "invalid");
|
|
2657
|
+
if (hint) hint.textContent = "";
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
const ok = isValidPhoneNumber(v, PHONE_DEFAULT_REGION);
|
|
2661
|
+
field?.classList.toggle("valid", ok);
|
|
2662
|
+
field?.classList.toggle("invalid", !ok);
|
|
2663
|
+
if (hint) hint.textContent = ok ? "" : "Enter a valid phone number";
|
|
2664
|
+
};
|
|
2665
|
+
const reformat = () => {
|
|
2666
|
+
if (input.selectionStart === input.value.length && input.selectionEnd === input.value.length) {
|
|
2667
|
+
const formatted = new AsYouType(PHONE_DEFAULT_REGION).input(input.value);
|
|
2668
|
+
if (formatted.length >= input.value.length) input.value = formatted;
|
|
2669
|
+
}
|
|
2670
|
+
updateHint();
|
|
2671
|
+
};
|
|
2672
|
+
input.addEventListener("input", (ev) => {
|
|
2673
|
+
const ie = ev;
|
|
2674
|
+
if (typeof ie.inputType === "string" && ie.inputType.startsWith("delete")) {
|
|
2675
|
+
updateHint();
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
reformat();
|
|
2679
|
+
});
|
|
2680
|
+
input.addEventListener("change", reformat);
|
|
2681
|
+
}
|
|
2569
2682
|
/** Collect identity + consent from the pre-chat form, then drop into the chat view. */
|
|
2570
2683
|
handlePrechatSubmit(form) {
|
|
2571
2684
|
if (!form.reportValidity()) return;
|
|
2572
2685
|
const data = new FormData(form);
|
|
2573
2686
|
const val = (k) => data.get(k)?.trim() || void 0;
|
|
2574
2687
|
const checked = (k) => data.get(k) === "on";
|
|
2688
|
+
const rawPhone = val("phone");
|
|
2689
|
+
const phoneInput = form.querySelector("input[name=\"phone\"]");
|
|
2690
|
+
if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
|
|
2691
|
+
const required = phoneInput.hasAttribute("required");
|
|
2692
|
+
const field = phoneInput.closest(".pc-field");
|
|
2693
|
+
field?.classList.add("invalid");
|
|
2694
|
+
const hint = field?.querySelector(".pc-hint");
|
|
2695
|
+
if (hint) hint.textContent = "Enter a valid phone number";
|
|
2696
|
+
if (required) {
|
|
2697
|
+
phoneInput.focus();
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
const phone = rawPhone ? phoneToE164(rawPhone) ?? rawPhone : void 0;
|
|
2575
2702
|
this.controller?.setUserInfo({
|
|
2576
2703
|
name: val("name"),
|
|
2577
2704
|
email: val("email"),
|
|
2578
|
-
phone
|
|
2705
|
+
phone,
|
|
2579
2706
|
consent: {
|
|
2580
2707
|
emailOptIn: checked("emailOptIn"),
|
|
2581
2708
|
smsOptIn: checked("smsOptIn")
|