@agentable-ui/genui 0.1.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/README.md +66 -0
- package/dist/index.d.ts +221 -0
- package/dist/index.js +947 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agentable contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @agentable-ui/genui
|
|
2
|
+
|
|
3
|
+
Generative UI for Agentable: the agent describes what to render, you keep control of what can be rendered.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @agentable-ui/genui @agentable-ui/ui @agentable-ui/react @agentable-ui/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { AgentSurface } from "@agentable-ui/genui";
|
|
11
|
+
import { uiCatalog } from "@agentable-ui/ui";
|
|
12
|
+
|
|
13
|
+
<AgentSurface components={uiCatalog} name="present" maxNodes={60} />;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That declares a `present` tool whose input schema is generated from the catalog. The agent sends a UI spec; the component tree it can produce is exactly the catalog you passed and nothing else.
|
|
17
|
+
|
|
18
|
+
## Why one tool
|
|
19
|
+
|
|
20
|
+
Exposing a tool per component burns the budget and lets the agent invent markup. A single `present` tool with a generated schema keeps the cost flat and the output constrained.
|
|
21
|
+
|
|
22
|
+
## Validation
|
|
23
|
+
|
|
24
|
+
`validateSpec(spec, catalog, allowed, opts)` runs before anything renders and rejects unknown components, bad prop shapes, specs deeper than `maxDepth` (default 6) and specs larger than `maxNodes` (default 60). Failures come back to the agent as structured issues it can correct, not as a thrown error.
|
|
25
|
+
|
|
26
|
+
- `catalogSchema(catalog, allowed)` — the generated JSON Schema for the `present` tool.
|
|
27
|
+
- `renderSpec(spec, catalog)` — render a validated spec yourself.
|
|
28
|
+
|
|
29
|
+
## Interactive surfaces
|
|
30
|
+
|
|
31
|
+
Nodes can do more than display props:
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
{
|
|
35
|
+
"component": "RecordTable", "id": "orders",
|
|
36
|
+
"bind": { "rows": "data.paid" }, // read app data
|
|
37
|
+
"on": { "select": [{ "type": "set", "path": "ui.selected", "value": "{{value}}" }] }
|
|
38
|
+
}
|
|
39
|
+
{
|
|
40
|
+
"component": "Button", "when": "ui.selected", "props": { "label": "Refund this order" },
|
|
41
|
+
"on": { "press": [{ "type": "tool", "name": "refund_order", "args": { "id": "{{ui.selected.id}}" }, "into": "ui.last" }] }
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `data.*` is what you pass as `<AgentSurface data={...}>`. It is read-only to generated UI and re-renders when it changes.
|
|
46
|
+
- `ui.*` belongs to the surface. Actions (`set`, `merge`, `push`, `remove`, `increment`, `toggle`) may only write here.
|
|
47
|
+
- `tool` actions call registered tools through `agentable.invokeCapability`, so confirmation, caching and invalidation work exactly as for an agent. Only tools an agent could reach (tier 0 or 1) are callable; narrow them with `tools={[...]}` or turn them off with `tools={false}`.
|
|
48
|
+
- `present({ ops })` patches the current tree by node id (`add`, `update`, `remove`, `set`) instead of resending it.
|
|
49
|
+
- With `data` set, a read-only `<name>_state` tool returns the shape of `data`, the current `ui` state and the tree.
|
|
50
|
+
- Catalog entries declare events as `events: { press: "onPress" }`: event name to callback prop, whose first argument becomes `{{value}}`.
|
|
51
|
+
|
|
52
|
+
## Catalog entries
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const catalog = {
|
|
56
|
+
Stat: {
|
|
57
|
+
component: Stat,
|
|
58
|
+
props: z.object({ label: z.string(), value: z.string(), delta: z.string().optional() }),
|
|
59
|
+
description: "A single headline number.",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`props` accepts a JSON Schema object or any Standard Schema validator. `uiCatalog` from [`@agentable-ui/ui`](../ui) is a ready-made set.
|
|
65
|
+
|
|
66
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { JSONSchema, SchemaInput, Tier } from "@agentable-ui/core";
|
|
3
|
+
//#region src/state.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Surface state. Two roots: `data` is the app's, read-only to generated UI;
|
|
6
|
+
* `ui` belongs to the surface and is the only thing actions may write.
|
|
7
|
+
*/
|
|
8
|
+
interface SurfaceState {
|
|
9
|
+
data: Record<string, unknown>;
|
|
10
|
+
ui: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
/** "a.b[0].c" -> ["a", "b", 0, "c"]. Throws on __proto__, prototype and constructor segments. */
|
|
13
|
+
export declare function parsePath(path: string): Array<string | number>;
|
|
14
|
+
/** Read a path, following own properties only. Undefined for missing or unsafe paths. */
|
|
15
|
+
export declare function getPath(obj: unknown, path: string): unknown;
|
|
16
|
+
type StateAction = {
|
|
17
|
+
type: "set";
|
|
18
|
+
path: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
} | {
|
|
21
|
+
type: "merge";
|
|
22
|
+
path: string;
|
|
23
|
+
value: Record<string, unknown>;
|
|
24
|
+
} | {
|
|
25
|
+
type: "push";
|
|
26
|
+
path: string;
|
|
27
|
+
value: unknown;
|
|
28
|
+
} | {
|
|
29
|
+
type: "remove";
|
|
30
|
+
path: string;
|
|
31
|
+
} | {
|
|
32
|
+
type: "increment";
|
|
33
|
+
path: string;
|
|
34
|
+
by?: number;
|
|
35
|
+
} | {
|
|
36
|
+
type: "toggle";
|
|
37
|
+
path: string;
|
|
38
|
+
};
|
|
39
|
+
interface UiStore {
|
|
40
|
+
get(): Record<string, unknown>;
|
|
41
|
+
/** Apply actions all-or-nothing. Throws, leaving state untouched, if any fails. */
|
|
42
|
+
dispatch(actions: StateAction | StateAction[]): void;
|
|
43
|
+
replace(next: Record<string, unknown>): void;
|
|
44
|
+
subscribe(fn: () => void): () => void;
|
|
45
|
+
}
|
|
46
|
+
export declare function createUiStore(initial?: Record<string, unknown>): UiStore;
|
|
47
|
+
/**
|
|
48
|
+
* Substitute `{{…}}` placeholders in action values.
|
|
49
|
+
* {{value}} / {{value.id}} the event's value, or a field of it
|
|
50
|
+
* {{ui.x}} / {{data.x}} surface state at the time of the event
|
|
51
|
+
* A string that is exactly one placeholder keeps the resolved type; embedded
|
|
52
|
+
* placeholders (e.g. "ui.rows[{{value}}].done") are stringified.
|
|
53
|
+
*/
|
|
54
|
+
export declare function interpolate<T>(input: T, payload: Record<string, unknown>, state: SurfaceState): T;
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/spec.d.ts
|
|
57
|
+
/** Call a tool through the registry. The result lands in `into` (a ui.* path) when given. */
|
|
58
|
+
interface ToolAction {
|
|
59
|
+
type: "tool";
|
|
60
|
+
name: string;
|
|
61
|
+
args?: Record<string, unknown>;
|
|
62
|
+
into?: string;
|
|
63
|
+
}
|
|
64
|
+
type UIAction = StateAction | ToolAction;
|
|
65
|
+
interface UISpec {
|
|
66
|
+
component: string;
|
|
67
|
+
/** Stable id, needed to target the node with patch ops. */
|
|
68
|
+
id?: string;
|
|
69
|
+
props?: Record<string, unknown>;
|
|
70
|
+
/** propName -> state path ("data.orders.open", "ui.selected.name"). Resolved on every render. */
|
|
71
|
+
bind?: Record<string, string>;
|
|
72
|
+
/** eventName -> actions, run in order when the component fires the event. */
|
|
73
|
+
on?: Record<string, UIAction[]>;
|
|
74
|
+
/** Render only while this state path is truthy ("!path" for falsy). */
|
|
75
|
+
when?: string;
|
|
76
|
+
children?: UISpec[];
|
|
77
|
+
}
|
|
78
|
+
/** Incremental edits to the presented tree. */
|
|
79
|
+
type SurfaceOp = {
|
|
80
|
+
op: "add";
|
|
81
|
+
parent: string;
|
|
82
|
+
node: UISpec;
|
|
83
|
+
index?: number;
|
|
84
|
+
} |
|
|
85
|
+
/** Replace the node with the same id. Its children are kept unless `node.children` is given. */
|
|
86
|
+
{
|
|
87
|
+
op: "update";
|
|
88
|
+
node: UISpec;
|
|
89
|
+
} | {
|
|
90
|
+
op: "remove";
|
|
91
|
+
id: string;
|
|
92
|
+
} |
|
|
93
|
+
/** Write surface state; `path` must be under ui. */
|
|
94
|
+
{
|
|
95
|
+
op: "set";
|
|
96
|
+
path: string;
|
|
97
|
+
value: unknown;
|
|
98
|
+
};
|
|
99
|
+
/** The slice of a catalog entry validation needs. */
|
|
100
|
+
interface ValidatableEntry {
|
|
101
|
+
props: SchemaInput;
|
|
102
|
+
childrenProp?: string;
|
|
103
|
+
events?: Record<string, string>;
|
|
104
|
+
}
|
|
105
|
+
interface SpecIssue {
|
|
106
|
+
path: string;
|
|
107
|
+
message: string;
|
|
108
|
+
}
|
|
109
|
+
interface ValidateOptions {
|
|
110
|
+
maxDepth?: number;
|
|
111
|
+
maxNodes?: number;
|
|
112
|
+
/** State that bindings and `when` resolve against. Bound data.* paths must exist. */
|
|
113
|
+
state?: SurfaceState;
|
|
114
|
+
/** Tool names that `tool` actions may call. None when absent. */
|
|
115
|
+
tools?: ReadonlySet<string>;
|
|
116
|
+
}
|
|
117
|
+
export declare function isVisible(node: Pick<UISpec, "when">, state: SurfaceState): boolean;
|
|
118
|
+
/** Static props overlaid with bound values that currently resolve. */
|
|
119
|
+
export declare function resolveProps(node: UISpec, state: SurfaceState): Record<string, unknown>;
|
|
120
|
+
/**
|
|
121
|
+
* Validate a spec against the catalog. Unknown props are dropped; unknown
|
|
122
|
+
* components, bad props, dangling data bindings and writes outside ui.* fail.
|
|
123
|
+
* Nodes hidden by `when` skip the checks that depend on bound values: their
|
|
124
|
+
* data (a selection, say) legitimately does not exist until they show.
|
|
125
|
+
*/
|
|
126
|
+
export declare function validateSpec(spec: UISpec, catalog: Record<string, ValidatableEntry>, allowed: Set<string>, opts?: ValidateOptions): Promise<{
|
|
127
|
+
ok: true;
|
|
128
|
+
spec: UISpec;
|
|
129
|
+
nodes: number;
|
|
130
|
+
} | {
|
|
131
|
+
ok: false;
|
|
132
|
+
issues: SpecIssue[];
|
|
133
|
+
}>;
|
|
134
|
+
/**
|
|
135
|
+
* Apply patch ops to a tree. Returns the new tree plus the state writes to
|
|
136
|
+
* dispatch; throws with a message naming the op that failed. The caller
|
|
137
|
+
* validates the result before committing, so a bad batch changes nothing.
|
|
138
|
+
*/
|
|
139
|
+
export declare function applyOps(root: UISpec, ops: SurfaceOp[]): {
|
|
140
|
+
root: UISpec;
|
|
141
|
+
state: StateAction[];
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/index.d.ts
|
|
145
|
+
export interface CatalogComponent<P = Record<string, unknown>> {
|
|
146
|
+
component: ComponentType<P>;
|
|
147
|
+
description: string;
|
|
148
|
+
props: SchemaInput;
|
|
149
|
+
childrenProp?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Events the component can fire: event name -> callback prop. The callback's
|
|
152
|
+
* first argument becomes `{{value}}` in the node's actions, e.g.
|
|
153
|
+
* `{ change: "onChange" }` for an input that calls `onChange(nextValue)`.
|
|
154
|
+
*/
|
|
155
|
+
events?: Record<string, string>;
|
|
156
|
+
/** Tier-gate the catalog entry. Tier 1 entries appear in `present` only after their group is opened. */
|
|
157
|
+
tier?: Tier;
|
|
158
|
+
group?: string;
|
|
159
|
+
}
|
|
160
|
+
export type Catalog = Record<string, CatalogComponent<never>>;
|
|
161
|
+
export interface PresentArgs {
|
|
162
|
+
/** The tree to render. Replaces the current one and resets ui state. null clears the surface. */
|
|
163
|
+
root?: UISpec | null;
|
|
164
|
+
/** Patch the current tree (applied after `root` when both are given). */
|
|
165
|
+
ops?: SurfaceOp[];
|
|
166
|
+
/** ui.* state to start from when sending `root`. */
|
|
167
|
+
ui?: Record<string, unknown>;
|
|
168
|
+
/** Optional title shown above the rendered tree. */
|
|
169
|
+
title?: string;
|
|
170
|
+
}
|
|
171
|
+
/** Build the `present` input schema for the currently allowed components. */
|
|
172
|
+
export declare function catalogSchema(catalog: Catalog, allowed: Iterable<string>): JSONSchema;
|
|
173
|
+
/** How a rendered node reaches live state and fires its events. Omit for a static render. */
|
|
174
|
+
export interface RenderContext {
|
|
175
|
+
state: SurfaceState;
|
|
176
|
+
emit: (node: UISpec, event: string, value: unknown) => void;
|
|
177
|
+
}
|
|
178
|
+
export declare function renderSpec(spec: UISpec, catalog: Catalog, key?: string, ctx?: RenderContext): ReactNode;
|
|
179
|
+
export interface AgentSurfaceProps {
|
|
180
|
+
components: Catalog;
|
|
181
|
+
/** Tool name. Default "present". */
|
|
182
|
+
name?: string;
|
|
183
|
+
id?: string;
|
|
184
|
+
group?: string;
|
|
185
|
+
tier?: Tier;
|
|
186
|
+
priority?: number;
|
|
187
|
+
maxDepth?: number;
|
|
188
|
+
maxNodes?: number;
|
|
189
|
+
placeholder?: ReactNode;
|
|
190
|
+
onPresent?: (spec: UISpec | null, title?: string) => void;
|
|
191
|
+
className?: string;
|
|
192
|
+
/** Show a "clear" affordance. Default true. */
|
|
193
|
+
clearable?: boolean;
|
|
194
|
+
/** App data the agent may bind to as data.*. Read-only to generated UI; re-renders when it changes. */
|
|
195
|
+
data?: Record<string, unknown>;
|
|
196
|
+
/**
|
|
197
|
+
* Tools that `tool` actions may call. Default: every tool an agent can reach
|
|
198
|
+
* on this page (tier 0 and 1). `false` disables tool actions.
|
|
199
|
+
*/
|
|
200
|
+
tools?: string[] | false;
|
|
201
|
+
/**
|
|
202
|
+
* Name of the read-only tool that returns surface state and the shape of
|
|
203
|
+
* `data`. Defaults to `<name>_state` when `data` is given; `false` omits it.
|
|
204
|
+
*/
|
|
205
|
+
stateTool?: string | false;
|
|
206
|
+
/** Called when an action in the generated UI fails or is declined. */
|
|
207
|
+
onActionError?: (message: string, node: UISpec) => void;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Renders whatever the agent last called `present` with. Every catalog
|
|
211
|
+
* component is declared as a capability with a `render` descriptor, so the
|
|
212
|
+
* catalog is a projection of the same registry that holds the tools.
|
|
213
|
+
*
|
|
214
|
+
* Nodes can bind props to app data (data.*) or surface state (ui.*), and wire
|
|
215
|
+
* events to actions that write ui.* or call registered tools. A button in the
|
|
216
|
+
* generated UI and an agent reach a tool through the same registry path, so
|
|
217
|
+
* confirmation, caching and invalidation apply to both.
|
|
218
|
+
*/
|
|
219
|
+
export declare function AgentSurface({ components, name, id, group, tier, priority, maxDepth, maxNodes, placeholder, onPresent, className, clearable, data, tools, stateTool, onActionError }: AgentSurfaceProps): import("react").JSX.Element;
|
|
220
|
+
//#endregion
|
|
221
|
+
export type { SpecIssue, StateAction, SurfaceOp, SurfaceState, ToolAction, UIAction, UISpec, UiStore, ValidateOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { Component, createElement, useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { toJsonSchema, validateInput } from "@agentable-ui/core";
|
|
3
|
+
import { useAgentable, useAgentableSnapshot, useCapability } from "@agentable-ui/react";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/state.ts
|
|
6
|
+
/** Keys that reach an object's prototype. Paths come from agents and user input, so they are never followed. */
|
|
7
|
+
const UNSAFE_KEYS = /* @__PURE__ */ new Set([
|
|
8
|
+
"__proto__",
|
|
9
|
+
"prototype",
|
|
10
|
+
"constructor"
|
|
11
|
+
]);
|
|
12
|
+
/** "a.b[0].c" -> ["a", "b", 0, "c"]. Throws on __proto__, prototype and constructor segments. */
|
|
13
|
+
function parsePath(path) {
|
|
14
|
+
if (!path) return [];
|
|
15
|
+
const keys = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean).map((p) => /^\d+$/.test(p) ? Number(p) : p);
|
|
16
|
+
for (const k of keys) if (typeof k === "string" && UNSAFE_KEYS.has(k)) throw new Error(`Path "${path}" uses the reserved key "${k}".`);
|
|
17
|
+
return keys;
|
|
18
|
+
}
|
|
19
|
+
/** Read a path, following own properties only. Undefined for missing or unsafe paths. */
|
|
20
|
+
function getPath(obj, path) {
|
|
21
|
+
let keys;
|
|
22
|
+
try {
|
|
23
|
+
keys = parsePath(path);
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let cur = obj;
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
if (cur == null || typeof cur !== "object" || !Object.hasOwn(cur, key)) return void 0;
|
|
30
|
+
cur = cur[key];
|
|
31
|
+
}
|
|
32
|
+
return cur;
|
|
33
|
+
}
|
|
34
|
+
/** True for "data.x" / "ui.x" (and "!ui.x" when `negatable`). */
|
|
35
|
+
function isStatePath(path, roots = ["data", "ui"], negatable = false) {
|
|
36
|
+
if (typeof path !== "string") return false;
|
|
37
|
+
const p = negatable && path.startsWith("!") ? path.slice(1) : path;
|
|
38
|
+
let keys;
|
|
39
|
+
try {
|
|
40
|
+
keys = parsePath(p);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const head = keys[0];
|
|
45
|
+
return typeof head === "string" && roots.includes(head) && keys.length > 1;
|
|
46
|
+
}
|
|
47
|
+
function setPath(obj, keys, value) {
|
|
48
|
+
if (!keys.length) throw new Error("Cannot replace the whole ui state.");
|
|
49
|
+
let cur = obj;
|
|
50
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
51
|
+
const k = keys[i];
|
|
52
|
+
const next = keys[i + 1];
|
|
53
|
+
if (!Object.hasOwn(cur, k) || cur[k] == null || typeof cur[k] !== "object") cur[k] = typeof next === "number" ? [] : {};
|
|
54
|
+
cur = cur[k];
|
|
55
|
+
}
|
|
56
|
+
const last = keys[keys.length - 1];
|
|
57
|
+
if (value === void 0) {
|
|
58
|
+
if (Array.isArray(cur) && typeof last === "number") cur.splice(last, 1);
|
|
59
|
+
else delete cur[last];
|
|
60
|
+
} else cur[last] = value;
|
|
61
|
+
}
|
|
62
|
+
/** Keys under `ui` for a "ui.x.y" path. Throws for anything outside ui. */
|
|
63
|
+
function uiKeys(path) {
|
|
64
|
+
const keys = parsePath(path);
|
|
65
|
+
if (keys[0] !== "ui" || keys.length < 2) throw new Error(`Actions may only write ui.* paths (got "${path}").`);
|
|
66
|
+
return keys.slice(1);
|
|
67
|
+
}
|
|
68
|
+
function reduce(ui, a) {
|
|
69
|
+
const keys = uiKeys(a.path);
|
|
70
|
+
const cur = getPath({ ui }, a.path);
|
|
71
|
+
switch (a.type) {
|
|
72
|
+
case "set": return setPath(ui, keys, a.value);
|
|
73
|
+
case "merge":
|
|
74
|
+
if (cur !== void 0 && (typeof cur !== "object" || cur === null || Array.isArray(cur))) throw new Error(`merge target "${a.path}" is not an object`);
|
|
75
|
+
return setPath(ui, keys, {
|
|
76
|
+
...cur,
|
|
77
|
+
...a.value
|
|
78
|
+
});
|
|
79
|
+
case "push":
|
|
80
|
+
if (cur !== void 0 && !Array.isArray(cur)) throw new Error(`push target "${a.path}" is not an array`);
|
|
81
|
+
return setPath(ui, keys, [...cur ?? [], a.value]);
|
|
82
|
+
case "remove": return setPath(ui, keys, void 0);
|
|
83
|
+
case "increment":
|
|
84
|
+
if (cur !== void 0 && typeof cur !== "number") throw new Error(`increment target "${a.path}" is not a number`);
|
|
85
|
+
return setPath(ui, keys, (cur ?? 0) + (a.by ?? 1));
|
|
86
|
+
case "toggle": return setPath(ui, keys, !cur);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function createUiStore(initial = {}) {
|
|
90
|
+
let ui = structuredClone(initial);
|
|
91
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
92
|
+
const emit = () => listeners.forEach((l) => l());
|
|
93
|
+
return {
|
|
94
|
+
get: () => ui,
|
|
95
|
+
dispatch(actions) {
|
|
96
|
+
const list = Array.isArray(actions) ? actions : [actions];
|
|
97
|
+
const draft = structuredClone(ui);
|
|
98
|
+
for (const a of list) reduce(draft, a);
|
|
99
|
+
ui = draft;
|
|
100
|
+
emit();
|
|
101
|
+
},
|
|
102
|
+
replace(next) {
|
|
103
|
+
ui = structuredClone(next);
|
|
104
|
+
emit();
|
|
105
|
+
},
|
|
106
|
+
subscribe(fn) {
|
|
107
|
+
listeners.add(fn);
|
|
108
|
+
return () => {
|
|
109
|
+
listeners.delete(fn);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const PLACEHOLDER = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
115
|
+
const WHOLE = /^\{\{\s*([^}]+?)\s*\}\}$/;
|
|
116
|
+
function resolveExpr(expr, payload, state) {
|
|
117
|
+
if (isStatePath(expr)) return getPath(state, expr);
|
|
118
|
+
return getPath(payload, expr);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Substitute `{{…}}` placeholders in action values.
|
|
122
|
+
* {{value}} / {{value.id}} the event's value, or a field of it
|
|
123
|
+
* {{ui.x}} / {{data.x}} surface state at the time of the event
|
|
124
|
+
* A string that is exactly one placeholder keeps the resolved type; embedded
|
|
125
|
+
* placeholders (e.g. "ui.rows[{{value}}].done") are stringified.
|
|
126
|
+
*/
|
|
127
|
+
function interpolate(input, payload, state) {
|
|
128
|
+
const sub = (v) => {
|
|
129
|
+
if (typeof v === "string") {
|
|
130
|
+
const whole = v.match(WHOLE);
|
|
131
|
+
if (whole) return resolveExpr(whole[1], payload, state) ?? null;
|
|
132
|
+
if (!v.includes("{{")) return v;
|
|
133
|
+
return v.replace(PLACEHOLDER, (_, expr) => {
|
|
134
|
+
const r = resolveExpr(expr, payload, state);
|
|
135
|
+
return r == null ? "" : typeof r === "object" ? JSON.stringify(r) : String(r);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(v)) return v.map(sub);
|
|
139
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, sub(x)]));
|
|
140
|
+
return v;
|
|
141
|
+
};
|
|
142
|
+
return sub(input);
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/spec.ts
|
|
146
|
+
const ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
147
|
+
const STATE_ACTIONS = /* @__PURE__ */ new Set([
|
|
148
|
+
"set",
|
|
149
|
+
"merge",
|
|
150
|
+
"push",
|
|
151
|
+
"remove",
|
|
152
|
+
"increment",
|
|
153
|
+
"toggle"
|
|
154
|
+
]);
|
|
155
|
+
function isVisible(node, state) {
|
|
156
|
+
if (!node.when) return true;
|
|
157
|
+
const neg = node.when.startsWith("!");
|
|
158
|
+
const v = getPath(state, neg ? node.when.slice(1) : node.when);
|
|
159
|
+
return neg ? !v : !!v;
|
|
160
|
+
}
|
|
161
|
+
/** Static props overlaid with bound values that currently resolve. */
|
|
162
|
+
function resolveProps(node, state) {
|
|
163
|
+
const out = { ...node.props ?? {} };
|
|
164
|
+
for (const [prop, path] of Object.entries(node.bind ?? {})) {
|
|
165
|
+
const v = getPath(state, path);
|
|
166
|
+
if (v !== void 0) out[prop] = v;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
171
|
+
function actionIssues(a, path, tools) {
|
|
172
|
+
if (!isObject(a) || typeof a.type !== "string") return [{
|
|
173
|
+
path,
|
|
174
|
+
message: "Each action needs a type."
|
|
175
|
+
}];
|
|
176
|
+
if (a.type === "tool") {
|
|
177
|
+
const out = [];
|
|
178
|
+
if (typeof a.name !== "string" || !tools?.has(a.name)) {
|
|
179
|
+
const allowed = tools?.size ? [...tools].join(", ") : "(none)";
|
|
180
|
+
out.push({
|
|
181
|
+
path: `${path}.name`,
|
|
182
|
+
message: `Unknown tool "${String(a.name)}". Callable: ${allowed}.`
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (a.args !== void 0 && !isObject(a.args)) out.push({
|
|
186
|
+
path: `${path}.args`,
|
|
187
|
+
message: "args must be an object."
|
|
188
|
+
});
|
|
189
|
+
if (a.into !== void 0 && !isStatePath(a.into, ["ui"])) out.push({
|
|
190
|
+
path: `${path}.into`,
|
|
191
|
+
message: "into must be a ui.* path."
|
|
192
|
+
});
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
if (!STATE_ACTIONS.has(a.type)) return [{
|
|
196
|
+
path: `${path}.type`,
|
|
197
|
+
message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle or tool.`
|
|
198
|
+
}];
|
|
199
|
+
if (!isStatePath(a.path, ["ui"])) return [{
|
|
200
|
+
path: `${path}.path`,
|
|
201
|
+
message: "Actions may only write ui.* paths; data.* is read-only."
|
|
202
|
+
}];
|
|
203
|
+
if ((a.type === "set" || a.type === "push") && !("value" in a)) return [{
|
|
204
|
+
path,
|
|
205
|
+
message: `${a.type} needs a value.`
|
|
206
|
+
}];
|
|
207
|
+
if (a.type === "merge" && !isObject(a.value)) return [{
|
|
208
|
+
path: `${path}.value`,
|
|
209
|
+
message: "merge needs an object value."
|
|
210
|
+
}];
|
|
211
|
+
if (a.type === "increment" && a.by !== void 0 && typeof a.by !== "number") return [{
|
|
212
|
+
path: `${path}.by`,
|
|
213
|
+
message: "by must be a number."
|
|
214
|
+
}];
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Validate a spec against the catalog. Unknown props are dropped; unknown
|
|
219
|
+
* components, bad props, dangling data bindings and writes outside ui.* fail.
|
|
220
|
+
* Nodes hidden by `when` skip the checks that depend on bound values: their
|
|
221
|
+
* data (a selection, say) legitimately does not exist until they show.
|
|
222
|
+
*/
|
|
223
|
+
async function validateSpec(spec, catalog, allowed, opts = {}) {
|
|
224
|
+
const maxDepth = opts.maxDepth ?? 6;
|
|
225
|
+
const maxNodes = opts.maxNodes ?? 60;
|
|
226
|
+
const state = opts.state ?? {
|
|
227
|
+
data: {},
|
|
228
|
+
ui: {}
|
|
229
|
+
};
|
|
230
|
+
const issues = [];
|
|
231
|
+
const ids = /* @__PURE__ */ new Set();
|
|
232
|
+
let nodes = 0;
|
|
233
|
+
async function walk(node, path, depth, parentHidden) {
|
|
234
|
+
nodes++;
|
|
235
|
+
if (nodes > maxNodes) {
|
|
236
|
+
issues.push({
|
|
237
|
+
path,
|
|
238
|
+
message: `More than ${maxNodes} nodes.`
|
|
239
|
+
});
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
if (depth > maxDepth) {
|
|
243
|
+
issues.push({
|
|
244
|
+
path,
|
|
245
|
+
message: `Deeper than ${maxDepth} levels.`
|
|
246
|
+
});
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (!node || typeof node !== "object" || typeof node.component !== "string") {
|
|
250
|
+
issues.push({
|
|
251
|
+
path,
|
|
252
|
+
message: "Each node needs a component name."
|
|
253
|
+
});
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const entry = catalog[node.component];
|
|
257
|
+
if (!entry || !allowed.has(node.component)) {
|
|
258
|
+
issues.push({
|
|
259
|
+
path,
|
|
260
|
+
message: `Unknown component "${node.component}". Allowed: ${[...allowed].join(", ")}.`
|
|
261
|
+
});
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const out = { component: node.component };
|
|
265
|
+
const before = issues.length;
|
|
266
|
+
if (node.id !== void 0) {
|
|
267
|
+
if (typeof node.id !== "string" || !ID_PATTERN.test(node.id)) issues.push({
|
|
268
|
+
path: `${path}.id`,
|
|
269
|
+
message: "id must match [a-zA-Z0-9_-]+."
|
|
270
|
+
});
|
|
271
|
+
else if (ids.has(node.id)) issues.push({
|
|
272
|
+
path: `${path}.id`,
|
|
273
|
+
message: `Duplicate id "${node.id}".`
|
|
274
|
+
});
|
|
275
|
+
else {
|
|
276
|
+
ids.add(node.id);
|
|
277
|
+
out.id = node.id;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (node.when !== void 0) {
|
|
281
|
+
if (!isStatePath(node.when, ["data", "ui"], true)) issues.push({
|
|
282
|
+
path: `${path}.when`,
|
|
283
|
+
message: "when must be a state path like \"ui.selected\" or \"!data.done\"."
|
|
284
|
+
});
|
|
285
|
+
else out.when = node.when;
|
|
286
|
+
}
|
|
287
|
+
const hidden = parentHidden || out.when !== void 0 && !isVisible(out, state);
|
|
288
|
+
const schema = toJsonSchema(entry.props);
|
|
289
|
+
const known = new Set(Object.keys(schema.properties ?? {}));
|
|
290
|
+
const bind = {};
|
|
291
|
+
let unresolved = false;
|
|
292
|
+
if (node.bind !== void 0) {
|
|
293
|
+
if (!isObject(node.bind)) issues.push({
|
|
294
|
+
path: `${path}.bind`,
|
|
295
|
+
message: "bind must map prop names to state paths."
|
|
296
|
+
});
|
|
297
|
+
else for (const [prop, sp] of Object.entries(node.bind)) {
|
|
298
|
+
const at = `${path}.bind.${prop}`;
|
|
299
|
+
if (!known.has(prop)) issues.push({
|
|
300
|
+
path: at,
|
|
301
|
+
message: `"${node.component}" has no prop "${prop}".`
|
|
302
|
+
});
|
|
303
|
+
else if (!isStatePath(sp)) issues.push({
|
|
304
|
+
path: at,
|
|
305
|
+
message: "Bind to a state path under data.* or ui.*, e.g. \"data.orders.open\"."
|
|
306
|
+
});
|
|
307
|
+
else {
|
|
308
|
+
bind[prop] = sp;
|
|
309
|
+
if (getPath(state, sp) === void 0) {
|
|
310
|
+
unresolved = true;
|
|
311
|
+
if (!hidden && sp.startsWith("data.")) issues.push({
|
|
312
|
+
path: at,
|
|
313
|
+
message: `State path "${sp}" does not exist.`
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (Object.keys(bind).length) out.bind = bind;
|
|
319
|
+
}
|
|
320
|
+
const raw = isObject(node.props) ? node.props : {};
|
|
321
|
+
const cleaned = {};
|
|
322
|
+
for (const [k, v] of Object.entries(raw)) if (known.has(k)) cleaned[k] = v;
|
|
323
|
+
if (hidden || unresolved) out.props = cleaned;
|
|
324
|
+
else {
|
|
325
|
+
const result = await validateInput(entry.props, resolveProps({
|
|
326
|
+
...out,
|
|
327
|
+
props: cleaned
|
|
328
|
+
}, state));
|
|
329
|
+
if (!result.ok) for (const i of result.issues) issues.push({
|
|
330
|
+
path: `${path}.props${i.path ? "." + i.path : ""}`,
|
|
331
|
+
message: i.message
|
|
332
|
+
});
|
|
333
|
+
else {
|
|
334
|
+
const value = { ...result.value };
|
|
335
|
+
for (const prop of Object.keys(bind)) delete value[prop];
|
|
336
|
+
out.props = value;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (node.on !== void 0) {
|
|
340
|
+
const events = entry.events ?? {};
|
|
341
|
+
if (!isObject(node.on)) issues.push({
|
|
342
|
+
path: `${path}.on`,
|
|
343
|
+
message: "on must map event names to action lists."
|
|
344
|
+
});
|
|
345
|
+
else {
|
|
346
|
+
const on = {};
|
|
347
|
+
for (const [ev, actions] of Object.entries(node.on)) {
|
|
348
|
+
const at = `${path}.on.${ev}`;
|
|
349
|
+
if (!(ev in events)) {
|
|
350
|
+
const names = Object.keys(events);
|
|
351
|
+
issues.push({
|
|
352
|
+
path: at,
|
|
353
|
+
message: `"${node.component}" has no event "${ev}".${names.length ? ` Events: ${names.join(", ")}.` : ""}`
|
|
354
|
+
});
|
|
355
|
+
} else if (!Array.isArray(actions)) issues.push({
|
|
356
|
+
path: at,
|
|
357
|
+
message: "Handlers are lists of actions."
|
|
358
|
+
});
|
|
359
|
+
else {
|
|
360
|
+
actions.forEach((a, i) => issues.push(...actionIssues(a, `${at}[${i}]`, opts.tools)));
|
|
361
|
+
on[ev] = actions;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (Object.keys(on).length) out.on = on;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (Array.isArray(node.children) && node.children.length) {
|
|
368
|
+
if (!entry.childrenProp) issues.push({
|
|
369
|
+
path: `${path}.children`,
|
|
370
|
+
message: `"${node.component}" does not accept children.`
|
|
371
|
+
});
|
|
372
|
+
else {
|
|
373
|
+
const kids = [];
|
|
374
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
375
|
+
const kid = await walk(node.children[i], `${path}.children[${i}]`, depth + 1, hidden);
|
|
376
|
+
if (kid) kids.push(kid);
|
|
377
|
+
}
|
|
378
|
+
out.children = kids;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return issues.length > before ? null : out;
|
|
382
|
+
}
|
|
383
|
+
const cleaned = await walk(spec, "root", 1, false);
|
|
384
|
+
if (issues.length || !cleaned) return {
|
|
385
|
+
ok: false,
|
|
386
|
+
issues
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
spec: cleaned,
|
|
391
|
+
nodes
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Apply patch ops to a tree. Returns the new tree plus the state writes to
|
|
396
|
+
* dispatch; throws with a message naming the op that failed. The caller
|
|
397
|
+
* validates the result before committing, so a bad batch changes nothing.
|
|
398
|
+
*/
|
|
399
|
+
function applyOps(root, ops) {
|
|
400
|
+
let tree = structuredClone(root);
|
|
401
|
+
const state = [];
|
|
402
|
+
const matches = (n, id) => n.id === id || id === "root" && n === tree;
|
|
403
|
+
ops.forEach((op, i) => {
|
|
404
|
+
const at = `ops[${i}]`;
|
|
405
|
+
if (!isObject(op)) throw new Error(`${at}: each op must be an object.`);
|
|
406
|
+
if (op.op === "set") {
|
|
407
|
+
state.push({
|
|
408
|
+
type: "set",
|
|
409
|
+
path: op.path,
|
|
410
|
+
value: op.value
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (op.op === "add") {
|
|
415
|
+
const parent = find(tree, (n) => matches(n, op.parent));
|
|
416
|
+
if (!parent) throw new Error(`${at}: no node with id "${op.parent}".`);
|
|
417
|
+
const kids = parent.children ??= [];
|
|
418
|
+
const idx = typeof op.index === "number" ? Math.max(0, Math.min(op.index, kids.length)) : kids.length;
|
|
419
|
+
kids.splice(idx, 0, op.node);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (op.op === "update") {
|
|
423
|
+
const id = op.node?.id;
|
|
424
|
+
if (!id) throw new Error(`${at}: update needs node.id.`);
|
|
425
|
+
const target = find(tree, (n) => n.id === id);
|
|
426
|
+
if (!target) throw new Error(`${at}: no node with id "${id}".`);
|
|
427
|
+
const next = {
|
|
428
|
+
...op.node,
|
|
429
|
+
children: op.node.children ?? target.children
|
|
430
|
+
};
|
|
431
|
+
if (target === tree) tree = next;
|
|
432
|
+
else replaceIn(tree, target, next);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (op.op === "remove") {
|
|
436
|
+
const target = find(tree, (n) => n.id === op.id);
|
|
437
|
+
if (!target) throw new Error(`${at}: no node with id "${op.id}".`);
|
|
438
|
+
if (target === tree) throw new Error(`${at}: cannot remove the root; present null to clear.`);
|
|
439
|
+
replaceIn(tree, target, null);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
throw new Error(`${at}: unknown op "${String(op.op)}". Use add, update, remove or set.`);
|
|
443
|
+
});
|
|
444
|
+
return {
|
|
445
|
+
root: tree,
|
|
446
|
+
state
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function find(node, pred) {
|
|
450
|
+
if (pred(node)) return node;
|
|
451
|
+
for (const c of node.children ?? []) {
|
|
452
|
+
const hit = find(c, pred);
|
|
453
|
+
if (hit) return hit;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function replaceIn(node, target, next) {
|
|
457
|
+
const kids = node.children;
|
|
458
|
+
if (!kids) return false;
|
|
459
|
+
const i = kids.indexOf(target);
|
|
460
|
+
if (i >= 0) {
|
|
461
|
+
if (next) kids[i] = next;
|
|
462
|
+
else kids.splice(i, 1);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
return kids.some((c) => replaceIn(c, target, next));
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/index.tsx
|
|
469
|
+
const ACTION_TYPES = [
|
|
470
|
+
"set",
|
|
471
|
+
"merge",
|
|
472
|
+
"push",
|
|
473
|
+
"remove",
|
|
474
|
+
"increment",
|
|
475
|
+
"toggle",
|
|
476
|
+
"tool"
|
|
477
|
+
];
|
|
478
|
+
/** Build the `present` input schema for the currently allowed components. */
|
|
479
|
+
function catalogSchema(catalog, allowed) {
|
|
480
|
+
return {
|
|
481
|
+
type: "object",
|
|
482
|
+
$defs: {
|
|
483
|
+
node: {
|
|
484
|
+
type: "object",
|
|
485
|
+
properties: {
|
|
486
|
+
component: {
|
|
487
|
+
type: "string",
|
|
488
|
+
enum: [...allowed]
|
|
489
|
+
},
|
|
490
|
+
id: {
|
|
491
|
+
type: "string",
|
|
492
|
+
description: "Stable id; needed to patch this node with ops."
|
|
493
|
+
},
|
|
494
|
+
props: {
|
|
495
|
+
type: "object",
|
|
496
|
+
description: "Props for the component; see the tool description."
|
|
497
|
+
},
|
|
498
|
+
bind: {
|
|
499
|
+
type: "object",
|
|
500
|
+
additionalProperties: { type: "string" },
|
|
501
|
+
description: "Prop -> state path, e.g. {\"value\":\"data.revenue\"}. data.* is app data, ui.* is surface state."
|
|
502
|
+
},
|
|
503
|
+
on: {
|
|
504
|
+
type: "object",
|
|
505
|
+
additionalProperties: {
|
|
506
|
+
type: "array",
|
|
507
|
+
items: { $ref: "#/$defs/action" }
|
|
508
|
+
},
|
|
509
|
+
description: "Event -> actions, run in order."
|
|
510
|
+
},
|
|
511
|
+
when: {
|
|
512
|
+
type: "string",
|
|
513
|
+
description: "Render only while this path is truthy (\"!ui.x\" for falsy)."
|
|
514
|
+
},
|
|
515
|
+
children: {
|
|
516
|
+
type: "array",
|
|
517
|
+
items: { $ref: "#/$defs/node" }
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
required: ["component"]
|
|
521
|
+
},
|
|
522
|
+
action: {
|
|
523
|
+
type: "object",
|
|
524
|
+
properties: {
|
|
525
|
+
type: {
|
|
526
|
+
type: "string",
|
|
527
|
+
enum: ACTION_TYPES
|
|
528
|
+
},
|
|
529
|
+
path: {
|
|
530
|
+
type: "string",
|
|
531
|
+
description: "ui.* path to write."
|
|
532
|
+
},
|
|
533
|
+
value: {},
|
|
534
|
+
by: { type: "number" },
|
|
535
|
+
name: {
|
|
536
|
+
type: "string",
|
|
537
|
+
description: "tool: the tool to call."
|
|
538
|
+
},
|
|
539
|
+
args: {
|
|
540
|
+
type: "object",
|
|
541
|
+
description: "tool: arguments. {{value}} is the event value; {{ui.x}} and {{data.x}} read state."
|
|
542
|
+
},
|
|
543
|
+
into: {
|
|
544
|
+
type: "string",
|
|
545
|
+
description: "tool: ui.* path that receives the result."
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
required: ["type"]
|
|
549
|
+
},
|
|
550
|
+
op: {
|
|
551
|
+
type: "object",
|
|
552
|
+
properties: {
|
|
553
|
+
op: {
|
|
554
|
+
type: "string",
|
|
555
|
+
enum: [
|
|
556
|
+
"add",
|
|
557
|
+
"update",
|
|
558
|
+
"remove",
|
|
559
|
+
"set"
|
|
560
|
+
]
|
|
561
|
+
},
|
|
562
|
+
parent: {
|
|
563
|
+
type: "string",
|
|
564
|
+
description: "add: parent node id (\"root\" for the root)."
|
|
565
|
+
},
|
|
566
|
+
index: { type: "number" },
|
|
567
|
+
node: { $ref: "#/$defs/node" },
|
|
568
|
+
id: {
|
|
569
|
+
type: "string",
|
|
570
|
+
description: "remove: node id."
|
|
571
|
+
},
|
|
572
|
+
path: {
|
|
573
|
+
type: "string",
|
|
574
|
+
description: "set: ui.* path."
|
|
575
|
+
},
|
|
576
|
+
value: {}
|
|
577
|
+
},
|
|
578
|
+
required: ["op"]
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
properties: {
|
|
582
|
+
root: {
|
|
583
|
+
anyOf: [{ $ref: "#/$defs/node" }, { type: "null" }],
|
|
584
|
+
description: "Tree to render; replaces the current one. null clears."
|
|
585
|
+
},
|
|
586
|
+
ops: {
|
|
587
|
+
type: "array",
|
|
588
|
+
items: { $ref: "#/$defs/op" },
|
|
589
|
+
description: "Edit the current tree in place instead of resending it."
|
|
590
|
+
},
|
|
591
|
+
ui: {
|
|
592
|
+
type: "object",
|
|
593
|
+
description: "Initial ui.* state for a new root."
|
|
594
|
+
},
|
|
595
|
+
title: { type: "string" }
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function propSummary(schema) {
|
|
600
|
+
const props = schema.properties ?? {};
|
|
601
|
+
const required = new Set(schema.required ?? []);
|
|
602
|
+
return Object.entries(props).map(([k, v]) => {
|
|
603
|
+
let t = Array.isArray(v.type) ? v.type.join("|") : String(v.type ?? "any");
|
|
604
|
+
if (Array.isArray(v.enum)) t = v.enum.map(String).join("|");
|
|
605
|
+
if (t === "array") t = `${v.items?.type ?? "any"}[]`;
|
|
606
|
+
return `${k}${required.has(k) ? "" : "?"}:${t}`;
|
|
607
|
+
}).join(",");
|
|
608
|
+
}
|
|
609
|
+
function renderSpec(spec, catalog, key = "0", ctx) {
|
|
610
|
+
const entry = catalog[spec.component];
|
|
611
|
+
if (!entry) return null;
|
|
612
|
+
if (ctx && !isVisible(spec, ctx.state)) return null;
|
|
613
|
+
const props = {
|
|
614
|
+
...ctx ? resolveProps(spec, ctx.state) : spec.props ?? {},
|
|
615
|
+
key
|
|
616
|
+
};
|
|
617
|
+
if (ctx && spec.on) {
|
|
618
|
+
for (const [event, prop] of Object.entries(entry.events ?? {})) if (spec.on[event]) props[prop] = (value) => ctx.emit(spec, event, value);
|
|
619
|
+
}
|
|
620
|
+
if (entry.childrenProp && spec.children?.length) props[entry.childrenProp] = spec.children.map((c, i) => renderSpec(c, catalog, `${key}.${c.id ?? i}`, ctx));
|
|
621
|
+
return createElement(entry.component, props);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Renders whatever the agent last called `present` with. Every catalog
|
|
625
|
+
* component is declared as a capability with a `render` descriptor, so the
|
|
626
|
+
* catalog is a projection of the same registry that holds the tools.
|
|
627
|
+
*
|
|
628
|
+
* Nodes can bind props to app data (data.*) or surface state (ui.*), and wire
|
|
629
|
+
* events to actions that write ui.* or call registered tools. A button in the
|
|
630
|
+
* generated UI and an agent reach a tool through the same registry path, so
|
|
631
|
+
* confirmation, caching and invalidation apply to both.
|
|
632
|
+
*/
|
|
633
|
+
function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError }) {
|
|
634
|
+
const [state, setState] = useState({
|
|
635
|
+
spec: null,
|
|
636
|
+
at: 0
|
|
637
|
+
});
|
|
638
|
+
const [error, setError] = useState(null);
|
|
639
|
+
const [store] = useState(() => createUiStore());
|
|
640
|
+
const ui = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
641
|
+
const agentable = useAgentable();
|
|
642
|
+
const snapshot = useAgentableSnapshot();
|
|
643
|
+
const onPresentRef = useRef(onPresent);
|
|
644
|
+
onPresentRef.current = onPresent;
|
|
645
|
+
const onActionErrorRef = useRef(onActionError);
|
|
646
|
+
onActionErrorRef.current = onActionError;
|
|
647
|
+
const catalogRef = useRef(components);
|
|
648
|
+
catalogRef.current = components;
|
|
649
|
+
const specRef = useRef(state.spec);
|
|
650
|
+
specRef.current = state.spec;
|
|
651
|
+
const dataRef = useRef(data ?? {});
|
|
652
|
+
dataRef.current = data ?? {};
|
|
653
|
+
const baseId = id ?? `genui.${name}`;
|
|
654
|
+
const readName = stateTool === void 0 ? data ? `${name}_state` : false : stateTool;
|
|
655
|
+
const openGroups = useMemo(() => new Set(snapshot.groups.filter((g) => g.open).map((g) => g.name)), [snapshot.groups]);
|
|
656
|
+
const allowed = useMemo(() => {
|
|
657
|
+
const out = /* @__PURE__ */ new Set();
|
|
658
|
+
for (const [n, e] of Object.entries(components)) if ((e.tier ?? 0) === 0 || e.group && openGroups.has(e.group)) out.add(n);
|
|
659
|
+
return out;
|
|
660
|
+
}, [components, openGroups]);
|
|
661
|
+
const allowedKey = [...allowed].join(",");
|
|
662
|
+
const allowedRef = useRef(allowed);
|
|
663
|
+
allowedRef.current = allowed;
|
|
664
|
+
const callable = useMemo(() => {
|
|
665
|
+
const out = /* @__PURE__ */ new Map();
|
|
666
|
+
if (tools === false) return out;
|
|
667
|
+
const only = tools ? new Set(tools) : null;
|
|
668
|
+
for (const c of snapshot.capabilities) {
|
|
669
|
+
if (!c.toolName || c.declaredTier > 1 || c.id.startsWith(`${baseId}.`) || c.id === baseId) continue;
|
|
670
|
+
if (only && !only.has(c.toolName)) continue;
|
|
671
|
+
if (!out.has(c.toolName)) out.set(c.toolName, c.id);
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
}, [
|
|
675
|
+
snapshot.capabilities,
|
|
676
|
+
tools,
|
|
677
|
+
baseId
|
|
678
|
+
]);
|
|
679
|
+
const callableRef = useRef(callable);
|
|
680
|
+
callableRef.current = callable;
|
|
681
|
+
const entryCaps = useMemo(() => Object.entries(components).map(([n, e]) => ({
|
|
682
|
+
id: `${baseId}.catalog.${n}`,
|
|
683
|
+
group: e.group ?? group,
|
|
684
|
+
tier: e.tier ?? 0,
|
|
685
|
+
render: {
|
|
686
|
+
component: n,
|
|
687
|
+
props: e.props,
|
|
688
|
+
description: e.description
|
|
689
|
+
}
|
|
690
|
+
})), [
|
|
691
|
+
components,
|
|
692
|
+
baseId,
|
|
693
|
+
group
|
|
694
|
+
]);
|
|
695
|
+
const description = useMemo(() => {
|
|
696
|
+
const lines = [...allowed].map((n) => {
|
|
697
|
+
const e = components[n];
|
|
698
|
+
let schema;
|
|
699
|
+
try {
|
|
700
|
+
schema = toJsonSchema(e.props);
|
|
701
|
+
} catch {
|
|
702
|
+
schema = {};
|
|
703
|
+
}
|
|
704
|
+
const events = Object.keys(e.events ?? {});
|
|
705
|
+
return `${n}{${propSummary(schema)}}${e.childrenProp ? "+children" : ""}${events.length ? ` on:${events.join("|")}` : ""}`;
|
|
706
|
+
});
|
|
707
|
+
let text = "Render UI with this app's components: a tree of {component, props, children}. Nodes can bind props to state, run actions on events, and be patched by id via ops.";
|
|
708
|
+
if (readName) text += ` Call ${readName} for bindable data.`;
|
|
709
|
+
text += ` Components: ${lines.join(" ")}`;
|
|
710
|
+
if (text.length > 500) text = text.slice(0, 497) + "...";
|
|
711
|
+
return text;
|
|
712
|
+
}, [
|
|
713
|
+
allowedKey,
|
|
714
|
+
components,
|
|
715
|
+
readName
|
|
716
|
+
]);
|
|
717
|
+
const inputSchema = useMemo(() => catalogSchema(components, allowed), [components, allowed]);
|
|
718
|
+
const clear = useCallback(() => {
|
|
719
|
+
store.replace({});
|
|
720
|
+
setError(null);
|
|
721
|
+
setState({
|
|
722
|
+
spec: null,
|
|
723
|
+
at: Date.now()
|
|
724
|
+
});
|
|
725
|
+
}, [store]);
|
|
726
|
+
const execute = useCallback(async (args) => {
|
|
727
|
+
if (args.root === null) {
|
|
728
|
+
clear();
|
|
729
|
+
onPresentRef.current?.(null);
|
|
730
|
+
return { cleared: true };
|
|
731
|
+
}
|
|
732
|
+
if (args.root === void 0 && !args.ops?.length) return { error: "Send root to render a tree, ops to patch it, or root: null to clear." };
|
|
733
|
+
let tree = args.root ?? specRef.current;
|
|
734
|
+
if (!tree) return { error: "Nothing is presented yet; send root first." };
|
|
735
|
+
const draft = createUiStore(args.root ? args.ui ?? {} : store.get());
|
|
736
|
+
try {
|
|
737
|
+
if (args.ops?.length) {
|
|
738
|
+
const patched = applyOps(tree, args.ops);
|
|
739
|
+
tree = patched.root;
|
|
740
|
+
if (patched.state.length) draft.dispatch(patched.state);
|
|
741
|
+
}
|
|
742
|
+
} catch (e) {
|
|
743
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
744
|
+
}
|
|
745
|
+
const result = await validateSpec(tree, catalogRef.current, allowedRef.current, {
|
|
746
|
+
maxDepth,
|
|
747
|
+
maxNodes,
|
|
748
|
+
state: {
|
|
749
|
+
data: dataRef.current,
|
|
750
|
+
ui: draft.get()
|
|
751
|
+
},
|
|
752
|
+
tools: new Set(callableRef.current.keys())
|
|
753
|
+
});
|
|
754
|
+
if (!result.ok) return {
|
|
755
|
+
error: "Invalid UI spec.",
|
|
756
|
+
issues: result.issues
|
|
757
|
+
};
|
|
758
|
+
const title = args.title ?? (args.root ? void 0 : state.title);
|
|
759
|
+
store.replace(draft.get());
|
|
760
|
+
setError(null);
|
|
761
|
+
setState({
|
|
762
|
+
spec: result.spec,
|
|
763
|
+
title,
|
|
764
|
+
at: args.root ? Date.now() : state.at
|
|
765
|
+
});
|
|
766
|
+
onPresentRef.current?.(result.spec, title);
|
|
767
|
+
return {
|
|
768
|
+
rendered: true,
|
|
769
|
+
nodes: result.nodes
|
|
770
|
+
};
|
|
771
|
+
}, [
|
|
772
|
+
maxDepth,
|
|
773
|
+
maxNodes,
|
|
774
|
+
store,
|
|
775
|
+
clear,
|
|
776
|
+
state.title,
|
|
777
|
+
state.at
|
|
778
|
+
]);
|
|
779
|
+
useCapability({
|
|
780
|
+
id: baseId,
|
|
781
|
+
group,
|
|
782
|
+
tier,
|
|
783
|
+
priority,
|
|
784
|
+
tool: {
|
|
785
|
+
name,
|
|
786
|
+
title: "Present UI",
|
|
787
|
+
description,
|
|
788
|
+
inputSchema,
|
|
789
|
+
annotations: {
|
|
790
|
+
readOnlyHint: false,
|
|
791
|
+
consequentialHint: false
|
|
792
|
+
},
|
|
793
|
+
confirm: false,
|
|
794
|
+
execute
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
useCapability(readName ? {
|
|
798
|
+
id: `${baseId}.state`,
|
|
799
|
+
group,
|
|
800
|
+
tier,
|
|
801
|
+
priority,
|
|
802
|
+
tool: {
|
|
803
|
+
name: readName,
|
|
804
|
+
title: "Read surface state",
|
|
805
|
+
description: `Read what ${name} can bind to. Without a path: the shape of data.*, current ui.* state and the presented tree. With a path (e.g. "data.orders"): that value.`,
|
|
806
|
+
inputSchema: {
|
|
807
|
+
type: "object",
|
|
808
|
+
properties: { path: {
|
|
809
|
+
type: "string",
|
|
810
|
+
description: "data.* or ui.* path."
|
|
811
|
+
} }
|
|
812
|
+
},
|
|
813
|
+
annotations: { readOnlyHint: true },
|
|
814
|
+
cache: false,
|
|
815
|
+
execute: ({ path }) => {
|
|
816
|
+
const s = {
|
|
817
|
+
data: dataRef.current,
|
|
818
|
+
ui: store.get()
|
|
819
|
+
};
|
|
820
|
+
if (path) return getPath(s, path) ?? null;
|
|
821
|
+
return {
|
|
822
|
+
data: shapeOf(s.data),
|
|
823
|
+
ui: s.ui,
|
|
824
|
+
tree: specRef.current
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} : null);
|
|
829
|
+
const fail = useCallback((message, node) => {
|
|
830
|
+
setError(message);
|
|
831
|
+
onActionErrorRef.current?.(message, node);
|
|
832
|
+
}, []);
|
|
833
|
+
const emit = useCallback((node, event, value) => {
|
|
834
|
+
const actions = node.on?.[event];
|
|
835
|
+
if (!actions?.length) return;
|
|
836
|
+
(async () => {
|
|
837
|
+
setError(null);
|
|
838
|
+
for (const raw of actions) {
|
|
839
|
+
const action = interpolate(raw, { value }, {
|
|
840
|
+
data: dataRef.current,
|
|
841
|
+
ui: store.get()
|
|
842
|
+
});
|
|
843
|
+
if (action.type !== "tool") {
|
|
844
|
+
try {
|
|
845
|
+
store.dispatch(action);
|
|
846
|
+
} catch (e) {
|
|
847
|
+
return fail(e instanceof Error ? e.message : String(e), node);
|
|
848
|
+
}
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const capId = callableRef.current.get(action.name);
|
|
852
|
+
if (!agentable || !capId) return fail(`Tool "${action.name}" is not available.`, node);
|
|
853
|
+
const result = parseResult(await agentable.invokeCapability(capId, action.args ?? {}));
|
|
854
|
+
if (result && typeof result === "object" && "error" in result) return fail(String(result.error), node);
|
|
855
|
+
if (action.into) store.dispatch({
|
|
856
|
+
type: "set",
|
|
857
|
+
path: action.into,
|
|
858
|
+
value: result
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
})();
|
|
862
|
+
}, [
|
|
863
|
+
agentable,
|
|
864
|
+
store,
|
|
865
|
+
fail
|
|
866
|
+
]);
|
|
867
|
+
const ctx = useMemo(() => ({
|
|
868
|
+
state: {
|
|
869
|
+
data: data ?? {},
|
|
870
|
+
ui
|
|
871
|
+
},
|
|
872
|
+
emit
|
|
873
|
+
}), [
|
|
874
|
+
data,
|
|
875
|
+
ui,
|
|
876
|
+
emit
|
|
877
|
+
]);
|
|
878
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
879
|
+
className,
|
|
880
|
+
"data-agentable-surface": "",
|
|
881
|
+
"data-empty": state.spec ? void 0 : "",
|
|
882
|
+
children: [entryCaps.map((c) => /* @__PURE__ */ jsx(DeclareRender, { cap: c }, c.id)), state.spec ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
883
|
+
state.title || clearable ? /* @__PURE__ */ jsxs("div", {
|
|
884
|
+
"data-agentable-surface-header": "",
|
|
885
|
+
children: [state.title ? /* @__PURE__ */ jsx("h3", { children: state.title }) : /* @__PURE__ */ jsx("span", {}), clearable ? /* @__PURE__ */ jsx("button", {
|
|
886
|
+
type: "button",
|
|
887
|
+
onClick: clear,
|
|
888
|
+
"data-agentable-button": "",
|
|
889
|
+
"aria-label": "Clear agent output",
|
|
890
|
+
children: "Clear"
|
|
891
|
+
}) : null]
|
|
892
|
+
}) : null,
|
|
893
|
+
error ? /* @__PURE__ */ jsx("p", {
|
|
894
|
+
role: "alert",
|
|
895
|
+
"data-agentable-surface-error": "",
|
|
896
|
+
children: error
|
|
897
|
+
}) : null,
|
|
898
|
+
/* @__PURE__ */ jsx(SurfaceBoundary, { children: /* @__PURE__ */ jsx("div", {
|
|
899
|
+
"data-agentable-surface-body": "",
|
|
900
|
+
children: renderSpec(state.spec, components, "0", ctx)
|
|
901
|
+
}) }, state.at)
|
|
902
|
+
] }) : placeholder ?? null]
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
function DeclareRender({ cap }) {
|
|
906
|
+
useCapability(cap);
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
/** Bound values change after validation; a component that throws on them must not take the page down. */
|
|
910
|
+
var SurfaceBoundary = class extends Component {
|
|
911
|
+
state = { failed: null };
|
|
912
|
+
static getDerivedStateFromError(e) {
|
|
913
|
+
return { failed: e instanceof Error ? e.message : String(e) };
|
|
914
|
+
}
|
|
915
|
+
render() {
|
|
916
|
+
if (this.state.failed) return /* @__PURE__ */ jsxs("p", {
|
|
917
|
+
role: "alert",
|
|
918
|
+
"data-agentable-surface-error": "",
|
|
919
|
+
children: ["This view failed to render: ", this.state.failed]
|
|
920
|
+
});
|
|
921
|
+
return this.props.children;
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
function parseResult(text) {
|
|
925
|
+
try {
|
|
926
|
+
return JSON.parse(text);
|
|
927
|
+
} catch {
|
|
928
|
+
return text;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/** A compact description of data for the agent: keys, array lengths, a sample item's fields. */
|
|
932
|
+
function shapeOf(value, depth = 0) {
|
|
933
|
+
if (Array.isArray(value)) {
|
|
934
|
+
const first = value[0];
|
|
935
|
+
return first && typeof first === "object" && !Array.isArray(first) ? {
|
|
936
|
+
array: value.length,
|
|
937
|
+
item: Object.keys(first)
|
|
938
|
+
} : { array: value.length };
|
|
939
|
+
}
|
|
940
|
+
if (value && typeof value === "object") {
|
|
941
|
+
if (depth >= 2) return Object.keys(value);
|
|
942
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, shapeOf(v, depth + 1)]));
|
|
943
|
+
}
|
|
944
|
+
return typeof value;
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
export { AgentSurface, applyOps, catalogSchema, createUiStore, getPath, interpolate, isVisible, parsePath, renderSpec, resolveProps, validateSpec };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentable-ui/genui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generative UI for Agentable: AgentSurface, catalog schema generation, validated rendering via a single present tool.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/harsh071/agentable-ui",
|
|
23
|
+
"directory": "packages/genui"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"webmcp",
|
|
27
|
+
"agent",
|
|
28
|
+
"mcp",
|
|
29
|
+
"components",
|
|
30
|
+
"react",
|
|
31
|
+
"modelContext"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@agentable-ui/core": "^0.1.0",
|
|
38
|
+
"@agentable-ui/react": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": "^18.2.0 || ^19.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"react": "^19.2.8",
|
|
45
|
+
"react-dom": "^19.2.8",
|
|
46
|
+
"@types/react": "^19.2.18",
|
|
47
|
+
"@types/react-dom": "^19.2.7",
|
|
48
|
+
"@testing-library/react": "^16.3.3",
|
|
49
|
+
"@agentable-ui/ui": "^0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
54
|
+
"test": "vitest run"
|
|
55
|
+
}
|
|
56
|
+
}
|