@agentable-ui/genui 0.1.0 → 0.3.1
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 +78 -34
- package/dist/index.d.ts +72 -6
- package/dist/index.js +290 -19
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,66 +1,110 @@
|
|
|
1
1
|
# @agentable-ui/genui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render model-generated interfaces using components you allow. Use `Renderer` for text streamed by your application, or `AgentSurface` for an in-page agent that calls a `present` tool.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
4
6
|
|
|
5
7
|
```bash
|
|
6
8
|
npm install @agentable-ui/genui @agentable-ui/ui @agentable-ui/react @agentable-ui/core
|
|
7
9
|
```
|
|
8
10
|
|
|
11
|
+
## Render a program
|
|
12
|
+
|
|
13
|
+
This complete example renders a static program. Replace `program` with the accumulated text from your model stream and set `isStreaming` while chunks arrive.
|
|
14
|
+
|
|
9
15
|
```tsx
|
|
10
|
-
import {
|
|
16
|
+
import { Renderer } from "@agentable-ui/genui";
|
|
11
17
|
import { uiCatalog } from "@agentable-ui/ui";
|
|
18
|
+
import "@agentable-ui/ui/styles.css";
|
|
19
|
+
|
|
20
|
+
const program = `root = Card("Order summary", [total])
|
|
21
|
+
total = Stat("Revenue", "$48.2k")`;
|
|
12
22
|
|
|
13
|
-
|
|
23
|
+
export default function Summary() {
|
|
24
|
+
return <Renderer components={uiCatalog} response={program} isStreaming={false} />;
|
|
25
|
+
}
|
|
14
26
|
```
|
|
15
27
|
|
|
16
|
-
|
|
28
|
+
`response` is the **full text received so far**, not just the newest chunk. Complete lines render as they arrive; interactions stay disabled until streaming finishes. `onError` receives structured parsing and validation issues. Your app owns model requests, credentials, and streaming transport.
|
|
17
29
|
|
|
18
|
-
##
|
|
30
|
+
## Give a browser agent a surface
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
```tsx
|
|
33
|
+
import { AgentableProvider } from "@agentable-ui/react";
|
|
34
|
+
import { AgentSurface } from "@agentable-ui/genui";
|
|
35
|
+
import { uiCatalog } from "@agentable-ui/ui";
|
|
36
|
+
import "@agentable-ui/ui/styles.css";
|
|
37
|
+
|
|
38
|
+
export default function App() {
|
|
39
|
+
return (
|
|
40
|
+
<AgentableProvider>
|
|
41
|
+
<AgentSurface components={uiCatalog} name="present" maxNodes={60} />
|
|
42
|
+
</AgentableProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
```
|
|
21
46
|
|
|
22
|
-
|
|
47
|
+
The surface declares one `present` tool with a schema derived from its currently allowed catalog. An agent can supply either a program or a JSON tree:
|
|
23
48
|
|
|
24
|
-
|
|
49
|
+
```json
|
|
50
|
+
{"program":"root = Card(\"Hello\")"}
|
|
51
|
+
```
|
|
25
52
|
|
|
26
|
-
|
|
27
|
-
|
|
53
|
+
```json
|
|
54
|
+
{"root":{"component":"Card","props":{"title":"Hello"}}}
|
|
55
|
+
```
|
|
28
56
|
|
|
29
|
-
|
|
57
|
+
A single tool avoids one tool per visual component; its schema size still depends on the catalog. Use a focused catalog and inspect the result in devtools.
|
|
30
58
|
|
|
31
|
-
|
|
59
|
+
## Bind data and handle events
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
- `$data.*` in Lang (or `data.*` in JSON bindings) reads application data passed through `data`. Generated UI cannot write to it. Read paths can filter and count arrays: `$data.items[done=true].length`.
|
|
62
|
+
- `$ui.*` is local interface state. Actions can set, merge, push, remove, increment, or toggle it.
|
|
63
|
+
- A catalog entry maps event names to callback props, for example `events: { press: "onPress" }`.
|
|
64
|
+
- `when` conditionally shows a node. `@Say` forwards a message to `onMessage`.
|
|
65
|
+
|
|
66
|
+
For example:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
root = Stack([input, greeting])
|
|
70
|
+
input = TextInput("Name", value: $ui.name, change: @Set($ui.name, $event))
|
|
71
|
+
greeting = Text("Thanks for sharing your name.", when: $ui.name)
|
|
43
72
|
```
|
|
44
73
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
74
|
+
Tool routing differs by host:
|
|
75
|
+
|
|
76
|
+
| Host | Tool configuration |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `Renderer` | `tools={{ tool_name: callback }}` supplies callbacks. Missing names can fall back to a mounted Agentable registry. Direct callbacks follow your application's policies. |
|
|
79
|
+
| `AgentSurface` | `tools={["tool_name"]}` narrows callable registry tools; `tools={false}` disables tool actions. Registry calls retain confirmation, caching, and invalidation. |
|
|
51
80
|
|
|
52
|
-
|
|
81
|
+
`AgentSurface` can also patch the current tree with `present({ ops })`, using node ids and `add`, `update`, `remove`, or `set` operations. When `data` is supplied, a read-only `<name>_state` tool reports the data shape, UI state, and current tree; set `stateTool={false}` to omit it.
|
|
82
|
+
|
|
83
|
+
## Validation and custom catalogs
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { Stat } from "@agentable-ui/ui";
|
|
53
87
|
|
|
54
|
-
```ts
|
|
55
88
|
const catalog = {
|
|
56
89
|
Stat: {
|
|
57
90
|
component: Stat,
|
|
58
|
-
|
|
59
|
-
|
|
91
|
+
description: "A headline number.",
|
|
92
|
+
props: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
label: { type: "string" },
|
|
96
|
+
value: { type: "string" },
|
|
97
|
+
},
|
|
98
|
+
required: ["label", "value"],
|
|
99
|
+
},
|
|
60
100
|
},
|
|
61
101
|
};
|
|
62
102
|
```
|
|
63
103
|
|
|
64
|
-
|
|
104
|
+
Catalog schemas accept JSON Schema or supported Standard Schema validators. Unknown components, invalid props, and excessive depth/node counts produce structured issues. `AgentSurface` defaults to depth 6 and 60 nodes. Renderer prunes invalid nodes so valid parts can remain visible; validation options depend on the host.
|
|
105
|
+
|
|
106
|
+
Lower-level exports include `validateSpec`, `catalogSchema`, and `renderSpec`. Validate before calling `renderSpec` directly.
|
|
107
|
+
|
|
108
|
+
See [lang](../lang) for syntax, prompts, and parsing; [ui](../ui) for the built-in catalog.
|
|
65
109
|
|
|
66
|
-
MIT
|
|
110
|
+
MIT licensed.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ComponentType, ReactNode } from "react";
|
|
2
2
|
import { JSONSchema, SchemaInput, Tier } from "@agentable-ui/core";
|
|
3
|
+
import { Library } from "@agentable-ui/lang";
|
|
3
4
|
//#region src/state.d.ts
|
|
4
5
|
/**
|
|
5
6
|
* Surface state. Two roots: `data` is the app's, read-only to generated UI;
|
|
@@ -9,9 +10,19 @@ interface SurfaceState {
|
|
|
9
10
|
data: Record<string, unknown>;
|
|
10
11
|
ui: Record<string, unknown>;
|
|
11
12
|
}
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** A read-only list filter in a path: `items[done=true]`, `items[status!="archived"]`. */
|
|
14
|
+
interface PathFilter {
|
|
15
|
+
field: string;
|
|
16
|
+
op: "=" | "!=";
|
|
17
|
+
value: string | number | boolean;
|
|
18
|
+
}
|
|
19
|
+
type PathKey = string | number | PathFilter;
|
|
20
|
+
/**
|
|
21
|
+
* "a.b[0].c" -> ["a", "b", 0, "c"]; "items[done=true]" -> ["items", {field: "done", op: "=", value: true}].
|
|
22
|
+
* Throws on __proto__, prototype and constructor segments, and on malformed selectors.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parsePath(path: string): PathKey[];
|
|
25
|
+
/** Read a path, following own properties only. Filters narrow arrays. Undefined for missing or unsafe paths. */
|
|
15
26
|
export declare function getPath(obj: unknown, path: string): unknown;
|
|
16
27
|
type StateAction = {
|
|
17
28
|
type: "set";
|
|
@@ -61,7 +72,12 @@ interface ToolAction {
|
|
|
61
72
|
args?: Record<string, unknown>;
|
|
62
73
|
into?: string;
|
|
63
74
|
}
|
|
64
|
-
|
|
75
|
+
/** Send text to the assistant (the surface's `onMessage`). */
|
|
76
|
+
interface MessageAction {
|
|
77
|
+
type: "message";
|
|
78
|
+
text: string;
|
|
79
|
+
}
|
|
80
|
+
type UIAction = StateAction | ToolAction | MessageAction;
|
|
65
81
|
interface UISpec {
|
|
66
82
|
component: string;
|
|
67
83
|
/** Stable id, needed to target the node with patch ops. */
|
|
@@ -113,6 +129,11 @@ interface ValidateOptions {
|
|
|
113
129
|
state?: SurfaceState;
|
|
114
130
|
/** Tool names that `tool` actions may call. None when absent. */
|
|
115
131
|
tools?: ReadonlySet<string>;
|
|
132
|
+
/**
|
|
133
|
+
* Drop nodes with issues instead of failing the whole spec. Used while a
|
|
134
|
+
* program streams in, where a half-formed node must not blank the rest.
|
|
135
|
+
*/
|
|
136
|
+
prune?: boolean;
|
|
116
137
|
}
|
|
117
138
|
export declare function isVisible(node: Pick<UISpec, "when">, state: SurfaceState): boolean;
|
|
118
139
|
/** Static props overlaid with bound values that currently resolve. */
|
|
@@ -127,6 +148,7 @@ export declare function validateSpec(spec: UISpec, catalog: Record<string, Valid
|
|
|
127
148
|
ok: true;
|
|
128
149
|
spec: UISpec;
|
|
129
150
|
nodes: number;
|
|
151
|
+
issues: SpecIssue[];
|
|
130
152
|
} | {
|
|
131
153
|
ok: false;
|
|
132
154
|
issues: SpecIssue[];
|
|
@@ -141,6 +163,46 @@ export declare function applyOps(root: UISpec, ops: SurfaceOp[]): {
|
|
|
141
163
|
state: StateAction[];
|
|
142
164
|
};
|
|
143
165
|
//#endregion
|
|
166
|
+
//#region src/renderer.d.ts
|
|
167
|
+
interface RendererError {
|
|
168
|
+
code: string;
|
|
169
|
+
message: string;
|
|
170
|
+
line?: number;
|
|
171
|
+
path?: string;
|
|
172
|
+
}
|
|
173
|
+
interface RendererProps {
|
|
174
|
+
/** Components the program may use. The library (signatures, prompt) is derived from it. */
|
|
175
|
+
components: Catalog;
|
|
176
|
+
/** Pass a prebuilt library to share it with prompt generation. */
|
|
177
|
+
library?: Library;
|
|
178
|
+
/** The program text received so far. */
|
|
179
|
+
response: string;
|
|
180
|
+
/** True while tokens are still arriving: partial lines are held back and the UI is inert. */
|
|
181
|
+
isStreaming?: boolean;
|
|
182
|
+
/** App data the program may bind to as $data.*. */
|
|
183
|
+
data?: Record<string, unknown>;
|
|
184
|
+
/** Functions @Tool may call by name. Names not here fall back to the Agentable registry when a provider is mounted. */
|
|
185
|
+
tools?: Record<string, (args: Record<string, unknown>) => unknown | Promise<unknown>>;
|
|
186
|
+
/** Text sent by @Say. */
|
|
187
|
+
onMessage?: (text: string, node: UISpec) => void;
|
|
188
|
+
/** Structured parse and validation errors; `[]` once clean. Warnings are left out. */
|
|
189
|
+
onError?: (errors: RendererError[]) => void;
|
|
190
|
+
/** An action failed or was declined. */
|
|
191
|
+
onActionError?: (message: string, node: UISpec) => void;
|
|
192
|
+
/** $ui.* state to start from. */
|
|
193
|
+
initialUi?: Record<string, unknown>;
|
|
194
|
+
onUiChange?: (ui: Record<string, unknown>) => void;
|
|
195
|
+
placeholder?: ReactNode;
|
|
196
|
+
className?: string;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Renders an Agentable Lang program as it streams. Each complete line is
|
|
200
|
+
* parsed, the tree is validated against the catalog with bad nodes pruned,
|
|
201
|
+
* and the rest draws immediately. Bindings, `when` and actions work exactly
|
|
202
|
+
* as in <AgentSurface>; tool actions go to `tools` or the registry.
|
|
203
|
+
*/
|
|
204
|
+
export declare function Renderer({ components, library, response, isStreaming, data, tools, onMessage, onError, onActionError, initialUi, onUiChange, placeholder, className }: RendererProps): import("react").JSX.Element;
|
|
205
|
+
//#endregion
|
|
144
206
|
//#region src/index.d.ts
|
|
145
207
|
export interface CatalogComponent<P = Record<string, unknown>> {
|
|
146
208
|
component: ComponentType<P>;
|
|
@@ -161,6 +223,8 @@ export type Catalog = Record<string, CatalogComponent<never>>;
|
|
|
161
223
|
export interface PresentArgs {
|
|
162
224
|
/** The tree to render. Replaces the current one and resets ui state. null clears the surface. */
|
|
163
225
|
root?: UISpec | null;
|
|
226
|
+
/** The tree as an Agentable Lang program (about half the tokens of `root`). */
|
|
227
|
+
program?: string;
|
|
164
228
|
/** Patch the current tree (applied after `root` when both are given). */
|
|
165
229
|
ops?: SurfaceOp[];
|
|
166
230
|
/** ui.* state to start from when sending `root`. */
|
|
@@ -205,6 +269,8 @@ export interface AgentSurfaceProps {
|
|
|
205
269
|
stateTool?: string | false;
|
|
206
270
|
/** Called when an action in the generated UI fails or is declined. */
|
|
207
271
|
onActionError?: (message: string, node: UISpec) => void;
|
|
272
|
+
/** Receives text a `@Say` / message action sends to the assistant. Without it the action fails. */
|
|
273
|
+
onMessage?: (text: string, node: UISpec) => void;
|
|
208
274
|
}
|
|
209
275
|
/**
|
|
210
276
|
* Renders whatever the agent last called `present` with. Every catalog
|
|
@@ -216,6 +282,6 @@ export interface AgentSurfaceProps {
|
|
|
216
282
|
* generated UI and an agent reach a tool through the same registry path, so
|
|
217
283
|
* confirmation, caching and invalidation apply to both.
|
|
218
284
|
*/
|
|
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;
|
|
285
|
+
export declare function AgentSurface({ components, name, id, group, tier, priority, maxDepth, maxNodes, placeholder, onPresent, className, clearable, data, tools, stateTool, onActionError, onMessage }: AgentSurfaceProps): import("react").JSX.Element;
|
|
220
286
|
//#endregion
|
|
221
|
-
export type { SpecIssue, StateAction, SurfaceOp, SurfaceState, ToolAction, UIAction, UISpec, UiStore, ValidateOptions };
|
|
287
|
+
export type { MessageAction, PathFilter, PathKey, RendererError, RendererProps, SpecIssue, StateAction, SurfaceOp, SurfaceState, ToolAction, UIAction, UISpec, UiStore, ValidateOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Component, createElement, useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
1
|
+
import { Component, createElement, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import { toJsonSchema, validateInput } from "@agentable-ui/core";
|
|
3
3
|
import { useAgentable, useAgentableSnapshot, useCapability } from "@agentable-ui/react";
|
|
4
|
+
import { createLibrary } from "@agentable-ui/lang";
|
|
4
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
//#region src/state.ts
|
|
6
7
|
/** Keys that reach an object's prototype. Paths come from agents and user input, so they are never followed. */
|
|
@@ -9,14 +10,62 @@ const UNSAFE_KEYS = /* @__PURE__ */ new Set([
|
|
|
9
10
|
"prototype",
|
|
10
11
|
"constructor"
|
|
11
12
|
]);
|
|
12
|
-
|
|
13
|
+
const isFilter = (k) => typeof k === "object";
|
|
14
|
+
function literal(raw) {
|
|
15
|
+
if (raw.startsWith("\"")) return raw.slice(1, -1);
|
|
16
|
+
if (raw === "true") return true;
|
|
17
|
+
if (raw === "false") return false;
|
|
18
|
+
if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* "a.b[0].c" -> ["a", "b", 0, "c"]; "items[done=true]" -> ["items", {field: "done", op: "=", value: true}].
|
|
23
|
+
* Throws on __proto__, prototype and constructor segments, and on malformed selectors.
|
|
24
|
+
*/
|
|
13
25
|
function parsePath(path) {
|
|
14
26
|
if (!path) return [];
|
|
15
|
-
const keys =
|
|
16
|
-
|
|
27
|
+
const keys = [];
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < path.length) {
|
|
30
|
+
const c = path[i];
|
|
31
|
+
if (c === ".") {
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const rest = path.slice(i);
|
|
36
|
+
if (c === "[") {
|
|
37
|
+
const index = /^\[(\d+)\]/.exec(rest);
|
|
38
|
+
if (index) {
|
|
39
|
+
keys.push(Number(index[1]));
|
|
40
|
+
i += index[0].length;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const filter = /^\[([A-Za-z_][A-Za-z0-9_]*)(!?=)("[^"\]]*"|[A-Za-z0-9_.-]+)\]/.exec(rest);
|
|
44
|
+
if (!filter) throw new Error(`Path "${path}" has a bad selector; use [0] or [field=value].`);
|
|
45
|
+
keys.push({
|
|
46
|
+
field: filter[1],
|
|
47
|
+
op: filter[2],
|
|
48
|
+
value: literal(filter[3])
|
|
49
|
+
});
|
|
50
|
+
i += filter[0].length;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const seg = /^[^.[\]]+/.exec(rest)[0];
|
|
54
|
+
keys.push(/^\d+$/.test(seg) ? Number(seg) : seg);
|
|
55
|
+
i += seg.length;
|
|
56
|
+
}
|
|
57
|
+
for (const k of keys) {
|
|
58
|
+
const name = isFilter(k) ? k.field : k;
|
|
59
|
+
if (typeof name === "string" && UNSAFE_KEYS.has(name)) throw new Error(`Path "${path}" uses the reserved key "${name}".`);
|
|
60
|
+
}
|
|
17
61
|
return keys;
|
|
18
62
|
}
|
|
19
|
-
|
|
63
|
+
function matches(item, f) {
|
|
64
|
+
const v = item !== null && typeof item === "object" && Object.hasOwn(item, f.field) ? item[f.field] : void 0;
|
|
65
|
+
const eq = typeof f.value === "boolean" ? Boolean(v) === f.value : v !== void 0 && v !== null && String(v) === String(f.value);
|
|
66
|
+
return f.op === "=" ? eq : !eq;
|
|
67
|
+
}
|
|
68
|
+
/** Read a path, following own properties only. Filters narrow arrays. Undefined for missing or unsafe paths. */
|
|
20
69
|
function getPath(obj, path) {
|
|
21
70
|
let keys;
|
|
22
71
|
try {
|
|
@@ -26,6 +75,11 @@ function getPath(obj, path) {
|
|
|
26
75
|
}
|
|
27
76
|
let cur = obj;
|
|
28
77
|
for (const key of keys) {
|
|
78
|
+
if (isFilter(key)) {
|
|
79
|
+
if (!Array.isArray(cur)) return void 0;
|
|
80
|
+
cur = cur.filter((item) => matches(item, key));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
29
83
|
if (cur == null || typeof cur !== "object" || !Object.hasOwn(cur, key)) return void 0;
|
|
30
84
|
cur = cur[key];
|
|
31
85
|
}
|
|
@@ -63,6 +117,7 @@ function setPath(obj, keys, value) {
|
|
|
63
117
|
function uiKeys(path) {
|
|
64
118
|
const keys = parsePath(path);
|
|
65
119
|
if (keys[0] !== "ui" || keys.length < 2) throw new Error(`Actions may only write ui.* paths (got "${path}").`);
|
|
120
|
+
if (keys.some(isFilter)) throw new Error(`Filters like [field=value] are read-only (got "${path}").`);
|
|
66
121
|
return keys.slice(1);
|
|
67
122
|
}
|
|
68
123
|
function reduce(ui, a) {
|
|
@@ -192,9 +247,13 @@ function actionIssues(a, path, tools) {
|
|
|
192
247
|
});
|
|
193
248
|
return out;
|
|
194
249
|
}
|
|
250
|
+
if (a.type === "message") return typeof a.text === "string" ? [] : [{
|
|
251
|
+
path: `${path}.text`,
|
|
252
|
+
message: "message needs a text string."
|
|
253
|
+
}];
|
|
195
254
|
if (!STATE_ACTIONS.has(a.type)) return [{
|
|
196
255
|
path: `${path}.type`,
|
|
197
|
-
message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle or
|
|
256
|
+
message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle, tool or message.`
|
|
198
257
|
}];
|
|
199
258
|
if (!isStatePath(a.path, ["ui"])) return [{
|
|
200
259
|
path: `${path}.path`,
|
|
@@ -364,6 +423,7 @@ async function validateSpec(spec, catalog, allowed, opts = {}) {
|
|
|
364
423
|
if (Object.keys(on).length) out.on = on;
|
|
365
424
|
}
|
|
366
425
|
}
|
|
426
|
+
const ownBad = issues.length > before;
|
|
367
427
|
if (Array.isArray(node.children) && node.children.length) {
|
|
368
428
|
if (!entry.childrenProp) issues.push({
|
|
369
429
|
path: `${path}.children`,
|
|
@@ -378,17 +438,22 @@ async function validateSpec(spec, catalog, allowed, opts = {}) {
|
|
|
378
438
|
out.children = kids;
|
|
379
439
|
}
|
|
380
440
|
}
|
|
381
|
-
|
|
441
|
+
if (ownBad) {
|
|
442
|
+
nodes--;
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
382
446
|
}
|
|
383
447
|
const cleaned = await walk(spec, "root", 1, false);
|
|
384
|
-
if (issues.length
|
|
448
|
+
if (!cleaned || issues.length && !opts.prune) return {
|
|
385
449
|
ok: false,
|
|
386
450
|
issues
|
|
387
451
|
};
|
|
388
452
|
return {
|
|
389
453
|
ok: true,
|
|
390
454
|
spec: cleaned,
|
|
391
|
-
nodes
|
|
455
|
+
nodes,
|
|
456
|
+
issues
|
|
392
457
|
};
|
|
393
458
|
}
|
|
394
459
|
/**
|
|
@@ -465,6 +530,185 @@ function replaceIn(node, target, next) {
|
|
|
465
530
|
return kids.some((c) => replaceIn(c, target, next));
|
|
466
531
|
}
|
|
467
532
|
//#endregion
|
|
533
|
+
//#region src/renderer.tsx
|
|
534
|
+
/**
|
|
535
|
+
* Renders an Agentable Lang program as it streams. Each complete line is
|
|
536
|
+
* parsed, the tree is validated against the catalog with bad nodes pruned,
|
|
537
|
+
* and the rest draws immediately. Bindings, `when` and actions work exactly
|
|
538
|
+
* as in <AgentSurface>; tool actions go to `tools` or the registry.
|
|
539
|
+
*/
|
|
540
|
+
function Renderer({ components, library, response, isStreaming = false, data, tools, onMessage, onError, onActionError, initialUi, onUiChange, placeholder, className }) {
|
|
541
|
+
const lib = useMemo(() => library ?? createLibrary(components), [library, components]);
|
|
542
|
+
const [store] = useState(() => createUiStore(initialUi ?? {}));
|
|
543
|
+
const ui = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
544
|
+
const agentable = useAgentable();
|
|
545
|
+
const snapshot = useAgentableSnapshot();
|
|
546
|
+
const [spec, setSpec] = useState(null);
|
|
547
|
+
const [actionError, setActionError] = useState(null);
|
|
548
|
+
const dataRef = useRef(data ?? {});
|
|
549
|
+
dataRef.current = data ?? {};
|
|
550
|
+
const toolsRef = useRef(tools);
|
|
551
|
+
toolsRef.current = tools;
|
|
552
|
+
const onMessageRef = useRef(onMessage);
|
|
553
|
+
onMessageRef.current = onMessage;
|
|
554
|
+
const onErrorRef = useRef(onError);
|
|
555
|
+
onErrorRef.current = onError;
|
|
556
|
+
const onActionErrorRef = useRef(onActionError);
|
|
557
|
+
onActionErrorRef.current = onActionError;
|
|
558
|
+
const onUiChangeRef = useRef(onUiChange);
|
|
559
|
+
onUiChangeRef.current = onUiChange;
|
|
560
|
+
useEffect(() => store.subscribe(() => onUiChangeRef.current?.(store.get())), [store]);
|
|
561
|
+
const registryTools = useMemo(() => {
|
|
562
|
+
const out = /* @__PURE__ */ new Map();
|
|
563
|
+
for (const c of snapshot.capabilities) if (c.toolName && c.declaredTier <= 1 && !out.has(c.toolName)) out.set(c.toolName, c.id);
|
|
564
|
+
return out;
|
|
565
|
+
}, [snapshot.capabilities]);
|
|
566
|
+
const registryRef = useRef(registryTools);
|
|
567
|
+
registryRef.current = registryTools;
|
|
568
|
+
const callable = useMemo(() => /* @__PURE__ */ new Set([...Object.keys(tools ?? {}), ...registryTools.keys()]), [tools, registryTools]);
|
|
569
|
+
const parsed = useMemo(() => lib.parse(isStreaming ? heldBack(response) : response), [
|
|
570
|
+
lib,
|
|
571
|
+
response,
|
|
572
|
+
isStreaming
|
|
573
|
+
]);
|
|
574
|
+
useEffect(() => {
|
|
575
|
+
let live = true;
|
|
576
|
+
const allowed = new Set(Object.keys(components));
|
|
577
|
+
const errors = parsed.errors.filter((e) => e.severity === "error" && (e.code !== "no-root" || !isStreaming)).map(fromLang);
|
|
578
|
+
if (!parsed.root) {
|
|
579
|
+
setSpec(null);
|
|
580
|
+
onErrorRef.current?.(errors);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
validateSpec(parsed.root, components, allowed, {
|
|
584
|
+
state: {
|
|
585
|
+
data: dataRef.current,
|
|
586
|
+
ui: store.get()
|
|
587
|
+
},
|
|
588
|
+
tools: callable,
|
|
589
|
+
prune: true,
|
|
590
|
+
maxNodes: 200
|
|
591
|
+
}).then((r) => {
|
|
592
|
+
if (!live) return;
|
|
593
|
+
if (r.ok) {
|
|
594
|
+
setSpec(r.spec);
|
|
595
|
+
onErrorRef.current?.([...errors, ...r.issues.map(fromIssue)]);
|
|
596
|
+
} else {
|
|
597
|
+
setSpec(null);
|
|
598
|
+
onErrorRef.current?.([...errors, ...r.issues.map(fromIssue)]);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
return () => {
|
|
602
|
+
live = false;
|
|
603
|
+
};
|
|
604
|
+
}, [
|
|
605
|
+
parsed,
|
|
606
|
+
components,
|
|
607
|
+
callable,
|
|
608
|
+
isStreaming
|
|
609
|
+
]);
|
|
610
|
+
const fail = useCallback((message, node) => {
|
|
611
|
+
if (onActionErrorRef.current) onActionErrorRef.current(message, node);
|
|
612
|
+
else setActionError(message);
|
|
613
|
+
}, []);
|
|
614
|
+
const emit = useCallback((node, event, value) => {
|
|
615
|
+
const actions = node.on?.[event];
|
|
616
|
+
if (!actions?.length) return;
|
|
617
|
+
(async () => {
|
|
618
|
+
setActionError(null);
|
|
619
|
+
for (const raw of actions) {
|
|
620
|
+
const action = interpolate(raw, { value }, {
|
|
621
|
+
data: dataRef.current,
|
|
622
|
+
ui: store.get()
|
|
623
|
+
});
|
|
624
|
+
if (action.type === "message") {
|
|
625
|
+
if (!onMessageRef.current) return fail("Nothing is listening for messages here.", node);
|
|
626
|
+
onMessageRef.current(action.text, node);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (action.type !== "tool") {
|
|
630
|
+
try {
|
|
631
|
+
store.dispatch(action);
|
|
632
|
+
} catch (e) {
|
|
633
|
+
return fail(e instanceof Error ? e.message : String(e), node);
|
|
634
|
+
}
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
let result;
|
|
638
|
+
const fn = toolsRef.current?.[action.name];
|
|
639
|
+
try {
|
|
640
|
+
if (fn) result = await fn(action.args ?? {});
|
|
641
|
+
else {
|
|
642
|
+
const capId = registryRef.current.get(action.name);
|
|
643
|
+
if (!agentable || !capId) return fail(`Tool "${action.name}" is not available.`, node);
|
|
644
|
+
result = parseResult$1(await agentable.invokeCapability(capId, action.args ?? {}));
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
return fail(e instanceof Error ? e.message : String(e), node);
|
|
648
|
+
}
|
|
649
|
+
if (result && typeof result === "object" && "error" in result) return fail(String(result.error), node);
|
|
650
|
+
if (action.into) store.dispatch({
|
|
651
|
+
type: "set",
|
|
652
|
+
path: action.into,
|
|
653
|
+
value: result
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
})();
|
|
657
|
+
}, [
|
|
658
|
+
agentable,
|
|
659
|
+
store,
|
|
660
|
+
fail
|
|
661
|
+
]);
|
|
662
|
+
const ctx = useMemo(() => ({
|
|
663
|
+
state: {
|
|
664
|
+
data: data ?? {},
|
|
665
|
+
ui
|
|
666
|
+
},
|
|
667
|
+
emit
|
|
668
|
+
}), [
|
|
669
|
+
data,
|
|
670
|
+
ui,
|
|
671
|
+
emit
|
|
672
|
+
]);
|
|
673
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
674
|
+
className,
|
|
675
|
+
"data-agentable-surface": "",
|
|
676
|
+
"data-streaming": isStreaming ? "" : void 0,
|
|
677
|
+
"data-empty": spec ? void 0 : "",
|
|
678
|
+
children: [actionError ? /* @__PURE__ */ jsx("p", {
|
|
679
|
+
role: "alert",
|
|
680
|
+
"data-agentable-surface-error": "",
|
|
681
|
+
children: actionError
|
|
682
|
+
}) : null, spec ? /* @__PURE__ */ jsx("div", {
|
|
683
|
+
"data-agentable-surface-body": "",
|
|
684
|
+
inert: isStreaming || void 0,
|
|
685
|
+
children: renderSpec(spec, components, "0", ctx)
|
|
686
|
+
}) : placeholder ?? null]
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
/** While streaming, the last unterminated line is not parsed. */
|
|
690
|
+
function heldBack(text) {
|
|
691
|
+
const nl = text.lastIndexOf("\n");
|
|
692
|
+
return nl < 0 ? "" : text.slice(0, nl + 1);
|
|
693
|
+
}
|
|
694
|
+
const fromLang = (e) => ({
|
|
695
|
+
code: e.code,
|
|
696
|
+
message: e.message,
|
|
697
|
+
line: e.line
|
|
698
|
+
});
|
|
699
|
+
const fromIssue = (i) => ({
|
|
700
|
+
code: "invalid-node",
|
|
701
|
+
message: i.message,
|
|
702
|
+
path: i.path
|
|
703
|
+
});
|
|
704
|
+
function parseResult$1(text) {
|
|
705
|
+
try {
|
|
706
|
+
return JSON.parse(text);
|
|
707
|
+
} catch {
|
|
708
|
+
return text;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
//#endregion
|
|
468
712
|
//#region src/index.tsx
|
|
469
713
|
const ACTION_TYPES = [
|
|
470
714
|
"set",
|
|
@@ -583,6 +827,10 @@ function catalogSchema(catalog, allowed) {
|
|
|
583
827
|
anyOf: [{ $ref: "#/$defs/node" }, { type: "null" }],
|
|
584
828
|
description: "Tree to render; replaces the current one. null clears."
|
|
585
829
|
},
|
|
830
|
+
program: {
|
|
831
|
+
type: "string",
|
|
832
|
+
description: "The tree as a program, one node per line: `root = Stack([a, b])`, `a = Stat(\"Revenue\", \"$48k\")`. Same components; fewer tokens than root."
|
|
833
|
+
},
|
|
586
834
|
ops: {
|
|
587
835
|
type: "array",
|
|
588
836
|
items: { $ref: "#/$defs/op" },
|
|
@@ -630,7 +878,7 @@ function renderSpec(spec, catalog, key = "0", ctx) {
|
|
|
630
878
|
* generated UI and an agent reach a tool through the same registry path, so
|
|
631
879
|
* confirmation, caching and invalidation apply to both.
|
|
632
880
|
*/
|
|
633
|
-
function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError }) {
|
|
881
|
+
function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError, onMessage }) {
|
|
634
882
|
const [state, setState] = useState({
|
|
635
883
|
spec: null,
|
|
636
884
|
at: 0
|
|
@@ -644,8 +892,13 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
|
|
|
644
892
|
onPresentRef.current = onPresent;
|
|
645
893
|
const onActionErrorRef = useRef(onActionError);
|
|
646
894
|
onActionErrorRef.current = onActionError;
|
|
895
|
+
const onMessageRef = useRef(onMessage);
|
|
896
|
+
onMessageRef.current = onMessage;
|
|
647
897
|
const catalogRef = useRef(components);
|
|
648
898
|
catalogRef.current = components;
|
|
899
|
+
const library = useMemo(() => createLibrary(components), [components]);
|
|
900
|
+
const libraryRef = useRef(library);
|
|
901
|
+
libraryRef.current = library;
|
|
649
902
|
const specRef = useRef(state.spec);
|
|
650
903
|
specRef.current = state.spec;
|
|
651
904
|
const dataRef = useRef(data ?? {});
|
|
@@ -704,7 +957,7 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
|
|
|
704
957
|
const events = Object.keys(e.events ?? {});
|
|
705
958
|
return `${n}{${propSummary(schema)}}${e.childrenProp ? "+children" : ""}${events.length ? ` on:${events.join("|")}` : ""}`;
|
|
706
959
|
});
|
|
707
|
-
let text = "Render UI with this app's components: a
|
|
960
|
+
let text = "Render UI with this app's components: a program (one `name = Component(args)` line per node) or a {component, props, children} tree. Nodes bind props to state, run actions on events, and can be patched by id via ops.";
|
|
708
961
|
if (readName) text += ` Call ${readName} for bindable data.`;
|
|
709
962
|
text += ` Components: ${lines.join(" ")}`;
|
|
710
963
|
if (text.length > 500) text = text.slice(0, 497) + "...";
|
|
@@ -729,10 +982,23 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
|
|
|
729
982
|
onPresentRef.current?.(null);
|
|
730
983
|
return { cleared: true };
|
|
731
984
|
}
|
|
732
|
-
|
|
733
|
-
|
|
985
|
+
let root = args.root;
|
|
986
|
+
if (typeof args.program === "string" && root === void 0) {
|
|
987
|
+
const parsed = libraryRef.current.parse(args.program);
|
|
988
|
+
const errors = parsed.errors.filter((e) => e.severity === "error");
|
|
989
|
+
if (!parsed.root || errors.length) return {
|
|
990
|
+
error: "Invalid program.",
|
|
991
|
+
issues: parsed.errors.map((e) => ({
|
|
992
|
+
path: `line ${e.line}`,
|
|
993
|
+
message: e.message
|
|
994
|
+
}))
|
|
995
|
+
};
|
|
996
|
+
root = parsed.root;
|
|
997
|
+
}
|
|
998
|
+
if (root === void 0 && !args.ops?.length) return { error: "Send program or root to render, ops to patch, or root: null to clear." };
|
|
999
|
+
let tree = root ?? specRef.current;
|
|
734
1000
|
if (!tree) return { error: "Nothing is presented yet; send root first." };
|
|
735
|
-
const draft = createUiStore(
|
|
1001
|
+
const draft = createUiStore(root ? args.ui ?? {} : store.get());
|
|
736
1002
|
try {
|
|
737
1003
|
if (args.ops?.length) {
|
|
738
1004
|
const patched = applyOps(tree, args.ops);
|
|
@@ -755,13 +1021,13 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
|
|
|
755
1021
|
error: "Invalid UI spec.",
|
|
756
1022
|
issues: result.issues
|
|
757
1023
|
};
|
|
758
|
-
const title = args.title ?? (
|
|
1024
|
+
const title = args.title ?? (root ? void 0 : state.title);
|
|
759
1025
|
store.replace(draft.get());
|
|
760
1026
|
setError(null);
|
|
761
1027
|
setState({
|
|
762
1028
|
spec: result.spec,
|
|
763
1029
|
title,
|
|
764
|
-
at:
|
|
1030
|
+
at: root ? Date.now() : state.at
|
|
765
1031
|
});
|
|
766
1032
|
onPresentRef.current?.(result.spec, title);
|
|
767
1033
|
return {
|
|
@@ -827,8 +1093,8 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
|
|
|
827
1093
|
}
|
|
828
1094
|
} : null);
|
|
829
1095
|
const fail = useCallback((message, node) => {
|
|
830
|
-
|
|
831
|
-
|
|
1096
|
+
if (onActionErrorRef.current) onActionErrorRef.current(message, node);
|
|
1097
|
+
else setError(message);
|
|
832
1098
|
}, []);
|
|
833
1099
|
const emit = useCallback((node, event, value) => {
|
|
834
1100
|
const actions = node.on?.[event];
|
|
@@ -840,6 +1106,11 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
|
|
|
840
1106
|
data: dataRef.current,
|
|
841
1107
|
ui: store.get()
|
|
842
1108
|
});
|
|
1109
|
+
if (action.type === "message") {
|
|
1110
|
+
if (!onMessageRef.current) return fail("Nothing is listening for messages here.", node);
|
|
1111
|
+
onMessageRef.current(action.text, node);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
843
1114
|
if (action.type !== "tool") {
|
|
844
1115
|
try {
|
|
845
1116
|
store.dispatch(action);
|
|
@@ -944,4 +1215,4 @@ function shapeOf(value, depth = 0) {
|
|
|
944
1215
|
return typeof value;
|
|
945
1216
|
}
|
|
946
1217
|
//#endregion
|
|
947
|
-
export { AgentSurface, applyOps, catalogSchema, createUiStore, getPath, interpolate, isVisible, parsePath, renderSpec, resolveProps, validateSpec };
|
|
1218
|
+
export { AgentSurface, Renderer, applyOps, catalogSchema, createUiStore, getPath, interpolate, isVisible, parsePath, renderSpec, resolveProps, validateSpec };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentable-ui/genui",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Generative UI for Agentable:
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Generative UI for Agentable: a streaming Renderer for Agentable Lang programs, AgentSurface with a single validated present tool, catalog schema generation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@agentable-ui/
|
|
38
|
-
"@agentable-ui/
|
|
37
|
+
"@agentable-ui/react": "^0.1.1",
|
|
38
|
+
"@agentable-ui/lang": "^0.2.1",
|
|
39
|
+
"@agentable-ui/core": "^0.2.0"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"react": "^18.2.0 || ^19.0.0"
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"@types/react": "^19.2.18",
|
|
47
48
|
"@types/react-dom": "^19.2.7",
|
|
48
49
|
"@testing-library/react": "^16.3.3",
|
|
49
|
-
"@agentable-ui/ui": "^0.1
|
|
50
|
+
"@agentable-ui/ui": "^0.2.1"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
53
|
"build": "tsdown",
|