@agentable-ui/genui 0.1.0 → 0.2.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/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;
@@ -61,7 +62,12 @@ interface ToolAction {
61
62
  args?: Record<string, unknown>;
62
63
  into?: string;
63
64
  }
64
- type UIAction = StateAction | ToolAction;
65
+ /** Send text to the assistant (the surface's `onMessage`). */
66
+ interface MessageAction {
67
+ type: "message";
68
+ text: string;
69
+ }
70
+ type UIAction = StateAction | ToolAction | MessageAction;
65
71
  interface UISpec {
66
72
  component: string;
67
73
  /** Stable id, needed to target the node with patch ops. */
@@ -113,6 +119,11 @@ interface ValidateOptions {
113
119
  state?: SurfaceState;
114
120
  /** Tool names that `tool` actions may call. None when absent. */
115
121
  tools?: ReadonlySet<string>;
122
+ /**
123
+ * Drop nodes with issues instead of failing the whole spec. Used while a
124
+ * program streams in, where a half-formed node must not blank the rest.
125
+ */
126
+ prune?: boolean;
116
127
  }
117
128
  export declare function isVisible(node: Pick<UISpec, "when">, state: SurfaceState): boolean;
118
129
  /** Static props overlaid with bound values that currently resolve. */
@@ -127,6 +138,7 @@ export declare function validateSpec(spec: UISpec, catalog: Record<string, Valid
127
138
  ok: true;
128
139
  spec: UISpec;
129
140
  nodes: number;
141
+ issues: SpecIssue[];
130
142
  } | {
131
143
  ok: false;
132
144
  issues: SpecIssue[];
@@ -141,6 +153,46 @@ export declare function applyOps(root: UISpec, ops: SurfaceOp[]): {
141
153
  state: StateAction[];
142
154
  };
143
155
  //#endregion
156
+ //#region src/renderer.d.ts
157
+ interface RendererError {
158
+ code: string;
159
+ message: string;
160
+ line?: number;
161
+ path?: string;
162
+ }
163
+ interface RendererProps {
164
+ /** Components the program may use. The library (signatures, prompt) is derived from it. */
165
+ components: Catalog;
166
+ /** Pass a prebuilt library to share it with prompt generation. */
167
+ library?: Library;
168
+ /** The program text received so far. */
169
+ response: string;
170
+ /** True while tokens are still arriving: partial lines are held back and the UI is inert. */
171
+ isStreaming?: boolean;
172
+ /** App data the program may bind to as $data.*. */
173
+ data?: Record<string, unknown>;
174
+ /** Functions @Tool may call by name. Names not here fall back to the Agentable registry when a provider is mounted. */
175
+ tools?: Record<string, (args: Record<string, unknown>) => unknown | Promise<unknown>>;
176
+ /** Text sent by @Say. */
177
+ onMessage?: (text: string, node: UISpec) => void;
178
+ /** Structured parse and validation errors; `[]` once clean. Warnings are left out. */
179
+ onError?: (errors: RendererError[]) => void;
180
+ /** An action failed or was declined. */
181
+ onActionError?: (message: string, node: UISpec) => void;
182
+ /** $ui.* state to start from. */
183
+ initialUi?: Record<string, unknown>;
184
+ onUiChange?: (ui: Record<string, unknown>) => void;
185
+ placeholder?: ReactNode;
186
+ className?: string;
187
+ }
188
+ /**
189
+ * Renders an Agentable Lang program as it streams. Each complete line is
190
+ * parsed, the tree is validated against the catalog with bad nodes pruned,
191
+ * and the rest draws immediately. Bindings, `when` and actions work exactly
192
+ * as in <AgentSurface>; tool actions go to `tools` or the registry.
193
+ */
194
+ export declare function Renderer({ components, library, response, isStreaming, data, tools, onMessage, onError, onActionError, initialUi, onUiChange, placeholder, className }: RendererProps): import("react").JSX.Element;
195
+ //#endregion
144
196
  //#region src/index.d.ts
145
197
  export interface CatalogComponent<P = Record<string, unknown>> {
146
198
  component: ComponentType<P>;
@@ -161,6 +213,8 @@ export type Catalog = Record<string, CatalogComponent<never>>;
161
213
  export interface PresentArgs {
162
214
  /** The tree to render. Replaces the current one and resets ui state. null clears the surface. */
163
215
  root?: UISpec | null;
216
+ /** The tree as an Agentable Lang program (about half the tokens of `root`). */
217
+ program?: string;
164
218
  /** Patch the current tree (applied after `root` when both are given). */
165
219
  ops?: SurfaceOp[];
166
220
  /** ui.* state to start from when sending `root`. */
@@ -205,6 +259,8 @@ export interface AgentSurfaceProps {
205
259
  stateTool?: string | false;
206
260
  /** Called when an action in the generated UI fails or is declined. */
207
261
  onActionError?: (message: string, node: UISpec) => void;
262
+ /** Receives text a `@Say` / message action sends to the assistant. Without it the action fails. */
263
+ onMessage?: (text: string, node: UISpec) => void;
208
264
  }
209
265
  /**
210
266
  * Renders whatever the agent last called `present` with. Every catalog
@@ -216,6 +272,6 @@ export interface AgentSurfaceProps {
216
272
  * generated UI and an agent reach a tool through the same registry path, so
217
273
  * confirmation, caching and invalidation apply to both.
218
274
  */
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;
275
+ 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
276
  //#endregion
221
- export type { SpecIssue, StateAction, SurfaceOp, SurfaceState, ToolAction, UIAction, UISpec, UiStore, ValidateOptions };
277
+ export type { MessageAction, 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. */
@@ -192,9 +193,13 @@ function actionIssues(a, path, tools) {
192
193
  });
193
194
  return out;
194
195
  }
196
+ if (a.type === "message") return typeof a.text === "string" ? [] : [{
197
+ path: `${path}.text`,
198
+ message: "message needs a text string."
199
+ }];
195
200
  if (!STATE_ACTIONS.has(a.type)) return [{
196
201
  path: `${path}.type`,
197
- message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle or tool.`
202
+ message: `Unknown action "${a.type}". Use set, merge, push, remove, increment, toggle, tool or message.`
198
203
  }];
199
204
  if (!isStatePath(a.path, ["ui"])) return [{
200
205
  path: `${path}.path`,
@@ -364,6 +369,7 @@ async function validateSpec(spec, catalog, allowed, opts = {}) {
364
369
  if (Object.keys(on).length) out.on = on;
365
370
  }
366
371
  }
372
+ const ownBad = issues.length > before;
367
373
  if (Array.isArray(node.children) && node.children.length) {
368
374
  if (!entry.childrenProp) issues.push({
369
375
  path: `${path}.children`,
@@ -378,17 +384,22 @@ async function validateSpec(spec, catalog, allowed, opts = {}) {
378
384
  out.children = kids;
379
385
  }
380
386
  }
381
- return issues.length > before ? null : out;
387
+ if (ownBad) {
388
+ nodes--;
389
+ return null;
390
+ }
391
+ return out;
382
392
  }
383
393
  const cleaned = await walk(spec, "root", 1, false);
384
- if (issues.length || !cleaned) return {
394
+ if (!cleaned || issues.length && !opts.prune) return {
385
395
  ok: false,
386
396
  issues
387
397
  };
388
398
  return {
389
399
  ok: true,
390
400
  spec: cleaned,
391
- nodes
401
+ nodes,
402
+ issues
392
403
  };
393
404
  }
394
405
  /**
@@ -465,6 +476,185 @@ function replaceIn(node, target, next) {
465
476
  return kids.some((c) => replaceIn(c, target, next));
466
477
  }
467
478
  //#endregion
479
+ //#region src/renderer.tsx
480
+ /**
481
+ * Renders an Agentable Lang program as it streams. Each complete line is
482
+ * parsed, the tree is validated against the catalog with bad nodes pruned,
483
+ * and the rest draws immediately. Bindings, `when` and actions work exactly
484
+ * as in <AgentSurface>; tool actions go to `tools` or the registry.
485
+ */
486
+ function Renderer({ components, library, response, isStreaming = false, data, tools, onMessage, onError, onActionError, initialUi, onUiChange, placeholder, className }) {
487
+ const lib = useMemo(() => library ?? createLibrary(components), [library, components]);
488
+ const [store] = useState(() => createUiStore(initialUi ?? {}));
489
+ const ui = useSyncExternalStore(store.subscribe, store.get, store.get);
490
+ const agentable = useAgentable();
491
+ const snapshot = useAgentableSnapshot();
492
+ const [spec, setSpec] = useState(null);
493
+ const [actionError, setActionError] = useState(null);
494
+ const dataRef = useRef(data ?? {});
495
+ dataRef.current = data ?? {};
496
+ const toolsRef = useRef(tools);
497
+ toolsRef.current = tools;
498
+ const onMessageRef = useRef(onMessage);
499
+ onMessageRef.current = onMessage;
500
+ const onErrorRef = useRef(onError);
501
+ onErrorRef.current = onError;
502
+ const onActionErrorRef = useRef(onActionError);
503
+ onActionErrorRef.current = onActionError;
504
+ const onUiChangeRef = useRef(onUiChange);
505
+ onUiChangeRef.current = onUiChange;
506
+ useEffect(() => store.subscribe(() => onUiChangeRef.current?.(store.get())), [store]);
507
+ const registryTools = useMemo(() => {
508
+ const out = /* @__PURE__ */ new Map();
509
+ for (const c of snapshot.capabilities) if (c.toolName && c.declaredTier <= 1 && !out.has(c.toolName)) out.set(c.toolName, c.id);
510
+ return out;
511
+ }, [snapshot.capabilities]);
512
+ const registryRef = useRef(registryTools);
513
+ registryRef.current = registryTools;
514
+ const callable = useMemo(() => /* @__PURE__ */ new Set([...Object.keys(tools ?? {}), ...registryTools.keys()]), [tools, registryTools]);
515
+ const parsed = useMemo(() => lib.parse(isStreaming ? heldBack(response) : response), [
516
+ lib,
517
+ response,
518
+ isStreaming
519
+ ]);
520
+ useEffect(() => {
521
+ let live = true;
522
+ const allowed = new Set(Object.keys(components));
523
+ const errors = parsed.errors.filter((e) => e.severity === "error" && (e.code !== "no-root" || !isStreaming)).map(fromLang);
524
+ if (!parsed.root) {
525
+ setSpec(null);
526
+ onErrorRef.current?.(errors);
527
+ return;
528
+ }
529
+ validateSpec(parsed.root, components, allowed, {
530
+ state: {
531
+ data: dataRef.current,
532
+ ui: store.get()
533
+ },
534
+ tools: callable,
535
+ prune: true,
536
+ maxNodes: 200
537
+ }).then((r) => {
538
+ if (!live) return;
539
+ if (r.ok) {
540
+ setSpec(r.spec);
541
+ onErrorRef.current?.([...errors, ...r.issues.map(fromIssue)]);
542
+ } else {
543
+ setSpec(null);
544
+ onErrorRef.current?.([...errors, ...r.issues.map(fromIssue)]);
545
+ }
546
+ });
547
+ return () => {
548
+ live = false;
549
+ };
550
+ }, [
551
+ parsed,
552
+ components,
553
+ callable,
554
+ isStreaming
555
+ ]);
556
+ const fail = useCallback((message, node) => {
557
+ setActionError(message);
558
+ onActionErrorRef.current?.(message, node);
559
+ }, []);
560
+ const emit = useCallback((node, event, value) => {
561
+ const actions = node.on?.[event];
562
+ if (!actions?.length) return;
563
+ (async () => {
564
+ setActionError(null);
565
+ for (const raw of actions) {
566
+ const action = interpolate(raw, { value }, {
567
+ data: dataRef.current,
568
+ ui: store.get()
569
+ });
570
+ if (action.type === "message") {
571
+ if (!onMessageRef.current) return fail("Nothing is listening for messages here.", node);
572
+ onMessageRef.current(action.text, node);
573
+ continue;
574
+ }
575
+ if (action.type !== "tool") {
576
+ try {
577
+ store.dispatch(action);
578
+ } catch (e) {
579
+ return fail(e instanceof Error ? e.message : String(e), node);
580
+ }
581
+ continue;
582
+ }
583
+ let result;
584
+ const fn = toolsRef.current?.[action.name];
585
+ try {
586
+ if (fn) result = await fn(action.args ?? {});
587
+ else {
588
+ const capId = registryRef.current.get(action.name);
589
+ if (!agentable || !capId) return fail(`Tool "${action.name}" is not available.`, node);
590
+ result = parseResult$1(await agentable.invokeCapability(capId, action.args ?? {}));
591
+ }
592
+ } catch (e) {
593
+ return fail(e instanceof Error ? e.message : String(e), node);
594
+ }
595
+ if (result && typeof result === "object" && "error" in result) return fail(String(result.error), node);
596
+ if (action.into) store.dispatch({
597
+ type: "set",
598
+ path: action.into,
599
+ value: result
600
+ });
601
+ }
602
+ })();
603
+ }, [
604
+ agentable,
605
+ store,
606
+ fail
607
+ ]);
608
+ const ctx = useMemo(() => ({
609
+ state: {
610
+ data: data ?? {},
611
+ ui
612
+ },
613
+ emit
614
+ }), [
615
+ data,
616
+ ui,
617
+ emit
618
+ ]);
619
+ return /* @__PURE__ */ jsxs("div", {
620
+ className,
621
+ "data-agentable-surface": "",
622
+ "data-streaming": isStreaming ? "" : void 0,
623
+ "data-empty": spec ? void 0 : "",
624
+ children: [actionError ? /* @__PURE__ */ jsx("p", {
625
+ role: "alert",
626
+ "data-agentable-surface-error": "",
627
+ children: actionError
628
+ }) : null, spec ? /* @__PURE__ */ jsx("div", {
629
+ "data-agentable-surface-body": "",
630
+ inert: isStreaming || void 0,
631
+ children: renderSpec(spec, components, "0", ctx)
632
+ }) : placeholder ?? null]
633
+ });
634
+ }
635
+ /** While streaming, the last unterminated line is not parsed. */
636
+ function heldBack(text) {
637
+ const nl = text.lastIndexOf("\n");
638
+ return nl < 0 ? "" : text.slice(0, nl + 1);
639
+ }
640
+ const fromLang = (e) => ({
641
+ code: e.code,
642
+ message: e.message,
643
+ line: e.line
644
+ });
645
+ const fromIssue = (i) => ({
646
+ code: "invalid-node",
647
+ message: i.message,
648
+ path: i.path
649
+ });
650
+ function parseResult$1(text) {
651
+ try {
652
+ return JSON.parse(text);
653
+ } catch {
654
+ return text;
655
+ }
656
+ }
657
+ //#endregion
468
658
  //#region src/index.tsx
469
659
  const ACTION_TYPES = [
470
660
  "set",
@@ -583,6 +773,10 @@ function catalogSchema(catalog, allowed) {
583
773
  anyOf: [{ $ref: "#/$defs/node" }, { type: "null" }],
584
774
  description: "Tree to render; replaces the current one. null clears."
585
775
  },
776
+ program: {
777
+ type: "string",
778
+ description: "The tree as a program, one node per line: `root = Stack([a, b])`, `a = Stat(\"Revenue\", \"$48k\")`. Same components; fewer tokens than root."
779
+ },
586
780
  ops: {
587
781
  type: "array",
588
782
  items: { $ref: "#/$defs/op" },
@@ -630,7 +824,7 @@ function renderSpec(spec, catalog, key = "0", ctx) {
630
824
  * generated UI and an agent reach a tool through the same registry path, so
631
825
  * confirmation, caching and invalidation apply to both.
632
826
  */
633
- function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError }) {
827
+ function AgentSurface({ components, name = "present", id, group, tier, priority, maxDepth = 6, maxNodes = 60, placeholder, onPresent, className, clearable = true, data, tools, stateTool, onActionError, onMessage }) {
634
828
  const [state, setState] = useState({
635
829
  spec: null,
636
830
  at: 0
@@ -644,8 +838,13 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
644
838
  onPresentRef.current = onPresent;
645
839
  const onActionErrorRef = useRef(onActionError);
646
840
  onActionErrorRef.current = onActionError;
841
+ const onMessageRef = useRef(onMessage);
842
+ onMessageRef.current = onMessage;
647
843
  const catalogRef = useRef(components);
648
844
  catalogRef.current = components;
845
+ const library = useMemo(() => createLibrary(components), [components]);
846
+ const libraryRef = useRef(library);
847
+ libraryRef.current = library;
649
848
  const specRef = useRef(state.spec);
650
849
  specRef.current = state.spec;
651
850
  const dataRef = useRef(data ?? {});
@@ -704,7 +903,7 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
704
903
  const events = Object.keys(e.events ?? {});
705
904
  return `${n}{${propSummary(schema)}}${e.childrenProp ? "+children" : ""}${events.length ? ` on:${events.join("|")}` : ""}`;
706
905
  });
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.";
906
+ 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
907
  if (readName) text += ` Call ${readName} for bindable data.`;
709
908
  text += ` Components: ${lines.join(" ")}`;
710
909
  if (text.length > 500) text = text.slice(0, 497) + "...";
@@ -729,10 +928,23 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
729
928
  onPresentRef.current?.(null);
730
929
  return { cleared: true };
731
930
  }
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;
931
+ let root = args.root;
932
+ if (typeof args.program === "string" && root === void 0) {
933
+ const parsed = libraryRef.current.parse(args.program);
934
+ const errors = parsed.errors.filter((e) => e.severity === "error");
935
+ if (!parsed.root || errors.length) return {
936
+ error: "Invalid program.",
937
+ issues: parsed.errors.map((e) => ({
938
+ path: `line ${e.line}`,
939
+ message: e.message
940
+ }))
941
+ };
942
+ root = parsed.root;
943
+ }
944
+ if (root === void 0 && !args.ops?.length) return { error: "Send program or root to render, ops to patch, or root: null to clear." };
945
+ let tree = root ?? specRef.current;
734
946
  if (!tree) return { error: "Nothing is presented yet; send root first." };
735
- const draft = createUiStore(args.root ? args.ui ?? {} : store.get());
947
+ const draft = createUiStore(root ? args.ui ?? {} : store.get());
736
948
  try {
737
949
  if (args.ops?.length) {
738
950
  const patched = applyOps(tree, args.ops);
@@ -755,13 +967,13 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
755
967
  error: "Invalid UI spec.",
756
968
  issues: result.issues
757
969
  };
758
- const title = args.title ?? (args.root ? void 0 : state.title);
970
+ const title = args.title ?? (root ? void 0 : state.title);
759
971
  store.replace(draft.get());
760
972
  setError(null);
761
973
  setState({
762
974
  spec: result.spec,
763
975
  title,
764
- at: args.root ? Date.now() : state.at
976
+ at: root ? Date.now() : state.at
765
977
  });
766
978
  onPresentRef.current?.(result.spec, title);
767
979
  return {
@@ -840,6 +1052,11 @@ function AgentSurface({ components, name = "present", id, group, tier, priority,
840
1052
  data: dataRef.current,
841
1053
  ui: store.get()
842
1054
  });
1055
+ if (action.type === "message") {
1056
+ if (!onMessageRef.current) return fail("Nothing is listening for messages here.", node);
1057
+ onMessageRef.current(action.text, node);
1058
+ continue;
1059
+ }
843
1060
  if (action.type !== "tool") {
844
1061
  try {
845
1062
  store.dispatch(action);
@@ -944,4 +1161,4 @@ function shapeOf(value, depth = 0) {
944
1161
  return typeof value;
945
1162
  }
946
1163
  //#endregion
947
- export { AgentSurface, applyOps, catalogSchema, createUiStore, getPath, interpolate, isVisible, parsePath, renderSpec, resolveProps, validateSpec };
1164
+ 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.0",
4
- "description": "Generative UI for Agentable: AgentSurface, catalog schema generation, validated rendering via a single present tool.",
3
+ "version": "0.2.0",
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/core": "^0.1.0",
38
- "@agentable-ui/react": "^0.1.0"
37
+ "@agentable-ui/core": "^0.1.1",
38
+ "@agentable-ui/react": "^0.1.0",
39
+ "@agentable-ui/lang": "^0.1.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.0"
50
+ "@agentable-ui/ui": "^0.1.1"
50
51
  },
51
52
  "scripts": {
52
53
  "build": "tsdown",