Skip to content

Repository files navigation

Expo React Native + json-render (Chat-Generated Interactive UI)

English | 中文

This project is an Expo Router (React Native) application demonstrating how to implement json-render (UITree + Catalog + Registry + Actions + DataBinding) on mobile, featuring:

  • Streaming UI generation in chat messages: assistant outputs mixed text + JSONL patches, rendering incrementally as data arrives
  • Guardrails: Catalog (Zod) constrains allowed components and props, preventing "model hallucination"
  • Interactive UI: buttons/inputs/checkboxes trigger actions executed by frontend handlers
  • Todo recommendation multi-select → submit to DB: users select recommended items in UI, click submit to write to SQLite/Drizzle, and send selection results back to model (USER_EVENT)

If you only want to know "how to run it" and "where core code is", check the "Quick Start" and "Key Files" sections below.


App Entry Points

This project uses Expo Router with Native Tabs:

  • Todo: Traditional Todo page (SQLite/Drizzle + React Query)
  • Chatbot: Chat page (connects to model, outputs mixed text + UI patches)
  • Lab: Developer playground (json-render demos + Runtime Component Studio + live system prompt preview)

Related routes:

  • src/app/(home)/todo/index.tsx
  • src/app/(home)/chat/index.tsx (chat page)
  • src/app/(home)/lab/index.tsx (Lab home)
  • src/app/(home)/lab/json-render/index.tsx (static rendering demo)
  • src/app/(home)/lab/json-render/stream.tsx (mock streaming demo)

Quick Start

1) Install Dependencies

This repository uses bun.lock:

bun install

2) Configure Model API Key (Required)

Copy the environment file and fill in your key:

cp .env.example .env

Required in .env:

GLM_API_KEY=your_key_here

The server-side API currently uses GLM (openai-compatible):

  • Entry: src/app/api/chat+api.ts
  • System prompt: src/lib/systemPrompt.ts

3) Run

bun run start

Optional:

bun run ios
bun run android
bun run web

Directory Structure (json-render Related Parts)

For complete implementation steps and acceptance criteria, see:

  • docs/json-render-mvp-plan.md
  • docs/dynamic-ui-builder-mvp-plan.md
  • src/components/json-render/
    • catalog.ts: Catalog (Zod) - guardrails (allowed components/props/actions)
    • registry.tsx: Registry - type -> React Component
    • components/: RN component implementations for each json-render block (Button/Card/Stack/TodoCard...)
    • demo/: Static and streaming demos
  • src/components/lab/RuntimeComponentStudio.tsx: Runtime component studio (generate/save/enable/preview)
  • src/lib/json-render/uitreeStream.ts: Patch application + utilities to build UITree from mixed text (JSONL patches)
  • src/hooks/useUITreeStream.ts: Hook wrapper for incremental parsing while streaming
  • src/lib/json-render/patchLine.ts: Single-line patch parsing (fault-tolerant: handles data:, trailing commas, ignores fences)
  • src/lib/runtime/tsxToComponent.ts: Runtime TSX -> JS compilation + execution (Babel standalone + sandboxed require)
  • src/lib/runtime/componentRegisterLine.ts: Parses COMPONENT_REGISTER ... lines from assistant output
  • src/lib/chat/types.ts: Structured chat model (ChatMessage / MessagePart / UIBlock)
  • src/components/chat/JsonRenderMessageCard.tsx: Renders persisted UIBlock messages (snapshot/live + committed/expired)
  • src/components/chat/AssistantJsonRender.tsx: Draft streaming renderer (mixed text + patches) used while assistant is still streaming
  • src/lib/systemPrompt.ts: System prompt builder (base prompt + enabled runtime components list)
  • src/app/api/chat+api.ts: Chat API (Vercel AI SDK streamText)
  • src/app/api/runtime-component+api.ts: Runtime component generation API (streams model output as plain text JSON)
  • src/providers/RuntimeComponentsProvider.tsx: Global runtime component store (enabled components + session components)
  • src/db/runtimeComponents.ts: SQLite/Drizzle CRUD for runtime_components

How json-render Works in This Project (Core Concepts)

1) UITree (flat tree)

json-render UI is not JSX, but a serializable UITree:

  • root: string
  • elements: Record<string, UIElement>
  • UIElement: { key, type, props?, children?, parentKey?, visible? }

