json-ui-lite-rn 0.12.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.
Files changed (52) hide show
  1. package/README.md +230 -0
  2. package/lib/commonjs/GenUINode.js +113 -0
  3. package/lib/commonjs/GenUINode.js.map +1 -0
  4. package/lib/commonjs/GenerativeUIView.js +116 -0
  5. package/lib/commonjs/GenerativeUIView.js.map +1 -0
  6. package/lib/commonjs/index.js +78 -0
  7. package/lib/commonjs/index.js.map +1 -0
  8. package/lib/commonjs/package.json +1 -0
  9. package/lib/commonjs/parseGenUIProps.js +53 -0
  10. package/lib/commonjs/parseGenUIProps.js.map +1 -0
  11. package/lib/commonjs/prompt.js +27 -0
  12. package/lib/commonjs/prompt.js.map +1 -0
  13. package/lib/commonjs/registry.js +70 -0
  14. package/lib/commonjs/registry.js.map +1 -0
  15. package/lib/commonjs/tools.js +386 -0
  16. package/lib/commonjs/tools.js.map +1 -0
  17. package/lib/module/GenUINode.js +108 -0
  18. package/lib/module/GenUINode.js.map +1 -0
  19. package/lib/module/GenerativeUIView.js +111 -0
  20. package/lib/module/GenerativeUIView.js.map +1 -0
  21. package/lib/module/index.js +9 -0
  22. package/lib/module/index.js.map +1 -0
  23. package/lib/module/parseGenUIProps.js +49 -0
  24. package/lib/module/parseGenUIProps.js.map +1 -0
  25. package/lib/module/prompt.js +23 -0
  26. package/lib/module/prompt.js.map +1 -0
  27. package/lib/module/registry.js +66 -0
  28. package/lib/module/registry.js.map +1 -0
  29. package/lib/module/tools.js +383 -0
  30. package/lib/module/tools.js.map +1 -0
  31. package/lib/typescript/GenUINode.d.ts +12 -0
  32. package/lib/typescript/GenUINode.d.ts.map +1 -0
  33. package/lib/typescript/GenerativeUIView.d.ts +23 -0
  34. package/lib/typescript/GenerativeUIView.d.ts.map +1 -0
  35. package/lib/typescript/index.d.ts +8 -0
  36. package/lib/typescript/index.d.ts.map +1 -0
  37. package/lib/typescript/parseGenUIProps.d.ts +20 -0
  38. package/lib/typescript/parseGenUIProps.d.ts.map +1 -0
  39. package/lib/typescript/prompt.d.ts +12 -0
  40. package/lib/typescript/prompt.d.ts.map +1 -0
  41. package/lib/typescript/registry.d.ts +36 -0
  42. package/lib/typescript/registry.d.ts.map +1 -0
  43. package/lib/typescript/tools.d.ts +119 -0
  44. package/lib/typescript/tools.d.ts.map +1 -0
  45. package/package.json +56 -0
  46. package/src/GenUINode.tsx +115 -0
  47. package/src/GenerativeUIView.tsx +137 -0
  48. package/src/index.ts +25 -0
  49. package/src/parseGenUIProps.ts +59 -0
  50. package/src/prompt.ts +49 -0
  51. package/src/registry.ts +73 -0
  52. package/src/tools.ts +392 -0
