@assistant-ui/react-generative-ui 0.0.16 → 0.0.17
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/convert/boundSpec.d.ts +5 -1
- package/dist/convert/boundSpec.d.ts.map +1 -1
- package/dist/convert/boundSpec.js +13 -4
- package/dist/convert/boundSpec.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js +2 -5
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/package.json +5 -5
- package/src/convert/boundSpec.test.ts +47 -2
- package/src/convert/boundSpec.ts +12 -14
- package/src/teams/toTeamsAttachments.ts +2 -9
|
@@ -35,7 +35,11 @@ declare function clampReasonDetail(reason: ClampReason): string;
|
|
|
35
35
|
* (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
|
|
36
36
|
* entries by index, which bounds a proxy however it fabricates `length` or
|
|
37
37
|
* replaces its array methods; recursion itself is capped at
|
|
38
|
-
* {@link MAX_TRAVERSAL_DEPTH}.
|
|
38
|
+
* {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
|
|
39
|
+
* `children` came from a single read, because a record that answers
|
|
40
|
+
* `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
|
|
41
|
+
* otherwise hand the bound one value and `normalizeSpec` another, skipping
|
|
42
|
+
* every bound here for that subtree. `onClamp` fires once per level that was
|
|
39
43
|
* truncated, receiving the reason for that truncation: `"children"`,
|
|
40
44
|
* `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
|
|
41
45
|
* {@link NODE_BUDGET} nodes, so shared references cannot multiply work
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"boundSpec.d.ts","names":[],"sources":["../../src/convert/boundSpec.ts"],"mappings":";;cAGa;;;;;;;cAQA;;;;;;;;cASA;;;;;;;;cASA;KAED;;iBAGI,kBAAkB,QAAQ
|
|
1
|
+
{"version":3,"file":"boundSpec.d.ts","names":[],"sources":["../../src/convert/boundSpec.ts"],"mappings":";;cAGa;;;;;;;cAQA;;;;;;;;cASA;;;;;;;;cASA;KAED;;iBAGI,kBAAkB,QAAQ;;;;;;;;;;;;;;;;;;;iBAwF1B,UACd,eACA,UAAU,QAAQ"}
|
|
@@ -57,16 +57,21 @@ function boundNode(value, depth, onClamp, state, ancestors) {
|
|
|
57
57
|
ancestors.delete(value);
|
|
58
58
|
return result;
|
|
59
59
|
}
|
|
60
|
-
if (value !== null && typeof value === "object"
|
|
60
|
+
if (value !== null && typeof value === "object") {
|
|
61
|
+
const record = value;
|
|
62
|
+
const children = record["children"];
|
|
63
|
+
if (children === void 0) return {
|
|
64
|
+
...record,
|
|
65
|
+
children: void 0
|
|
66
|
+
};
|
|
61
67
|
if (ancestors.has(value)) {
|
|
62
68
|
onClamp("cycle");
|
|
63
69
|
return null;
|
|
64
70
|
}
|
|
65
71
|
ancestors.add(value);
|
|
66
|
-
const record = value;
|
|
67
72
|
const result = {
|
|
68
73
|
...record,
|
|
69
|
-
children: boundNode(
|
|
74
|
+
children: boundNode(children, depth + 1, onClamp, state, ancestors)
|
|
70
75
|
};
|
|
71
76
|
ancestors.delete(value);
|
|
72
77
|
return result;
|
|
@@ -81,7 +86,11 @@ function boundNode(value, depth, onClamp, state, ancestors) {
|
|
|
81
86
|
* (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
|
|
82
87
|
* entries by index, which bounds a proxy however it fabricates `length` or
|
|
83
88
|
* replaces its array methods; recursion itself is capped at
|
|
84
|
-
* {@link MAX_TRAVERSAL_DEPTH}.
|
|
89
|
+
* {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
|
|
90
|
+
* `children` came from a single read, because a record that answers
|
|
91
|
+
* `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
|
|
92
|
+
* otherwise hand the bound one value and `normalizeSpec` another, skipping
|
|
93
|
+
* every bound here for that subtree. `onClamp` fires once per level that was
|
|
85
94
|
* truncated, receiving the reason for that truncation: `"children"`,
|
|
86
95
|
* `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
|
|
87
96
|
* {@link NODE_BUDGET} nodes, so shared references cannot multiply work
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"boundSpec.js","names":[],"sources":["../../src/convert/boundSpec.ts"],"sourcesContent":["import { copyBounded } from \"./copyBounded\";\n\n/** The maximum traversal depth, counted in visited values, not tree levels. */\nexport const MAX_TRAVERSAL_DEPTH = 64;\n\n/**\n * The element-nesting ceiling a caller observes. `boundNode` visits an element\n * and its `children` array separately, so one level of nesting spends two of\n * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never\n * the looser of the two. A root array costs one more level than a root object.\n */\nexport const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;\n\n/**\n * The maximum number of entries kept at any single level (root, or a\n * `children` array at any depth) of the raw spec, before it ever reaches\n * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass\n * can never be made to walk further than this regardless of what the array\n * claims about itself.\n */\nexport const CHILDREN_CAP = 200;\n\n/**\n * The total number of nodes {@link boundSpec} will visit across one walk,\n * regardless of how many times a shared reference recurs. Bounds the\n * combinatorial work a DAG of shared or self-referential nodes would\n * otherwise force even though each individual array stays within\n * {@link CHILDREN_CAP}.\n */\nexport const NODE_BUDGET = 5000;\n\nexport type ClampReason = \"children\" | \"budget\" | \"cycle\" | \"depth\";\n\n/** Renders the warning detail a protocol reports for one {@link ClampReason}. */\nexport function clampReasonDetail(reason: ClampReason): string {\n if (reason === \"budget\") {\n return `the tree was truncated after ${NODE_BUDGET} nodes.`;\n }\n if (reason === \"cycle\") return \"a self-referencing node was dropped.\";\n if (reason === \"depth\") {\n return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;\n }\n return `children were clamped to ${CHILDREN_CAP} entries.`;\n}\n\ninterface BoundState {\n remaining: number;\n exhausted: boolean;\n}\n\nfunction boundNode(\n value: unknown,\n depth: number,\n onClamp: (reason: ClampReason) => void,\n state: BoundState,\n ancestors: WeakSet<object>,\n): unknown {\n if (state.remaining <= 0) {\n if (!state.exhausted) {\n state.exhausted = true;\n onClamp(\"budget\");\n }\n return null;\n }\n state.remaining -= 1;\n if (depth > MAX_TRAVERSAL_DEPTH) {\n onClamp(\"depth\");\n return null;\n }\n if (Array.isArray(value)) {\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const { items: bounded, truncated } = copyBounded(value, CHILDREN_CAP);\n if (truncated) onClamp(\"children\");\n const result = bounded.map((item) =>\n boundNode(item, depth + 1, onClamp, state, ancestors),\n );\n ancestors.delete(value);\n return result;\n }\n if (
|
|
1
|
+
{"version":3,"file":"boundSpec.js","names":[],"sources":["../../src/convert/boundSpec.ts"],"sourcesContent":["import { copyBounded } from \"./copyBounded\";\n\n/** The maximum traversal depth, counted in visited values, not tree levels. */\nexport const MAX_TRAVERSAL_DEPTH = 64;\n\n/**\n * The element-nesting ceiling a caller observes. `boundNode` visits an element\n * and its `children` array separately, so one level of nesting spends two of\n * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never\n * the looser of the two. A root array costs one more level than a root object.\n */\nexport const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;\n\n/**\n * The maximum number of entries kept at any single level (root, or a\n * `children` array at any depth) of the raw spec, before it ever reaches\n * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass\n * can never be made to walk further than this regardless of what the array\n * claims about itself.\n */\nexport const CHILDREN_CAP = 200;\n\n/**\n * The total number of nodes {@link boundSpec} will visit across one walk,\n * regardless of how many times a shared reference recurs. Bounds the\n * combinatorial work a DAG of shared or self-referential nodes would\n * otherwise force even though each individual array stays within\n * {@link CHILDREN_CAP}.\n */\nexport const NODE_BUDGET = 5000;\n\nexport type ClampReason = \"children\" | \"budget\" | \"cycle\" | \"depth\";\n\n/** Renders the warning detail a protocol reports for one {@link ClampReason}. */\nexport function clampReasonDetail(reason: ClampReason): string {\n if (reason === \"budget\") {\n return `the tree was truncated after ${NODE_BUDGET} nodes.`;\n }\n if (reason === \"cycle\") return \"a self-referencing node was dropped.\";\n if (reason === \"depth\") {\n return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;\n }\n return `children were clamped to ${CHILDREN_CAP} entries.`;\n}\n\ninterface BoundState {\n remaining: number;\n exhausted: boolean;\n}\n\nfunction boundNode(\n value: unknown,\n depth: number,\n onClamp: (reason: ClampReason) => void,\n state: BoundState,\n ancestors: WeakSet<object>,\n): unknown {\n if (state.remaining <= 0) {\n if (!state.exhausted) {\n state.exhausted = true;\n onClamp(\"budget\");\n }\n return null;\n }\n state.remaining -= 1;\n if (depth > MAX_TRAVERSAL_DEPTH) {\n onClamp(\"depth\");\n return null;\n }\n if (Array.isArray(value)) {\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const { items: bounded, truncated } = copyBounded(value, CHILDREN_CAP);\n if (truncated) onClamp(\"children\");\n const result = bounded.map((item) =>\n boundNode(item, depth + 1, onClamp, state, ancestors),\n );\n ancestors.delete(value);\n return result;\n }\n if (value !== null && typeof value === \"object\") {\n const record = value as Record<string, unknown>;\n const children = record[\"children\"];\n // The spread performs a `children` read of its own, so the copy states the\n // bounded value even when there is none.\n if (children === undefined) return { ...record, children: undefined };\n if (ancestors.has(value)) {\n onClamp(\"cycle\");\n return null;\n }\n ancestors.add(value);\n const result = {\n ...record,\n children: boundNode(children, depth + 1, onClamp, state, ancestors),\n };\n ancestors.delete(value);\n return result;\n }\n return value;\n}\n\n/**\n * Produces a bounded plain copy of a raw generative-ui spec before it\n * reaches `normalizeSpec`, whose own traversal of the root array or any\n * `children` array walks the full reported length of a hostile proxied\n * array before any per-field cap downstream ever applies. Every array\n * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}\n * entries by index, which bounds a proxy however it fabricates `length` or\n * replaces its array methods; recursion itself is capped at\n * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose\n * `children` came from a single read, because a record that answers\n * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would\n * otherwise hand the bound one value and `normalizeSpec` another, skipping\n * every bound here for that subtree. `onClamp` fires once per level that was\n * truncated, receiving the reason for that truncation: `\"children\"`,\n * `\"depth\"`, `\"budget\"`, or `\"cycle\"`. The walk also spends a total budget of\n * {@link NODE_BUDGET} nodes, so shared references cannot multiply work\n * exponentially, and a node that is its own ancestor is cut to `null`.\n */\nexport function boundSpec(\n spec: unknown,\n onClamp: (reason: ClampReason) => void,\n): unknown {\n return boundNode(\n spec,\n 0,\n onClamp,\n { remaining: NODE_BUDGET, exhausted: false },\n new WeakSet(),\n );\n}\n"],"mappings":";;;AAGA,MAAa,sBAAsB;;;;;;;AAQnC,MAAa,oBAAA;;;;;;;;AASb,MAAa,eAAe;;;;;;;;AAS5B,MAAa,cAAc;;AAK3B,SAAgB,kBAAkB,QAA6B;CAC7D,IAAI,WAAW,UACb,OAAO,gCAAgC,YAAY;CAErD,IAAI,WAAW,SAAS,OAAO;CAC/B,IAAI,WAAW,SACb,OAAO;CAET,OAAO;AACT;AAOA,SAAS,UACP,OACA,OACA,SACA,OACA,WACS;CACT,IAAI,MAAM,aAAa,GAAG;EACxB,IAAI,CAAC,MAAM,WAAW;GACpB,MAAM,YAAY;GAClB,QAAQ,QAAQ;EAClB;EACA,OAAO;CACT;CACA,MAAM,aAAa;CACnB,IAAI,QAAA,IAA6B;EAC/B,QAAQ,OAAO;EACf,OAAO;CACT;CACA,IAAI,MAAM,QAAQ,KAAK,GAAG;EACxB,IAAI,UAAU,IAAI,KAAK,GAAG;GACxB,QAAQ,OAAO;GACf,OAAO;EACT;EACA,UAAU,IAAI,KAAK;EACnB,MAAM,EAAE,OAAO,SAAS,cAAc,YAAY,OAAA,GAAmB;EACrE,IAAI,WAAW,QAAQ,UAAU;EACjC,MAAM,SAAS,QAAQ,KAAK,SAC1B,UAAU,MAAM,QAAQ,GAAG,SAAS,OAAO,SAAS,CACtD;EACA,UAAU,OAAO,KAAK;EACtB,OAAO;CACT;CACA,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU;EAC/C,MAAM,SAAS;EACf,MAAM,WAAW,OAAO;EAGxB,IAAI,aAAa,KAAA,GAAW,OAAO;GAAE,GAAG;GAAQ,UAAU,KAAA;EAAU;EACpE,IAAI,UAAU,IAAI,KAAK,GAAG;GACxB,QAAQ,OAAO;GACf,OAAO;EACT;EACA,UAAU,IAAI,KAAK;EACnB,MAAM,SAAS;GACb,GAAG;GACH,UAAU,UAAU,UAAU,QAAQ,GAAG,SAAS,OAAO,SAAS;EACpE;EACA,UAAU,OAAO,KAAK;EACtB,OAAO;CACT;CACA,OAAO;AACT;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,UACd,MACA,SACS;CACT,OAAO,UACL,MACA,GACA,SACA;EAAE,WAAW;EAAa,WAAW;CAAM,mBAC3C,IAAI,QAAQ,CACd;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"toTeamsAttachments.d.ts","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"mappings":";;;;;;;;;
|
|
1
|
+
{"version":3,"file":"toTeamsAttachments.d.ts","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"mappings":";;;;;;;;;iBA0EgB,mBACd,eACA,WAAW,wBACV"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeSpec } from "../ir.js";
|
|
2
|
+
import { copyBounded } from "../convert/copyBounded.js";
|
|
2
3
|
import { boundSpec, clampReasonDetail } from "../convert/boundSpec.js";
|
|
3
4
|
import { isElement } from "../convert/isElement.js";
|
|
4
5
|
import { PAYLOAD_SOFT_CAP, buildAttachment, utf8ByteLength } from "./constants.js";
|
|
@@ -9,10 +10,6 @@ const normalizedList = (node) => {
|
|
|
9
10
|
if (!Array.isArray(node)) return [node];
|
|
10
11
|
return node.flatMap((child) => normalizedList(child));
|
|
11
12
|
};
|
|
12
|
-
const clampArray = (value, cap) => ({
|
|
13
|
-
items: value.slice(0, cap),
|
|
14
|
-
truncated: value.length > cap
|
|
15
|
-
});
|
|
16
13
|
/**
|
|
17
14
|
* Recognizes a root that is a single `Carousel` element, returning its `Card`
|
|
18
15
|
* children. A non-card child is routed through {@link discardedChild}, which
|
|
@@ -62,7 +59,7 @@ function toTeamsAttachments(node, _options) {
|
|
|
62
59
|
const { root } = normalizeSpec(bounded);
|
|
63
60
|
const carouselCards = rootCarouselCards(root, context);
|
|
64
61
|
if (carouselCards !== void 0) {
|
|
65
|
-
const { items, truncated } =
|
|
62
|
+
const { items, truncated } = copyBounded(carouselCards, 10);
|
|
66
63
|
if (truncated) warnings.push({
|
|
67
64
|
code: "clamped",
|
|
68
65
|
component: "Carousel",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"toTeamsAttachments.js","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"sourcesContent":["import { boundSpec, clampReasonDetail } from \"../convert/boundSpec\";\nimport { isElement } from \"../convert/isElement\";\nimport {\n normalizeSpec,\n type NormalizedUIElement,\n type NormalizedUINode,\n} from \"../ir\";\nimport {\n CAROUSEL_ATTACHMENT_CAP,\n PAYLOAD_SOFT_CAP,\n buildAttachment,\n utf8ByteLength,\n} from \"./constants\";\nimport {\n convertRootToCard,\n discardedChild,\n type ConversionContext,\n} from \"./toAdaptiveCard\";\nimport type {\n TeamsAttachmentsResult,\n TeamsCardAttachment,\n TeamsConversionWarning,\n ToAdaptiveCardOptions,\n} from \"./types\";\n\nconst normalizedList = (\n node: NormalizedUINode | undefined,\n): NormalizedUINode[] => {\n if (node === undefined || node === null) return [];\n if (!Array.isArray(node)) return [node];\n return node.flatMap((child) => normalizedList(child));\n};\n\
|
|
1
|
+
{"version":3,"file":"toTeamsAttachments.js","names":[],"sources":["../../src/teams/toTeamsAttachments.ts"],"sourcesContent":["import { boundSpec, clampReasonDetail } from \"../convert/boundSpec\";\nimport { copyBounded } from \"../convert/copyBounded\";\nimport { isElement } from \"../convert/isElement\";\nimport {\n normalizeSpec,\n type NormalizedUIElement,\n type NormalizedUINode,\n} from \"../ir\";\nimport {\n CAROUSEL_ATTACHMENT_CAP,\n PAYLOAD_SOFT_CAP,\n buildAttachment,\n utf8ByteLength,\n} from \"./constants\";\nimport {\n convertRootToCard,\n discardedChild,\n type ConversionContext,\n} from \"./toAdaptiveCard\";\nimport type {\n TeamsAttachmentsResult,\n TeamsCardAttachment,\n TeamsConversionWarning,\n ToAdaptiveCardOptions,\n} from \"./types\";\n\nconst normalizedList = (\n node: NormalizedUINode | undefined,\n): NormalizedUINode[] => {\n if (node === undefined || node === null) return [];\n if (!Array.isArray(node)) return [node];\n return node.flatMap((child) => normalizedList(child));\n};\n\n/**\n * Recognizes a root that is a single `Carousel` element, returning its `Card`\n * children. A non-card child is routed through {@link discardedChild}, which\n * reports it and forwards whatever it lost, rather than being filtered out\n * silently. `undefined` means the root is not exclusively a\n * root-level carousel, so the caller falls back to converting the whole tree\n * as one card.\n */\nfunction rootCarouselCards(\n root: NormalizedUINode | readonly NormalizedUINode[],\n context: ConversionContext,\n): NormalizedUIElement[] | undefined {\n const element = Array.isArray(root) && root.length === 1 ? root[0] : root;\n if (!isElement(element) || element.type !== \"Carousel\") return undefined;\n const cards: NormalizedUIElement[] = [];\n let discarded = 0;\n for (const child of normalizedList(element.children)) {\n if (isElement(child) && child.type === \"Card\") {\n cards.push(child);\n continue;\n }\n if (discardedChild(child, context, 1)) discarded += 1;\n }\n if (discarded > 0) {\n context.warnings.push({\n code: \"dropped\",\n component: \"Carousel\",\n detail: `${discarded} non-card ${discarded === 1 ? \"child was\" : \"children were\"} dropped.`,\n });\n }\n return cards;\n}\n\n/**\n * Converts a generative-ui tree into Teams bot-framework message attachments.\n * A root-level `Carousel` becomes one attachment per `Card` child (clamped to\n * {@link CAROUSEL_ATTACHMENT_CAP}) with `attachmentLayout: \"carousel\"`;\n * anything else converts through {@link toAdaptiveCard}'s engine into a\n * single attachment. Never throws.\n */\nexport function toTeamsAttachments(\n node: unknown,\n _options?: ToAdaptiveCardOptions,\n): TeamsAttachmentsResult {\n const warnings: TeamsConversionWarning[] = [];\n const context: ConversionContext = { warnings, usedInputIds: new Set() };\n try {\n const bounded = boundSpec(node, (reason) =>\n warnings.push({\n code: \"clamped\",\n component: \"Root\",\n detail: clampReasonDetail(reason),\n }),\n );\n const { root } = normalizeSpec(bounded as never);\n const carouselCards = rootCarouselCards(root, context);\n if (carouselCards !== undefined) {\n const { items, truncated } = copyBounded(\n carouselCards,\n CAROUSEL_ATTACHMENT_CAP,\n );\n if (truncated) {\n warnings.push({\n code: \"clamped\",\n component: \"Carousel\",\n detail: `cards were clamped to ${CAROUSEL_ATTACHMENT_CAP} entries.`,\n });\n }\n const attachments: TeamsCardAttachment[] = items.map((child) =>\n buildAttachment(convertRootToCard(child, context)),\n );\n const size = utf8ByteLength(JSON.stringify(attachments));\n if (size > PAYLOAD_SOFT_CAP) {\n warnings.push({\n code: \"advisory\",\n component: \"Carousel\",\n detail: `the carousel attachments total ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,\n });\n }\n return { attachments, attachmentLayout: \"carousel\", warnings };\n }\n const attachment = buildAttachment(convertRootToCard(root, context));\n return { attachments: [attachment], warnings };\n } catch {\n return {\n attachments: [],\n warnings: [\n {\n code: \"dropped\",\n component: \"Root\",\n detail: \"Input could not be converted.\",\n },\n ],\n };\n }\n}\n"],"mappings":";;;;;;;AA0BA,MAAM,kBACJ,SACuB;CACvB,IAAI,SAAS,KAAA,KAAa,SAAS,MAAM,OAAO,CAAC;CACjD,IAAI,CAAC,MAAM,QAAQ,IAAI,GAAG,OAAO,CAAC,IAAI;CACtC,OAAO,KAAK,SAAS,UAAU,eAAe,KAAK,CAAC;AACtD;;;;;;;;;AAUA,SAAS,kBACP,MACA,SACmC;CACnC,MAAM,UAAU,MAAM,QAAQ,IAAI,KAAK,KAAK,WAAW,IAAI,KAAK,KAAK;CACrE,IAAI,CAAC,UAAU,OAAO,KAAK,QAAQ,SAAS,YAAY,OAAO,KAAA;CAC/D,MAAM,QAA+B,CAAC;CACtC,IAAI,YAAY;CAChB,KAAK,MAAM,SAAS,eAAe,QAAQ,QAAQ,GAAG;EACpD,IAAI,UAAU,KAAK,KAAK,MAAM,SAAS,QAAQ;GAC7C,MAAM,KAAK,KAAK;GAChB;EACF;EACA,IAAI,eAAe,OAAO,SAAS,CAAC,GAAG,aAAa;CACtD;CACA,IAAI,YAAY,GACd,QAAQ,SAAS,KAAK;EACpB,MAAM;EACN,WAAW;EACX,QAAQ,GAAG,UAAU,YAAY,cAAc,IAAI,cAAc,gBAAgB;CACnF,CAAC;CAEH,OAAO;AACT;;;;;;;;AASA,SAAgB,mBACd,MACA,UACwB;CACxB,MAAM,WAAqC,CAAC;CAC5C,MAAM,UAA6B;EAAE;EAAU,8BAAc,IAAI,IAAI;CAAE;CACvE,IAAI;EACF,MAAM,UAAU,UAAU,OAAO,WAC/B,SAAS,KAAK;GACZ,MAAM;GACN,WAAW;GACX,QAAQ,kBAAkB,MAAM;EAClC,CAAC,CACH;EACA,MAAM,EAAE,SAAS,cAAc,OAAgB;EAC/C,MAAM,gBAAgB,kBAAkB,MAAM,OAAO;EACrD,IAAI,kBAAkB,KAAA,GAAW;GAC/B,MAAM,EAAE,OAAO,cAAc,YAC3B,eAAA,EAEF;GACA,IAAI,WACF,SAAS,KAAK;IACZ,MAAM;IACN,WAAW;IACX,QAAQ;GACV,CAAC;GAEH,MAAM,cAAqC,MAAM,KAAK,UACpD,gBAAgB,kBAAkB,OAAO,OAAO,CAAC,CACnD;GACA,MAAM,OAAO,eAAe,KAAK,UAAU,WAAW,CAAC;GACvD,IAAI,OAAA,KACF,SAAS,KAAK;IACZ,MAAM;IACN,WAAW;IACX,QAAQ,kCAAkC,KAAK,mBAAmB,iBAAiB;GACrF,CAAC;GAEH,OAAO;IAAE;IAAa,kBAAkB;IAAY;GAAS;EAC/D;EAEA,OAAO;GAAE,aAAa,CADH,gBAAgB,kBAAkB,MAAM,OAAO,CAClC,CAAC;GAAG;EAAS;CAC/C,QAAQ;EACN,OAAO;GACL,aAAa,CAAC;GACd,UAAU,CACR;IACE,MAAM;IACN,WAAW;IACX,QAAQ;GACV,CACF;EACF;CACF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react-generative-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "Generative UI components for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"generative-ui",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
],
|
|
51
51
|
"sideEffects": false,
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"assistant-stream": "^0.3.
|
|
53
|
+
"assistant-stream": "^0.3.41"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@assistant-ui/react": "^0.15.0",
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@assistant-ui/react": "0.15.
|
|
68
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
67
|
+
"@assistant-ui/react": "0.15.18",
|
|
68
|
+
"@assistant-ui/x-buildutils": "0.0.25",
|
|
69
69
|
"@types/json-schema": "^7.0.15",
|
|
70
70
|
"@types/react": "^19.2.18",
|
|
71
71
|
"react": "^19.2.8",
|
|
72
72
|
"react-dom": "^19.2.8",
|
|
73
73
|
"vitest": "^4.1.11",
|
|
74
|
-
"zod": "^4.4
|
|
74
|
+
"zod": "^4.5.4"
|
|
75
75
|
},
|
|
76
76
|
"publishConfig": {
|
|
77
77
|
"access": "public",
|
|
@@ -20,10 +20,12 @@ const nest = (levels: number): unknown =>
|
|
|
20
20
|
: { type: "Box", children: [nest(levels - 1)] };
|
|
21
21
|
|
|
22
22
|
describe("boundSpec", () => {
|
|
23
|
-
it("
|
|
23
|
+
it("copies an object without children and passes primitives through", () => {
|
|
24
24
|
const leaf = { type: "Text", props: { value: "hi" } };
|
|
25
|
+
const { reasons, result } = walk(leaf);
|
|
25
26
|
|
|
26
|
-
expect(
|
|
27
|
+
expect({ reasons, result }).toEqual({ reasons: [], result: leaf });
|
|
28
|
+
expect(result).not.toBe(leaf);
|
|
27
29
|
expect(walk("text").result).toBe("text");
|
|
28
30
|
expect(walk(null).result).toBe(null);
|
|
29
31
|
});
|
|
@@ -86,6 +88,49 @@ describe("boundSpec", () => {
|
|
|
86
88
|
expect(result).toEqual([{ type: "Text" }, { type: "Text" }]);
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
it("bounds a record that hides its children behind a has trap", () => {
|
|
92
|
+
const hostileChildren = new Proxy([{ type: "Text" }], {
|
|
93
|
+
get: (target, prop, receiver) =>
|
|
94
|
+
prop === "length"
|
|
95
|
+
? Number.MAX_SAFE_INTEGER
|
|
96
|
+
: Reflect.get(target, prop, receiver),
|
|
97
|
+
});
|
|
98
|
+
const hostile = new Proxy(
|
|
99
|
+
{ type: "Box", children: hostileChildren } as Record<string, unknown>,
|
|
100
|
+
{
|
|
101
|
+
has: (target, prop) => prop !== "children" && Reflect.has(target, prop),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const { reasons, result } = walk(hostile);
|
|
106
|
+
|
|
107
|
+
expect(reasons).toEqual(["children"]);
|
|
108
|
+
expect((result as { children: unknown[] }).children).toHaveLength(
|
|
109
|
+
CHILDREN_CAP,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("bounds a record that answers a later read of children differently", () => {
|
|
114
|
+
let reads = 0;
|
|
115
|
+
const hostile = new Proxy(
|
|
116
|
+
{ type: "Box", children: undefined } as Record<string, unknown>,
|
|
117
|
+
{
|
|
118
|
+
get: (target, prop, receiver) => {
|
|
119
|
+
if (prop !== "children") return Reflect.get(target, prop, receiver);
|
|
120
|
+
reads += 1;
|
|
121
|
+
return reads === 1
|
|
122
|
+
? undefined
|
|
123
|
+
: Array(CHILDREN_CAP * 2).fill({ type: "Text" });
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const { reasons, result } = walk(hostile);
|
|
129
|
+
|
|
130
|
+
expect(reasons).toEqual([]);
|
|
131
|
+
expect((result as { children: unknown }).children).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
89
134
|
it("accepts nesting exactly at the element-depth ceiling", () => {
|
|
90
135
|
expect(walk(nest(MAX_ELEMENT_DEPTH)).reasons).toEqual([]);
|
|
91
136
|
});
|
package/src/convert/boundSpec.ts
CHANGED
|
@@ -81,26 +81,20 @@ function boundNode(
|
|
|
81
81
|
ancestors.delete(value);
|
|
82
82
|
return result;
|
|
83
83
|
}
|
|
84
|
-
if (
|
|
85
|
-
value
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
if (value !== null && typeof value === "object") {
|
|
85
|
+
const record = value as Record<string, unknown>;
|
|
86
|
+
const children = record["children"];
|
|
87
|
+
// The spread performs a `children` read of its own, so the copy states the
|
|
88
|
+
// bounded value even when there is none.
|
|
89
|
+
if (children === undefined) return { ...record, children: undefined };
|
|
89
90
|
if (ancestors.has(value)) {
|
|
90
91
|
onClamp("cycle");
|
|
91
92
|
return null;
|
|
92
93
|
}
|
|
93
94
|
ancestors.add(value);
|
|
94
|
-
const record = value as Record<string, unknown>;
|
|
95
95
|
const result = {
|
|
96
96
|
...record,
|
|
97
|
-
children: boundNode(
|
|
98
|
-
record["children"],
|
|
99
|
-
depth + 1,
|
|
100
|
-
onClamp,
|
|
101
|
-
state,
|
|
102
|
-
ancestors,
|
|
103
|
-
),
|
|
97
|
+
children: boundNode(children, depth + 1, onClamp, state, ancestors),
|
|
104
98
|
};
|
|
105
99
|
ancestors.delete(value);
|
|
106
100
|
return result;
|
|
@@ -116,7 +110,11 @@ function boundNode(
|
|
|
116
110
|
* (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
|
|
117
111
|
* entries by index, which bounds a proxy however it fabricates `length` or
|
|
118
112
|
* replaces its array methods; recursion itself is capped at
|
|
119
|
-
* {@link MAX_TRAVERSAL_DEPTH}.
|
|
113
|
+
* {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
|
|
114
|
+
* `children` came from a single read, because a record that answers
|
|
115
|
+
* `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
|
|
116
|
+
* otherwise hand the bound one value and `normalizeSpec` another, skipping
|
|
117
|
+
* every bound here for that subtree. `onClamp` fires once per level that was
|
|
120
118
|
* truncated, receiving the reason for that truncation: `"children"`,
|
|
121
119
|
* `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
|
|
122
120
|
* {@link NODE_BUDGET} nodes, so shared references cannot multiply work
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { boundSpec, clampReasonDetail } from "../convert/boundSpec";
|
|
2
|
+
import { copyBounded } from "../convert/copyBounded";
|
|
2
3
|
import { isElement } from "../convert/isElement";
|
|
3
4
|
import {
|
|
4
5
|
normalizeSpec,
|
|
@@ -31,14 +32,6 @@ const normalizedList = (
|
|
|
31
32
|
return node.flatMap((child) => normalizedList(child));
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
const clampArray = <T>(
|
|
35
|
-
value: readonly T[],
|
|
36
|
-
cap: number,
|
|
37
|
-
): { readonly items: T[]; readonly truncated: boolean } => ({
|
|
38
|
-
items: value.slice(0, cap),
|
|
39
|
-
truncated: value.length > cap,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
35
|
/**
|
|
43
36
|
* Recognizes a root that is a single `Carousel` element, returning its `Card`
|
|
44
37
|
* children. A non-card child is routed through {@link discardedChild}, which
|
|
@@ -96,7 +89,7 @@ export function toTeamsAttachments(
|
|
|
96
89
|
const { root } = normalizeSpec(bounded as never);
|
|
97
90
|
const carouselCards = rootCarouselCards(root, context);
|
|
98
91
|
if (carouselCards !== undefined) {
|
|
99
|
-
const { items, truncated } =
|
|
92
|
+
const { items, truncated } = copyBounded(
|
|
100
93
|
carouselCards,
|
|
101
94
|
CAROUSEL_ATTACHMENT_CAP,
|
|
102
95
|
);
|