@memberjunction/realtime-widget 6.2.0-edge.5 → 6.2.0-edge.6
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 +11 -2
- package/dist/embed/chunks/UserViewEngine-IVGLM2DH.js +7 -0
- package/dist/embed/chunks/chunk-MRWWS7FK.js +162091 -0
- package/dist/embed/chunks/chunk-N3BM2W5J.js +36503 -0
- package/dist/embed/chunks/chunk-RAIOBPHZ.js +6127 -0
- package/dist/embed/chunks/chunk-S3LX5KYE.js +46 -0
- package/dist/embed/chunks/chunk-VZ6KR7K2.js +2492 -0
- package/dist/embed/chunks/chunk-ZX4SRAYD.js +17043 -0
- package/dist/embed/chunks/dist-P2JMWJFV.js +66 -0
- package/dist/embed/chunks/guest-tool-relay-GAWXNIUD.js +30 -0
- package/dist/embed/chunks/guest-voice-mint-KJMP4VTK.js +94 -0
- package/dist/embed/chunks/realtime-voice-controller-PCOD3UBR.js +30558 -0
- package/dist/embed/chunks/runtime-widget-transport-AFGCQC7N.js +161 -0
- package/dist/embed/mj-widget.js +1056 -0
- package/dist/embed.js +3 -2
- package/dist/embed.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +9 -0
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +56 -7
- package/dist/loader.js.map +1 -1
- package/dist/session/widget-session-client.d.ts +46 -5
- package/dist/session/widget-session-client.d.ts.map +1 -1
- package/dist/session/widget-session-client.js +118 -17
- package/dist/session/widget-session-client.js.map +1 -1
- package/dist/transport/runtime-widget-transport.d.ts +0 -6
- package/dist/transport/runtime-widget-transport.d.ts.map +1 -1
- package/dist/transport/runtime-widget-transport.js +2 -31
- package/dist/transport/runtime-widget-transport.js.map +1 -1
- package/package.json +10 -10
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_VOICE_LIMITS
|
|
3
|
+
} from "./chunks/chunk-S3LX5KYE.js";
|
|
4
|
+
import {
|
|
5
|
+
CreateProofOfPossessionJwt,
|
|
6
|
+
CreateProofOfPossessionKey
|
|
7
|
+
} from "./chunks/chunk-ZX4SRAYD.js";
|
|
8
|
+
|
|
9
|
+
// dist/ui/tokens.js
|
|
10
|
+
var WIDGET_SHADOW_STYLES = `
|
|
11
|
+
:host {
|
|
12
|
+
/* base semantic tokens (shadow root needs its own \u2014 it can't see the app :root) */
|
|
13
|
+
--mj-text-primary: #1f2933;
|
|
14
|
+
--mj-text-secondary: #52606d;
|
|
15
|
+
--mj-text-inverse: #ffffff;
|
|
16
|
+
--mj-bg-surface: #ffffff;
|
|
17
|
+
--mj-bg-surface-card: #f5f7fa;
|
|
18
|
+
--mj-bg-surface-sunken: #eef1f5;
|
|
19
|
+
--mj-border-default: #e2e8f0;
|
|
20
|
+
--mj-brand-primary: #264faf;
|
|
21
|
+
--mj-brand-primary-hover: #1d3f8c;
|
|
22
|
+
--mj-status-error: #dc2626;
|
|
23
|
+
--mj-status-error-text: #b91c1c;
|
|
24
|
+
--mj-status-error-bg: #fef2f2;
|
|
25
|
+
|
|
26
|
+
/* chat tokens (mirror the Angular bootstrap names) */
|
|
27
|
+
--mj-chat-bubble-user-bg: var(--mj-brand-primary);
|
|
28
|
+
--mj-chat-bubble-user-text: var(--mj-text-inverse);
|
|
29
|
+
--mj-chat-bubble-agent-bg: var(--mj-bg-surface-card);
|
|
30
|
+
--mj-chat-bubble-agent-text: var(--mj-text-primary);
|
|
31
|
+
--mj-chat-composer-bg: var(--mj-bg-surface);
|
|
32
|
+
--mj-chat-composer-border: var(--mj-border-default);
|
|
33
|
+
|
|
34
|
+
all: initial;
|
|
35
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
36
|
+
color: var(--mj-text-primary);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
* { box-sizing: border-box; }
|
|
40
|
+
|
|
41
|
+
.mj-widget-launcher {
|
|
42
|
+
position: fixed; right: 20px; bottom: 20px;
|
|
43
|
+
width: 56px; height: 56px; border-radius: 50%;
|
|
44
|
+
background: var(--mj-brand-primary); color: var(--mj-text-inverse);
|
|
45
|
+
border: none; cursor: pointer; font-size: 24px;
|
|
46
|
+
box-shadow: 0 4px 12px color-mix(in srgb, var(--mj-text-primary) 25%, transparent);
|
|
47
|
+
}
|
|
48
|
+
.mj-widget-launcher:hover { background: var(--mj-brand-primary-hover); }
|
|
49
|
+
|
|
50
|
+
.mj-widget-panel {
|
|
51
|
+
position: fixed; right: 20px; bottom: 88px;
|
|
52
|
+
width: 360px; max-width: calc(100vw - 40px); height: 520px; max-height: calc(100vh - 120px);
|
|
53
|
+
display: flex; flex-direction: column;
|
|
54
|
+
background: var(--mj-bg-surface); border: 1px solid var(--mj-border-default);
|
|
55
|
+
border-radius: 12px; overflow: hidden;
|
|
56
|
+
box-shadow: 0 8px 28px color-mix(in srgb, var(--mj-text-primary) 22%, transparent);
|
|
57
|
+
}
|
|
58
|
+
.mj-widget-panel[hidden] { display: none; }
|
|
59
|
+
|
|
60
|
+
.mj-widget-header {
|
|
61
|
+
padding: 12px 16px; background: var(--mj-brand-primary); color: var(--mj-text-inverse);
|
|
62
|
+
display: flex; align-items: center; justify-content: space-between; font-weight: 600;
|
|
63
|
+
}
|
|
64
|
+
.mj-widget-close { background: none; border: none; color: var(--mj-text-inverse); cursor: pointer; font-size: 18px; }
|
|
65
|
+
|
|
66
|
+
.mj-widget-transcript {
|
|
67
|
+
flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;
|
|
68
|
+
background: var(--mj-bg-surface-sunken);
|
|
69
|
+
}
|
|
70
|
+
.mj-widget-empty { color: var(--mj-text-secondary); text-align: center; margin: auto; padding: 16px; }
|
|
71
|
+
|
|
72
|
+
.mj-widget-msg { max-width: 80%; padding: 8px 12px; border-radius: 12px; white-space: pre-wrap; word-break: break-word; }
|
|
73
|
+
.mj-widget-msg.user { align-self: flex-end; background: var(--mj-chat-bubble-user-bg); color: var(--mj-chat-bubble-user-text); }
|
|
74
|
+
.mj-widget-msg.agent { align-self: flex-start; background: var(--mj-chat-bubble-agent-bg); color: var(--mj-chat-bubble-agent-text); }
|
|
75
|
+
.mj-widget-msg.system { align-self: center; color: var(--mj-status-error-text); font-size: 0.85em; }
|
|
76
|
+
|
|
77
|
+
.mj-widget-progress { align-self: flex-start; color: var(--mj-text-secondary); font-size: 0.85em; font-style: italic; }
|
|
78
|
+
.mj-widget-progress[hidden] { display: none; }
|
|
79
|
+
|
|
80
|
+
/* Interactive-channel demonstration surface (Phase 2 \u2014 e.g. the Whiteboard the agent draws on during voice). */
|
|
81
|
+
.mj-widget-surface { display: flex; flex-direction: column; border-bottom: 1px solid var(--mj-border-default); background: var(--mj-bg-surface); }
|
|
82
|
+
.mj-widget-surface[hidden] { display: none; }
|
|
83
|
+
.mj-widget-surface-title { padding: 6px 12px; font-size: 0.8em; font-weight: 600; color: var(--mj-text-secondary); background: var(--mj-bg-surface-card); }
|
|
84
|
+
.mj-widget-surface-host { height: 200px; width: 100%; background: var(--mj-bg-surface); overflow: hidden; }
|
|
85
|
+
.mj-widget-surface-host svg { display: block; width: 100%; height: 100%; }
|
|
86
|
+
|
|
87
|
+
.mj-widget-composer {
|
|
88
|
+
display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--mj-chat-composer-border);
|
|
89
|
+
background: var(--mj-chat-composer-bg);
|
|
90
|
+
}
|
|
91
|
+
.mj-widget-input {
|
|
92
|
+
flex: 1; resize: none; padding: 8px 10px; border: 1px solid var(--mj-border-default);
|
|
93
|
+
border-radius: 8px; font: inherit; color: var(--mj-text-primary); background: var(--mj-bg-surface);
|
|
94
|
+
}
|
|
95
|
+
.mj-widget-send {
|
|
96
|
+
background: var(--mj-brand-primary); color: var(--mj-text-inverse); border: none;
|
|
97
|
+
border-radius: 8px; padding: 0 14px; cursor: pointer; font: inherit;
|
|
98
|
+
}
|
|
99
|
+
.mj-widget-send:disabled { opacity: 0.5; cursor: default; }
|
|
100
|
+
|
|
101
|
+
/* Connection-lost banner (graceful degradation, W6) */
|
|
102
|
+
.mj-widget-banner {
|
|
103
|
+
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
|
104
|
+
background: var(--mj-status-error-bg); color: var(--mj-status-error-text);
|
|
105
|
+
border-bottom: 1px solid color-mix(in srgb, var(--mj-status-error) 35%, transparent);
|
|
106
|
+
font-size: 0.85em;
|
|
107
|
+
}
|
|
108
|
+
.mj-widget-banner[hidden] { display: none; }
|
|
109
|
+
.mj-widget-banner-text { flex: 1; }
|
|
110
|
+
.mj-widget-banner-retry {
|
|
111
|
+
background: var(--mj-status-error); color: var(--mj-text-inverse); border: none;
|
|
112
|
+
border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; font-size: 0.95em;
|
|
113
|
+
}
|
|
114
|
+
.mj-widget-banner-retry:hover { background: var(--mj-status-error-text); }
|
|
115
|
+
.mj-widget-memory-notice {
|
|
116
|
+
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
|
117
|
+
background: var(--mj-bg-surface-sunken); color: var(--mj-text-secondary);
|
|
118
|
+
border-top: 1px solid var(--mj-border-default);
|
|
119
|
+
font-size: 0.75em;
|
|
120
|
+
}
|
|
121
|
+
.mj-widget-memory-notice-text { flex: 1; }
|
|
122
|
+
.mj-widget-forget {
|
|
123
|
+
background: none; color: var(--mj-text-secondary); border: none;
|
|
124
|
+
padding: 2px 4px; cursor: pointer; font: inherit; font-size: 1em;
|
|
125
|
+
text-decoration: underline; white-space: nowrap;
|
|
126
|
+
}
|
|
127
|
+
.mj-widget-forget:hover { color: var(--mj-text-primary); }
|
|
128
|
+
.mj-widget-forget:disabled { opacity: 0.6; cursor: default; }
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
// dist/ui/browser-capabilities.js
|
|
132
|
+
function VoiceIsSupported(nav = typeof navigator !== "undefined" ? navigator : void 0, win = typeof window !== "undefined" ? window : void 0) {
|
|
133
|
+
if (!nav || !win)
|
|
134
|
+
return false;
|
|
135
|
+
if (win.isSecureContext !== true)
|
|
136
|
+
return false;
|
|
137
|
+
return typeof nav.mediaDevices?.getUserMedia === "function";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// dist/ui/support-widget-element.js
|
|
141
|
+
var WIDGET_TAG_NAME = "mj-support-widget";
|
|
142
|
+
var SupportWidgetElement = class extends HTMLElement {
|
|
143
|
+
constructor() {
|
|
144
|
+
super();
|
|
145
|
+
this.transport = null;
|
|
146
|
+
this.voiceController = null;
|
|
147
|
+
this.session = null;
|
|
148
|
+
this.messages = [];
|
|
149
|
+
this.sending = false;
|
|
150
|
+
this.voiceActive = false;
|
|
151
|
+
this.headerTitle = "Support";
|
|
152
|
+
this.greeting = "Hi! How can we help you today?";
|
|
153
|
+
this.pendingRetry = null;
|
|
154
|
+
this.forgetHandler = null;
|
|
155
|
+
this.forgetting = false;
|
|
156
|
+
this.voiceSupportedProbe = () => VoiceIsSupported();
|
|
157
|
+
this.trapHandler = (e) => this.onPanelKeydown(e);
|
|
158
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
159
|
+
}
|
|
160
|
+
connectedCallback() {
|
|
161
|
+
this.render();
|
|
162
|
+
}
|
|
163
|
+
/** Sets the UI title + greeting (called by the loader before mount). */
|
|
164
|
+
Configure(options) {
|
|
165
|
+
if (options.title)
|
|
166
|
+
this.headerTitle = options.title;
|
|
167
|
+
if (options.greeting)
|
|
168
|
+
this.greeting = options.greeting;
|
|
169
|
+
if (this.isConnected)
|
|
170
|
+
this.render();
|
|
171
|
+
}
|
|
172
|
+
/** Injects the minted session (drives modality/agent pinning). */
|
|
173
|
+
SetSession(session) {
|
|
174
|
+
this.session = session;
|
|
175
|
+
}
|
|
176
|
+
/** Injects the transport the element sends turns through. */
|
|
177
|
+
SetTransport(transport) {
|
|
178
|
+
this.transport = transport;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Wires the RV5 "forget me" action. The loader supplies a handler that archives the visitor's
|
|
182
|
+
* server-side memory and clears the durable cookie. When set (and the widget remembers returning
|
|
183
|
+
* visitors), a privacy notice + "Forget me" control render below the composer.
|
|
184
|
+
*/
|
|
185
|
+
SetForgetHandler(handler) {
|
|
186
|
+
this.forgetHandler = handler;
|
|
187
|
+
if (this.isConnected)
|
|
188
|
+
this.render();
|
|
189
|
+
}
|
|
190
|
+
/** Injects the voice controller (enables the mic button when modality allows voice). */
|
|
191
|
+
SetVoiceController(controller) {
|
|
192
|
+
this.voiceController = controller;
|
|
193
|
+
if (this.isConnected)
|
|
194
|
+
this.render();
|
|
195
|
+
}
|
|
196
|
+
/** Overrides the voice capability probe (used by tests to simulate unsupported browsers). */
|
|
197
|
+
SetVoiceSupportedProbe(probe) {
|
|
198
|
+
this.voiceSupportedProbe = probe;
|
|
199
|
+
if (this.isConnected)
|
|
200
|
+
this.render();
|
|
201
|
+
}
|
|
202
|
+
/** Opens the chat panel, traps focus inside it, and focuses the composer. */
|
|
203
|
+
Open() {
|
|
204
|
+
const panel = this.panel();
|
|
205
|
+
if (!panel)
|
|
206
|
+
return;
|
|
207
|
+
panel.removeAttribute("hidden");
|
|
208
|
+
panel.addEventListener("keydown", this.trapHandler);
|
|
209
|
+
this.input()?.focus();
|
|
210
|
+
}
|
|
211
|
+
/** Closes the chat panel, releases the focus trap, and returns focus to the launcher. */
|
|
212
|
+
Close() {
|
|
213
|
+
const panel = this.panel();
|
|
214
|
+
panel?.setAttribute("hidden", "");
|
|
215
|
+
panel?.removeEventListener("keydown", this.trapHandler);
|
|
216
|
+
this.launcher()?.focus();
|
|
217
|
+
}
|
|
218
|
+
// ── rendering ────────────────────────────────────────────────────────────
|
|
219
|
+
render() {
|
|
220
|
+
this.root.innerHTML = "";
|
|
221
|
+
const style = document.createElement("style");
|
|
222
|
+
style.textContent = WIDGET_SHADOW_STYLES;
|
|
223
|
+
this.root.append(style, this.buildLauncher(), this.buildPanel());
|
|
224
|
+
this.renderTranscript();
|
|
225
|
+
}
|
|
226
|
+
buildLauncher() {
|
|
227
|
+
const btn = document.createElement("button");
|
|
228
|
+
btn.className = "mj-widget-launcher";
|
|
229
|
+
btn.type = "button";
|
|
230
|
+
btn.textContent = "\u{1F4AC}";
|
|
231
|
+
btn.setAttribute("aria-label", "Open support chat");
|
|
232
|
+
btn.addEventListener("click", () => this.Open());
|
|
233
|
+
return btn;
|
|
234
|
+
}
|
|
235
|
+
buildPanel() {
|
|
236
|
+
const panel = document.createElement("section");
|
|
237
|
+
panel.className = "mj-widget-panel";
|
|
238
|
+
panel.setAttribute("hidden", "");
|
|
239
|
+
panel.setAttribute("role", "dialog");
|
|
240
|
+
panel.setAttribute("aria-label", `${this.headerTitle} chat`);
|
|
241
|
+
panel.append(this.buildHeader(), this.buildBanner(), this.buildDemonstrationSurface(), this.buildTranscriptContainer(), this.buildProgress(), this.buildComposer());
|
|
242
|
+
const notice = this.buildMemoryNotice();
|
|
243
|
+
if (notice) {
|
|
244
|
+
panel.append(notice);
|
|
245
|
+
}
|
|
246
|
+
return panel;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* RV5 visitor-facing privacy notice + "Forget me" control. Rendered only when this widget remembers
|
|
250
|
+
* returning visitors AND a forget handler is wired — otherwise returns null (no notice, no control),
|
|
251
|
+
* keeping the default (memory-off) widget unchanged.
|
|
252
|
+
*/
|
|
253
|
+
buildMemoryNotice() {
|
|
254
|
+
if (!this.session?.rememberReturningVisitors || !this.forgetHandler) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const notice = document.createElement("div");
|
|
258
|
+
notice.className = "mj-widget-memory-notice";
|
|
259
|
+
const text = document.createElement("span");
|
|
260
|
+
text.className = "mj-widget-memory-notice-text";
|
|
261
|
+
text.textContent = "We remember your past chats to help you faster.";
|
|
262
|
+
const forget = document.createElement("button");
|
|
263
|
+
forget.className = "mj-widget-forget";
|
|
264
|
+
forget.type = "button";
|
|
265
|
+
forget.textContent = "Forget me";
|
|
266
|
+
forget.addEventListener("click", () => void this.onForgetClicked());
|
|
267
|
+
notice.append(text, forget);
|
|
268
|
+
return notice;
|
|
269
|
+
}
|
|
270
|
+
/** Invokes the wired forget handler, then surfaces a confirmation as a system line. */
|
|
271
|
+
async onForgetClicked() {
|
|
272
|
+
if (this.forgetting || !this.forgetHandler) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.forgetting = true;
|
|
276
|
+
const forget = this.query(".mj-widget-forget");
|
|
277
|
+
if (forget) {
|
|
278
|
+
forget.disabled = true;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await this.forgetHandler();
|
|
282
|
+
this.appendMessage("system", "Your saved chat history has been forgotten.");
|
|
283
|
+
} catch {
|
|
284
|
+
this.appendMessage("system", "Could not forget your history right now. Please try again later.");
|
|
285
|
+
} finally {
|
|
286
|
+
this.forgetting = false;
|
|
287
|
+
if (forget) {
|
|
288
|
+
forget.disabled = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** The connection-lost banner (hidden until a send / token refresh fails). */
|
|
293
|
+
buildBanner() {
|
|
294
|
+
const banner = document.createElement("div");
|
|
295
|
+
banner.className = "mj-widget-banner";
|
|
296
|
+
banner.setAttribute("hidden", "");
|
|
297
|
+
banner.setAttribute("role", "alert");
|
|
298
|
+
const text = document.createElement("span");
|
|
299
|
+
text.className = "mj-widget-banner-text";
|
|
300
|
+
const retry = document.createElement("button");
|
|
301
|
+
retry.className = "mj-widget-banner-retry";
|
|
302
|
+
retry.type = "button";
|
|
303
|
+
retry.textContent = "Retry";
|
|
304
|
+
retry.addEventListener("click", () => this.onRetryClicked());
|
|
305
|
+
banner.append(text, retry);
|
|
306
|
+
return banner;
|
|
307
|
+
}
|
|
308
|
+
buildHeader() {
|
|
309
|
+
const header = document.createElement("header");
|
|
310
|
+
header.className = "mj-widget-header";
|
|
311
|
+
const title = document.createElement("span");
|
|
312
|
+
title.textContent = this.headerTitle;
|
|
313
|
+
const controls = document.createElement("span");
|
|
314
|
+
if (this.voiceEnabled()) {
|
|
315
|
+
controls.appendChild(this.buildVoiceButton());
|
|
316
|
+
}
|
|
317
|
+
const close = document.createElement("button");
|
|
318
|
+
close.className = "mj-widget-close";
|
|
319
|
+
close.type = "button";
|
|
320
|
+
close.textContent = "\xD7";
|
|
321
|
+
close.setAttribute("aria-label", "Close support chat");
|
|
322
|
+
close.addEventListener("click", () => this.Close());
|
|
323
|
+
controls.appendChild(close);
|
|
324
|
+
header.append(title, controls);
|
|
325
|
+
return header;
|
|
326
|
+
}
|
|
327
|
+
buildTranscriptContainer() {
|
|
328
|
+
const transcript = document.createElement("div");
|
|
329
|
+
transcript.className = "mj-widget-transcript";
|
|
330
|
+
transcript.setAttribute("role", "log");
|
|
331
|
+
transcript.setAttribute("aria-live", "polite");
|
|
332
|
+
return transcript;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The interactive-channel demonstration surface (Phase 2): a titled stage an MJ channel (e.g. the
|
|
336
|
+
* Whiteboard) renders into while voice is active. Hidden until a channel's first tool call reveals it
|
|
337
|
+
* via {@link RevealChannelSurface}; a widget with no enabled channels never shows it.
|
|
338
|
+
*/
|
|
339
|
+
buildDemonstrationSurface() {
|
|
340
|
+
const surface = document.createElement("div");
|
|
341
|
+
surface.className = "mj-widget-surface";
|
|
342
|
+
surface.setAttribute("hidden", "");
|
|
343
|
+
const title = document.createElement("div");
|
|
344
|
+
title.className = "mj-widget-surface-title";
|
|
345
|
+
const host = document.createElement("div");
|
|
346
|
+
host.className = "mj-widget-surface-host";
|
|
347
|
+
surface.append(title, host);
|
|
348
|
+
return surface;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Reveals the demonstration surface for an interactive channel and returns the inner host element the
|
|
352
|
+
* channel renders into. Called (via the voice callbacks) the first time the agent engages a channel.
|
|
353
|
+
*/
|
|
354
|
+
RevealChannelSurface(_channelName, title) {
|
|
355
|
+
const surface = this.query(".mj-widget-surface");
|
|
356
|
+
const titleEl = this.query(".mj-widget-surface-title");
|
|
357
|
+
const host = this.query(".mj-widget-surface-host");
|
|
358
|
+
if (titleEl)
|
|
359
|
+
titleEl.textContent = title;
|
|
360
|
+
surface?.removeAttribute("hidden");
|
|
361
|
+
return host ?? document.createElement("div");
|
|
362
|
+
}
|
|
363
|
+
/** Hides + clears the demonstration surface (on voice end). */
|
|
364
|
+
hideChannelSurface() {
|
|
365
|
+
const surface = this.query(".mj-widget-surface");
|
|
366
|
+
surface?.setAttribute("hidden", "");
|
|
367
|
+
const host = this.query(".mj-widget-surface-host");
|
|
368
|
+
if (host)
|
|
369
|
+
host.innerHTML = "";
|
|
370
|
+
}
|
|
371
|
+
buildProgress() {
|
|
372
|
+
const progress = document.createElement("div");
|
|
373
|
+
progress.className = "mj-widget-progress";
|
|
374
|
+
progress.setAttribute("hidden", "");
|
|
375
|
+
return progress;
|
|
376
|
+
}
|
|
377
|
+
buildComposer() {
|
|
378
|
+
const form = document.createElement("form");
|
|
379
|
+
form.className = "mj-widget-composer";
|
|
380
|
+
const input = document.createElement("textarea");
|
|
381
|
+
input.className = "mj-widget-input";
|
|
382
|
+
input.rows = 1;
|
|
383
|
+
input.setAttribute("aria-label", "Type your message");
|
|
384
|
+
input.placeholder = "Type your message\u2026";
|
|
385
|
+
input.addEventListener("keydown", (e) => this.onComposerKeydown(e));
|
|
386
|
+
const send = document.createElement("button");
|
|
387
|
+
send.className = "mj-widget-send";
|
|
388
|
+
send.type = "submit";
|
|
389
|
+
send.textContent = "Send";
|
|
390
|
+
form.append(input, send);
|
|
391
|
+
form.addEventListener("submit", (e) => {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
void this.sendCurrent();
|
|
394
|
+
});
|
|
395
|
+
return form;
|
|
396
|
+
}
|
|
397
|
+
renderTranscript() {
|
|
398
|
+
const transcript = this.query(".mj-widget-transcript");
|
|
399
|
+
if (!transcript)
|
|
400
|
+
return;
|
|
401
|
+
transcript.innerHTML = "";
|
|
402
|
+
if (this.messages.length === 0) {
|
|
403
|
+
const empty = document.createElement("div");
|
|
404
|
+
empty.className = "mj-widget-empty";
|
|
405
|
+
empty.textContent = this.greeting;
|
|
406
|
+
transcript.appendChild(empty);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
for (const msg of this.messages) {
|
|
410
|
+
const el = document.createElement("div");
|
|
411
|
+
el.className = `mj-widget-msg ${msg.role}`;
|
|
412
|
+
el.textContent = msg.text;
|
|
413
|
+
transcript.appendChild(el);
|
|
414
|
+
}
|
|
415
|
+
transcript.scrollTop = transcript.scrollHeight;
|
|
416
|
+
}
|
|
417
|
+
// ── interaction ──────────────────────────────────────────────────────────
|
|
418
|
+
onComposerKeydown(e) {
|
|
419
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
void this.sendCurrent();
|
|
422
|
+
} else if (e.key === "Escape") {
|
|
423
|
+
this.Close();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// ── focus trap (W6 accessibility) ──────────────────────────────────────────
|
|
427
|
+
/** Keeps Tab focus cycling within the open panel; Esc still closes. */
|
|
428
|
+
onPanelKeydown(e) {
|
|
429
|
+
if (e.key === "Escape") {
|
|
430
|
+
this.Close();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (e.key !== "Tab")
|
|
434
|
+
return;
|
|
435
|
+
const focusables = this.focusableElements();
|
|
436
|
+
if (focusables.length === 0)
|
|
437
|
+
return;
|
|
438
|
+
const first = focusables[0];
|
|
439
|
+
const last = focusables[focusables.length - 1];
|
|
440
|
+
const active = this.activeWithinPanel();
|
|
441
|
+
if (e.shiftKey && active === first) {
|
|
442
|
+
e.preventDefault();
|
|
443
|
+
last.focus();
|
|
444
|
+
} else if (!e.shiftKey && active === last) {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
first.focus();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** The focusable controls inside the panel, in DOM order, excluding hidden subtrees. */
|
|
450
|
+
focusableElements() {
|
|
451
|
+
const panel = this.panel();
|
|
452
|
+
if (!panel)
|
|
453
|
+
return [];
|
|
454
|
+
const selector = 'button:not([disabled]), textarea:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
455
|
+
return Array.from(panel.querySelectorAll(selector)).filter((el) => !this.isInHiddenSubtree(el, panel));
|
|
456
|
+
}
|
|
457
|
+
/** True when the element (or an ancestor up to the panel) carries the `hidden` attribute. */
|
|
458
|
+
isInHiddenSubtree(el, panel) {
|
|
459
|
+
let node = el;
|
|
460
|
+
while (node && node !== panel) {
|
|
461
|
+
if (node.hasAttribute("hidden"))
|
|
462
|
+
return true;
|
|
463
|
+
node = node.parentElement;
|
|
464
|
+
}
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
/** The currently-focused element, resolved through the shadow root. */
|
|
468
|
+
activeWithinPanel() {
|
|
469
|
+
const active = this.root.activeElement;
|
|
470
|
+
return active instanceof HTMLElement ? active : null;
|
|
471
|
+
}
|
|
472
|
+
/** Reads the composer, appends the user turn, dispatches it, renders the reply. */
|
|
473
|
+
async sendCurrent() {
|
|
474
|
+
const input = this.input();
|
|
475
|
+
const text = input?.value.trim() ?? "";
|
|
476
|
+
if (!text || this.sending)
|
|
477
|
+
return;
|
|
478
|
+
if (!this.transport) {
|
|
479
|
+
this.appendMessage("system", "Support is not connected yet. Please try again in a moment.");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (input)
|
|
483
|
+
input.value = "";
|
|
484
|
+
this.appendMessage("user", text);
|
|
485
|
+
await this.dispatchTurn(text);
|
|
486
|
+
}
|
|
487
|
+
/** Sends one turn through the transport and renders progress + the reply. */
|
|
488
|
+
async dispatchTurn(text) {
|
|
489
|
+
this.setSending(true);
|
|
490
|
+
try {
|
|
491
|
+
const result = await this.transport.SendMessage(text, (m) => this.showProgress(m));
|
|
492
|
+
if (result.success) {
|
|
493
|
+
this.clearConnectionError();
|
|
494
|
+
this.appendMessage("agent", result.reply || "(no response)");
|
|
495
|
+
} else {
|
|
496
|
+
this.ShowConnectionError(result.error ?? "Message could not be sent.", () => void this.dispatchTurn(text));
|
|
497
|
+
}
|
|
498
|
+
} catch (err) {
|
|
499
|
+
const msg = err instanceof Error ? err.message : "Connection lost.";
|
|
500
|
+
this.ShowConnectionError(msg, () => void this.dispatchTurn(text));
|
|
501
|
+
} finally {
|
|
502
|
+
this.hideProgress();
|
|
503
|
+
this.setSending(false);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
appendMessage(role, text) {
|
|
507
|
+
this.messages.push({ role, text, timestampMs: Date.now() });
|
|
508
|
+
this.renderTranscript();
|
|
509
|
+
}
|
|
510
|
+
showProgress(message) {
|
|
511
|
+
const progress = this.query(".mj-widget-progress");
|
|
512
|
+
if (!progress)
|
|
513
|
+
return;
|
|
514
|
+
progress.textContent = message;
|
|
515
|
+
progress.removeAttribute("hidden");
|
|
516
|
+
}
|
|
517
|
+
hideProgress() {
|
|
518
|
+
this.query(".mj-widget-progress")?.setAttribute("hidden", "");
|
|
519
|
+
}
|
|
520
|
+
setSending(sending) {
|
|
521
|
+
this.sending = sending;
|
|
522
|
+
const send = this.query(".mj-widget-send");
|
|
523
|
+
if (send)
|
|
524
|
+
send.disabled = sending;
|
|
525
|
+
}
|
|
526
|
+
// ── voice (W4) ─────────────────────────────────────────────────────────────
|
|
527
|
+
/**
|
|
528
|
+
* Voice is offered only when a controller is wired, the instance enables Voice/Both,
|
|
529
|
+
* AND the browser can actually capture the mic (secure context + getUserMedia). On an
|
|
530
|
+
* insecure origin or an unsupported browser the mic affordance is hidden and the
|
|
531
|
+
* visitor falls back to text (graceful degradation, W6).
|
|
532
|
+
*/
|
|
533
|
+
voiceEnabled() {
|
|
534
|
+
const modalityAllows = this.session?.modality === "Voice" || this.session?.modality === "Both";
|
|
535
|
+
return this.voiceController !== null && modalityAllows && this.voiceSupportedProbe();
|
|
536
|
+
}
|
|
537
|
+
buildVoiceButton() {
|
|
538
|
+
const btn = document.createElement("button");
|
|
539
|
+
btn.className = "mj-widget-close mj-widget-voice";
|
|
540
|
+
btn.type = "button";
|
|
541
|
+
btn.textContent = this.voiceActive ? "\u23F9" : "\u{1F3A4}";
|
|
542
|
+
btn.setAttribute("aria-label", this.voiceActive ? "Stop voice" : "Start voice");
|
|
543
|
+
btn.setAttribute("aria-pressed", String(this.voiceActive));
|
|
544
|
+
btn.addEventListener("click", () => void this.toggleVoice());
|
|
545
|
+
return btn;
|
|
546
|
+
}
|
|
547
|
+
/** Starts or stops a voice session via the injected controller. */
|
|
548
|
+
async toggleVoice() {
|
|
549
|
+
if (!this.voiceController)
|
|
550
|
+
return;
|
|
551
|
+
if (this.voiceActive) {
|
|
552
|
+
await this.voiceController.Stop();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
await this.voiceController.Start({
|
|
557
|
+
onState: (s) => this.onVoiceState(s),
|
|
558
|
+
onTranscript: (t) => {
|
|
559
|
+
if (t.isFinal && t.text.trim())
|
|
560
|
+
this.appendMessage(t.role, t.text);
|
|
561
|
+
},
|
|
562
|
+
onEnded: (reason) => this.onVoiceEnded(reason),
|
|
563
|
+
// Phase 2: an MJ interactive channel reveals its surface here on first use.
|
|
564
|
+
getChannelSurface: (name, title) => this.RevealChannelSurface(name, title)
|
|
565
|
+
});
|
|
566
|
+
} catch (err) {
|
|
567
|
+
this.appendMessage("system", err instanceof Error ? err.message : "Could not start voice.");
|
|
568
|
+
this.onVoiceEnded();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
onVoiceState(state) {
|
|
572
|
+
this.voiceActive = state !== "idle" && state !== "ended" && state !== "error";
|
|
573
|
+
this.refreshVoiceButton();
|
|
574
|
+
if (state === "listening")
|
|
575
|
+
this.showProgress("\u{1F399} Listening\u2026");
|
|
576
|
+
else if (state === "speaking")
|
|
577
|
+
this.showProgress("\u{1F50A} Speaking\u2026");
|
|
578
|
+
else
|
|
579
|
+
this.hideProgress();
|
|
580
|
+
}
|
|
581
|
+
onVoiceEnded(reason) {
|
|
582
|
+
this.voiceActive = false;
|
|
583
|
+
this.refreshVoiceButton();
|
|
584
|
+
this.hideProgress();
|
|
585
|
+
this.hideChannelSurface();
|
|
586
|
+
if (reason)
|
|
587
|
+
this.appendMessage("system", reason);
|
|
588
|
+
}
|
|
589
|
+
refreshVoiceButton() {
|
|
590
|
+
const btn = this.query(".mj-widget-voice");
|
|
591
|
+
if (!btn)
|
|
592
|
+
return;
|
|
593
|
+
btn.textContent = this.voiceActive ? "\u23F9" : "\u{1F3A4}";
|
|
594
|
+
btn.setAttribute("aria-label", this.voiceActive ? "Stop voice" : "Start voice");
|
|
595
|
+
btn.setAttribute("aria-pressed", String(this.voiceActive));
|
|
596
|
+
}
|
|
597
|
+
// ── small DOM accessors (testable) ─────────────────────────────────────────
|
|
598
|
+
/** Surfaces a system/notification line in the transcript (used by the notification adapter). */
|
|
599
|
+
ShowSystemMessage(text) {
|
|
600
|
+
this.appendMessage("system", text);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Shows the connection-lost banner with a retry affordance. Used when a send fails or
|
|
604
|
+
* a token refresh fails (graceful degradation, W6). Invoking the banner's Retry button
|
|
605
|
+
* (or {@link RetryConnection}) runs the supplied action and clears the banner on success.
|
|
606
|
+
*
|
|
607
|
+
* @param message The user-facing reason shown in the banner.
|
|
608
|
+
* @param retry The action to run when the visitor clicks Retry.
|
|
609
|
+
*/
|
|
610
|
+
ShowConnectionError(message, retry) {
|
|
611
|
+
this.pendingRetry = retry;
|
|
612
|
+
const banner = this.query(".mj-widget-banner");
|
|
613
|
+
const text = this.query(".mj-widget-banner-text");
|
|
614
|
+
if (text)
|
|
615
|
+
text.textContent = message;
|
|
616
|
+
banner?.removeAttribute("hidden");
|
|
617
|
+
}
|
|
618
|
+
/** Programmatically triggers the pending retry (same effect as clicking Retry). */
|
|
619
|
+
RetryConnection() {
|
|
620
|
+
this.onRetryClicked();
|
|
621
|
+
}
|
|
622
|
+
/** Hides the connection-lost banner and drops the pending retry. */
|
|
623
|
+
clearConnectionError() {
|
|
624
|
+
this.pendingRetry = null;
|
|
625
|
+
this.query(".mj-widget-banner")?.setAttribute("hidden", "");
|
|
626
|
+
}
|
|
627
|
+
/** Runs the pending retry (if any) and hides the banner; the retry re-shows it on a repeat failure. */
|
|
628
|
+
onRetryClicked() {
|
|
629
|
+
const retry = this.pendingRetry;
|
|
630
|
+
this.clearConnectionError();
|
|
631
|
+
retry?.();
|
|
632
|
+
}
|
|
633
|
+
/** Exposes the shadow root for tests + integration code. */
|
|
634
|
+
get ShadowRootRef() {
|
|
635
|
+
return this.root;
|
|
636
|
+
}
|
|
637
|
+
query(selector) {
|
|
638
|
+
return this.root.querySelector(selector);
|
|
639
|
+
}
|
|
640
|
+
panel() {
|
|
641
|
+
return this.query(".mj-widget-panel");
|
|
642
|
+
}
|
|
643
|
+
launcher() {
|
|
644
|
+
return this.query(".mj-widget-launcher");
|
|
645
|
+
}
|
|
646
|
+
input() {
|
|
647
|
+
return this.query(".mj-widget-input");
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
function DefineSupportWidgetElement() {
|
|
651
|
+
if (typeof customElements !== "undefined" && !customElements.get(WIDGET_TAG_NAME)) {
|
|
652
|
+
customElements.define(WIDGET_TAG_NAME, SupportWidgetElement);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// dist/session/widget-session-client.js
|
|
657
|
+
var WidgetSessionError = class extends Error {
|
|
658
|
+
constructor(message, Status, Code) {
|
|
659
|
+
super(message);
|
|
660
|
+
this.Status = Status;
|
|
661
|
+
this.Code = Code;
|
|
662
|
+
this.name = "WidgetSessionError";
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* True when the server refused for good (401/403): retrying the same request cannot succeed, so the
|
|
666
|
+
* caller must start a new session. Rate limits (429), server faults (5xx) and network failures are temporary.
|
|
667
|
+
*/
|
|
668
|
+
get IsRefused() {
|
|
669
|
+
return this.Status === 401 || this.Status === 403;
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
var REFRESH_LEAD_MS = 6e4;
|
|
673
|
+
var MIN_REFRESH_DELAY_MS = 5e3;
|
|
674
|
+
var WidgetSessionClient = class {
|
|
675
|
+
constructor(apiUrl, widgetKey, fetchImpl) {
|
|
676
|
+
this.apiUrl = apiUrl;
|
|
677
|
+
this.widgetKey = widgetKey;
|
|
678
|
+
this.proofKey = null;
|
|
679
|
+
this.serverOffsetMs = 0;
|
|
680
|
+
this.fetchImpl = fetchImpl ?? ((input, init) => fetch(input, init));
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Mints a fresh guest session. Throws on a non-2xx / unsuccessful response.
|
|
684
|
+
* @param visitorKey the durable returning-visitor anchor from the first-party cookie, if present —
|
|
685
|
+
* the server uses it to chain this visit to the visitor's prior conversation (gated on the widget's
|
|
686
|
+
* RememberReturningVisitors toggle). Omit on a first visit; the server mints one when remembering is on.
|
|
687
|
+
*/
|
|
688
|
+
async Mint(visitorKey) {
|
|
689
|
+
const key = await CreateProofOfPossessionKey();
|
|
690
|
+
const session = await this.call("/widget/session", visitorKey, void 0, key);
|
|
691
|
+
this.proofKey = key;
|
|
692
|
+
return session;
|
|
693
|
+
}
|
|
694
|
+
/** Refreshes the guest session (server re-mints by the same key while verifying the current token to
|
|
695
|
+
* preserve `mj_sid`). Carries the visitor anchor so the refreshed session keeps the same
|
|
696
|
+
* returning-visitor identity. */
|
|
697
|
+
async Refresh(currentToken, visitorKey) {
|
|
698
|
+
if (!this.proofKey) {
|
|
699
|
+
throw new Error("Widget session refresh needs the key registered at mint; call Mint first.");
|
|
700
|
+
}
|
|
701
|
+
return this.call("/widget/session/refresh", visitorKey, currentToken, this.proofKey);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* W5 magic-link upgrade ("Verify it's you"): asks the server to email a verification link scoped to
|
|
705
|
+
* the widget's app. On success the visitor receives an email; clicking it redeems a VERIFIED session
|
|
706
|
+
* JWT (via the existing `/magic-link/redeem`). The host page hands that token back to the widget,
|
|
707
|
+
* which calls `transport.UpdateToken(verifiedToken)` to swap it in — the live conversationId is
|
|
708
|
+
* preserved because the transport holds it. Never throws; returns a structured result for the UI.
|
|
709
|
+
*/
|
|
710
|
+
async RequestUpgrade(email) {
|
|
711
|
+
try {
|
|
712
|
+
const res = await this.fetchImpl(`${this.trimmedApiUrl()}/widget/upgrade`, {
|
|
713
|
+
method: "POST",
|
|
714
|
+
headers: { "content-type": "application/json" },
|
|
715
|
+
body: JSON.stringify({ widgetKey: this.widgetKey, email })
|
|
716
|
+
});
|
|
717
|
+
const body = await res.json();
|
|
718
|
+
if (!res.ok || !body?.success) {
|
|
719
|
+
return { success: false, error: body?.error ?? body?.errorCode ?? "Could not start verification." };
|
|
720
|
+
}
|
|
721
|
+
return { success: true, emailSent: body.emailSent };
|
|
722
|
+
} catch (err) {
|
|
723
|
+
return { success: false, error: err instanceof Error ? err.message : "Could not start verification." };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* RV5 "forget me": asks the server to archive this visitor's memory and clear the VisitorKey
|
|
728
|
+
* linkage. The caller is responsible for clearing the durable cookie afterward
|
|
729
|
+
* (`clearVisitorKey`). Public endpoint — the durable key is the proof. Never throws.
|
|
730
|
+
*/
|
|
731
|
+
async Forget(visitorKey) {
|
|
732
|
+
try {
|
|
733
|
+
const res = await this.fetchImpl(`${this.trimmedApiUrl()}/widget/forget`, {
|
|
734
|
+
method: "POST",
|
|
735
|
+
headers: { "content-type": "application/json" },
|
|
736
|
+
body: JSON.stringify({ widgetKey: this.widgetKey, visitorKey })
|
|
737
|
+
});
|
|
738
|
+
const body = await res.json();
|
|
739
|
+
if (!res.ok || !body?.success) {
|
|
740
|
+
return { success: false, error: body?.error ?? body?.errorCode ?? "Could not forget this visitor." };
|
|
741
|
+
}
|
|
742
|
+
return { success: true, notesArchived: body.notesArchived };
|
|
743
|
+
} catch (err) {
|
|
744
|
+
return { success: false, error: err instanceof Error ? err.message : "Could not forget this visitor." };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* RV4 (magic-link upgrade path): after the visitor verifies and the widget swaps in the VERIFIED
|
|
749
|
+
* token, promote their anonymous returning-visitor trail to the verified record. AUTHENTICATED —
|
|
750
|
+
* the verified token is sent as a Bearer credential; the server reads the verified email from it.
|
|
751
|
+
* Never throws; returns a structured result.
|
|
752
|
+
*/
|
|
753
|
+
async ResolveIdentity(verifiedToken, visitorKey) {
|
|
754
|
+
try {
|
|
755
|
+
const res = await this.fetchImpl(`${this.trimmedApiUrl()}/widget/resolve-identity`, {
|
|
756
|
+
method: "POST",
|
|
757
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${verifiedToken}` },
|
|
758
|
+
body: JSON.stringify({ widgetKey: this.widgetKey, visitorKey })
|
|
759
|
+
});
|
|
760
|
+
const body = await res.json();
|
|
761
|
+
if (!res.ok || !body?.success) {
|
|
762
|
+
return { success: false, error: body?.error ?? body?.errorCode ?? "Could not resolve identity." };
|
|
763
|
+
}
|
|
764
|
+
return { success: true, mergedConversations: body.mergedConversations };
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return { success: false, error: err instanceof Error ? err.message : "Could not resolve identity." };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** Milliseconds until the given session should be refreshed (at least {@link MIN_REFRESH_DELAY_MS}). */
|
|
770
|
+
static MsUntilRefresh(session, nowMs) {
|
|
771
|
+
return Math.max(MIN_REFRESH_DELAY_MS, session.expiresAtMs - nowMs - REFRESH_LEAD_MS);
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* POSTs to a session endpoint. A mint (no `bearerToken`) registers `key`'s public half; a refresh sends the
|
|
775
|
+
* token and a proof signed by `key` for this URL and token.
|
|
776
|
+
*/
|
|
777
|
+
async call(path, visitorKey, bearerToken, key) {
|
|
778
|
+
const url = `${this.trimmedApiUrl()}${path}`;
|
|
779
|
+
const headers = { "content-type": "application/json" };
|
|
780
|
+
const request = { widgetKey: this.widgetKey };
|
|
781
|
+
if (visitorKey) {
|
|
782
|
+
request.visitorKey = visitorKey;
|
|
783
|
+
}
|
|
784
|
+
if (bearerToken) {
|
|
785
|
+
headers["authorization"] = `Bearer ${bearerToken}`;
|
|
786
|
+
headers["dpop"] = await CreateProofOfPossessionJwt(key, {
|
|
787
|
+
Method: "POST",
|
|
788
|
+
Url: url,
|
|
789
|
+
AccessToken: bearerToken,
|
|
790
|
+
NowSeconds: Math.floor((Date.now() + this.serverOffsetMs) / 1e3)
|
|
791
|
+
});
|
|
792
|
+
} else {
|
|
793
|
+
request.proofKey = key.PublicJwk;
|
|
794
|
+
}
|
|
795
|
+
const res = await this.fetchImpl(url, { method: "POST", headers, body: JSON.stringify(request) });
|
|
796
|
+
const body = await res.json();
|
|
797
|
+
if (!res.ok || !body?.success || !body.token) {
|
|
798
|
+
throw new WidgetSessionError(`Widget session mint failed (${res.status}): ${body?.errorCode ?? body?.error ?? "unknown"}`, res.status, body?.errorCode ?? null);
|
|
799
|
+
}
|
|
800
|
+
this.learnServerOffset(body.token);
|
|
801
|
+
return this.toSession(body);
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Records the server's clock offset from a token it just issued (`iat` is its "now"). The offset is off by the
|
|
805
|
+
* response's one-way latency, far inside the server's 60 s window. A token without a readable `iat` keeps the
|
|
806
|
+
* previous offset: the server stamps `iat` on every token it signs, so this is never a guess about server time.
|
|
807
|
+
*/
|
|
808
|
+
learnServerOffset(token) {
|
|
809
|
+
const iat = ReadTokenIssuedAtSeconds(token);
|
|
810
|
+
if (iat !== null) {
|
|
811
|
+
this.serverOffsetMs = iat * 1e3 - Date.now();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Maps the API response to the in-memory session, validating required fields. `expiresAtMs` is put on this
|
|
816
|
+
* browser's clock (the server's expiry less the learned offset), so `expiresAtMs - Date.now()` is the time the
|
|
817
|
+
* token really has left even on a device whose clock is off.
|
|
818
|
+
*/
|
|
819
|
+
toSession(body) {
|
|
820
|
+
if (!body.widgetId || !body.applicationId || !body.pinnedAgentId) {
|
|
821
|
+
throw new Error("Widget session response missing required fields (widgetId/applicationId/pinnedAgentId).");
|
|
822
|
+
}
|
|
823
|
+
const expiresAtMs = body.expiresAt ? Date.parse(body.expiresAt) : Number.NaN;
|
|
824
|
+
return {
|
|
825
|
+
token: body.token,
|
|
826
|
+
expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs - this.serverOffsetMs : Date.now() + 15 * 6e4,
|
|
827
|
+
widgetId: body.widgetId,
|
|
828
|
+
applicationId: body.applicationId,
|
|
829
|
+
pinnedAgentId: body.pinnedAgentId,
|
|
830
|
+
modality: body.modality ?? "Text",
|
|
831
|
+
sessionId: body.sessionId ?? "",
|
|
832
|
+
voiceMaxSessionMinutes: body.voiceMaxSessionMinutes,
|
|
833
|
+
rememberReturningVisitors: body.rememberReturningVisitors ?? false,
|
|
834
|
+
visitorKey: body.visitorKey,
|
|
835
|
+
lastConversationId: body.lastConversationId,
|
|
836
|
+
linkedEntityId: body.linkedEntityId,
|
|
837
|
+
linkedRecordId: body.linkedRecordId,
|
|
838
|
+
enabledChannels: Array.isArray(body.enabledChannels) ? body.enabledChannels : []
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
trimmedApiUrl() {
|
|
842
|
+
return this.apiUrl.replace(/\/+$/, "");
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
function ReadTokenIssuedAtSeconds(token) {
|
|
846
|
+
const part = token.split(".")[1];
|
|
847
|
+
if (!part) {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
|
|
852
|
+
const payload = JSON.parse(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)));
|
|
853
|
+
if (typeof payload !== "object" || payload === null || !("iat" in payload)) {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
return typeof payload.iat === "number" && Number.isFinite(payload.iat) ? payload.iat : null;
|
|
857
|
+
} catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// dist/session/visitor-key-cookie.js
|
|
863
|
+
var COOKIE_PREFIX = "mjwv_";
|
|
864
|
+
var COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
|
865
|
+
function cookieName(widgetKey) {
|
|
866
|
+
return COOKIE_PREFIX + widgetKey.replace(/[^A-Za-z0-9_-]/g, "");
|
|
867
|
+
}
|
|
868
|
+
function ReadVisitorKey(widgetKey) {
|
|
869
|
+
if (typeof document === "undefined" || !document.cookie) {
|
|
870
|
+
return void 0;
|
|
871
|
+
}
|
|
872
|
+
const name = cookieName(widgetKey) + "=";
|
|
873
|
+
for (const part of document.cookie.split(";")) {
|
|
874
|
+
const trimmed = part.trim();
|
|
875
|
+
if (trimmed.startsWith(name)) {
|
|
876
|
+
const raw = trimmed.slice(name.length);
|
|
877
|
+
try {
|
|
878
|
+
return decodeURIComponent(raw) || void 0;
|
|
879
|
+
} catch {
|
|
880
|
+
return raw || void 0;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return void 0;
|
|
885
|
+
}
|
|
886
|
+
function WriteVisitorKey(widgetKey, visitorKey) {
|
|
887
|
+
if (typeof document === "undefined" || !visitorKey) {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
|
|
891
|
+
document.cookie = `${cookieName(widgetKey)}=${encodeURIComponent(visitorKey)}; Max-Age=${COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secure}`;
|
|
892
|
+
}
|
|
893
|
+
function ClearVisitorKey(widgetKey) {
|
|
894
|
+
if (typeof document === "undefined") {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
document.cookie = `${cookieName(widgetKey)}=; Max-Age=0; Path=/; SameSite=Lax`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// dist/loader.js
|
|
901
|
+
async function MountWidget(options, deps = {}) {
|
|
902
|
+
DefineSupportWidgetElement();
|
|
903
|
+
const client = (deps.sessionClientFactory ?? defaultSessionClient)(options.apiUrl, options.widgetKey);
|
|
904
|
+
const presentedVisitorKey = ReadVisitorKey(options.widgetKey);
|
|
905
|
+
const session = await client.Mint(presentedVisitorKey);
|
|
906
|
+
if (session.rememberReturningVisitors && session.visitorKey) {
|
|
907
|
+
WriteVisitorKey(options.widgetKey, session.visitorKey);
|
|
908
|
+
}
|
|
909
|
+
const transport = await (deps.transportFactory ?? defaultTransport)(options.apiUrl);
|
|
910
|
+
await transport.Initialize(session);
|
|
911
|
+
const element = document.createElement(WIDGET_TAG_NAME);
|
|
912
|
+
element.Configure({ title: options.title, greeting: options.greeting });
|
|
913
|
+
element.SetSession(session);
|
|
914
|
+
element.SetTransport(transport);
|
|
915
|
+
if (session.modality === "Voice" || session.modality === "Both") {
|
|
916
|
+
element.SetVoiceController(await (deps.voiceControllerFactory ?? defaultVoiceController)(session));
|
|
917
|
+
}
|
|
918
|
+
if (session.rememberReturningVisitors && session.visitorKey) {
|
|
919
|
+
const visitorKey = session.visitorKey;
|
|
920
|
+
element.SetForgetHandler(async () => {
|
|
921
|
+
const result = await client.Forget(visitorKey);
|
|
922
|
+
if (!result.success) {
|
|
923
|
+
throw new Error(result.error ?? "forget failed");
|
|
924
|
+
}
|
|
925
|
+
ClearVisitorKey(options.widgetKey);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
resolveMountTarget(options.mountTarget).appendChild(element);
|
|
929
|
+
registerNotificationAdapter(element);
|
|
930
|
+
scheduleRefresh(client, transport, element, session, deps.scheduler ?? defaultScheduler);
|
|
931
|
+
return element;
|
|
932
|
+
}
|
|
933
|
+
async function BootstrapFromDocument(doc = document) {
|
|
934
|
+
const mountEl = doc.querySelector("[data-widget-key]");
|
|
935
|
+
const widgetKey = mountEl?.dataset.widgetKey;
|
|
936
|
+
const apiUrl = mountEl?.dataset.apiUrl;
|
|
937
|
+
if (!mountEl || !widgetKey || !apiUrl) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return MountWidget({
|
|
941
|
+
widgetKey,
|
|
942
|
+
apiUrl,
|
|
943
|
+
mountTarget: mountEl,
|
|
944
|
+
title: mountEl.dataset.title,
|
|
945
|
+
greeting: mountEl.dataset.greeting
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
async function StartEmbeddedWidget(doc = document) {
|
|
949
|
+
try {
|
|
950
|
+
await BootstrapFromDocument(doc);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
if (typeof crypto === "undefined" || !crypto.subtle) {
|
|
953
|
+
console.error("[mj-widget] The support widget needs a secure context (HTTPS, or localhost) for the Web Crypto API, which this page does not have, so it is not shown. Serve the page over HTTPS.", error);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
console.error("[mj-widget] Could not start the support widget:", error);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function defaultSessionClient(apiUrl, widgetKey) {
|
|
960
|
+
return new WidgetSessionClient(apiUrl, widgetKey);
|
|
961
|
+
}
|
|
962
|
+
async function defaultTransport(apiUrl) {
|
|
963
|
+
const { RuntimeWidgetTransport } = await import("./chunks/runtime-widget-transport-AFGCQC7N.js");
|
|
964
|
+
return new RuntimeWidgetTransport(apiUrl);
|
|
965
|
+
}
|
|
966
|
+
async function defaultVoiceController(session) {
|
|
967
|
+
const [{ RealtimeVoiceController }, { createGuestVoiceMint }, { createGuestToolRelay }] = await Promise.all([
|
|
968
|
+
import("./chunks/realtime-voice-controller-PCOD3UBR.js"),
|
|
969
|
+
import("./chunks/guest-voice-mint-KJMP4VTK.js"),
|
|
970
|
+
import("./chunks/guest-tool-relay-GAWXNIUD.js")
|
|
971
|
+
]);
|
|
972
|
+
return new RealtimeVoiceController(
|
|
973
|
+
createGuestVoiceMint(session),
|
|
974
|
+
voiceLimitsForSession(session),
|
|
975
|
+
// Phase 2: the interactive channels this widget may attach (Whiteboard, …) + the live relay for
|
|
976
|
+
// non-channel tool calls. Empty enabledChannels (the default) leaves voice behavior unchanged.
|
|
977
|
+
session.enabledChannels,
|
|
978
|
+
createGuestToolRelay()
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
function voiceLimitsForSession(session) {
|
|
982
|
+
if (session.voiceMaxSessionMinutes && session.voiceMaxSessionMinutes > 0) {
|
|
983
|
+
return { ...DEFAULT_VOICE_LIMITS, maxSessionMinutes: session.voiceMaxSessionMinutes };
|
|
984
|
+
}
|
|
985
|
+
return void 0;
|
|
986
|
+
}
|
|
987
|
+
function defaultScheduler(cb, delayMs) {
|
|
988
|
+
setTimeout(cb, delayMs);
|
|
989
|
+
}
|
|
990
|
+
function resolveMountTarget(target) {
|
|
991
|
+
if (target instanceof HTMLElement)
|
|
992
|
+
return target;
|
|
993
|
+
if (typeof target === "string") {
|
|
994
|
+
const found = document.querySelector(target);
|
|
995
|
+
if (found)
|
|
996
|
+
return found;
|
|
997
|
+
}
|
|
998
|
+
return document.body;
|
|
999
|
+
}
|
|
1000
|
+
function registerNotificationAdapter(element) {
|
|
1001
|
+
void import("./chunks/dist-P2JMWJFV.js").then(({ ConversationsRuntime }) => {
|
|
1002
|
+
ConversationsRuntime.Instance.UseNotificationAdapter({
|
|
1003
|
+
Notify: (level, message) => {
|
|
1004
|
+
if (level === "error" || level === "warning") {
|
|
1005
|
+
element.ShowSystemMessage(message);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
function scheduleRefresh(client, transport, element, session, scheduler) {
|
|
1012
|
+
const delay = WidgetSessionClient.MsUntilRefresh(session, Date.now());
|
|
1013
|
+
scheduler(() => {
|
|
1014
|
+
void (async () => {
|
|
1015
|
+
try {
|
|
1016
|
+
const refreshed = await client.Refresh(session.token, session.visitorKey);
|
|
1017
|
+
transport.UpdateToken(refreshed.token);
|
|
1018
|
+
scheduleRefresh(client, transport, element, refreshed, scheduler);
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
if (error instanceof WidgetSessionError && error.IsRefused) {
|
|
1021
|
+
await restartSession(client, transport, element, session, scheduler);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
showRefreshRetry(client, transport, element, session, scheduler);
|
|
1025
|
+
}
|
|
1026
|
+
})();
|
|
1027
|
+
}, delay);
|
|
1028
|
+
}
|
|
1029
|
+
async function restartSession(client, transport, element, previous, scheduler) {
|
|
1030
|
+
try {
|
|
1031
|
+
const fresh = await client.Mint(previous.visitorKey);
|
|
1032
|
+
await transport.Initialize(fresh);
|
|
1033
|
+
element.SetSession(fresh);
|
|
1034
|
+
element.ShowSystemMessage("Your previous chat ended. You can start a new one.");
|
|
1035
|
+
scheduleRefresh(client, transport, element, fresh, scheduler);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
console.error("[mj-widget] Could not start a new session after the previous one was refused:", error);
|
|
1038
|
+
showRefreshRetry(client, transport, element, previous, scheduler);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function showRefreshRetry(client, transport, element, session, scheduler) {
|
|
1042
|
+
element.ShowConnectionError("Connection to support was lost.", () => scheduleRefresh(client, transport, element, session, scheduler));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// dist/embed.js
|
|
1046
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
1047
|
+
DefineSupportWidgetElement();
|
|
1048
|
+
const run = () => {
|
|
1049
|
+
void StartEmbeddedWidget();
|
|
1050
|
+
};
|
|
1051
|
+
if (document.readyState === "loading") {
|
|
1052
|
+
document.addEventListener("DOMContentLoaded", run, { once: true });
|
|
1053
|
+
} else {
|
|
1054
|
+
run();
|
|
1055
|
+
}
|
|
1056
|
+
}
|