@assistant-ui/react-generative-ui 0.0.2 → 0.0.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"buildPresentParameters.js","names":[],"sources":["../src/buildPresentParameters.ts"],"sourcesContent":["import { toJSONSchema } from \"assistant-stream\";\nimport type { JSONSchema7, JSONSchema7Definition } from \"json-schema\";\nimport { TYPE_KEY } from \"./constants\";\nimport type { GenerativeUILibrary } from \"./types\";\n\n/**\n * Builds the JSON schema for the `present` tool from a {@link GenerativeUILibrary}.\n *\n * The model produces a node `{ $type, ...props }` where `$type` selects a\n * component and the rest are its props. The schema is a flat object: `$type` is\n * an enum of the component names, every component's props are merged into one\n * optional bag, and `children` recurses via `$defs` so the tree can nest.\n *\n * It is intentionally flat rather than a per-`$type` discriminated union. Tool /\n * function-call schemas (OpenAI and others) require the top-level parameters to\n * be a plain object and reject a top-level `oneOf`/`anyOf`/`enum`. So props can't\n * be refined per `$type` at the root; the model is guided instead by `$type`'s\n * description (which lists each component) and each prop's own description. The\n * renderer validates nothing here — an unknown `$type` or stray prop is handled\n * at render time — so a looser schema only costs the model a hint, not safety.\n */\nexport function buildPresentParameters(\n library: GenerativeUILibrary,\n): JSONSchema7 {\n const names = Object.keys(library);\n\n // Merge every component's props into one optional bag. `$type`/`children` are\n // framework-reserved, so drop any author-declared copies. On a name clash the\n // first component's schema wins — props are an advisory hint here, not a\n // strict per-component contract.\n const props: Record<string, JSONSchema7Definition> = {};\n for (const name of names) {\n const propsSchema = toJSONSchema(library[name]!.properties);\n if (propsSchema.type !== \"object\") {\n throw new Error(\n `[@assistant-ui/react-generative-ui] Component \"${name}\": ` +\n \"`properties` must be an object schema (e.g. `z.object({ ... })`).\",\n );\n }\n for (const [key, schema] of Object.entries(propsSchema.properties ?? {})) {\n if (key === TYPE_KEY || key === \"children\") continue;\n if (!(key in props)) {\n props[key] = schema;\n } else if (process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\n `[@assistant-ui/react-generative-ui] Prop \"${key}\" is declared by more ` +\n \"than one component; the first component's schema is kept and the rest \" +\n \"are ignored. Rename or align the type to avoid an ambiguous schema.\",\n );\n }\n }\n }\n\n // Carry each component's description on the `$type` enum, since there are no\n // per-branch schemas to hang them on anymore.\n const typeDescription =\n names.length > 0\n ? `The component to render. ${names\n .map((name) => `\"${name}\": ${library[name]!.description}`)\n .join(\"; \")}`\n : \"The component to render.\";\n\n const node: JSONSchema7 = {\n type: \"object\",\n properties: {\n [TYPE_KEY]: { type: \"string\", enum: names, description: typeDescription },\n ...props,\n children: { $ref: \"#/$defs/children\" },\n },\n required: [TYPE_KEY],\n };\n\n const children: JSONSchema7 = {\n description: \"Nested generative UI rendered inside this component.\",\n anyOf: [\n { type: \"string\" },\n { $ref: \"#/$defs/node\" },\n {\n type: \"array\",\n items: { anyOf: [{ type: \"string\" }, { $ref: \"#/$defs/node\" }] },\n },\n ],\n };\n\n return {\n ...node,\n $defs: { node, children },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,uBACd,SACa;CACb,MAAM,QAAQ,OAAO,KAAK,OAAO;CAMjC,MAAM,QAA+C,CAAC;CACtD,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,cAAc,aAAa,QAAQ,MAAO,UAAU;EAC1D,IAAI,YAAY,SAAS,UACvB,MAAM,IAAI,MACR,kDAAkD,KAAK,yEAEzD;EAEF,KAAK,MAAM,CAAC,KAAK,WAAW,OAAO,QAAQ,YAAY,cAAc,CAAC,CAAC,GAAG;GACxE,IAAI,QAAA,WAAoB,QAAQ,YAAY;GAC5C,IAAI,EAAE,OAAO,QACX,MAAM,OAAO;QACR,IAAI,QAAQ,IAAI,gBAAgB,cAErC,QAAQ,KACN,6CAA6C,IAAI,gKAGnD;EAEJ;CACF;CAIA,MAAM,kBACJ,MAAM,SAAS,IACX,4BAA4B,MACzB,KAAK,SAAS,IAAI,KAAK,KAAK,QAAQ,MAAO,aAAa,EACxD,KAAK,IAAI,MACZ;CAEN,MAAM,OAAoB;EACxB,MAAM;EACN,YAAY;IACT,WAAW;IAAE,MAAM;IAAU,MAAM;IAAO,aAAa;GAAgB;GACxE,GAAG;GACH,UAAU,EAAE,MAAM,mBAAmB;EACvC;EACA,UAAU,CAAC,QAAQ;CACrB;CAEA,MAAM,WAAwB;EAC5B,aAAa;EACb,OAAO;GACL,EAAE,MAAM,SAAS;GACjB,EAAE,MAAM,eAAe;GACvB;IACE,MAAM;IACN,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,SAAS,GAAG,EAAE,MAAM,eAAe,CAAC,EAAE;GACjE;EACF;CACF;CAEA,OAAO;EACL,GAAG;EACH,OAAO;GAAE;GAAM;EAAS;CAC1B;AACF"}
1
+ {"version":3,"file":"buildPresentParameters.js","names":[],"sources":["../src/buildPresentParameters.ts"],"sourcesContent":["import { toJSONSchema } from \"assistant-stream\";\nimport type { JSONSchema7, JSONSchema7Definition } from \"json-schema\";\nimport { TYPE_KEY } from \"./constants\";\nimport type { GenerativeUILibrary } from \"./types\";\n\n/**\n * Builds the JSON schema for the `present` tool from a {@link GenerativeUILibrary}.\n *\n * The model produces a node `{ $type, ...props }` where `$type` selects a\n * component and the rest are its props. The schema is a flat object: `$type` is\n * an enum of the component names, every component's props are merged into one\n * optional bag, and `children` recurses via `$defs` so the tree can nest.\n *\n * It is intentionally flat rather than a per-`$type` discriminated union. Tool /\n * function-call schemas (OpenAI and others) require the top-level parameters to\n * be a plain object and reject a top-level `oneOf`/`anyOf`/`enum`. So props can't\n * be refined per `$type` at the root; the model is guided instead by `$type`'s\n * description (which lists each component) and each prop's own description. The\n * renderer validates nothing here — an unknown `$type` or stray prop is handled\n * at render time — so a looser schema only costs the model a hint, not safety.\n */\nexport function buildPresentParameters(\n library: GenerativeUILibrary,\n): JSONSchema7 {\n const names = Object.keys(library);\n\n // Merge every component's props into one optional bag. `$type`/`children` are\n // framework-reserved, so drop any author-declared copies. On a name clash the\n // first component's schema wins — props are an advisory hint here, not a\n // strict per-component contract.\n const props: Record<string, JSONSchema7Definition> = {};\n for (const name of names) {\n const propsSchema = toJSONSchema(library[name]!.properties);\n if (propsSchema.type !== \"object\") {\n throw new Error(\n `[@assistant-ui/react-generative-ui] Component \"${name}\": ` +\n \"`properties` must be an object schema (e.g. `z.object({ ... })`).\",\n );\n }\n for (const [key, schema] of Object.entries(propsSchema.properties ?? {})) {\n if (key === TYPE_KEY || key === \"children\") continue;\n if (!(key in props)) {\n props[key] = schema;\n } else if (process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\n `[@assistant-ui/react-generative-ui] Prop \"${key}\" is declared by more ` +\n \"than one component; the first component's schema is kept and the rest \" +\n \"are ignored. Rename or align the type to avoid an ambiguous schema.\",\n );\n }\n }\n }\n\n // Carry each component's description on the `$type` enum, since there are no\n // per-branch schemas to hang them on anymore.\n const typeDescription =\n names.length > 0\n ? `The component to render. ${names\n .map((name) => `\"${name}\": ${library[name]!.description}`)\n .join(\"; \")}`\n : \"The component to render.\";\n\n const node: JSONSchema7 = {\n type: \"object\",\n properties: {\n [TYPE_KEY]: { type: \"string\", enum: names, description: typeDescription },\n ...props,\n children: { $ref: \"#/$defs/children\" },\n },\n required: [TYPE_KEY],\n };\n\n const children: JSONSchema7 = {\n description: \"Nested generative UI rendered inside this component.\",\n anyOf: [\n { type: \"string\" },\n { $ref: \"#/$defs/node\" },\n {\n type: \"array\",\n items: { anyOf: [{ type: \"string\" }, { $ref: \"#/$defs/node\" }] },\n },\n ],\n };\n\n return {\n ...node,\n $defs: { node, children },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,uBACd,SACa;CACb,MAAM,QAAQ,OAAO,KAAK,OAAO;CAMjC,MAAM,QAA+C,CAAC;CACtD,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,cAAc,aAAa,QAAQ,KAAK,CAAE,UAAU;EAC1D,IAAI,YAAY,SAAS,UACvB,MAAM,IAAI,MACR,kDAAkD,KAAK,yEAEzD;EAEF,KAAK,MAAM,CAAC,KAAK,WAAW,OAAO,QAAQ,YAAY,cAAc,CAAC,CAAC,GAAG;GACxE,IAAI,QAAA,WAAoB,QAAQ,YAAY;GAC5C,IAAI,EAAE,OAAO,QACX,MAAM,OAAO;QACR,IAAI,QAAQ,IAAI,gBAAgB,cAErC,QAAQ,KACN,6CAA6C,IAAI,gKAGnD;EAEJ;CACF;CAIA,MAAM,kBACJ,MAAM,SAAS,IACX,4BAA4B,MACzB,KAAK,SAAS,IAAI,KAAK,KAAK,QAAQ,KAAK,CAAE,aAAa,CAAC,CACzD,KAAK,IAAI,MACZ;CAEN,MAAM,OAAoB;EACxB,MAAM;EACN,YAAY;IACT,WAAW;IAAE,MAAM;IAAU,MAAM;IAAO,aAAa;GAAgB;GACxE,GAAG;GACH,UAAU,EAAE,MAAM,mBAAmB;EACvC;EACA,UAAU,CAAC,QAAQ;CACrB;CAEA,MAAM,WAAwB;EAC5B,aAAa;EACb,OAAO;GACL,EAAE,MAAM,SAAS;GACjB,EAAE,MAAM,eAAe;GACvB;IACE,MAAM;IACN,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,SAAS,GAAG,EAAE,MAAM,eAAe,CAAC,EAAE;GACjE;EACF;CACF;CAEA,OAAO;EACL,GAAG;EACH,OAAO;GAAE;GAAM;EAAS;CAC1B;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"generativeUIToJSX.js","names":[],"sources":["../src/generativeUIToJSX.ts"],"sourcesContent":["import { TYPE_KEY } from \"./constants\";\n\n/**\n * Serializes a generative-UI node to a JSX-like string for display — the\n * \"view source\" of a model-produced tree. The wire form\n * `{ $type: \"Weather\", id: \"x\" }` becomes `<Weather id=\"x\" />`, and nested\n * `children` render between tags: `<Card title=\"Hi\"><Text>hello</Text></Card>`.\n *\n * It is a faithful textual rendering, not a parser: text children are emitted\n * verbatim (not HTML/JSX-escaped), so the result is meant to be shown, not\n * re-parsed. Returns `\"\"` for nodes that aren't renderable (no `$type` yet,\n * `null`, booleans).\n */\nexport function generativeUIToJSX(node: unknown): string {\n return toJSX(node, 0);\n}\n\n/**\n * The deepest tree we serialize. Input is model-produced, so a runaway/\n * adversarial response could nest arbitrarily deep and overflow the stack — past\n * this depth (far beyond any real UI) we stop. Mirrors the renderer's bound.\n */\nconst MAX_DEPTH = 64;\n\nfunction toJSX(node: unknown, depth: number): string {\n if (depth > MAX_DEPTH) return \"\";\n if (node == null || typeof node === \"boolean\") return \"\";\n if (typeof node === \"string\") return node;\n if (typeof node === \"number\") return String(node);\n if (Array.isArray(node))\n return node.map((child) => toJSX(child, depth + 1)).join(\"\");\n if (typeof node !== \"object\") return \"\";\n\n const {\n [TYPE_KEY]: type,\n children,\n ...props\n } = node as Record<string, unknown>;\n if (typeof type !== \"string\") return \"\";\n\n const attrs = Object.entries(props)\n .map(([key, value]) => formatAttr(key, value))\n .join(\"\");\n const inner = children === undefined ? \"\" : toJSX(children, depth + 1);\n\n return inner === \"\"\n ? `<${type}${attrs} />`\n : `<${type}${attrs}>${inner}</${type}>`;\n}\n\n/** Formats one prop as a JSX attribute (`id=\"x\"`, `count={3}`, `open`, …). */\nfunction formatAttr(key: string, value: unknown): string {\n if (value === undefined) return \"\";\n if (value === true) return ` ${key}`;\n if (typeof value === \"string\") {\n // Plain double-quoted form when safe; expression form when a quote or\n // newline would break the attribute.\n return /[\"\\n]/.test(value)\n ? ` ${key}={${JSON.stringify(value)}}`\n : ` ${key}=\"${value}\"`;\n }\n return ` ${key}={${JSON.stringify(value)}}`;\n}\n"],"mappings":";;;;;;;;;;;;;AAaA,SAAgB,kBAAkB,MAAuB;CACvD,OAAO,MAAM,MAAM,CAAC;AACtB;;;;;;AAOA,MAAM,YAAY;AAElB,SAAS,MAAM,MAAe,OAAuB;CACnD,IAAI,QAAQ,WAAW,OAAO;CAC9B,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI,OAAO,SAAS,UAAU,OAAO,OAAO,IAAI;CAChD,IAAI,MAAM,QAAQ,IAAI,GACpB,OAAO,KAAK,KAAK,UAAU,MAAM,OAAO,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE;CAC7D,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,MAAM,GACH,WAAW,MACZ,UACA,GAAG,UACD;CACJ,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,MAAM,QAAQ,OAAO,QAAQ,KAAK,EAC/B,KAAK,CAAC,KAAK,WAAW,WAAW,KAAK,KAAK,CAAC,EAC5C,KAAK,EAAE;CACV,MAAM,QAAQ,aAAa,KAAA,IAAY,KAAK,MAAM,UAAU,QAAQ,CAAC;CAErE,OAAO,UAAU,KACb,IAAI,OAAO,MAAM,OACjB,IAAI,OAAO,MAAM,GAAG,MAAM,IAAI,KAAK;AACzC;;AAGA,SAAS,WAAW,KAAa,OAAwB;CACvD,IAAI,UAAU,KAAA,GAAW,OAAO;CAChC,IAAI,UAAU,MAAM,OAAO,IAAI;CAC/B,IAAI,OAAO,UAAU,UAGnB,OAAO,QAAQ,KAAK,KAAK,IACrB,IAAI,IAAI,IAAI,KAAK,UAAU,KAAK,EAAE,KAClC,IAAI,IAAI,IAAI,MAAM;CAExB,OAAO,IAAI,IAAI,IAAI,KAAK,UAAU,KAAK,EAAE;AAC3C"}
1
+ {"version":3,"file":"generativeUIToJSX.js","names":[],"sources":["../src/generativeUIToJSX.ts"],"sourcesContent":["import { TYPE_KEY } from \"./constants\";\n\n/**\n * Serializes a generative-UI node to a JSX-like string for display — the\n * \"view source\" of a model-produced tree. The wire form\n * `{ $type: \"Weather\", id: \"x\" }` becomes `<Weather id=\"x\" />`, and nested\n * `children` render between tags: `<Card title=\"Hi\"><Text>hello</Text></Card>`.\n *\n * It is a faithful textual rendering, not a parser: text children are emitted\n * verbatim (not HTML/JSX-escaped), so the result is meant to be shown, not\n * re-parsed. Returns `\"\"` for nodes that aren't renderable (no `$type` yet,\n * `null`, booleans).\n */\nexport function generativeUIToJSX(node: unknown): string {\n return toJSX(node, 0);\n}\n\n/**\n * The deepest tree we serialize. Input is model-produced, so a runaway/\n * adversarial response could nest arbitrarily deep and overflow the stack — past\n * this depth (far beyond any real UI) we stop. Mirrors the renderer's bound.\n */\nconst MAX_DEPTH = 64;\n\nfunction toJSX(node: unknown, depth: number): string {\n if (depth > MAX_DEPTH) return \"\";\n if (node == null || typeof node === \"boolean\") return \"\";\n if (typeof node === \"string\") return node;\n if (typeof node === \"number\") return String(node);\n if (Array.isArray(node))\n return node.map((child) => toJSX(child, depth + 1)).join(\"\");\n if (typeof node !== \"object\") return \"\";\n\n const {\n [TYPE_KEY]: type,\n children,\n ...props\n } = node as Record<string, unknown>;\n if (typeof type !== \"string\") return \"\";\n\n const attrs = Object.entries(props)\n .map(([key, value]) => formatAttr(key, value))\n .join(\"\");\n const inner = children === undefined ? \"\" : toJSX(children, depth + 1);\n\n return inner === \"\"\n ? `<${type}${attrs} />`\n : `<${type}${attrs}>${inner}</${type}>`;\n}\n\n/** Formats one prop as a JSX attribute (`id=\"x\"`, `count={3}`, `open`, …). */\nfunction formatAttr(key: string, value: unknown): string {\n if (value === undefined) return \"\";\n if (value === true) return ` ${key}`;\n if (typeof value === \"string\") {\n // Plain double-quoted form when safe; expression form when a quote or\n // newline would break the attribute.\n return /[\"\\n]/.test(value)\n ? ` ${key}={${JSON.stringify(value)}}`\n : ` ${key}=\"${value}\"`;\n }\n return ` ${key}={${JSON.stringify(value)}}`;\n}\n"],"mappings":";;;;;;;;;;;;;AAaA,SAAgB,kBAAkB,MAAuB;CACvD,OAAO,MAAM,MAAM,CAAC;AACtB;;;;;;AAOA,MAAM,YAAY;AAElB,SAAS,MAAM,MAAe,OAAuB;CACnD,IAAI,QAAQ,WAAW,OAAO;CAC9B,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI,OAAO,SAAS,UAAU,OAAO,OAAO,IAAI;CAChD,IAAI,MAAM,QAAQ,IAAI,GACpB,OAAO,KAAK,KAAK,UAAU,MAAM,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE;CAC7D,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,MAAM,GACH,WAAW,MACZ,UACA,GAAG,UACD;CACJ,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,MAAM,QAAQ,OAAO,QAAQ,KAAK,CAAC,CAChC,KAAK,CAAC,KAAK,WAAW,WAAW,KAAK,KAAK,CAAC,CAAC,CAC7C,KAAK,EAAE;CACV,MAAM,QAAQ,aAAa,KAAA,IAAY,KAAK,MAAM,UAAU,QAAQ,CAAC;CAErE,OAAO,UAAU,KACb,IAAI,OAAO,MAAM,OACjB,IAAI,OAAO,MAAM,GAAG,MAAM,IAAI,KAAK;AACzC;;AAGA,SAAS,WAAW,KAAa,OAAwB;CACvD,IAAI,UAAU,KAAA,GAAW,OAAO;CAChC,IAAI,UAAU,MAAM,OAAO,IAAI;CAC/B,IAAI,OAAO,UAAU,UAGnB,OAAO,QAAQ,KAAK,KAAK,IACrB,IAAI,IAAI,IAAI,KAAK,UAAU,KAAK,EAAE,KAClC,IAAI,IAAI,IAAI,MAAM;CAExB,OAAO,IAAI,IAAI,IAAI,KAAK,UAAU,KAAK,EAAE;AAC3C"}
@@ -1 +1 @@
1
- {"version":3,"file":"renderGenerativeUI.d.ts","names":[],"sources":["../src/renderGenerativeUI.tsx"],"mappings":";;;;;;AAoBA;;;;;;;iBAAgB,kBAAA,CACd,IAAA,WACA,OAAA,EAAS,mBAAA,EACT,OAAA,GAAS,yBAAA,GACR,SAAA"}
1
+ {"version":3,"file":"renderGenerativeUI.d.ts","names":[],"sources":["../src/renderGenerativeUI.tsx"],"mappings":";;;;;;AAqBA;;;;;;;iBAAgB,kBAAA,CACd,IAAA,WACA,OAAA,EAAS,mBAAA,EACT,OAAA,GAAS,yBAAA,GACR,SAAA"}
@@ -1,4 +1,5 @@
1
1
  import { TYPE_KEY } from "./constants.js";
