@runtypelabs/persona 2.1.0 → 2.3.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/LICENSE +21 -0
- package/dist/index.cjs +41 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +130 -1
- package/dist/index.d.ts +130 -1
- package/dist/index.global.js +68 -64
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +41 -41
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/widget.css +40 -10
- package/package.json +3 -1
- package/src/client.ts +0 -1
- package/src/components/artifact-pane.ts +8 -1
- package/src/components/composer-builder.ts +1 -0
- package/src/components/header-builder.ts +1 -0
- package/src/components/header-layouts.ts +41 -1
- package/src/components/message-bubble.test.ts +97 -0
- package/src/components/message-bubble.ts +22 -2
- package/src/components/panel.ts +2 -0
- package/src/index.ts +19 -1
- package/src/install-config.test.ts +38 -0
- package/src/install.ts +3 -1
- package/src/postprocessors.test.ts +84 -0
- package/src/presets.ts +127 -0
- package/src/styles/widget.css +40 -10
- package/src/types/theme.ts +41 -0
- package/src/types.ts +29 -1
- package/src/ui.ts +25 -8
- package/src/utils/actions.test.ts +114 -0
- package/src/utils/sanitize.test.ts +114 -0
- package/src/utils/sanitize.ts +83 -0
- package/src/utils/tokens.ts +54 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createMarkdownProcessor,
|
|
5
|
+
createDirectivePostprocessor,
|
|
6
|
+
escapeHtml,
|
|
7
|
+
} from "./postprocessors";
|
|
8
|
+
import { createDefaultSanitizer } from "./utils/sanitize";
|
|
9
|
+
|
|
10
|
+
describe("markdown + sanitization integration", () => {
|
|
11
|
+
const md = createMarkdownProcessor();
|
|
12
|
+
const sanitize = createDefaultSanitizer();
|
|
13
|
+
|
|
14
|
+
it("strips script tags from markdown output", () => {
|
|
15
|
+
const html = sanitize(md("# Title\n<script>alert(1)</script>"));
|
|
16
|
+
expect(html).toContain("<h1>Title</h1>");
|
|
17
|
+
expect(html).not.toContain("<script>");
|
|
18
|
+
expect(html).not.toContain("alert(1)");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("strips onerror handlers from img tags in markdown", () => {
|
|
22
|
+
const html = sanitize(md('<img src="x" onerror="alert(1)">'));
|
|
23
|
+
expect(html).not.toContain("onerror");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("strips javascript: URIs from markdown links", () => {
|
|
27
|
+
const html = sanitize(md('[click](javascript:alert(1))'));
|
|
28
|
+
expect(html).not.toContain("javascript:");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("preserves safe markdown headings", () => {
|
|
32
|
+
const html = sanitize(md("## Hello\n\nParagraph text."));
|
|
33
|
+
expect(html).toContain("<h2>Hello</h2>");
|
|
34
|
+
expect(html).toContain("<p>Paragraph text.</p>");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("preserves safe markdown code blocks", () => {
|
|
38
|
+
const html = sanitize(md("```js\nconst x = 1;\n```"));
|
|
39
|
+
expect(html).toContain("<code");
|
|
40
|
+
expect(html).toContain("const x = 1;");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("preserves safe links", () => {
|
|
44
|
+
const html = sanitize(md("[example](https://example.com)"));
|
|
45
|
+
expect(html).toContain('href="https://example.com"');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("directive postprocessor + sanitization", () => {
|
|
50
|
+
const directive = createDirectivePostprocessor();
|
|
51
|
+
const sanitize = createDefaultSanitizer();
|
|
52
|
+
|
|
53
|
+
it("preserves form directive placeholders", () => {
|
|
54
|
+
const html = sanitize(directive('<Form type="init" />'));
|
|
55
|
+
expect(html).toContain('data-tv-form="init"');
|
|
56
|
+
expect(html).toContain("persona-form-directive");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sanitizes content surrounding directives", () => {
|
|
60
|
+
const html = sanitize(directive('<Form type="init" />\n<script>bad</script>'));
|
|
61
|
+
expect(html).toContain('data-tv-form="init"');
|
|
62
|
+
expect(html).not.toContain("<script>");
|
|
63
|
+
expect(html).not.toContain("bad");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles JSON-style directives", () => {
|
|
67
|
+
const html = sanitize(
|
|
68
|
+
directive('<Directive>{"component":"form","type":"contact"}</Directive>')
|
|
69
|
+
);
|
|
70
|
+
expect(html).toContain('data-tv-form="contact"');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("escapeHtml", () => {
|
|
75
|
+
it("escapes all HTML special characters", () => {
|
|
76
|
+
expect(escapeHtml('<script>alert("xss")&</script>')).toBe(
|
|
77
|
+
"<script>alert("xss")&</script>"
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("escapes single quotes", () => {
|
|
82
|
+
expect(escapeHtml("it's")).toBe("it's");
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { AgentWidgetConfig } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A named preset containing partial widget configuration.
|
|
5
|
+
* Apply with: `createAgentExperience(el, { ...PRESET_SHOP.config, apiUrl: '...' })`
|
|
6
|
+
* or via IIFE: `{ ...AgentWidget.PRESETS.shop.config, apiUrl: '...' }`
|
|
7
|
+
*/
|
|
8
|
+
export interface WidgetPreset {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
config: Partial<AgentWidgetConfig>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shopping / e-commerce preset.
|
|
16
|
+
* Dark header, rounded launchers, shopping-oriented copy.
|
|
17
|
+
*/
|
|
18
|
+
export const PRESET_SHOP: WidgetPreset = {
|
|
19
|
+
id: 'shop',
|
|
20
|
+
label: 'Shopping Assistant',
|
|
21
|
+
config: {
|
|
22
|
+
theme: {
|
|
23
|
+
primary: '#111827',
|
|
24
|
+
accent: '#1d4ed8',
|
|
25
|
+
surface: '#ffffff',
|
|
26
|
+
muted: '#6b7280',
|
|
27
|
+
container: '#f8fafc',
|
|
28
|
+
border: '#f1f5f9',
|
|
29
|
+
divider: '#f1f5f9',
|
|
30
|
+
messageBorder: '#f1f5f9',
|
|
31
|
+
inputBackground: '#ffffff',
|
|
32
|
+
callToAction: '#000000',
|
|
33
|
+
callToActionBackground: '#ffffff',
|
|
34
|
+
sendButtonBackgroundColor: '#111827',
|
|
35
|
+
sendButtonTextColor: '#ffffff',
|
|
36
|
+
radiusSm: '0.75rem',
|
|
37
|
+
radiusMd: '1rem',
|
|
38
|
+
radiusLg: '1.5rem',
|
|
39
|
+
launcherRadius: '9999px',
|
|
40
|
+
buttonRadius: '9999px',
|
|
41
|
+
},
|
|
42
|
+
launcher: {
|
|
43
|
+
title: 'Shopping Assistant',
|
|
44
|
+
subtitle: 'Here to help you find what you need',
|
|
45
|
+
agentIconText: '🛍️',
|
|
46
|
+
position: 'bottom-right',
|
|
47
|
+
width: 'min(400px, calc(100vw - 24px))',
|
|
48
|
+
},
|
|
49
|
+
copy: {
|
|
50
|
+
welcomeTitle: 'Welcome to our shop!',
|
|
51
|
+
welcomeSubtitle: 'I can help you find products and answer questions',
|
|
52
|
+
inputPlaceholder: 'Ask me anything...',
|
|
53
|
+
sendButtonLabel: 'Send',
|
|
54
|
+
},
|
|
55
|
+
suggestionChips: [
|
|
56
|
+
'What can you help me with?',
|
|
57
|
+
'Tell me about your features',
|
|
58
|
+
'How does this work?',
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Minimal preset.
|
|
65
|
+
* Stripped-down header, no launcher button, suitable for inline embeds.
|
|
66
|
+
*/
|
|
67
|
+
export const PRESET_MINIMAL: WidgetPreset = {
|
|
68
|
+
id: 'minimal',
|
|
69
|
+
label: 'Minimal',
|
|
70
|
+
config: {
|
|
71
|
+
launcher: {
|
|
72
|
+
enabled: false,
|
|
73
|
+
fullHeight: true,
|
|
74
|
+
},
|
|
75
|
+
layout: {
|
|
76
|
+
header: {
|
|
77
|
+
layout: 'minimal',
|
|
78
|
+
showCloseButton: false,
|
|
79
|
+
},
|
|
80
|
+
messages: {
|
|
81
|
+
layout: 'minimal',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
theme: {
|
|
85
|
+
panelBorderRadius: '0',
|
|
86
|
+
panelShadow: 'none',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fullscreen assistant preset.
|
|
93
|
+
* No launcher, content-max-width constrained, minimal header.
|
|
94
|
+
*/
|
|
95
|
+
export const PRESET_FULLSCREEN: WidgetPreset = {
|
|
96
|
+
id: 'fullscreen',
|
|
97
|
+
label: 'Fullscreen Assistant',
|
|
98
|
+
config: {
|
|
99
|
+
launcher: {
|
|
100
|
+
enabled: false,
|
|
101
|
+
fullHeight: true,
|
|
102
|
+
},
|
|
103
|
+
layout: {
|
|
104
|
+
header: {
|
|
105
|
+
layout: 'minimal',
|
|
106
|
+
showCloseButton: false,
|
|
107
|
+
},
|
|
108
|
+
contentMaxWidth: '72ch',
|
|
109
|
+
},
|
|
110
|
+
theme: {
|
|
111
|
+
panelBorderRadius: '0',
|
|
112
|
+
panelShadow: 'none',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** All named presets keyed by ID. */
|
|
118
|
+
export const PRESETS: Record<string, WidgetPreset> = {
|
|
119
|
+
shop: PRESET_SHOP,
|
|
120
|
+
minimal: PRESET_MINIMAL,
|
|
121
|
+
fullscreen: PRESET_FULLSCREEN,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Look up a preset by ID. */
|
|
125
|
+
export function getPreset(id: string): WidgetPreset | undefined {
|
|
126
|
+
return PRESETS[id];
|
|
127
|
+
}
|
package/src/styles/widget.css
CHANGED
|
@@ -2064,9 +2064,9 @@
|
|
|
2064
2064
|
display: inline-flex;
|
|
2065
2065
|
align-items: center;
|
|
2066
2066
|
justify-content: center;
|
|
2067
|
-
padding: 0.25rem;
|
|
2068
|
-
border-radius: var(--persona-radius-md, 0.375rem);
|
|
2069
|
-
border: 1px solid var(--persona-border, #e5e7eb);
|
|
2067
|
+
padding: var(--persona-artifact-toolbar-icon-padding, 0.25rem);
|
|
2068
|
+
border-radius: var(--persona-artifact-toolbar-icon-radius, var(--persona-radius-md, 0.375rem));
|
|
2069
|
+
border: var(--persona-artifact-toolbar-icon-border, 1px solid var(--persona-border, #e5e7eb));
|
|
2070
2070
|
background: var(--persona-surface, #ffffff);
|
|
2071
2071
|
color: var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827));
|
|
2072
2072
|
cursor: pointer;
|
|
@@ -2074,7 +2074,8 @@
|
|
|
2074
2074
|
}
|
|
2075
2075
|
|
|
2076
2076
|
#persona-root .persona-artifact-toolbar-document .persona-artifact-doc-icon-btn:hover {
|
|
2077
|
-
|
|
2077
|
+
color: var(--persona-artifact-toolbar-icon-hover-color, inherit);
|
|
2078
|
+
background: var(--persona-artifact-toolbar-icon-hover-bg, var(--persona-container, #f3f4f6));
|
|
2078
2079
|
}
|
|
2079
2080
|
|
|
2080
2081
|
#persona-root .persona-artifact-toolbar-document .persona-artifact-doc-icon-btn[aria-pressed="true"] {
|
|
@@ -2086,24 +2087,53 @@
|
|
|
2086
2087
|
display: inline-flex;
|
|
2087
2088
|
align-items: center;
|
|
2088
2089
|
gap: 0.35rem;
|
|
2089
|
-
padding: 0.25rem 0.5rem;
|
|
2090
|
-
border-radius: var(--persona-radius-md, 0.375rem);
|
|
2091
|
-
border: 1px solid var(--persona-border, #e5e7eb);
|
|
2092
|
-
background: var(--persona-surface, #ffffff);
|
|
2093
|
-
color: var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827));
|
|
2090
|
+
padding: var(--persona-artifact-toolbar-copy-padding, 0.25rem 0.5rem);
|
|
2091
|
+
border-radius: var(--persona-artifact-toolbar-copy-radius, var(--persona-radius-md, 0.375rem));
|
|
2092
|
+
border: var(--persona-artifact-toolbar-copy-border, 1px solid var(--persona-border, #e5e7eb));
|
|
2093
|
+
background: var(--persona-artifact-toolbar-copy-bg, var(--persona-surface, #ffffff));
|
|
2094
|
+
color: var(--persona-artifact-toolbar-copy-color, var(--persona-artifact-doc-toolbar-icon-color, var(--persona-text, #111827)));
|
|
2094
2095
|
cursor: pointer;
|
|
2095
2096
|
font-size: 0.75rem;
|
|
2096
2097
|
line-height: 1.25;
|
|
2097
2098
|
}
|
|
2098
2099
|
|
|
2099
2100
|
#persona-root .persona-artifact-toolbar-document .persona-artifact-doc-copy-btn:hover {
|
|
2100
|
-
background: var(--persona-container, #f3f4f6);
|
|
2101
|
+
background: var(--persona-artifact-toolbar-icon-hover-bg, var(--persona-container, #f3f4f6));
|
|
2101
2102
|
}
|
|
2102
2103
|
|
|
2103
2104
|
#persona-root .persona-artifact-toolbar-document .persona-artifact-doc-copy-label {
|
|
2104
2105
|
font-weight: 500;
|
|
2105
2106
|
}
|
|
2106
2107
|
|
|
2108
|
+
/* Copy menu dropdown theming */
|
|
2109
|
+
#persona-root .persona-artifact-doc-copy-menu {
|
|
2110
|
+
background: var(--persona-artifact-toolbar-copy-menu-bg, var(--persona-surface, #fff));
|
|
2111
|
+
border: var(--persona-artifact-toolbar-copy-menu-border, 1px solid var(--persona-border, #e5e7eb));
|
|
2112
|
+
box-shadow: var(--persona-artifact-toolbar-copy-menu-shadow, 0 4px 6px -1px rgba(0,0,0,.1));
|
|
2113
|
+
border-radius: var(--persona-artifact-toolbar-copy-menu-radius, 0.375rem);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
#persona-root .persona-artifact-doc-copy-menu button:hover {
|
|
2117
|
+
background: var(--persona-artifact-toolbar-copy-menu-item-hover-bg, var(--persona-container, #f3f4f6));
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/* Artifact tab theming */
|
|
2121
|
+
#persona-root .persona-artifact-tab {
|
|
2122
|
+
background: var(--persona-artifact-tab-bg, transparent);
|
|
2123
|
+
border-radius: var(--persona-artifact-tab-radius, 0.5rem);
|
|
2124
|
+
color: var(--persona-artifact-tab-color, inherit);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
#persona-root .persona-artifact-tab.persona-bg-persona-container {
|
|
2128
|
+
background: var(--persona-artifact-tab-active-bg, var(--persona-container, #f3f4f6));
|
|
2129
|
+
border-color: var(--persona-artifact-tab-active-border, var(--persona-border, #e5e7eb));
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/* Artifact toolbar background theming */
|
|
2133
|
+
#persona-root .persona-artifact-toolbar {
|
|
2134
|
+
background: var(--persona-artifact-toolbar-bg, var(--persona-surface, #fff));
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2107
2137
|
/* Draggable split handle (desktop split only; hidden in drawer / narrow host / small viewport) */
|
|
2108
2138
|
#persona-root .persona-artifact-split-handle {
|
|
2109
2139
|
width: 6px;
|
package/src/types/theme.ts
CHANGED
|
@@ -298,6 +298,41 @@ export interface ComposerChromeTokens {
|
|
|
298
298
|
shadow: string;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/** Artifact toolbar chrome. */
|
|
302
|
+
export interface ArtifactToolbarTokens {
|
|
303
|
+
iconHoverColor?: string;
|
|
304
|
+
iconHoverBackground?: string;
|
|
305
|
+
iconPadding?: string;
|
|
306
|
+
iconBorderRadius?: string;
|
|
307
|
+
iconBorder?: string;
|
|
308
|
+
toggleGroupGap?: string;
|
|
309
|
+
toggleBorderRadius?: string;
|
|
310
|
+
copyBackground?: string;
|
|
311
|
+
copyBorder?: string;
|
|
312
|
+
copyColor?: string;
|
|
313
|
+
copyBorderRadius?: string;
|
|
314
|
+
copyPadding?: string;
|
|
315
|
+
copyMenuBackground?: string;
|
|
316
|
+
copyMenuBorder?: string;
|
|
317
|
+
copyMenuShadow?: string;
|
|
318
|
+
copyMenuBorderRadius?: string;
|
|
319
|
+
copyMenuItemHoverBackground?: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Artifact tab strip chrome. */
|
|
323
|
+
export interface ArtifactTabTokens {
|
|
324
|
+
background?: string;
|
|
325
|
+
activeBackground?: string;
|
|
326
|
+
activeBorder?: string;
|
|
327
|
+
borderRadius?: string;
|
|
328
|
+
textColor?: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Artifact pane chrome. */
|
|
332
|
+
export interface ArtifactPaneTokens {
|
|
333
|
+
toolbarBackground?: string;
|
|
334
|
+
}
|
|
335
|
+
|
|
301
336
|
export interface ComponentTokens {
|
|
302
337
|
button: ButtonTokens;
|
|
303
338
|
input: InputTokens;
|
|
@@ -313,6 +348,12 @@ export interface ComponentTokens {
|
|
|
313
348
|
toolBubble: ToolBubbleTokens;
|
|
314
349
|
reasoningBubble: ReasoningBubbleTokens;
|
|
315
350
|
composer: ComposerChromeTokens;
|
|
351
|
+
/** Artifact toolbar, tab strip, and pane chrome. */
|
|
352
|
+
artifact?: {
|
|
353
|
+
toolbar?: ArtifactToolbarTokens;
|
|
354
|
+
tab?: ArtifactTabTokens;
|
|
355
|
+
pane?: ArtifactPaneTokens;
|
|
356
|
+
};
|
|
316
357
|
}
|
|
317
358
|
|
|
318
359
|
export interface PaletteExtras {
|
package/src/types.ts
CHANGED
|
@@ -525,6 +525,14 @@ export type AgentWidgetArtifactsFeature = {
|
|
|
525
525
|
allowedTypes?: PersonaArtifactKind[];
|
|
526
526
|
/** Split / drawer dimensions and launcher widen behavior */
|
|
527
527
|
layout?: AgentWidgetArtifactsLayoutConfig;
|
|
528
|
+
/**
|
|
529
|
+
* Called when an artifact card action is triggered (open, download).
|
|
530
|
+
* Return `true` to prevent the default behavior.
|
|
531
|
+
*/
|
|
532
|
+
onArtifactAction?: (action: {
|
|
533
|
+
type: 'open' | 'download';
|
|
534
|
+
artifactId: string;
|
|
535
|
+
}) => boolean | void;
|
|
528
536
|
};
|
|
529
537
|
|
|
530
538
|
export type AgentWidgetFeatureFlags = {
|
|
@@ -1452,6 +1460,12 @@ export type AgentWidgetHeaderLayoutConfig = {
|
|
|
1452
1460
|
trailingActions?: AgentWidgetHeaderTrailingAction[];
|
|
1453
1461
|
/** Called when a `trailingActions` button is clicked. */
|
|
1454
1462
|
onAction?: (actionId: string) => void;
|
|
1463
|
+
/**
|
|
1464
|
+
* Called when the header title row is clicked.
|
|
1465
|
+
* Useful for dropdown menus or navigation triggered from the header.
|
|
1466
|
+
* When set, the title row becomes visually interactive (cursor: pointer).
|
|
1467
|
+
*/
|
|
1468
|
+
onTitleClick?: () => void;
|
|
1455
1469
|
};
|
|
1456
1470
|
|
|
1457
1471
|
/**
|
|
@@ -2557,7 +2571,21 @@ export type AgentWidgetConfig = {
|
|
|
2557
2571
|
* ```
|
|
2558
2572
|
*/
|
|
2559
2573
|
markdown?: AgentWidgetMarkdownConfig;
|
|
2560
|
-
|
|
2574
|
+
|
|
2575
|
+
/**
|
|
2576
|
+
* HTML sanitization for rendered message content.
|
|
2577
|
+
*
|
|
2578
|
+
* The widget renders AI-generated markdown as HTML. By default, all HTML
|
|
2579
|
+
* output is sanitized using DOMPurify to prevent XSS attacks.
|
|
2580
|
+
*
|
|
2581
|
+
* - `true` (default): sanitize using built-in DOMPurify
|
|
2582
|
+
* - `false`: disable sanitization (only use with fully trusted content sources)
|
|
2583
|
+
* - `(html: string) => string`: custom sanitizer function
|
|
2584
|
+
*
|
|
2585
|
+
* @default true
|
|
2586
|
+
*/
|
|
2587
|
+
sanitize?: boolean | ((html: string) => string);
|
|
2588
|
+
|
|
2561
2589
|
/**
|
|
2562
2590
|
* Configuration for message action buttons (copy, upvote, downvote).
|
|
2563
2591
|
* Shows action buttons on assistant messages for user feedback.
|
package/src/ui.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
|
|
2
|
+
import { resolveSanitizer } from "./utils/sanitize";
|
|
2
3
|
import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
|
|
3
4
|
import {
|
|
4
5
|
AgentWidgetConfig,
|
|
@@ -321,6 +322,9 @@ const buildPostprocessor = (
|
|
|
321
322
|
? createMarkdownProcessorFromConfig(cfg.markdown)
|
|
322
323
|
: null;
|
|
323
324
|
|
|
325
|
+
// Resolve sanitizer: enabled by default, can be disabled or replaced
|
|
326
|
+
const sanitize = resolveSanitizer(cfg?.sanitize);
|
|
327
|
+
|
|
324
328
|
return (context) => {
|
|
325
329
|
let nextText = context.text ?? "";
|
|
326
330
|
const rawPayload = context.message.rawContent ?? null;
|
|
@@ -347,20 +351,20 @@ const buildPostprocessor = (
|
|
|
347
351
|
}
|
|
348
352
|
|
|
349
353
|
// Priority: postprocessMessage > markdown config > escapeHtml
|
|
354
|
+
let html: string;
|
|
350
355
|
if (cfg?.postprocessMessage) {
|
|
351
|
-
|
|
356
|
+
html = cfg.postprocessMessage({
|
|
352
357
|
...context,
|
|
353
358
|
text: nextText,
|
|
354
359
|
raw: rawPayload ?? context.text ?? ""
|
|
355
360
|
});
|
|
361
|
+
} else if (markdownProcessor) {
|
|
362
|
+
html = markdownProcessor(nextText);
|
|
363
|
+
} else {
|
|
364
|
+
html = escapeHtml(nextText);
|
|
356
365
|
}
|
|
357
366
|
|
|
358
|
-
|
|
359
|
-
if (markdownProcessor) {
|
|
360
|
-
return markdownProcessor(nextText);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return escapeHtml(nextText);
|
|
367
|
+
return sanitize ? sanitize(html) : html;
|
|
364
368
|
};
|
|
365
369
|
};
|
|
366
370
|
|
|
@@ -1142,6 +1146,9 @@ export const createAgentExperience = (
|
|
|
1142
1146
|
event.stopPropagation();
|
|
1143
1147
|
const artifactId = dlBtn.getAttribute('data-download-artifact');
|
|
1144
1148
|
if (!artifactId) return;
|
|
1149
|
+
// Let integrator intercept
|
|
1150
|
+
const dlPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'download', artifactId });
|
|
1151
|
+
if (dlPrevented === true) return;
|
|
1145
1152
|
// Try session state first, fall back to content stored in the card's rawContent props
|
|
1146
1153
|
const artifact = session.getArtifactById(artifactId);
|
|
1147
1154
|
let markdown = artifact?.markdown;
|
|
@@ -1180,6 +1187,9 @@ export const createAgentExperience = (
|
|
|
1180
1187
|
if (!card) return;
|
|
1181
1188
|
const artifactId = card.getAttribute('data-open-artifact');
|
|
1182
1189
|
if (!artifactId) return;
|
|
1190
|
+
// Let integrator intercept
|
|
1191
|
+
const openPrevented = config.features?.artifacts?.onArtifactAction?.({ type: 'open', artifactId });
|
|
1192
|
+
if (openPrevented === true) return;
|
|
1183
1193
|
event.preventDefault();
|
|
1184
1194
|
event.stopPropagation();
|
|
1185
1195
|
session.selectArtifact(artifactId);
|
|
@@ -3560,6 +3570,8 @@ export const createAgentExperience = (
|
|
|
3560
3570
|
const previousMessageActions = config.messageActions;
|
|
3561
3571
|
const previousLayoutMessages = config.layout?.messages;
|
|
3562
3572
|
const previousColorScheme = config.colorScheme;
|
|
3573
|
+
const previousLoadingIndicator = config.loadingIndicator;
|
|
3574
|
+
const previousIterationDisplay = config.iterationDisplay;
|
|
3563
3575
|
config = { ...config, ...nextConfig };
|
|
3564
3576
|
// applyFullHeightStyles resets mount.style.cssText, so call it before applyThemeVariables
|
|
3565
3577
|
applyFullHeightStyles();
|
|
@@ -3790,7 +3802,12 @@ export const createAgentExperience = (
|
|
|
3790
3802
|
const toolCallConfigChanged = JSON.stringify(nextConfig.toolCall) !== JSON.stringify(previousToolCallConfig);
|
|
3791
3803
|
const messageActionsChanged = JSON.stringify(config.messageActions) !== JSON.stringify(previousMessageActions);
|
|
3792
3804
|
const layoutMessagesChanged = JSON.stringify(config.layout?.messages) !== JSON.stringify(previousLayoutMessages);
|
|
3793
|
-
const
|
|
3805
|
+
const loadingIndicatorChanged = config.loadingIndicator?.render !== previousLoadingIndicator?.render
|
|
3806
|
+
|| config.loadingIndicator?.renderIdle !== previousLoadingIndicator?.renderIdle
|
|
3807
|
+
|| config.loadingIndicator?.showBubble !== previousLoadingIndicator?.showBubble;
|
|
3808
|
+
const iterationDisplayChanged = config.iterationDisplay !== previousIterationDisplay;
|
|
3809
|
+
const messagesConfigChanged = toolCallConfigChanged || messageActionsChanged || layoutMessagesChanged
|
|
3810
|
+
|| loadingIndicatorChanged || iterationDisplayChanged;
|
|
3794
3811
|
if (messagesConfigChanged && session) {
|
|
3795
3812
|
configVersion++;
|
|
3796
3813
|
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
defaultJsonActionParser,
|
|
4
|
+
defaultActionHandlers,
|
|
5
|
+
createActionManager,
|
|
6
|
+
} from "./actions";
|
|
7
|
+
import type { AgentWidgetMessage } from "../types";
|
|
8
|
+
|
|
9
|
+
const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMessage => ({
|
|
10
|
+
id: "msg-1",
|
|
11
|
+
role: "assistant",
|
|
12
|
+
content: "",
|
|
13
|
+
createdAt: new Date().toISOString(),
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("defaultJsonActionParser", () => {
|
|
18
|
+
it("parses valid action JSON", () => {
|
|
19
|
+
const result = defaultJsonActionParser({
|
|
20
|
+
text: '{"action":"message","text":"hi"}',
|
|
21
|
+
message: makeMessage(),
|
|
22
|
+
});
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
type: "message",
|
|
25
|
+
payload: { text: "hi" },
|
|
26
|
+
raw: { action: "message", text: "hi" },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null for non-action JSON", () => {
|
|
31
|
+
const result = defaultJsonActionParser({
|
|
32
|
+
text: '{"foo":"bar"}',
|
|
33
|
+
message: makeMessage(),
|
|
34
|
+
});
|
|
35
|
+
expect(result).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null for non-JSON text", () => {
|
|
39
|
+
const result = defaultJsonActionParser({
|
|
40
|
+
text: "hello world",
|
|
41
|
+
message: makeMessage(),
|
|
42
|
+
});
|
|
43
|
+
expect(result).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null for empty text", () => {
|
|
47
|
+
expect(defaultJsonActionParser({ text: "", message: makeMessage() })).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("strips code fences before parsing", () => {
|
|
51
|
+
const text = '```json\n{"action":"message","text":"fenced"}\n```';
|
|
52
|
+
const result = defaultJsonActionParser({ text, message: makeMessage() });
|
|
53
|
+
expect(result).toEqual({
|
|
54
|
+
type: "message",
|
|
55
|
+
payload: { text: "fenced" },
|
|
56
|
+
raw: { action: "message", text: "fenced" },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("createActionManager.process", () => {
|
|
62
|
+
const makeManager = (overrides?: Record<string, unknown>) => {
|
|
63
|
+
let metadata: Record<string, unknown> = {};
|
|
64
|
+
return createActionManager({
|
|
65
|
+
parsers: [defaultJsonActionParser],
|
|
66
|
+
handlers: [defaultActionHandlers.message],
|
|
67
|
+
getSessionMetadata: () => metadata,
|
|
68
|
+
updateSessionMetadata: (updater) => { metadata = updater(metadata); },
|
|
69
|
+
emit: vi.fn(),
|
|
70
|
+
documentRef: null,
|
|
71
|
+
...overrides,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it("skips streaming messages", () => {
|
|
76
|
+
const manager = makeManager();
|
|
77
|
+
const result = manager.process({
|
|
78
|
+
text: '{"action":"message","text":"hi"}',
|
|
79
|
+
message: makeMessage(),
|
|
80
|
+
streaming: true,
|
|
81
|
+
});
|
|
82
|
+
expect(result).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("skips non-assistant messages", () => {
|
|
86
|
+
const manager = makeManager();
|
|
87
|
+
const result = manager.process({
|
|
88
|
+
text: '{"action":"message","text":"hi"}',
|
|
89
|
+
message: makeMessage({ role: "user" }),
|
|
90
|
+
streaming: false,
|
|
91
|
+
});
|
|
92
|
+
expect(result).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("deduplicates by message ID", () => {
|
|
96
|
+
const manager = makeManager();
|
|
97
|
+
const msg = makeMessage({ content: '{"action":"message","text":"hi"}' });
|
|
98
|
+
const first = manager.process({ text: msg.content, message: msg, streaming: false });
|
|
99
|
+
expect(first).not.toBeNull();
|
|
100
|
+
|
|
101
|
+
const second = manager.process({ text: msg.content, message: msg, streaming: false });
|
|
102
|
+
expect(second).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("processes valid action and returns display text", () => {
|
|
106
|
+
const manager = makeManager();
|
|
107
|
+
const result = manager.process({
|
|
108
|
+
text: '{"action":"message","text":"hello"}',
|
|
109
|
+
message: makeMessage(),
|
|
110
|
+
streaming: false,
|
|
111
|
+
});
|
|
112
|
+
expect(result).toEqual({ text: "hello", persist: true, resubmit: undefined });
|
|
113
|
+
});
|
|
114
|
+
});
|