Note: children is an element field, NOT a prop. If the model puts children in props, Catalog (strict mode) will reject it.

2) Catalog (guardrails, must align with Registry)

Catalog defines:

  • Which component types are allowed (type)
  • Props schema for each component (Zod)
  • Which actions are allowed, and params schema for each action

Defined in src/components/json-render/catalog.ts using validation: "strict".

3) Registry (type -> RN Component)

Registry maps element.type to actual RN components:

  • File: src/components/json-render/registry.tsx
  • Unknown types fall back to UnknownComponent to prevent entire message crash

4) Providers (Data / Actions / Visibility)

Rendering UITree typically requires:

  • DataProvider: Provides data model (for Input two-way binding, dynamic value rendering)
  • ActionProvider: Handles interactions like button clicks
  • VisibilityProvider: Supports conditional visible display (optional)

In chat scenarios, providers are composed inside chat renderers:

  • While streaming: renders draft UI via AssistantJsonRender (parses mixed text + patches).
  • After completion: stores a structured UIBlock in SQLite and renders it via JsonRenderMessageCard.

5) Streaming (JSONL patches)

The chat API returns a mixed stream:

  • Regular text lines: explanations for the user
  • JSON patch lines: one JSON object per line (JSONL), used to update UITree

Frontend parsing chain:

  1. src/lib/json-render/patchLine.ts: Determines if a line is a patch (with fault tolerance)
  2. src/lib/json-render/uitreeStream.ts: Applies patches to incrementally appended text to get tree (shared logic)
  3. src/hooks/useUITreeStream.ts: Hook wrapper for streaming scenarios
  4. Renderer tree={tree} registry={...}: Renders as RN UI

Supported patch ops:

  • set|add|replace: equivalent to "write value to path"
  • remove /elements/{key}: deletes element (this project cascades deletion to descendants and cleans parent children references)

Dynamic Personalized UI (Runtime Components)

This project extends json-render with Runtime Components to unlock personalized UI beyond the static registry.

Key idea:

  • AI can generate a custom component (TSX) at runtime.
  • The app compiles & executes it safely (dev builds only) and registers it by name.
  • The model can then reference it inside UITree using:
    • type: "RuntimeComponent"
    • props: { name: "<ComponentName>", props: { ... } }

Where to try it:

  • Open the Lab tab:
    • Manage components (save/delete/enable/preview)
    • See the live system prompt (enabled components are appended so the model can use them correctly)

Two ways components enter the app:

  • Studio generation API: src/app/api/runtime-component+api.ts produces a JSON payload (streamed as text) that is compiled and saved to SQLite.
  • Chat inline registration: assistant can emit COMPONENT_REGISTER {...} lines; the client registers & persists them at message completion.

For the full protocol, caveats, and gotchas, see docs/dynamic-ui-builder-mvp-plan.md.

Chat Message Model (UIBlock)

Chat history uses structured message parts, stored in SQLite (chat_messages.partsJson):

  • text: user-visible text (patch lines are stripped before persistence)
  • ui: a UIBlock with tree + dataSnapshot + status
  • user_event: structured events emitted from UI interactions

UIBlock.status lifecycle:

  • active: interactive
  • committed: read-only after confirm/submit
  • expired: read-only when user moves to the next turn

For one-time confirm buttons, the model can set:

  • emit_user_event payload includes __ui: { commit: true }

This signals the client to freeze the UI block and persist a snapshot.

How to Achieve "Todo Recommendation → Multi-Select → Submit to SQLite (with USER_EVENT Callback)"

This is the core of the "current effect".

Target Experience

User says: "Help me recommend some todos, let me multi-select and submit"

Assistant generates a card:

  • List contains recommended items (selectable)
  • After clicking Submit selected:
    • Writes selected items to SQLite (todos_table)
    • Todo tab / TodoCardLive refreshes in sync
    • Sends selection results back to model via USER_EVENT (so model "knows what you chose")

1) How is the recommendation list rendered?

The UI generated by the model uses TodoCard (not TodoCardLive):

  • TodoCard is a "locally controlled list": data stored in DataProvider at /todos
  • Model seeds recommendations via TodoCard.props.initialTodos (first render seeds to /todos)