2
+ import { getPartialJsonObjectMeta } from "assistant-stream/utils";
2
3
  import { Fragment } from "react";
3
4
  import { jsx } from "react/jsx-runtime";
4
5
  //#region src/renderGenerativeUI.tsx
@@ -13,7 +14,8 @@ const DEFAULT_CONTEXT = { status: "done" };
13
14
  * recursively so components can nest.
14
15
  */
15
16
  function renderGenerativeUI(node, library, context = DEFAULT_CONTEXT) {
16
- return renderNode(normalizeNode(node), library, context);
17
+ const meta = getPartialJsonObjectMeta(node);
18
+ return renderNode(normalizeNode(node, meta?.state === "partial" ? meta.partialPath : void 0), library, context);
17
19
  }
18
20
  /**
19
21
  * The deepest tree we normalize. The input comes from the model, so a runaway
@@ -22,21 +24,32 @@ function renderGenerativeUI(node, library, context = DEFAULT_CONTEXT) {
22
24
  * bounds rendering too, since it only walks the normalized tree.
23
25
  */
24
26
  const MAX_DEPTH = 64;
25
- /** Converts the flat wire form into a normalized {@link GenerativeUINode}. */
26
- function normalizeNode(node, depth = 0) {
27
+ /**
28
+ * Converts the flat wire form into a normalized {@link GenerativeUINode}.
29
+ *
30
+ * `partialPath` is the remaining segment of the parse meta's partial path
31
+ * relative to `node` (`undefined` once the walk leaves the partial frontier,
32
+ * i.e. everything below is complete).
33
+ */
34
+ function normalizeNode(node, partialPath, depth = 0) {
27
35
  if (depth > MAX_DEPTH) return null;
28
36
  if (node == null || typeof node === "boolean") return null;
29
37
  if (typeof node === "string" || typeof node === "number") return node;
30
- if (Array.isArray(node)) return node.map((child) => normalizeNode(child, depth + 1));
38
+ if (Array.isArray(node)) return node.map((child, index) => normalizeNode(child, descend(partialPath, String(index)), depth + 1));
31
39
  if (typeof node !== "object") return null;
32
40
  const { [TYPE_KEY]: type, ...props } = node;
33
41
  if (typeof type !== "string") return null;
34
- if ("children" in props) props["children"] = normalizeNode(props["children"], depth + 1);
42
+ if (partialPath?.length === 1 && partialPath[0] === "$type") return null;
43
+ if ("children" in props) props["children"] = normalizeNode(props["children"], descend(partialPath, "children"), depth + 1);
35
44
  return {
36
45
  type,
37
46
  props
38
47
  };
39
48
  }
49
+ /** Steps the partial path down into `key`; siblings of the path are complete. */
50
+ function descend(partialPath, key) {
51
+ return partialPath?.[0] === key ? partialPath.slice(1) : void 0;
52
+ }
40
53
  function renderNode(node, library, context) {
41
54
  if (node == null || typeof node === "boolean") return null;
42
55
  if (typeof node === "string" || typeof node === "number") return node;
@@ -1 +1 @@
1
- {"version":3,"file":"renderGenerativeUI.js","names":[],"sources":["../src/renderGenerativeUI.tsx"],"sourcesContent":["import { Fragment, type ReactNode } from \"react\";\nimport { TYPE_KEY } from \"./constants\";\nimport type {\n GenerativeUIElement,\n GenerativeUILibrary,\n GenerativeUINode,\n GenerativeUIRenderContext,\n} from \"./types\";\n\nconst DEFAULT_CONTEXT: GenerativeUIRenderContext = { status: \"done\" };\n\n/**\n * Renders a generative-ui tree against a {@link GenerativeUILibrary}.\n *\n * The model emits each node as a flat object `{ $type, ...props }`. We first\n * normalize that wire form into React-shaped elements (`{ type, props }`), then\n * render: each `type` is looked up in the library and its `props` are passed\n * to the component's `render(props, context)`, with `children` rendered\n * recursively so components can nest.\n */\nexport function renderGenerativeUI(\n node: unknown,\n library: GenerativeUILibrary,\n context: GenerativeUIRenderContext = DEFAULT_CONTEXT,\n): ReactNode {\n return renderNode(normalizeNode(node), library, context);\n}\n\n/**\n * The deepest tree we normalize. The input comes from the model, so a runaway\n * or adversarial response could nest arbitrarily deep and overflow the stack;\n * past this depth we stop (far beyond any real UI). Bounding normalization\n * bounds rendering too, since it only walks the normalized tree.\n */\nconst MAX_DEPTH = 64;\n\n/** Converts the flat wire form into a normalized {@link GenerativeUINode}. */\nfunction normalizeNode(node: unknown, depth = 0): GenerativeUINode {\n if (depth > MAX_DEPTH) return null;\n if (node == null || typeof node === \"boolean\") return null;\n if (typeof node === \"string\" || typeof node === \"number\") return node;\n if (Array.isArray(node))\n return node.map((child) => normalizeNode(child, depth + 1));\n if (typeof node !== \"object\") return null;\n\n const { [TYPE_KEY]: type, ...props } = node as Record<string, unknown>;\n // Args stream in incrementally; a node whose `$type` has not arrived yet is\n // not an error, it just isn't renderable.\n if (typeof type !== \"string\") return null;\n\n if (\"children\" in props) {\n props[\"children\"] = normalizeNode(props[\"children\"], depth + 1);\n }\n return { type, props } as GenerativeUIElement;\n}\n\nfunction renderNode(\n node: GenerativeUINode,\n library: GenerativeUILibrary,\n context: GenerativeUIRenderContext,\n): ReactNode {\n if (node == null || typeof node === \"boolean\") return null;\n if (typeof node === \"string\" || typeof node === \"number\") return node;\n if (Array.isArray(node)) {\n // The wire format has no per-node id, so the key is positional. Pairing the\n // index with the node's kind means that when the model splices or reorders\n // `children` and the kind at an index changes, the key changes and React\n // remounts instead of handing a streaming node's hook state to a different\n // component.\n return node.map((child, index) => (\n <Fragment key={`${index}:${nodeKind(child)}`}>\n {renderNode(child, library, context)}\n </Fragment>\n ));\n }\n return renderElement(node, library, context);\n}\n\nfunction renderElement(\n element: GenerativeUIElement,\n library: GenerativeUILibrary,\n context: GenerativeUIRenderContext,\n): ReactNode {\n const entry = library[element.type];\n if (!entry) {\n reportUnknownComponent(element.type, Object.keys(library));\n return null;\n }\n\n // Components that opt out of prop streaming wait until their props are\n // complete rather than rendering from a partial parse.\n if (!entry.streamProperties && context.status === \"streaming\") return null;\n\n // Inject the framework props last so the model can never override them.\n const { children, ...rest } = element.props;\n const props: Record<string, unknown> = { ...rest, $status: context.status };\n if (children !== undefined) {\n props[\"children\"] = renderNode(children, library, context);\n }\n\n return <GenerativeUIComponentRenderer render={entry.render} props={props} />;\n}\n\n/**\n * Mounts a single node's `render` on its own fiber so the function may use\n * hooks and hold state independently of its siblings and parent.\n */\nfunction GenerativeUIComponentRenderer({\n render,\n props,\n}: {\n render: (props: any) => ReactNode;\n props: Record<string, unknown>;\n}): ReactNode {\n return render(props);\n}\n\n/** A coarse kind tag for a child, used in its list key so a node changing kind\n * at a given index forces a remount rather than a wrong-fiber reuse. */\nfunction nodeKind(node: GenerativeUINode): string {\n if (node == null || typeof node === \"boolean\") return \"\";\n if (typeof node === \"string\" || typeof node === \"number\") return \"#text\";\n if (Array.isArray(node)) return \"#array\";\n return node.type;\n}\n\nfunction reportUnknownComponent(type: string, available: string[]): void {\n if (process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.error(\n `[@assistant-ui/react-generative-ui] Unknown component \"${type}\". ` +\n `Available components: ${available.join(\", \") || \"(none)\"}.`,\n );\n }\n}\n"],"mappings":";;;;AASA,MAAM,kBAA6C,EAAE,QAAQ,OAAO;;;;;;;;;;AAWpE,SAAgB,mBACd,MACA,SACA,UAAqC,iBAC1B;CACX,OAAO,WAAW,cAAc,IAAI,GAAG,SAAS,OAAO;AACzD;;;;;;;AAQA,MAAM,YAAY;;AAGlB,SAAS,cAAc,MAAe,QAAQ,GAAqB;CACjE,IAAI,QAAQ,WAAW,OAAO;CAC9B,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,UAAU,OAAO;CACjE,IAAI,MAAM,QAAQ,IAAI,GACpB,OAAO,KAAK,KAAK,UAAU,cAAc,OAAO,QAAQ,CAAC,CAAC;CAC5D,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,MAAM,GAAG,WAAW,MAAM,GAAG,UAAU;CAGvC,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,IAAI,cAAc,OAChB,MAAM,cAAc,cAAc,MAAM,aAAa,QAAQ,CAAC;CAEhE,OAAO;EAAE;EAAM;CAAM;AACvB;AAEA,SAAS,WACP,MACA,SACA,SACW;CACX,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,UAAU,OAAO;CACjE,IAAI,MAAM,QAAQ,IAAI,GAMpB,OAAO,KAAK,KAAK,OAAO,UACtB,oBAAC,UAAD,EAAA,UACG,WAAW,OAAO,SAAS,OAAO,EAC3B,GAFK,GAAG,MAAM,GAAG,SAAS,KAAK,GAE/B,CACX;CAEH,OAAO,cAAc,MAAM,SAAS,OAAO;AAC7C;AAEA,SAAS,cACP,SACA,SACA,SACW;CACX,MAAM,QAAQ,QAAQ,QAAQ;CAC9B,IAAI,CAAC,OAAO;EACV,uBAAuB,QAAQ,MAAM,OAAO,KAAK,OAAO,CAAC;EACzD,OAAO;CACT;CAIA,IAAI,CAAC,MAAM,oBAAoB,QAAQ,WAAW,aAAa,OAAO;CAGtE,MAAM,EAAE,UAAU,GAAG,SAAS,QAAQ;CACtC,MAAM,QAAiC;EAAE,GAAG;EAAM,SAAS,QAAQ;CAAO;CAC1E,IAAI,aAAa,KAAA,GACf,MAAM,cAAc,WAAW,UAAU,SAAS,OAAO;CAG3D,OAAO,oBAAC,+BAAD;EAA+B,QAAQ,MAAM;EAAe;CAAQ,CAAA;AAC7E;;;;;AAMA,SAAS,8BAA8B,EACrC,QACA,SAIY;CACZ,OAAO,OAAO,KAAK;AACrB;;;AAIA,SAAS,SAAS,MAAgC;CAChD,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,UAAU,OAAO;CACjE,IAAI,MAAM,QAAQ,IAAI,GAAG,OAAO;CAChC,OAAO,KAAK;AACd;AAEA,SAAS,uBAAuB,MAAc,WAA2B;CACvE,IAAI,QAAQ,IAAI,gBAAgB,cAE9B,QAAQ,MACN,0DAA0D,KAAK,2BACpC,UAAU,KAAK,IAAI,KAAK,SAAS,EAC9D;AAEJ"}
1
+ {"version":3,"file":"renderGenerativeUI.js","names":[],"sources":["../src/renderGenerativeUI.tsx"],"sourcesContent":["import { getPartialJsonObjectMeta } from \"assistant-stream/utils\";\nimport { Fragment, type ReactNode } from \"react\";\nimport { TYPE_KEY } from \"./constants\";\nimport type {\n GenerativeUIElement,\n GenerativeUILibrary,\n GenerativeUINode,\n GenerativeUIRenderContext,\n} from \"./types\";\n\nconst DEFAULT_CONTEXT: GenerativeUIRenderContext = { status: \"done\" };\n\n/**\n * Renders a generative-ui tree against a {@link GenerativeUILibrary}.\n *\n * The model emits each node as a flat object `{ $type, ...props }`. We first\n * normalize that wire form into React-shaped elements (`{ type, props }`), then\n * render: each `type` is looked up in the library and its `props` are passed\n * to the component's `render(props, context)`, with `children` rendered\n * recursively so components can nest.\n */\nexport function renderGenerativeUI(\n node: unknown,\n library: GenerativeUILibrary,\n context: GenerativeUIRenderContext = DEFAULT_CONTEXT,\n): ReactNode {\n // Tool args are parsed incrementally, and the parse meta records which path\n // is still mid-arrival, so normalization can hold back a node whose `$type`\n // string has not finished streaming.\n const meta = getPartialJsonObjectMeta(node as Record<symbol, unknown>);\n const partialPath = meta?.state === \"partial\" ? meta.partialPath : undefined;\n return renderNode(normalizeNode(node, partialPath), library, context);\n}\n\n/**\n * The deepest tree we normalize. The input comes from the model, so a runaway\n * or adversarial response could nest arbitrarily deep and overflow the stack;\n * past this depth we stop (far beyond any real UI). Bounding normalization\n * bounds rendering too, since it only walks the normalized tree.\n */\nconst MAX_DEPTH = 64;\n\n/**\n * Converts the flat wire form into a normalized {@link GenerativeUINode}.\n *\n * `partialPath` is the remaining segment of the parse meta's partial path\n * relative to `node` (`undefined` once the walk leaves the partial frontier,\n * i.e. everything below is complete).\n */\nfunction normalizeNode(\n node: unknown,\n partialPath: readonly string[] | undefined,\n depth = 0,\n): GenerativeUINode {\n if (depth > MAX_DEPTH) return null;\n if (node == null || typeof node === \"boolean\") return null;\n if (typeof node === \"string\" || typeof node === \"number\") return node;\n if (Array.isArray(node))\n return node.map((child, index) =>\n normalizeNode(child, descend(partialPath, String(index)), depth + 1),\n );\n if (typeof node !== \"object\") return null;\n\n const { [TYPE_KEY]: type, ...props } = node as Record<string, unknown>;\n // Args stream in incrementally; a node whose `$type` has not arrived yet\n // (or whose `$type` string is still mid-arrival) is not an error, it just\n // isn't renderable.\n if (typeof type !== \"string\") return null;\n if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;\n\n if (\"children\" in props) {\n props[\"children\"] = normalizeNode(\n props[\"children\"],\n descend(partialPath, \"children\"),\n depth + 1,\n );\n }\n return { type, props } as GenerativeUIElement;\n}\n\n/** Steps the partial path down into `key`; siblings of the path are complete. */\nfunction descend(\n partialPath: readonly string[] | undefined,\n key: string,\n): readonly string[] | undefined {\n return partialPath?.[0] === key ? partialPath.slice(1) : undefined;\n}\n\nfunction renderNode(\n node: GenerativeUINode,\n library: GenerativeUILibrary,\n context: GenerativeUIRenderContext,\n): ReactNode {\n if (node == null || typeof node === \"boolean\") return null;\n if (typeof node === \"string\" || typeof node === \"number\") return node;\n if (Array.isArray(node)) {\n // The wire format has no per-node id, so the key is positional. Pairing the\n // index with the node's kind means that when the model splices or reorders\n // `children` and the kind at an index changes, the key changes and React\n // remounts instead of handing a streaming node's hook state to a different\n // component.\n return node.map((child, index) => (\n <Fragment key={`${index}:${nodeKind(child)}`}>\n {renderNode(child, library, context)}\n </Fragment>\n ));\n }\n return renderElement(node, library, context);\n}\n\nfunction renderElement(\n element: GenerativeUIElement,\n library: GenerativeUILibrary,\n context: GenerativeUIRenderContext,\n): ReactNode {\n const entry = library[element.type];\n if (!entry) {\n reportUnknownComponent(element.type, Object.keys(library));\n return null;\n }\n\n // Components that opt out of prop streaming wait until their props are\n // complete rather than rendering from a partial parse.\n if (!entry.streamProperties && context.status === \"streaming\") return null;\n\n // Inject the framework props last so the model can never override them.\n const { children, ...rest } = element.props;\n const props: Record<string, unknown> = { ...rest, $status: context.status };\n if (children !== undefined) {\n props[\"children\"] = renderNode(children, library, context);\n }\n\n return <GenerativeUIComponentRenderer render={entry.render} props={props} />;\n}\n\n/**\n * Mounts a single node's `render` on its own fiber so the function may use\n * hooks and hold state independently of its siblings and parent.\n */\nfunction GenerativeUIComponentRenderer({\n render,\n props,\n}: {\n render: (props: any) => ReactNode;\n props: Record<string, unknown>;\n}): ReactNode {\n return render(props);\n}\n\n/** A coarse kind tag for a child, used in its list key so a node changing kind\n * at a given index forces a remount rather than a wrong-fiber reuse. */\nfunction nodeKind(node: GenerativeUINode): string {\n if (node == null || typeof node === \"boolean\") return \"\";\n if (typeof node === \"string\" || typeof node === \"number\") return \"#text\";\n if (Array.isArray(node)) return \"#array\";\n return node.type;\n}\n\nfunction reportUnknownComponent(type: string, available: string[]): void {\n if (process.env[\"NODE_ENV\"] !== \"production\") {\n // eslint-disable-next-line no-console\n console.error(\n `[@assistant-ui/react-generative-ui] Unknown component \"${type}\". ` +\n `Available components: ${available.join(\", \") || \"(none)\"}.`,\n );\n }\n}\n"],"mappings":";;;;;AAUA,MAAM,kBAA6C,EAAE,QAAQ,OAAO;;;;;;;;;;AAWpE,SAAgB,mBACd,MACA,SACA,UAAqC,iBAC1B;CAIX,MAAM,OAAO,yBAAyB,IAA+B;CAErE,OAAO,WAAW,cAAc,MADZ,MAAM,UAAU,YAAY,KAAK,cAAc,KAAA,CAClB,GAAG,SAAS,OAAO;AACtE;;;;;;;AAQA,MAAM,YAAY;;;;;;;;AASlB,SAAS,cACP,MACA,aACA,QAAQ,GACU;CAClB,IAAI,QAAQ,WAAW,OAAO;CAC9B,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,UAAU,OAAO;CACjE,IAAI,MAAM,QAAQ,IAAI,GACpB,OAAO,KAAK,KAAK,OAAO,UACtB,cAAc,OAAO,QAAQ,aAAa,OAAO,KAAK,CAAC,GAAG,QAAQ,CAAC,CACrE;CACF,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,MAAM,GAAG,WAAW,MAAM,GAAG,UAAU;CAIvC,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI,aAAa,WAAW,KAAK,YAAY,OAAA,SAAiB,OAAO;CAErE,IAAI,cAAc,OAChB,MAAM,cAAc,cAClB,MAAM,aACN,QAAQ,aAAa,UAAU,GAC/B,QAAQ,CACV;CAEF,OAAO;EAAE;EAAM;CAAM;AACvB;;AAGA,SAAS,QACP,aACA,KAC+B;CAC/B,OAAO,cAAc,OAAO,MAAM,YAAY,MAAM,CAAC,IAAI,KAAA;AAC3D;AAEA,SAAS,WACP,MACA,SACA,SACW;CACX,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,UAAU,OAAO;CACjE,IAAI,MAAM,QAAQ,IAAI,GAMpB,OAAO,KAAK,KAAK,OAAO,UACtB,oBAAC,UAAD,EAAA,UACG,WAAW,OAAO,SAAS,OAAO,EAC3B,GAFK,GAAG,MAAM,GAAG,SAAS,KAAK,GAE/B,CACX;CAEH,OAAO,cAAc,MAAM,SAAS,OAAO;AAC7C;AAEA,SAAS,cACP,SACA,SACA,SACW;CACX,MAAM,QAAQ,QAAQ,QAAQ;CAC9B,IAAI,CAAC,OAAO;EACV,uBAAuB,QAAQ,MAAM,OAAO,KAAK,OAAO,CAAC;EACzD,OAAO;CACT;CAIA,IAAI,CAAC,MAAM,oBAAoB,QAAQ,WAAW,aAAa,OAAO;CAGtE,MAAM,EAAE,UAAU,GAAG,SAAS,QAAQ;CACtC,MAAM,QAAiC;EAAE,GAAG;EAAM,SAAS,QAAQ;CAAO;CAC1E,IAAI,aAAa,KAAA,GACf,MAAM,cAAc,WAAW,UAAU,SAAS,OAAO;CAG3D,OAAO,oBAAC,+BAAD;EAA+B,QAAQ,MAAM;EAAe;CAAQ,CAAA;AAC7E;;;;;AAMA,SAAS,8BAA8B,EACrC,QACA,SAIY;CACZ,OAAO,OAAO,KAAK;AACrB;;;AAIA,SAAS,SAAS,MAAgC;CAChD,IAAI,QAAQ,QAAQ,OAAO,SAAS,WAAW,OAAO;CACtD,IAAI,OAAO,SAAS,YAAY,OAAO,SAAS,UAAU,OAAO;CACjE,IAAI,MAAM,QAAQ,IAAI,GAAG,OAAO;CAChC,OAAO,KAAK;AACd;AAEA,SAAS,uBAAuB,MAAc,WAA2B;CACvE,IAAI,QAAQ,IAAI,gBAAgB,cAE9B,QAAQ,MACN,0DAA0D,KAAK,2BACpC,UAAU,KAAK,IAAI,KAAK,SAAS,EAC9D;AAEJ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-generative-ui",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Generative UI components for assistant-ui",
5
5
  "keywords": [
6
6
  "generative-ui",
@@ -38,7 +38,7 @@
38
38
  ],
39
39
  "sideEffects": false,
40
40
  "dependencies": {
41
- "assistant-stream": "^0.3.20"
41
+ "assistant-stream": "^0.3.21"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@assistant-ui/react": "^0.14.14",
@@ -53,13 +53,13 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/json-schema": "^7.0.15",
56
- "@types/react": "^19.2.15",
57
- "react": "^19.2.6",
58
- "react-dom": "^19.2.6",
59
- "vitest": "^4.1.7",
56
+ "@types/react": "^19.2.17",
57
+ "react": "^19.2.7",
58
+ "react-dom": "^19.2.7",
59
+ "vitest": "^4.1.8",
60
60
  "zod": "^4.4.3",
61
- "@assistant-ui/x-buildutils": "0.0.11",
62
- "@assistant-ui/react": "0.14.14"
61
+ "@assistant-ui/react": "0.14.18",
62
+ "@assistant-ui/x-buildutils": "0.0.12"
63
63
  },
64
64
  "publishConfig": {
65
65
  "access": "public",
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
+ import { parsePartialJsonObject } from "assistant-stream/utils";
3
4
  import { z } from "zod";
4
5
  import { renderGenerativeUI } from "./renderGenerativeUI";
5
6
  import { buildPresentParameters } from "./buildPresentParameters";
@@ -90,6 +91,41 @@ describe("renderGenerativeUI", () => {
90
91
  expect(html).toBe("");
91
92
  });
92
93
 
94
+ it("holds back a node whose `$type` is still streaming", () => {
95
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
96
+ try {
97
+ for (const argsText of ['{"$type": "', '{"$type": "Tex']) {
98
+ const html = renderToStaticMarkup(
99
+ <>
100
+ {renderGenerativeUI(parsePartialJsonObject(argsText), library, {
101
+ status: "streaming",
102
+ })}
103
+ </>,
104
+ );
105
+ expect(html).toBe("");
106
+ }
107
+ expect(error).not.toHaveBeenCalled();
108
+ } finally {
109
+ error.mockRestore();
110
+ }
111
+ });
112
+
113
+ it("renders completed siblings while a nested `$type` streams", () => {
114
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
115
+ try {
116
+ const args = parsePartialJsonObject(
117
+ '{"$type": "Live", "label": "hi", "children": [{"$type": "Liv',
118
+ );
119
+ const html = renderToStaticMarkup(
120
+ <>{renderGenerativeUI(args, library, { status: "streaming" })}</>,
121
+ );
122
+ expect(html).toBe('<span data-status="streaming">hi</span>');
123
+ expect(error).not.toHaveBeenCalled();
124
+ } finally {
125
+ error.mockRestore();
126
+ }
127
+ });
128
+
93
129
  it("renders nothing for a node without a resolved type", () => {
94
130
  const html = renderToStaticMarkup(<>{renderGenerativeUI({}, library)}</>);
95
131
  expect(html).toBe("");
@@ -1,3 +1,4 @@
1
+ import { getPartialJsonObjectMeta } from "assistant-stream/utils";
1
2
  import { Fragment, type ReactNode } from "react";
2
3
  import { TYPE_KEY } from "./constants";
3
4
  import type {
@@ -23,7 +24,12 @@ export function renderGenerativeUI(
23
24
  library: GenerativeUILibrary,
24
25
  context: GenerativeUIRenderContext = DEFAULT_CONTEXT,
25
26
  ): ReactNode {
26
- return renderNode(normalizeNode(node), library, context);
27
+ // Tool args are parsed incrementally, and the parse meta records which path
28
+ // is still mid-arrival, so normalization can hold back a node whose `$type`
29
+ // string has not finished streaming.
30
+ const meta = getPartialJsonObjectMeta(node as Record<symbol, unknown>);
31
+ const partialPath = meta?.state === "partial" ? meta.partialPath : undefined;
32
+ return renderNode(normalizeNode(node, partialPath), library, context);
27
33
  }
28
34
 
29
35
  /**
@@ -34,26 +40,52 @@ export function renderGenerativeUI(
34
40
  */
35
41
  const MAX_DEPTH = 64;
36
42
 
37
- /** Converts the flat wire form into a normalized {@link GenerativeUINode}. */
38
- function normalizeNode(node: unknown, depth = 0): GenerativeUINode {
43
+ /**
44
+ * Converts the flat wire form into a normalized {@link GenerativeUINode}.
45
+ *
46
+ * `partialPath` is the remaining segment of the parse meta's partial path
47
+ * relative to `node` (`undefined` once the walk leaves the partial frontier,
48
+ * i.e. everything below is complete).
49
+ */
50
+ function normalizeNode(
51
+ node: unknown,
52
+ partialPath: readonly string[] | undefined,
53
+ depth = 0,
54
+ ): GenerativeUINode {
39
55
  if (depth > MAX_DEPTH) return null;
40
56
  if (node == null || typeof node === "boolean") return null;
41
57
  if (typeof node === "string" || typeof node === "number") return node;
42
58
  if (Array.isArray(node))
43
- return node.map((child) => normalizeNode(child, depth + 1));
59
+ return node.map((child, index) =>
60
+ normalizeNode(child, descend(partialPath, String(index)), depth + 1),
61
+ );
44
62
  if (typeof node !== "object") return null;
45
63
 
46
64
  const { [TYPE_KEY]: type, ...props } = node as Record<string, unknown>;
47
- // Args stream in incrementally; a node whose `$type` has not arrived yet is
48
- // not an error, it just isn't renderable.
65
+ // Args stream in incrementally; a node whose `$type` has not arrived yet
66
+ // (or whose `$type` string is still mid-arrival) is not an error, it just
67
+ // isn't renderable.
49
68
  if (typeof type !== "string") return null;
69
+ if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;
50
70
 
51
71
  if ("children" in props) {
52
- props["children"] = normalizeNode(props["children"], depth + 1);
72
+ props["children"] = normalizeNode(
73
+ props["children"],
74
+ descend(partialPath, "children"),
75
+ depth + 1,
76
+ );
53
77
  }
54
78
  return { type, props } as GenerativeUIElement;
55
79
  }
56
80
 
81
+ /** Steps the partial path down into `key`; siblings of the path are complete. */
82
+ function descend(
83
+ partialPath: readonly string[] | undefined,
84
+ key: string,
85
+ ): readonly string[] | undefined {
86
+ return partialPath?.[0] === key ? partialPath.slice(1) : undefined;
87
+ }
88
+
57
89
  function renderNode(
58
90
  node: GenerativeUINode,
59
91
  library: GenerativeUILibrary,