@runtypelabs/persona 4.14.0 → 4.16.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 +4 -4
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
- package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-QJ4LV2KW.js +1 -0
- package/dist/codegen.cjs +12 -11
- package/dist/codegen.js +14 -13
- package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
- package/dist/context-mentions.cjs +11 -8
- package/dist/context-mentions.d.cts +520 -21
- package/dist/context-mentions.d.ts +520 -21
- package/dist/context-mentions.js +11 -8
- package/dist/index.cjs +69 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +595 -46
- package/dist/index.d.ts +595 -46
- package/dist/index.global.js +60 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -68
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +520 -21
- package/dist/smart-dom-reader.d.ts +520 -21
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +67 -67
- package/dist/theme-editor-preview.d.cts +534 -22
- package/dist/theme-editor-preview.d.ts +534 -22
- package/dist/theme-editor-preview.js +57 -57
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +521 -23
- package/dist/theme-editor.d.ts +521 -23
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +104 -3
- package/dist/theme-reference.d.ts +104 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +10 -7
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/artifact-preview.ts +1 -1
- package/src/components/composer-parts.test.ts +103 -4
- package/src/components/composer-parts.ts +53 -15
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/messages.ts +5 -2
- package/src/components/panel.ts +92 -71
- package/src/components/pill-composer-builder.test.ts +18 -0
- package/src/components/suggestions.test.ts +273 -0
- package/src/components/suggestions.ts +528 -86
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +18 -7
- package/src/defaults.ts +7 -5
- package/src/generated/runtype-openapi-contract.ts +17 -0
- package/src/index-core.ts +29 -2
- package/src/launcher-global.ts +11 -8
- package/src/plugins/types.ts +136 -3
- package/src/presets.ts +3 -3
- package/src/session.ts +10 -8
- package/src/styles/widget-styles.test.ts +382 -0
- package/src/styles/widget.css +778 -4
- package/src/suggest-replies-tool.test.ts +233 -2
- package/src/suggest-replies-tool.ts +115 -39
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/preview-utils.test.ts +34 -0
- package/src/theme-editor/preview-utils.ts +77 -2
- package/src/theme-editor/sections.test.ts +33 -0
- package/src/theme-editor/sections.ts +83 -6
- package/src/theme-reference.ts +34 -5
- package/src/types/theme.ts +72 -3
- package/src/types.ts +312 -18
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.detached-panel.test.ts +1 -1
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +1112 -1
- package/src/ui.ts +1008 -149
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-file.test.ts +1 -1
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/code-generators.test.ts +40 -0
- package/src/utils/code-generators.ts +22 -0
- package/src/utils/config-merge.test.ts +24 -0
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/icons.test.ts +26 -0
- package/src/utils/icons.ts +8 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +216 -0
- package/src/utils/tokens.ts +113 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
- package/dist/chunk-MMUPR2JW.js +0 -1
|
@@ -563,6 +563,46 @@ describe("Full Config with Hooks Integration", () => {
|
|
|
563
563
|
// Backward Compatibility Tests
|
|
564
564
|
// =============================================================================
|
|
565
565
|
|
|
566
|
+
describe("Suggestions Serialization", () => {
|
|
567
|
+
it("preserves structured suggestion configuration in every format", () => {
|
|
568
|
+
const config = {
|
|
569
|
+
...minimalConfig,
|
|
570
|
+
suggestions: {
|
|
571
|
+
starters: {
|
|
572
|
+
variant: "card",
|
|
573
|
+
behavior: "fill",
|
|
574
|
+
items: [
|
|
575
|
+
{
|
|
576
|
+
id: "compare",
|
|
577
|
+
label: "Compare plans",
|
|
578
|
+
prompt: "Compare plans for my team",
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
},
|
|
582
|
+
followUps: {
|
|
583
|
+
placement: "after-message",
|
|
584
|
+
overflow: "scroll",
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
const formats: CodeFormat[] = [
|
|
589
|
+
"esm",
|
|
590
|
+
"react-component",
|
|
591
|
+
"react-advanced",
|
|
592
|
+
"script-installer",
|
|
593
|
+
"script-manual",
|
|
594
|
+
"script-advanced",
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
for (const format of formats) {
|
|
598
|
+
const code = generateCodeSnippet(config, format);
|
|
599
|
+
expect(code).toContain("suggestions");
|
|
600
|
+
expect(code).toContain("Compare plans");
|
|
601
|
+
expect(code).toContain("after-message");
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
566
606
|
describe("Backward Compatibility", () => {
|
|
567
607
|
it("should work without options parameter", () => {
|
|
568
608
|
const code = generateCodeSnippet(minimalConfig, "esm");
|
|
@@ -430,6 +430,19 @@ function generateMarkdownConfig(config: any, indent: string): string[] {
|
|
|
430
430
|
return lines;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// Suggestions may contain nested rich items. JSON object syntax is also valid
|
|
434
|
+
// JavaScript/TypeScript, so preserve the complete public shape instead of
|
|
435
|
+
// duplicating every leaf in each output-format generator.
|
|
436
|
+
function generateSuggestionsConfig(config: any, indent: string): string[] {
|
|
437
|
+
if (!config.suggestions) return [];
|
|
438
|
+
const serialized = JSON.stringify(config.suggestions, null, 2).split("\n");
|
|
439
|
+
return serialized.map((line, index) => {
|
|
440
|
+
const prefix = index === 0 ? `${indent}suggestions: ` : indent;
|
|
441
|
+
const suffix = index === serialized.length - 1 ? "," : "";
|
|
442
|
+
return `${prefix}${line}${suffix}`;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
433
446
|
// Helper to generate layout config (excluding render functions and slots)
|
|
434
447
|
function generateLayoutConfig(config: any, indent: string): string[] {
|
|
435
448
|
const lines: string[] = [];
|
|
@@ -697,6 +710,8 @@ function generateESMCode(config: any, options?: CodeGeneratorOptions): string {
|
|
|
697
710
|
lines.push(" },");
|
|
698
711
|
}
|
|
699
712
|
|
|
713
|
+
lines.push(...generateSuggestionsConfig(config, " "));
|
|
714
|
+
|
|
700
715
|
if (config.suggestionChips && config.suggestionChips.length > 0) {
|
|
701
716
|
lines.push(" suggestionChips: [");
|
|
702
717
|
config.suggestionChips.forEach((chip: string) => {
|
|
@@ -845,6 +860,8 @@ function generateReactComponentCode(config: any, options?: CodeGeneratorOptions)
|
|
|
845
860
|
lines.push(" },");
|
|
846
861
|
}
|
|
847
862
|
|
|
863
|
+
lines.push(...generateSuggestionsConfig(config, " "));
|
|
864
|
+
|
|
848
865
|
if (config.suggestionChips && config.suggestionChips.length > 0) {
|
|
849
866
|
lines.push(" suggestionChips: [");
|
|
850
867
|
config.suggestionChips.forEach((chip: string) => {
|
|
@@ -1110,6 +1127,8 @@ function generateReactAdvancedCode(config: any, options?: CodeGeneratorOptions):
|
|
|
1110
1127
|
lines.push(" },");
|
|
1111
1128
|
}
|
|
1112
1129
|
|
|
1130
|
+
lines.push(...generateSuggestionsConfig(config, " "));
|
|
1131
|
+
|
|
1113
1132
|
if (config.suggestionChips && config.suggestionChips.length > 0) {
|
|
1114
1133
|
lines.push(" suggestionChips: [");
|
|
1115
1134
|
config.suggestionChips.forEach((chip: string) => {
|
|
@@ -1312,6 +1331,7 @@ function buildSerializableConfig(config: any): Record<string, any> {
|
|
|
1312
1331
|
if (config.voiceRecognition) serializableConfig.voiceRecognition = config.voiceRecognition;
|
|
1313
1332
|
if (config.statusIndicator) serializableConfig.statusIndicator = config.statusIndicator;
|
|
1314
1333
|
if (config.features) serializableConfig.features = config.features;
|
|
1334
|
+
if (config.suggestions) serializableConfig.suggestions = config.suggestions;
|
|
1315
1335
|
if (config.suggestionChips?.length > 0) serializableConfig.suggestionChips = config.suggestionChips;
|
|
1316
1336
|
if (config.suggestionChipsConfig) serializableConfig.suggestionChipsConfig = config.suggestionChipsConfig;
|
|
1317
1337
|
if (config.debug) serializableConfig.debug = config.debug;
|
|
@@ -1520,6 +1540,8 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
|
|
|
1520
1540
|
lines.push(" },");
|
|
1521
1541
|
}
|
|
1522
1542
|
|
|
1543
|
+
lines.push(...generateSuggestionsConfig(config, " "));
|
|
1544
|
+
|
|
1523
1545
|
if (config.suggestionChips && config.suggestionChips.length > 0) {
|
|
1524
1546
|
lines.push(" suggestionChips: [");
|
|
1525
1547
|
config.suggestionChips.forEach((chip: string) => {
|
|
@@ -32,6 +32,30 @@ describe("mergeConfigUpdate", () => {
|
|
|
32
32
|
expect(next.suggestionChips).toEqual(["x"]);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it("patches suggestion surfaces without dropping sibling behavior", () => {
|
|
36
|
+
const prev = base({
|
|
37
|
+
suggestions: {
|
|
38
|
+
starters: { variant: "card", behavior: "fill" },
|
|
39
|
+
followUps: { variant: "chip", placement: "after-message" },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const next = mergeConfigUpdate(prev, {
|
|
43
|
+
suggestions: {
|
|
44
|
+
followUps: { overflow: "wrap" },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(next.suggestions?.starters).toEqual({
|
|
49
|
+
variant: "card",
|
|
50
|
+
behavior: "fill",
|
|
51
|
+
});
|
|
52
|
+
expect(next.suggestions?.followUps).toEqual({
|
|
53
|
+
variant: "chip",
|
|
54
|
+
placement: "after-message",
|
|
55
|
+
overflow: "wrap",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
35
59
|
it("replaces callbacks wholesale", () => {
|
|
36
60
|
const first = vi.fn();
|
|
37
61
|
const second = vi.fn();
|
|
@@ -25,6 +25,8 @@ const REPLACE_LEAF_PATHS = new Set<string>([
|
|
|
25
25
|
"features.artifacts.display",
|
|
26
26
|
"features.streamAnimation.plugins",
|
|
27
27
|
"preferences",
|
|
28
|
+
// Discriminated union: recursive merge would splice two icon variants.
|
|
29
|
+
"welcome.icon",
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
32
|
// Recursive patch merge. A key merges recursively iff both previous and patch
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { resolveContentMaxWidth } from "./content-width";
|
|
4
|
+
import type { AgentWidgetConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
const cfg = (value?: Partial<AgentWidgetConfig>) =>
|
|
7
|
+
value as AgentWidgetConfig | undefined;
|
|
8
|
+
|
|
9
|
+
describe("resolveContentMaxWidth", () => {
|
|
10
|
+
it("defaults panels to the surveyed 768px column", () => {
|
|
11
|
+
expect(resolveContentMaxWidth(cfg({}), false)).toBe("768px");
|
|
12
|
+
expect(resolveContentMaxWidth(undefined, false)).toBe("768px");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps the composer-bar's own 720px fallback", () => {
|
|
16
|
+
expect(resolveContentMaxWidth(cfg({}), true)).toBe("720px");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("lets layout.contentMaxWidth win everywhere", () => {
|
|
20
|
+
const config = cfg({ layout: { contentMaxWidth: "90ch" } });
|
|
21
|
+
expect(resolveContentMaxWidth(config, false)).toBe("90ch");
|
|
22
|
+
expect(resolveContentMaxWidth(config, true)).toBe("90ch");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("passes the \"none\" opt-out through untouched", () => {
|
|
26
|
+
expect(
|
|
27
|
+
resolveContentMaxWidth(cfg({ layout: { contentMaxWidth: "none" } }), false)
|
|
28
|
+
).toBe("none");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("prefers composerBar.contentMaxWidth over the 720px fallback", () => {
|
|
32
|
+
const config = cfg({
|
|
33
|
+
launcher: { composerBar: { contentMaxWidth: "600px" } },
|
|
34
|
+
});
|
|
35
|
+
expect(resolveContentMaxWidth(config, true)).toBe("600px");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentWidgetConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolver-owned default: every surveyed product caps the conversation
|
|
5
|
+
* column (704 to 896px, modal value 768px,
|
|
6
|
+
* "Hero width versus transcript width"). Kept out of defaults.ts so a
|
|
7
|
+
* materialized value cannot override the composer-bar's own 720px fallback.
|
|
8
|
+
* `"none"` opts out (applied as `max-width: none`).
|
|
9
|
+
*/
|
|
10
|
+
export const resolveContentMaxWidth = (
|
|
11
|
+
config: AgentWidgetConfig | undefined,
|
|
12
|
+
composerBar: boolean
|
|
13
|
+
): string =>
|
|
14
|
+
config?.layout?.contentMaxWidth ??
|
|
15
|
+
(composerBar
|
|
16
|
+
? config?.launcher?.composerBar?.contentMaxWidth ?? "720px"
|
|
17
|
+
: "768px");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { renderLucideIcon } from "./icons";
|
|
6
|
+
|
|
7
|
+
describe("renderLucideIcon", () => {
|
|
8
|
+
it.each(["lightbulb", "chart-column", "image", "pen-line"] as const)(
|
|
9
|
+
"renders %s as an inline svg",
|
|
10
|
+
(name) => {
|
|
11
|
+
const svg = renderLucideIcon(name);
|
|
12
|
+
expect(svg).not.toBeNull();
|
|
13
|
+
expect(svg?.tagName.toLowerCase()).toBe("svg");
|
|
14
|
+
expect(svg?.getAttribute("viewBox")).toBe("0 0 24 24");
|
|
15
|
+
// Geometry, not an empty shell.
|
|
16
|
+
expect(svg!.childNodes.length).toBeGreaterThan(0);
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
it("returns null and warns for a name outside the registry", () => {
|
|
21
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
22
|
+
expect(renderLucideIcon("definitely-not-an-icon")).toBeNull();
|
|
23
|
+
expect(warn).toHaveBeenCalled();
|
|
24
|
+
warn.mockRestore();
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/utils/icons.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
Plus,
|
|
71
71
|
Minus,
|
|
72
72
|
Pencil,
|
|
73
|
+
PenLine,
|
|
73
74
|
Trash,
|
|
74
75
|
Trash2,
|
|
75
76
|
Save,
|
|
@@ -117,12 +118,15 @@ import {
|
|
|
117
118
|
Folder,
|
|
118
119
|
FolderOpen,
|
|
119
120
|
Files,
|
|
121
|
+
// ---------- Data ----------
|
|
122
|
+
ChartColumn,
|
|
120
123
|
// ---------- Decorative ----------
|
|
121
124
|
Sparkles,
|
|
122
125
|
Zap,
|
|
123
126
|
Sun,
|
|
124
127
|
Moon,
|
|
125
128
|
Flag,
|
|
129
|
+
Lightbulb,
|
|
126
130
|
// ---------- Devices ----------
|
|
127
131
|
Monitor,
|
|
128
132
|
Smartphone,
|
|
@@ -220,6 +224,7 @@ const LUCIDE_ICONS = {
|
|
|
220
224
|
"plus": Plus,
|
|
221
225
|
"minus": Minus,
|
|
222
226
|
"pencil": Pencil,
|
|
227
|
+
"pen-line": PenLine,
|
|
223
228
|
"trash": Trash,
|
|
224
229
|
"trash-2": Trash2,
|
|
225
230
|
"save": Save,
|
|
@@ -267,12 +272,15 @@ const LUCIDE_ICONS = {
|
|
|
267
272
|
"folder": Folder,
|
|
268
273
|
"folder-open": FolderOpen,
|
|
269
274
|
"files": Files,
|
|
275
|
+
// Data
|
|
276
|
+
"chart-column": ChartColumn,
|
|
270
277
|
// Decorative
|
|
271
278
|
"sparkles": Sparkles,
|
|
272
279
|
"zap": Zap,
|
|
273
280
|
"sun": Sun,
|
|
274
281
|
"moon": Moon,
|
|
275
282
|
"flag": Flag,
|
|
283
|
+
"lightbulb": Lightbulb,
|
|
276
284
|
// Devices
|
|
277
285
|
"monitor": Monitor,
|
|
278
286
|
"smartphone": Smartphone,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createPluginStorageFactory, pluginStorageKey } from "./plugin-storage";
|
|
6
|
+
import type { AgentWidgetConfig } from "../types";
|
|
7
|
+
|
|
8
|
+
const config = (patch: Partial<AgentWidgetConfig> = {}): AgentWidgetConfig =>
|
|
9
|
+
({ apiUrl: "https://api.example.com/chat", ...patch }) as AgentWidgetConfig;
|
|
10
|
+
|
|
11
|
+
describe("plugin storage facade", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
window.localStorage.clear();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("namespaces keys by keyPrefix and plugin id", () => {
|
|
18
|
+
expect(pluginStorageKey(config(), "home-screen", "view")).toBe(
|
|
19
|
+
"persona-plugin:home-screen:view"
|
|
20
|
+
);
|
|
21
|
+
expect(
|
|
22
|
+
pluginStorageKey(
|
|
23
|
+
config({ persistState: { keyPrefix: "myapp-" } }),
|
|
24
|
+
"home-screen",
|
|
25
|
+
"view"
|
|
26
|
+
)
|
|
27
|
+
).toBe("myapp-plugin:home-screen:view");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads and writes localStorage under the namespaced key", () => {
|
|
31
|
+
const storage = createPluginStorageFactory(() => config())("pre-chat");
|
|
32
|
+
storage.set("identity", "ada");
|
|
33
|
+
|
|
34
|
+
expect(window.localStorage.getItem("persona-plugin:pre-chat:identity")).toBe(
|
|
35
|
+
"ada"
|
|
36
|
+
);
|
|
37
|
+
expect(storage.get("identity")).toBe("ada");
|
|
38
|
+
|
|
39
|
+
storage.remove("identity");
|
|
40
|
+
expect(storage.get("identity")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps two plugins from colliding on the same key", () => {
|
|
44
|
+
const factory = createPluginStorageFactory(() => config());
|
|
45
|
+
factory("a").set("view", "home");
|
|
46
|
+
factory("b").set("view", "chat");
|
|
47
|
+
|
|
48
|
+
expect(factory("a").get("view")).toBe("home");
|
|
49
|
+
expect(factory("b").get("view")).toBe("chat");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("downgrades to an in-memory map under persistState: false", () => {
|
|
53
|
+
const storage = createPluginStorageFactory(() =>
|
|
54
|
+
config({ persistState: false })
|
|
55
|
+
)("pre-chat");
|
|
56
|
+
storage.set("identity", "ada");
|
|
57
|
+
|
|
58
|
+
expect(storage.get("identity")).toBe("ada");
|
|
59
|
+
expect(window.localStorage.getItem("persona-plugin:pre-chat:identity")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("falls back to memory when localStorage access throws (private mode)", () => {
|
|
63
|
+
const setItem = vi
|
|
64
|
+
.spyOn(Storage.prototype, "setItem")
|
|
65
|
+
.mockImplementation(() => {
|
|
66
|
+
throw new Error("blocked");
|
|
67
|
+
});
|
|
68
|
+
const getItem = vi
|
|
69
|
+
.spyOn(Storage.prototype, "getItem")
|
|
70
|
+
.mockImplementation(() => {
|
|
71
|
+
throw new Error("blocked");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const storage = createPluginStorageFactory(() => config())("home-screen");
|
|
75
|
+
expect(() => storage.set("view", "home")).not.toThrow();
|
|
76
|
+
expect(storage.get("view")).toBe("home");
|
|
77
|
+
|
|
78
|
+
setItem.mockRestore();
|
|
79
|
+
getItem.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("follows a live keyPrefix change through controller.update()", () => {
|
|
83
|
+
let current = config();
|
|
84
|
+
const storage = createPluginStorageFactory(() => current)("home-screen");
|
|
85
|
+
storage.set("view", "home");
|
|
86
|
+
|
|
87
|
+
current = config({ persistState: { keyPrefix: "next-" } });
|
|
88
|
+
expect(storage.get("view")).toBeNull();
|
|
89
|
+
storage.set("view", "chat");
|
|
90
|
+
expect(window.localStorage.getItem("next-plugin:home-screen:view")).toBe(
|
|
91
|
+
"chat"
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AgentWidgetConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Synchronous key-value store handed to plugin render hooks. Backed by
|
|
5
|
+
* `localStorage`, namespaced per plugin id. The async `storageAdapter` cannot
|
|
6
|
+
* back a synchronous API and `persistState` is not a general key-value surface.
|
|
7
|
+
*/
|
|
8
|
+
export interface AgentWidgetPluginStorage {
|
|
9
|
+
get: (key: string) => string | null;
|
|
10
|
+
set: (key: string, value: string) => void;
|
|
11
|
+
remove: (key: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One factory per widget instance; the in-memory fallback is shared by it. */
|
|
15
|
+
export type PluginStorageFactory = (
|
|
16
|
+
pluginId: string
|
|
17
|
+
) => AgentWidgetPluginStorage;
|
|
18
|
+
|
|
19
|
+
const resolveKeyPrefix = (config: AgentWidgetConfig | undefined): string => {
|
|
20
|
+
const persist = config?.persistState;
|
|
21
|
+
return (
|
|
22
|
+
(typeof persist === "object" ? persist?.keyPrefix : undefined) ?? "persona-"
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const pluginStorageKey = (
|
|
27
|
+
config: AgentWidgetConfig | undefined,
|
|
28
|
+
pluginId: string,
|
|
29
|
+
key: string
|
|
30
|
+
): string => `${resolveKeyPrefix(config)}plugin:${pluginId}:${key}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `persistState: false` and blocked storage both downgrade to a per-instance
|
|
34
|
+
* Map. localStorage access can throw (Safari private mode, partitioned
|
|
35
|
+
* iframes), so the first throw switches this instance to the Map for good.
|
|
36
|
+
*/
|
|
37
|
+
export const createPluginStorageFactory = (
|
|
38
|
+
getConfig: () => AgentWidgetConfig | undefined
|
|
39
|
+
): PluginStorageFactory => {
|
|
40
|
+
const memory = new Map<string, string>();
|
|
41
|
+
let blocked = false;
|
|
42
|
+
|
|
43
|
+
const store = (): Storage | null => {
|
|
44
|
+
if (blocked) return null;
|
|
45
|
+
if (getConfig()?.persistState === false) return null;
|
|
46
|
+
try {
|
|
47
|
+
if (typeof window === "undefined" || !window.localStorage) return null;
|
|
48
|
+
return window.localStorage;
|
|
49
|
+
} catch {
|
|
50
|
+
blocked = true;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (pluginId: string): AgentWidgetPluginStorage => {
|
|
56
|
+
const keyFor = (key: string) =>
|
|
57
|
+
pluginStorageKey(getConfig(), pluginId, key);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
get: (key) => {
|
|
61
|
+
const full = keyFor(key);
|
|
62
|
+
const local = store();
|
|
63
|
+
if (local) {
|
|
64
|
+
try {
|
|
65
|
+
return local.getItem(full);
|
|
66
|
+
} catch {
|
|
67
|
+
blocked = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return memory.get(full) ?? null;
|
|
71
|
+
},
|
|
72
|
+
set: (key, value) => {
|
|
73
|
+
const full = keyFor(key);
|
|
74
|
+
const local = store();
|
|
75
|
+
if (local) {
|
|
76
|
+
try {
|
|
77
|
+
local.setItem(full, value);
|
|
78
|
+
return;
|
|
79
|
+
} catch {
|
|
80
|
+
blocked = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
memory.set(full, value);
|
|
84
|
+
},
|
|
85
|
+
remove: (key) => {
|
|
86
|
+
const full = keyFor(key);
|
|
87
|
+
memory.delete(full);
|
|
88
|
+
const local = store();
|
|
89
|
+
if (!local) return;
|
|
90
|
+
try {
|
|
91
|
+
local.removeItem(full);
|
|
92
|
+
} catch {
|
|
93
|
+
blocked = true;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
};
|