@@ -0,0 +1,59 @@
1
+ import type { z } from 'zod'
2
+
3
+ import type { JsonUIElement } from './registry'
4
+
5
+ export type GenUIStylesConfig = Record<string, z.ZodTypeAny>
6
+
7
+ export type ParsedGenUIProps = {
8
+ baseStyle: Record<string, unknown>
9
+ text: string | undefined
10
+ label: string | undefined
11
+ props: Record<string, unknown>
12
+ }
13
+
14
+ export type ParseGenUIElementPropsOptions = {
15
+ nodeId?: string
16
+ type?: string
17
+ }
18
+
19
+ /**
20
+ * Parses a JSON UI element's props into a baseStyle object (validated by style validators)
21
+ * and common fields (text, label). Use this in custom GenUINode implementations to reuse
22
+ * the same style and prop parsing as the default renderer.
23
+ */
24
+ export function parseGenUIElementProps(
25
+ element: JsonUIElement,
26
+ styleValidators: GenUIStylesConfig,
27
+ options?: ParseGenUIElementPropsOptions
28
+ ): ParsedGenUIProps {
29
+ const { nodeId = '', type = '' } = options ?? {}
30
+ const props = element.props ?? {}
31
+ const style = props.style as Record<string, unknown> | undefined
32
+ const flex = props.flex as number | undefined
33
+ const padding = props.padding as number | undefined
34
+ const gap = props.gap as number | undefined
35
+ const text = (props.text ?? props.value ?? props.label) as string | undefined
36
+ const label = (props.label ?? props.text) as string | undefined
37
+
38
+ const baseStyle = {
39
+ ...(flex != null ? { flex } : {}),
40
+ ...(padding != null ? { padding } : {}),
41
+ ...(gap != null ? { gap } : {}),
42
+ ...style,
43
+ } as Record<string, unknown>
44
+
45
+ for (const key of Object.keys(props)) {
46
+ const validator = styleValidators[key]
47
+ if (validator) {
48
+ if (validator.safeParse(props[key]).success) {
49
+ baseStyle[key] = props[key]
50
+ } else {
51
+ console.warn(
52
+ `[json-ui-lite-rn] Invalid style prop: ${key} for node ${nodeId} of type ${type}: ${props[key]}`
53
+ )
54
+ }
55
+ }
56
+ }
57
+
58
+ return { baseStyle, text, label, props }
59
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { GEN_UI_STYLE_HINTS } from './registry'
2
+
3
+ type StyleHints = Record<
4
+ string,
5
+ {
6
+ type: 'number' | 'string'
7
+ description?: string
8
+ }
9
+ >
10
+
11
+ export type BuildGenUISystemPromptOptions = {
12
+ additionalInstructions?: string
13
+ requireLayoutReadBeforeAddingNodes?: boolean
14
+ styleHints?: StyleHints
15
+ }
16
+
17
+ export function buildGenUISystemPrompt({
18
+ additionalInstructions,
19
+ requireLayoutReadBeforeAddingNodes = true,
20
+ styleHints = GEN_UI_STYLE_HINTS,
21
+ }: BuildGenUISystemPromptOptions = {}) {
22
+ const styleKeysText = Object.entries(styleHints)
23
+ .map(([key, entry]) => {
24
+ let text = `${key} [${entry.type}]`
25
+ if (entry.description) text += ` (${entry.description})`
26
+ return text
27
+ })
28
+ .join(', ')
29
+
30
+ const parts = [
31
+ 'You are a helpful assistant.',
32
+ 'You have tools to create and update UI nodes. Before any tool calls for UI, ALWAYS CALL getUILayout before and after.',
33
+ 'Remember this is React Native, not web, and use simple props.',
34
+ `If you set the "style" prop on a UI node, the possible keys are: ${styleKeysText}.`,
35
+ 'Remember NEVER use web values.',
36
+ ]
37
+
38
+ if (requireLayoutReadBeforeAddingNodes) {
39
+ parts.push(
40
+ 'BEFORE ADDING ANY UI ELEMENTS, GET THE WHOLE UI TREE with getGenUILayout.'
41
+ )
42
+ }
43
+
44
+ if (additionalInstructions?.trim()) {
45
+ parts.push(additionalInstructions.trim())
46
+ }
47
+
48
+ return parts.join(' ')
49
+ }
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod'
2
+
3
+ export type JsonUIElement = {
4
+ type: string
5
+ props: Record<string, unknown>
6
+ children?: string[]
7
+ }
8
+
9
+ export type JsonUISpec = {
10
+ root: string
11
+ elements: Record<string, JsonUIElement>
12
+ }
13
+
14
+ export const DEFAULT_GEN_UI_ROOT_ID = 'root'
15
+
16
+ export const GEN_UI_NODE_NAMES = {
17
+ Text: 'Text',
18
+ Paragraph: 'Paragraph',
19
+ Label: 'Label',
20
+ Heading: 'Heading',
21
+ Button: 'Button',
22
+ TextInput: 'TextInput',
23
+ } as const
24
+
25
+ export const GEN_UI_NODE_NAMES_THAT_SUPPORT_CHILDREN: string[] = []
26
+
27
+ export const GEN_UI_NODE_HINTS: Record<keyof typeof GEN_UI_NODE_NAMES, string> =
28
+ {
29
+ Text: 'Single line text. Props: text [string], style [object].',
30
+ Paragraph: 'Long text. Props: text [string], style [object].',
31
+ Label: 'Small label. Props: text [string], style [object].',
32
+ Heading: 'Title text. Props: text [string], style [object].',
33
+ Button: 'Tap button. Props: text [string], style [object].',
34
+ TextInput:
35
+ 'Single line text input. Props: placeholder [string], style [object].',
36
+ }
37
+
38
+ export const GEN_UI_STYLES = {
39
+ flex: z.number(),
40
+ padding: z.number(),
41
+ gap: z.number(),
42
+ backgroundColor: z.string(),
43
+ color: z.string(),
44
+ fontSize: z.number(),
45
+ fontWeight: z.string(),
46
+ textAlign: z.string(),
47
+ }
48
+
49
+ export const GEN_UI_STYLE_HINTS: Record<
50
+ keyof typeof GEN_UI_STYLES,
51
+ { type: 'number' | 'string'; description?: string }
52
+ > = {
53
+ flex: { type: 'number', description: 'Flex grow/shrink basis value.' },
54
+ padding: {
55
+ type: 'number',
56
+ description: 'Padding in density-independent px.',
57
+ },
58
+ gap: {
59
+ type: 'number',
60
+ description: 'Spacing between children in density-independent px.',
61
+ },
62
+ backgroundColor: { type: 'string', description: 'React Native color value.' },
63
+ color: { type: 'string', description: 'Text color value.' },
64
+ fontSize: { type: 'number', description: 'Font size in points.' },
65
+ fontWeight: {
66
+ type: 'string',
67
+ description: 'Font weight string like "400", "600", "bold".',
68
+ },
69
+ textAlign: {
70
+ type: 'string',
71
+ description: 'Text alignment, for example "left", "center", "right".',
72
+ },
73
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,392 @@
1
+ import { tool } from 'ai'
2
+ import { z } from 'zod'
3
+
4
+ import {
5
+ DEFAULT_GEN_UI_ROOT_ID,
6
+ GEN_UI_NODE_HINTS,
7
+ GEN_UI_NODE_NAMES_THAT_SUPPORT_CHILDREN,
8
+ type JsonUISpec,
9
+ } from './registry'
10
+
11
+ /**
12
+ * Sometimes LLMs call tools with a string instead of an object.
13
+ */
14
+ function smartParse(
15
+ props: string | Record<string, unknown>
16
+ ): Record<string, unknown> {
17
+ return typeof props === 'string' ? JSON.parse(props) : props
18
+ }
19
+
20
+ const defaultCreateId = () =>
21
+ `UI-${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`
22
+
23
+ export type CreateGenTUIoolsOptions<TSpec extends JsonUISpec = JsonUISpec> = {
24
+ contextId: string
25
+ getSpec: (contextId: string) => TSpec | null
26
+ updateSpec: (contextId: string, spec: TSpec | null) => void
27
+ createId?: () => string
28
+ rootId?: string
29
+ nodeHints?: Record<string, string>
30
+ nodeNamesThatSupportChildren?: readonly string[]
31
+ toolWrapper?: <TArgs, TResult>(
32
+ toolName: string,
33
+ execute: (args: TArgs) => Promise<TResult>
34
+ ) => (args: TArgs) => Promise<TResult>
35
+ }
36
+
37
+ const cloneSpec = <TSpec extends JsonUISpec>(spec: TSpec): TSpec =>
38
+ ({
39
+ ...spec,
40
+ elements: Object.fromEntries(
41
+ Object.entries(spec.elements).map(([id, element]) => [
42
+ id,
43
+ {
44
+ ...element,
45
+ props: { ...(element.props ?? {}) },
46
+ children: [...(element.children ?? [])],
47
+ },
48
+ ])
49
+ ),
50
+ }) as TSpec
51
+
52
+ let mutationQueue: Promise<void> = Promise.resolve()
53
+
54
+ const withMutationLock = async <T>(run: () => Promise<T>): Promise<T> => {
55
+ let release: () => void = () => {}
56
+ const pending = new Promise<void>((resolve) => {
57
+ release = resolve
58
+ })
59
+ const previous = mutationQueue
60
+ mutationQueue = mutationQueue.then(() => pending)
61
+
62
+ await previous
63
+ try {
64
+ return await run()
65
+ } finally {
66
+ release()
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Creates generative UI tools that read/update a JSON UI spec.
72
+ */
73
+ export function createGenUITools<TSpec extends JsonUISpec = JsonUISpec>({
74
+ contextId,
75
+ getSpec,
76
+ updateSpec,
77
+ createId = defaultCreateId,
78
+ rootId = DEFAULT_GEN_UI_ROOT_ID,
79
+ nodeHints = GEN_UI_NODE_HINTS,
80
+ nodeNamesThatSupportChildren = GEN_UI_NODE_NAMES_THAT_SUPPORT_CHILDREN,
81
+ toolWrapper = (_, execute) => execute,
82
+ }: CreateGenTUIoolsOptions<TSpec>) {
83
+ // Serialize mutating tool calls to avoid interleaving writes.
84
+ let cachedSpec: TSpec | null = null
85
+
86
+ const readSpec = (): TSpec | null => {
87
+ if (cachedSpec) return cloneSpec(cachedSpec)
88
+ const spec = getSpec(contextId)
89
+ if (!spec) return null
90
+ cachedSpec = cloneSpec(spec)
91
+ return cloneSpec(cachedSpec)
92
+ }
93
+
94
+ const commitSpec = (spec: TSpec | null) => {
95
+ cachedSpec = spec ? cloneSpec(spec) : null
96
+ updateSpec(contextId, spec ? cloneSpec(spec) : null)
97
+ }
98
+
99
+ const getUIRootNode = tool({
100
+ description:
101
+ 'Get the root node of the generative UI tree. Returns id, type, props, and children (array of { id, type }). Root always exists with id "root".',
102
+ inputSchema: z.object({}),
103
+ execute: toolWrapper('getUIRootNode', async () => {
104
+ const spec = readSpec()
105
+ if (!spec?.root || !spec.elements[spec.root]) return { root: null }
106
+ const element = spec.elements[spec.root]
107
+ const children = (element.children ?? []).map((id) => ({
108
+ id,
109
+ type: spec.elements[id]?.type ?? 'unknown',
110
+ }))
111
+ return {
112
+ root: {
113
+ id: spec.root,
114
+ type: element.type,
115
+ props: element.props,
116
+ children,
117
+ },
118
+ }
119
+ }),
120
+ })
121
+
122
+ const getUINode = tool({
123
+ description:
124
+ 'Get a node by id. Returns id, type, props, and children (array of { id, type }). If id is omitted, returns root node.',
125
+ inputSchema: z.object({
126
+ id: z.string().optional().describe('Node id; omit for root'),
127
+ }),
128
+ execute: toolWrapper('getUINode', async ({ id }) => {
129
+ const spec = readSpec()
130
+ if (!spec) return { node: null }
131
+ const nodeId = id ?? spec.root
132
+ const element = spec.elements[nodeId]
133
+ if (!element) return { node: null }
134
+ const children = (element.children ?? []).map((childId) => ({
135
+ id: childId,
136
+ type: spec.elements[childId]?.type ?? 'unknown',
137
+ }))
138
+ return {
139
+ node: {
140
+ id: nodeId,
141
+ type: element.type,
142
+ props: element.props,
143
+ children,
144
+ },
145
+ }
146
+ }),
147
+ })
148
+
149
+ const getUILayout = tool({
150
+ description: 'Get compact UI layout.',
151
+ inputSchema: z.object({}),
152
+ execute: toolWrapper('getUILayout', async () => {
153
+ const spec = readSpec()
154
+ if (!spec) return { root: null, nodes: [] }
155
+
156
+ const parentByChild: Record<string, string | null> = {}
157
+ for (const [id, element] of Object.entries(spec.elements)) {
158
+ for (const childId of element.children ?? []) {
159
+ parentByChild[childId] = id
160
+ }
161
+ }
162
+
163
+ const nodes = Object.entries(spec.elements).map(([id, element]) => ({
164
+ id,
165
+ type: element.type,
166
+ parentId: parentByChild[id] ?? null,
167
+ children: element.children ?? [],
168
+ props: Object.keys(element.props ?? {}),
169
+ }))
170
+
171
+ return { root: spec.root, nodes }
172
+ }),
173
+ })
174
+
175
+ const getAvailableUINodes = tool({
176
+ description: 'List nodes + props.',
177
+ inputSchema: z.object({}),
178
+ execute: toolWrapper('getAvailableUINodes', async () => ({
179
+ nodes: Object.entries(nodeHints).map(([name, props]) => ({
180
+ name,
181
+ props,
182
+ })),
183
+ })),
184
+ })
185
+
186
+ const setUINodeProps = tool({
187
+ description: 'Set or add props for a node by id.',
188
+ inputSchema: z.object({
189
+ id: z.string().describe('Node id'),
190
+ props: z.string().describe('Props object for the node'),
191
+ replace: z.boolean().optional().describe('Replace existing props'),
192
+ }),
193
+ execute: toolWrapper(
194
+ 'setUINodeProps',
195
+ async ({ id, props: propsArg, replace = false }) =>
196
+ withMutationLock(async () => {
197
+ const parsedProps = smartParse(propsArg)
198
+ const spec = readSpec()
199
+ if (!spec) return { success: false, message: 'No UI spec' }
200
+ if (!spec.elements[id]) {
201
+ return { success: false, message: 'Node not found' }
202
+ }
203
+
204
+ const elements = { ...spec.elements }
205
+ const current = elements[id]
206
+ const nextProps = replace
207
+ ? parsedProps
208
+ : { ...current.props, ...parsedProps }
209
+
210
+ elements[id] = { ...current, props: nextProps }
211
+
212
+ commitSpec({ root: spec.root, elements } as TSpec)
213
+ return { success: true }
214
+ })
215
+ ),
216
+ })
217
+
218
+ const deleteUINode = tool({
219
+ description:
220
+ 'Delete a node by id. Cannot delete the root node (id "root"). Removes the node and its reference from the parent\'s children.',
221
+ inputSchema: z.object({
222
+ id: z.string().describe('Node id to delete'),
223
+ }),
224
+ execute: toolWrapper('deleteUINode', async ({ id }) =>
225
+ withMutationLock(async () => {
226
+ if (id === rootId) {
227
+ return { success: false, message: 'Cannot delete root node' }
228
+ }
229
+ const spec = readSpec()
230
+ if (!spec) return { success: false, message: 'No UI spec' }
231
+ const elements = { ...spec.elements }
232
+ delete elements[id]
233
+ for (const key of Object.keys(elements)) {
234
+ const element = elements[key]
235
+ if (element.children?.includes(id)) {
236
+ elements[key] = {
237
+ ...element,
238
+ children: element.children.filter((childId) => childId !== id),
239
+ }
240
+ }
241
+ }
242
+ commitSpec({ root: spec.root, elements } as TSpec)
243
+ return { success: true }
244
+ })
245
+ ),
246
+ })
247
+
248
+ const addUINode = tool({
249
+ description:
250
+ 'Add a new node as a child of parentId. Creates element with type and props. Returns new node id. Props must be a valid JSON object.',
251
+ inputSchema: z.object({
252
+ parentId: z.string().optional().describe('Parent node id; omit for root'),
253
+ type: z
254
+ .string()
255
+ .describe('Component type (e.g. Container, Column, Text, Button)'),
256
+ props: z.string().optional().describe('Props object for the node'),
257
+ }),
258
+ execute: toolWrapper(
259
+ 'addUINode',
260
+ async ({ parentId, type, props: propsArg }) =>
261
+ withMutationLock(async () => {
262
+ const parsedProps = smartParse(propsArg ?? '{}')
263
+ const spec = readSpec()
264
+ if (!spec) {
265
+ console.warn('[json-ui-lite-rn tool addNode] No UI spec, aborting')
266
+
267
+ return { success: false, message: 'No UI spec' }
268
+ }
269
+
270
+ parentId ??= spec.root
271
+
272
+ if (!spec.elements[parentId]) {
273
+ console.warn(
274
+ '[json-ui-lite-rn tool addNode] Parent not found, aborting'
275
+ )
276
+ return { success: false, message: 'Parent not found' }
277
+ }
278
+
279
+ const newId = createId()
280
+ spec.elements[newId] = {
281
+ type,
282
+ props: parsedProps ?? {},
283
+ children: [],
284
+ }
285
+ let parent = spec.elements[parentId]
286
+
287
+ if (!nodeNamesThatSupportChildren.includes(parent.type)) {
288
+ parent = spec.elements[spec.root]
289
+ parentId = spec.root
290
+ }
291
+
292
+ spec.elements[parentId].children ??= []
293
+ spec.elements[parentId].children!.push(newId)
294
+
295
+ commitSpec({
296
+ root: spec.root,
297
+ elements: spec.elements,
298
+ } as TSpec)
299
+ return { success: true, id: newId }
300
+ })
301
+ ),
302
+ })
303
+
304
+ const reorderUINodes = tool({
305
+ description:
306
+ 'Move one node among siblings by offset (negative = up, positive = down).',
307
+ inputSchema: z.object({
308
+ nodeId: z.string().describe('Node id to move'),
309
+ offset: z
310
+ .number()
311
+ .describe(
312
+ 'Relative index shift among siblings; negative moves earlier, positive moves later'
313
+ ),
314
+ }),
315
+ execute: toolWrapper('reorderUINodes', async ({ nodeId, offset }) =>
316
+ withMutationLock(async () => {
317
+ const spec = readSpec()
318
+ if (!spec) return { success: false, message: 'No UI spec' }
319
+
320
+ const findParentId = (childId: string) => {
321
+ for (const [id, element] of Object.entries(spec.elements)) {
322
+ if (element.children?.includes(childId)) return id
323
+ }
324
+ return null
325
+ }
326
+
327
+ const nodeParentId = findParentId(nodeId)
328
+ if (!nodeParentId) {
329
+ return {
330
+ success: false,
331
+ message: 'nodeId must exist and have a parent',
332
+ }
333
+ }
334
+
335
+ const parentId = nodeParentId
336
+ const parent = spec.elements[parentId]
337
+ if (!parent) return { success: false, message: 'Parent not found' }
338
+
339
+ const currentChildren = [...(parent.children ?? [])]
340
+ const nodeIndex = currentChildren.indexOf(nodeId)
341
+ if (nodeIndex === -1) {
342
+ return {
343
+ success: false,
344
+ message: 'nodeId must be a direct child',
345
+ }
346
+ }
347
+
348
+ if (offset === 0) {
349
+ return {
350
+ success: true,
351
+ parentId,
352
+ nodeId,
353
+ fromIndex: nodeIndex,
354
+ toIndex: nodeIndex,
355
+ appliedOffset: 0,
356
+ childIds: currentChildren,
357
+ }
358
+ }
359
+
360
+ const maxIndex = currentChildren.length - 1
361
+ const toIndex = Math.min(Math.max(nodeIndex + offset, 0), maxIndex)
362
+ currentChildren.splice(nodeIndex, 1)
363
+ currentChildren.splice(toIndex, 0, nodeId)
364
+
365
+ const elements = { ...spec.elements }
366
+ elements[parentId].children = currentChildren
367
+ commitSpec({ root: spec.root, elements } as TSpec)
368
+
369
+ return {
370
+ success: true,
371
+ parentId,
372
+ nodeId,
373
+ fromIndex: nodeIndex,
374
+ toIndex,
375
+ appliedOffset: toIndex - nodeIndex,
376
+ childIds: currentChildren,
377
+ }
378
+ })
379
+ ),
380
+ })
381
+
382
+ return {
383
+ getUIRootNode,
384
+ getUINode,
385
+ getUILayout,
386
+ getAvailableUINodes,
387
+ setUINodeProps,
388
+ deleteUINode,
389
+ addUINode,
390
+ reorderUINodes,
391
+ }
392
+ }