@runtypelabs/persona 3.25.0 → 3.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -13
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BZVr1YOV.d.cts → types-CxvHw0X6.d.cts} +551 -1
- package/dist/animations/{types-BZVr1YOV.d.ts → types-CxvHw0X6.d.ts} +551 -1
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1230 -3
- package/dist/index.d.ts +1230 -3
- package/dist/index.global.js +52 -52
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +47 -47
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +133 -0
- package/dist/launcher.global.js.map +1 -0
- package/dist/smart-dom-reader.d.cts +551 -1
- package/dist/smart-dom-reader.d.ts +551 -1
- package/dist/theme-editor.cjs +37 -37
- package/dist/theme-editor.d.cts +551 -1
- package/dist/theme-editor.d.ts +551 -1
- package/dist/theme-editor.js +35 -35
- package/package.json +8 -4
- package/src/client.test.ts +58 -9
- package/src/client.ts +18 -0
- package/src/generated/runtype-openapi-contract.ts +1249 -0
- package/src/index-core.ts +19 -0
- package/src/install.test.ts +442 -0
- package/src/install.ts +283 -49
- package/src/launcher-global.ts +115 -0
- package/src/runtime/init.test.ts +71 -0
- package/src/runtime/init.ts +17 -1
- package/src/types.ts +14 -8
package/src/install.ts
CHANGED
|
@@ -24,14 +24,42 @@ interface SiteAgentInstallConfig {
|
|
|
24
24
|
useShadowDom?: boolean;
|
|
25
25
|
// Expose the widget handle on window[windowKey] for programmatic access
|
|
26
26
|
windowKey?: string;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Fired as soon as the installer script executes, before it loads or gates
|
|
29
|
+
* anything. For diagnostics / load-timing baselines ("did my embed run").
|
|
30
|
+
*/
|
|
31
|
+
onScriptLoad?: (info: { version: string }) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Fired when the floating launcher is painted on the page — at page-load time.
|
|
34
|
+
* Deferred installs: the critical launcher mounts. Eager floating installs:
|
|
35
|
+
* the full widget's launcher mounts. Use this for "widget appeared" analytics.
|
|
36
|
+
* Does NOT fire for inline / docked / composer-bar installs (no floating
|
|
37
|
+
* launcher) — use `onChatReady` there.
|
|
38
|
+
*/
|
|
39
|
+
onLauncherShown?: (info: { deferred: boolean; element?: HTMLElement }) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Fired when the full widget is initialized and its controller API is
|
|
42
|
+
* callable. Deferred installs: after the user first opens the panel. Eager
|
|
43
|
+
* installs: on page load.
|
|
44
|
+
*/
|
|
45
|
+
onChatReady?: (handle: any) => void;
|
|
46
|
+
/**
|
|
47
|
+
* @deprecated Use `onChatReady`. Retained as a working alias; it will be
|
|
48
|
+
* removed in the next major version.
|
|
49
|
+
*/
|
|
28
50
|
onReady?: (handle: any) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Fired when a load step fails (stylesheet, full bundle, or init), so you can
|
|
53
|
+
* detect ad-blocked / timed-out installs instead of failing silently.
|
|
54
|
+
*/
|
|
55
|
+
onError?: (info: { phase: "css" | "bundle" | "init"; error: unknown }) => void;
|
|
29
56
|
}
|
|
30
57
|
|
|
31
58
|
declare global {
|
|
32
59
|
interface Window {
|
|
33
60
|
siteAgentConfig?: SiteAgentInstallConfig;
|
|
34
61
|
AgentWidget?: any;
|
|
62
|
+
AgentWidgetLauncher?: any;
|
|
35
63
|
}
|
|
36
64
|
}
|
|
37
65
|
|
|
@@ -111,6 +139,56 @@ declare global {
|
|
|
111
139
|
const windowConfig: SiteAgentInstallConfig = window.siteAgentConfig || {};
|
|
112
140
|
const config: SiteAgentInstallConfig = { ...windowConfig, ...scriptConfig };
|
|
113
141
|
|
|
142
|
+
// --- Lifecycle helpers -----------------------------------------------------
|
|
143
|
+
// A throwing user callback must never break the installer.
|
|
144
|
+
const safeCall = <T>(fn: ((arg: T) => void) | undefined, arg: T): void => {
|
|
145
|
+
try {
|
|
146
|
+
fn?.(arg);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error("[Persona] lifecycle callback threw:", e);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const dispatchLifecycle = (name: string, detail: unknown): void => {
|
|
152
|
+
try {
|
|
153
|
+
window.dispatchEvent(new CustomEvent(name, { detail }));
|
|
154
|
+
} catch {
|
|
155
|
+
/* CustomEvent unsupported — ignore */
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const fail = (phase: "css" | "bundle" | "init", error: unknown): void => {
|
|
159
|
+
console.error("Failed to install AgentWidget:", error);
|
|
160
|
+
safeCall(config.onError, { phase, error });
|
|
161
|
+
dispatchLifecycle("persona:error", { phase, error });
|
|
162
|
+
};
|
|
163
|
+
// `onReady` is the deprecated alias of `onChatReady`; warn once if it's used.
|
|
164
|
+
let warnedOnReadyDeprecated = false;
|
|
165
|
+
const resolveChatReady = (): ((handle: any) => void) | undefined => {
|
|
166
|
+
if (config.onChatReady) return config.onChatReady;
|
|
167
|
+
if (config.onReady) {
|
|
168
|
+
if (!warnedOnReadyDeprecated) {
|
|
169
|
+
warnedOnReadyDeprecated = true;
|
|
170
|
+
console.warn(
|
|
171
|
+
"[Persona] `onReady` is deprecated — use `onChatReady`. `onReady` still works but is removed in the next major."
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return config.onReady;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
};
|
|
178
|
+
// True when the config renders a standard floating launcher button — the only
|
|
179
|
+
// case that paints a clickable launcher at load. Shared by the deferral gate
|
|
180
|
+
// and the eager-path `onLauncherShown` so the event name stays honest.
|
|
181
|
+
const hasFloatingLauncher = (widgetConfig: any): boolean => {
|
|
182
|
+
const launcher = widgetConfig.launcher ?? {};
|
|
183
|
+
if (launcher.enabled === false) return false; // inline embed
|
|
184
|
+
return (launcher.mountMode ?? "floating") === "floating"; // not docked / composer-bar
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Earliest signal: the installer has executed. Fire before any loading or
|
|
188
|
+
// preview-gating so it's a reliable "did my embed run" beacon for diagnostics.
|
|
189
|
+
safeCall(config.onScriptLoad, { version: config.version || "latest" });
|
|
190
|
+
dispatchLifecycle("persona:script-load", { version: config.version || "latest" });
|
|
191
|
+
|
|
114
192
|
const isPreviewModeEnabled = (): boolean => {
|
|
115
193
|
if (!config.previewQueryParam) {
|
|
116
194
|
return true;
|
|
@@ -131,27 +209,31 @@ declare global {
|
|
|
131
209
|
|
|
132
210
|
// Determine CDN base URL
|
|
133
211
|
const getCdnBase = () => {
|
|
212
|
+
// For a custom URL override, derive the sibling launcher URL when the
|
|
213
|
+
// override mirrors the dist layout (…/index.global.js → …/launcher.global.js)
|
|
214
|
+
// so self-hosted deployments still get the optimization. A non-standard
|
|
215
|
+
// jsUrl yields null → eager-load fallback.
|
|
134
216
|
if (config.cssUrl && config.jsUrl) {
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const packageName = "@runtypelabs/persona";
|
|
139
|
-
const basePath = `/npm/${packageName}@${version}/dist`;
|
|
140
|
-
|
|
141
|
-
if (cdn === "unpkg") {
|
|
142
|
-
return {
|
|
143
|
-
cssUrl: `https://unpkg.com${basePath}/widget.css`,
|
|
144
|
-
jsUrl: `https://unpkg.com${basePath}/index.global.js`
|
|
145
|
-
};
|
|
146
|
-
} else {
|
|
217
|
+
const derivedLauncherUrl = config.jsUrl.replace(/index\.global\.js($|\?)/, "launcher.global.js$1");
|
|
147
218
|
return {
|
|
148
|
-
cssUrl:
|
|
149
|
-
jsUrl:
|
|
219
|
+
cssUrl: config.cssUrl,
|
|
220
|
+
jsUrl: config.jsUrl,
|
|
221
|
+
launcherUrl: (derivedLauncherUrl !== config.jsUrl ? derivedLauncherUrl : null) as string | null,
|
|
150
222
|
};
|
|
151
223
|
}
|
|
224
|
+
|
|
225
|
+
const packageName = "@runtypelabs/persona";
|
|
226
|
+
const basePath = `/npm/${packageName}@${version}/dist`;
|
|
227
|
+
const host = cdn === "unpkg" ? "https://unpkg.com" : "https://cdn.jsdelivr.net";
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
cssUrl: `${host}${basePath}/widget.css`,
|
|
231
|
+
jsUrl: `${host}${basePath}/index.global.js`,
|
|
232
|
+
launcherUrl: `${host}${basePath}/launcher.global.js` as string | null,
|
|
233
|
+
};
|
|
152
234
|
};
|
|
153
235
|
|
|
154
|
-
const { cssUrl, jsUrl } = getCdnBase();
|
|
236
|
+
const { cssUrl, jsUrl, launcherUrl } = getCdnBase();
|
|
155
237
|
|
|
156
238
|
// Check if CSS is already loaded
|
|
157
239
|
const isCssLoaded = () => {
|
|
@@ -238,41 +320,76 @@ declare global {
|
|
|
238
320
|
});
|
|
239
321
|
};
|
|
240
322
|
|
|
241
|
-
//
|
|
242
|
-
const
|
|
323
|
+
// Load the tiny launcher-only critical bundle (launcher.global.js)
|
|
324
|
+
const isLauncherLoaded = () => !!window.AgentWidgetLauncher;
|
|
325
|
+
|
|
326
|
+
const loadLauncher = (): Promise<void> => {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
if (isLauncherLoaded() || !launcherUrl) {
|
|
329
|
+
resolve();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const script = document.createElement("script");
|
|
334
|
+
script.src = launcherUrl;
|
|
335
|
+
script.async = true;
|
|
336
|
+
script.onload = () => resolve();
|
|
337
|
+
script.onerror = () => reject(new Error(`Failed to load launcher from ${launcherUrl}`));
|
|
338
|
+
document.head.appendChild(script);
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Warm the full bundle in the background (download-only, not executed) so the
|
|
343
|
+
// first open is quick. Runs at idle so it never competes with the launcher.
|
|
344
|
+
const prefetchFullBundle = (): void => {
|
|
345
|
+
const addPrefetch = () => {
|
|
346
|
+
if (isJsLoaded()) return;
|
|
347
|
+
const link = document.createElement("link");
|
|
348
|
+
link.rel = "prefetch";
|
|
349
|
+
link.as = "script";
|
|
350
|
+
link.href = jsUrl;
|
|
351
|
+
document.head.appendChild(link);
|
|
352
|
+
};
|
|
353
|
+
if (typeof requestIdleCallback !== "undefined") {
|
|
354
|
+
requestIdleCallback(addPrefetch, { timeout: 4000 });
|
|
355
|
+
} else {
|
|
356
|
+
setTimeout(addPrefetch, 1200);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Merge top-level installer options into the widget config. Shared by the
|
|
361
|
+
// eager init path and the deferred-launcher path.
|
|
362
|
+
const buildWidgetInit = (): { target: string | HTMLElement; widgetConfig: any; hasApiConfig: boolean } => {
|
|
363
|
+
const target = config.target || "body";
|
|
364
|
+
const widgetConfig: any = { ...config.config };
|
|
365
|
+
|
|
366
|
+
if (config.apiUrl && !widgetConfig.apiUrl) widgetConfig.apiUrl = config.apiUrl;
|
|
367
|
+
if (config.clientToken && !widgetConfig.clientToken) widgetConfig.clientToken = config.clientToken;
|
|
368
|
+
if (config.flowId && !widgetConfig.flowId) widgetConfig.flowId = config.flowId;
|
|
369
|
+
|
|
370
|
+
const hasApiConfig = !!(widgetConfig.apiUrl || widgetConfig.clientToken);
|
|
371
|
+
return { target, widgetConfig, hasApiConfig };
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Initialize the full widget. When `openAfter` is true (the deferred-launcher
|
|
375
|
+
// handoff), open the panel immediately via the public controller API so the
|
|
376
|
+
// user's click on the critical launcher carries through.
|
|
377
|
+
const initWidget = (openAfter = false): any => {
|
|
243
378
|
if (!window.AgentWidget || !window.AgentWidget.initAgentWidget) {
|
|
244
379
|
console.warn("AgentWidget not available. Make sure the script loaded successfully.");
|
|
245
380
|
return;
|
|
246
381
|
}
|
|
247
382
|
|
|
248
|
-
const target
|
|
249
|
-
// Merge top-level config options into widget config
|
|
250
|
-
const widgetConfig = { ...config.config };
|
|
251
|
-
|
|
252
|
-
// Merge apiUrl from top-level config into widget config if present
|
|
253
|
-
if (config.apiUrl && !widgetConfig.apiUrl) {
|
|
254
|
-
widgetConfig.apiUrl = config.apiUrl;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Merge clientToken from top-level config into widget config if present
|
|
258
|
-
if (config.clientToken && !widgetConfig.clientToken) {
|
|
259
|
-
widgetConfig.clientToken = config.clientToken;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Merge flowId from top-level config into widget config if present
|
|
263
|
-
if (config.flowId && !widgetConfig.flowId) {
|
|
264
|
-
widgetConfig.flowId = config.flowId;
|
|
265
|
-
}
|
|
383
|
+
const { target, widgetConfig, hasApiConfig } = buildWidgetInit();
|
|
266
384
|
|
|
267
385
|
// Only initialize if we have either apiUrl OR clientToken (or other config)
|
|
268
|
-
const hasApiConfig = widgetConfig.apiUrl || widgetConfig.clientToken;
|
|
269
386
|
if (!hasApiConfig && Object.keys(widgetConfig).length === 0) {
|
|
270
387
|
return;
|
|
271
388
|
}
|
|
272
389
|
|
|
273
390
|
// Auto-apply markdown postprocessor if not explicitly set and available
|
|
274
391
|
if (!widgetConfig.postprocessMessage && window.AgentWidget.markdownPostprocessor) {
|
|
275
|
-
widgetConfig.postprocessMessage = ({ text }: { text: string }) =>
|
|
392
|
+
widgetConfig.postprocessMessage = ({ text }: { text: string }) =>
|
|
276
393
|
window.AgentWidget.markdownPostprocessor(text);
|
|
277
394
|
}
|
|
278
395
|
|
|
@@ -285,32 +402,149 @@ declare global {
|
|
|
285
402
|
windowKey: config.windowKey
|
|
286
403
|
});
|
|
287
404
|
|
|
288
|
-
|
|
289
|
-
|
|
405
|
+
// Handoff from the critical launcher: the user already clicked, so open
|
|
406
|
+
// the panel via the existing public controller method.
|
|
407
|
+
if (openAfter && handle && typeof handle.open === "function") {
|
|
408
|
+
handle.open();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Eager floating installs paint their launcher at load time too — emit the
|
|
412
|
+
// same page-load "appeared" signal as the deferred path. The deferred
|
|
413
|
+
// handoff (openAfter) already fired it at launcher mount, and non-floating
|
|
414
|
+
// modes have no launcher, so guard on both.
|
|
415
|
+
if (!openAfter && hasFloatingLauncher(widgetConfig)) {
|
|
416
|
+
safeCall(config.onLauncherShown, { deferred: false });
|
|
417
|
+
dispatchLifecycle("persona:launcher-shown", { deferred: false });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The full widget is initialized and its controller API is callable.
|
|
421
|
+
safeCall(resolveChatReady(), handle);
|
|
422
|
+
dispatchLifecycle("persona:chat-ready", handle);
|
|
423
|
+
dispatchLifecycle("persona:ready", handle); // deprecated alias — removed next major
|
|
424
|
+
return handle;
|
|
290
425
|
} catch (error) {
|
|
291
|
-
|
|
426
|
+
fail("init", error);
|
|
292
427
|
}
|
|
293
428
|
};
|
|
294
429
|
|
|
430
|
+
// A persisted "open" state reopens the panel on reload with no click
|
|
431
|
+
// (ui.ts:7513). The installer can't see that from config, so it reads the very
|
|
432
|
+
// same storage key the widget writes. Mirrors normalizePersistStateConfig
|
|
433
|
+
// (ui.ts:213): openState persistence defaults on, storage to sessionStorage,
|
|
434
|
+
// key prefix to "persona-".
|
|
435
|
+
const hasPersistedOpenState = (widgetConfig: any): boolean => {
|
|
436
|
+
const persistState = widgetConfig.persistState;
|
|
437
|
+
if (!persistState) return false; // persistence off → nothing to restore
|
|
438
|
+
const asObject = typeof persistState === "object" ? persistState : null;
|
|
439
|
+
if (asObject && asObject.persist?.openState === false) return false; // open-state persistence opted out
|
|
440
|
+
const keyPrefix = (asObject && asObject.keyPrefix) || "persona-";
|
|
441
|
+
const storageType = (asObject && asObject.storage) || "session";
|
|
442
|
+
try {
|
|
443
|
+
const storage = storageType === "local" ? window.localStorage : window.sessionStorage;
|
|
444
|
+
return storage.getItem(`${keyPrefix}widget-open`) === "true";
|
|
445
|
+
} catch {
|
|
446
|
+
return false; // storage blocked (private mode) → the widget can't restore either
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// The deferred-launcher optimization only applies to the common floating case
|
|
451
|
+
// that paints a collapsed launcher and waits for a click. Anything that starts
|
|
452
|
+
// open or renders differently eager-loads the full bundle exactly as before —
|
|
453
|
+
// including the two open triggers config alone can't express: a host
|
|
454
|
+
// onStateLoaded hook that may request open, and a restored "was open" state.
|
|
455
|
+
const shouldDeferPanel = (widgetConfig: any): boolean => {
|
|
456
|
+
if (!launcherUrl) return false; // custom bundle URL override — can't derive launcher URL
|
|
457
|
+
if (!hasFloatingLauncher(widgetConfig)) return false; // inline / docked / composer-bar
|
|
458
|
+
const launcher = widgetConfig.launcher ?? {};
|
|
459
|
+
if (launcher.autoExpand === true) return false; // starts open
|
|
460
|
+
if (typeof widgetConfig.onStateLoaded === "function") return false; // hook may request open
|
|
461
|
+
if (hasPersistedOpenState(widgetConfig)) return false; // restored "was open"
|
|
462
|
+
return true;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// Render the real launcher from the tiny critical bundle; load + mount the
|
|
466
|
+
// full widget on first click, then remove the critical launcher.
|
|
467
|
+
const mountDeferredLauncher = (target: string | HTMLElement, widgetConfig: any): void => {
|
|
468
|
+
let phase: "idle" | "loading" | "done" = "idle";
|
|
469
|
+
let launcherHandle: { destroy: () => void } | undefined;
|
|
470
|
+
|
|
471
|
+
const onOpen = () => {
|
|
472
|
+
if (phase !== "idle") return; // already loading or handed off
|
|
473
|
+
phase = "loading";
|
|
474
|
+
loadJS()
|
|
475
|
+
.then(() => {
|
|
476
|
+
initWidget(true); // mount the full widget + open the panel
|
|
477
|
+
launcherHandle?.destroy(); // remove the critical launcher (same component → invisible)
|
|
478
|
+
phase = "done";
|
|
479
|
+
})
|
|
480
|
+
.catch((error) => {
|
|
481
|
+
phase = "idle"; // allow the click to be retried
|
|
482
|
+
console.error("Failed to load AgentWidget on open:", error);
|
|
483
|
+
safeCall(config.onError, { phase: "bundle", error });
|
|
484
|
+
dispatchLifecycle("persona:error", { phase: "bundle", error });
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const mounted = window.AgentWidgetLauncher.mount({ target, config: widgetConfig, onOpen });
|
|
489
|
+
launcherHandle = mounted;
|
|
490
|
+
|
|
491
|
+
// The real launcher is now painted at page-load time — emit the page-load
|
|
492
|
+
// "appeared" signal (distinct from `onChatReady`, which waits for first open).
|
|
493
|
+
safeCall(config.onLauncherShown, { deferred: true, element: mounted.element });
|
|
494
|
+
dispatchLifecycle("persona:launcher-shown", { deferred: true, element: mounted.element });
|
|
495
|
+
|
|
496
|
+
// Warm the full bundle so the first open is quick.
|
|
497
|
+
prefetchFullBundle();
|
|
498
|
+
};
|
|
499
|
+
|
|
295
500
|
// Main installation flow (called after hydration completes)
|
|
296
501
|
const install = async () => {
|
|
297
502
|
try {
|
|
298
|
-
await loadCSS();
|
|
299
|
-
await loadJS();
|
|
300
|
-
|
|
301
503
|
// Auto-init if we have config OR apiUrl OR clientToken
|
|
302
504
|
const shouldAutoInit = autoInit && (
|
|
303
|
-
config.config ||
|
|
304
|
-
config.apiUrl ||
|
|
505
|
+
config.config ||
|
|
506
|
+
config.apiUrl ||
|
|
305
507
|
config.clientToken
|
|
306
508
|
);
|
|
307
|
-
|
|
509
|
+
|
|
510
|
+
// Fast path: render the real launcher from the ~13 KB critical bundle and
|
|
511
|
+
// defer the full widget until first open. Only for the common floating
|
|
512
|
+
// case; everything else falls through to the eager path below.
|
|
513
|
+
if (shouldAutoInit) {
|
|
514
|
+
const { target, widgetConfig } = buildWidgetInit();
|
|
515
|
+
if (shouldDeferPanel(widgetConfig)) {
|
|
516
|
+
try {
|
|
517
|
+
// CSS + launcher in parallel so the launcher paints correctly styled.
|
|
518
|
+
await Promise.all([loadCSS(), loadLauncher()]);
|
|
519
|
+
if (window.AgentWidgetLauncher && window.AgentWidgetLauncher.mount) {
|
|
520
|
+
mountDeferredLauncher(target, widgetConfig);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
} catch (error) {
|
|
524
|
+
console.warn("Deferred launcher failed; falling back to eager load.", error);
|
|
525
|
+
}
|
|
526
|
+
// Fall through to the eager path on any failure.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Eager path (unchanged behavior): load the full bundle, then init.
|
|
531
|
+
try {
|
|
532
|
+
await loadCSS();
|
|
533
|
+
} catch (error) {
|
|
534
|
+
return fail("css", error);
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
await loadJS();
|
|
538
|
+
} catch (error) {
|
|
539
|
+
return fail("bundle", error);
|
|
540
|
+
}
|
|
308
541
|
if (shouldAutoInit) {
|
|
309
542
|
// Wait a tick to ensure AgentWidget is fully initialized
|
|
310
|
-
setTimeout(initWidget, 0);
|
|
543
|
+
setTimeout(() => initWidget(false), 0);
|
|
311
544
|
}
|
|
312
545
|
} catch (error) {
|
|
313
|
-
|
|
546
|
+
// Safety net for anything unexpected before the eager loads above.
|
|
547
|
+
fail("init", error);
|
|
314
548
|
}
|
|
315
549
|
};
|
|
316
550
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critical-path launcher entry — built to `launcher.global.js` (IIFE).
|
|
3
|
+
*
|
|
4
|
+
* Ships ONLY the real collapsed launcher (`createLauncherButton`) plus the full
|
|
5
|
+
* theme system, so the launcher paints pixel-identically to the full widget from
|
|
6
|
+
* a tiny bundle (~13 KB brotli vs ~134 KB for `index.global.js`). The heavy
|
|
7
|
+
* conversation panel is deferred until first open by the installer (Phase 2).
|
|
8
|
+
*
|
|
9
|
+
* The full Lucide icon registry is kept on purpose: any *supported* icon a site
|
|
10
|
+
* configures (`launcher.agentIconName` / `callToActionIconName`) must render at
|
|
11
|
+
* first paint with no flash, and the only synchronously-available source is this
|
|
12
|
+
* bundle. See the "Deferred Launcher Loading" pattern in CLAUDE.md.
|
|
13
|
+
*
|
|
14
|
+
* Public global (via tsup `--global-name AgentWidgetLauncher`):
|
|
15
|
+
*
|
|
16
|
+
* window.AgentWidgetLauncher.mount({ target, config, onOpen })
|
|
17
|
+
* → { root, element, update, destroy }
|
|
18
|
+
*/
|
|
19
|
+
import { createLauncherButton } from "./components/launcher";
|
|
20
|
+
import { applyThemeVariables } from "./utils/theme";
|
|
21
|
+
import { mergeWithDefaults } from "./defaults";
|
|
22
|
+
import type { AgentWidgetConfig } from "./types";
|
|
23
|
+
|
|
24
|
+
export interface AgentWidgetLauncherMountOptions {
|
|
25
|
+
/** Where to mount. Defaults to `document.body` (the floating launcher is `position: fixed`). */
|
|
26
|
+
target?: string | HTMLElement;
|
|
27
|
+
/** The same widget config the full widget will receive — drives theme, icons, position, copy. */
|
|
28
|
+
config?: AgentWidgetConfig;
|
|
29
|
+
/** Called when the launcher is clicked; the installer loads the full widget and opens the panel. */
|
|
30
|
+
onOpen: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AgentWidgetLauncherHandle {
|
|
34
|
+
/** The `[data-persona-root]` wrapper that carries the theme CSS variables. */
|
|
35
|
+
root: HTMLElement;
|
|
36
|
+
/** The launcher button element itself. */
|
|
37
|
+
element: HTMLButtonElement;
|
|
38
|
+
/** Re-apply theme + re-render the launcher with new config. */
|
|
39
|
+
update: (config: AgentWidgetConfig) => void;
|
|
40
|
+
/** Remove the critical launcher (called at handoff once the full widget is mounted). */
|
|
41
|
+
destroy: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Marks the critical launcher's wrapper so the installer can find/remove it at
|
|
46
|
+
* handoff without disturbing the full widget's own `[data-persona-root]`.
|
|
47
|
+
*/
|
|
48
|
+
export const CRITICAL_LAUNCHER_ATTR = "data-persona-launcher-critical";
|
|
49
|
+
|
|
50
|
+
const resolveTarget = (target?: string | HTMLElement): HTMLElement => {
|
|
51
|
+
if (target instanceof HTMLElement) return target;
|
|
52
|
+
if (typeof target === "string") {
|
|
53
|
+
const el = document.querySelector<HTMLElement>(target);
|
|
54
|
+
if (el) return el;
|
|
55
|
+
}
|
|
56
|
+
return document.body;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mount the real collapsed launcher from the critical bundle.
|
|
61
|
+
*
|
|
62
|
+
* Mirrors the full widget's DOM exactly (`runtime/init.ts` + `ui.ts`): a
|
|
63
|
+
* `[data-persona-root]` wrapper carries the theme CSS variables and the launcher
|
|
64
|
+
* button is its child. Keeping the theme vars on the wrapper (not the button)
|
|
65
|
+
* leaves the button's own inline style byte-identical to the full widget's, so
|
|
66
|
+
* the eventual mount-then-remove handoff is invisible.
|
|
67
|
+
*/
|
|
68
|
+
export const mount = (
|
|
69
|
+
options: AgentWidgetLauncherMountOptions
|
|
70
|
+
): AgentWidgetLauncherHandle => {
|
|
71
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"AgentWidgetLauncher can only be mounted in a browser environment"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { onOpen } = options;
|
|
78
|
+
const target = resolveTarget(options.target);
|
|
79
|
+
|
|
80
|
+
// Render from the SAME effective config the full widget uses: `ui.ts` runs
|
|
81
|
+
// `mergeWithDefaults(config)` before building the launcher (ui.ts:495). Without
|
|
82
|
+
// this, the critical launcher misses launcher defaults like
|
|
83
|
+
// `callToActionIconPadding` and `agentIconName`, so it looks subtly different
|
|
84
|
+
// from the full widget that replaces it on open.
|
|
85
|
+
const config = mergeWithDefaults(options.config) as AgentWidgetConfig;
|
|
86
|
+
|
|
87
|
+
const root = document.createElement("div");
|
|
88
|
+
root.setAttribute("data-persona-root", "true");
|
|
89
|
+
root.setAttribute(CRITICAL_LAUNCHER_ATTR, "true");
|
|
90
|
+
applyThemeVariables(root, config);
|
|
91
|
+
|
|
92
|
+
const launcher = createLauncherButton(config, onOpen);
|
|
93
|
+
root.appendChild(launcher.element);
|
|
94
|
+
target.appendChild(root);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
root,
|
|
98
|
+
element: launcher.element,
|
|
99
|
+
update: (next: AgentWidgetConfig) => {
|
|
100
|
+
const merged = mergeWithDefaults(next) as AgentWidgetConfig;
|
|
101
|
+
applyThemeVariables(root, merged);
|
|
102
|
+
launcher.update(merged);
|
|
103
|
+
},
|
|
104
|
+
destroy: () => {
|
|
105
|
+
launcher.destroy();
|
|
106
|
+
root.remove();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Note: the `window.AgentWidgetLauncher` global is created by tsup's
|
|
112
|
+
// `--global-name AgentWidgetLauncher` at build time. The installer
|
|
113
|
+
// (`install.ts`) declares the `Window` augmentation it needs; this entry never
|
|
114
|
+
// reads the global, so re-declaring it here would only create a cross-file type
|
|
115
|
+
// conflict.
|
package/src/runtime/init.test.ts
CHANGED
|
@@ -159,6 +159,77 @@ describe("initAgentWidget windowKey and ready notifications", () => {
|
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
describe("initAgentWidget onChatReady / onReady deprecation", () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
document.body.innerHTML = "";
|
|
165
|
+
createAgentExperienceMock.mockReset();
|
|
166
|
+
createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("calls onChatReady after initialization without a deprecation warning", async () => {
|
|
170
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
171
|
+
const { initAgentWidget } = await import("./init");
|
|
172
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
173
|
+
|
|
174
|
+
const onChatReady = vi.fn();
|
|
175
|
+
const handle = initAgentWidget({
|
|
176
|
+
target: "#target",
|
|
177
|
+
onChatReady,
|
|
178
|
+
config: { launcher: { enabled: false } },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(onChatReady).toHaveBeenCalledOnce();
|
|
182
|
+
expect(warn).not.toHaveBeenCalled();
|
|
183
|
+
|
|
184
|
+
handle.destroy();
|
|
185
|
+
warn.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("prefers onChatReady over the deprecated onReady (onReady never fires, no warning)", async () => {
|
|
189
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
190
|
+
const { initAgentWidget } = await import("./init");
|
|
191
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
192
|
+
|
|
193
|
+
const onChatReady = vi.fn();
|
|
194
|
+
const onReady = vi.fn();
|
|
195
|
+
const handle = initAgentWidget({
|
|
196
|
+
target: "#target",
|
|
197
|
+
onChatReady,
|
|
198
|
+
onReady,
|
|
199
|
+
config: { launcher: { enabled: false } },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(onChatReady).toHaveBeenCalledOnce();
|
|
203
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
204
|
+
expect(warn).not.toHaveBeenCalled();
|
|
205
|
+
|
|
206
|
+
handle.destroy();
|
|
207
|
+
warn.mockRestore();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("still calls the deprecated onReady but warns at most once across multiple widgets", async () => {
|
|
211
|
+
// Fresh module so the once-per-page warn flag starts unset.
|
|
212
|
+
vi.resetModules();
|
|
213
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
214
|
+
const { initAgentWidget } = await import("./init");
|
|
215
|
+
document.body.innerHTML = `<div id="a"></div><div id="b"></div>`;
|
|
216
|
+
|
|
217
|
+
const onReadyA = vi.fn();
|
|
218
|
+
const onReadyB = vi.fn();
|
|
219
|
+
const a = initAgentWidget({ target: "#a", onReady: onReadyA, config: { launcher: { enabled: false } } });
|
|
220
|
+
const b = initAgentWidget({ target: "#b", onReady: onReadyB, config: { launcher: { enabled: false } } });
|
|
221
|
+
|
|
222
|
+
expect(onReadyA).toHaveBeenCalledOnce();
|
|
223
|
+
expect(onReadyB).toHaveBeenCalledOnce();
|
|
224
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(String(warn.mock.calls[0]?.[0])).toContain("`onReady` is deprecated");
|
|
226
|
+
|
|
227
|
+
a.destroy();
|
|
228
|
+
b.destroy();
|
|
229
|
+
warn.mockRestore();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
162
233
|
describe("install script onReady and persona:ready event", () => {
|
|
163
234
|
beforeEach(() => {
|
|
164
235
|
document.body.innerHTML = "";
|
package/src/runtime/init.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentW
|
|
|
3
3
|
import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
|
|
4
4
|
import { createWidgetHostLayout } from "./host-layout";
|
|
5
5
|
|
|
6
|
+
// Warn at most once per page when the deprecated `onReady` alias is used.
|
|
7
|
+
let warnedOnReadyDeprecated = false;
|
|
8
|
+
|
|
6
9
|
const ensureTarget = (target: string | HTMLElement): HTMLElement => {
|
|
7
10
|
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
8
11
|
throw new Error("Chat widget can only be mounted in a browser environment");
|
|
@@ -157,7 +160,20 @@ export const initAgentWidget = (
|
|
|
157
160
|
};
|
|
158
161
|
|
|
159
162
|
mountController();
|
|
160
|
-
|
|
163
|
+
// Fired when the controller is mounted and its API is callable. `onReady` is
|
|
164
|
+
// the deprecated alias of `onChatReady` (removed next major) — warn once.
|
|
165
|
+
if (options.onChatReady) {
|
|
166
|
+
options.onChatReady();
|
|
167
|
+
} else if (options.onReady) {
|
|
168
|
+
if (!warnedOnReadyDeprecated) {
|
|
169
|
+
warnedOnReadyDeprecated = true;
|
|
170
|
+
// eslint-disable-next-line no-console
|
|
171
|
+
console.warn(
|
|
172
|
+
"[Persona] `onReady` is deprecated — use `onChatReady`. `onReady` still works but is removed in the next major."
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
options.onReady();
|
|
176
|
+
}
|
|
161
177
|
|
|
162
178
|
const rebuildLayout = (nextConfig?: _AgentWidgetConfig) => {
|
|
163
179
|
destroyCurrentController();
|