@runtypelabs/persona 4.11.0 → 4.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen.cjs +13 -13
- package/dist/codegen.d.cts +9 -0
- package/dist/codegen.d.ts +9 -0
- package/dist/codegen.js +13 -13
- package/dist/context-mentions.d.cts +2 -0
- package/dist/context-mentions.d.ts +2 -0
- package/dist/index.cjs +52 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.global.js +26 -26
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +53 -53
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +2 -0
- package/dist/smart-dom-reader.d.ts +2 -0
- package/dist/theme-editor-preview.cjs +37 -37
- package/dist/theme-editor-preview.d.cts +2 -0
- package/dist/theme-editor-preview.d.ts +2 -0
- package/dist/theme-editor-preview.js +26 -26
- package/dist/theme-editor.d.cts +2 -0
- package/dist/theme-editor.d.ts +2 -0
- package/package.json +1 -1
- package/src/codegen.test.ts +43 -0
- package/src/install.test.ts +83 -0
- package/src/install.ts +48 -2
- package/src/types.ts +2 -0
- package/src/ui.tool-display.test.ts +177 -8
- package/src/ui.ts +15 -7
- package/src/utils/code-generators.ts +30 -4
package/dist/theme-editor.d.cts
CHANGED
|
@@ -4447,6 +4447,8 @@ type AgentWidgetToolCallConfig$1 = {
|
|
|
4447
4447
|
}) => HTMLElement | string | null;
|
|
4448
4448
|
/**
|
|
4449
4449
|
* Override the summary content for grouped consecutive tool-call containers.
|
|
4450
|
+
* In `groupedMode: "summary"`, the summary owns the visible row starting
|
|
4451
|
+
* with the first tool so its DOM stays stable as more calls arrive.
|
|
4450
4452
|
* Return `null` to fall back to the built-in `Called [x] tools` summary.
|
|
4451
4453
|
*/
|
|
4452
4454
|
renderGroupedSummary?: (context: {
|
package/dist/theme-editor.d.ts
CHANGED
|
@@ -4447,6 +4447,8 @@ type AgentWidgetToolCallConfig$1 = {
|
|
|
4447
4447
|
}) => HTMLElement | string | null;
|
|
4448
4448
|
/**
|
|
4449
4449
|
* Override the summary content for grouped consecutive tool-call containers.
|
|
4450
|
+
* In `groupedMode: "summary"`, the summary owns the visible row starting
|
|
4451
|
+
* with the first tool so its DOM stays stable as more calls arrive.
|
|
4450
4452
|
* Return `null` to fall back to the built-in `Called [x] tools` summary.
|
|
4451
4453
|
*/
|
|
4452
4454
|
renderGroupedSummary?: (context: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.12.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
package/src/codegen.test.ts
CHANGED
|
@@ -23,6 +23,49 @@ describe("codegen subpath", () => {
|
|
|
23
23
|
expect(code).not.toContain("@latest");
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
describe("cdnBase option", () => {
|
|
27
|
+
const cdnBase = "https://cdn.runtype.com/persona/latest";
|
|
28
|
+
|
|
29
|
+
it("emits the custom base in every script format", () => {
|
|
30
|
+
expect(fromSubpath(config, "script-installer", { cdnBase })).toContain(
|
|
31
|
+
`src="${cdnBase}/install.global.js"`
|
|
32
|
+
);
|
|
33
|
+
const manual = fromSubpath(config, "script-manual", { cdnBase });
|
|
34
|
+
expect(manual).toContain(`href="${cdnBase}/widget.css"`);
|
|
35
|
+
expect(manual).toContain(`src="${cdnBase}/index.global.js"`);
|
|
36
|
+
expect(fromSubpath(config, "script-advanced", { cdnBase })).toContain(
|
|
37
|
+
`var CDN_BASE = '${cdnBase}';`
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("tolerates a trailing slash", () => {
|
|
42
|
+
const code = fromSubpath(config, "script-installer", { cdnBase: `${cdnBase}/` });
|
|
43
|
+
expect(code).toContain(`src="${cdnBase}/install.global.js"`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("percent-encodes characters that could break the emitted JS/HTML contexts", () => {
|
|
47
|
+
const hostile = "https://cdn.example.com/x'\"\\`/persona";
|
|
48
|
+
const encoded = "https://cdn.example.com/x%27%22%5C%60/persona";
|
|
49
|
+
// script-advanced emits into a single-quoted JS literal
|
|
50
|
+
expect(fromSubpath(config, "script-advanced", { cdnBase: hostile })).toContain(
|
|
51
|
+
`var CDN_BASE = '${encoded}';`
|
|
52
|
+
);
|
|
53
|
+
// script-installer / script-manual emit into double-quoted HTML attributes
|
|
54
|
+
expect(fromSubpath(config, "script-installer", { cdnBase: hostile })).toContain(
|
|
55
|
+
`src="${encoded}/install.global.js"`
|
|
56
|
+
);
|
|
57
|
+
expect(fromSubpath(config, "script-manual", { cdnBase: hostile })).toContain(
|
|
58
|
+
`href="${encoded}/widget.css"`
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("leaves no jsDelivr URL behind when a custom base is set", () => {
|
|
63
|
+
for (const format of ["script-installer", "script-manual", "script-advanced"] as const) {
|
|
64
|
+
expect(fromSubpath(config, format, { cdnBase })).not.toContain("jsdelivr");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
26
69
|
describe("mount target option", () => {
|
|
27
70
|
it("defaults to body when no target is given", () => {
|
|
28
71
|
expect(fromSubpath(config, "react-component")).toContain("target: 'body'");
|
package/src/install.test.ts
CHANGED
|
@@ -343,6 +343,89 @@ describe("install.ts: deferral gate", () => {
|
|
|
343
343
|
});
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
+
describe("install.ts: CDN base derivation", () => {
|
|
347
|
+
// The installer captures document.currentScript.src synchronously at module
|
|
348
|
+
// evaluation; fake it with an own-property override (jsdom's getter lives on
|
|
349
|
+
// the prototype, so a configurable instance property shadows it cleanly).
|
|
350
|
+
function setCurrentScript(src: string) {
|
|
351
|
+
const script = document.createElement("script");
|
|
352
|
+
script.src = src;
|
|
353
|
+
Object.defineProperty(document, "currentScript", { value: script, configurable: true });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
afterEach(() => {
|
|
357
|
+
delete (document as any).currentScript;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run the deferred path with NO bundles provided so loadCSS/loadLauncher
|
|
362
|
+
* inject real (never-resolving in jsdom) elements whose URLs we can read.
|
|
363
|
+
*/
|
|
364
|
+
async function installedUrls(installerOptions: any) {
|
|
365
|
+
await install({ config: { apiUrl: "/api", launcher: { enabled: true } }, ...installerOptions });
|
|
366
|
+
await flush();
|
|
367
|
+
const css = document.head.querySelector("link[rel=\"stylesheet\"][data-persona]") as HTMLLinkElement | null;
|
|
368
|
+
const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
|
|
369
|
+
return { cssHref: css?.href ?? null, scripts };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
it("derives asset URLs from the installer's own script directory (first-party CDN)", async () => {
|
|
373
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
374
|
+
const { cssHref, scripts } = await installedUrls({});
|
|
375
|
+
expect(cssHref).toBe("https://cdn.runtype.com/persona/latest/widget.css");
|
|
376
|
+
expect(scripts).toContain("https://cdn.runtype.com/persona/latest/launcher.global.js");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("keeps npm-CDN siblings on the installer's own pinned range (no @latest skew)", async () => {
|
|
380
|
+
setCurrentScript("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/install.global.js");
|
|
381
|
+
const { cssHref, scripts } = await installedUrls({});
|
|
382
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/widget.css");
|
|
383
|
+
expect(scripts).toContain("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@4/dist/launcher.global.js");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("the full bundle URL follows the installer directory on the eager path", async () => {
|
|
387
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
388
|
+
markCssLoaded();
|
|
389
|
+
await install({ config: { apiUrl: "/api", launcher: { enabled: false } } });
|
|
390
|
+
await flush();
|
|
391
|
+
const scripts = Array.from(document.head.querySelectorAll("script")).map((s) => s.src);
|
|
392
|
+
expect(scripts).toContain("https://cdn.runtype.com/persona/latest/index.global.js");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("falls back to jsDelivr@latest when currentScript is unavailable", async () => {
|
|
396
|
+
// No setCurrentScript: module/inline contexts have no script src.
|
|
397
|
+
const { cssHref } = await installedUrls({});
|
|
398
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("falls back to the npm CDN for a non-http(s) installer source", async () => {
|
|
402
|
+
setCurrentScript("data:text/javascript,void%200");
|
|
403
|
+
const { cssHref } = await installedUrls({});
|
|
404
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@latest/dist/widget.css");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("an explicit `version` opts back into npm-CDN URLs even when the src is derivable", async () => {
|
|
408
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
409
|
+
const { cssHref } = await installedUrls({ version: "1.2.3" });
|
|
410
|
+
expect(cssHref).toBe("https://cdn.jsdelivr.net/npm/@runtypelabs/persona@1.2.3/dist/widget.css");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("an explicit `cdn` opts back into npm-CDN URLs even when the src is derivable", async () => {
|
|
414
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
415
|
+
const { cssHref } = await installedUrls({ cdn: "unpkg" });
|
|
416
|
+
expect(cssHref).toBe("https://unpkg.com/@runtypelabs/persona@latest/dist/widget.css");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("explicit cssUrl + jsUrl win over the derived installer base", async () => {
|
|
420
|
+
setCurrentScript("https://cdn.runtype.com/persona/latest/install.global.js");
|
|
421
|
+
const { cssHref } = await installedUrls({
|
|
422
|
+
cssUrl: "https://assets.example.com/persona/widget.css",
|
|
423
|
+
jsUrl: "https://assets.example.com/persona/index.global.js",
|
|
424
|
+
});
|
|
425
|
+
expect(cssHref).toBe("https://assets.example.com/persona/widget.css");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
346
429
|
describe("install.ts: onError", () => {
|
|
347
430
|
it("fires onError with phase 'init' and dispatches persona:error when initialization throws", async () => {
|
|
348
431
|
markCssLoaded();
|
package/src/install.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
export {};
|
|
8
8
|
|
|
9
9
|
interface SiteAgentInstallConfig {
|
|
10
|
+
// Setting `version` or `cdn` explicitly opts into npm-CDN asset URLs
|
|
11
|
+
// (jsDelivr/unpkg). When neither is set, assets load from the directory the
|
|
12
|
+
// installer script itself was served from — see getCdnBase().
|
|
10
13
|
version?: string;
|
|
11
14
|
cdn?: "unpkg" | "jsdelivr";
|
|
12
15
|
cssUrl?: string;
|
|
@@ -137,6 +140,10 @@ declare global {
|
|
|
137
140
|
// Get config from script attributes (must be called synchronously during script execution)
|
|
138
141
|
const scriptConfig = getConfigFromScript();
|
|
139
142
|
|
|
143
|
+
// The installer's own URL, captured now because document.currentScript is
|
|
144
|
+
// null once synchronous execution ends (and always inside modules).
|
|
145
|
+
const installerSrc = (document.currentScript as HTMLScriptElement | null)?.src || "";
|
|
146
|
+
|
|
140
147
|
// Merge script attributes with window config (script attributes take precedence)
|
|
141
148
|
const windowConfig: SiteAgentInstallConfig = window.siteAgentConfig || {};
|
|
142
149
|
const config: SiteAgentInstallConfig = { ...windowConfig, ...scriptConfig };
|
|
@@ -194,7 +201,28 @@ declare global {
|
|
|
194
201
|
const cdn = config.cdn || "jsdelivr";
|
|
195
202
|
const autoInit = config.autoInit !== false; // Default to true
|
|
196
203
|
|
|
197
|
-
//
|
|
204
|
+
// Directory the installer itself was served from. Every published layout
|
|
205
|
+
// (npm CDN dist/, first-party CDN, self-hosted copies) keeps the sibling
|
|
206
|
+
// assets next to install.global.js, so this base is valid wherever the
|
|
207
|
+
// installer loaded from — and always satisfies the same CSP/origin rules
|
|
208
|
+
// that let the installer through. Non-http(s) sources (inline, blob:) and
|
|
209
|
+
// module contexts (no currentScript) yield null.
|
|
210
|
+
const getInstallerBase = (): string | null => {
|
|
211
|
+
if (!installerSrc) return null;
|
|
212
|
+
try {
|
|
213
|
+
const url = new URL(installerSrc, document.baseURI);
|
|
214
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
215
|
+
return url.origin + url.pathname.slice(0, url.pathname.lastIndexOf("/"));
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Determine CDN base URL. Precedence:
|
|
222
|
+
// 1. explicit cssUrl + jsUrl
|
|
223
|
+
// 2. explicit `cdn` / `version` → npm CDN construction
|
|
224
|
+
// 3. the installer's own directory (default)
|
|
225
|
+
// 4. npm CDN construction (currentScript unavailable)
|
|
198
226
|
const getCdnBase = () => {
|
|
199
227
|
// For a custom URL override, derive the sibling launcher URL when the
|
|
200
228
|
// override mirrors the dist layout (…/index.global.js → …/launcher.global.js)
|
|
@@ -209,8 +237,26 @@ declare global {
|
|
|
209
237
|
};
|
|
210
238
|
}
|
|
211
239
|
|
|
240
|
+
// Default: load assets from wherever the installer was served, so a page
|
|
241
|
+
// whose CSP allows only its own CDN (e.g. cdn.runtype.com) never has
|
|
242
|
+
// sibling requests fan out to a third-party host. An explicit `cdn` or
|
|
243
|
+
// `version` keeps the documented npm-CDN behavior.
|
|
244
|
+
if (!config.cdn && !config.version) {
|
|
245
|
+
const selfBase = getInstallerBase();
|
|
246
|
+
if (selfBase) {
|
|
247
|
+
return {
|
|
248
|
+
cssUrl: `${selfBase}/widget.css`,
|
|
249
|
+
jsUrl: `${selfBase}/index.global.js`,
|
|
250
|
+
launcherUrl: `${selfBase}/launcher.global.js` as string | null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
212
255
|
const packageName = "@runtypelabs/persona";
|
|
213
|
-
|
|
256
|
+
// jsDelivr namespaces npm packages under /npm/; unpkg serves them at the root.
|
|
257
|
+
const basePath = cdn === "unpkg"
|
|
258
|
+
? `/${packageName}@${version}/dist`
|
|
259
|
+
: `/npm/${packageName}@${version}/dist`;
|
|
214
260
|
const host = cdn === "unpkg" ? "https://unpkg.com" : "https://cdn.jsdelivr.net";
|
|
215
261
|
|
|
216
262
|
return {
|
package/src/types.ts
CHANGED
|
@@ -3478,6 +3478,8 @@ export type AgentWidgetToolCallConfig = {
|
|
|
3478
3478
|
}) => HTMLElement | string | null;
|
|
3479
3479
|
/**
|
|
3480
3480
|
* Override the summary content for grouped consecutive tool-call containers.
|
|
3481
|
+
* In `groupedMode: "summary"`, the summary owns the visible row starting
|
|
3482
|
+
* with the first tool so its DOM stays stable as more calls arrive.
|
|
3481
3483
|
* Return `null` to fall back to the built-in `Called [x] tools` summary.
|
|
3482
3484
|
*/
|
|
3483
3485
|
renderGroupedSummary?: (context: {
|
|
@@ -31,7 +31,10 @@ const injectToolMessage = (
|
|
|
31
31
|
id,
|
|
32
32
|
role: "assistant",
|
|
33
33
|
content: "",
|
|
34
|
-
|
|
34
|
+
// Real stream updates reuse the original tool message's timeline fields.
|
|
35
|
+
// Keep them stable here so an upsert does not reorder the group.
|
|
36
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
37
|
+
sequence: Number(id.match(/(\d+)$/)?.[1] ?? 0),
|
|
35
38
|
streaming: status !== "complete",
|
|
36
39
|
variant: "tool",
|
|
37
40
|
toolCall: {
|
|
@@ -76,6 +79,7 @@ const injectReasoningMessage = (
|
|
|
76
79
|
|
|
77
80
|
describe("createAgentExperience tool call display modes", () => {
|
|
78
81
|
beforeEach(() => {
|
|
82
|
+
window.localStorage.clear();
|
|
79
83
|
vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
|
|
80
84
|
cb(0);
|
|
81
85
|
return 1;
|
|
@@ -157,6 +161,48 @@ describe("createAgentExperience tool call display modes", () => {
|
|
|
157
161
|
controller.destroy();
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
it("preserves a custom collapsed summary across tool chunk updates", () => {
|
|
165
|
+
const mount = createMount();
|
|
166
|
+
const controller = createAgentExperience(mount, {
|
|
167
|
+
apiUrl: "https://api.example.com/chat",
|
|
168
|
+
launcher: { enabled: false },
|
|
169
|
+
features: {
|
|
170
|
+
toolCallDisplay: {
|
|
171
|
+
grouped: false,
|
|
172
|
+
expandable: false,
|
|
173
|
+
loadingAnimation: "none",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
toolCall: {
|
|
177
|
+
renderCollapsedSummary: ({ toolCall }: any) => {
|
|
178
|
+
const summary = document.createElement("span");
|
|
179
|
+
summary.setAttribute("data-test-collapsed-summary", "true");
|
|
180
|
+
summary.textContent = `${toolCall.name}:${toolCall.status}`;
|
|
181
|
+
return summary;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
} as any);
|
|
185
|
+
|
|
186
|
+
injectToolMessage(controller, {
|
|
187
|
+
id: "tool-1",
|
|
188
|
+
name: "Run SQL",
|
|
189
|
+
chunks: ["starting"],
|
|
190
|
+
});
|
|
191
|
+
const bubble = mount.querySelector("#bubble-tool-1");
|
|
192
|
+
const summary = mount.querySelector("[data-test-collapsed-summary='true']");
|
|
193
|
+
|
|
194
|
+
injectToolMessage(controller, {
|
|
195
|
+
id: "tool-1",
|
|
196
|
+
name: "Run SQL",
|
|
197
|
+
chunks: ["starting", "received rows"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(mount.querySelector("#bubble-tool-1")).toBe(bubble);
|
|
201
|
+
expect(mount.querySelector("[data-test-collapsed-summary='true']")).toBe(summary);
|
|
202
|
+
|
|
203
|
+
controller.destroy();
|
|
204
|
+
});
|
|
205
|
+
|
|
160
206
|
it("renders a collapsed preview for active reasoning rows when enabled", () => {
|
|
161
207
|
const mount = createMount();
|
|
162
208
|
const controller = createAgentExperience(mount, {
|
|
@@ -202,6 +248,41 @@ describe("createAgentExperience tool call display modes", () => {
|
|
|
202
248
|
controller.destroy();
|
|
203
249
|
});
|
|
204
250
|
|
|
251
|
+
it("preserves tool rows when a standalone row becomes a stacked group", () => {
|
|
252
|
+
const mount = createMount();
|
|
253
|
+
const controller = createAgentExperience(mount, {
|
|
254
|
+
apiUrl: "https://api.example.com/chat",
|
|
255
|
+
launcher: { enabled: false },
|
|
256
|
+
features: {
|
|
257
|
+
toolCallDisplay: {
|
|
258
|
+
grouped: true,
|
|
259
|
+
groupedMode: "stack",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
} as any);
|
|
263
|
+
|
|
264
|
+
injectToolMessage(controller, { id: "tool-1", name: "Inspect data" });
|
|
265
|
+
|
|
266
|
+
const firstRow = mount.querySelector("#wrapper-tool-1");
|
|
267
|
+
const firstBubble = mount.querySelector("#bubble-tool-1");
|
|
268
|
+
expect(firstRow).not.toBeNull();
|
|
269
|
+
expect(firstBubble).not.toBeNull();
|
|
270
|
+
|
|
271
|
+
injectToolMessage(controller, { id: "tool-2", name: "Run analysis" });
|
|
272
|
+
|
|
273
|
+
const groupRow = mount.querySelector("[data-persona-tool-group-row='true']");
|
|
274
|
+
expect(groupRow).not.toBeNull();
|
|
275
|
+
expect(mount.querySelector("#wrapper-tool-1")).toBe(firstRow);
|
|
276
|
+
expect(mount.querySelector("#bubble-tool-1")).toBe(firstBubble);
|
|
277
|
+
|
|
278
|
+
injectToolMessage(controller, { id: "tool-3", name: "Build chart" });
|
|
279
|
+
expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
|
|
280
|
+
expect(mount.querySelector("#wrapper-tool-1")).toBe(firstRow);
|
|
281
|
+
expect(mount.querySelector("#bubble-tool-1")).toBe(firstBubble);
|
|
282
|
+
|
|
283
|
+
controller.destroy();
|
|
284
|
+
});
|
|
285
|
+
|
|
205
286
|
it("keeps tool calls grouped across reasoning that is hidden", () => {
|
|
206
287
|
const mount = createMount();
|
|
207
288
|
const controller = createAgentExperience(mount, {
|
|
@@ -226,30 +307,118 @@ describe("createAgentExperience tool call display modes", () => {
|
|
|
226
307
|
controller.destroy();
|
|
227
308
|
});
|
|
228
309
|
|
|
229
|
-
it("
|
|
310
|
+
it("keeps a summary group's visible DOM stable from the first tool onward", () => {
|
|
230
311
|
const mount = createMount();
|
|
312
|
+
const renderedSummaries: HTMLElement[] = [];
|
|
231
313
|
const controller = createAgentExperience(mount, {
|
|
232
314
|
apiUrl: "https://api.example.com/chat",
|
|
233
315
|
launcher: { enabled: false },
|
|
234
316
|
features: {
|
|
235
317
|
toolCallDisplay: {
|
|
318
|
+
collapsedMode: "tool-name",
|
|
319
|
+
activePreview: false,
|
|
236
320
|
grouped: true,
|
|
237
321
|
groupedMode: "summary",
|
|
322
|
+
expandable: false,
|
|
323
|
+
loadingAnimation: "shimmer-color",
|
|
238
324
|
},
|
|
239
325
|
},
|
|
240
326
|
toolCall: {
|
|
241
|
-
|
|
327
|
+
renderCollapsedSummary: ({ message, toolCall }: any) => {
|
|
328
|
+
const step = document.createElement("span");
|
|
329
|
+
step.setAttribute("data-test-collapsed-step", message.id);
|
|
330
|
+
step.textContent = `${toolCall.name}:${toolCall.status}`;
|
|
331
|
+
return step;
|
|
332
|
+
},
|
|
333
|
+
renderGroupedSummary: ({ toolCalls }: any) => {
|
|
334
|
+
const list = document.createElement("span");
|
|
335
|
+
list.setAttribute("data-test-activity-list", "true");
|
|
336
|
+
toolCalls.forEach((toolCall: { id: string; status: string }) => {
|
|
337
|
+
const step = document.createElement("span");
|
|
338
|
+
step.setAttribute("data-test-activity-step", toolCall.id);
|
|
339
|
+
step.textContent = `${toolCall.id}:${toolCall.status}`;
|
|
340
|
+
list.appendChild(step);
|
|
341
|
+
});
|
|
342
|
+
renderedSummaries.push(list);
|
|
343
|
+
return list;
|
|
344
|
+
},
|
|
242
345
|
},
|
|
243
346
|
} as any);
|
|
244
347
|
|
|
245
|
-
injectToolMessage(controller, {
|
|
246
|
-
|
|
348
|
+
injectToolMessage(controller, {
|
|
349
|
+
id: "tool-1",
|
|
350
|
+
name: "Inspect data",
|
|
351
|
+
status: "pending",
|
|
352
|
+
});
|
|
247
353
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
354
|
+
const groupRow = mount.querySelector("[data-persona-tool-group-row='true']");
|
|
355
|
+
const activityList = mount.querySelector("[data-test-activity-list='true']");
|
|
356
|
+
const firstStep = mount.querySelector("[data-test-activity-step='tool-1']");
|
|
357
|
+
expect(groupRow).not.toBeNull();
|
|
358
|
+
expect(groupRow?.getAttribute("data-wrapper-id")).toBe("tool-group-tool-1");
|
|
359
|
+
expect(activityList).not.toBeNull();
|
|
360
|
+
expect(firstStep).not.toBeNull();
|
|
251
361
|
expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
|
|
252
362
|
|
|
363
|
+
injectToolMessage(controller, {
|
|
364
|
+
id: "tool-1",
|
|
365
|
+
name: "Inspect data",
|
|
366
|
+
status: "running",
|
|
367
|
+
chunks: ["starting"],
|
|
368
|
+
});
|
|
369
|
+
expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
|
|
370
|
+
expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
|
|
371
|
+
expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
|
|
372
|
+
|
|
373
|
+
injectToolMessage(controller, {
|
|
374
|
+
id: "tool-1",
|
|
375
|
+
name: "Inspect data",
|
|
376
|
+
chunks: ["starting", "received rows"],
|
|
377
|
+
});
|
|
378
|
+
expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
|
|
379
|
+
expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
|
|
380
|
+
expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
|
|
381
|
+
|
|
382
|
+
injectToolMessage(controller, { id: "tool-2", name: "Run analysis" });
|
|
383
|
+
|
|
384
|
+
const secondStep = mount.querySelector("[data-test-activity-step='tool-2']");
|
|
385
|
+
expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
|
|
386
|
+
expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
|
|
387
|
+
expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
|
|
388
|
+
expect(secondStep).not.toBeNull();
|
|
389
|
+
expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
|
|
390
|
+
|
|
391
|
+
injectToolMessage(controller, {
|
|
392
|
+
id: "tool-1",
|
|
393
|
+
name: "Inspect data",
|
|
394
|
+
status: "complete",
|
|
395
|
+
chunks: ["starting", "received rows"],
|
|
396
|
+
});
|
|
397
|
+
expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
|
|
398
|
+
expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
|
|
399
|
+
expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
|
|
400
|
+
expect(mount.querySelector("[data-test-activity-step='tool-2']")).toBe(secondStep);
|
|
401
|
+
expect(firstStep?.textContent).toBe("tool-1:complete");
|
|
402
|
+
expect(secondStep?.textContent).toBe("tool-2:running");
|
|
403
|
+
|
|
404
|
+
injectToolMessage(controller, {
|
|
405
|
+
id: "tool-2",
|
|
406
|
+
name: "Run analysis",
|
|
407
|
+
status: "complete",
|
|
408
|
+
});
|
|
409
|
+
expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
|
|
410
|
+
expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
|
|
411
|
+
expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
|
|
412
|
+
expect(mount.querySelector("[data-test-activity-step='tool-2']")).toBe(secondStep);
|
|
413
|
+
expect(secondStep?.textContent).toBe("tool-2:complete");
|
|
414
|
+
expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
|
|
415
|
+
|
|
416
|
+
// The callback can return a fresh tree on every status update; idiomorph
|
|
417
|
+
// must reconcile it into the existing visible summary rather than mounting
|
|
418
|
+
// those detached callback results.
|
|
419
|
+
expect(renderedSummaries.length).toBeGreaterThan(1);
|
|
420
|
+
expect(renderedSummaries).not.toContain(activityList);
|
|
421
|
+
|
|
253
422
|
controller.destroy();
|
|
254
423
|
});
|
|
255
424
|
});
|
package/src/ui.ts
CHANGED
|
@@ -4817,6 +4817,8 @@ export const createAgentExperience = (
|
|
|
4817
4817
|
if (config.features?.toolCallDisplay?.grouped) {
|
|
4818
4818
|
const toolGroups: AgentWidgetMessage[][] = [];
|
|
4819
4819
|
let currentGroup: AgentWidgetMessage[] = [];
|
|
4820
|
+
const summaryOnly =
|
|
4821
|
+
config.features.toolCallDisplay.groupedMode === "summary";
|
|
4820
4822
|
|
|
4821
4823
|
messages.forEach((message) => {
|
|
4822
4824
|
if (message.variant === "tool" && message.toolCall && showToolCalls) {
|
|
@@ -4829,16 +4831,20 @@ export const createAgentExperience = (
|
|
|
4829
4831
|
if (message.variant === "reasoning" && !showReasoning) {
|
|
4830
4832
|
return;
|
|
4831
4833
|
}
|
|
4832
|
-
|
|
4834
|
+
// Summary mode owns the visible row from the first tool onward. Waiting
|
|
4835
|
+
// for a second tool would replace the standalone tool bubble with a new
|
|
4836
|
+
// group row, restarting custom activity animations and transiently
|
|
4837
|
+
// dropping interactive DOM state during the handoff.
|
|
4838
|
+
if (currentGroup.length > 1 || (summaryOnly && currentGroup.length > 0)) {
|
|
4833
4839
|
toolGroups.push(currentGroup);
|
|
4834
4840
|
}
|
|
4835
4841
|
currentGroup = [];
|
|
4836
4842
|
});
|
|
4837
|
-
if (currentGroup.length > 1) {
|
|
4843
|
+
if (currentGroup.length > 1 || (summaryOnly && currentGroup.length > 0)) {
|
|
4838
4844
|
toolGroups.push(currentGroup);
|
|
4839
4845
|
}
|
|
4840
4846
|
|
|
4841
|
-
toolGroups.forEach((group
|
|
4847
|
+
toolGroups.forEach((group) => {
|
|
4842
4848
|
const wrappers = group
|
|
4843
4849
|
.map((groupMessage) =>
|
|
4844
4850
|
Array.from(tempContainer.children).find(
|
|
@@ -4849,12 +4855,12 @@ export const createAgentExperience = (
|
|
|
4849
4855
|
)
|
|
4850
4856
|
.filter((wrapper): wrapper is HTMLElement => Boolean(wrapper));
|
|
4851
4857
|
|
|
4852
|
-
if (wrappers.length < 2) {
|
|
4858
|
+
if (wrappers.length < (summaryOnly ? 1 : 2)) {
|
|
4853
4859
|
return;
|
|
4854
4860
|
}
|
|
4855
4861
|
|
|
4856
4862
|
const groupWrapper = createMessageRow(
|
|
4857
|
-
`tool-group-${
|
|
4863
|
+
`tool-group-${group[0].id}`,
|
|
4858
4864
|
"assistant"
|
|
4859
4865
|
);
|
|
4860
4866
|
groupWrapper.setAttribute("data-persona-tool-group-row", "true");
|
|
@@ -4867,8 +4873,9 @@ export const createAgentExperience = (
|
|
|
4867
4873
|
const summary = document.createElement("div");
|
|
4868
4874
|
summary.className =
|
|
4869
4875
|
"persona-tool-group-summary persona-text-xs persona-text-persona-muted";
|
|
4876
|
+
summary.id = `tool-group-summary-${group[0].id}`;
|
|
4870
4877
|
|
|
4871
|
-
const defaultSummary = `Called ${group.length} tools`;
|
|
4878
|
+
const defaultSummary = `Called ${group.length} ${group.length === 1 ? "tool" : "tools"}`;
|
|
4872
4879
|
const renderedSummary = config.toolCall?.renderGroupedSummary?.({
|
|
4873
4880
|
messages: group,
|
|
4874
4881
|
toolCalls: group
|
|
@@ -4883,8 +4890,9 @@ export const createAgentExperience = (
|
|
|
4883
4890
|
|
|
4884
4891
|
const stack = document.createElement("div");
|
|
4885
4892
|
stack.className = "persona-tool-group-stack persona-flex persona-flex-col";
|
|
4893
|
+
stack.id = `tool-group-stack-${group[0].id}`;
|
|
4894
|
+
stack.setAttribute("data-persona-tool-group-stack", "true");
|
|
4886
4895
|
|
|
4887
|
-
const summaryOnly = config.features?.toolCallDisplay?.groupedMode === "summary";
|
|
4888
4896
|
groupContainer.appendChild(summary);
|
|
4889
4897
|
if (!summaryOnly) {
|
|
4890
4898
|
groupContainer.appendChild(stack);
|
|
@@ -151,8 +151,33 @@ export type CodeGeneratorOptions = {
|
|
|
151
151
|
* @default "body"
|
|
152
152
|
*/
|
|
153
153
|
target?: string;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Base URL of the directory holding the widget assets (install.global.js,
|
|
157
|
+
* index.global.js, widget.css) for the script formats (script-installer,
|
|
158
|
+
* script-manual, script-advanced). Pass a first-party base such as
|
|
159
|
+
* "https://cdn.runtype.com/persona/latest" when the embedding page's CSP
|
|
160
|
+
* does not allow third-party CDNs. A trailing slash is tolerated.
|
|
161
|
+
* @default the jsDelivr dist directory pinned to this package version
|
|
162
|
+
*/
|
|
163
|
+
cdnBase?: string;
|
|
154
164
|
};
|
|
155
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Asset directory for script formats: explicit cdnBase or the version-pinned
|
|
168
|
+
* jsDelivr dist. The base is emitted into single-quoted JS literals AND
|
|
169
|
+
* double-quoted HTML attributes, so percent-encode the characters that could
|
|
170
|
+
* break out of either context. All of them are illegal unencoded in a URL
|
|
171
|
+
* (RFC 3986), so encoding never changes the meaning of a valid base URL.
|
|
172
|
+
*/
|
|
173
|
+
function resolveCdnBase(options?: CodeGeneratorOptions): string {
|
|
174
|
+
const base = options?.cdnBase
|
|
175
|
+
?.trim()
|
|
176
|
+
.replace(/\/+$/, "")
|
|
177
|
+
.replace(/[\\'"<>`]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
178
|
+
return base || `https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist`;
|
|
179
|
+
}
|
|
180
|
+
|
|
156
181
|
// Internal type for normalized hooks (always strings)
|
|
157
182
|
type NormalizedHooks = {
|
|
158
183
|
[K in keyof CodeGeneratorHooks]: string | undefined;
|
|
@@ -1402,7 +1427,7 @@ function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions
|
|
|
1402
1427
|
// Escape single quotes in JSON for HTML attribute
|
|
1403
1428
|
const configJson = JSON.stringify(payload, null, 0).replace(/'/g, "'");
|
|
1404
1429
|
|
|
1405
|
-
return `<script src="
|
|
1430
|
+
return `<script src="${resolveCdnBase(options)}/install.global.js" data-config='${configJson}'></script>`;
|
|
1406
1431
|
}
|
|
1407
1432
|
|
|
1408
1433
|
function generateScriptManualCode(config: any, options?: CodeGeneratorOptions): string {
|
|
@@ -1410,12 +1435,13 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
|
|
|
1410
1435
|
const parserType = getParserTypeFromConfig(config as AgentWidgetConfig);
|
|
1411
1436
|
const shouldEmitParserType = parserType !== "plain";
|
|
1412
1437
|
|
|
1438
|
+
const cdnBase = resolveCdnBase(options);
|
|
1413
1439
|
const lines: string[] = [
|
|
1414
1440
|
"<!-- Load CSS -->",
|
|
1415
|
-
`<link rel="stylesheet" href="
|
|
1441
|
+
`<link rel="stylesheet" href="${cdnBase}/widget.css" />`,
|
|
1416
1442
|
"",
|
|
1417
1443
|
"<!-- Load JavaScript -->",
|
|
1418
|
-
`<script src="
|
|
1444
|
+
`<script src="${cdnBase}/index.global.js"></script>`,
|
|
1419
1445
|
"",
|
|
1420
1446
|
"<!-- Initialize widget -->",
|
|
1421
1447
|
"<script>",
|
|
@@ -1565,7 +1591,7 @@ function generateScriptAdvancedCode(config: any, options?: CodeGeneratorOptions)
|
|
|
1565
1591
|
` var CONFIG = ${configJson.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n')};`,
|
|
1566
1592
|
"",
|
|
1567
1593
|
" // Constants",
|
|
1568
|
-
` var CDN_BASE = '
|
|
1594
|
+
` var CDN_BASE = '${resolveCdnBase(options)}';`,
|
|
1569
1595
|
" var STORAGE_KEY = 'chat-widget-state';",
|
|
1570
1596
|
" var PROCESSED_ACTIONS_KEY = 'chat-widget-processed-actions';",
|
|
1571
1597
|
"",
|