Implementation:

  • TodoCard: src/components/json-render/components/TodoCard.tsx
    • When /todos is empty and initialTodos is provided, automatically calls setTodos(initialList)
  • Catalog constraints: src/components/json-render/catalog.ts

2) Why does checking toggle state?

Each item inside TodoCard uses TaskItem (checkbox UI), clicking triggers action:

  • todo_toggle: toggles completed for corresponding id in /todos

Handler registered in chat renderer (see ActionProvider below).

3) How does submit button "write to DB"?

Model generates a Button:

{ "name": "todo_submit_selected_to_db", "params": { "todosPath": "/todos" } }

The execution logic for this action is NOT on the model side, but on the frontend:

  • Handler: src/components/chat/JsonRenderMessageCard.tsx
    • Reads /todos from DataProvider
    • Filters items with completed=true
    • db.insert(todosTable).values(...) writes to SQLite (Drizzle)
    • invalidateQueries(["todos"]) triggers refresh
    • Sends { selected: [...] } back via emit_user_event

4) Why "callback USER_EVENT" instead of letting model write to DB directly?

Because the model runs on the server side (API), it cannot see what users selected in the UI; selection state is local state in the frontend DataProvider.

The correct approach is:

  • Model outputs UI and intent (action name + params)
  • Frontend executes intent (write DB / navigate / update local state)
  • Frontend sends execution results or user selections back to model via USER_EVENT (for model to continue next step)

This is also a core guardrail of json-render: actions are "declarative intents", not arbitrary code.


Teaching the Model to "Stably Generate Correct UI" (Prompt Engineering)

System prompt: src/lib/systemPrompt.ts

Here are some critical rules that significantly reduce "frequent errors":

  1. Strict Output Protocol
  • Patches must be "single-line pure JSON" (JSONL), do not mix explanatory text with JSON
  • Do not use markdown code fences (otherwise client may parse incorrectly)
  1. Structure Aligns with Catalog
  • children is on element, not on props
  • Only whitelist components and whitelist props allowed
  1. Key Uniqueness
  • element.key must be globally unique
  • Do not duplicate the same key reference in children array
  • Do not use unstable keys like $1/$2
  1. Few-Shot Examples

This project includes two example sets in the prompt:

  • TodoCardLive (direct DB sync)
  • TodoCard (recommendation multi-select + submit to DB)

Suggested: when adding new components/actions, also add a "minimum viable" few-shot example.


How to Add a New json-render Component (Best Practices)

Taking Badge as an example, recommended order:

  1. Implement RN component: src/components/json-render/components/Badge.tsx
  2. Register to registry: src/components/json-render/registry.tsx
  3. Add schema in catalog: src/components/json-render/catalog.ts
  4. Update system prompt whitelist + examples: src/lib/systemPrompt.ts
  5. Add element in demo page to verify: src/components/json-render/demo/demoTree.ts

Rules:

  • Component names in Catalog and Registry must match (string alignment)
  • With Catalog validation: "strict", extra/missing props fields cause validation failure (extra fields are especially common)

Troubleshooting

1) UI not displaying / "catalog rejected" message

Usually because the model's output element.props does not match schema in src/components/json-render/catalog.ts.

Suggestions:

  • Open raw chat message (Show raw) to see actual output
  • Check if children was mistakenly written into props
  • Check if type is in whitelist

2) Checkbox click "flashes and reverts"

Usually because ActionProvider's handler captured stale data (state closure).

This project fixes it via registerHandler(...) (ActionProvider initial handlers don't update with props):

  • src/components/chat/JsonRenderMessageCard.tsx
  • (draft streaming) src/components/chat/AssistantJsonRender.tsx

3) "Encountered two children with the same key"

Cause: duplicate keys in tree, or children array duplicates references.

Suggestions:

  • Make model use stable, readable keys
  • This project adds fault-tolerant deduplication for element.children in useUITreeStream, but don't rely on it

References

  • docs/json-render-mvp-plan.md: json-render MVP implementation guide for this repo (recommended reading first)
  • src/components/json-render/demo/*: Minimal examples for static and streaming
  • src/components/chat/JsonRenderMessageCard.tsx: Actual implementation of persisted chat UI blocks

About

AI-generated interactive UI in React Native using json-render framework with streaming JSONL patches, SQLite/Drizzle persistence, and user action callbacks

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages