@runtypelabs/persona 3.11.0 → 3.13.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 +79 -0
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +64 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.global.js +61 -61
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -43
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/theme-editor.cjs +133 -14
- package/dist/theme-editor.d.cts +58 -0
- package/dist/theme-editor.d.ts +58 -0
- package/dist/theme-editor.js +133 -14
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/package.json +1 -1
- package/src/components/reasoning-bubble.ts +139 -5
- package/src/defaults.ts +1 -0
- package/src/install.ts +14 -3
- package/src/runtime/init.test.ts +132 -0
- package/src/theme-reference.ts +6 -3
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types.ts +58 -0
- package/src/utils/code-generators.test.ts +57 -0
- package/src/utils/code-generators.ts +21 -6
- package/src/utils/formatting.test.ts +25 -1
- package/src/utils/formatting.ts +15 -0
- package/src/utils/message-fingerprint.test.ts +12 -0
- package/src/utils/message-fingerprint.ts +1 -0
- package/src/utils/morph.test.ts +86 -0
- package/src/utils/morph.ts +8 -0
package/src/install.ts
CHANGED
|
@@ -22,6 +22,10 @@ interface SiteAgentInstallConfig {
|
|
|
22
22
|
previewQueryParam?: string;
|
|
23
23
|
// Shadow DOM option (defaults to false for better CSS compatibility)
|
|
24
24
|
useShadowDom?: boolean;
|
|
25
|
+
// Expose the widget handle on window[windowKey] for programmatic access
|
|
26
|
+
windowKey?: string;
|
|
27
|
+
// Called when the widget is initialized and ready for interaction
|
|
28
|
+
onReady?: (handle: any) => void;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
declare global {
|
|
@@ -55,7 +59,10 @@ declare global {
|
|
|
55
59
|
const configJson = script.getAttribute('data-config');
|
|
56
60
|
if (configJson) {
|
|
57
61
|
try {
|
|
58
|
-
|
|
62
|
+
// HTML attributes preserve literal newlines/tabs which are invalid
|
|
63
|
+
// control characters inside JSON string literals — strip them.
|
|
64
|
+
const normalizedJson = configJson.replace(/[\r\n]+\s*/g, '');
|
|
65
|
+
const parsedConfig = JSON.parse(normalizedJson);
|
|
59
66
|
// If it has nested 'config' property, use it; otherwise treat as widget config
|
|
60
67
|
if (parsedConfig.config) {
|
|
61
68
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
@@ -270,12 +277,16 @@ declare global {
|
|
|
270
277
|
}
|
|
271
278
|
|
|
272
279
|
try {
|
|
273
|
-
window.AgentWidget.initAgentWidget({
|
|
280
|
+
const handle = window.AgentWidget.initAgentWidget({
|
|
274
281
|
target,
|
|
275
282
|
config: widgetConfig,
|
|
276
283
|
// Explicitly disable shadow DOM for better CSS compatibility with host page
|
|
277
|
-
useShadowDom: config.useShadowDom ?? false
|
|
284
|
+
useShadowDom: config.useShadowDom ?? false,
|
|
285
|
+
windowKey: config.windowKey
|
|
278
286
|
});
|
|
287
|
+
|
|
288
|
+
config.onReady?.(handle);
|
|
289
|
+
window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
|
|
279
290
|
} catch (error) {
|
|
280
291
|
console.error("Failed to initialize AgentWidget:", error);
|
|
281
292
|
}
|
package/src/runtime/init.test.ts
CHANGED
|
@@ -85,6 +85,138 @@ function createMockController(config?: { launcher?: { enabled?: boolean; autoExp
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
describe("initAgentWidget windowKey and ready notifications", () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
document.body.innerHTML = "";
|
|
91
|
+
createAgentExperienceMock.mockReset();
|
|
92
|
+
createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("assigns the handle to window[windowKey] when windowKey is provided", async () => {
|
|
96
|
+
const { initAgentWidget } = await import("./init");
|
|
97
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
98
|
+
|
|
99
|
+
const handle = initAgentWidget({
|
|
100
|
+
target: "#target",
|
|
101
|
+
windowKey: "testWidget",
|
|
102
|
+
config: { launcher: { enabled: false } },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect((window as any).testWidget).toBe(handle);
|
|
106
|
+
|
|
107
|
+
handle.destroy();
|
|
108
|
+
expect((window as any).testWidget).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not set a window key when windowKey is omitted", async () => {
|
|
112
|
+
const { initAgentWidget } = await import("./init");
|
|
113
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
114
|
+
|
|
115
|
+
const handle = initAgentWidget({
|
|
116
|
+
target: "#target",
|
|
117
|
+
config: { launcher: { enabled: false } },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// No arbitrary key should have been set
|
|
121
|
+
expect((window as any).testWidget2).toBeUndefined();
|
|
122
|
+
handle.destroy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("calls onReady after initialization", async () => {
|
|
126
|
+
const { initAgentWidget } = await import("./init");
|
|
127
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
128
|
+
|
|
129
|
+
const onReady = vi.fn();
|
|
130
|
+
const handle = initAgentWidget({
|
|
131
|
+
target: "#target",
|
|
132
|
+
onReady,
|
|
133
|
+
config: { launcher: { enabled: false } },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(onReady).toHaveBeenCalledOnce();
|
|
137
|
+
handle.destroy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("the window key handle proxies controller methods", async () => {
|
|
141
|
+
const { initAgentWidget } = await import("./init");
|
|
142
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
143
|
+
|
|
144
|
+
initAgentWidget({
|
|
145
|
+
target: "#target",
|
|
146
|
+
windowKey: "proxyTest",
|
|
147
|
+
config: { launcher: { enabled: false } },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const proxy = (window as any).proxyTest;
|
|
151
|
+
expect(proxy).toBeDefined();
|
|
152
|
+
expect(typeof proxy.open).toBe("function");
|
|
153
|
+
expect(typeof proxy.close).toBe("function");
|
|
154
|
+
expect(typeof proxy.on).toBe("function");
|
|
155
|
+
expect(typeof proxy.destroy).toBe("function");
|
|
156
|
+
expect(typeof proxy.getState).toBe("function");
|
|
157
|
+
|
|
158
|
+
proxy.destroy();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("install script onReady and persona:ready event", () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
document.body.innerHTML = "";
|
|
165
|
+
createAgentExperienceMock.mockReset();
|
|
166
|
+
createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("persona:ready event fires with the handle as detail", async () => {
|
|
170
|
+
const { initAgentWidget } = await import("./init");
|
|
171
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
172
|
+
|
|
173
|
+
const eventPromise = new Promise<any>((resolve) => {
|
|
174
|
+
window.addEventListener("persona:ready", (e) => {
|
|
175
|
+
resolve((e as CustomEvent).detail);
|
|
176
|
+
}, { once: true });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const handle = initAgentWidget({
|
|
180
|
+
target: "#target",
|
|
181
|
+
windowKey: "eventTest",
|
|
182
|
+
config: { launcher: { enabled: false } },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Simulate what install.ts does after initAgentWidget returns
|
|
186
|
+
window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
|
|
187
|
+
|
|
188
|
+
const detail = await eventPromise;
|
|
189
|
+
expect(detail).toBe(handle);
|
|
190
|
+
expect(typeof detail.open).toBe("function");
|
|
191
|
+
expect(typeof detail.on).toBe("function");
|
|
192
|
+
|
|
193
|
+
handle.destroy();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("persona:ready event listener set up before init receives the handle", async () => {
|
|
197
|
+
const { initAgentWidget } = await import("./init");
|
|
198
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
199
|
+
|
|
200
|
+
const received: any[] = [];
|
|
201
|
+
window.addEventListener("persona:ready", (e) => {
|
|
202
|
+
received.push((e as CustomEvent).detail);
|
|
203
|
+
}, { once: true });
|
|
204
|
+
|
|
205
|
+
const handle = initAgentWidget({
|
|
206
|
+
target: "#target",
|
|
207
|
+
config: { launcher: { enabled: false } },
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Simulate install.ts dispatching the event
|
|
211
|
+
window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
|
|
212
|
+
|
|
213
|
+
expect(received).toHaveLength(1);
|
|
214
|
+
expect(received[0]).toBe(handle);
|
|
215
|
+
|
|
216
|
+
handle.destroy();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
88
220
|
describe("initAgentWidget docked mode", () => {
|
|
89
221
|
beforeEach(() => {
|
|
90
222
|
document.body.innerHTML = "";
|
package/src/theme-reference.ts
CHANGED
|
@@ -218,9 +218,12 @@ export const THEME_TOKEN_DOCS = {
|
|
|
218
218
|
'shadow, backgroundColor, borderColor, borderWidth, borderRadius, headerBackgroundColor, headerTextColor, headerPaddingX, headerPaddingY, contentBackgroundColor, contentTextColor, contentPaddingX, contentPaddingY, codeBlockBackgroundColor, codeBlockBorderColor, codeBlockTextColor, toggleTextColor, labelTextColor, activeTextTemplate, completeTextTemplate, loadingAnimationColor, loadingAnimationSecondaryColor, loadingAnimationDuration, renderCollapsedSummary, renderCollapsedPreview, renderGroupedSummary.',
|
|
219
219
|
},
|
|
220
220
|
reasoning: {
|
|
221
|
-
description:
|
|
221
|
+
description:
|
|
222
|
+
'Reasoning/thinking row rendering hooks, text templates, and loading animations. ' +
|
|
223
|
+
'Text templates support {duration} placeholder and inline formatting (~dim~, *italic*, **bold**). ' +
|
|
224
|
+
'renderCollapsedSummary receives elapsed (static string) and createElapsedElement() (live-updating span) in its context.',
|
|
222
225
|
properties:
|
|
223
|
-
'renderCollapsedSummary, renderCollapsedPreview.',
|
|
226
|
+
'renderCollapsedSummary, renderCollapsedPreview, activeTextTemplate, completeTextTemplate, loadingAnimationColor, loadingAnimationSecondaryColor, loadingAnimationDuration.',
|
|
224
227
|
},
|
|
225
228
|
approval: {
|
|
226
229
|
description:
|
|
@@ -285,7 +288,7 @@ export const THEME_TOKEN_DOCS = {
|
|
|
285
288
|
features: {
|
|
286
289
|
description: 'Feature flags.',
|
|
287
290
|
properties:
|
|
288
|
-
'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped, expandable, loadingAnimation), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines, expandable), artifacts (sidebar config).',
|
|
291
|
+
'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped, expandable, loadingAnimation), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines, expandable, loadingAnimation), artifacts (sidebar config).',
|
|
289
292
|
},
|
|
290
293
|
},
|
|
291
294
|
}
|
package/src/types.ts
CHANGED
|
@@ -656,6 +656,17 @@ export type AgentWidgetReasoningDisplayFeature = {
|
|
|
656
656
|
* @default true
|
|
657
657
|
*/
|
|
658
658
|
expandable?: boolean;
|
|
659
|
+
/**
|
|
660
|
+
* Animation mode applied to the reasoning header text while reasoning is active.
|
|
661
|
+
* Reuses the same modes as tool call animations.
|
|
662
|
+
* - "none" — static text, no animation
|
|
663
|
+
* - "pulse" — opacity pulse on the entire header text
|
|
664
|
+
* - "shimmer" — monochrome opacity sweep per character
|
|
665
|
+
* - "shimmer-color" — color gradient sweep per character
|
|
666
|
+
* - "rainbow" — rainbow color cycle per character
|
|
667
|
+
* @default "none"
|
|
668
|
+
*/
|
|
669
|
+
loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
|
|
659
670
|
};
|
|
660
671
|
|
|
661
672
|
export type AgentWidgetFeatureFlags = {
|
|
@@ -1390,6 +1401,14 @@ export type AgentWidgetReasoningConfig = {
|
|
|
1390
1401
|
previewText: string;
|
|
1391
1402
|
isActive: boolean;
|
|
1392
1403
|
config: AgentWidgetConfig;
|
|
1404
|
+
/** Static elapsed time snapshot, e.g. "2.6s". */
|
|
1405
|
+
elapsed: string;
|
|
1406
|
+
/**
|
|
1407
|
+
* Returns a `<span>` whose text content is automatically updated every
|
|
1408
|
+
* 100ms by the widget's global timer. Place it anywhere in your returned
|
|
1409
|
+
* HTMLElement to get a live-ticking duration display.
|
|
1410
|
+
*/
|
|
1411
|
+
createElapsedElement: () => HTMLElement;
|
|
1393
1412
|
}) => HTMLElement | string | null;
|
|
1394
1413
|
/**
|
|
1395
1414
|
* Override the lightweight collapsed preview content shown for active reasoning rows.
|
|
@@ -1402,6 +1421,45 @@ export type AgentWidgetReasoningConfig = {
|
|
|
1402
1421
|
isActive: boolean;
|
|
1403
1422
|
config: AgentWidgetConfig;
|
|
1404
1423
|
}) => HTMLElement | string | null;
|
|
1424
|
+
/**
|
|
1425
|
+
* Template string for the header text while reasoning is active (streaming).
|
|
1426
|
+
*
|
|
1427
|
+
* **Placeholders:** `{duration}` (live-updating elapsed time).
|
|
1428
|
+
*
|
|
1429
|
+
* **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — parsed at render time.
|
|
1430
|
+
*
|
|
1431
|
+
* When not set, falls back to the default "Thinking..." text.
|
|
1432
|
+
* @example "Thinking... ~{duration}~"
|
|
1433
|
+
*/
|
|
1434
|
+
activeTextTemplate?: string;
|
|
1435
|
+
/**
|
|
1436
|
+
* Template string for the header text when reasoning is complete.
|
|
1437
|
+
*
|
|
1438
|
+
* **Placeholders:** `{duration}` (final elapsed time).
|
|
1439
|
+
*
|
|
1440
|
+
* **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — same syntax as `activeTextTemplate`.
|
|
1441
|
+
*
|
|
1442
|
+
* When not set, falls back to the default "Thought for X seconds" text.
|
|
1443
|
+
* @example "Thought for ~{duration}~"
|
|
1444
|
+
*/
|
|
1445
|
+
completeTextTemplate?: string;
|
|
1446
|
+
/**
|
|
1447
|
+
* Primary color for shimmer-color animation mode.
|
|
1448
|
+
* Defaults to the current text color.
|
|
1449
|
+
*/
|
|
1450
|
+
loadingAnimationColor?: string;
|
|
1451
|
+
/**
|
|
1452
|
+
* Secondary/end color for shimmer-color animation mode.
|
|
1453
|
+
* Creates a gradient sweep between `loadingAnimationColor` and this color.
|
|
1454
|
+
* @default "#3b82f6"
|
|
1455
|
+
*/
|
|
1456
|
+
loadingAnimationSecondaryColor?: string;
|
|
1457
|
+
/**
|
|
1458
|
+
* Duration of one full animation cycle in milliseconds.
|
|
1459
|
+
* Applies to pulse, shimmer, shimmer-color, and rainbow modes.
|
|
1460
|
+
* @default 2000
|
|
1461
|
+
*/
|
|
1462
|
+
loadingAnimationDuration?: number;
|
|
1405
1463
|
};
|
|
1406
1464
|
|
|
1407
1465
|
export type AgentWidgetSuggestionChipsConfig = {
|
|
@@ -620,3 +620,60 @@ describe("CDN Version", () => {
|
|
|
620
620
|
expect(VERSION).toMatch(/^\d+\.\d+\.\d+/);
|
|
621
621
|
});
|
|
622
622
|
});
|
|
623
|
+
|
|
624
|
+
// =============================================================================
|
|
625
|
+
// windowKey option
|
|
626
|
+
// =============================================================================
|
|
627
|
+
|
|
628
|
+
describe("windowKey option", () => {
|
|
629
|
+
it("script-installer with windowKey nests config and includes windowKey in JSON", () => {
|
|
630
|
+
const code = generateCodeSnippet(minimalConfig, "script-installer", { windowKey: "myWidget" });
|
|
631
|
+
|
|
632
|
+
// Parse the data-config JSON from the output
|
|
633
|
+
const match = code.match(/data-config='([^']*)'/);
|
|
634
|
+
expect(match).not.toBeNull();
|
|
635
|
+
const parsed = JSON.parse(match![1]);
|
|
636
|
+
|
|
637
|
+
expect(parsed.windowKey).toBe("myWidget");
|
|
638
|
+
expect(parsed.config).toBeDefined();
|
|
639
|
+
expect(parsed.config.apiUrl).toBe(minimalConfig.apiUrl);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("script-installer without windowKey uses flat config (no nesting)", () => {
|
|
643
|
+
const code = generateCodeSnippet(minimalConfig, "script-installer");
|
|
644
|
+
|
|
645
|
+
const match = code.match(/data-config='([^']*)'/);
|
|
646
|
+
expect(match).not.toBeNull();
|
|
647
|
+
const parsed = JSON.parse(match![1]);
|
|
648
|
+
|
|
649
|
+
expect(parsed.windowKey).toBeUndefined();
|
|
650
|
+
expect(parsed.config).toBeUndefined();
|
|
651
|
+
expect(parsed.apiUrl).toBe(minimalConfig.apiUrl);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("script-manual with windowKey includes windowKey and captures handle", () => {
|
|
655
|
+
const code = generateCodeSnippet(minimalConfig, "script-manual", { windowKey: "myWidget" });
|
|
656
|
+
|
|
657
|
+
expect(code).toContain("var handle = window.AgentWidget.initAgentWidget(");
|
|
658
|
+
expect(code).toContain("windowKey: 'myWidget'");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("script-manual without windowKey still captures handle but omits windowKey", () => {
|
|
662
|
+
const code = generateCodeSnippet(minimalConfig, "script-manual");
|
|
663
|
+
|
|
664
|
+
expect(code).toContain("var handle = window.AgentWidget.initAgentWidget(");
|
|
665
|
+
expect(code).not.toContain("windowKey");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("script-advanced with windowKey includes windowKey in initAgentWidget call", () => {
|
|
669
|
+
const code = generateCodeSnippet(minimalConfig, "script-advanced", { windowKey: "myWidget" });
|
|
670
|
+
|
|
671
|
+
expect(code).toContain("windowKey: 'myWidget'");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("script-advanced without windowKey omits windowKey", () => {
|
|
675
|
+
const code = generateCodeSnippet(minimalConfig, "script-advanced");
|
|
676
|
+
|
|
677
|
+
expect(code).not.toContain("windowKey");
|
|
678
|
+
});
|
|
679
|
+
});
|
|
@@ -134,6 +134,13 @@ export type CodeGeneratorOptions = {
|
|
|
134
134
|
* @default true
|
|
135
135
|
*/
|
|
136
136
|
includeHookComments?: boolean;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* If provided, emits `windowKey` in the generated `initAgentWidget()` call
|
|
140
|
+
* so the widget handle is stored on `window[windowKey]`.
|
|
141
|
+
* Only affects script formats (script-installer, script-manual, script-advanced).
|
|
142
|
+
*/
|
|
143
|
+
windowKey?: string;
|
|
137
144
|
};
|
|
138
145
|
|
|
139
146
|
// Internal type for normalized hooks (always strings)
|
|
@@ -551,7 +558,7 @@ export function generateCodeSnippet(
|
|
|
551
558
|
if (format === "esm") {
|
|
552
559
|
return generateESMCode(cleanConfig, normalizedOptions);
|
|
553
560
|
} else if (format === "script-installer") {
|
|
554
|
-
return generateScriptInstallerCode(cleanConfig);
|
|
561
|
+
return generateScriptInstallerCode(cleanConfig, normalizedOptions);
|
|
555
562
|
} else if (format === "script-advanced") {
|
|
556
563
|
return generateScriptAdvancedCode(cleanConfig, normalizedOptions);
|
|
557
564
|
} else if (format === "react-component") {
|
|
@@ -1346,12 +1353,18 @@ function buildSerializableConfig(config: any): Record<string, any> {
|
|
|
1346
1353
|
return serializableConfig;
|
|
1347
1354
|
}
|
|
1348
1355
|
|
|
1349
|
-
function generateScriptInstallerCode(config: any): string {
|
|
1356
|
+
function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions): string {
|
|
1350
1357
|
const serializableConfig = buildSerializableConfig(config);
|
|
1351
|
-
|
|
1358
|
+
|
|
1359
|
+
// When windowKey is provided, nest the widget config under `config` so the
|
|
1360
|
+
// install script's parsedConfig.config detection picks it up alongside windowKey.
|
|
1361
|
+
const payload = options?.windowKey
|
|
1362
|
+
? { config: serializableConfig, windowKey: options.windowKey }
|
|
1363
|
+
: serializableConfig;
|
|
1364
|
+
|
|
1352
1365
|
// Escape single quotes in JSON for HTML attribute
|
|
1353
|
-
const configJson = JSON.stringify(
|
|
1354
|
-
|
|
1366
|
+
const configJson = JSON.stringify(payload, null, 0).replace(/'/g, "'");
|
|
1367
|
+
|
|
1355
1368
|
return `<script src="https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist/install.global.js" data-config='${configJson}'></script>`;
|
|
1356
1369
|
}
|
|
1357
1370
|
|
|
@@ -1369,8 +1382,9 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
|
|
|
1369
1382
|
"",
|
|
1370
1383
|
"<!-- Initialize widget -->",
|
|
1371
1384
|
"<script>",
|
|
1372
|
-
" window.AgentWidget.initAgentWidget({",
|
|
1385
|
+
" var handle = window.AgentWidget.initAgentWidget({",
|
|
1373
1386
|
" target: 'body',",
|
|
1387
|
+
...(options?.windowKey ? [` windowKey: '${options.windowKey}',`] : []),
|
|
1374
1388
|
" config: {"
|
|
1375
1389
|
];
|
|
1376
1390
|
|
|
@@ -1723,6 +1737,7 @@ function generateScriptAdvancedCode(config: any, options?: CodeGeneratorOptions)
|
|
|
1723
1737
|
" var handle = agentWidget.initAgentWidget({",
|
|
1724
1738
|
" target: 'body',",
|
|
1725
1739
|
" useShadowDom: false,",
|
|
1740
|
+
...(options?.windowKey ? [` windowKey: '${options.windowKey}',`] : []),
|
|
1726
1741
|
" config: widgetConfig",
|
|
1727
1742
|
" });",
|
|
1728
1743
|
"",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createJsonStreamParser, parseFormattedTemplate } from "./formatting";
|
|
2
|
+
import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } from "./formatting";
|
|
3
3
|
|
|
4
4
|
describe("JSON Stream Parser", () => {
|
|
5
5
|
it("should extract text field incrementally as JSON streams in", () => {
|
|
@@ -244,3 +244,27 @@ describe("parseFormattedTemplate", () => {
|
|
|
244
244
|
]);
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
describe("computeReasoningElapsed", () => {
|
|
249
|
+
it("uses durationMs when provided", () => {
|
|
250
|
+
const result = computeReasoningElapsed({
|
|
251
|
+
id: "r1", status: "complete", chunks: [], durationMs: 2600,
|
|
252
|
+
});
|
|
253
|
+
expect(result).toBe("2.6s");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("computes from startedAt/completedAt when durationMs is undefined", () => {
|
|
257
|
+
const result = computeReasoningElapsed({
|
|
258
|
+
id: "r2", status: "complete", chunks: [],
|
|
259
|
+
startedAt: 1000, completedAt: 16000,
|
|
260
|
+
});
|
|
261
|
+
expect(result).toBe("15s");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("returns <0.1s for very short durations", () => {
|
|
265
|
+
const result = computeReasoningElapsed({
|
|
266
|
+
id: "r3", status: "complete", chunks: [], durationMs: 50,
|
|
267
|
+
});
|
|
268
|
+
expect(result).toBe("<0.1s");
|
|
269
|
+
});
|
|
270
|
+
});
|
package/src/utils/formatting.ts
CHANGED
|
@@ -115,6 +115,21 @@ export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
|
|
|
115
115
|
return formatElapsedMs(durationMs);
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Computes the current elapsed time string for a reasoning block.
|
|
120
|
+
*/
|
|
121
|
+
export const computeReasoningElapsed = (reasoning: AgentWidgetReasoning): string => {
|
|
122
|
+
const durationMs =
|
|
123
|
+
reasoning.durationMs !== undefined
|
|
124
|
+
? reasoning.durationMs
|
|
125
|
+
: Math.max(
|
|
126
|
+
0,
|
|
127
|
+
(reasoning.completedAt ?? Date.now()) -
|
|
128
|
+
(reasoning.startedAt ?? reasoning.completedAt ?? Date.now())
|
|
129
|
+
);
|
|
130
|
+
return formatElapsedMs(durationMs);
|
|
131
|
+
};
|
|
132
|
+
|
|
118
133
|
/**
|
|
119
134
|
* Resolves a text template with tool call placeholders.
|
|
120
135
|
* Supported placeholders: {toolName}, {duration}
|
|
@@ -90,6 +90,18 @@ describe("computeMessageFingerprint", () => {
|
|
|
90
90
|
expect(fp1).not.toBe(fp2);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
it("changes when toolCall name changes", () => {
|
|
94
|
+
const fp1 = computeMessageFingerprint(
|
|
95
|
+
makeMessage({ toolCall: { status: "running" } }),
|
|
96
|
+
0
|
|
97
|
+
);
|
|
98
|
+
const fp2 = computeMessageFingerprint(
|
|
99
|
+
makeMessage({ toolCall: { status: "running", name: "UCP Search Catalog" } }),
|
|
100
|
+
0
|
|
101
|
+
);
|
|
102
|
+
expect(fp1).not.toBe(fp2);
|
|
103
|
+
});
|
|
104
|
+
|
|
93
105
|
it("changes when toolCall chunks change", () => {
|
|
94
106
|
const fp1 = computeMessageFingerprint(
|
|
95
107
|
makeMessage({ toolCall: { status: "running", chunks: ["Loaded tools"] } }),
|
|
@@ -53,6 +53,7 @@ export function computeMessageFingerprint(
|
|
|
53
53
|
message.llmContent?.length ?? 0,
|
|
54
54
|
message.approval?.status ?? "",
|
|
55
55
|
message.toolCall?.status ?? "",
|
|
56
|
+
message.toolCall?.name ?? "",
|
|
56
57
|
message.toolCall?.chunks?.length ?? 0,
|
|
57
58
|
message.toolCall?.chunks?.[message.toolCall.chunks.length - 1]?.slice(-32) ?? "",
|
|
58
59
|
typeof message.toolCall?.args === "string"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { morphMessages } from "./morph";
|
|
4
|
+
|
|
5
|
+
function makeContainer(html: string): HTMLElement {
|
|
6
|
+
const div = document.createElement("div");
|
|
7
|
+
div.innerHTML = html;
|
|
8
|
+
return div;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeNewContent(html: string): HTMLElement {
|
|
12
|
+
const div = document.createElement("div");
|
|
13
|
+
div.innerHTML = html;
|
|
14
|
+
return div;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("morphMessages", () => {
|
|
18
|
+
describe("data-preserve-animation", () => {
|
|
19
|
+
it("preserves animated element when old and new both have data-preserve-animation with same text", () => {
|
|
20
|
+
const container = makeContainer(
|
|
21
|
+
'<span data-preserve-animation="true">Calling tool... 0.1s</span>'
|
|
22
|
+
);
|
|
23
|
+
const oldSpan = container.querySelector("span")!;
|
|
24
|
+
|
|
25
|
+
morphMessages(
|
|
26
|
+
container,
|
|
27
|
+
makeNewContent(
|
|
28
|
+
'<span data-preserve-animation="true">Calling tool... 0.1s</span>'
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(container.querySelector("span")).toBe(oldSpan);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("allows morph when new node drops data-preserve-animation (tool completed)", () => {
|
|
36
|
+
const container = makeContainer(
|
|
37
|
+
'<span data-preserve-animation="true">Calling tool... 0.5s</span>'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
morphMessages(
|
|
41
|
+
container,
|
|
42
|
+
makeNewContent("<span>Finished tool 0.5s</span>")
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(container.querySelector("span")!.textContent).toBe(
|
|
46
|
+
"Finished tool 0.5s"
|
|
47
|
+
);
|
|
48
|
+
expect(
|
|
49
|
+
container.querySelector("span")!.hasAttribute("data-preserve-animation")
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("allows morph when text content changes despite both having data-preserve-animation", () => {
|
|
54
|
+
const container = makeContainer(
|
|
55
|
+
'<span data-preserve-animation="true">Calling tool... 0.1s</span>'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
morphMessages(
|
|
59
|
+
container,
|
|
60
|
+
makeNewContent(
|
|
61
|
+
'<span data-preserve-animation="true">Calling UCP Search Catalog... 0.2s</span>'
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(container.querySelector("span")!.textContent).toBe(
|
|
66
|
+
"Calling UCP Search Catalog... 0.2s"
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not preserve when preserveTypingAnimation is false", () => {
|
|
71
|
+
const container = makeContainer(
|
|
72
|
+
'<span data-preserve-animation="true">Old text</span>'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
morphMessages(
|
|
76
|
+
container,
|
|
77
|
+
makeNewContent(
|
|
78
|
+
'<span data-preserve-animation="true">New text</span>'
|
|
79
|
+
),
|
|
80
|
+
{ preserveTypingAnimation: false }
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(container.querySelector("span")!.textContent).toBe("New text");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/utils/morph.ts
CHANGED
|
@@ -35,6 +35,14 @@ export const morphMessages = (
|
|
|
35
35
|
if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
+
// Allow morph when content has meaningfully changed (e.g. tool name arrived)
|
|
39
|
+
if (newNode instanceof HTMLElement && newNode.hasAttribute("data-preserve-animation")) {
|
|
40
|
+
const oldText = oldNode.textContent ?? "";
|
|
41
|
+
const newText = newNode.textContent ?? "";
|
|
42
|
+
if (oldText !== newText) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
38
46
|
return false;
|
|
39
47
|
}
|
|
40
48
|
}
|