@runtypelabs/persona 3.34.1 → 3.35.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 +11 -11
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
- package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +11 -11
- package/dist/codegen.js +10 -10
- package/dist/index.cjs +46 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +514 -158
- package/dist/index.d.ts +514 -158
- package/dist/index.global.js +41 -89
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -44
- 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 +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +53 -0
- package/dist/plugin-kit.d.cts +4 -4
- package/dist/plugin-kit.d.ts +4 -4
- package/dist/runtype-tts.js +1 -0
- package/dist/smart-dom-reader.cjs +1 -1
- package/dist/smart-dom-reader.d.cts +352 -103
- package/dist/smart-dom-reader.d.ts +352 -103
- package/dist/smart-dom-reader.js +1 -1
- package/dist/theme-editor-preview.cjs +203 -0
- package/dist/theme-editor-preview.d.cts +5748 -0
- package/dist/theme-editor-preview.d.ts +5748 -0
- package/dist/theme-editor-preview.js +203 -0
- package/dist/theme-editor.cjs +14 -131
- package/dist/theme-editor.d.cts +362 -574
- package/dist/theme-editor.d.ts +362 -574
- package/dist/theme-editor.js +14 -131
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +1 -1
- package/dist/theme-reference.d.ts +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +80 -0
- package/dist/voice-worklet-player.d.cts +215 -0
- package/dist/voice-worklet-player.d.ts +215 -0
- package/dist/voice-worklet-player.js +80 -0
- package/dist/widget.css +54 -36
- package/package.json +16 -2
- package/src/animations/glyph-cycle.ts +11 -14
- package/src/animations/wipe.ts +1 -1
- package/src/ask-user-question-tool.test.ts +1 -1
- package/src/ask-user-question-tool.ts +6 -8
- package/src/client.test.ts +12 -12
- package/src/client.ts +30 -31
- package/src/codegen.test.ts +1 -1
- package/src/codegen.ts +1 -1
- package/src/components/approval-bubble.test.ts +1 -1
- package/src/components/approval-bubble.ts +3 -3
- package/src/components/artifact-pane.ts +3 -3
- package/src/components/ask-user-question-bubble.test.ts +14 -14
- package/src/components/ask-user-question-bubble.ts +10 -10
- package/src/components/composer-builder.ts +17 -16
- package/src/components/composer-parts.test.ts +54 -0
- package/src/components/composer-parts.ts +95 -126
- package/src/components/demo-carousel.ts +1 -1
- package/src/components/event-stream-view.test.ts +7 -7
- package/src/components/event-stream-view.ts +5 -5
- package/src/components/header-builder.ts +33 -27
- package/src/components/header-layouts.ts +1 -1
- package/src/components/header-parts.test.ts +90 -0
- package/src/components/header-parts.ts +62 -93
- package/src/components/launcher.test.ts +11 -0
- package/src/components/launcher.ts +1 -1
- package/src/components/message-bubble.test.ts +74 -2
- package/src/components/message-bubble.ts +25 -11
- package/src/components/messages.ts +2 -2
- package/src/components/panel.test.ts +1 -1
- package/src/components/panel.ts +92 -97
- package/src/components/pill-composer-builder.ts +28 -24
- package/src/components/reasoning-bubble.ts +2 -2
- package/src/components/suggestions.ts +1 -1
- package/src/components/tool-bubble.ts +1 -1
- package/src/components/widget-view.test.ts +181 -0
- package/src/components/widget-view.ts +234 -0
- package/src/defaults.ts +54 -51
- package/src/generated/runtype-openapi-contract.ts +5 -0
- package/src/index-core.ts +30 -4
- package/src/index-global.ts +73 -6
- package/src/index.ts +7 -1
- package/src/install.test.ts +11 -11
- package/src/install.ts +28 -18
- package/src/launcher-global.ts +25 -14
- package/src/markdown-parsers-eager.ts +20 -0
- package/src/markdown-parsers-entry.ts +4 -0
- package/src/markdown-parsers-loader.ts +49 -0
- package/src/plugin-kit.test.ts +1 -1
- package/src/plugin-kit.ts +7 -7
- package/src/plugins/types.ts +5 -5
- package/src/postprocessors.ts +27 -14
- package/src/runtime/host-layout.test.ts +1 -1
- package/src/runtime/host-layout.ts +4 -5
- package/src/runtime/init.ts +2 -2
- package/src/session.test.ts +5 -6
- package/src/session.ts +333 -227
- package/src/session.voice.test.ts +146 -0
- package/src/session.webmcp.test.ts +24 -25
- package/src/smart-dom-reader.ts +3 -4
- package/src/styles/widget.css +54 -36
- package/src/suggest-replies-tool.test.ts +3 -3
- package/src/suggest-replies-tool.ts +5 -5
- package/src/testing/index.ts +1 -1
- package/src/theme-editor/color-utils.ts +2 -2
- package/src/theme-editor/index.ts +4 -13
- package/src/theme-editor/presets.ts +1 -1
- package/src/theme-editor/preview-utils.ts +22 -20
- package/src/theme-editor/preview.ts +4 -1
- package/src/theme-editor/role-mappings.ts +3 -3
- package/src/theme-editor/sections.ts +21 -20
- package/src/theme-editor/types.ts +1 -1
- package/src/theme-editor/webmcp/index.ts +1 -1
- package/src/theme-editor/webmcp/summary.ts +1 -2
- package/src/theme-editor/webmcp/tools.ts +30 -30
- package/src/theme-editor/webmcp/types.ts +2 -2
- package/src/theme-editor-preview.ts +10 -0
- package/src/theme-reference.ts +26 -26
- package/src/types.ts +368 -99
- package/src/ui.approval-plugin.test.ts +3 -3
- package/src/ui.ask-user-question-plugin.test.ts +9 -9
- package/src/ui.component-directive.test.ts +4 -4
- package/src/ui.composer-bar.test.ts +10 -11
- package/src/ui.composer-keyboard.test.ts +5 -5
- package/src/ui.header-icon-color.test.ts +59 -0
- package/src/ui.scroll.test.ts +5 -6
- package/src/ui.stop-button.test.ts +3 -4
- package/src/ui.suggest-replies.test.ts +2 -2
- package/src/ui.ts +292 -131
- package/src/utils/buttons.ts +1 -1
- package/src/utils/code-generators.ts +1 -1
- package/src/utils/composer-history.ts +3 -3
- package/src/utils/dom-context.test.ts +2 -2
- package/src/utils/dom-context.ts +7 -7
- package/src/utils/dom.test.ts +67 -0
- package/src/utils/dom.ts +75 -0
- package/src/utils/dropdown.ts +2 -2
- package/src/utils/event-stream-buffer.test.ts +2 -2
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +5 -0
- package/src/utils/morph.ts +1 -1
- package/src/utils/sanitize.ts +32 -16
- package/src/utils/sequence-buffer.test.ts +8 -8
- package/src/utils/sequence-buffer.ts +4 -4
- package/src/utils/smart-dom-adapter.test.ts +1 -1
- package/src/utils/smart-dom-adapter.ts +4 -5
- package/src/utils/speech-text.test.ts +122 -0
- package/src/utils/speech-text.ts +101 -0
- package/src/utils/stream-animation.test.ts +3 -3
- package/src/utils/stream-animation.ts +6 -6
- package/src/utils/theme.test.ts +1 -1
- package/src/utils/throughput-tracker.test.ts +11 -11
- package/src/utils/throughput-tracker.ts +9 -9
- package/src/utils/tokens.ts +13 -13
- package/src/utils/virtual-scroller.ts +1 -1
- package/src/vendor/smart-dom-reader/README.md +4 -4
- package/src/vendor/smart-dom-reader/index.d.ts +1 -1
- package/src/vendor/smart-dom-reader/index.js +2 -2
- package/src/version.ts +1 -1
- package/src/voice/audio-playback-manager.test.ts +159 -0
- package/src/voice/audio-playback-manager.ts +145 -30
- package/src/voice/browser-speech-engine.ts +130 -0
- package/src/voice/fallback-speech-engine.ts +89 -0
- package/src/voice/index.ts +21 -0
- package/src/voice/read-aloud-controller.test.ts +143 -0
- package/src/voice/read-aloud-controller.ts +136 -0
- package/src/voice/runtype-speech-engine.test.ts +280 -0
- package/src/voice/runtype-speech-engine.ts +211 -0
- package/src/voice/runtype-tts-entry.ts +10 -0
- package/src/voice/runtype-tts-loader.test.ts +47 -0
- package/src/voice/runtype-tts-loader.ts +42 -0
- package/src/voice/runtype-voice-provider.ts +332 -537
- package/src/voice/voice-factory.ts +26 -4
- package/src/voice/voice.test.ts +321 -7
- package/src/voice/worklet-playback-engine.ts +272 -0
- package/src/voice-worklet-player.ts +39 -0
- package/src/webmcp-bridge.test.ts +6 -6
- package/src/webmcp-bridge.ts +24 -25
- package/src/webmcp-polyfill.ts +1 -1
- package/src/voice/voice-activity-detector.ts +0 -90
package/src/utils/buttons.ts
CHANGED
|
@@ -283,7 +283,7 @@ export interface ComboButtonHandle {
|
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
/**
|
|
286
|
-
* Creates a combo button
|
|
286
|
+
* Creates a combo button: a clickable label with a chevron that opens a dropdown menu.
|
|
287
287
|
*
|
|
288
288
|
* The entire label + chevron area acts as a single interactive unit with an optional
|
|
289
289
|
* hover pill effect. Clicking anywhere on it toggles the dropdown.
|
|
@@ -1380,7 +1380,7 @@ function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions
|
|
|
1380
1380
|
// branch hoists every sibling key (Object.assign(scriptConfig, parsed)), while
|
|
1381
1381
|
// a flat payload becomes the widget config wholesale and its siblings are
|
|
1382
1382
|
// ignored. So whenever either is set we emit the nested `{ config, ...}` form
|
|
1383
|
-
// with the option as a SIBLING of `config
|
|
1383
|
+
// with the option as a SIBLING of `config`: nesting `target` inside the
|
|
1384
1384
|
// widget config would leave the widget mounted on `body`.
|
|
1385
1385
|
const needsWrapper = Boolean(options?.windowKey || options?.target);
|
|
1386
1386
|
const payload = needsWrapper
|
|
@@ -36,7 +36,7 @@ export interface ComposerHistoryInput {
|
|
|
36
36
|
export interface ComposerHistoryResult {
|
|
37
37
|
/** Whether the key was consumed (caller should preventDefault). */
|
|
38
38
|
handled: boolean;
|
|
39
|
-
/** New textarea value to apply
|
|
39
|
+
/** New textarea value to apply: only present when it should change. */
|
|
40
40
|
value?: string;
|
|
41
41
|
/** Next navigation state. */
|
|
42
42
|
state: ComposerHistoryState;
|
|
@@ -86,7 +86,7 @@ export function navigateComposerHistory(
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// Already at the oldest entry
|
|
89
|
+
// Already at the oldest entry: consume the key but don't change.
|
|
90
90
|
return { handled: true, state };
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -104,7 +104,7 @@ export function navigateComposerHistory(
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Stepped past the newest entry
|
|
107
|
+
// Stepped past the newest entry: restore the saved draft and exit.
|
|
108
108
|
return {
|
|
109
109
|
handled: true,
|
|
110
110
|
value: state.draft,
|
|
@@ -181,7 +181,7 @@ describe("collectEnrichedPageContext", () => {
|
|
|
181
181
|
it("handles empty body", () => {
|
|
182
182
|
document.body.innerHTML = "";
|
|
183
183
|
const result = collectEnrichedPageContext();
|
|
184
|
-
// May return body itself or empty
|
|
184
|
+
// May return body itself or empty: either is valid
|
|
185
185
|
expect(Array.isArray(result)).toBe(true);
|
|
186
186
|
});
|
|
187
187
|
|
|
@@ -386,7 +386,7 @@ describe("formatEnrichedContext", () => {
|
|
|
386
386
|
interactivity: "static",
|
|
387
387
|
attributes: {},
|
|
388
388
|
formattedSummary:
|
|
389
|
-
"[Shirt](/p/1)
|
|
389
|
+
"[Shirt](/p/1): $10\nselector: div.card\nactions: Add",
|
|
390
390
|
},
|
|
391
391
|
];
|
|
392
392
|
const out = formatEnrichedContext(elements, { mode: "structured" });
|
package/src/utils/dom-context.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Enriched DOM context collection for providing richer page information to AI.
|
|
3
3
|
*
|
|
4
4
|
* Captures interactive elements, stable CSS selectors, ARIA roles, data attributes,
|
|
5
|
-
* and visibility state
|
|
5
|
+
* and visibility state: giving the LLM much better context than basic className/innerText.
|
|
6
6
|
*
|
|
7
7
|
* ## Modes
|
|
8
8
|
*
|
|
9
9
|
* - **structured** (default): collects candidates, scores them with optional {@link ParseRule}
|
|
10
10
|
* hooks, then applies `maxElements`. Rich containers (e.g. product cards) can surface
|
|
11
11
|
* before unrelated static noise.
|
|
12
|
-
* - **simple**: legacy behavior
|
|
12
|
+
* - **simple**: legacy behavior: cap during traversal, interactive-first ordering, no rule
|
|
13
13
|
* scoring or {@link EnrichedPageElement.formattedSummary}.
|
|
14
14
|
*/
|
|
15
15
|
|
|
@@ -44,7 +44,7 @@ export type DomContextMode = "simple" | "structured";
|
|
|
44
44
|
export interface ParseOptionsConfig {
|
|
45
45
|
/**
|
|
46
46
|
* `structured` (default): score candidates with rules, then apply `maxElements`.
|
|
47
|
-
* `simple`: legacy traversal cap and ordering only
|
|
47
|
+
* `simple`: legacy traversal cap and ordering only: rules are ignored (with a warning
|
|
48
48
|
* if `rules` was passed on {@link DomContextOptions}).
|
|
49
49
|
*/
|
|
50
50
|
mode?: DomContextMode;
|
|
@@ -294,9 +294,9 @@ export const defaultParseRules: ParseRule[] = [
|
|
|
294
294
|
const ctas = extractCtaLabels(el);
|
|
295
295
|
const head =
|
|
296
296
|
href && title
|
|
297
|
-
? `[${title}](${href})${price ?
|
|
297
|
+
? `[${title}](${href})${price ? `: ${price}` : ""}`
|
|
298
298
|
: title
|
|
299
|
-
? `${title}${price ?
|
|
299
|
+
? `${title}${price ? `: ${price}` : ""}`
|
|
300
300
|
: price || enriched.text.trim().slice(0, 120);
|
|
301
301
|
const lines = [
|
|
302
302
|
head,
|
|
@@ -443,7 +443,7 @@ function isElementVisible(el: HTMLElement): boolean {
|
|
|
443
443
|
if (style.display === "none") return false;
|
|
444
444
|
if (style.visibility === "hidden") return false;
|
|
445
445
|
} catch {
|
|
446
|
-
// getComputedStyle can fail in some environments
|
|
446
|
+
// getComputedStyle can fail in some environments: assume visible
|
|
447
447
|
}
|
|
448
448
|
|
|
449
449
|
if (el.style.display === "none") return false;
|
|
@@ -801,7 +801,7 @@ function collectSimple(
|
|
|
801
801
|
* - **Default (structured):** walks up to `maxCandidates` nodes, scores with
|
|
802
802
|
* {@link defaultParseRules} (or `rules`), suppresses redundant descendants when a
|
|
803
803
|
* formatting rule matches, then keeps the top `maxElements` by score (DOM order tie-break).
|
|
804
|
-
* - **simple:** legacy path
|
|
804
|
+
* - **simple:** legacy path: stops once `maxElements` nodes are collected during traversal
|
|
805
805
|
* and sorts interactive before static.
|
|
806
806
|
*
|
|
807
807
|
* Pass `options: { mode: "simple" }` to disable rules. If `mode` is `simple` and `rules` is
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { createNode, cx } from "./dom";
|
|
5
|
+
|
|
6
|
+
describe("createNode", () => {
|
|
7
|
+
it("applies className, text, attributes, and inline styles", () => {
|
|
8
|
+
const el = createNode("button", {
|
|
9
|
+
className: "persona-foo persona-bar",
|
|
10
|
+
text: "Send",
|
|
11
|
+
attrs: { type: "submit", "data-persona-composer-submit": "" },
|
|
12
|
+
style: { display: "none", zIndex: "5" },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(el.tagName).toBe("BUTTON");
|
|
16
|
+
expect(el.className).toBe("persona-foo persona-bar");
|
|
17
|
+
expect(el.textContent).toBe("Send");
|
|
18
|
+
expect(el.getAttribute("type")).toBe("submit");
|
|
19
|
+
expect(el.getAttribute("data-persona-composer-submit")).toBe("");
|
|
20
|
+
expect(el.style.display).toBe("none");
|
|
21
|
+
expect(el.style.zIndex).toBe("5");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("appends node and string children in order, skipping nullish values", () => {
|
|
25
|
+
const child = createNode("span", { text: "child" });
|
|
26
|
+
const parent = createNode("div", {}, child, "tail", null, undefined);
|
|
27
|
+
|
|
28
|
+
expect(parent.childNodes.length).toBe(2);
|
|
29
|
+
expect(parent.firstChild).toBe(child);
|
|
30
|
+
expect(parent.lastChild?.textContent).toBe("tail");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("defaults to a bare element when no options are provided", () => {
|
|
34
|
+
const el = createNode("div");
|
|
35
|
+
expect(el.tagName).toBe("DIV");
|
|
36
|
+
expect(el.className).toBe("");
|
|
37
|
+
expect(el.childNodes.length).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("skips nullish style values so conditionals can be inlined", () => {
|
|
41
|
+
const el = createNode("div", {
|
|
42
|
+
style: { display: "none", color: undefined, width: "10px" },
|
|
43
|
+
});
|
|
44
|
+
expect(el.style.display).toBe("none");
|
|
45
|
+
expect(el.style.width).toBe("10px");
|
|
46
|
+
// An undefined value must not be written (no literal "undefined").
|
|
47
|
+
expect(el.style.color).toBe("");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("cx", () => {
|
|
52
|
+
it("joins truthy fragments and drops falsy ones", () => {
|
|
53
|
+
expect(cx("a", false, "b", null, undefined, "", "c")).toBe("a b c");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("supports inline conditional classes", () => {
|
|
57
|
+
const active = true;
|
|
58
|
+
const disabled = false;
|
|
59
|
+
expect(cx("base", active && "is-active", disabled && "is-disabled")).toBe(
|
|
60
|
+
"base is-active"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns an empty string when every fragment is falsy", () => {
|
|
65
|
+
expect(cx(false, null, undefined, "")).toBe("");
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/utils/dom.ts
CHANGED
|
@@ -28,6 +28,81 @@ export const createFragment = (): DocumentFragment => {
|
|
|
28
28
|
return document.createDocumentFragment();
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
export interface CreateNodeOptions {
|
|
32
|
+
/** Sets `element.className`. */
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Sets `element.textContent` before any `children` are appended. */
|
|
35
|
+
text?: string;
|
|
36
|
+
/** Attribute name → value pairs applied via `setAttribute`. */
|
|
37
|
+
attrs?: Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* Inline styles. Nullish (`undefined`/`null`) values are skipped so callers
|
|
40
|
+
* can inline conditionals (e.g. `borderColor: cfg.borderColor`) without an
|
|
41
|
+
* `if` per property. Note this only *sets* values, it never clears them, so
|
|
42
|
+
* prefer it for constructing fresh elements rather than re-styling live ones.
|
|
43
|
+
*/
|
|
44
|
+
style?: Partial<CSSStyleDeclaration>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ergonomic element factory that bundles the className + attribute + style +
|
|
49
|
+
* children boilerplate the widget's DOM builders otherwise repeat by hand.
|
|
50
|
+
*
|
|
51
|
+
* `createElement` stays the right tool for the simple `(tag, className)` case;
|
|
52
|
+
* reach for `createNode` when a node also needs attributes, inline styles, or
|
|
53
|
+
* up-front children. Nullish `children` are skipped so callers can inline
|
|
54
|
+
* conditionals (e.g. `maybeIcon && el`).
|
|
55
|
+
*/
|
|
56
|
+
export const createNode = <K extends keyof HTMLElementTagNameMap>(
|
|
57
|
+
tag: K,
|
|
58
|
+
options: CreateNodeOptions = {},
|
|
59
|
+
...children: Array<Node | string | null | undefined>
|
|
60
|
+
): HTMLElementTagNameMap[K] => {
|
|
61
|
+
const element = document.createElement(tag);
|
|
62
|
+
|
|
63
|
+
if (options.className) {
|
|
64
|
+
element.className = options.className;
|
|
65
|
+
}
|
|
66
|
+
if (options.text !== undefined) {
|
|
67
|
+
element.textContent = options.text;
|
|
68
|
+
}
|
|
69
|
+
if (options.attrs) {
|
|
70
|
+
for (const [name, value] of Object.entries(options.attrs)) {
|
|
71
|
+
element.setAttribute(name, value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (options.style) {
|
|
75
|
+
const style = element.style as unknown as Record<string, string>;
|
|
76
|
+
const source = options.style as Record<string, string | null | undefined>;
|
|
77
|
+
for (const property of Object.keys(source)) {
|
|
78
|
+
const value = source[property];
|
|
79
|
+
if (value != null) {
|
|
80
|
+
style[property] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const appendable = children.filter(
|
|
86
|
+
(child): child is Node | string => child != null
|
|
87
|
+
);
|
|
88
|
+
if (appendable.length > 0) {
|
|
89
|
+
element.append(...appendable);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return element;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Join truthy class-name fragments into a single space-separated string
|
|
97
|
+
* (the clsx / classnames pattern). Falsy fragments
|
|
98
|
+
* (`false` / `null` / `undefined` / `""`) are dropped, so conditional classes
|
|
99
|
+
* read inline as `cond && "persona-foo"` instead of imperative
|
|
100
|
+
* `classList.add(...)` branches.
|
|
101
|
+
*/
|
|
102
|
+
export const cx = (
|
|
103
|
+
...parts: Array<string | false | null | undefined>
|
|
104
|
+
): string => parts.filter(Boolean).join(" ");
|
|
105
|
+
|
|
31
106
|
|
|
32
107
|
|
|
33
108
|
|
package/src/utils/dropdown.ts
CHANGED
|
@@ -72,11 +72,11 @@ export function createDropdownMenu(options: CreateDropdownOptions): DropdownMenu
|
|
|
72
72
|
menu.setAttribute("data-persona-theme-zone", "dropdown");
|
|
73
73
|
|
|
74
74
|
if (portal) {
|
|
75
|
-
// Fixed positioning
|
|
75
|
+
// Fixed positioning: menu is portaled outside the anchor's overflow context
|
|
76
76
|
menu.style.position = "fixed";
|
|
77
77
|
menu.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
|
|
78
78
|
} else {
|
|
79
|
-
// Absolute positioning
|
|
79
|
+
// Absolute positioning: menu lives inside the anchor
|
|
80
80
|
menu.style.position = "absolute";
|
|
81
81
|
menu.style.top = "100%";
|
|
82
82
|
menu.style.marginTop = "4px";
|
|
@@ -239,7 +239,7 @@ describe("EventStreamBuffer", () => {
|
|
|
239
239
|
expect(buf.getAll()).toEqual([]);
|
|
240
240
|
expect(store.clear).toHaveBeenCalled();
|
|
241
241
|
|
|
242
|
-
// Simulate new session sending events
|
|
242
|
+
// Simulate new session sending events: buffer should accept them
|
|
243
243
|
const newEvt = makeEvent("c", 3);
|
|
244
244
|
buf.push(newEvt);
|
|
245
245
|
expect(buf.getSize()).toBe(1);
|
|
@@ -253,7 +253,7 @@ describe("EventStreamBuffer", () => {
|
|
|
253
253
|
buf.push(makeEvent("session1_a", 1));
|
|
254
254
|
buf.push(makeEvent("session1_b", 2));
|
|
255
255
|
|
|
256
|
-
// Session reset happens (no clear called
|
|
256
|
+
// Session reset happens (no clear called: buffer persists)
|
|
257
257
|
// Session 2 events
|
|
258
258
|
buf.push(makeEvent("session2_a", 3));
|
|
259
259
|
|
|
@@ -47,6 +47,15 @@ describe("computeMessageFingerprint", () => {
|
|
|
47
47
|
expect(fp1).not.toBe(fp2);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
it("changes when voiceProcessing flips with identical text (transcript finalize)", () => {
|
|
51
|
+
// On transcript finalize, voiceProcessing goes true→false with the SAME
|
|
52
|
+
// text. The fingerprint must change so the live transcribing/thinking
|
|
53
|
+
// bubble is replaced by the finalized one instead of served from cache.
|
|
54
|
+
const fp1 = computeMessageFingerprint(makeMessage({ content: "hello there", voiceProcessing: true }), 0);
|
|
55
|
+
const fp2 = computeMessageFingerprint(makeMessage({ content: "hello there", voiceProcessing: false }), 0);
|
|
56
|
+
expect(fp1).not.toBe(fp2);
|
|
57
|
+
});
|
|
58
|
+
|
|
50
59
|
it("changes when role changes", () => {
|
|
51
60
|
const fp1 = computeMessageFingerprint(makeMessage({ role: "assistant" }), 0);
|
|
52
61
|
const fp2 = computeMessageFingerprint(makeMessage({ role: "user" }), 0);
|
|
@@ -12,6 +12,7 @@ export type FingerprintableMessage = {
|
|
|
12
12
|
role: string;
|
|
13
13
|
content: string;
|
|
14
14
|
streaming?: boolean;
|
|
15
|
+
voiceProcessing?: boolean;
|
|
15
16
|
variant?: string;
|
|
16
17
|
rawContent?: string;
|
|
17
18
|
llmContent?: string;
|
|
@@ -49,6 +50,10 @@ export function computeMessageFingerprint(
|
|
|
49
50
|
message.content?.length ?? 0,
|
|
50
51
|
message.content?.slice(-32) ?? "",
|
|
51
52
|
message.streaming ? "1" : "0",
|
|
53
|
+
// voiceProcessing flips true→false on transcript finalize, usually with the
|
|
54
|
+
// SAME text — so without it the cache would reuse the live transcribing/
|
|
55
|
+
// thinking bubble (waveform/dots) for the finalized message.
|
|
56
|
+
message.voiceProcessing ? "1" : "0",
|
|
52
57
|
message.variant ?? "",
|
|
53
58
|
message.rawContent?.length ?? 0,
|
|
54
59
|
message.llmContent?.length ?? 0,
|
package/src/utils/morph.ts
CHANGED
|
@@ -33,7 +33,7 @@ export const morphMessages = (
|
|
|
33
33
|
// Plugins actively mutating a node (e.g. glyph-cycle's tick loop)
|
|
34
34
|
// opt out of morph entirely via this attribute. Unlike
|
|
35
35
|
// `data-preserve-animation`, this is honored regardless of whether
|
|
36
|
-
// the new DOM carries the attribute
|
|
36
|
+
// the new DOM carries the attribute: it's a runtime-only marker.
|
|
37
37
|
if (oldNode.hasAttribute("data-preserve-runtime")) {
|
|
38
38
|
return false;
|
|
39
39
|
}
|
package/src/utils/sanitize.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type * as DOMPurifyType from "dompurify";
|
|
2
|
+
import { escapeHtml } from "../postprocessors";
|
|
3
|
+
import { getMarkdownParsersSync } from "../markdown-parsers-loader";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* A function that sanitizes an HTML string, returning safe HTML.
|
|
5
7
|
*/
|
|
6
8
|
export type SanitizeFunction = (html: string) => string;
|
|
7
9
|
|
|
8
|
-
const DEFAULT_PURIFY_CONFIG:
|
|
10
|
+
const DEFAULT_PURIFY_CONFIG: DOMPurifyType.Config = {
|
|
9
11
|
// Tags safe for markdown-rendered content
|
|
10
12
|
ALLOWED_TAGS: [
|
|
11
13
|
// Headings & structure
|
|
@@ -41,7 +43,7 @@ const DEFAULT_PURIFY_CONFIG: DOMPurify.Config = {
|
|
|
41
43
|
],
|
|
42
44
|
};
|
|
43
45
|
|
|
44
|
-
/** Raster image data URI pattern
|
|
46
|
+
/** Raster image data URI pattern: blocks SVG and other non-image types. */
|
|
45
47
|
const SAFE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|avif)/i;
|
|
46
48
|
|
|
47
49
|
/**
|
|
@@ -49,22 +51,36 @@ const SAFE_DATA_URI = /^data:image\/(?:png|jpe?g|gif|webp|bmp|x-icon|avif)/i;
|
|
|
49
51
|
* Uses the global window when available (browser).
|
|
50
52
|
*/
|
|
51
53
|
export const createDefaultSanitizer = (): SanitizeFunction => {
|
|
52
|
-
|
|
53
|
-
// The widget only runs in browsers, so `window` is always available at runtime.
|
|
54
|
-
const purify = DOMPurify(typeof window !== "undefined" ? window : (undefined as never));
|
|
54
|
+
let purifyInstance: ReturnType<typeof DOMPurifyType.default> | null = null;
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
data.keepAttr = false;
|
|
63
|
-
}
|
|
56
|
+
return (html: string): string => {
|
|
57
|
+
const parsers = getMarkdownParsersSync();
|
|
58
|
+
if (!parsers) {
|
|
59
|
+
// If DOMPurify hasn't loaded yet, fall back to escaping HTML completely
|
|
60
|
+
// to remain safe until the module is available.
|
|
61
|
+
return escapeHtml(html);
|
|
64
62
|
}
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
if (!purifyInstance) {
|
|
65
|
+
const { DOMPurify } = parsers;
|
|
66
|
+
// DOMPurify needs a DOM context. In the browser, pass `window`.
|
|
67
|
+
// The widget only runs in browsers, so `window` is always available at runtime.
|
|
68
|
+
purifyInstance = DOMPurify(typeof window !== "undefined" ? window : (undefined as never));
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
// Hook: strip data:image/svg+xml and other unsafe data: URIs from src/href
|
|
71
|
+
purifyInstance.addHook("uponSanitizeAttribute", (_node, data) => {
|
|
72
|
+
if (data.attrName === "src" || data.attrName === "href") {
|
|
73
|
+
const val = data.attrValue;
|
|
74
|
+
if (val.toLowerCase().startsWith("data:") && !SAFE_DATA_URI.test(val)) {
|
|
75
|
+
data.attrValue = "";
|
|
76
|
+
data.keepAttr = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return purifyInstance.sanitize(html, DEFAULT_PURIFY_CONFIG) as string;
|
|
83
|
+
};
|
|
68
84
|
};
|
|
69
85
|
|
|
70
86
|
/**
|
|
@@ -32,16 +32,16 @@ describe("SequenceReorderBuffer", () => {
|
|
|
32
32
|
|
|
33
33
|
it("reorders leading out-of-order events (3, 1, 2 → 1, 2, 3)", () => {
|
|
34
34
|
const buf = new SequenceReorderBuffer(emitter);
|
|
35
|
-
// seq=3 arrives first
|
|
35
|
+
// seq=3 arrives first: should be buffered (3 > nextExpected=1)
|
|
36
36
|
buf.push("step_delta", { seq: 3, text: "c" });
|
|
37
37
|
expect(emitted).toHaveLength(0);
|
|
38
38
|
|
|
39
|
-
// seq=1 arrives
|
|
39
|
+
// seq=1 arrives: matches nextExpected, emits, then drains seq=2 (not present), stops
|
|
40
40
|
buf.push("step_delta", { seq: 1, text: "a" });
|
|
41
41
|
expect(emitted).toHaveLength(1);
|
|
42
42
|
expect(emitted[0].payload.text).toBe("a");
|
|
43
43
|
|
|
44
|
-
// seq=2 arrives
|
|
44
|
+
// seq=2 arrives: matches nextExpected=2, emits, drains seq=3 from buffer
|
|
45
45
|
buf.push("step_delta", { seq: 2, text: "b" });
|
|
46
46
|
expect(emitted).toHaveLength(3);
|
|
47
47
|
expect(emitted[1].payload.text).toBe("b");
|
|
@@ -69,7 +69,7 @@ describe("SequenceReorderBuffer", () => {
|
|
|
69
69
|
|
|
70
70
|
expect(emitted).toHaveLength(1);
|
|
71
71
|
|
|
72
|
-
// Advance past gap timeout
|
|
72
|
+
// Advance past gap timeout: seq=2 never arrives, flush seq=3 anyway
|
|
73
73
|
vi.advanceTimersByTime(60);
|
|
74
74
|
|
|
75
75
|
expect(emitted).toHaveLength(2);
|
|
@@ -96,7 +96,7 @@ describe("SequenceReorderBuffer", () => {
|
|
|
96
96
|
buf.push("step_delta", { seq: 3, text: "c" });
|
|
97
97
|
expect(emitted).toHaveLength(3);
|
|
98
98
|
|
|
99
|
-
// Now seq=1 arrives again
|
|
99
|
+
// Now seq=1 arrives again: it's a duplicate (1 < nextExpected=4), still emitted
|
|
100
100
|
buf.push("step_delta", { seq: 1, text: "a-dup" });
|
|
101
101
|
expect(emitted).toHaveLength(4);
|
|
102
102
|
expect(emitted[3].payload.text).toBe("a-dup");
|
|
@@ -184,7 +184,7 @@ describe("SequenceReorderBuffer", () => {
|
|
|
184
184
|
|
|
185
185
|
it("leading gap flushes via timeout when seq=1 never arrives", () => {
|
|
186
186
|
const buf = new SequenceReorderBuffer(emitter, 50);
|
|
187
|
-
// Only seq=2 and seq=3 arrive
|
|
187
|
+
// Only seq=2 and seq=3 arrive: seq=1 is missing
|
|
188
188
|
buf.push("step_delta", { seq: 2, text: "b" });
|
|
189
189
|
buf.push("step_delta", { seq: 3, text: "c" });
|
|
190
190
|
expect(emitted).toHaveLength(0); // both buffered
|
|
@@ -232,7 +232,7 @@ describe("SequenceReorderBuffer", () => {
|
|
|
232
232
|
buf.push("step_delta", { seq: 3, text: "first-at-3" });
|
|
233
233
|
expect(emitted).toHaveLength(1);
|
|
234
234
|
|
|
235
|
-
// Second event with same seq=3
|
|
235
|
+
// Second event with same seq=3: prior one should be emitted out-of-order
|
|
236
236
|
buf.push("reason_delta", { seq: 3, text: "second-at-3" });
|
|
237
237
|
|
|
238
238
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
@@ -244,7 +244,7 @@ describe("SequenceReorderBuffer", () => {
|
|
|
244
244
|
expect(emitted).toHaveLength(2);
|
|
245
245
|
expect(emitted[1].payload.text).toBe("first-at-3");
|
|
246
246
|
|
|
247
|
-
// seq=2 arrives
|
|
247
|
+
// seq=2 arrives: advances nextExpected through the buffered second-at-3
|
|
248
248
|
buf.push("step_delta", { seq: 2, text: "b" });
|
|
249
249
|
expect(emitted).toHaveLength(4);
|
|
250
250
|
expect(emitted[2].payload.text).toBe("b");
|
|
@@ -19,7 +19,7 @@ export class SequenceReorderBuffer {
|
|
|
19
19
|
// - `agentContext.seq`: tool_start, tool_delta, tool_complete (agent loop)
|
|
20
20
|
const seq = payload?.seq ?? payload?.sequenceIndex ?? payload?.agentContext?.seq;
|
|
21
21
|
|
|
22
|
-
// No seq field
|
|
22
|
+
// No seq field: emit immediately (backward compat).
|
|
23
23
|
// If there are buffered events waiting for a gap to fill, flush them
|
|
24
24
|
// first: the server sending an unsequenced event means it has moved on
|
|
25
25
|
// and the missing seq numbers are not coming.
|
|
@@ -49,17 +49,17 @@ export class SequenceReorderBuffer {
|
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// If seq < nextExpected, it's a duplicate or late arrival
|
|
52
|
+
// If seq < nextExpected, it's a duplicate or late arrival: emit anyway (don't drop)
|
|
53
53
|
if (seq < this.nextExpectedSeq!) {
|
|
54
54
|
this.emitter(payloadType, payload);
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// seq > nextExpected
|
|
58
|
+
// seq > nextExpected: buffer it and start gap timer.
|
|
59
59
|
// If another event with the same seq is already buffered, the server
|
|
60
60
|
// broke its "seq is unique per stream" invariant. Rather than silently
|
|
61
61
|
// overwrite (losing one event) or swallow the new one, emit the prior
|
|
62
|
-
// event immediately
|
|
62
|
+
// event immediately, out of order, but better than dropping it, and
|
|
63
63
|
// warn so the issue is visible.
|
|
64
64
|
const existing = this.buffer.get(seq);
|
|
65
65
|
if (existing !== undefined) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Pure mapper test
|
|
1
|
+
// Pure mapper test: no DOM, no installed/vendored library at runtime (types only).
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
3
|
import { smartDomResultToEnriched } from "./smart-dom-adapter";
|
|
4
4
|
import { formatEnrichedContext } from "./dom-context";
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Pure mapper: `@mcp-b/smart-dom-reader` output → Persona's {@link EnrichedPageElement}[].
|
|
3
3
|
*
|
|
4
4
|
* This module imports the smart-dom-reader types with `import type` ONLY, so the
|
|
5
|
-
* library's runtime value is never pulled in here. That keeps the mapper
|
|
6
|
-
* unit test
|
|
5
|
+
* library's runtime value is never pulled in here. That keeps the mapper, and its
|
|
6
|
+
* unit test: free of any DOM and free of the (vendored) library at runtime; the
|
|
7
7
|
* types are erased during compilation.
|
|
8
8
|
*
|
|
9
9
|
* The smart-dom-reader returns a structured {@link SmartDOMResult} JSON object with
|
|
@@ -43,8 +43,7 @@ export interface SmartDomAdapterOptions {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Candidate selector types that resolve through `document.querySelector` (plain CSS).
|
|
46
|
-
* Everything else smart-dom-reader can emit
|
|
47
|
-
* is not actionable via the current click loop, so it is skipped here.
|
|
46
|
+
* Everything else smart-dom-reader can emit, `xpath` and text pseudo-selectors, * is not actionable via the current click loop, so it is skipped here.
|
|
48
47
|
*/
|
|
49
48
|
const PLAIN_CSS_CANDIDATE_TYPES: ReadonlySet<ElementSelectorCandidate["type"]> =
|
|
50
49
|
new Set(["id", "data-testid", "role-aria", "name", "class-path", "css-path"]);
|
|
@@ -119,7 +118,7 @@ function collectAttributes(el: ExtractedElement): Record<string, string> {
|
|
|
119
118
|
* Returns true when the element sits under `excludeSelector` (e.g. the widget host).
|
|
120
119
|
* smart-dom-reader has no exclude option and pierces shadow DOM by default, so this
|
|
121
120
|
* guards against the widget reading its own UI. The check is a substring match across
|
|
122
|
-
* the element's candidate selectors and ancestor chain
|
|
121
|
+
* the element's candidate selectors and ancestor chain: robust for the default
|
|
123
122
|
* `.persona-host` class guard.
|
|
124
123
|
*/
|
|
125
124
|
function isExcluded(el: ExtractedElement, excludeSelector: string): boolean {
|