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.
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.tsxsrc/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)
This repository uses bun.lock:
bun installCopy the environment file and fill in your key:
cp .env.example .envRequired in .env:
GLM_API_KEY=your_key_hereThe server-side API currently uses GLM (openai-compatible):
- Entry:
src/app/api/chat+api.ts - System prompt:
src/lib/systemPrompt.ts
bun run startOptional:
bun run ios
bun run android
bun run webFor complete implementation steps and acceptance criteria, see:
docs/json-render-mvp-plan.mddocs/dynamic-ui-builder-mvp-plan.md
src/components/json-render/catalog.ts: Catalog (Zod) - guardrails (allowed components/props/actions)registry.tsx: Registry -type -> React Componentcomponents/: 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 buildUITreefrom mixed text (JSONL patches)src/hooks/useUITreeStream.ts: Hook wrapper for incremental parsing while streamingsrc/lib/json-render/patchLine.ts: Single-line patch parsing (fault-tolerant: handlesdata:, trailing commas, ignores fences)src/lib/runtime/tsxToComponent.ts: Runtime TSX -> JS compilation + execution (Babel standalone + sandboxed require)src/lib/runtime/componentRegisterLine.ts: ParsesCOMPONENT_REGISTER ...lines from assistant outputsrc/lib/chat/types.ts: Structured chat model (ChatMessage/MessagePart/UIBlock)src/components/chat/JsonRenderMessageCard.tsx: Renders persistedUIBlockmessages (snapshot/live + committed/expired)src/components/chat/AssistantJsonRender.tsx: Draft streaming renderer (mixed text + patches) used while assistant is still streamingsrc/lib/systemPrompt.ts: System prompt builder (base prompt + enabled runtime components list)src/app/api/chat+api.ts: Chat API (Vercel AI SDKstreamText)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 forruntime_components
json-render UI is not JSX, but a serializable UITree:
root: stringelements: 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.
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".
Registry maps element.type to actual RN components:
- File:
src/components/json-render/registry.tsx - Unknown types fall back to
UnknownComponentto prevent entire message crash
Rendering UITree typically requires:
DataProvider: Provides data model (for Input two-way binding, dynamic value rendering)ActionProvider: Handles interactions like button clicksVisibilityProvider: Supports conditionalvisibledisplay (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
UIBlockin SQLite and renders it viaJsonRenderMessageCard.
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:
src/lib/json-render/patchLine.ts: Determines if a line is a patch (with fault tolerance)src/lib/json-render/uitreeStream.ts: Applies patches to incrementally appended text to gettree(shared logic)src/hooks/useUITreeStream.ts: Hook wrapper for streaming scenariosRenderer 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)
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
Labtab:- 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.tsproduces 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 history uses structured message parts, stored in SQLite (chat_messages.partsJson):
text: user-visible text (patch lines are stripped before persistence)ui: aUIBlockwithtree + dataSnapshot + statususer_event: structured events emitted from UI interactions
UIBlock.status lifecycle:
active: interactivecommitted: read-only after confirm/submitexpired: read-only when user moves to the next turn
For one-time confirm buttons, the model can set:
emit_user_eventpayload includes__ui: { commit: true }
This signals the client to freeze the UI block and persist a snapshot.
This is the core of the "current effect".
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")
- Writes selected items to SQLite (
The UI generated by the model uses TodoCard (not TodoCardLive):
TodoCardis 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
/todosis empty andinitialTodosis provided, automatically callssetTodos(initialList)
- When
- Catalog constraints:
src/components/json-render/catalog.ts
Each item inside TodoCard uses TaskItem (checkbox UI), clicking triggers action:
todo_toggle: togglescompletedfor corresponding id in/todos
Handler registered in chat renderer (see ActionProvider below).
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
/todosfrom DataProvider - Filters items with
completed=true db.insert(todosTable).values(...)writes to SQLite (Drizzle)invalidateQueries(["todos"])triggers refresh- Sends
{ selected: [...] }back viaemit_user_event
- Reads
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.
System prompt: src/lib/systemPrompt.ts
Here are some critical rules that significantly reduce "frequent errors":
- 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)
- Structure Aligns with Catalog
childrenis on element, not onprops- Only whitelist components and whitelist props allowed
- 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
- 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.
Taking Badge as an example, recommended order:
- Implement RN component:
src/components/json-render/components/Badge.tsx - Register to registry:
src/components/json-render/registry.tsx - Add schema in catalog:
src/components/json-render/catalog.ts - Update system prompt whitelist + examples:
src/lib/systemPrompt.ts - 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)
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
childrenwas mistakenly written intoprops - Check if type is in whitelist
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
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
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 streamingsrc/components/chat/JsonRenderMessageCard.tsx: Actual implementation of persisted chat UI blocks