@smooai/chat-widget 0.9.0 → 0.10.1
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 +887 -845
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1314 -1272
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +15 -1
- package/src/element.ts +36 -5
- package/src/logo.ts +9 -4
- package/src/styles.ts +29 -8
package/dist/index.js
CHANGED
|
@@ -2,1356 +2,1358 @@ import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-
|
|
|
2
2
|
import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
|
|
3
3
|
import { createStore } from "zustand/vanilla";
|
|
4
4
|
import { persist } from "zustand/middleware";
|
|
5
|
-
//#region src/
|
|
6
|
-
/** Resolve a partial config against the built-in defaults. */
|
|
7
|
-
function resolveConfig(config) {
|
|
8
|
-
const theme = config.theme ?? {};
|
|
9
|
-
const primary = theme.primary ?? "#00a6a6";
|
|
10
|
-
const primaryText = theme.primaryText ?? "#f8fafc";
|
|
11
|
-
const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b";
|
|
12
|
-
const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc";
|
|
13
|
-
const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary;
|
|
14
|
-
const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText;
|
|
15
|
-
return {
|
|
16
|
-
endpoint: config.endpoint,
|
|
17
|
-
mode: config.mode ?? "popover",
|
|
18
|
-
agentId: config.agentId,
|
|
19
|
-
agentName: config.agentName ?? "Assistant",
|
|
20
|
-
userName: config.userName,
|
|
21
|
-
userEmail: config.userEmail,
|
|
22
|
-
userPhone: config.userPhone,
|
|
23
|
-
authContext: config.authContext,
|
|
24
|
-
placeholder: config.placeholder ?? "Type a message…",
|
|
25
|
-
greeting: config.greeting ?? "Hi! How can I help you today?",
|
|
26
|
-
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
27
|
-
startOpen: config.startOpen ?? false,
|
|
28
|
-
examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5),
|
|
29
|
-
requireName: config.requireName ?? false,
|
|
30
|
-
requireEmail: config.requireEmail ?? false,
|
|
31
|
-
requirePhone: config.requirePhone ?? false,
|
|
32
|
-
collectPhone: config.collectPhone ?? true,
|
|
33
|
-
collectConsent: config.collectConsent ?? true,
|
|
34
|
-
allowChatRestore: config.allowChatRestore ?? true,
|
|
35
|
-
allowAnonymous: config.allowAnonymous ?? false,
|
|
36
|
-
theme: {
|
|
37
|
-
text: theme.text ?? "#f8fafc",
|
|
38
|
-
background: theme.background ?? "#040d30",
|
|
39
|
-
primary,
|
|
40
|
-
primaryText,
|
|
41
|
-
secondary: theme.secondary ?? "#ff6b6c",
|
|
42
|
-
assistantBubble,
|
|
43
|
-
assistantBubbleText,
|
|
44
|
-
userBubble,
|
|
45
|
-
userBubbleText,
|
|
46
|
-
border: theme.border ?? "rgba(255, 255, 255, 0.1)"
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Whether the pre-chat identity form should gate the conversation: at least one
|
|
52
|
-
* field is required and anonymous chat is not allowed.
|
|
53
|
-
*/
|
|
54
|
-
function needsUserInfo(resolved) {
|
|
55
|
-
return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
|
|
56
|
-
}
|
|
57
|
-
//#endregion
|
|
58
|
-
//#region src/fingerprint.ts
|
|
5
|
+
//#region src/markdown.ts
|
|
59
6
|
/**
|
|
60
|
-
*
|
|
61
|
-
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
62
|
-
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
63
|
-
* CRM contact). It rides every `create_conversation_session` as
|
|
64
|
-
* `browserFingerprint` (see ADR-048).
|
|
7
|
+
* A tiny, safe-by-default Markdown → HTML renderer for the chat widget.
|
|
65
8
|
*
|
|
66
|
-
* ## Why not
|
|
9
|
+
* ## Why a hand-rolled renderer (and not markdown-it / snarkdown)?
|
|
67
10
|
*
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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.
|
|
74
17
|
*
|
|
75
|
-
*
|
|
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**:
|
|
76
23
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
90
51
|
*/
|
|
91
|
-
/**
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
parts.push(`plat:${nav.platform ?? ""}`);
|
|
101
|
-
parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
|
|
102
|
-
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 "'";
|
|
103
61
|
}
|
|
104
|
-
if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
105
|
-
if (typeof Intl !== "undefined") try {
|
|
106
|
-
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
|
|
107
|
-
} catch {}
|
|
108
|
-
parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
|
|
109
|
-
} catch {}
|
|
110
|
-
return parts.join("|");
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
114
|
-
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
115
|
-
* any security decision — only to derive a deterministic seed from the signal
|
|
116
|
-
* string above.
|
|
117
|
-
*/
|
|
118
|
-
function fnv1a(input) {
|
|
119
|
-
let h = 2166136261;
|
|
120
|
-
for (let i = 0; i < input.length; i++) {
|
|
121
|
-
h ^= input.charCodeAt(i);
|
|
122
|
-
h = Math.imul(h, 16777619);
|
|
123
|
-
}
|
|
124
|
-
return (h >>> 0).toString(16).padStart(8, "0");
|
|
125
|
-
}
|
|
126
|
-
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
127
|
-
function generateUuid() {
|
|
128
|
-
try {
|
|
129
|
-
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
130
|
-
} catch {}
|
|
131
|
-
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
132
|
-
const r = Math.random() * 16 | 0;
|
|
133
|
-
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
134
62
|
});
|
|
135
63
|
}
|
|
136
64
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return `${generateUuid()}.${signalHash}`;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Return the cached fingerprint if one was already computed for this browser,
|
|
148
|
-
* otherwise compute + persist a new one via the provided accessors. The
|
|
149
|
-
* 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.
|
|
150
72
|
*/
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|
|
157
81
|
}
|
|
158
|
-
//#endregion
|
|
159
|
-
//#region src/persistence.ts
|
|
160
82
|
/**
|
|
161
|
-
*
|
|
162
|
-
* (ADR-048, SMOODEV-2129e).
|
|
163
|
-
*
|
|
164
|
-
* Built on Zustand's framework-agnostic `vanilla` store + the `persist`
|
|
165
|
-
* middleware (the user explicitly chose Zustand; it's ~1KB and has no React
|
|
166
|
-
* dependency, so it works inside the web component). The store is keyed per
|
|
167
|
-
* agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
|
|
168
|
-
* on the same origin don't clobber each other.
|
|
169
|
-
*
|
|
170
|
-
* ## What persists (and, deliberately, what does NOT)
|
|
171
|
-
*
|
|
172
|
-
* Only a **pointer** to the conversation plus the visitor's identity + marketing
|
|
173
|
-
* consent + verified email + browser fingerprint. The transcript is **never**
|
|
174
|
-
* persisted: the smooth-operator server is the source of truth and the widget
|
|
175
|
-
* re-hydrates history via `getMessages` on resume. This keeps localStorage small
|
|
176
|
-
* and avoids stale/divergent transcripts.
|
|
83
|
+
* Render the inline span grammar of a single line/segment to safe HTML.
|
|
177
84
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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.
|
|
180
88
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
identity: {},
|
|
192
|
-
consent: { ...EMPTY_CONSENT },
|
|
193
|
-
verifiedEmail: null,
|
|
194
|
-
verifiedEmailSessionId: null,
|
|
195
|
-
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
|
+
}
|
|
196
99
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
* IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
|
|
207
|
-
* zustand v5's `persist` treats a missing `storage` option by falling back to its
|
|
208
|
-
* OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
|
|
209
|
-
* localStorage the guard was trying to avoid (throwing again in privacy mode).
|
|
210
|
-
* Handing it this no-op storage keeps the store working purely in memory and
|
|
211
|
-
* guarantees the fallback can't touch real localStorage.
|
|
212
|
-
*/
|
|
213
|
-
function memoryStorage() {
|
|
214
|
-
const mem = /* @__PURE__ */ new Map();
|
|
215
|
-
return {
|
|
216
|
-
getItem: (name) => {
|
|
217
|
-
const raw = mem.get(name);
|
|
218
|
-
if (!raw) return null;
|
|
219
|
-
try {
|
|
220
|
-
return JSON.parse(raw);
|
|
221
|
-
} catch {
|
|
222
|
-
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;
|
|
223
109
|
}
|
|
224
|
-
},
|
|
225
|
-
setItem: (name, value) => {
|
|
226
|
-
mem.set(name, JSON.stringify(value));
|
|
227
|
-
},
|
|
228
|
-
removeItem: (name) => {
|
|
229
|
-
mem.delete(name);
|
|
230
110
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
function safeStorage() {
|
|
240
|
-
let ls = null;
|
|
241
|
-
try {
|
|
242
|
-
ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
243
|
-
if (ls) {
|
|
244
|
-
const probe = "__smoo_probe__";
|
|
245
|
-
ls.setItem(probe, "1");
|
|
246
|
-
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
|
+
}
|
|
247
119
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
} catch {
|
|
259
|
-
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;
|
|
260
130
|
}
|
|
261
|
-
},
|
|
262
|
-
setItem: (name, value) => {
|
|
263
|
-
try {
|
|
264
|
-
storage.setItem(name, JSON.stringify(value));
|
|
265
|
-
} catch {}
|
|
266
|
-
},
|
|
267
|
-
removeItem: (name) => {
|
|
268
|
-
try {
|
|
269
|
-
storage.removeItem(name);
|
|
270
|
-
} catch {}
|
|
271
131
|
}
|
|
272
|
-
|
|
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;
|
|
273
156
|
}
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const base = initialPersisted();
|
|
281
|
-
if (!persisted || typeof persisted !== "object") return base;
|
|
282
|
-
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;
|
|
283
163
|
return {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
|
|
288
|
-
email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
|
|
289
|
-
phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
|
|
290
|
-
},
|
|
291
|
-
consent: {
|
|
292
|
-
emailOptIn: p.consent?.emailOptIn === true,
|
|
293
|
-
smsOptIn: p.consent?.smsOptIn === true,
|
|
294
|
-
consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
|
|
295
|
-
consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
|
|
296
|
-
},
|
|
297
|
-
verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
|
|
298
|
-
verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
|
|
299
|
-
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
|
|
300
167
|
};
|
|
301
168
|
}
|
|
302
|
-
/**
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
setSessionId: (sessionId) => set({ sessionId }),
|
|
311
|
-
mergeIdentity: (identity) => set((state) => ({ identity: {
|
|
312
|
-
...state.identity,
|
|
313
|
-
...identity.name !== void 0 ? { name: identity.name } : {},
|
|
314
|
-
...identity.email !== void 0 ? { email: identity.email } : {},
|
|
315
|
-
...identity.phone !== void 0 ? { phone: identity.phone } : {}
|
|
316
|
-
} })),
|
|
317
|
-
setConsent: (consent) => set({ consent: { ...consent } }),
|
|
318
|
-
setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
|
|
319
|
-
verifiedEmail,
|
|
320
|
-
verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
|
|
321
|
-
})),
|
|
322
|
-
setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
|
|
323
|
-
clearSession: () => set({
|
|
324
|
-
sessionId: null,
|
|
325
|
-
verifiedEmail: null,
|
|
326
|
-
verifiedEmailSessionId: null
|
|
327
|
-
})
|
|
328
|
-
}), {
|
|
329
|
-
name: storageKey(agentId),
|
|
330
|
-
version: 1,
|
|
331
|
-
storage: safeStorage(),
|
|
332
|
-
migrate,
|
|
333
|
-
partialize: (state) => ({
|
|
334
|
-
version: 1,
|
|
335
|
-
sessionId: state.sessionId,
|
|
336
|
-
identity: state.identity,
|
|
337
|
-
consent: state.consent,
|
|
338
|
-
verifiedEmail: state.verifiedEmail,
|
|
339
|
-
verifiedEmailSessionId: state.verifiedEmailSessionId,
|
|
340
|
-
browserFingerprint: state.browserFingerprint
|
|
341
|
-
})
|
|
342
|
-
}));
|
|
343
|
-
}
|
|
344
|
-
//#endregion
|
|
345
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
|
|
346
|
-
function _typeof(o) {
|
|
347
|
-
"@babel/helpers - typeof";
|
|
348
|
-
return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
|
|
349
|
-
return typeof o;
|
|
350
|
-
} : function(o) {
|
|
351
|
-
return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
|
|
352
|
-
}, _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
|
+
};
|
|
353
177
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
}
|
|
363
188
|
}
|
|
364
|
-
return
|
|
365
|
-
}
|
|
366
|
-
//#endregion
|
|
367
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/toPropertyKey.js
|
|
368
|
-
function toPropertyKey(t) {
|
|
369
|
-
var i = toPrimitive(t, "string");
|
|
370
|
-
return "symbol" == _typeof(i) ? i : i + "";
|
|
371
|
-
}
|
|
372
|
-
//#endregion
|
|
373
|
-
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/defineProperty.js
|
|
374
|
-
function _defineProperty(e, r, t) {
|
|
375
|
-
return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
|
|
376
|
-
value: t,
|
|
377
|
-
enumerable: !0,
|
|
378
|
-
configurable: !0,
|
|
379
|
-
writable: !0
|
|
380
|
-
}) : e[r] = t, e;
|
|
189
|
+
return -1;
|
|
381
190
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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.
|
|
387
198
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
392
269
|
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* in-progress assistant message, then the terminal
|
|
398
|
-
* `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.
|
|
399
274
|
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
* cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
|
|
406
|
-
* verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
|
|
407
|
-
* 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
|
|
408
|
-
* `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
|
|
409
|
-
* 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 `…`.
|
|
410
280
|
*
|
|
411
|
-
* The
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
|
|
415
|
-
* the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
|
|
416
|
-
* (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
|
|
417
|
-
* the cross-device identity flow + fingerprint resume are HTTP POST routes on the
|
|
418
|
-
* 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.
|
|
419
283
|
*/
|
|
420
|
-
function
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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;
|
|
427
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;
|
|
428
302
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (!response || typeof response !== "object") return null;
|
|
432
|
-
const r = response;
|
|
433
|
-
if (Array.isArray(r.responseParts)) return r.responseParts.filter((p) => typeof p === "string").join("\n\n");
|
|
434
|
-
return null;
|
|
435
|
-
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/config.ts
|
|
436
305
|
/**
|
|
437
|
-
*
|
|
306
|
+
* Public configuration surface for the chat widget.
|
|
438
307
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* non-array shapes, and missing fields) so a server that doesn't emit them, or
|
|
443
|
-
* an older client, can't break rendering. Each citation always carries
|
|
444
|
-
* `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(...)`).
|
|
445
311
|
*/
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const title = typeof obj.title === "string" ? obj.title : id || "Source";
|
|
456
|
-
const snippet = typeof obj.snippet === "string" ? obj.snippet : "";
|
|
457
|
-
const url = typeof obj.url === "string" && obj.url ? obj.url : void 0;
|
|
458
|
-
const score = typeof obj.score === "number" ? obj.score : 0;
|
|
459
|
-
out.push({
|
|
460
|
-
id,
|
|
461
|
-
title,
|
|
462
|
-
snippet,
|
|
463
|
-
score,
|
|
464
|
-
url
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
return out;
|
|
468
|
-
}
|
|
469
|
-
/** Convert a server message row into a finalized {@link ChatMessage}. */
|
|
470
|
-
function wireMessageToChat(m, idx) {
|
|
471
|
-
const text = typeof m.content?.text === "string" ? m.content.text : "";
|
|
472
|
-
if (!text) return null;
|
|
473
|
-
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;
|
|
474
321
|
return {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
}
|
|
479
355
|
};
|
|
480
356
|
}
|
|
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
|
-
|
|
534
|
-
emailOptIn: consent.emailOptIn,
|
|
535
|
-
smsOptIn: consent.smsOptIn,
|
|
536
|
-
consentSource: "chat-widget-prechat",
|
|
537
|
-
consentAt
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
setInterrupt(interrupt) {
|
|
542
|
-
this.interrupt = interrupt;
|
|
543
|
-
this.events.onInterrupt?.(interrupt);
|
|
544
|
-
}
|
|
545
|
-
setIdentityRestore(state) {
|
|
546
|
-
this.identityRestore = state;
|
|
547
|
-
this.events.onIdentityRestore?.(state);
|
|
548
|
-
}
|
|
549
|
-
get currentIdentityRestore() {
|
|
550
|
-
return this.identityRestore;
|
|
551
|
-
}
|
|
552
|
-
/** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
|
|
553
|
-
verifyOtp(code) {
|
|
554
|
-
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
|
|
555
|
-
this.client.verifyOtp({
|
|
556
|
-
sessionId: this.sessionId,
|
|
557
|
-
requestId: this.activeRequestId,
|
|
558
|
-
code
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
/** Approve or reject a pending tool write to resume the paused turn. */
|
|
562
|
-
confirmTool(approved) {
|
|
563
|
-
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return;
|
|
564
|
-
this.client.confirmToolAction({
|
|
565
|
-
sessionId: this.sessionId,
|
|
566
|
-
requestId: this.activeRequestId,
|
|
567
|
-
approved
|
|
568
|
-
});
|
|
569
|
-
this.setInterrupt(null);
|
|
570
|
-
}
|
|
571
|
-
nextId(prefix) {
|
|
572
|
-
this.seq += 1;
|
|
573
|
-
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
574
|
-
}
|
|
575
|
-
setStatus(status, detail) {
|
|
576
|
-
this.status = status;
|
|
577
|
-
this.events.onStatus(status, detail);
|
|
578
|
-
}
|
|
579
|
-
emitMessages() {
|
|
580
|
-
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
581
|
-
}
|
|
582
|
-
/** Compute (once) + return the persisted browser fingerprint. */
|
|
583
|
-
fingerprint() {
|
|
584
|
-
const state = this.store.getState();
|
|
585
|
-
return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
|
|
586
|
-
}
|
|
587
|
-
/**
|
|
588
|
-
* Build the `metadata` payload threaded into `create_conversation_session`:
|
|
589
|
-
* phone (no first-class engine field) and consent.
|
|
590
|
-
*
|
|
591
|
-
* NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
|
|
592
|
-
* OTP proof bound to the session it was verified against
|
|
593
|
-
* (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
|
|
594
|
-
* as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
|
|
595
|
-
* every brand-new `create_conversation_session` would mislabel a fresh
|
|
596
|
-
* visitor's session with a prior (possibly different) visitor's email on a
|
|
597
|
-
* shared browser. The verified email is only used when RESUMING the exact
|
|
598
|
-
* session it was proven for — see {@link verifiedEmailForSession}.
|
|
599
|
-
*/
|
|
600
|
-
sessionMetadata() {
|
|
601
|
-
const state = this.store.getState();
|
|
602
|
-
const meta = {};
|
|
603
|
-
if (state.identity.phone) meta.userPhone = state.identity.phone;
|
|
604
|
-
const consent = state.consent;
|
|
605
|
-
if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
|
|
606
|
-
const c = {
|
|
607
|
-
emailOptIn: consent.emailOptIn,
|
|
608
|
-
smsOptIn: consent.smsOptIn,
|
|
609
|
-
consentSource: consent.consentSource ?? "chat-widget-prechat"
|
|
610
|
-
};
|
|
611
|
-
if (consent.consentAt) c.consentAt = consent.consentAt;
|
|
612
|
-
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 ?? ""}`);
|
|
613
410
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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);
|
|
632
430
|
}
|
|
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
|
-
|
|
668
|
-
|
|
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;
|
|
669
530
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
531
|
+
},
|
|
532
|
+
setItem: (name, value) => {
|
|
533
|
+
mem.set(name, JSON.stringify(value));
|
|
534
|
+
},
|
|
535
|
+
removeItem: (name) => {
|
|
536
|
+
mem.delete(name);
|
|
675
537
|
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const res = await fetch(`${this.httpBase}${path}`, {
|
|
693
|
-
method: "POST",
|
|
694
|
-
headers: { "content-type": "application/json" },
|
|
695
|
-
credentials: "omit",
|
|
696
|
-
body: JSON.stringify({
|
|
697
|
-
...this.authBody(),
|
|
698
|
-
...payload
|
|
699
|
-
})
|
|
700
|
-
});
|
|
701
|
-
let json = {};
|
|
702
|
-
try {
|
|
703
|
-
json = await res.json();
|
|
704
|
-
} catch {
|
|
705
|
-
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);
|
|
706
554
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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 {}
|
|
710
578
|
}
|
|
711
|
-
|
|
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.");
|
|
712
670
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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 {
|
|
723
733
|
return null;
|
|
724
734
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
|
736
772
|
});
|
|
737
|
-
this.sessionId = session.sessionId;
|
|
738
|
-
this.store.getState().setSessionId(session.sessionId);
|
|
739
|
-
}
|
|
740
|
-
/**
|
|
741
|
-
* Attempt to resume `sessionId`: returns true and hydrates the transcript when
|
|
742
|
-
* the session is live, false when it has ended / can't be fetched.
|
|
743
|
-
*/
|
|
744
|
-
async tryResume(sessionId) {
|
|
745
|
-
if (!this.client) return false;
|
|
746
|
-
let snap;
|
|
747
|
-
try {
|
|
748
|
-
snap = await this.client.getSession({ sessionId });
|
|
749
|
-
} catch {
|
|
750
|
-
return false;
|
|
751
|
-
}
|
|
752
|
-
if (snap.status === "ended") return false;
|
|
753
|
-
this.sessionId = sessionId;
|
|
754
|
-
await this.hydrateHistory(sessionId);
|
|
755
|
-
return true;
|
|
756
|
-
}
|
|
757
|
-
/** Page recent history (newest-first), reverse to chronological, and render. */
|
|
758
|
-
async hydrateHistory(sessionId) {
|
|
759
|
-
if (!this.client) return;
|
|
760
|
-
try {
|
|
761
|
-
const page = await this.client.getMessages({
|
|
762
|
-
sessionId,
|
|
763
|
-
limit: 50
|
|
764
|
-
});
|
|
765
|
-
const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
|
|
766
|
-
const hydrated = [];
|
|
767
|
-
chronological.forEach((m, i) => {
|
|
768
|
-
const chat = wireMessageToChat(m, i);
|
|
769
|
-
if (chat) hydrated.push(chat);
|
|
770
|
-
});
|
|
771
|
-
this.messages.length = 0;
|
|
772
|
-
this.messages.push(...hydrated);
|
|
773
|
-
this.emitMessages();
|
|
774
|
-
} catch {}
|
|
775
773
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
this
|
|
798
|
-
this
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if (finalText && finalText.length > assistant.text.length) assistant.text = finalText;
|
|
816
|
-
if (!assistant.text) assistant.text = "(no response)";
|
|
817
|
-
const citations = extractCitations(inner);
|
|
818
|
-
if (citations.length > 0) assistant.citations = citations;
|
|
819
|
-
assistant.streaming = false;
|
|
820
|
-
this.emitMessages();
|
|
821
|
-
} catch (err) {
|
|
822
|
-
assistant.streaming = false;
|
|
823
|
-
const message = err instanceof ProtocolError ? `Error: ${err.message}` : this.config.connectionErrorMessage ?? "We couldn't reach the chat.";
|
|
824
|
-
assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message;
|
|
825
|
-
this.emitMessages();
|
|
826
|
-
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
827
|
-
} finally {
|
|
828
|
-
this.activeRequestId = null;
|
|
829
|
-
this.setInterrupt(null);
|
|
830
|
-
}
|
|
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);
|
|
831
813
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const inner = event.data?.data ?? {};
|
|
835
|
-
const str = (v) => typeof v === "string" ? v : void 0;
|
|
836
|
-
const num = (v) => typeof v === "number" ? v : void 0;
|
|
837
|
-
switch (event.type) {
|
|
838
|
-
case "otp_verification_required": {
|
|
839
|
-
const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"];
|
|
840
|
-
this.setInterrupt({
|
|
841
|
-
kind: "otp",
|
|
842
|
-
toolId: str(inner.toolId),
|
|
843
|
-
actionDescription: str(inner.actionDescription),
|
|
844
|
-
availableChannels: channels.length > 0 ? channels : ["email"]
|
|
845
|
-
});
|
|
846
|
-
break;
|
|
847
|
-
}
|
|
848
|
-
case "otp_sent":
|
|
849
|
-
if (this.interrupt?.kind === "otp") this.setInterrupt({
|
|
850
|
-
...this.interrupt,
|
|
851
|
-
sent: {
|
|
852
|
-
channel: str(inner.channel),
|
|
853
|
-
maskedDestination: str(inner.maskedDestination)
|
|
854
|
-
},
|
|
855
|
-
error: void 0
|
|
856
|
-
});
|
|
857
|
-
break;
|
|
858
|
-
case "otp_verified":
|
|
859
|
-
if (this.interrupt?.kind === "otp") this.setInterrupt(null);
|
|
860
|
-
break;
|
|
861
|
-
case "otp_invalid":
|
|
862
|
-
if (this.interrupt?.kind === "otp") this.setInterrupt({
|
|
863
|
-
...this.interrupt,
|
|
864
|
-
error: str(inner.message) ?? "That code was incorrect.",
|
|
865
|
-
attemptsRemaining: num(inner.attemptsRemaining)
|
|
866
|
-
});
|
|
867
|
-
break;
|
|
868
|
-
case "write_confirmation_required":
|
|
869
|
-
this.setInterrupt({
|
|
870
|
-
kind: "confirm",
|
|
871
|
-
toolId: str(inner.toolId),
|
|
872
|
-
actionDescription: str(inner.actionDescription)
|
|
873
|
-
});
|
|
874
|
-
break;
|
|
875
|
-
default: break;
|
|
876
|
-
}
|
|
814
|
+
get connectionStatus() {
|
|
815
|
+
return this.status;
|
|
877
816
|
}
|
|
878
|
-
/**
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
*/
|
|
882
|
-
async requestIdentityOtp(email, channel = "email") {
|
|
883
|
-
const trimmed = email.trim();
|
|
884
|
-
if (!trimmed) return;
|
|
885
|
-
this.setIdentityRestore({
|
|
886
|
-
phase: "requesting",
|
|
887
|
-
email: trimmed,
|
|
888
|
-
channel
|
|
889
|
-
});
|
|
890
|
-
if (!this.sessionId) try {
|
|
891
|
-
await this.connect();
|
|
892
|
-
} catch {}
|
|
893
|
-
if (!this.sessionId) {
|
|
894
|
-
this.setIdentityRestore({
|
|
895
|
-
phase: "error",
|
|
896
|
-
message: "No active session to verify against."
|
|
897
|
-
});
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
try {
|
|
901
|
-
const json = await this.postInternal("/internal/identity/request-otp", {
|
|
902
|
-
sessionId: this.sessionId,
|
|
903
|
-
email: trimmed,
|
|
904
|
-
channel
|
|
905
|
-
});
|
|
906
|
-
const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
|
|
907
|
-
this.setIdentityRestore({
|
|
908
|
-
phase: "awaiting_code",
|
|
909
|
-
email: trimmed,
|
|
910
|
-
channel,
|
|
911
|
-
maskedDestination: masked
|
|
912
|
-
});
|
|
913
|
-
} catch (err) {
|
|
914
|
-
this.setIdentityRestore({
|
|
915
|
-
phase: "error",
|
|
916
|
-
message: err instanceof Error ? err.message : "Could not send a verification code."
|
|
917
|
-
});
|
|
918
|
-
}
|
|
817
|
+
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
818
|
+
getStore() {
|
|
819
|
+
return this.store;
|
|
919
820
|
}
|
|
920
|
-
/**
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
phase: "verifying",
|
|
821
|
+
/** True when a persisted session pointer exists (drives the resume path). */
|
|
822
|
+
hasPersistedSession() {
|
|
823
|
+
return !!this.store.getState().sessionId;
|
|
824
|
+
}
|
|
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);
|
|
829
|
+
}
|
|
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,
|
|
935
835
|
email,
|
|
936
|
-
|
|
836
|
+
phone
|
|
937
837
|
});
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
this.store.getState().setVerifiedEmail(email, this.sessionId);
|
|
946
|
-
await this.resolveIdentity(email);
|
|
947
|
-
} else if (json.event === "otp_invalid") {
|
|
948
|
-
const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
|
|
949
|
-
this.setIdentityRestore({
|
|
950
|
-
phase: "awaiting_code",
|
|
951
|
-
email,
|
|
952
|
-
channel,
|
|
953
|
-
error: "That code was incorrect.",
|
|
954
|
-
attemptsRemaining: remaining
|
|
955
|
-
});
|
|
956
|
-
} else this.setIdentityRestore({
|
|
957
|
-
phase: "error",
|
|
958
|
-
message: "Verification failed."
|
|
959
|
-
});
|
|
960
|
-
} catch (err) {
|
|
961
|
-
this.setIdentityRestore({
|
|
962
|
-
phase: "error",
|
|
963
|
-
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
|
|
964
845
|
});
|
|
965
846
|
}
|
|
966
847
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
|
973
866
|
});
|
|
974
|
-
try {
|
|
975
|
-
const json = await this.postInternal("/internal/identity/resolve", {
|
|
976
|
-
sessionId: this.sessionId,
|
|
977
|
-
email
|
|
978
|
-
});
|
|
979
|
-
if (json.resolved !== true) {
|
|
980
|
-
this.setIdentityRestore({
|
|
981
|
-
phase: "resolved",
|
|
982
|
-
email,
|
|
983
|
-
conversations: []
|
|
984
|
-
});
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
const raw = json.conversations;
|
|
988
|
-
const conversations = Array.isArray(raw) ? raw.map((c) => {
|
|
989
|
-
if (!c || typeof c !== "object") return null;
|
|
990
|
-
const o = c;
|
|
991
|
-
const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
|
|
992
|
-
const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
|
|
993
|
-
if (!sessionId) return null;
|
|
994
|
-
return {
|
|
995
|
-
conversationId,
|
|
996
|
-
sessionId,
|
|
997
|
-
lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
|
|
998
|
-
preview: typeof o.preview === "string" ? o.preview : void 0
|
|
999
|
-
};
|
|
1000
|
-
}).filter((c) => c !== null) : [];
|
|
1001
|
-
this.setIdentityRestore({
|
|
1002
|
-
phase: "resolved",
|
|
1003
|
-
email,
|
|
1004
|
-
conversations
|
|
1005
|
-
});
|
|
1006
|
-
} catch (err) {
|
|
1007
|
-
this.setIdentityRestore({
|
|
1008
|
-
phase: "error",
|
|
1009
|
-
message: err instanceof Error ? err.message : "Could not load your chats."
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
867
|
}
|
|
1013
|
-
/**
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
|
|
1021
|
-
if (await this.tryResume(sessionId)) {
|
|
1022
|
-
this.store.getState().setSessionId(sessionId);
|
|
1023
|
-
if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
|
|
1024
|
-
this.setIdentityRestore({ phase: "idle" });
|
|
1025
|
-
this.setStatus("ready");
|
|
1026
|
-
} else this.setIdentityRestore({
|
|
1027
|
-
phase: "error",
|
|
1028
|
-
message: "That conversation is no longer available."
|
|
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
|
|
1029
875
|
});
|
|
876
|
+
this.setInterrupt(null);
|
|
1030
877
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
this.
|
|
878
|
+
nextId(prefix) {
|
|
879
|
+
this.seq += 1;
|
|
880
|
+
return `${prefix}-${this.seq}-${Date.now().toString(36)}`;
|
|
1034
881
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
this.
|
|
1038
|
-
this.client = null;
|
|
1039
|
-
this.sessionId = null;
|
|
1040
|
-
this.activeRequestId = null;
|
|
1041
|
-
this.resumeAttempted = false;
|
|
1042
|
-
this.setInterrupt(null);
|
|
1043
|
-
this.setStatus("closed");
|
|
882
|
+
setStatus(status, detail) {
|
|
883
|
+
this.status = status;
|
|
884
|
+
this.events.onStatus(status, detail);
|
|
1044
885
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
/**
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
* The
|
|
1064
|
-
*
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
* tag out of the input — a literal `<script>` in the input is treated as
|
|
1080
|
-
* plain text.
|
|
1081
|
-
* 2. **Every** text run is HTML-escaped via {@link escapeHtml} before it
|
|
1082
|
-
* reaches the output. Raw `<`, `>`, `&`, `"`, `'` can never become markup.
|
|
1083
|
-
* 3. **Images are dropped entirely** — `` renders as its alt text,
|
|
1084
|
-
* no `<img>` is ever produced (a scraped tracking pixel must not load).
|
|
1085
|
-
* 4. **Links** are gated through {@link safeHttpUrl}: only absolute `http(s)`
|
|
1086
|
-
* URLs become anchors (with `target="_blank"` + a hardened `rel`);
|
|
1087
|
-
* `javascript:`/`data:`/relative/etc. fall back to plain (escaped) text.
|
|
1088
|
-
* 5. **Headings** (`#`..`######`) are *downgraded* to bold lines — a full
|
|
1089
|
-
* `<h1>` is far too large inside a chat bubble or citation card.
|
|
1090
|
-
*
|
|
1091
|
-
* The output is a string of HTML that is only ever assigned to `innerHTML` of
|
|
1092
|
-
* an element the caller controls; because of (1)–(4) it can only contain the
|
|
1093
|
-
* allowlisted, attribute-sanitized tags.
|
|
1094
|
-
*
|
|
1095
|
-
* Supported subset (deliberately small):
|
|
1096
|
-
* - Paragraphs (blank-line separated) and hard/soft line breaks
|
|
1097
|
-
* - `**bold**` / `__bold__`, `*italic*` / `_italic_`
|
|
1098
|
-
* - `` `inline code` `` and fenced ``` ```code blocks``` ```
|
|
1099
|
-
* - `- ` / `* ` / `+ ` unordered lists, `1.` ordered lists
|
|
1100
|
-
* - `> ` blockquotes
|
|
1101
|
-
* - `[text](http(s)://url)` links (images dropped to alt text)
|
|
1102
|
-
* - `#`..`######` headings → bold line
|
|
1103
|
-
*/
|
|
1104
|
-
/** Escape the five HTML-significant characters so a text run can never be markup. */
|
|
1105
|
-
function escapeHtml(value) {
|
|
1106
|
-
return value.replace(/[&<>"']/g, (c) => {
|
|
1107
|
-
switch (c) {
|
|
1108
|
-
case "&": return "&";
|
|
1109
|
-
case "<": return "<";
|
|
1110
|
-
case ">": return ">";
|
|
1111
|
-
case "\"": return """;
|
|
1112
|
-
default: return "'";
|
|
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;
|
|
1113
920
|
}
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1116
|
-
/**
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
function safeHttpUrl(url) {
|
|
1126
|
-
if (!url) return null;
|
|
1127
|
-
try {
|
|
1128
|
-
const parsed = new URL(url);
|
|
1129
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
|
|
1130
|
-
} catch {
|
|
921
|
+
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
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.
|
|
928
|
+
*/
|
|
929
|
+
verifiedEmailForSession(sessionId) {
|
|
930
|
+
const state = this.store.getState();
|
|
931
|
+
if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
|
|
1131
932
|
return null;
|
|
1132
933
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
if (
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
+
}
|
|
1170
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;
|
|
1171
982
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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 = {};
|
|
1183
1013
|
}
|
|
1184
|
-
if (
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
if (end > i + 1) {
|
|
1188
|
-
flush();
|
|
1189
|
-
out += `<strong>${renderInline(input.slice(i + 2, end))}</strong>`;
|
|
1190
|
-
i = end + 2;
|
|
1191
|
-
continue;
|
|
1192
|
-
}
|
|
1014
|
+
if (!res.ok) {
|
|
1015
|
+
const err = json.error;
|
|
1016
|
+
throw new Error(err?.message ?? `${path} failed (${res.status})`);
|
|
1193
1017
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1018
|
+
return json;
|
|
1019
|
+
}
|
|
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;
|
|
1202
1058
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1059
|
+
if (snap.status === "ended") return false;
|
|
1060
|
+
this.sessionId = sessionId;
|
|
1061
|
+
await this.hydrateHistory(sessionId);
|
|
1062
|
+
return true;
|
|
1205
1063
|
}
|
|
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
|
-
|
|
1238
|
-
|
|
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);
|
|
1239
1137
|
}
|
|
1240
1138
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const out = [];
|
|
1257
|
-
let i = 0;
|
|
1258
|
-
while (i < lines.length) {
|
|
1259
|
-
const line = lines[i];
|
|
1260
|
-
const fence = FENCE_RE.exec(line);
|
|
1261
|
-
if (fence) {
|
|
1262
|
-
const marker = fence[1];
|
|
1263
|
-
const body = [];
|
|
1264
|
-
i++;
|
|
1265
|
-
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
|
1266
|
-
body.push(lines[i]);
|
|
1267
|
-
i++;
|
|
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;
|
|
1268
1154
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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;
|
|
1272
1183
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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;
|
|
1276
1206
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
+
});
|
|
1282
1225
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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;
|
|
1295
1239
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
+
});
|
|
1306
1272
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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;
|
|
1293
|
+
}
|
|
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
|
+
});
|
|
1313
1318
|
}
|
|
1314
|
-
out.push(`<p>${renderInline(para.join("\n")).replace(/\n/g, "<br>")}</p>`);
|
|
1315
1319
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
* The result is still rendered through {@link renderMarkdown} downstream, so any
|
|
1334
|
-
* remaining inline markup (bold/links) stays safe.
|
|
1335
|
-
*/
|
|
1336
|
-
function cleanCitationSnippet(raw) {
|
|
1337
|
-
let s = raw ?? "";
|
|
1338
|
-
let changed = true;
|
|
1339
|
-
while (changed) {
|
|
1340
|
-
changed = false;
|
|
1341
|
-
const before = s;
|
|
1342
|
-
s = s.replace(/^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*/, "");
|
|
1343
|
-
s = s.replace(/^\s*!\[[^\]]*\]\([^)]*\)\s*/, "");
|
|
1344
|
-
s = s.replace(/^\s*#{1,6}\s+/, "");
|
|
1345
|
-
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
|
+
});
|
|
1346
1337
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
const lastSpace = cut.lastIndexOf(" ");
|
|
1351
|
-
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" });
|
|
1352
1341
|
}
|
|
1353
|
-
|
|
1354
|
-
|
|
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>";
|
|
1355
1357
|
//#endregion
|
|
1356
1358
|
//#region src/styles.ts
|
|
1357
1359
|
/**
|
|
@@ -1396,11 +1398,11 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1396
1398
|
--sac-ease: cubic-bezier(.16, 1, .3, 1);
|
|
1397
1399
|
|
|
1398
1400
|
${mode === "fullpage" ? `/* Full-page: fill the host's box (sized by its container, else the viewport). */
|
|
1399
|
-
display:
|
|
1401
|
+
display: flex;
|
|
1402
|
+
flex-direction: column;
|
|
1400
1403
|
position: relative;
|
|
1401
1404
|
width: 100%;
|
|
1402
|
-
height: 100
|
|
1403
|
-
min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */
|
|
1405
|
+
height: 100%;` : `/* Popover: float in the bottom-right corner. */
|
|
1404
1406
|
position: fixed;
|
|
1405
1407
|
bottom: 24px;
|
|
1406
1408
|
right: 24px;
|
|
@@ -1408,7 +1410,20 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1408
1410
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
1409
1411
|
-webkit-font-smoothing: antialiased;
|
|
1410
1412
|
}
|
|
1411
|
-
|
|
1413
|
+
${mode === "fullpage" ? `
|
|
1414
|
+
/* Viewport fallback — the element sets this attribute only when the host's
|
|
1415
|
+
container gives it no resolved height (e.g. mounted straight into an
|
|
1416
|
+
auto-height <body>). A sized container always wins, so an embed inside a
|
|
1417
|
+
fixed-height box never overflows it (composer stays visible). */
|
|
1418
|
+
:host([data-viewport-fallback]) { min-height: 100dvh; }
|
|
1419
|
+
/* The render wrapper passes the host's box down to the panel via flex. */
|
|
1420
|
+
.wrap {
|
|
1421
|
+
flex: 1;
|
|
1422
|
+
display: flex;
|
|
1423
|
+
flex-direction: column;
|
|
1424
|
+
min-height: 0;
|
|
1425
|
+
}
|
|
1426
|
+
` : ""}
|
|
1412
1427
|
* { box-sizing: border-box; }
|
|
1413
1428
|
|
|
1414
1429
|
/* ───────────────────────────── Launcher ───────────────────────────── */
|
|
@@ -1490,11 +1505,13 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1490
1505
|
pointer-events: none;
|
|
1491
1506
|
background: radial-gradient(120% 100% at 50% 0%, color-mix(in srgb, var(--sac-primary) 22%, transparent), transparent 70%);
|
|
1492
1507
|
}
|
|
1493
|
-
/* Full-page: the panel becomes the whole surface
|
|
1508
|
+
/* Full-page: the panel becomes the whole surface — it follows the host's box
|
|
1509
|
+
(via the .wrap flex chain), never a hardcoded viewport unit. */
|
|
1494
1510
|
.panel.fullpage {
|
|
1495
1511
|
width: 100%;
|
|
1496
|
-
|
|
1497
|
-
|
|
1512
|
+
flex: 1;
|
|
1513
|
+
height: auto;
|
|
1514
|
+
min-height: 0;
|
|
1498
1515
|
max-width: none;
|
|
1499
1516
|
max-height: none;
|
|
1500
1517
|
border: none;
|
|
@@ -1526,8 +1543,9 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1526
1543
|
0 1px 0 rgba(255, 255, 255, .25) inset;
|
|
1527
1544
|
}
|
|
1528
1545
|
.avatar svg { width: 22px; height: 22px; }
|
|
1529
|
-
.avatar .logo-wrap { display: flex; }
|
|
1546
|
+
.avatar .logo-wrap { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
|
1530
1547
|
.avatar .logo { height: 22px; width: auto; display: block; }
|
|
1548
|
+
.avatar .logo-img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; display: block; border-radius: 9px; }
|
|
1531
1549
|
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
|
1532
1550
|
.title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; }
|
|
1533
1551
|
.status {
|
|
@@ -1576,6 +1594,7 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1576
1594
|
.panel.fullpage .header { padding: 18px 22px; }
|
|
1577
1595
|
.panel.fullpage .avatar { width: 44px; height: 44px; }
|
|
1578
1596
|
.panel.fullpage .avatar .logo { height: 26px; }
|
|
1597
|
+
.panel.fullpage .avatar svg { width: 28px; height: 28px; }
|
|
1579
1598
|
|
|
1580
1599
|
/* ────────────────────────────── Messages ──────────────────────────── */
|
|
1581
1600
|
.messages {
|
|
@@ -2097,6 +2116,7 @@ const OBSERVED = [
|
|
|
2097
2116
|
"endpoint",
|
|
2098
2117
|
"agent-id",
|
|
2099
2118
|
"agent-name",
|
|
2119
|
+
"logo-url",
|
|
2100
2120
|
"placeholder",
|
|
2101
2121
|
"greeting",
|
|
2102
2122
|
"start-open",
|
|
@@ -2208,6 +2228,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2208
2228
|
mode: this.overrides.mode ?? (modeAttr === "fullpage" ? "fullpage" : modeAttr === "popover" ? "popover" : void 0) ?? "popover",
|
|
2209
2229
|
agentId,
|
|
2210
2230
|
agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0,
|
|
2231
|
+
logoUrl: this.overrides.logoUrl ?? this.getAttribute("logo-url") ?? void 0,
|
|
2211
2232
|
userName: this.overrides.userName,
|
|
2212
2233
|
userEmail: this.overrides.userEmail,
|
|
2213
2234
|
userPhone: this.overrides.userPhone,
|
|
@@ -2262,8 +2283,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2262
2283
|
const style = document.createElement("style");
|
|
2263
2284
|
style.textContent = buildStyles(resolved.theme, resolved.mode);
|
|
2264
2285
|
const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase());
|
|
2286
|
+
const headerLogo = resolved.logoUrl ? `<img src="${escapeHtml(resolved.logoUrl)}" alt="" class="logo-img" />` : SMOOTH_ICON_SVG;
|
|
2265
2287
|
const header = fullpage ? `<div class="header">
|
|
2266
|
-
<div class="avatar"><span class="logo-wrap">${
|
|
2288
|
+
<div class="avatar"><span class="logo-wrap">${headerLogo}</span></div>
|
|
2267
2289
|
<div class="meta">
|
|
2268
2290
|
<span class="title">${escapeHtml(resolved.agentName)}</span>
|
|
2269
2291
|
<span class="status"><span class="dot off"></span><span class="status-text"></span></span>
|
|
@@ -2314,6 +2336,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2314
2336
|
<div class="footer">powered by <b>smooth‑operator</b>${restoreLink}</div>
|
|
2315
2337
|
</div>`;
|
|
2316
2338
|
const container = document.createElement("div");
|
|
2339
|
+
container.className = "wrap";
|
|
2317
2340
|
container.innerHTML = `
|
|
2318
2341
|
${fullpage ? "" : `<button class="launcher" part="launcher" aria-label="Open chat">${ICON.spark}</button>`}
|
|
2319
2342
|
<div class="panel${fullpage ? " fullpage" : " hidden"}" part="panel" role="${fullpage ? "region" : "dialog"}" aria-label="${escapeHtml(resolved.agentName)} chat">
|
|
@@ -2357,6 +2380,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2357
2380
|
await this.controller?.connect().catch(() => {});
|
|
2358
2381
|
})();
|
|
2359
2382
|
});
|
|
2383
|
+
if (fullpage) this.syncViewportFallback();
|
|
2360
2384
|
if (fullpage && !gating) this.controller?.connect().catch(() => {});
|
|
2361
2385
|
this.syncOpenState();
|
|
2362
2386
|
if (!gating) this.renderMessages();
|
|
@@ -2846,6 +2870,24 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
2846
2870
|
this.streamBubbleEl.textContent = this.streamTarget;
|
|
2847
2871
|
}
|
|
2848
2872
|
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Full-page sizing probe: decide whether the host's container gives it a
|
|
2875
|
+
* real box. With the `.wrap` flex chain hidden, `height: 100%` of a SIZED
|
|
2876
|
+
* container still resolves (clientHeight > 0), while an auto-height parent
|
|
2877
|
+
* (e.g. mounted straight into `<body>`) collapses to ~0. Only the latter
|
|
2878
|
+
* gets `data-viewport-fallback`, whose CSS applies `min-height: 100dvh` —
|
|
2879
|
+
* so an embed inside a fixed-height box never overflows it (the composer
|
|
2880
|
+
* stays visible), and a bare full-page route still fills the viewport.
|
|
2881
|
+
*/
|
|
2882
|
+
syncViewportFallback() {
|
|
2883
|
+
const wrap = this.shadowRoot?.querySelector(".wrap");
|
|
2884
|
+
if (!wrap) return;
|
|
2885
|
+
const prev = wrap.style.display;
|
|
2886
|
+
wrap.style.display = "none";
|
|
2887
|
+
const heightless = this.clientHeight < 8;
|
|
2888
|
+
wrap.style.display = prev;
|
|
2889
|
+
this.toggleAttribute("data-viewport-fallback", heightless);
|
|
2890
|
+
}
|
|
2849
2891
|
/** Cancel any in-flight reveal loop and clear its state (called on full rebuild). */
|
|
2850
2892
|
resetReveal() {
|
|
2851
2893
|
if (this.rafId && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.rafId);
|