mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7103ce519 | ||
|
|
43ad534482 | ||
|
|
ea97aff3e0 | ||
|
|
9af3f999e0 | ||
|
|
06b8745da7 | ||
|
|
ddae61805e | ||
|
|
f11283fa92 | ||
|
|
fd8c7a489f | ||
|
|
f8e39b30fe | ||
|
|
68ba7c6d7d | ||
|
|
7c4a6fed9a | ||
|
|
5b4fcaa349 | ||
|
|
2b3f3a723f |
@@ -1,8 +1,8 @@
|
||||
# json-render
|
||||
|
||||
**The framework for User-Generated Interfaces (UGI).**
|
||||
**The Generative UI framework.**
|
||||
|
||||
Dynamic, personalized UIs per user without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react
|
||||
@@ -14,7 +14,7 @@ npm install @json-render/core @json-render/remotion
|
||||
|
||||
## Why json-render?
|
||||
|
||||
json-render enables **User-Generated Interfaces**: dynamic UIs that end users create through natural language prompts, powered by Generative UI. You define the guardrails, AI generates within them:
|
||||
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them:
|
||||
|
||||
- **Guardrailed** - AI can only use components in your catalog
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
@@ -79,7 +79,7 @@ const { registry } = defineRegistry(catalog, {
|
||||
</div>
|
||||
),
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -116,15 +116,21 @@ function Dashboard({ spec }) {
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
import { schema } from "@json-render/react";
|
||||
|
||||
// Element tree spec format
|
||||
// Flat spec format (root key + elements map)
|
||||
const spec = {
|
||||
root: {
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: [
|
||||
{ type: "Button", props: { label: "Click me" } }
|
||||
]
|
||||
}
|
||||
root: "card-1",
|
||||
elements: {
|
||||
"card-1": {
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: ["button-1"],
|
||||
},
|
||||
"button-1": {
|
||||
type: "Button",
|
||||
props: { label: "Click me" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// defineRegistry creates a type-safe component registry
|
||||
@@ -213,12 +219,10 @@ const systemPrompt = catalog.prompt();
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Error occurred" },
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "path": "/form/hasError" },
|
||||
{ "not": { "path": "/form/errorDismissed" } }
|
||||
]
|
||||
}
|
||||
"visible": [
|
||||
{ "$state": "/form/hasError" },
|
||||
{ "$state": "/form/errorDismissed", "not": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -230,15 +234,15 @@ Any prop value can be data-driven using expressions:
|
||||
{
|
||||
"type": "Icon",
|
||||
"props": {
|
||||
"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
|
||||
"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
|
||||
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two expression forms:
|
||||
|
||||
- **`{ "$path": "/state/key" }`** - reads a value from the data model
|
||||
- **`{ "$state": "/state/key" }`** - reads a value from the state model
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
|
||||
|
||||
### Actions
|
||||
@@ -248,7 +252,7 @@ Components can trigger actions, including the built-in `setState` action:
|
||||
```json
|
||||
{
|
||||
"type": "Pressable",
|
||||
"props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
|
||||
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
|
||||
"children": ["home-icon"]
|
||||
}
|
||||
```
|
||||
@@ -269,6 +273,7 @@ pnpm dev
|
||||
- http://localhost:3000 - Docs & Playground
|
||||
- http://localhost:3001 - Example Dashboard
|
||||
- http://localhost:3002 - Remotion Video Example
|
||||
- Chat Example: run `pnpm dev` in `examples/chat`
|
||||
- React Native example: run `npx expo start` in `examples/react-native`
|
||||
|
||||
## How It Works
|
||||
@@ -285,7 +290,7 @@ flowchart LR
|
||||
```
|
||||
|
||||
1. **Define the guardrails** - what components, actions, and data bindings AI can use
|
||||
2. **Users generate** - end users describe what they want in natural language
|
||||
2. **Prompt** - describe what you want in natural language
|
||||
3. **AI generates JSON** - output is always predictable, constrained to your catalog
|
||||
4. **Render fast** - stream and render progressively as the model responds
|
||||
|
||||
|
||||
@@ -12,3 +12,7 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
# Automatically populated when you add Vercel KV to your project
|
||||
KV_REST_API_URL=
|
||||
KV_REST_API_TOKEN=
|
||||
|
||||
# Rate Limiting
|
||||
# RATE_LIMIT_PER_MINUTE=10
|
||||
# RATE_LIMIT_PER_DAY=100
|
||||
|
||||
@@ -65,7 +65,8 @@ A2UI uses an adjacency list model - a flat list of components with ID references
|
||||
## Define the A2UI Catalog
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
// A2UI BoundValue schema
|
||||
@@ -83,7 +84,7 @@ const Children = z.object({
|
||||
}).optional(),
|
||||
}).refine(d => d.explicitList || d.template);
|
||||
|
||||
export const a2uiCatalog = createCatalog({
|
||||
export const a2uiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Text: {
|
||||
description: 'Displays text content',
|
||||
|
||||
@@ -88,7 +88,8 @@ Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards h
|
||||
Define a catalog matching the Adaptive Cards element types:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common Adaptive Cards properties
|
||||
@@ -108,7 +109,7 @@ const BaseElement = {
|
||||
spacing: Spacing.optional(),
|
||||
};
|
||||
|
||||
export const adaptiveCardsCatalog = createCatalog({
|
||||
export const adaptiveCardsCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Root card
|
||||
AdaptiveCard: {
|
||||
|
||||
@@ -149,10 +149,11 @@ export type AGUIEvent = z.infer<typeof AGUIEvent>;
|
||||
Create a catalog for UI components that agents can render:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aguiCatalog = createCatalog({
|
||||
export const aguiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Container: {
|
||||
description: 'A container for grouping elements',
|
||||
|
||||
@@ -2,34 +2,37 @@ export const metadata = { title: "AI SDK Integration" }
|
||||
|
||||
# AI SDK Integration
|
||||
|
||||
Use json-render with the Vercel AI SDK for seamless streaming.
|
||||
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Generate** (standalone UI) and **Chat** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ai
|
||||
npm install ai @ai-sdk/react
|
||||
```
|
||||
|
||||
## API Route Setup
|
||||
## Generate Mode
|
||||
|
||||
In generate mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
|
||||
|
||||
### API Route
|
||||
|
||||
```typescript
|
||||
// app/api/generate/route.ts
|
||||
import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
import { streamText } from "ai";
|
||||
import { catalog } from "@/lib/catalog";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, currentTree } = await req.json();
|
||||
|
||||
// Generate system prompt from catalog
|
||||
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
|
||||
// Optionally include current UI state for context
|
||||
const contextPrompt = currentTree
|
||||
const contextPrompt = currentTree
|
||||
? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
|
||||
: '';
|
||||
: "";
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
model: yourModel,
|
||||
system: systemPrompt + contextPrompt,
|
||||
prompt,
|
||||
});
|
||||
@@ -38,60 +41,167 @@ export async function POST(req: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Hook
|
||||
### Client
|
||||
|
||||
Use `useUIStream` on the client:
|
||||
Use `useUIStream` on the client to compile the JSONL stream into a spec:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useUIStream, Renderer } from '@json-render/react';
|
||||
import { useUIStream, Renderer } from "@json-render/react";
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming, error, send } = useUIStream({
|
||||
api: '/api/generate',
|
||||
api: "/api/generate",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => send('Create a dashboard with metrics')}
|
||||
<button
|
||||
onClick={() => send("Create a dashboard with metrics")}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
{isStreaming ? 'Generating...' : 'Generate'}
|
||||
{isStreaming ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
|
||||
|
||||
{error && <p className="text-red-500">{error.message}</p>}
|
||||
|
||||
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Chat Mode
|
||||
|
||||
In chat mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
|
||||
|
||||
### API Route
|
||||
|
||||
Use `pipeJsonRender` to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up.
|
||||
|
||||
```typescript
|
||||
// app/api/chat/route.ts
|
||||
import { streamText } from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import {
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
} from "ai";
|
||||
import { catalog } from "@/lib/catalog";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: yourModel,
|
||||
system: catalog.prompt({ mode: "chat" }),
|
||||
messages,
|
||||
});
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
Use `useChat` from the AI SDK and `useJsonRenderMessage` from json-render to extract the spec from each message:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useJsonRenderMessage, Renderer } from "@json-render/react";
|
||||
|
||||
function Chat() {
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||
api: "/api/chat",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ask something..."
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) {
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{text && <p>{text}</p>}
|
||||
{hasSpec && spec && (
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt Engineering
|
||||
|
||||
The `catalog.prompt()` method creates an optimized system prompt that:
|
||||
|
||||
- Lists all available components and their props
|
||||
- Describes available actions
|
||||
- Specifies the expected JSON output format
|
||||
- Specifies the expected output format (JSONL-only or text + JSONL depending on mode)
|
||||
- Includes examples for better generation
|
||||
|
||||
## Custom System Prompts
|
||||
### Custom Rules
|
||||
|
||||
Pass custom rules to tailor AI behavior:
|
||||
|
||||
```typescript
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
'Always use Card components for grouping related content',
|
||||
'Prefer horizontal layouts (Row) for metrics',
|
||||
'Use consistent spacing with padding="md"',
|
||||
"Always use Card components for grouping related content",
|
||||
"Prefer horizontal layouts (Row) for metrics",
|
||||
"Use consistent spacing with padding=\"md\"",
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Chat Mode Prompt
|
||||
|
||||
```typescript
|
||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
```
|
||||
|
||||
In chat mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
|
||||
|
||||
## Which Mode?
|
||||
|
||||
| | Generate | Chat |
|
||||
|---|---|---|
|
||||
| Output | JSONL only | Text + JSONL |
|
||||
| Text-only replies | No | Yes |
|
||||
| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
|
||||
| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
|
||||
| Use case | Playgrounds, builders | Chatbots, copilots |
|
||||
|
||||
Learn more in the [Generation Modes](/docs/generation-modes) guide.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [progressive streaming](/docs/streaming).
|
||||
- Learn about [progressive streaming](/docs/streaming)
|
||||
- See the [chat example](https://github.com/vercel-labs/json-render/tree/main/examples/chat) for a complete implementation
|
||||
|
||||
@@ -13,12 +13,12 @@ Walk the UI spec depth-first.
|
||||
```typescript
|
||||
function traverseSpec(
|
||||
spec: Spec,
|
||||
visitor: SpecVisitor,
|
||||
visitor: TreeVisitor,
|
||||
startKey?: string
|
||||
): void
|
||||
|
||||
interface SpecVisitor {
|
||||
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||
interface TreeVisitor {
|
||||
(element: UIElement, key: string, depth: number, parent: UIElement | null): void;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -36,7 +36,7 @@ const components = collectUsedComponents(spec);
|
||||
|
||||
### collectStatePaths
|
||||
|
||||
Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
|
||||
Get all state paths referenced in props (statePath, bindPath, etc.).
|
||||
|
||||
```typescript
|
||||
function collectStatePaths(spec: Spec): Set<string>
|
||||
@@ -77,8 +77,8 @@ serializePropValue("hello")
|
||||
serializePropValue(42)
|
||||
// { value: '42', needsBraces: true }
|
||||
|
||||
serializePropValue({ path: 'user/name' })
|
||||
// { value: '{ path: "user/name" }', needsBraces: true }
|
||||
serializePropValue({ $state: '/user/name' })
|
||||
// { value: '{ $state: "/user/name" }', needsBraces: true }
|
||||
```
|
||||
|
||||
### serializeProps
|
||||
|
||||
@@ -72,8 +72,9 @@ interface Catalog {
|
||||
}
|
||||
|
||||
interface PromptOptions {
|
||||
system?: string; // Custom system message intro
|
||||
customRules?: string[]; // Additional rules to append
|
||||
system?: string; // Custom system message intro
|
||||
customRules?: string[]; // Additional rules to append
|
||||
mode?: "generate" | "chat"; // Output mode (default: "generate")
|
||||
}
|
||||
|
||||
interface SpecValidationResult<T> {
|
||||
@@ -203,28 +204,25 @@ Pre-built Zod schemas for common json-render types:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DynamicValueSchema, // string | number | boolean | null | { path: string }
|
||||
DynamicStringSchema, // string | { path: string }
|
||||
DynamicNumberSchema, // number | { path: string }
|
||||
DynamicBooleanSchema, // boolean | { path: string }
|
||||
DynamicValueSchema, // string | number | boolean | null | { $state: string }
|
||||
DynamicStringSchema, // string | { $state: string }
|
||||
DynamicNumberSchema, // number | { $state: string }
|
||||
DynamicBooleanSchema, // boolean | { $state: string }
|
||||
} from '@json-render/core';
|
||||
|
||||
// Dynamic values can be literals or data path references
|
||||
type DynamicValue<T> = T | { path: string };
|
||||
// Dynamic values can be literals or state path references
|
||||
type DynamicValue<T> = T | { $state: string };
|
||||
|
||||
// Example: a prop that can be a literal or bound to data
|
||||
// Example: a prop that can be a literal or bound to state
|
||||
const schema = z.object({
|
||||
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
|
||||
label: DynamicStringSchema, // "Hello" or { $state: "/user/name" }
|
||||
});
|
||||
```
|
||||
|
||||
### Visibility & Logic Schemas
|
||||
### Visibility Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
VisibilityConditionSchema, // Full visibility condition
|
||||
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
|
||||
} from '@json-render/core';
|
||||
import { VisibilityConditionSchema } from '@json-render/core';
|
||||
|
||||
// Use in component props that need conditional rendering
|
||||
const schema = z.object({
|
||||
@@ -304,6 +302,74 @@ const obj = {};
|
||||
applySpecStreamPatch(obj, patch);
|
||||
```
|
||||
|
||||
### applySpecPatch
|
||||
|
||||
Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec):
|
||||
|
||||
```typescript
|
||||
import { applySpecPatch } from '@json-render/core';
|
||||
|
||||
let spec: Spec = { root: "", elements: {} };
|
||||
applySpecPatch(spec, { op: "add", path: "/root", value: "main" });
|
||||
|
||||
// For React state updates, spread to create a new reference:
|
||||
setSpec({ ...applySpecPatch(spec, patch) });
|
||||
```
|
||||
|
||||
### nestedToFlat
|
||||
|
||||
Convert a nested element tree (with inline children) into the flat `Spec` format:
|
||||
|
||||
```typescript
|
||||
import { nestedToFlat } from '@json-render/core';
|
||||
|
||||
const flat = nestedToFlat({
|
||||
type: "Card",
|
||||
props: { title: "Hello" },
|
||||
children: [
|
||||
{ type: "Text", props: { content: "World" }, children: [] }
|
||||
],
|
||||
});
|
||||
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
|
||||
```
|
||||
|
||||
### createMixedStreamParser
|
||||
|
||||
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
|
||||
|
||||
```typescript
|
||||
import { createMixedStreamParser } from '@json-render/core';
|
||||
|
||||
const parser = createMixedStreamParser({
|
||||
onText: (text) => appendToMessage(text),
|
||||
onPatch: (patch) => applySpecPatch(spec, patch),
|
||||
});
|
||||
|
||||
// As chunks arrive from the stream:
|
||||
for await (const chunk of stream) {
|
||||
parser.push(chunk);
|
||||
}
|
||||
parser.flush();
|
||||
```
|
||||
|
||||
### pipeJsonRender
|
||||
|
||||
Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Chat mode API routes.
|
||||
|
||||
```typescript
|
||||
import { pipeJsonRender } from '@json-render/core';
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from 'ai';
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
```
|
||||
|
||||
See [Generation Modes](/docs/generation-modes) for full Chat mode setup.
|
||||
|
||||
### SpecStream Types
|
||||
|
||||
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
|
||||
@@ -322,6 +388,16 @@ interface SpecStreamCompiler<T> {
|
||||
getPatches(): SpecStreamLine[];
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
interface MixedStreamCallbacks {
|
||||
onText: (text: string) => void;
|
||||
onPatch: (patch: SpecStreamLine) => void;
|
||||
}
|
||||
|
||||
interface MixedStreamParser {
|
||||
push(chunk: string): void;
|
||||
flush(): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
@@ -332,10 +408,10 @@ interface SpecStreamCompiler<T> {
|
||||
import { getByPath, setByPath } from '@json-render/core';
|
||||
|
||||
// Get value by JSON Pointer path
|
||||
const value = getByPath(data, '/user/name'); // "Alice"
|
||||
const value = getByPath(state, '/user/name'); // "Alice"
|
||||
|
||||
// Set value by path (mutates object)
|
||||
setByPath(data, '/user/email', 'alice@example.com');
|
||||
setByPath(state, '/user/email', 'alice@example.com');
|
||||
```
|
||||
|
||||
### resolveDynamicValue
|
||||
@@ -343,9 +419,9 @@ setByPath(data, '/user/email', 'alice@example.com');
|
||||
```typescript
|
||||
import { resolveDynamicValue } from '@json-render/core';
|
||||
|
||||
// Resolve a dynamic value against data
|
||||
const name = resolveDynamicValue("Hello", data); // "Hello"
|
||||
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
|
||||
// Resolve a dynamic value against state
|
||||
const name = resolveDynamicValue("Hello", state); // "Hello"
|
||||
const name2 = resolveDynamicValue({ $state: "/user/name" }, state); // "Alice"
|
||||
```
|
||||
|
||||
### findFormValue
|
||||
@@ -354,32 +430,88 @@ const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
|
||||
import { findFormValue } from '@json-render/core';
|
||||
|
||||
// Find form values regardless of path format
|
||||
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
|
||||
const value = findFormValue("name", params, data);
|
||||
// Checks: params.name, params["form.name"], state["form.name"], state.form.name
|
||||
const value = findFormValue("name", params, state);
|
||||
```
|
||||
|
||||
## buildUserPrompt
|
||||
|
||||
Build structured user prompts for AI generation, with support for refinement and state context.
|
||||
|
||||
```typescript
|
||||
import { buildUserPrompt } from '@json-render/core';
|
||||
|
||||
function buildUserPrompt(options: UserPromptOptions): string
|
||||
|
||||
interface UserPromptOptions {
|
||||
prompt: string; // The user's text prompt
|
||||
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
|
||||
state?: Record<string, unknown> | null; // Runtime state context to include
|
||||
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
||||
}
|
||||
```
|
||||
|
||||
### Fresh generation
|
||||
|
||||
```typescript
|
||||
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
||||
```
|
||||
|
||||
### Refinement (patch-only mode)
|
||||
|
||||
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
|
||||
|
||||
```typescript
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt: "add a dark mode toggle",
|
||||
currentSpec: existingSpec,
|
||||
});
|
||||
```
|
||||
|
||||
### With state context
|
||||
|
||||
Include runtime state so the AI knows what data is available:
|
||||
|
||||
```typescript
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt: "show my data",
|
||||
state: { todos: [{ text: "Buy milk" }] },
|
||||
});
|
||||
```
|
||||
|
||||
## evaluateVisibility
|
||||
|
||||
Evaluates a visibility condition against data and auth state.
|
||||
Evaluates a visibility condition against the state model.
|
||||
|
||||
```typescript
|
||||
function evaluateVisibility(
|
||||
condition: VisibilityCondition | undefined,
|
||||
data: Record<string, unknown>,
|
||||
auth?: AuthState
|
||||
ctx: VisibilityContext
|
||||
): boolean
|
||||
|
||||
interface VisibilityContext {
|
||||
stateModel: StateModel;
|
||||
repeatItem?: unknown; // Current repeat item (inside repeat scope)
|
||||
repeatIndex?: number; // Current repeat array index (inside repeat scope)
|
||||
}
|
||||
|
||||
type VisibilityCondition =
|
||||
| { path: string }
|
||||
| { auth: 'signedIn' | 'signedOut' | string }
|
||||
| { and: VisibilityCondition[] }
|
||||
| { or: VisibilityCondition[] }
|
||||
| { not: VisibilityCondition }
|
||||
| { eq: [DynamicValue, DynamicValue] }
|
||||
| { gt: [DynamicValue, DynamicValue] }
|
||||
| { gte: [DynamicValue, DynamicValue] }
|
||||
| { lt: [DynamicValue, DynamicValue] }
|
||||
| { lte: [DynamicValue, DynamicValue] };
|
||||
| { $state: string } // truthiness
|
||||
| { $state: string; not: true } // falsy
|
||||
| { $state: string; eq: unknown } // equality
|
||||
| { $state: string; neq: unknown } // inequality
|
||||
| { $state: string; gt: number } // greater than
|
||||
| { $state: string; gte: number } // gte
|
||||
| { $state: string; lt: number } // lt
|
||||
| { $state: string; lte: number } // lte
|
||||
| { $item: string } // item field (repeat scope)
|
||||
| { $item: string; eq: unknown } // item field equality
|
||||
| { $index: true } // index truthiness (repeat scope)
|
||||
| { $index: true; gt: number } // index comparison
|
||||
| VisibilityCondition[] // implicit AND
|
||||
| { $and: VisibilityCondition[] } // explicit AND
|
||||
| { $or: VisibilityCondition[] } // OR
|
||||
| boolean; // always / never
|
||||
```
|
||||
|
||||
## Types
|
||||
@@ -388,32 +520,35 @@ type VisibilityCondition =
|
||||
|
||||
```typescript
|
||||
interface UIElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[]; // Keys of child elements
|
||||
visible?: VisibilityCondition;
|
||||
validation?: ValidationSchema;
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
|
||||
repeat?: { statePath: string; key?: string }; // Repeat for arrays
|
||||
}
|
||||
```
|
||||
|
||||
Elements are stored in the `elements` map keyed by string IDs. The key comes from the map, not from the element itself.
|
||||
|
||||
### Spec (Element Tree)
|
||||
|
||||
```typescript
|
||||
interface Spec {
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>;
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>; // Flat element map
|
||||
state?: Record<string, unknown>; // Initial state model
|
||||
}
|
||||
```
|
||||
|
||||
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
|
||||
|
||||
### Action
|
||||
### ActionBinding
|
||||
|
||||
```typescript
|
||||
interface Action {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
interface ActionBinding {
|
||||
action: string;
|
||||
params?: Record<string, DynamicValue>;
|
||||
confirm?: {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -433,7 +568,7 @@ interface ValidationSchema {
|
||||
}
|
||||
|
||||
interface ValidationCheck {
|
||||
fn: string;
|
||||
type: string;
|
||||
args?: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ React Native renderer with standard components, providers, and hooks.
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
|
||||
| `TextInput` | `placeholder`, `statePath`, `secure`, `keyboardType`, `multiline` | Text input field |
|
||||
| `Switch` | `statePath`, `label` | Toggle switch |
|
||||
| `Checkbox` | `statePath`, `label` | Checkbox with label |
|
||||
| `Slider` | `statePath`, `min`, `max`, `step` | Range slider |
|
||||
| `SearchBar` | `placeholder`, `statePath` | Search input |
|
||||
| `TextInput` | `placeholder`, `value` (use `$bindState`), `secure`, `keyboardType`, `multiline` | Text input field |
|
||||
| `Switch` | `checked` (use `$bindState`), `label` | Toggle switch |
|
||||
| `Checkbox` | `checked` (use `$bindState`), `label` | Checkbox with label |
|
||||
| `Slider` | `value` (use `$bindState`), `min`, `max`, `step` | Range slider |
|
||||
| `SearchBar` | `placeholder`, `value` (use `$bindState`) | Search input |
|
||||
|
||||
### Feedback
|
||||
|
||||
@@ -83,6 +83,8 @@ React Native renderer with standard components, providers, and hooks.
|
||||
</VisibilityProvider>
|
||||
```
|
||||
|
||||
Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
@@ -135,7 +137,9 @@ const { state, get, set, update } = useStateStore();
|
||||
const value = useStateValue(path: string);
|
||||
```
|
||||
|
||||
### useStateBinding
|
||||
### useStateBinding (deprecated)
|
||||
|
||||
> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useStateBinding(path: string);
|
||||
|
||||
@@ -27,24 +27,21 @@ type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
|
||||
### VisibilityProvider
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider auth={AuthState}>
|
||||
<VisibilityProvider>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
|
||||
interface AuthState {
|
||||
isSignedIn: boolean;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
<ValidationProvider functions={Record<string, ValidatorFn>}>
|
||||
<ValidationProvider customFunctions={Record<string, ValidationFunction>}>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
|
||||
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||
type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||
```
|
||||
|
||||
## defineRegistry
|
||||
@@ -58,7 +55,7 @@ const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => <div>{props.title}{children}</div>,
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -88,10 +85,11 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||
|
||||
```tsx
|
||||
interface ComponentContext<P> {
|
||||
props: P; // Typed props from catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit?: (event: string) => void; // Emit a named event
|
||||
props: P; // Typed props from catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event
|
||||
loading?: boolean;
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
}
|
||||
```
|
||||
|
||||
@@ -117,10 +115,10 @@ const {
|
||||
|
||||
```typescript
|
||||
const {
|
||||
data, // Record<string, unknown>
|
||||
setState, // (data: object) => void
|
||||
getValue, // (path: string) => unknown
|
||||
setValue, // (path: string, value: unknown) => void
|
||||
state, // StateModel (Record<string, unknown>)
|
||||
get, // (path: string) => unknown
|
||||
set, // (path: string, value: unknown) => void
|
||||
update, // (updates: Record<string, unknown>) => void
|
||||
} = useStateStore();
|
||||
```
|
||||
|
||||
@@ -130,7 +128,9 @@ const {
|
||||
const value = useStateValue(path: string);
|
||||
```
|
||||
|
||||
### useStateBinding
|
||||
### useStateBinding (deprecated)
|
||||
|
||||
> **Deprecated.** Use `useBoundProp` with `$bindState` expressions instead.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useStateBinding(path: string);
|
||||
@@ -139,15 +139,15 @@ const [value, setValue] = useStateBinding(path: string);
|
||||
### useActions
|
||||
|
||||
```typescript
|
||||
const { dispatch } = useActions();
|
||||
// dispatch(actionName: string, params: object)
|
||||
const { execute } = useActions();
|
||||
// execute(binding: ActionBinding) => Promise<void>
|
||||
```
|
||||
|
||||
### useAction
|
||||
|
||||
```typescript
|
||||
const submitForm = useAction('submit_form');
|
||||
// submitForm(params: object)
|
||||
const { execute, isLoading } = useAction(binding: ActionBinding);
|
||||
// execute() => Promise<void>
|
||||
```
|
||||
|
||||
### useIsVisible
|
||||
@@ -160,10 +160,88 @@ const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||
|
||||
```typescript
|
||||
const {
|
||||
value, // unknown
|
||||
setValue, // (value: unknown) => void
|
||||
state, // FieldValidationState
|
||||
validate, // () => ValidationResult
|
||||
touch, // () => void
|
||||
clear, // () => void
|
||||
errors, // string[]
|
||||
validate, // () => Promise<boolean>
|
||||
isValid, // boolean
|
||||
} = useFieldValidation(path: string, checks: ValidationCheck[]);
|
||||
} = useFieldValidation(path: string, config?: ValidationConfig);
|
||||
```
|
||||
|
||||
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||
|
||||
### useBoundProp
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useBoundProp<T>(
|
||||
propValue: T | undefined, // The already-resolved prop value
|
||||
bindingPath: string | undefined // From bindings?.value
|
||||
);
|
||||
```
|
||||
|
||||
Use inside registry components:
|
||||
|
||||
```tsx
|
||||
const Input: ComponentRenderer = ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
};
|
||||
```
|
||||
|
||||
### Chat Hooks
|
||||
|
||||
Two hooks are available for chat + GenUI, depending on your setup:
|
||||
|
||||
- **`useChatUI`** -- Self-contained chat hook with its own message state, fetch logic, and mixed stream parsing. Use when you want a standalone chat experience without the Vercel AI SDK.
|
||||
- **`useJsonRenderMessage`** -- Extracts spec + text from an AI SDK `UIMessage.parts` array. Use with the Vercel AI SDK's `useChat` for full AI SDK integration.
|
||||
|
||||
### useChatUI
|
||||
|
||||
Hook for chat + GenUI experiences. Manages a multi-turn conversation where each assistant message can contain both text and a json-render UI spec.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
messages, // ChatMessage[] - all messages in the conversation
|
||||
isStreaming, // boolean - true while streaming
|
||||
error, // Error | null
|
||||
send, // (text: string) => Promise<void>
|
||||
clear, // () => void - reset conversation
|
||||
} = useChatUI({
|
||||
api: string, // API endpoint
|
||||
onComplete?: (message: ChatMessage) => void, // Called when streaming completes
|
||||
onError?: (error: Error) => void, // Called on error
|
||||
});
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
spec: Spec | null;
|
||||
}
|
||||
```
|
||||
|
||||
### useJsonRenderMessage
|
||||
|
||||
Extract a spec and text content from an AI SDK message's `parts` array. Designed for integration with Vercel AI SDK's `useChat`.
|
||||
|
||||
```typescript
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(parts: DataPart[]);
|
||||
|
||||
// spec: Spec | null - compiled from JSONL patches in data parts
|
||||
// text: string - concatenated text parts
|
||||
// hasSpec: boolean - true when spec is non-null
|
||||
```
|
||||
|
||||
### buildSpecFromParts / getTextFromParts
|
||||
|
||||
Standalone utilities for extracting spec and text from AI SDK message parts (non-hook versions):
|
||||
|
||||
```typescript
|
||||
import { buildSpecFromParts, getTextFromParts } from '@json-render/react';
|
||||
|
||||
const spec = buildSpecFromParts(message.parts); // Spec | null
|
||||
const text = getTextFromParts(message.parts); // string
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ The catalog defines what AI can generate. It's your guardrail.
|
||||
|
||||
## What is a Catalog?
|
||||
|
||||
A catalog is a schema that defines:
|
||||
A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defines the grammar (how specs are structured), the catalog defines the vocabulary (what components and actions are available). It lists:
|
||||
|
||||
- **Components** — UI elements AI can create (with props and optional slots)
|
||||
- **Actions** — Operations AI can trigger
|
||||
@@ -14,9 +14,11 @@ A catalog is a schema that defines:
|
||||
|
||||
## Creating a Catalog
|
||||
|
||||
`defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
@@ -35,7 +37,7 @@ const catalog = defineCatalog(schema, {
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(), // JSON Pointer to data
|
||||
value: z.union([z.string(), z.number()]),
|
||||
format: z.enum(['currency', 'percent', 'number']),
|
||||
}),
|
||||
description: "Display a single metric value",
|
||||
|
||||
@@ -4,6 +4,146 @@ export const metadata = { title: "Changelog" }
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.6.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: Chat Mode (Inline GenUI)
|
||||
|
||||
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
|
||||
|
||||
```typescript
|
||||
// Generate mode (default) — AI outputs only JSONL
|
||||
const prompt = catalog.prompt();
|
||||
|
||||
// Chat mode — AI outputs text + JSONL inline
|
||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
```
|
||||
|
||||
On the server, `pipeJsonRender()` separates text from JSONL patches in a mixed stream:
|
||||
|
||||
```typescript
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
```
|
||||
|
||||
On the client, `useJsonRenderMessage` extracts the spec and text from message parts:
|
||||
|
||||
```tsx
|
||||
import { useJsonRenderMessage } from "@json-render/react";
|
||||
|
||||
function ChatMessage({ message }) {
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
return (
|
||||
<div>
|
||||
{text && <Markdown>{text}</Markdown>}
|
||||
{hasSpec && <Renderer spec={spec} registry={registry} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### New: AI SDK Integration
|
||||
|
||||
First-class Vercel AI SDK support with typed data parts and stream utilities.
|
||||
|
||||
- `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads)
|
||||
- `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering
|
||||
- `createJsonRenderTransform()` low-level TransformStream for custom pipelines
|
||||
- `createMixedStreamParser()` for parsing mixed text + JSONL streams
|
||||
|
||||
### New: Two-Way Binding
|
||||
|
||||
Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state without custom `valuePath` props.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useBoundProp } from "@json-render/react";
|
||||
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
### New: Expression-Based Props and Visibility
|
||||
|
||||
All dynamic expressions now use structured `$state`, `$item`, and `$index` objects instead of string token rewriting. This is simpler, more explicit, and works for both props and visibility conditions.
|
||||
|
||||
**Props:**
|
||||
|
||||
```json
|
||||
{ "title": { "$state": "/user/name" } }
|
||||
{ "label": { "$item": "title" } }
|
||||
{ "position": { "$index": true } }
|
||||
```
|
||||
|
||||
**Visibility:**
|
||||
|
||||
```json
|
||||
{ "$state": "/isAdmin" }
|
||||
{ "$state": "/role", "eq": "admin" }
|
||||
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
|
||||
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
|
||||
{ "$item": "isActive" }
|
||||
{ "$index": true, "gt": 0 }
|
||||
```
|
||||
|
||||
Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`.
|
||||
|
||||
### New: React Chat Hooks
|
||||
|
||||
- `useChatUI()` — full chat hook with message history, streaming, and spec extraction
|
||||
- `useJsonRenderMessage()` — extract spec + text from a message's parts array
|
||||
- `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts
|
||||
- `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem`
|
||||
|
||||
### New: Chat Example
|
||||
|
||||
Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming inline UI generation.
|
||||
|
||||
### Improved: Renderer Performance
|
||||
|
||||
- `ElementRenderer` is now `React.memo`'d for better performance with repeat lists
|
||||
- `emit` is always defined (never `undefined`)
|
||||
- Repeat scope passes the actual item object, eliminating string token rewriting
|
||||
|
||||
### Improved: Utilities
|
||||
|
||||
- `applySpecPatch()` — typed wrapper for applying a single patch to a Spec
|
||||
- `nestedToFlat()` — convert nested tree specs to flat format
|
||||
- `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `{ $path }` and `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }` in props
|
||||
- Visibility: `{ path }` -> `{ $state }`, `{ and/or/not }` -> `{ $and/$or }` with `not` as operator flag
|
||||
- `DynamicValue`: `{ path: string }` -> `{ $state: string }`
|
||||
- `repeat.path` -> `repeat.statePath`
|
||||
- Action params: `path` -> `statePath` in setState action
|
||||
- `actionHandlers` -> `handlers` on `JSONUIProvider` / `ActionProvider`
|
||||
- `AuthState` and `{ auth }` visibility conditions removed (model auth as regular state)
|
||||
- Legacy catalog API removed: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`
|
||||
- React exports removed: `createRendererFromCatalog`, `rewriteRepeatTokens`
|
||||
- Codegen: `traverseTree` -> `traverseSpec`
|
||||
|
||||
See the [Migration Guide](/docs/migration) for detailed upgrade instructions.
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0
|
||||
|
||||
February 2026
|
||||
@@ -40,7 +180,7 @@ Components now use `emit` to fire named events instead of directly dispatching a
|
||||
```tsx
|
||||
// Component emits a named event
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>{props.label}</button>
|
||||
<button onClick={() => emit("press")}>{props.label}</button>
|
||||
),
|
||||
|
||||
// Element spec maps events to actions
|
||||
@@ -53,12 +193,12 @@ Button: ({ props, emit }) => (
|
||||
|
||||
### New: Repeat/List Rendering
|
||||
|
||||
Elements can now iterate over state arrays using the `repeat` field. Child elements use `$item` and `$index` tokens in `$path` expressions to reference the current item.
|
||||
Elements can now iterate over state arrays using the `repeat` field. Child elements use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current array index.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "path": "/posts", "key": "id" },
|
||||
"repeat": { "statePath": "/posts", "key": "id" },
|
||||
"children": ["post-card"]
|
||||
}
|
||||
```
|
||||
@@ -66,7 +206,7 @@ Elements can now iterate over state arrays using the `repeat` field. Child eleme
|
||||
```json
|
||||
{
|
||||
"type": "Card",
|
||||
"props": { "title": { "$path": "$item/title" } }
|
||||
"props": { "title": { "$item": "title" } }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -94,13 +234,13 @@ Validate spec structure and auto-fix common issues:
|
||||
```typescript
|
||||
import { validateSpec, autoFixSpec } from "@json-render/core";
|
||||
|
||||
const { valid, issues } = validateSpec(spec, catalog);
|
||||
const { valid, issues } = validateSpec(spec);
|
||||
const fixed = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
### Improved: State Management
|
||||
|
||||
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$path` expressions, and the built-in `setState` action updates state directly.
|
||||
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$state` expressions, and the built-in `setState` action updates state directly.
|
||||
|
||||
### Improved: AI Prompts
|
||||
|
||||
|
||||
@@ -70,12 +70,12 @@ The exported components are standalone with no json-render dependencies. They re
|
||||
// Generated component (standalone)
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
valuePath: string;
|
||||
statePath: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function Metric({ label, valuePath, data }: MetricProps) {
|
||||
const value = data ? getByPath(data, valuePath) : undefined;
|
||||
export function Metric({ label, statePath, data }: MetricProps) {
|
||||
const value = data ? getByPath(data, statePath) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
@@ -92,8 +92,8 @@ export function Metric({ label, valuePath, data }: MetricProps) {
|
||||
```typescript
|
||||
import { traverseSpec } from '@json-render/codegen';
|
||||
|
||||
traverseSpec(spec, (element, depth, parent) => {
|
||||
console.log(' '.repeat(depth * 2) + element.type);
|
||||
traverseSpec(spec, (element, key, depth, parent) => {
|
||||
console.log(' '.repeat(depth * 2) + `${key}: ${element.type}`);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -41,13 +41,13 @@ Start by defining the JSON structure your system will use. Here's an example of
|
||||
|
||||
## 2. Create the Catalog
|
||||
|
||||
Define a catalog that describes your components and validates props:
|
||||
Define a catalog that describes your components and validates props using `defineCatalog` — see [Catalog](/docs/catalog).
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const dashboardCatalog = createCatalog({
|
||||
export const dashboardCatalog = defineCatalog(mySchema, {
|
||||
components: {
|
||||
metric: {
|
||||
description: 'Displays a single metric value',
|
||||
@@ -240,11 +240,9 @@ const response = await generateText({
|
||||
|
||||
## 6. Validate Specs
|
||||
|
||||
Validate incoming specs against your schema:
|
||||
Validate incoming specs against your schema. Use `catalog.validate()` to check AI output against the catalog's Zod schema:
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
|
||||
function validateDashboard(spec: unknown) {
|
||||
// Validate root structure
|
||||
const rootResult = DashboardSchema.safeParse(spec);
|
||||
@@ -252,19 +250,13 @@ function validateDashboard(spec: unknown) {
|
||||
return { valid: false, errors: rootResult.error.errors };
|
||||
}
|
||||
|
||||
// Validate each widget against catalog
|
||||
const errors: string[] = [];
|
||||
for (const widget of rootResult.data.widgets) {
|
||||
const result = validate(
|
||||
{ type: widget.type, props: widget },
|
||||
dashboardCatalog
|
||||
);
|
||||
if (!result.valid) {
|
||||
errors.push(...result.errors.map(e => `${widget.type}: ${e}`));
|
||||
}
|
||||
// Validate each widget's props against the catalog
|
||||
const result = dashboardCatalog.validate(spec);
|
||||
if (!result.success) {
|
||||
return { valid: false, errors: result.error.errors };
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,123 +2,264 @@ export const metadata = { title: "Data Binding" }
|
||||
|
||||
# Data Binding
|
||||
|
||||
Connect UI components to your application data using JSON Pointer paths.
|
||||
Connect UI elements to dynamic data using expressions in your JSON specs.
|
||||
|
||||
## State Model
|
||||
|
||||
Every spec can include a `state` object that holds the data your UI reads from:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "greeting",
|
||||
"elements": {
|
||||
"greeting": {
|
||||
"type": "Text",
|
||||
"props": { "content": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"user": { "name": "Alice" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
State can also be provided programmatically at runtime. In `@json-render/react`, this is done via `StateProvider` and hooks like `useStateStore`. See the [React API reference](/docs/api/react) for details.
|
||||
|
||||
## JSON Pointer Paths
|
||||
|
||||
json-render uses JSON Pointer (RFC 6901) for data paths:
|
||||
All paths in json-render follow JSON Pointer (RFC 6901). A path is a string of `/`-separated tokens starting from the root:
|
||||
|
||||
```json
|
||||
// Given this data:
|
||||
```
|
||||
Given this state:
|
||||
{
|
||||
"user": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"metrics": {
|
||||
"revenue": 125000,
|
||||
"growth": 0.15
|
||||
}
|
||||
"user": { "name": "Alice", "email": "alice@example.com" },
|
||||
"todos": [
|
||||
{ "title": "Buy milk", "done": false },
|
||||
{ "title": "Walk dog", "done": true }
|
||||
]
|
||||
}
|
||||
|
||||
// These paths access:
|
||||
"/user/name" -> "Alice"
|
||||
"/metrics/revenue" -> 125000
|
||||
"/metrics/growth" -> 0.15
|
||||
"/user/name" -> "Alice"
|
||||
"/user/email" -> "alice@example.com"
|
||||
"/todos/0/title" -> "Buy milk"
|
||||
"/todos/1/done" -> true
|
||||
```
|
||||
|
||||
## StateProvider
|
||||
## Expressions
|
||||
|
||||
Wrap your app with StateProvider to enable data binding:
|
||||
Expressions are special objects you place in props to read dynamic values instead of hardcoding them. There are six expression types.
|
||||
|
||||
```tsx
|
||||
import { StateProvider } from '@json-render/react';
|
||||
### `$state` — Read from state
|
||||
|
||||
function App() {
|
||||
const initialState = {
|
||||
user: { name: 'Alice' },
|
||||
form: { email: '', message: '' },
|
||||
};
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
{/* Your UI */}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Reading Data
|
||||
|
||||
Use `useStateValue` for read-only access:
|
||||
|
||||
```tsx
|
||||
import { useStateValue } from '@json-render/react';
|
||||
|
||||
function UserGreeting() {
|
||||
const name = useStateValue('/user/name');
|
||||
return <h1>Hello, {name}!</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
## Two-Way Binding
|
||||
|
||||
Use `useStateBinding` for read-write access:
|
||||
|
||||
```tsx
|
||||
import { useStateBinding } from '@json-render/react';
|
||||
|
||||
function EmailInput() {
|
||||
const [email, setEmail] = useStateBinding('/form/email');
|
||||
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
value={email || ''}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the State Context
|
||||
|
||||
Access the full state context for advanced use cases:
|
||||
|
||||
```tsx
|
||||
import { useStateStore } from '@json-render/react';
|
||||
|
||||
function StateDebugger() {
|
||||
const { data, setState, getValue, setValue } = useStateStore();
|
||||
|
||||
// Read any path
|
||||
const revenue = getValue('/metrics/revenue');
|
||||
|
||||
// Write any path
|
||||
const updateRevenue = () => setValue('/metrics/revenue', 150000);
|
||||
|
||||
// Replace all state
|
||||
const resetState = () => setState({ user: {}, form: {} });
|
||||
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
}
|
||||
```
|
||||
|
||||
## In JSON UI Trees
|
||||
|
||||
AI can reference data paths in component props:
|
||||
Use `{ "$state": "/path" }` in any prop to read a value from the state model:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "/metrics/revenue",
|
||||
"format": "currency"
|
||||
"title": { "$state": "/user/name" },
|
||||
"subtitle": { "$state": "/user/email" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
If state contains `{ "user": { "name": "Alice", "email": "alice@example.com" } }`, the Card renders with title "Alice" and subtitle "alice@example.com".
|
||||
|
||||
### `$item` — Read from the current repeat item
|
||||
|
||||
Use `{ "$item": "field" }` inside a [repeat](#repeat) to read a field from the current array item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": { "content": { "$item": "title" } },
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "$item": "" }` to get the entire item object.
|
||||
|
||||
### `$index` — Current repeat index
|
||||
|
||||
Use `{ "$index": true }` inside a [repeat](#repeat) to get the current array index (zero-based number):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": { "content": { "$index": true } },
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
## Repeat
|
||||
|
||||
The `repeat` field on an element renders its children once per item in a state array. It is a top-level field on the element, sibling of `type`, `props`, and `children` — not inside `props`.
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "todo-list",
|
||||
"elements": {
|
||||
"todo-list": {
|
||||
"type": "Column",
|
||||
"props": { "gap": 8 },
|
||||
"repeat": { "statePath": "/todos", "key": "id" },
|
||||
"children": ["todo-item"]
|
||||
},
|
||||
"todo-item": {
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": { "$item": "title" },
|
||||
"subtitle": { "$item": "description" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"todos": [
|
||||
{ "id": "1", "title": "Buy milk", "description": "2% or whole" },
|
||||
{ "id": "2", "title": "Walk dog", "description": "Around the park" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `repeat.statePath` — JSON Pointer to the state array
|
||||
- `repeat.key` — field name on each item to use as a stable key for rendering
|
||||
|
||||
Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on.
|
||||
|
||||
## Two-Way Binding with `$bindState`
|
||||
|
||||
Form components use `{ "$bindState": "/path" }` on their natural value prop for two-way binding. The component reads from and writes to the state path.
|
||||
|
||||
### Value prop (text inputs)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "TextInput",
|
||||
"props": {
|
||||
"value": { "$bindState": "/form/email" },
|
||||
"placeholder": "Enter your email"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Checked prop (switches, checkboxes)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Switch",
|
||||
"props": {
|
||||
"label": "Enable notifications",
|
||||
"checked": { "$bindState": "/settings/notifications" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Pressed prop (toggle buttons)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ToggleButton",
|
||||
"props": {
|
||||
"label": "Bold",
|
||||
"pressed": { "$bindState": "/editor/bold" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
## Two-Way Binding with `$bindItem`
|
||||
|
||||
Inside a repeat scope, use `{ "$bindItem": "field" }` to bind to a field on the current item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Switch",
|
||||
"props": {
|
||||
"label": "Done",
|
||||
"checked": { "$bindItem": "completed" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Use `{ "$bindItem": "" }` to bind to the entire item.
|
||||
|
||||
`statePath` is not used for component binding. It remains for `repeat.statePath` (array iteration path) and action params like `setState.statePath` (target path for mutations).
|
||||
|
||||
## Conditional Props
|
||||
|
||||
Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Badge",
|
||||
"props": {
|
||||
"label": {
|
||||
"$cond": { "$state": "/user/isAdmin" },
|
||||
"$then": "Admin",
|
||||
"$else": "Member"
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Expression</th>
|
||||
<th>Syntax</th>
|
||||
<th>Context</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>{"$state"}</code></td>
|
||||
<td><code>{'{ "$state": "/path" }'}</code></td>
|
||||
<td>Anywhere</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$item"}</code></td>
|
||||
<td><code>{'{ "$item": "field" }'}</code></td>
|
||||
<td>Inside repeat only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$index"}</code></td>
|
||||
<td><code>{'{ "$index": true }'}</code></td>
|
||||
<td>Inside repeat only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$cond"}</code></td>
|
||||
<td><code>{'{ "$cond": ..., "$then": ..., "$else": ... }'}</code></td>
|
||||
<td>Anywhere</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$bindState"}</code></td>
|
||||
<td><code>{'{ "$bindState": "/path" }'}</code></td>
|
||||
<td>Form components (value, checked, pressed)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{"$bindItem"}</code></td>
|
||||
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||
<td>Form components inside repeat</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [actions](/docs/actions) for user interactions.
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
- [Action handlers](/docs/registry#action-handlers) — respond to user interactions
|
||||
- [React API reference](/docs/api/react) — React-specific hooks for programmatic state access
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
export const metadata = { title: "Generation Modes" }
|
||||
|
||||
# Generation Modes
|
||||
|
||||
json-render supports two modes for AI-generated UI: **Generate mode** for standalone UI and **Chat mode** for inline UI within a conversation.
|
||||
|
||||
The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
|
||||
|
||||
<GenerationModesDiagram />
|
||||
|
||||
## Generate Mode (Standalone)
|
||||
|
||||
In generate mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
|
||||
|
||||
This is the default mode and is ideal for:
|
||||
|
||||
- Playground and builder tools
|
||||
- Form generators
|
||||
- Dashboard builders
|
||||
- Any UI where the generated interface is the whole response
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
|
||||
// Generate mode is the default (no mode option needed)
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
"Use Card as root for forms and small UIs.",
|
||||
"Use Grid for multi-column layouts.",
|
||||
],
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-haiku-4.5",
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
});
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
On the client, use `useUIStream` from `@json-render/react` or the lower-level `createSpecStreamCompiler` from `@json-render/core` to compile the JSONL stream into a spec:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from "@json-render/react";
|
||||
|
||||
function Playground() {
|
||||
const { spec, isStreaming, send } = useUIStream({
|
||||
api: "/api/generate",
|
||||
});
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example output
|
||||
|
||||
The AI outputs only JSONL — one patch per line, no surrounding text:
|
||||
|
||||
```
|
||||
{"op":"add","path":"/root","value":"card-1"}
|
||||
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Sign In"},"children":["email","password","submit"]}}
|
||||
{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email"}}}
|
||||
{"op":"add","path":"/elements/password","value":{"type":"Input","props":{"label":"Password","name":"password","type":"password"}}}
|
||||
{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
|
||||
```
|
||||
|
||||
## Chat Mode (Inline)
|
||||
|
||||
In chat mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
|
||||
|
||||
This is ideal for:
|
||||
|
||||
- AI chatbots with rich UI responses
|
||||
- Copilot experiences
|
||||
- Educational assistants
|
||||
- Any conversational interface where generated UI is embedded in chat messages
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import { streamText } from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||
|
||||
// Enable chat mode
|
||||
const systemPrompt = catalog.prompt({ mode: "chat" });
|
||||
|
||||
const result = streamText({
|
||||
model: yourModel,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
});
|
||||
|
||||
// In your API route, pipe the stream through pipeJsonRender
|
||||
// to separate text from JSONL patches
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
```
|
||||
|
||||
`pipeJsonRender` inspects each line of the AI's response. Lines that parse as JSONL patches are emitted as `data-spec` parts (which the renderer picks up). Everything else is passed through as text.
|
||||
|
||||
### Client
|
||||
|
||||
On the client, use `useJsonRenderMessage` from `@json-render/react` to extract the spec from a chat message's parts:
|
||||
|
||||
```tsx
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useJsonRenderMessage } from "@json-render/react";
|
||||
|
||||
function Chat() {
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
{/* input form */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message }) {
|
||||
const { spec } = useJsonRenderMessage(message.parts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Render text parts */}
|
||||
{message.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p, i) => <p key={i}>{p.text}</p>)}
|
||||
|
||||
{/* Render the generated UI inline */}
|
||||
{spec && (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Example output
|
||||
|
||||
The AI writes a brief explanation, then JSONL patches on their own lines:
|
||||
|
||||
```
|
||||
Here's a dashboard showing the latest crypto prices:
|
||||
|
||||
{"op":"add","path":"/root","value":"dashboard"}
|
||||
{"op":"add","path":"/state/prices","value":[{"name":"Bitcoin","price":98450},{"name":"Ethereum","price":3120}]}
|
||||
{"op":"add","path":"/elements/dashboard","value":{"type":"Grid","props":{"columns":"2"},"children":["btc","eth"]}}
|
||||
{"op":"add","path":"/elements/btc","value":{"type":"Metric","props":{"label":"Bitcoin","value":{"$state":"/prices/0/price"}}}}
|
||||
{"op":"add","path":"/elements/eth","value":{"type":"Metric","props":{"label":"Ethereum","value":{"$state":"/prices/1/price"}}}}
|
||||
```
|
||||
|
||||
If the user asks a simple question ("what does BTC stand for?"), the AI replies with text only — no JSONL.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Generate</th>
|
||||
<th>Chat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Output format</td>
|
||||
<td>JSONL only</td>
|
||||
<td>Text + JSONL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Text-only replies</td>
|
||||
<td>No</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System prompt</td>
|
||||
<td><code>{"catalog.prompt()"}</code></td>
|
||||
<td><code>{'catalog.prompt({ mode: "chat" })'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stream utility</td>
|
||||
<td><code>{"useUIStream"}</code></td>
|
||||
<td><code>{"pipeJsonRender"}</code>{" + "}<code>{"useJsonRenderMessage"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Typical use case</td>
|
||||
<td>Playground, builders</td>
|
||||
<td>Chatbots, copilots</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Both modes use the same JSONL patch format (RFC 6902) and the same catalog/registry system. The only difference is whether the AI is allowed to include prose alongside the patches.
|
||||
|
||||
## Next
|
||||
|
||||
- Learn about the [JSONL streaming format](/docs/streaming)
|
||||
- See the [AI SDK integration](/docs/ai-sdk) for setup with the Vercel AI SDK
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||
import { CopyPageButton } from "@/components/copy-page-button";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
|
||||
export default function DocsLayout({
|
||||
children,
|
||||
@@ -25,7 +24,6 @@ export default function DocsLayout({
|
||||
<article>{children}</article>
|
||||
</div>
|
||||
</div>
|
||||
<DocsChat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
export const metadata = { title: "Migration Guide" }
|
||||
|
||||
# Migration Guide
|
||||
|
||||
This guide covers breaking changes introduced in v0.6.0 and how to update your code.
|
||||
|
||||
## State Provider
|
||||
|
||||
`DataProvider` has been renamed to `StateProvider`, and its props have changed.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
import { DataProvider } from "@json-render/react";
|
||||
|
||||
<DataProvider data={myData} getValue={getter} setValue={setter}>
|
||||
{children}
|
||||
</DataProvider>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
import { StateProvider } from "@json-render/react";
|
||||
|
||||
<StateProvider initialState={myData} onStateChange={(path, value) => console.log(path, value)}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `DataProvider` | `StateProvider` |
|
||||
| `data` prop | `initialState` prop |
|
||||
| `getValue` / `setValue` props | Removed (use `useStateStore()` hook for `get` / `set`) |
|
||||
| `useData` | `useStateStore` |
|
||||
| `useDataValue` | `useStateValue` |
|
||||
| `useDataBinding` | `useStateBinding` (deprecated, use `useBoundProp` instead) |
|
||||
| `DataModel` type | `StateModel` type |
|
||||
|
||||
## Dynamic Expressions
|
||||
|
||||
All dynamic value expressions have been renamed to use `$state`, `$item`, and `$index`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"label": { "$path": "/user/name" },
|
||||
"count": { "$data": "/items/length" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"label": { "$state": "/user/name" },
|
||||
"count": { "$state": "/items/length" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Inside repeat scopes, use `$item` and `$index`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": { "$item": "name" },
|
||||
"subtitle": { "$index": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `{ "$path": "/..." }` | `{ "$state": "/..." }` |
|
||||
| `{ "$data": "/..." }` | `{ "$state": "/..." }` |
|
||||
|
||||
## Two-Way Binding
|
||||
|
||||
Form components no longer use `valuePath` / `statePath` props. Instead, use `$bindState` expressions on the value prop, and `useBoundProp` in your registry.
|
||||
|
||||
**Before (catalog):**
|
||||
|
||||
```typescript
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**Before (spec):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": { "label": "Email", "valuePath": "/form/email" }
|
||||
}
|
||||
```
|
||||
|
||||
**Before (registry):**
|
||||
|
||||
```tsx
|
||||
Input: ({ props }) => {
|
||||
const [value, setValue] = useStateBinding(props.valuePath);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
**After (catalog):**
|
||||
|
||||
```typescript
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**After (spec):**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Input",
|
||||
"props": { "label": "Email", "value": { "$bindState": "/form/email" } }
|
||||
}
|
||||
```
|
||||
|
||||
**After (registry):**
|
||||
|
||||
```tsx
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return <input value={value ?? ""} onChange={(e) => setValue(e.target.value)} />;
|
||||
}
|
||||
```
|
||||
|
||||
`$bindState` reads from and writes to the given state path. Inside repeat scopes, use `$bindItem` to bind to a field on the current item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Checkbox",
|
||||
"props": { "checked": { "$bindItem": "completed" } }
|
||||
}
|
||||
```
|
||||
|
||||
## Visibility Conditions
|
||||
|
||||
Visibility conditions have been renamed to use `$state`, `$and`, and `$or`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{ "path": "/isAdmin" }
|
||||
{ "eq": [{ "path": "/role" }, "admin"] }
|
||||
{ "and": [{ "path": "/isAdmin" }, { "path": "/feature" }] }
|
||||
{ "or": [{ "path": "/roleA" }, { "path": "/roleB" }] }
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{ "$state": "/isAdmin" }
|
||||
{ "$state": "/role", "eq": "admin" }
|
||||
{ "$and": [{ "$state": "/isAdmin" }, { "$state": "/feature" }] }
|
||||
{ "$or": [{ "$state": "/roleA" }, { "$state": "/roleB" }] }
|
||||
```
|
||||
|
||||
You can also use an array as shorthand for `$and`:
|
||||
|
||||
```json
|
||||
[{ "$state": "/isAdmin" }, { "$state": "/feature" }]
|
||||
```
|
||||
|
||||
Inside repeat scopes, use `$item` and `$index`:
|
||||
|
||||
```json
|
||||
{ "$item": "isActive" }
|
||||
{ "$index": true, "eq": 0 }
|
||||
```
|
||||
|
||||
## Event System
|
||||
|
||||
Components now use `emit` to fire named events. `onAction` has been removed.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.("press")}>{props.label}</button>
|
||||
)
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit("press")}>{props.label}</button>
|
||||
)
|
||||
```
|
||||
|
||||
`emit` is always defined (never `undefined`), so optional chaining is not needed.
|
||||
|
||||
## Actions Context
|
||||
|
||||
`dispatch` has been renamed to `execute`, and the provider prop has been renamed from `actionHandlers` to `handlers`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
const { dispatch } = useActions();
|
||||
dispatch({ action: "submit", params: {} });
|
||||
|
||||
<ActionProvider actionHandlers={myHandlers}>
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
const { execute } = useActions();
|
||||
execute({ action: "submit", params: {} });
|
||||
|
||||
<ActionProvider handlers={myHandlers}>
|
||||
```
|
||||
|
||||
## Repeat / List Rendering
|
||||
|
||||
The `repeat` field now uses `statePath` instead of `path`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "path": "/todos", "key": "id" },
|
||||
"children": ["todo-item"]
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "statePath": "/todos", "key": "id" },
|
||||
"children": ["todo-item"]
|
||||
}
|
||||
```
|
||||
|
||||
## Catalog Creation
|
||||
|
||||
`createCatalog` and `generateSystemPrompt` have been replaced by `defineSchema` + `defineCatalog`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
import { createCatalog, generateSystemPrompt } from "@json-render/core";
|
||||
|
||||
const catalog = createCatalog({
|
||||
name: "my-app",
|
||||
components: { /* ... */ },
|
||||
actions: { /* ... */ },
|
||||
});
|
||||
|
||||
const prompt = generateSystemPrompt(catalog);
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
actions: { /* ... */ },
|
||||
});
|
||||
|
||||
const prompt = catalog.prompt();
|
||||
|
||||
// Chat mode prompt
|
||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{ "fn": "required", "message": "Required" }
|
||||
{ "fn": "minLength", "args": { "length": 8 }, "message": "Too short" }
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{ "type": "required", "message": "Required" }
|
||||
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
|
||||
```
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `{ fn: "required" }` | `{ type: "required" }` |
|
||||
| `ValidationProvider functions={...}` | `ValidationProvider customFunctions={...}` |
|
||||
| `useFieldValidation(path, checks)` | `useFieldValidation(path, config)` where config is `{ checks, validateOn? }` |
|
||||
|
||||
## Visibility Provider
|
||||
|
||||
The `auth` prop has been removed from `VisibilityProvider`. Auth state should be modeled as regular state.
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider auth={{ isSignedIn: true, role: "admin" }}>
|
||||
```
|
||||
|
||||
```json
|
||||
{ "auth": "signedIn" }
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={{ auth: { isSignedIn: true, role: "admin" } }}>
|
||||
<VisibilityProvider>
|
||||
```
|
||||
|
||||
```json
|
||||
{ "$state": "/auth/isSignedIn" }
|
||||
```
|
||||
|
||||
## Codegen
|
||||
|
||||
`traverseTree` has been renamed to `traverseSpec`, `SpecVisitor` to `TreeVisitor`, and the visitor callback now receives a `key` parameter.
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
import { traverseTree } from "@json-render/codegen";
|
||||
|
||||
traverseTree(tree, (element) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { traverseSpec } from "@json-render/codegen";
|
||||
|
||||
traverseSpec(spec, (element, key) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Action Params
|
||||
|
||||
Action params in specs now use `statePath` instead of `path`.
|
||||
|
||||
**Before:**
|
||||
|
||||
```json
|
||||
{
|
||||
"on": {
|
||||
"press": { "action": "setState", "params": { "path": "/count", "value": 0 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```json
|
||||
{
|
||||
"on": {
|
||||
"press": { "action": "setState", "params": { "statePath": "/count", "value": 0 } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Removed Exports
|
||||
|
||||
The following exports have been removed from `@json-render/core`:
|
||||
|
||||
| Removed | Replacement |
|
||||
|---------|-------------|
|
||||
| `createCatalog` | `defineCatalog(schema, config)` |
|
||||
| `generateCatalogPrompt` | `catalog.prompt()` |
|
||||
| `generateSystemPrompt` | `catalog.prompt()` |
|
||||
| `ComponentDefinition` | Use catalog component config directly |
|
||||
| `CatalogConfig` | Use `defineCatalog` parameters |
|
||||
| `SystemPromptOptions` | Use `PromptOptions` |
|
||||
| `LogicExpression` | Use `VisibilityCondition` |
|
||||
| `AuthState` | Model auth as regular state (e.g. `/auth/isSignedIn`) |
|
||||
| `evaluateLogicExpression` | Use `evaluateVisibility` |
|
||||
| `createRendererFromCatalog` | Use `defineRegistry` |
|
||||
| `traverseTree` (codegen) | Use `traverseSpec` |
|
||||
@@ -98,10 +98,11 @@ A typical OpenAPI schema for a request body:
|
||||
Create components that map to OpenAPI data types:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const openapiCatalog = createCatalog({
|
||||
export const openapiCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Form: {
|
||||
description: 'API form container',
|
||||
|
||||
@@ -2,35 +2,97 @@ export const metadata = { title: "Introduction" }
|
||||
|
||||
# Introduction
|
||||
|
||||
The framework for User-Generated Interfaces (UGI). Dynamic, personalized UIs per user without sacrificing reliability.
|
||||
json-render is a framework for **Generative UI** — AI-generated interfaces that are safe, predictable, and render natively on any platform.
|
||||
|
||||
## What is json-render?
|
||||
## What is Generative UI?
|
||||
|
||||
json-render is the framework for **User-Generated Interfaces**: dynamic UIs that end users generate through natural language prompts, powered by Generative UI. You define the guardrails: what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
|
||||
Most AI integrations treat the interface as fixed. Developers build layouts ahead of time, and AI fills in the data — a chatbot response, a summary, a recommendation. The UI itself never changes.
|
||||
|
||||
Every interface is unique to the user, but every interface is safe and predictable.
|
||||
**Generative UI is different.** The AI generates the interface itself: which components to show, how to arrange them, what data to bind, what actions to wire up. Every response can produce a unique, purpose-built UI tailored to the user's request.
|
||||
|
||||
## Why json-render?
|
||||
The challenge is that unconstrained AI output is unpredictable. It can hallucinate component names, produce invalid structures, or generate unsafe code. You need a way to let AI be creative with layout and composition while keeping it within boundaries you control.
|
||||
|
||||
### Guardrailed
|
||||
That is what json-render does. You define a **catalog** of components and actions. AI generates JSON constrained to that catalog. Your components render the result natively — on web or mobile — with full type safety and no arbitrary code execution.
|
||||
|
||||
AI can only use components in your catalog. No arbitrary code generation. Predefined components and actions for safe, predictable output.
|
||||
## How json-render Works
|
||||
|
||||
### Predictable
|
||||
### 1. Define your catalog
|
||||
|
||||
JSON output matches your schema, every time. Actions are declared by name, you control what they do.
|
||||
A catalog declares what AI can use: components with typed props, actions with typed params.
|
||||
|
||||
### Fast
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
Stream and render progressively as the model responds. No waiting for completion.
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
slots: ["default"],
|
||||
},
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-Platform
|
||||
### 2. AI generates a spec
|
||||
|
||||
Render on web with React and on mobile with React Native from the same catalog and spec format.
|
||||
Given a prompt like "show me a revenue dashboard", AI outputs a JSON spec — a flat tree of elements constrained to your catalog:
|
||||
|
||||
## How it works
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Revenue Dashboard" },
|
||||
"children": ["metric-1", "metric-2"]
|
||||
},
|
||||
"metric-1": {
|
||||
"type": "Metric",
|
||||
"props": { "label": "Total Revenue", "value": "$48,200" }
|
||||
},
|
||||
"metric-2": {
|
||||
"type": "Metric",
|
||||
"props": { "label": "Growth", "value": "+12%" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. Define the guardrails - what components, actions, and data bindings AI can use
|
||||
2. Users generate - end users describe what they want in natural language
|
||||
3. AI generates JSON - output is always predictable, constrained to your catalog
|
||||
4. Render fast - stream and render progressively as the model responds
|
||||
### 3. Your components render it
|
||||
|
||||
Map catalog types to real components with a registry, then render the spec:
|
||||
|
||||
```tsx
|
||||
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
|
||||
|
||||
<StateProvider initialState={{}}>
|
||||
<VisibilityProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
The result is a native UI built from your own components — not an iframe, not markdown, not generated code. The AI chose the structure; you control everything else.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **[Catalog](/docs/catalog)** — Define the components, actions, and validation functions AI can use. This is the contract between your app and the AI.
|
||||
- **[Registry](/docs/registry)** — Map catalog types to platform-specific implementations. React components on web, React Native views on mobile.
|
||||
- **[Specs](/docs/specs)** — The JSON output AI generates. A flat tree of typed elements with props, children, data bindings, and visibility conditions.
|
||||
- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
|
||||
- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
|
||||
- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
|
||||
- **[Generation Modes](/docs/generation-modes)** — Generate standalone UI (playground/builder) or inline UI within a chat conversation.
|
||||
|
||||
## Next
|
||||
|
||||
- [Installation](/docs/installation) — Add json-render to your project
|
||||
- [Quick Start](/docs/quick-start) — Build your first generative UI in 5 minutes
|
||||
|
||||
@@ -74,7 +74,7 @@ export const { registry } = defineRegistry(catalog, {
|
||||
Button: ({ props, emit }) => (
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
@@ -119,7 +119,7 @@ Use providers and the `Renderer` with your registry to display AI-generated UI:
|
||||
// app/page.tsx
|
||||
'use client';
|
||||
|
||||
import { Renderer, StateProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
|
||||
import { Renderer, StateProvider, ActionProvider, VisibilityProvider, ValidationProvider, useUIStream } from '@json-render/react';
|
||||
import { registry } from '@/lib/registry';
|
||||
|
||||
export default function Page() {
|
||||
@@ -140,20 +140,22 @@ export default function Page() {
|
||||
submit: (params) => console.log('Submit:', params),
|
||||
navigate: (params) => console.log('Navigate:', params),
|
||||
}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="prompt"
|
||||
placeholder="Describe what you want..."
|
||||
className="border p-2 rounded"
|
||||
/>
|
||||
<button type="submit" disabled={isStreaming}>
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
<ValidationProvider customFunctions={{}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="prompt"
|
||||
placeholder="Describe what you want..."
|
||||
className="border p-2 rounded"
|
||||
/>
|
||||
<button type="submit" disabled={isStreaming}>
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8">
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
@@ -165,5 +167,5 @@ export default function Page() {
|
||||
|
||||
- Learn about [catalogs](/docs/catalog) in depth
|
||||
- Explore [data binding](/docs/data-binding) for dynamic values
|
||||
- Add [actions](/docs/actions) for interactivity
|
||||
- Add [action handlers](/docs/registry#action-handlers) for interactivity
|
||||
- Implement [conditional visibility](/docs/visibility)
|
||||
|
||||
@@ -2,11 +2,19 @@ export const metadata = { title: "Registry" }
|
||||
|
||||
# Registry
|
||||
|
||||
Register React components and action handlers to bring your catalog to life.
|
||||
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
|
||||
|
||||
## defineRegistry
|
||||
What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
|
||||
|
||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
|
||||
- **`@json-render/react`** — Components (React elements) and action handlers
|
||||
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
|
||||
- **`@json-render/remotion`** — Clip components, transitions, and effects
|
||||
|
||||
## @json-render/react
|
||||
|
||||
### defineRegistry
|
||||
|
||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
@@ -22,8 +30,8 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
</div>
|
||||
),
|
||||
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -49,32 +57,52 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
|
||||
The returned object contains:
|
||||
|
||||
- `registry` - component registry for `<Renderer />`
|
||||
- `handlers` - factory for ActionProvider-compatible handlers
|
||||
- `executeAction` - imperative action dispatch (for use outside the React tree)
|
||||
- `registry` — component registry for `<Renderer />`
|
||||
- `handlers` — factory for ActionProvider-compatible handlers
|
||||
- `executeAction` — imperative action dispatch (for use outside the React tree)
|
||||
|
||||
## Component Props
|
||||
### Component Props
|
||||
|
||||
Each component in the registry receives a `ComponentContext` object:
|
||||
Each component receives a `ComponentContext` object:
|
||||
|
||||
```typescript
|
||||
interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
onAction?: (action: ActionTrigger) => void; // Dispatch an action
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
emit: (event: string) => void; // Emit a named event (e.g. "press")
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||
}
|
||||
```
|
||||
|
||||
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||
|
||||
## Action Handlers
|
||||
#### Using `bindings` for two-way binding
|
||||
|
||||
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
|
||||
|
||||
```tsx
|
||||
import { useBoundProp, defineRegistry } from '@json-render/react';
|
||||
|
||||
// Inside your registry:
|
||||
TextInput: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
|
||||
return (
|
||||
<input
|
||||
value={value ?? ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
`useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op.
|
||||
|
||||
### Action Handlers
|
||||
|
||||
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
||||
|
||||
### Defining Actions
|
||||
|
||||
Define available actions in your catalog:
|
||||
Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
@@ -104,9 +132,7 @@ const catalog = defineCatalog(schema, {
|
||||
});
|
||||
```
|
||||
|
||||
### Implementing Action Handlers
|
||||
|
||||
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
|
||||
Action handlers receive `(params, setState, state)` and are defined inside `defineRegistry`:
|
||||
|
||||
```tsx
|
||||
export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
@@ -132,43 +158,35 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
});
|
||||
```
|
||||
|
||||
## Using Data Binding
|
||||
### Data Binding
|
||||
|
||||
Use hooks inside your registry components to read and write data:
|
||||
Most data binding is handled automatically by the renderer — `$state`, `$item`, and `$index` expressions in props are resolved before your component receives them. See the [Data Binding](/docs/data-binding) guide for the full reference.
|
||||
|
||||
For two-way binding (form inputs), use `{ "$bindState": "/path" }` on the natural value prop (or `{ "$bindItem": "field" }` inside repeat scopes). The renderer provides a `bindings` map with the state path for each bound prop. Use `useBoundProp` to get `[value, setValue]`:
|
||||
|
||||
```tsx
|
||||
import { useStateStore } from '@json-render/react';
|
||||
import { getByPath } from '@json-render/core';
|
||||
import { useBoundProp } from '@json-render/react';
|
||||
|
||||
// Inside defineRegistry components:
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const { data } = useStateStore();
|
||||
const value = getByPath(data, props.valuePath);
|
||||
|
||||
return (
|
||||
<div className="metric">
|
||||
<span className="label">{props.label}</span>
|
||||
<span className="value">{formatValue(value)}</span>
|
||||
</div>
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value,
|
||||
bindings?.value
|
||||
);
|
||||
},
|
||||
|
||||
TextField: ({ props }) => {
|
||||
const { data, set } = useStateStore();
|
||||
const value = getByPath(data, props.valuePath) as string;
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
## Using the Renderer
|
||||
For read-only state access (e.g. displaying a value from state), use `$state` expressions in props — they are resolved before the component receives them. For custom logic, use `useStateStore` and `getByPath` from `@json-render/core`.
|
||||
|
||||
### Using the Renderer
|
||||
|
||||
Wire everything together with providers and the `<Renderer />` component:
|
||||
|
||||
@@ -182,19 +200,19 @@ import {
|
||||
} from '@json-render/react';
|
||||
import { registry, handlers } from './registry';
|
||||
|
||||
function App({ spec, data, setState }) {
|
||||
const dataRef = useRef(data);
|
||||
function App({ spec, state, setState }) {
|
||||
const stateRef = useRef(state);
|
||||
const setStateRef = useRef(setState);
|
||||
dataRef.current = data;
|
||||
stateRef.current = state;
|
||||
setStateRef.current = setState;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() => handlers(() => setStateRef.current, () => dataRef.current),
|
||||
() => handlers(() => setStateRef.current, () => stateRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateProvider initialState={data}>
|
||||
<StateProvider initialState={state}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
@@ -205,6 +223,55 @@ function App({ spec, data, setState }) {
|
||||
}
|
||||
```
|
||||
|
||||
## @json-render/react-native
|
||||
|
||||
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react-native';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{props.title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
|
||||
Button: ({ props, emit }) => (
|
||||
<Pressable onPress={() => emit("press")}>
|
||||
<Text>{props.label}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
|
||||
|
||||
## @json-render/remotion
|
||||
|
||||
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
|
||||
|
||||
```tsx
|
||||
import { Renderer, standardComponents } from '@json-render/remotion';
|
||||
|
||||
// Use the standard components directly
|
||||
<Renderer spec={timelineSpec} components={standardComponents} />
|
||||
|
||||
// Or extend with your own
|
||||
const components = {
|
||||
...standardComponents,
|
||||
CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
|
||||
};
|
||||
```
|
||||
|
||||
The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions.
|
||||
|
||||
See the [@json-render/remotion API reference](/docs/api/remotion) for the full API.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [data binding](/docs/data-binding) for dynamic values.
|
||||
|
||||
@@ -41,7 +41,7 @@ See the [Custom Schema guide](/docs/custom-schema) to learn how to implement sup
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Welcome, $data.user.name" },
|
||||
"props": { "content": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
@@ -64,25 +64,27 @@ interface Element {
|
||||
type: string; // Component type from catalog
|
||||
props: Record<string, any>; // Component properties
|
||||
children: string[]; // Array of child element keys
|
||||
visible?: VisibilityRule; // Conditional display
|
||||
visible?: VisibilityCondition; // Conditional display
|
||||
}
|
||||
```
|
||||
|
||||
### Data Binding Syntax
|
||||
|
||||
Reference dynamic data using the `$data` prefix in props:
|
||||
Reference dynamic data using `$state` expressions in props. The value is a JSON Pointer path into the state model:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"content": "$data.user.name",
|
||||
"count": "$data.items.length"
|
||||
"content": { "$state": "/user/name" },
|
||||
"count": { "$state": "/items/count" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
json-render also supports `$item` and `$index` expressions for lists, two-way binding via `$bindState` / `$bindItem`, and conditional props. See [Data Binding](/docs/data-binding) for the full reference.
|
||||
|
||||
### Action Format
|
||||
|
||||
Actions are defined in the catalog and referenced from components. The renderer handles action execution:
|
||||
@@ -121,7 +123,7 @@ const MyElementSchema = z.object({
|
||||
// Define your own data binding format
|
||||
const BoundValue = z.object({
|
||||
literal: z.string().optional(),
|
||||
path: z.string().optional(), // e.g., "/users/0/name"
|
||||
source: z.string().optional(), // e.g., "/users/0/name"
|
||||
});
|
||||
|
||||
// Define your own action format
|
||||
|
||||
@@ -6,7 +6,7 @@ A spec is a JSON document that describes your UI.
|
||||
|
||||
## What is a Spec?
|
||||
|
||||
A spec (specification) is the actual JSON that describes a UI. It conforms to a [schema](/docs/schemas) and uses components from a [catalog](/docs/catalog). Specs can be:
|
||||
A spec (specification) is the actual JSON that describes a UI. It uses components from a [catalog](/docs/catalog) and can optionally follow a [schema](/docs/schemas). Specs can be:
|
||||
|
||||
- Generated by AI in real-time
|
||||
- Stored in a database
|
||||
@@ -32,7 +32,7 @@ A basic spec using the `@json-render/react` schema. Note the flat structure with
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Hello, $data.user.name!" },
|
||||
"props": { "content": { "$state": "/user/greeting" } },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ A more complex spec with multiple nested elements:
|
||||
},
|
||||
"avatar-1": {
|
||||
"type": "Avatar",
|
||||
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
|
||||
"props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } },
|
||||
"children": []
|
||||
},
|
||||
"stack-1": {
|
||||
@@ -69,12 +69,12 @@ A more complex spec with multiple nested elements:
|
||||
},
|
||||
"name-text": {
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.name", "variant": "heading" },
|
||||
"props": { "content": { "$state": "/user/name" }, "variant": "heading" },
|
||||
"children": []
|
||||
},
|
||||
"email-text": {
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.email", "variant": "caption" },
|
||||
"props": { "content": { "$state": "/user/email" }, "variant": "caption" },
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
@@ -145,9 +145,11 @@ A high-level spec using semantic blocks for page layouts:
|
||||
|
||||
## Spec Anatomy
|
||||
|
||||
Specs are schema-agnostic — the JSON structure is entirely up to you. The examples below use the `root` + `elements` flat tree format from the `@json-render/react` schema, which is optimized for AI generation and streaming.
|
||||
|
||||
### Root and Elements
|
||||
|
||||
Every spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
|
||||
In the React schema, a spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -181,20 +183,22 @@ Each element in the map has a consistent shape:
|
||||
|
||||
### Dynamic Data
|
||||
|
||||
Props can reference data using `$data` paths:
|
||||
Props can reference data from the state model using `$state` expressions. The value is a JSON Pointer (RFC 6901) path into the state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"value": "$data.metrics.revenue",
|
||||
"change": "$data.metrics.revenueChange"
|
||||
"value": { "$state": "/metrics/revenue" },
|
||||
"change": { "$state": "/metrics/revenueChange" }
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
See [Data Binding](/docs/data-binding) for the full reference including `$item`, `$index`, repeat, and two-way binding.
|
||||
|
||||
### Conditional Visibility
|
||||
|
||||
Control when elements appear using the `visible` property:
|
||||
@@ -207,46 +211,52 @@ Control when elements appear using the `visible` property:
|
||||
},
|
||||
"children": [],
|
||||
"visible": {
|
||||
"path": "$data.form.isDirty",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
"$state": "/form/isDirty",
|
||||
"eq": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Working with Specs
|
||||
|
||||
### Rendering a Spec
|
||||
### Validating a Spec
|
||||
|
||||
Use `validateSpec` from `@json-render/core` to check a spec for structural issues:
|
||||
|
||||
```typescript
|
||||
import { validateSpec } from '@json-render/core';
|
||||
|
||||
const result = validateSpec(spec);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.issues);
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering a Spec (React)
|
||||
|
||||
With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility:
|
||||
|
||||
```tsx
|
||||
import { Renderer } from '@json-render/react';
|
||||
import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react';
|
||||
import { registry } from './registry';
|
||||
|
||||
function MyApp({ spec, data }) {
|
||||
function MyApp({ spec, initialState }) {
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
registry={registry}
|
||||
/>
|
||||
<StateProvider initialState={initialState}>
|
||||
<VisibilityProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Validating a Spec
|
||||
See the [@json-render/react API reference](/docs/api/react) for full provider and hook documentation.
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
### Streaming a Spec (React)
|
||||
|
||||
const result = validate(spec, catalog);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.errors);
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Specs
|
||||
|
||||
Specs can be streamed incrementally for progressive rendering:
|
||||
With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
@@ -266,6 +276,8 @@ function GenerativeUI() {
|
||||
}
|
||||
```
|
||||
|
||||
See [Streaming](/docs/streaming) for the full SpecStream format and server-side setup.
|
||||
|
||||
## Spec Sources
|
||||
|
||||
Specs can come from various sources:
|
||||
|
||||
@@ -15,28 +15,6 @@ json-render uses **SpecStream**, a JSONL-based streaming format where each line
|
||||
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}
|
||||
```
|
||||
|
||||
## useUIStream Hook
|
||||
|
||||
The hook handles parsing and state management:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
clear, // Function to reset spec and error
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||
onError: (error) => {}, // Optional: called when an error occurs
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Patch Operations (RFC 6902)
|
||||
|
||||
SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations:
|
||||
@@ -50,13 +28,13 @@ SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6
|
||||
|
||||
## Path Format
|
||||
|
||||
Paths use a key-based format for elements:
|
||||
Paths follow JSON Pointer (RFC 6901) into the spec object:
|
||||
|
||||
```bash
|
||||
/root -> Root element
|
||||
/root/children -> Children of root
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/children -> Children of card-1
|
||||
/root -> Root element key (string)
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/props -> Props of card-1
|
||||
/elements/card-1/children -> Children of card-1
|
||||
```
|
||||
|
||||
## Server-Side Setup
|
||||
@@ -81,7 +59,77 @@ export async function POST(req: Request) {
|
||||
}
|
||||
```
|
||||
|
||||
## Progressive Rendering
|
||||
## Low-Level SpecStream API
|
||||
|
||||
For custom or framework-agnostic streaming implementations, use the SpecStream compiler from `@json-render/core` directly:
|
||||
|
||||
```typescript
|
||||
import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Decode the Uint8Array chunk to a string
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}
|
||||
```
|
||||
|
||||
### One-Shot Compilation
|
||||
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":"card-1"}
|
||||
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Hello"},"children":[]}}`;
|
||||
|
||||
const spec = compileSpecStream<Spec>(jsonl);
|
||||
// { root: "card-1", elements: { "card-1": { type: "Card", props: { title: "Hello" }, children: [] } } }
|
||||
```
|
||||
|
||||
## Usage with React
|
||||
|
||||
`@json-render/react` provides the `useUIStream` hook, which wraps the low-level compiler in a React-friendly API with state management, error handling, and abort support.
|
||||
|
||||
### useUIStream Hook
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
clear, // Function to reset spec and error
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||
onError: (error) => {}, // Optional: called when an error occurs
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Rendering
|
||||
|
||||
The Renderer automatically updates as the spec changes:
|
||||
|
||||
@@ -98,7 +146,7 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Aborting Streams
|
||||
### Aborting Streams
|
||||
|
||||
Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
|
||||
|
||||
@@ -119,45 +167,4 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Low-Level SpecStream API
|
||||
|
||||
For custom streaming implementations, use the SpecStream compiler directly:
|
||||
|
||||
```typescript
|
||||
import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const { result, newPatches } = compiler.push(value);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}
|
||||
```
|
||||
|
||||
### One-Shot Compilation
|
||||
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":{"type":"Card"}}
|
||||
{"op":"add","path":"/root/props","value":{"title":"Hello"}}`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
// { root: { type: "Card", props: { title: "Hello" } } }
|
||||
```
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `useUIStream` documentation.
|
||||
|
||||
@@ -18,15 +18,17 @@ json-render includes common validation functions:
|
||||
|
||||
## Using Validation in JSON
|
||||
|
||||
Use `{ "$bindState": "/path" }` on the value prop for two-way binding. Validation checks run against the value at the bound path (available as `bindings?.value` in components):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Email",
|
||||
"valuePath": "/form/email",
|
||||
"value": { "$bindState": "/form/email" },
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Email is required" },
|
||||
{ "fn": "email", "message": "Invalid email format" }
|
||||
{ "type": "required", "message": "Email is required" },
|
||||
{ "type": "email", "message": "Invalid email format" }
|
||||
],
|
||||
"validateOn": "blur"
|
||||
}
|
||||
@@ -40,16 +42,16 @@ json-render includes common validation functions:
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Password",
|
||||
"valuePath": "/form/password",
|
||||
"value": { "$bindState": "/form/password" },
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Password is required" },
|
||||
{ "type": "required", "message": "Password is required" },
|
||||
{
|
||||
"fn": "minLength",
|
||||
"args": { "length": 8 },
|
||||
"type": "minLength",
|
||||
"args": { "min": 8 },
|
||||
"message": "Password must be at least 8 characters"
|
||||
},
|
||||
{
|
||||
"fn": "pattern",
|
||||
"type": "pattern",
|
||||
"args": { "pattern": "[A-Z]" },
|
||||
"message": "Must contain at least one uppercase letter"
|
||||
}
|
||||
@@ -60,11 +62,11 @@ json-render includes common validation functions:
|
||||
|
||||
## Custom Validation Functions
|
||||
|
||||
Define custom validators in your catalog:
|
||||
Define custom validators in your catalog's `functions` field. The catalog itself is framework-agnostic — only the `schema` import varies by platform:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
@@ -80,7 +82,9 @@ const catalog = defineCatalog(schema, {
|
||||
});
|
||||
```
|
||||
|
||||
Then implement them in your ValidationProvider:
|
||||
## Usage with React
|
||||
|
||||
In `@json-render/react`, use `ValidationProvider` to supply implementations for your custom validators:
|
||||
|
||||
```tsx
|
||||
import { ValidationProvider } from '@json-render/react';
|
||||
@@ -99,22 +103,25 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ValidationProvider functions={customValidators}>
|
||||
<ValidationProvider customFunctions={customValidators}>
|
||||
{/* Your UI */}
|
||||
</ValidationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using in Components
|
||||
### Using in Components
|
||||
|
||||
The `useFieldValidation` and `useBoundProp` hooks wire validation into your registry components. Validation uses the path from `bindings?.value` (the bound state path):
|
||||
|
||||
```tsx
|
||||
import { useFieldValidation } from '@json-render/react';
|
||||
import { useFieldValidation, useBoundProp } from '@json-render/react';
|
||||
|
||||
function TextField({ props }) {
|
||||
const { value, setValue, errors, validate } = useFieldValidation(
|
||||
props.valuePath,
|
||||
props.checks
|
||||
function TextField({ props, bindings }) {
|
||||
const [value, setValue] = useBoundProp(props.value, bindings?.value);
|
||||
const { errors, isValid, validate, touch, clear } = useFieldValidation(
|
||||
bindings?.value ?? null,
|
||||
{ checks: props.checks, validateOn: props.validateOn }
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -133,6 +140,8 @@ function TextField({ props }) {
|
||||
}
|
||||
```
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
||||
|
||||
## Validation Timing
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
@@ -143,4 +152,4 @@ Control when validation runs with `validateOn`:
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [AI SDK integration](/docs/ai-sdk).
|
||||
Learn about [generation modes](/docs/generation-modes).
|
||||
|
||||
@@ -2,14 +2,310 @@ export const metadata = { title: "Visibility" }
|
||||
|
||||
# Visibility
|
||||
|
||||
Conditionally show or hide components based on data, auth, or logic.
|
||||
Conditionally show or hide components based on state values and logic.
|
||||
|
||||
## VisibilityProvider
|
||||
## State-Based Visibility
|
||||
|
||||
Wrap your app with VisibilityProvider to enable conditional rendering:
|
||||
Show/hide based on state values. Use `$state` with a JSON Pointer path:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Form has errors" },
|
||||
"visible": { "$state": "/form/hasErrors" }
|
||||
}
|
||||
```
|
||||
|
||||
Visible when `/form/hasErrors` is truthy.
|
||||
|
||||
### Negation
|
||||
|
||||
Use `not: true` to invert a condition:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "WelcomeBanner",
|
||||
"visible": { "$state": "/user/hasSeenWelcome", "not": true }
|
||||
}
|
||||
```
|
||||
|
||||
Visible when `/user/hasSeenWelcome` is falsy.
|
||||
|
||||
## Auth-Based Visibility
|
||||
|
||||
Show/hide based on authentication state. Expose your auth state in the state model (e.g. at `/auth/isSignedIn`):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AdminPanel",
|
||||
"visible": { "$state": "/auth/isSignedIn" }
|
||||
}
|
||||
```
|
||||
|
||||
For signed-out only:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "LoginPrompt",
|
||||
"visible": { "$state": "/auth/isSignedIn", "not": true }
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison Operators
|
||||
|
||||
Compare a state value to a literal or another state path. Use **one operator per condition** -- if multiple are provided, only the first one is evaluated (precedence: `eq` > `neq` > `gt` > `gte` > `lt` > `lte`). Add `"not": true` to invert the result of any condition.
|
||||
|
||||
```json
|
||||
// Equal
|
||||
{
|
||||
"visible": { "$state": "/user/role", "eq": "admin" }
|
||||
}
|
||||
|
||||
// Not equal
|
||||
{
|
||||
"visible": { "$state": "/tab", "neq": "home" }
|
||||
}
|
||||
|
||||
// Greater than
|
||||
{
|
||||
"visible": { "$state": "/cart/total", "gt": 100 }
|
||||
}
|
||||
|
||||
// Greater than or equal
|
||||
{
|
||||
"visible": { "$state": "/cart/itemCount", "gte": 1 }
|
||||
}
|
||||
|
||||
// Less than
|
||||
{
|
||||
"visible": { "$state": "/cart/total", "lt": 1000 }
|
||||
}
|
||||
|
||||
// Less than or equal
|
||||
{
|
||||
"visible": { "$state": "/cart/itemCount", "lte": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
Comparison values can be literals or state references:
|
||||
|
||||
```json
|
||||
{
|
||||
"visible": { "$state": "/user/balance", "gte": { "$state": "/order/minimum" } }
|
||||
}
|
||||
```
|
||||
|
||||
## Combining Conditions (AND)
|
||||
|
||||
Place multiple conditions in an array for implicit AND:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SubmitButton",
|
||||
"visible": [
|
||||
{ "$state": "/form/isValid" },
|
||||
{ "$state": "/form/hasChanges" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All conditions must be true for the element to be visible.
|
||||
|
||||
## OR Conditions
|
||||
|
||||
Use `$or` when at least one condition should be true:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "SpecialOffer",
|
||||
"visible": { "$or": [
|
||||
{ "$state": "/user/isVIP" },
|
||||
{ "$state": "/cart/total", "gt": 200 }
|
||||
]}
|
||||
}
|
||||
```
|
||||
|
||||
Visible when the user is VIP **or** the cart total exceeds 200. `$or` can contain any visibility conditions, including nested arrays (AND) and comparisons.
|
||||
|
||||
## Explicit AND
|
||||
|
||||
Use `$and` when you need to nest AND logic inside `$or`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PromoCard",
|
||||
"visible": { "$or": [
|
||||
{ "$and": [
|
||||
{ "$state": "/user/isVIP" },
|
||||
{ "$state": "/cart/total", "gt": 50 }
|
||||
]},
|
||||
{ "$state": "/promo/active" }
|
||||
]}
|
||||
}
|
||||
```
|
||||
|
||||
For top-level AND, the implicit array form is simpler: `[condition, condition]`. Use `$and` only when nesting inside `$or`.
|
||||
|
||||
## Always / Never
|
||||
|
||||
Use boolean literals for constant visibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Footer",
|
||||
"visible": true
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DeprecatedPanel",
|
||||
"visible": false
|
||||
}
|
||||
```
|
||||
|
||||
## Repeat-Scoped Conditions
|
||||
|
||||
Inside a [repeat](/docs/data-binding#repeat), use `$item` and `$index` conditions to show/hide based on the current item:
|
||||
|
||||
### `$item` — Condition on item field
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Badge",
|
||||
"props": { "label": "Overdue" },
|
||||
"visible": { "$item": "isOverdue" }
|
||||
}
|
||||
```
|
||||
|
||||
With comparison:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DiscountTag",
|
||||
"visible": { "$item": "price", "gt": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
### `$index` — Condition on array index
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Divider",
|
||||
"visible": { "$index": true, "gt": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
This shows the divider for every item except the first (index 0).
|
||||
|
||||
`$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
|
||||
|
||||
## Complex Example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "RefundButton",
|
||||
"props": { "label": "Process Refund" },
|
||||
"visible": [
|
||||
{ "$state": "/auth/isSignedIn" },
|
||||
{ "$state": "/user/role", "eq": "support" },
|
||||
{ "$state": "/order/amount", "gt": 0 },
|
||||
{ "$state": "/order/isRefunded", "not": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Condition</th>
|
||||
<th>Syntax</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Truthiness</td>
|
||||
<td><code>{'{ "$state": "/path" }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Falsy (not)</td>
|
||||
<td><code>{'{ "$state": "/path", "not": true }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Equal</td>
|
||||
<td><code>{'{ "$state": "/path", "eq": value }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Not equal</td>
|
||||
<td><code>{'{ "$state": "/path", "neq": value }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Greater than</td>
|
||||
<td><code>{'{ "$state": "/path", "gt": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Greater or equal</td>
|
||||
<td><code>{'{ "$state": "/path", "gte": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Less than</td>
|
||||
<td><code>{'{ "$state": "/path", "lt": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Less or equal</td>
|
||||
<td><code>{'{ "$state": "/path", "lte": number }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Item field (repeat)</td>
|
||||
<td><code>{'{ "$item": "field" }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Item comparison</td>
|
||||
<td><code>{'{ "$item": "field", "eq": value }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Index (repeat)</td>
|
||||
<td><code>{'{ "$index": true, "gt": 0 }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AND (implicit)</td>
|
||||
<td><code>{"[ condition, condition ]"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>AND (explicit)</td>
|
||||
<td><code>{'{ "$and": [ condition, condition ] }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OR</td>
|
||||
<td><code>{'{ "$or": [ condition, condition ] }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Always</td>
|
||||
<td><code>{"true"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Never</td>
|
||||
<td><code>{"false"}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Comparison values can be literals or state references for state-to-state comparisons:
|
||||
|
||||
```json
|
||||
{ "$state": "/a", "eq": { "$state": "/b" } }
|
||||
```
|
||||
|
||||
## Usage with React
|
||||
|
||||
In `@json-render/react`, wrap your app with `VisibilityProvider` to enable conditional rendering. The `Renderer` handles visibility automatically — elements with unmet conditions are not rendered.
|
||||
|
||||
```tsx
|
||||
import { VisibilityProvider } from '@json-render/react';
|
||||
import { VisibilityProvider, StateProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -22,120 +318,21 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## Path-Based Visibility
|
||||
|
||||
Show/hide based on data values:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Form has errors" },
|
||||
"visible": { "path": "/form/hasErrors" }
|
||||
}
|
||||
|
||||
// Visible when /form/hasErrors is truthy
|
||||
```
|
||||
|
||||
## Auth-Based Visibility
|
||||
|
||||
Show/hide based on authentication state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AdminPanel",
|
||||
"visible": { "auth": "signedIn" }
|
||||
}
|
||||
|
||||
// Options: "signedIn", "signedOut", "admin", etc.
|
||||
```
|
||||
|
||||
## Logic Expressions
|
||||
|
||||
Combine conditions with logic operators:
|
||||
|
||||
```json
|
||||
// AND - all conditions must be true
|
||||
{
|
||||
"type": "SubmitButton",
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "path": "/form/isValid" },
|
||||
{ "path": "/form/hasChanges" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// OR - any condition must be true
|
||||
{
|
||||
"type": "HelpText",
|
||||
"visible": {
|
||||
"or": [
|
||||
{ "path": "/user/isNew" },
|
||||
{ "path": "/settings/showHelp" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// NOT - invert a condition
|
||||
{
|
||||
"type": "WelcomeBanner",
|
||||
"visible": {
|
||||
"not": { "path": "/user/hasSeenWelcome" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison Operators
|
||||
|
||||
```json
|
||||
// Equal
|
||||
{
|
||||
"visible": {
|
||||
"eq": [{ "path": "/user/role" }, "admin"]
|
||||
}
|
||||
}
|
||||
|
||||
// Greater than
|
||||
{
|
||||
"visible": {
|
||||
"gt": [{ "path": "/cart/total" }, 100]
|
||||
}
|
||||
}
|
||||
|
||||
// Available: eq, ne, gt, gte, lt, lte
|
||||
```
|
||||
|
||||
## Complex Example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "RefundButton",
|
||||
"props": { "label": "Process Refund" },
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "auth": "signedIn" },
|
||||
{ "eq": [{ "path": "/user/role" }, "support"] },
|
||||
{ "gt": [{ "path": "/order/amount" }, 0] },
|
||||
{ "not": { "path": "/order/isRefunded" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using in Components
|
||||
For advanced use cases, the `useIsVisible` hook lets you evaluate visibility conditions programmatically:
|
||||
|
||||
```tsx
|
||||
import { useIsVisible } from '@json-render/react';
|
||||
|
||||
// The Renderer handles visibility automatically, but you can also use the hook
|
||||
function ConditionalContent({ condition, children }) {
|
||||
const isVisible = useIsVisible(condition);
|
||||
|
||||
|
||||
if (!isVisible) return null;
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See the [@json-render/react API reference](/docs/api/react) for full details.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [form validation](/docs/validation).
|
||||
|
||||
@@ -10,14 +10,15 @@ export default function Home() {
|
||||
{/* Hero */}
|
||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
|
||||
The framework for User-Generated Interfaces
|
||||
The Generative UI Framework
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||
AI → json-render → UI
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
Dynamic, personalized UIs per user without sacrificing reliability.
|
||||
Predefined components and actions for safe, predictable output.
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing
|
||||
reliability. Predefined components and actions for safe, predictable
|
||||
output.
|
||||
</p>
|
||||
|
||||
<Demo />
|
||||
@@ -68,10 +69,10 @@ export default function Home() {
|
||||
<div className="text-xs text-muted-foreground font-mono mb-3">
|
||||
02
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Users Generate</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">AI Generates</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
End users describe what they want. AI generates JSON constrained
|
||||
to your catalog. Every interface is unique to the user.
|
||||
Describe what you want. AI generates JSON constrained to your
|
||||
catalog. Every interface is unique.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -99,10 +100,12 @@ export default function Home() {
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Components, actions, and validation functions.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
<Code lang="typescript">{`import { defineSchema, defineCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const catalog = createCatalog({
|
||||
const schema = defineSchema({ /* ... */ });
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({
|
||||
@@ -114,7 +117,7 @@ export const catalog = createCatalog({
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(),
|
||||
statePath: z.string(),
|
||||
format: z.enum(['currency', 'percent']),
|
||||
}),
|
||||
},
|
||||
@@ -143,7 +146,7 @@ export const catalog = createCatalog({
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "/metrics/revenue",
|
||||
"statePath": "/metrics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
}
|
||||
@@ -182,7 +185,7 @@ export const catalog = createCatalog({
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "analytics/revenue",
|
||||
"statePath": "analytics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
},
|
||||
@@ -222,7 +225,7 @@ export default function Page() {
|
||||
<Metric
|
||||
data={data}
|
||||
label="Total Revenue"
|
||||
valuePath="analytics/revenue"
|
||||
statePath="analytics/revenue"
|
||||
format="currency"
|
||||
/>
|
||||
<Chart data={data} statePath="analytics/salesByRegion" />
|
||||
@@ -248,8 +251,8 @@ export default function Page() {
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
title: "User-Generated Interfaces",
|
||||
desc: "Dynamic, personalized UIs per user powered by Generative UI",
|
||||
title: "Generative UI",
|
||||
desc: "Generate dynamic, personalized interfaces from prompts with AI",
|
||||
},
|
||||
{
|
||||
title: "Guardrails",
|
||||
@@ -265,7 +268,7 @@ export default function Page() {
|
||||
},
|
||||
{
|
||||
title: "Data Binding",
|
||||
desc: "Two-way state binding with dynamic prop expressions",
|
||||
desc: "Connect props to state with $state, $item, $index, and two-way binding",
|
||||
},
|
||||
{
|
||||
title: "Code Export",
|
||||
|
||||
@@ -18,15 +18,17 @@ GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||
|
||||
When answering questions:
|
||||
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
|
||||
- Use the readFile tool to read specific documentation pages
|
||||
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
|
||||
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
|
||||
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only
|
||||
- Always base your answers on the actual documentation content
|
||||
- Be concise and accurate
|
||||
- If the docs don't cover a topic, say so honestly
|
||||
- Do NOT include source references or file paths in your response`;
|
||||
- Do NOT include source references or file paths in your response
|
||||
- Do NOT use emojis in your responses`;
|
||||
|
||||
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
@@ -106,14 +108,19 @@ export async function POST(req: Request) {
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const docsFiles = await loadDocsFiles();
|
||||
const { tools } = await createBashTool({ files: docsFiles });
|
||||
const {
|
||||
tools: { bash, readFile },
|
||||
} = await createBashTool({ files: docsFiles });
|
||||
|
||||
const result = streamText({
|
||||
model: DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools,
|
||||
tools: {
|
||||
bash,
|
||||
readFile,
|
||||
},
|
||||
prepareStep: ({ messages: stepMessages }) => ({
|
||||
messages: addCacheControl(stepMessages),
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -82,7 +83,7 @@ export async function POST(req: Request) {
|
||||
});
|
||||
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||
} catch {
|
||||
// Usage not available -- skip silently
|
||||
// Usage not available — skip silently
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
|
||||
+53
-13
@@ -28,6 +28,7 @@
|
||||
--border: oklch(0.85 0 0);
|
||||
--input: oklch(0.85 0 0);
|
||||
--ring: oklch(0.6 0 0);
|
||||
--chat-bg: oklch(0.95 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -52,6 +53,7 @@
|
||||
--border: oklch(0.25 0 0);
|
||||
--input: oklch(0.25 0 0);
|
||||
--ring: oklch(0.4 0 0);
|
||||
--chat-bg: oklch(0.25 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -109,29 +111,66 @@
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
/* Hide page scrollbar */
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-background;
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-border rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-muted-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tool call shimmer animation */
|
||||
@keyframes tool-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-tool-shimmer {
|
||||
animation: tool-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Fix list rendering in chat content */
|
||||
.docs-chat-content ul,
|
||||
.docs-chat-content ol {
|
||||
list-style-position: outside;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
.docs-chat-content li > p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-chat-content li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* MDX table styles — fallback for GFM-generated tables */
|
||||
.mdx-table th,
|
||||
.mdx-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mdx-table th {
|
||||
font-weight: 600;
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
.mdx-table td {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
@@ -144,3 +183,4 @@ button {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
|
||||
+28
-11
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
@@ -22,13 +24,12 @@ export const metadata: Metadata = {
|
||||
template: "%s | json-render",
|
||||
},
|
||||
description:
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
keywords: [
|
||||
"json-render",
|
||||
"UGI",
|
||||
"User-Generated Interfaces",
|
||||
"AI UI generation",
|
||||
"generative UI",
|
||||
"AI UI generation",
|
||||
"user-generated interfaces",
|
||||
"React components",
|
||||
"React Native",
|
||||
"guardrails",
|
||||
@@ -42,23 +43,23 @@ export const metadata: Metadata = {
|
||||
locale: "en_US",
|
||||
url: "https://json-render.dev",
|
||||
siteName: "json-render",
|
||||
title: "json-render | The framework for User-Generated Interfaces",
|
||||
title: "json-render | The Generative UI Framework",
|
||||
description:
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: [
|
||||
{
|
||||
url: "/og",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "json-render - The framework for User-Generated Interfaces",
|
||||
alt: "json-render - The Generative UI Framework",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "json-render | The framework for User-Generated Interfaces",
|
||||
title: "json-render | The Generative UI Framework",
|
||||
description:
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: ["/og"],
|
||||
creator: "@verabornnot",
|
||||
},
|
||||
@@ -71,15 +72,31 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const cookieStore = await cookies();
|
||||
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
|
||||
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{chatOpen && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
|
||||
+67
-125
@@ -16,6 +16,7 @@ import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
|
||||
|
||||
@@ -24,10 +25,54 @@ interface SimulationStage {
|
||||
stream: string;
|
||||
}
|
||||
|
||||
// Shared state & element definitions for the progressive simulation stages.
|
||||
const FORM_STATE = { form: { name: "", email: "", message: "" } };
|
||||
|
||||
const NAME_INPUT = {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Name",
|
||||
name: "name",
|
||||
statePath: "/form/name",
|
||||
checks: [{ type: "required", message: "Name is required" }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EMAIL_INPUT = {
|
||||
type: "Input",
|
||||
props: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
statePath: "/form/email",
|
||||
checks: [
|
||||
{ type: "required", message: "Email is required" },
|
||||
{ type: "email", message: "Please enter a valid email" },
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MESSAGE_INPUT = {
|
||||
type: "Textarea",
|
||||
props: {
|
||||
label: "Message",
|
||||
name: "message",
|
||||
statePath: "/form/message",
|
||||
checks: [{ type: "required", message: "Message is required" }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const SUBMIT_BUTTON = {
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
on: { press: { action: "formSubmit" } },
|
||||
} as const;
|
||||
|
||||
const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
@@ -41,98 +86,72 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
|
||||
'{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email","statePath":"/form/email","checks":[{"type":"required","message":"Email is required"},{"type":"email","message":"Please enter a valid email"}]}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
state: FORM_STATE,
|
||||
elements: {
|
||||
card: {
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message", "submit"],
|
||||
},
|
||||
name: {
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
submit: {
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
},
|
||||
name: NAME_INPUT,
|
||||
email: EMAIL_INPUT,
|
||||
message: MESSAGE_INPUT,
|
||||
submit: SUBMIT_BUTTON,
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -230,87 +249,10 @@ export function Demo({
|
||||
>("components");
|
||||
|
||||
// Catalog data for the catalog tab
|
||||
const catalogData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = playgroundCatalog.data as any;
|
||||
|
||||
function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string =
|
||||
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values)
|
||||
? `(${values.join(" | ")})?`
|
||||
: "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const components = Object.entries(raw.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(raw.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}, []);
|
||||
const catalogData = useMemo(
|
||||
() => buildCatalogDisplayData(playgroundCatalog.data),
|
||||
[],
|
||||
);
|
||||
|
||||
// Disable body scroll when any modal is open
|
||||
useEffect(() => {
|
||||
|
||||
+467
-135
@@ -1,24 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Streamdown } from "streamdown";
|
||||
import Link from "next/link";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
const STORAGE_KEY = "docs-chat-messages";
|
||||
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||
|
||||
export function DocsChat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const DESKTOP_DEFAULT_WIDTH = 400;
|
||||
const DESKTOP_MIN_WIDTH = 300;
|
||||
const DESKTOP_MAX_WIDTH = 700;
|
||||
|
||||
const { messages, sendMessage, status, setMessages } = useChat({ transport });
|
||||
function setCookie(name: string, value: string) {
|
||||
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<
|
||||
string,
|
||||
{ label: string; pastLabel: string; argKey?: string }
|
||||
> = {
|
||||
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
|
||||
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
|
||||
};
|
||||
|
||||
function isToolPart(part: { type: string }): part is {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
} {
|
||||
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
}
|
||||
|
||||
function getToolName(part: { type: string; toolName?: string }): string {
|
||||
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
|
||||
return part.type.replace(/^tool-/, "");
|
||||
}
|
||||
|
||||
function ToolCallDisplay({
|
||||
part,
|
||||
}: {
|
||||
part: {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
toolName?: string;
|
||||
state: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
}) {
|
||||
const toolName = getToolName(part);
|
||||
const config = TOOL_LABELS[toolName] ?? {
|
||||
label: toolName,
|
||||
pastLabel: toolName,
|
||||
};
|
||||
const isDone = part.state === "output-available";
|
||||
const isError = part.state === "output-error";
|
||||
const isRunning = !isDone && !isError;
|
||||
const displayLabel = isRunning ? config.label : config.pastLabel;
|
||||
|
||||
const args = (part.input ?? {}) as Record<string, unknown>;
|
||||
const argValue = config.argKey ? args[config.argKey] : undefined;
|
||||
const argPreview =
|
||||
argValue != null
|
||||
? String(argValue)
|
||||
.replace(/\/workspace\//g, "/")
|
||||
.replace(/\.md$/, "")
|
||||
.replace(/\/index$/, "")
|
||||
: "";
|
||||
|
||||
// Link to the docs page if it's a /docs/ path from readFile
|
||||
const docsLink =
|
||||
toolName === "readFile" &&
|
||||
(argPreview === "/docs" || argPreview.startsWith("/docs/"))
|
||||
? argPreview
|
||||
: null;
|
||||
|
||||
const argEl = argPreview ? (
|
||||
docsLink ? (
|
||||
<Link href={docsLink} className="truncate underline underline-offset-2">
|
||||
{argPreview}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{argPreview}</span>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="text-xs py-0.5 min-w-0">
|
||||
{isRunning ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
|
||||
<span className="shrink-0">{displayLabel}</span>
|
||||
{argEl}
|
||||
{isError && <span className="text-destructive">failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What is json-render?",
|
||||
"How do I install it?",
|
||||
"How does streaming work?",
|
||||
"What components are available?",
|
||||
"How do I create a custom schema?",
|
||||
];
|
||||
|
||||
export function DocsChat({
|
||||
defaultOpen = false,
|
||||
defaultWidth = DESKTOP_DEFAULT_WIDTH,
|
||||
}: {
|
||||
defaultOpen?: boolean;
|
||||
defaultWidth?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [input, setInput] = useState("");
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
const [desktopWidth, setDesktopWidth] = useState(
|
||||
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
|
||||
);
|
||||
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, error } = useChat({
|
||||
transport,
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
const showMessages = messages.length > 0 || !!error || isLoading;
|
||||
|
||||
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 640px)");
|
||||
setIsDesktop(mq.matches);
|
||||
setHasMounted(true);
|
||||
// If on mobile but sidebar was open from cookie, close it
|
||||
if (!mq.matches && defaultOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
|
||||
useEffect(() => {
|
||||
if (hasMounted) {
|
||||
setCookie("docs-chat-open", String(open));
|
||||
}
|
||||
}, [open, hasMounted]);
|
||||
|
||||
// Push page content on desktop when pane is open.
|
||||
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
|
||||
// instead of appearing right next to the sidebar's scrollbar.
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (isDesktop && open) {
|
||||
body.style.paddingRight = `${desktopWidth}px`;
|
||||
if (!isDraggingRef.current) {
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
} else if (isDesktop) {
|
||||
body.style.paddingRight = "0px";
|
||||
body.style.transition = "padding-right 150ms ease";
|
||||
}
|
||||
return () => {
|
||||
body.style.paddingRight = "0px";
|
||||
body.style.transition = "";
|
||||
};
|
||||
}, [isDesktop, open, desktopWidth]);
|
||||
|
||||
// Resize handle drag
|
||||
const handleResizePointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
document.documentElement.style.transition = "none";
|
||||
const startX = e.clientX;
|
||||
const startWidth = desktopWidth;
|
||||
|
||||
const onPointerMove = (ev: globalThis.PointerEvent) => {
|
||||
const delta = startX - ev.clientX;
|
||||
const newWidth = Math.min(
|
||||
DESKTOP_MAX_WIDTH,
|
||||
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
|
||||
);
|
||||
setDesktopWidth(newWidth);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
document.documentElement.style.transition = "";
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
},
|
||||
[desktopWidth],
|
||||
);
|
||||
|
||||
// Persist width to cookie
|
||||
useEffect(() => {
|
||||
setCookie("docs-chat-width", String(desktopWidth));
|
||||
}, [desktopWidth]);
|
||||
|
||||
// Restore messages from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -52,144 +261,95 @@ export function DocsChat() {
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Auto-open when new messages arrive
|
||||
const prevMessageCount = useRef(0);
|
||||
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessageCount.current) {
|
||||
setOpen(true);
|
||||
}
|
||||
prevMessageCount.current = messages.length;
|
||||
}, [messages.length]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Close message area when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => {
|
||||
if (!prev) {
|
||||
setTimeout(() => inputRef.current?.focus(), 200);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}
|
||||
if (e.key === "Escape" && open && isDesktop) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, isDesktop]);
|
||||
|
||||
// Auto-focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
};
|
||||
// Auto-open when error occurs
|
||||
useEffect(() => {
|
||||
if (error) setOpen(true);
|
||||
}, [error]);
|
||||
|
||||
const handleClear = () => {
|
||||
// Scroll to bottom when messages change or error occurs
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, error]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
}, [setMessages]);
|
||||
|
||||
const getTextFromParts = (
|
||||
const hasVisibleContent = (
|
||||
parts: (typeof messages)[number]["parts"],
|
||||
): string => {
|
||||
return parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
): boolean => {
|
||||
return parts.some(
|
||||
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
|
||||
>
|
||||
{/* Messages panel */}
|
||||
{open && messages.length > 0 && (
|
||||
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
json-render Docs
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
{messages.map((message) => {
|
||||
const text = getTextFromParts(message.parts);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "assistant" ? (
|
||||
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Streamdown>{text}</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
placeholder="Ask about the docs..."
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
if (messages.length > 0) setOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
|
||||
/>
|
||||
// Shared chat panel content used by both desktop and mobile
|
||||
const chatPanel = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<span className="text-sm font-medium">json-render Docs</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{showMessages && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Send message"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -197,12 +357,184 @@ export function DocsChat() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: suggestions or messages */}
|
||||
{showMessages ? (
|
||||
<div
|
||||
ref={messagesScrollRef}
|
||||
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
|
||||
>
|
||||
{messages.map((message) => {
|
||||
if (!hasVisibleContent(message.parts)) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "user" ? (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{message.parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> =>
|
||||
p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text" && part.text) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isToolPart(part)) {
|
||||
return (
|
||||
<ToolCallDisplay key={part.toolCallId} part={part} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
return parsed.message || parsed.error || error.message;
|
||||
} catch {
|
||||
return (
|
||||
error.message || "Something went wrong. Please try again."
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex flex-wrap gap-2 p-4">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sendMessage({ text: s });
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
rows={1}
|
||||
enterKeyHint="send"
|
||||
placeholder="Ask a question..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ask AI trigger button */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
||||
aria-label="Ask AI"
|
||||
>
|
||||
Ask AI
|
||||
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
|
||||
<aside
|
||||
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ width: desktopWidth }}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onPointerDown={handleResizePointerDown}
|
||||
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile: Sheet overlay/drawer — only after mount to avoid flash on desktop */}
|
||||
{hasMounted && !isDesktop && (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
overlayClassName="!bg-background"
|
||||
className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--background)", opacity: 1 }}
|
||||
>
|
||||
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||
{chatPanel}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
|
||||
}
|
||||
|
||||
/** Simple form wireframe reused in both diagrams */
|
||||
function FormUI() {
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
|
||||
{/* Title */}
|
||||
<Skeleton className="h-2.5 w-14 mb-1" />
|
||||
{/* Input fields */}
|
||||
<Skeleton className="h-4 w-full rounded-sm" />
|
||||
<Skeleton className="h-4 w-full rounded-sm" />
|
||||
{/* Submit button */}
|
||||
<Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatModeDiagram() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
|
||||
Chat Mode
|
||||
</div>
|
||||
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 p-3 overflow-hidden flex justify-center">
|
||||
<div className="w-1/3 min-w-[120px] space-y-3">
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-muted rounded-xl px-3 py-2">
|
||||
<Skeleton className="h-2 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assistant text */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-2 w-3/4" />
|
||||
</div>
|
||||
|
||||
{/* Inline UI */}
|
||||
<FormUI />
|
||||
|
||||
{/* More text after UI */}
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-2 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="p-2 flex justify-center">
|
||||
<div className="w-1/3 min-w-[120px] flex items-center gap-2">
|
||||
<Skeleton className="h-7 flex-1 rounded-md" />
|
||||
<Skeleton className="h-7 w-7 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
|
||||
Text + UI interleaved in messages
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerateModeDiagram() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
|
||||
Generate Mode
|
||||
</div>
|
||||
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
|
||||
{/* Left panel - prompt */}
|
||||
<div className="w-[38%] border-r border-border flex flex-col">
|
||||
<div className="flex-1" />
|
||||
<div className="p-3 space-y-2">
|
||||
<Skeleton className="h-7 w-full rounded-md" />
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - UI preview */}
|
||||
<div className="flex-1 p-3 flex items-center justify-center">
|
||||
<div className="w-3/4">
|
||||
<FormUI />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
|
||||
Prompt separate from UI preview
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenerationModesDiagram() {
|
||||
return (
|
||||
<div className="not-prose my-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="h-[280px]">
|
||||
<ChatModeDiagram />
|
||||
</div>
|
||||
<div className="h-[280px]">
|
||||
<GenerateModeDiagram />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>10.1k</span>
|
||||
<span>10.5k</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Header } from "./header";
|
||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
||||
type RenderView = "preview" | "code";
|
||||
@@ -465,88 +466,10 @@ ${jsx}
|
||||
);
|
||||
|
||||
// Catalog data for the catalog tab
|
||||
const catalogData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = playgroundCatalog.data as any;
|
||||
|
||||
function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string =
|
||||
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values)
|
||||
? `(${values.join(" | ")})?`
|
||||
: "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const components = Object.entries(raw.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(raw.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}, []);
|
||||
const catalogData = useMemo(
|
||||
() => buildCatalogDisplayData(playgroundCatalog.data),
|
||||
[],
|
||||
);
|
||||
|
||||
// Code pane content
|
||||
const copyText =
|
||||
|
||||
@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
|
||||
"fixed inset-0 z-50 bg-black/75 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,17 +27,26 @@ const SheetOverlay = React.forwardRef<
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetSideVariants = {
|
||||
left: "inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 max-w-xs data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||
};
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SheetPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||
side?: "left" | "right";
|
||||
overlayClassName?: string;
|
||||
}
|
||||
>(({ className, children, side = "left", overlayClassName, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay className={overlayClassName} />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
|
||||
"inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
||||
sheetSideVariants[side],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,24 +16,37 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Introduction", href: "/docs" },
|
||||
{ title: "Installation", href: "/docs/installation" },
|
||||
{ title: "Quick Start", href: "/docs/quick-start" },
|
||||
{ title: "Migration Guide", href: "/docs/migration" },
|
||||
{ title: "Changelog", href: "/docs/changelog" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Core Concepts",
|
||||
title: "Core",
|
||||
items: [
|
||||
{ title: "Specs", href: "/docs/specs" },
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Validation", href: "/docs/validation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Rendering",
|
||||
items: [
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Generation Modes", href: "/docs/generation-modes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Examples",
|
||||
items: [
|
||||
{
|
||||
title: "Chat",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/chat",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
||||
@@ -55,7 +68,6 @@ export const docsNavigation: NavSection[] = [
|
||||
title: "Guides",
|
||||
items: [
|
||||
{ title: "Custom Schema", href: "/docs/custom-schema" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* Used by both page metadata exports and the OG image route.
|
||||
*
|
||||
* Keys mirror the page's URL path (e.g., "docs/changelog" → /og/docs/changelog).
|
||||
* Values are display titles (without the "| json-render" suffix -- the layout template adds that).
|
||||
* Values are display titles (without the "| json-render" suffix — the layout template adds that).
|
||||
*/
|
||||
export const PAGE_TITLES: Record<string, string> = {
|
||||
// Home (no slug)
|
||||
"": "The framework for\nUser-Generated Interfaces",
|
||||
"": "The Generative UI\nFramework",
|
||||
|
||||
// Top-level
|
||||
playground: "Playground",
|
||||
@@ -24,6 +24,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/validation": "Validation",
|
||||
"docs/data-binding": "Data Binding",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
"docs/ai-sdk": "AI SDK Integration",
|
||||
@@ -31,6 +32,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/openapi": "OpenAPI Integration",
|
||||
"docs/a2ui": "A2UI Integration",
|
||||
"docs/ag-ui": "AG-UI Integration",
|
||||
"docs/migration": "Migration Guide",
|
||||
"docs/changelog": "Changelog",
|
||||
|
||||
// API references
|
||||
|
||||
@@ -21,7 +21,10 @@ const noopRateLimiter = {
|
||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||
};
|
||||
|
||||
// 10 requests per minute (sliding window)
|
||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||
|
||||
// Requests per minute (sliding window)
|
||||
export const minuteRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_minuteRateLimit) {
|
||||
@@ -29,7 +32,7 @@ export const minuteRateLimit = {
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_minuteRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(10, "1 m"),
|
||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||
prefix: "ratelimit:minute",
|
||||
});
|
||||
}
|
||||
@@ -37,7 +40,7 @@ export const minuteRateLimit = {
|
||||
},
|
||||
};
|
||||
|
||||
// 100 requests per day (fixed window)
|
||||
// Requests per day (fixed window)
|
||||
export const dailyRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_dailyRateLimit) {
|
||||
@@ -45,7 +48,7 @@ export const dailyRateLimit = {
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_dailyRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.fixedWindow(100, "1 d"),
|
||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||
prefix: "ratelimit:daily",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Shared utility for extracting catalog data for display in the UI.
|
||||
* Used by both the demo and playground components.
|
||||
*/
|
||||
|
||||
export interface CatalogField {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface CatalogComponentInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
props: CatalogField[];
|
||||
slots: string[];
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface CatalogActionInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
params: CatalogField[];
|
||||
}
|
||||
|
||||
export interface CatalogDisplayData {
|
||||
components: CatalogComponentInfo[];
|
||||
actions: CatalogActionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract field names and types from a Zod schema object.
|
||||
* Supports both Zod v3 and v4 shape formats.
|
||||
*/
|
||||
export function extractFields(zodObj: unknown): CatalogField[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string = s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values) ? `(${values.join(" | ")})?` : "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract display data from a catalog's raw data.
|
||||
* Parses component definitions and action definitions into a
|
||||
* structured format suitable for rendering in the UI.
|
||||
*/
|
||||
export function buildCatalogDisplayData(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rawCatalogData: any,
|
||||
): CatalogDisplayData {
|
||||
const components = Object.entries(rawCatalogData.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(rawCatalogData.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}
|
||||
@@ -67,11 +67,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Tab navigation. Use statePath to bind the active tab value.",
|
||||
"Tab navigation. Use { $bindState } on value for active tab binding.",
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
@@ -297,10 +297,20 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
events: ["submit", "focus", "blur"],
|
||||
description: "Text input field. Use statePath for two-way binding.",
|
||||
description:
|
||||
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
|
||||
example: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
@@ -315,9 +325,19 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
name: z.string(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Multi-line text input. Use statePath for binding.",
|
||||
description:
|
||||
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
|
||||
},
|
||||
|
||||
Select: {
|
||||
@@ -326,10 +346,20 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Dropdown select input. Use statePath for binding.",
|
||||
description:
|
||||
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
@@ -337,10 +367,9 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Checkbox input. Use statePath for binding.",
|
||||
description: "Checkbox input. Use { $bindState } on checked for binding.",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
@@ -348,10 +377,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Radio button group. Use statePath for binding.",
|
||||
description:
|
||||
"Radio button group. Use { $bindState } on value for binding.",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
@@ -359,10 +389,9 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle switch. Use statePath for binding.",
|
||||
description: "Toggle switch. Use { $bindState } on checked for binding.",
|
||||
},
|
||||
|
||||
Slider: {
|
||||
@@ -371,10 +400,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
step: z.number().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.number().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Range slider input. Use statePath for binding.",
|
||||
description:
|
||||
"Range slider input. Use { $bindState } on value for binding.",
|
||||
},
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────
|
||||
@@ -416,11 +446,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
pressed: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
variant: z.enum(["default", "outline"]).nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle button. Use statePath for pressed state binding.",
|
||||
description:
|
||||
"Toggle button. Use { $bindState } on pressed for state binding.",
|
||||
},
|
||||
|
||||
ToggleGroup: {
|
||||
@@ -432,11 +462,11 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Group of toggle buttons. Type 'single' (default) or 'multiple'.",
|
||||
"Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
|
||||
},
|
||||
|
||||
ButtonGroup: {
|
||||
@@ -447,45 +477,46 @@ export const playgroundCatalog = defineCatalog(schema, {
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
statePath: z.string().nullable(),
|
||||
selected: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Segmented button group. Use statePath for selected value.",
|
||||
description:
|
||||
"Segmented button group. Use { $bindState } on selected for selected value.",
|
||||
},
|
||||
|
||||
Pagination: {
|
||||
props: z.object({
|
||||
totalPages: z.number(),
|
||||
statePath: z.string(),
|
||||
page: z.number().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Page navigation. Bind statePath to a number for current page.",
|
||||
"Page navigation. Use { $bindState } on page for current page number.",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
statePath: z.string(),
|
||||
value: z.unknown(),
|
||||
}),
|
||||
description: "Update a value in the state model at the given path.",
|
||||
description: "Update a value in the state model at the given statePath.",
|
||||
},
|
||||
|
||||
pushState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
statePath: z.string(),
|
||||
value: z.unknown(),
|
||||
clearPath: z.string().optional(),
|
||||
clearStatePath: z.string().optional(),
|
||||
}),
|
||||
description:
|
||||
'Append an item to an array in state. Value can contain {path:"/statePath"} refs and "$id" for auto IDs. clearPath resets another path after pushing.',
|
||||
'Append an item to an array in state. Value can contain {"$state":"/statePath"} refs and "$id" for auto IDs. clearStatePath resets another path after pushing.',
|
||||
},
|
||||
|
||||
removeState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
statePath: z.string(),
|
||||
index: z.number(),
|
||||
}),
|
||||
description: "Remove an item from an array in state at the given index.",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { defineRegistry, useStateBinding } from "@json-render/react";
|
||||
import {
|
||||
defineRegistry,
|
||||
useBoundProp,
|
||||
useStateBinding,
|
||||
useFieldValidation,
|
||||
} from "@json-render/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { playgroundCatalog } from "./catalog";
|
||||
@@ -210,25 +215,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
/>
|
||||
),
|
||||
|
||||
Tabs: ({ props, emit }) => {
|
||||
Tabs: ({ props, bindings, emit }) => {
|
||||
const tabs = props.tabs ?? [];
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(
|
||||
props.defaultValue ?? tabs[0]?.value ?? "",
|
||||
);
|
||||
const value = props.statePath
|
||||
? (boundValue ?? tabs[0]?.value ?? "")
|
||||
: localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<TabsPrimitive
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
@@ -765,13 +770,21 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
|
||||
// ── Form Inputs ───────────────────────────────────────────────────
|
||||
|
||||
Input: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Input: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState("");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -784,22 +797,36 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") emit?.("submit");
|
||||
if (e.key === "Enter") emit("submit");
|
||||
}}
|
||||
onFocus={() => emit("focus")}
|
||||
onBlur={() => {
|
||||
if (hasValidation) validate();
|
||||
emit("blur");
|
||||
}}
|
||||
onFocus={() => emit?.("focus")}
|
||||
onBlur={() => emit?.("blur")}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Textarea: ({ props }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Textarea: ({ props, bindings }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState("");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -811,18 +838,26 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
rows={props.rows ?? 3}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (hasValidation) validate();
|
||||
}}
|
||||
/>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Select: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState<string>("");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
const rawOptions = props.options ?? [];
|
||||
// Coerce options to strings – AI may produce objects/numbers instead of
|
||||
// plain strings which would cause duplicate `[object Object]` keys.
|
||||
@@ -830,6 +865,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
typeof opt === "string" ? opt : String(opt ?? ""),
|
||||
);
|
||||
|
||||
const hasValidation = !!(bindings?.value && props.checks?.length);
|
||||
const { errors, validate } = useFieldValidation(
|
||||
bindings?.value ?? "",
|
||||
hasValidation ? { checks: props.checks ?? [] } : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{props.label}</Label>
|
||||
@@ -837,7 +878,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
if (hasValidation) validate();
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -854,17 +896,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.length > 0 && (
|
||||
<p className="text-sm text-destructive">{errors[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Checkbox: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Checkbox: ({ props, bindings, emit }) => {
|
||||
const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const [localChecked, setLocalChecked] = useState(!!props.checked);
|
||||
const checked = props.statePath ? (boundValue ?? false) : localChecked;
|
||||
const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
|
||||
const isBound = !!bindings?.checked;
|
||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -873,7 +920,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c === true);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={props.name} className="cursor-pointer">
|
||||
@@ -883,17 +930,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Radio: ({ props, emit }) => {
|
||||
Radio: ({ props, bindings, emit }) => {
|
||||
const rawOptions = props.options ?? [];
|
||||
const options = rawOptions.map((opt) =>
|
||||
typeof opt === "string" ? opt : String(opt ?? ""),
|
||||
);
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(options[0] ?? "");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -902,7 +951,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{options.map((opt, idx) => (
|
||||
@@ -927,13 +976,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Switch: ({ props, bindings, emit }) => {
|
||||
const [boundChecked, setBoundChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const [localChecked, setLocalChecked] = useState(!!props.checked);
|
||||
const checked = props.statePath ? (boundValue ?? false) : localChecked;
|
||||
const setChecked = props.statePath ? setBoundValue! : setLocalChecked;
|
||||
const isBound = !!bindings?.checked;
|
||||
const checked = isBound ? (boundChecked ?? false) : localChecked;
|
||||
const setChecked = isBound ? setBoundChecked : setLocalChecked;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
@@ -945,22 +996,22 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => {
|
||||
setChecked(c);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Slider: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<number>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Slider: ({ props, bindings, emit }) => {
|
||||
const [boundValue, setBoundValue] = useBoundProp<number>(
|
||||
props.value as number | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(props.min ?? 0);
|
||||
const value = props.statePath
|
||||
? (boundValue ?? props.min ?? 0)
|
||||
: localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? props.min ?? 0) : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -977,7 +1028,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
step={props.step ?? 1}
|
||||
onValueChange={(v) => {
|
||||
setValue(v[0] ?? 0);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -998,7 +1049,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
<Button
|
||||
variant={variant}
|
||||
disabled={props.disabled ?? false}
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
@@ -1009,7 +1060,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
@@ -1024,10 +1075,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onClick={() => emit?.("select")}
|
||||
>
|
||||
<DropdownMenuItem key={item.value} onClick={() => emit("select")}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -1036,13 +1084,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Toggle: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<boolean>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
Toggle: ({ props, bindings, emit }) => {
|
||||
const [boundPressed, setBoundPressed] = useBoundProp<boolean>(
|
||||
props.pressed as boolean | undefined,
|
||||
bindings?.pressed,
|
||||
);
|
||||
const [localPressed, setLocalPressed] = useState(props.pressed ?? false);
|
||||
const pressed = props.statePath ? (boundValue ?? false) : localPressed;
|
||||
const setPressed = props.statePath ? setBoundValue! : setLocalPressed;
|
||||
const isBound = !!bindings?.pressed;
|
||||
const pressed = isBound ? (boundPressed ?? false) : localPressed;
|
||||
const setPressed = isBound ? setBoundPressed : setLocalPressed;
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
@@ -1050,7 +1100,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
pressed={pressed}
|
||||
onPressedChange={(v) => {
|
||||
setPressed(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
@@ -1058,15 +1108,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
ToggleGroup: ({ props, emit }) => {
|
||||
ToggleGroup: ({ props, bindings, emit }) => {
|
||||
const type = props.type ?? "single";
|
||||
const items = props.items ?? [];
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundValue, setBoundValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(items[0]?.value ?? "");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.value;
|
||||
const value = isBound ? (boundValue ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundValue : setLocalValue;
|
||||
|
||||
if (type === "multiple") {
|
||||
return (
|
||||
@@ -1087,7 +1139,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
onValueChange={(v) => {
|
||||
if (v) {
|
||||
setValue(v);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1100,14 +1152,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
ButtonGroup: ({ props, emit }) => {
|
||||
ButtonGroup: ({ props, bindings, emit }) => {
|
||||
const buttons = props.buttons ?? [];
|
||||
const [boundValue, setBoundValue] = props.statePath
|
||||
? useStateBinding<string>(props.statePath) // eslint-disable-line react-hooks/rules-of-hooks
|
||||
: [undefined, undefined];
|
||||
const [boundSelected, setBoundSelected] = useBoundProp<string>(
|
||||
props.selected as string | undefined,
|
||||
bindings?.selected,
|
||||
);
|
||||
const [localValue, setLocalValue] = useState(buttons[0]?.value ?? "");
|
||||
const value = props.statePath ? (boundValue ?? "") : localValue;
|
||||
const setValue = props.statePath ? setBoundValue! : setLocalValue;
|
||||
const isBound = !!bindings?.selected;
|
||||
const value = isBound ? (boundSelected ?? "") : localValue;
|
||||
const setValue = isBound ? setBoundSelected : setLocalValue;
|
||||
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-border">
|
||||
@@ -1123,7 +1177,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
} ${i === buttons.length - 1 ? "rounded-r-md" : ""}`}
|
||||
onClick={() => {
|
||||
setValue(btn.value);
|
||||
emit?.("change");
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
@@ -1133,11 +1187,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
);
|
||||
},
|
||||
|
||||
Pagination: ({ props, emit }) => {
|
||||
const [boundValue, setBoundValue] = useStateBinding<number>(
|
||||
props.statePath,
|
||||
Pagination: ({ props, bindings, emit }) => {
|
||||
const [boundPage, setBoundPage] = useBoundProp<number>(
|
||||
props.page as number | undefined,
|
||||
bindings?.page,
|
||||
);
|
||||
const currentPage = boundValue ?? 1;
|
||||
const currentPage = boundPage ?? 1;
|
||||
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
@@ -1149,8 +1204,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) {
|
||||
setBoundValue(currentPage - 1);
|
||||
emit?.("change");
|
||||
setBoundPage(currentPage - 1);
|
||||
emit("change");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1162,8 +1217,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
isActive={page === currentPage}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setBoundValue(page);
|
||||
emit?.("change");
|
||||
setBoundPage(page);
|
||||
emit("change");
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
@@ -1176,8 +1231,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < props.totalPages) {
|
||||
setBoundValue(currentPage + 1);
|
||||
emit?.("change");
|
||||
setBoundPage(currentPage + 1);
|
||||
emit("change");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useMemo, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Renderer,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
ValidationProvider,
|
||||
useValidation,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { registry, Fallback } from "./registry";
|
||||
@@ -27,27 +29,44 @@ const fallbackRenderer = (renderProps: { element: { type: string } }) => (
|
||||
);
|
||||
|
||||
/**
|
||||
* Action handlers for the playground preview.
|
||||
* These are passed to ActionProvider so custom actions (buttonClick, formSubmit,
|
||||
* linkClick) work when triggered from the rendered UI.
|
||||
* Inner component that sits inside ValidationProvider so it can call
|
||||
* useValidation() and wire validateAll into the formSubmit action handler.
|
||||
*
|
||||
* ActionProvider stores `handlers` in useState, so it only reads the initial
|
||||
* value. We use a ref so the handlers object is stable (created once) but
|
||||
* formSubmit always reads the latest validateAll.
|
||||
*/
|
||||
const actionHandlers: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void
|
||||
> = {
|
||||
buttonClick: (params) => {
|
||||
const message = (params?.message as string) || "Button clicked!";
|
||||
toast.success(message);
|
||||
},
|
||||
formSubmit: (params) => {
|
||||
const formName = (params?.formName as string) || "Form";
|
||||
toast.success(`${formName} submitted successfully!`);
|
||||
},
|
||||
linkClick: (params) => {
|
||||
const href = (params?.href as string) || "#";
|
||||
toast.info(`Navigating to: ${href}`);
|
||||
},
|
||||
};
|
||||
function ValidatedActions({ children }: { children: ReactNode }) {
|
||||
const { validateAll } = useValidation();
|
||||
const validateAllRef = useRef(validateAll);
|
||||
validateAllRef.current = validateAll;
|
||||
|
||||
const handlers = useMemo<
|
||||
Record<string, (params: Record<string, unknown>) => void>
|
||||
>(
|
||||
() => ({
|
||||
buttonClick: (params) => {
|
||||
const message = (params?.message as string) || "Button clicked!";
|
||||
toast.success(message);
|
||||
},
|
||||
formSubmit: () => {
|
||||
const allValid = validateAllRef.current();
|
||||
if (!allValid) {
|
||||
toast.error("Please fix the errors before submitting.");
|
||||
return;
|
||||
}
|
||||
toast.success("Form submitted successfully!");
|
||||
},
|
||||
linkClick: (params) => {
|
||||
const href = (params?.href as string) || "#";
|
||||
toast.info(`Navigating to: ${href}`);
|
||||
},
|
||||
}),
|
||||
[], // stable — ref ensures latest validateAll is always used
|
||||
);
|
||||
|
||||
return <ActionProvider handlers={handlers}>{children}</ActionProvider>;
|
||||
}
|
||||
|
||||
export function PlaygroundRenderer({
|
||||
spec,
|
||||
@@ -59,14 +78,16 @@ export function PlaygroundRenderer({
|
||||
return (
|
||||
<StateProvider initialState={data ?? spec.state}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRenderer}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
<ValidationProvider>
|
||||
<ValidatedActions>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRenderer}
|
||||
loading={loading}
|
||||
/>
|
||||
</ValidatedActions>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
import { GenerationModesDiagram } from "@/components/generation-modes-diagram";
|
||||
import { PackageInstall } from "@/components/package-install";
|
||||
|
||||
function slugify(text: string): string {
|
||||
@@ -130,7 +131,9 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
),
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">{children}</table>
|
||||
<table className="mdx-table w-full text-sm border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
@@ -145,6 +148,7 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
|
||||
// Custom components available in all MDX files
|
||||
GenerationModesDiagram,
|
||||
PackageInstall,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ const nextConfig = {
|
||||
destination: "/docs/registry",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/docs/actions",
|
||||
destination: "/docs/registry#action-handlers",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"shiki": "^3.21.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "2.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { agent } from "@/lib/agent";
|
||||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { pipeJsonRender } from "@json-render/core";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const uiMessages: UIMessage[] = body.messages;
|
||||
|
||||
if (!uiMessages || !Array.isArray(uiMessages) || uiMessages.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "messages array is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
const result = await agent.stream({ messages: modelMessages });
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
writer.merge(pipeJsonRender(result.toUIMessageStream()));
|
||||
},
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../../node_modules/streamdown/dist/*.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
currentColor 25%,
|
||||
hsl(0 0% 64%) 50%,
|
||||
currentColor 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "streamdown/styles.css";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render Chat Example",
|
||||
description: "AI-powered data explorer using ToolLoopAgent and json-render",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
||||
import {
|
||||
SPEC_DATA_PART,
|
||||
SPEC_DATA_PART_TYPE,
|
||||
type SpecDataPart,
|
||||
} from "@json-render/core";
|
||||
import { useJsonRenderMessage } from "@json-render/react";
|
||||
import { ExplorerRenderer } from "@/lib/render/renderer";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { code } from "@streamdown/code";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type AppDataParts = { [SPEC_DATA_PART]: SpecDataPart };
|
||||
type AppMessage = UIMessage<unknown, AppDataParts>;
|
||||
|
||||
// =============================================================================
|
||||
// Transport
|
||||
// =============================================================================
|
||||
|
||||
const transport = new DefaultChatTransport({ api: "/api/generate" });
|
||||
|
||||
// =============================================================================
|
||||
// Suggestions (shown in empty state)
|
||||
// =============================================================================
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
label: "Weather comparison",
|
||||
prompt: "Compare the weather in New York, London, and Tokyo",
|
||||
},
|
||||
{
|
||||
label: "GitHub repo stats",
|
||||
prompt: "Show me stats for the vercel/next.js and vercel/ai GitHub repos",
|
||||
},
|
||||
{
|
||||
label: "Crypto dashboard",
|
||||
prompt: "Build a crypto dashboard for Bitcoin, Ethereum, and Solana",
|
||||
},
|
||||
{
|
||||
label: "Hacker News top stories",
|
||||
prompt: "Show me the top 15 Hacker News stories right now",
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Tool Call Display
|
||||
// =============================================================================
|
||||
|
||||
/** Readable labels for tool names: [loading, done] */
|
||||
const TOOL_LABELS: Record<string, [string, string]> = {
|
||||
getWeather: ["Getting weather data", "Got weather data"],
|
||||
getGitHubRepo: ["Fetching GitHub repo", "Fetched GitHub repo"],
|
||||
getGitHubPullRequests: ["Fetching pull requests", "Fetched pull requests"],
|
||||
getCryptoPrice: ["Looking up crypto price", "Looked up crypto price"],
|
||||
getCryptoPriceHistory: ["Fetching price history", "Fetched price history"],
|
||||
getHackerNewsTop: ["Loading Hacker News", "Loaded Hacker News"],
|
||||
webSearch: ["Searching the web", "Searched the web"],
|
||||
};
|
||||
|
||||
function ToolCallDisplay({
|
||||
toolName,
|
||||
state,
|
||||
result,
|
||||
}: {
|
||||
toolName: string;
|
||||
state: string;
|
||||
result: unknown;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLoading =
|
||||
state !== "output-available" &&
|
||||
state !== "output-error" &&
|
||||
state !== "output-denied";
|
||||
const labels = TOOL_LABELS[toolName];
|
||||
const label = labels ? (isLoading ? labels[0] : labels[1]) : toolName;
|
||||
|
||||
return (
|
||||
<div className="text-sm group">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<span
|
||||
className={`text-muted-foreground ${isLoading ? "animate-shimmer" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 text-muted-foreground/0 group-hover:text-muted-foreground transition-all ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{expanded && !isLoading && result != null && (
|
||||
<div className="mt-1 max-h-64 overflow-auto">
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Bubble
|
||||
// =============================================================================
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
isLast,
|
||||
isStreaming,
|
||||
}: {
|
||||
message: AppMessage;
|
||||
isLast: boolean;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const isUser = message.role === "user";
|
||||
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
|
||||
|
||||
// Build ordered segments from parts, collapsing adjacent text and adjacent tools.
|
||||
// Spec data parts are tracked so the rendered UI appears inline where the AI
|
||||
// placed it rather than always at the bottom.
|
||||
const segments: Array<
|
||||
| { kind: "text"; text: string }
|
||||
| {
|
||||
kind: "tools";
|
||||
tools: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
state: string;
|
||||
output?: unknown;
|
||||
}>;
|
||||
}
|
||||
| { kind: "spec" }
|
||||
> = [];
|
||||
|
||||
let specInserted = false;
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text") {
|
||||
if (!part.text.trim()) continue;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === "text") {
|
||||
last.text += part.text;
|
||||
} else {
|
||||
segments.push({ kind: "text", text: part.text });
|
||||
}
|
||||
} else if (part.type.startsWith("tool-")) {
|
||||
const tp = part as {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: string;
|
||||
output?: unknown;
|
||||
};
|
||||
const last = segments[segments.length - 1];
|
||||
if (last?.kind === "tools") {
|
||||
last.tools.push({
|
||||
toolCallId: tp.toolCallId,
|
||||
toolName: tp.type.replace(/^tool-/, ""),
|
||||
state: tp.state,
|
||||
output: tp.output,
|
||||
});
|
||||
} else {
|
||||
segments.push({
|
||||
kind: "tools",
|
||||
tools: [
|
||||
{
|
||||
toolCallId: tp.toolCallId,
|
||||
toolName: tp.type.replace(/^tool-/, ""),
|
||||
state: tp.state,
|
||||
output: tp.output,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (part.type === SPEC_DATA_PART_TYPE && !specInserted) {
|
||||
// First spec data part — mark where the rendered UI should appear
|
||||
segments.push({ kind: "spec" });
|
||||
specInserted = true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnything = segments.length > 0 || hasSpec;
|
||||
const showLoader =
|
||||
isLast && isStreaming && message.role === "assistant" && !hasAnything;
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
{text && (
|
||||
<div className="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap bg-primary text-primary-foreground rounded-tr-md">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If there's a spec but no spec segment was inserted (edge case),
|
||||
// append it so it still renders.
|
||||
const specRenderedInline = specInserted;
|
||||
const showSpecAtEnd = hasSpec && !specRenderedInline;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.kind === "text") {
|
||||
const isLastSegment = i === segments.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={`text-${i}`}
|
||||
className="text-sm leading-relaxed [&_p+p]:mt-3 [&_ul]:mt-2 [&_ol]:mt-2 [&_pre]:mt-2"
|
||||
>
|
||||
<Streamdown
|
||||
plugins={{ code }}
|
||||
animated={isLast && isStreaming && isLastSegment}
|
||||
>
|
||||
{seg.text}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (seg.kind === "spec") {
|
||||
if (!hasSpec) return null;
|
||||
return (
|
||||
<div key="spec" className="w-full">
|
||||
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={`tools-${i}`} className="flex flex-col gap-1">
|
||||
{seg.tools.map((t) => (
|
||||
<ToolCallDisplay
|
||||
key={t.toolCallId}
|
||||
toolName={t.toolName}
|
||||
state={t.state}
|
||||
result={t.output}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{showLoader && (
|
||||
<div className="text-sm text-muted-foreground animate-shimmer">
|
||||
Thinking...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: render spec at end if no inline position was found */}
|
||||
{showSpecAtEnd && (
|
||||
<div className="w-full">
|
||||
<ExplorerRenderer spec={spec} loading={isLast && isStreaming} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Page
|
||||
// =============================================================================
|
||||
|
||||
export default function ChatPage() {
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const isStickToBottom = useRef(true);
|
||||
const isAutoScrolling = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { messages, sendMessage, setMessages, status, error } =
|
||||
useChat<AppMessage>({ transport });
|
||||
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
// Track whether the user has scrolled away from the bottom.
|
||||
// During programmatic scrolling, suppress button updates until we arrive.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const THRESHOLD = 80;
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const atBottom = scrollTop + clientHeight >= scrollHeight - THRESHOLD;
|
||||
|
||||
if (isAutoScrolling.current) {
|
||||
// Wait for the programmatic scroll to reach the bottom before
|
||||
// handing control back to the user-scroll tracker.
|
||||
if (atBottom) {
|
||||
isAutoScrolling.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isStickToBottom.current = atBottom;
|
||||
setShowScrollButton(!atBottom);
|
||||
};
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom on new messages, unless user scrolled up.
|
||||
// Uses instant scrollTop assignment (no smooth animation) to avoid
|
||||
// an ongoing animation that fights user scroll input.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || !isStickToBottom.current) return;
|
||||
isAutoScrolling.current = true;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
isAutoScrolling.current = false;
|
||||
});
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
isStickToBottom.current = true;
|
||||
setShowScrollButton(false);
|
||||
isAutoScrolling.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "smooth" });
|
||||
// isAutoScrolling is cleared by the scroll handler once it reaches bottom
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (text?: string) => {
|
||||
const message = text || input;
|
||||
if (!message.trim() || isStreaming) return;
|
||||
setInput("");
|
||||
await sendMessage({ text: message.trim() });
|
||||
},
|
||||
[input, isStreaming, sendMessage],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
}, [setMessages]);
|
||||
|
||||
const isEmpty = messages.length === 0;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="border-b px-6 py-3 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">json-render Chat Example</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
Start Over
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Messages area */}
|
||||
<main ref={scrollContainerRef} className="flex-1 overflow-auto">
|
||||
{isEmpty ? (
|
||||
/* Empty state */
|
||||
<div className="h-full flex flex-col items-center justify-center px-6 py-12">
|
||||
<div className="max-w-2xl w-full space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
What would you like to explore?
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Ask about weather, GitHub repos, crypto prices, or Hacker News
|
||||
-- the agent will fetch real data and build a dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
onClick={() => handleSubmit(s.prompt)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Message thread */
|
||||
<div className="max-w-4xl mx-auto px-10 py-6 space-y-6">
|
||||
{messages.map((message, index) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Input bar - always visible at bottom */}
|
||||
<div className="px-6 pb-3 flex-shrink-0 bg-background relative">
|
||||
{/* Scroll to bottom button */}
|
||||
{showScrollButton && !isEmpty && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute left-1/2 -translate-x-1/2 -top-10 z-10 h-8 w-8 rounded-full border border-border bg-background text-muted-foreground shadow-md flex items-center justify-center hover:text-foreground hover:bg-accent transition-colors"
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="max-w-4xl mx-auto relative">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isEmpty
|
||||
? "e.g., Compare weather in NYC, London, and Tokyo..."
|
||||
: "Ask a follow-up..."
|
||||
}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border border-input bg-card px-4 py-3 pr-12 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="absolute right-3 bottom-3 h-8 w-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className="p-2 rounded-md border border-border bg-card">
|
||||
<Sun className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-2 rounded-md border border-border bg-card hover:bg-accent transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export type { ButtonProps };
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Progress as ProgressPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||
import { Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ComponentRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
));
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{
|
||||
ignore: [
|
||||
"jsx",
|
||||
// React Three Fiber properties
|
||||
"args",
|
||||
"position",
|
||||
"rotation",
|
||||
"intensity",
|
||||
"distance",
|
||||
"metalness",
|
||||
"roughness",
|
||||
"emissive",
|
||||
"emissiveIntensity",
|
||||
"wireframe",
|
||||
"transparent",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { explorerCatalog } from "./render/catalog";
|
||||
import { getWeather } from "./tools/weather";
|
||||
import { getGitHubRepo, getGitHubPullRequests } from "./tools/github";
|
||||
import { getCryptoPrice, getCryptoPriceHistory } from "./tools/crypto";
|
||||
import { getHackerNewsTop } from "./tools/hackernews";
|
||||
import { webSearch } from "./tools/search";
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const AGENT_INSTRUCTIONS = `You are a knowledgeable assistant that helps users explore data and learn about any topic. You look up real-time information, build visual dashboards, and create rich educational content.
|
||||
|
||||
WORKFLOW:
|
||||
1. Call the appropriate tools to gather relevant data. Use webSearch for general topics not covered by specialized tools.
|
||||
2. Respond with a brief, conversational summary of what you found.
|
||||
3. Then output the JSONL UI spec wrapped in a \`\`\`spec fence to render a rich visual experience.
|
||||
|
||||
RULES:
|
||||
- Always call tools FIRST to get real data. Never make up data.
|
||||
- Embed the fetched data directly in /state paths so components can reference it.
|
||||
- Use Card components to group related information.
|
||||
- NEVER nest a Card inside another Card. If you need sub-sections inside a Card, use Stack, Separator, Heading, or Accordion instead.
|
||||
- Use Grid for multi-column layouts.
|
||||
- Use Metric for key numeric values (temperature, stars, price, etc.).
|
||||
- Use Table for lists of items (stories, forecasts, languages, etc.).
|
||||
- Use BarChart or LineChart for numeric trends and time-series data.
|
||||
- Use PieChart for compositional/proportional data (market share, breakdowns, distributions).
|
||||
- Use Tabs when showing multiple categories of data side by side.
|
||||
- Use Badge for status indicators.
|
||||
- Use Callout for key facts, tips, warnings, or important takeaways.
|
||||
- Use Accordion to organize detailed sections the user can expand for deeper reading.
|
||||
- Use Timeline for historical events, processes, step-by-step explanations, or milestones.
|
||||
- When teaching about a topic, combine multiple component types to create a rich, engaging experience.
|
||||
|
||||
3D SCENES:
|
||||
You can build interactive 3D scenes using React Three Fiber primitives. Use these when the user asks about spatial/visual topics (solar system, molecules, geometry, architecture, physics, etc.).
|
||||
|
||||
SCENE STRUCTURE:
|
||||
- Scene3D is the root container. ALL other 3D components must be descendants of a Scene3D.
|
||||
- Set height (CSS string like "500px"), background color, and cameraPosition [x,y,z].
|
||||
- Scene3D includes orbit controls so users can rotate, zoom, and pan the camera.
|
||||
|
||||
3D PRIMITIVES:
|
||||
- Sphere, Box, Cylinder, Cone, Torus, Plane, Ring — geometry meshes with built-in materials.
|
||||
- All accept: position [x,y,z], rotation [x,y,z], scale [x,y,z], color, args (geometry dimensions), metalness, roughness, emissive, emissiveIntensity, wireframe, opacity.
|
||||
- args vary per geometry: Sphere [radius, wSeg, hSeg], Box [w, h, d], Cylinder [rTop, rBot, h, seg], Ring [inner, outer, seg], etc.
|
||||
- Use emissive + emissiveIntensity for glowing objects (like stars/suns).
|
||||
|
||||
GROUPING & ANIMATION:
|
||||
- Group3D groups children and applies shared transform + animation.
|
||||
- animation: { rotate: [x, y, z] } — continuous rotation speed per frame on each axis.
|
||||
- IMPORTANT: Rotation values are applied EVERY FRAME (~60fps). Use very small values! Good orbit speeds are 0.0005 to 0.003. Values above 0.01 look frantic.
|
||||
- ORBIT PATTERN: To make an object orbit a center point, put it inside a Group3D with rotation animation. Position the object at its orbital distance from center. The rotating group creates the orbit.
|
||||
Example: Group3D(animation: {rotate: [0, 0.001, 0]}) > Sphere(position: [15, 0, 0]) — the sphere orbits at radius 15.
|
||||
- For self-rotation (planet spinning), use animation on the Sphere itself with small values like 0.002-0.005.
|
||||
|
||||
LIGHTS:
|
||||
- AmbientLight: base illumination for the whole scene (intensity ~0.2-0.5).
|
||||
- PointLight: emits from a position in all directions. Use for suns, lamps. Set high intensity (2+) for bright sources.
|
||||
- DirectionalLight: parallel rays like sunlight. Position sets direction.
|
||||
- Always include at least an AmbientLight so objects are visible.
|
||||
|
||||
HELPERS:
|
||||
- Stars: starfield background. Use for space scenes. count=5000, fade=true is a good default.
|
||||
- Label3D: text in 3D space that always faces the camera. Use to label objects. fontSize ~0.5-1.0 for readable labels.
|
||||
- Ring: great for orbit path indicators. Rotate [-1.5708, 0, 0] (i.e. -PI/2) to lay flat, set low opacity (~0.15-0.3).
|
||||
|
||||
3D SCENE EXAMPLE (Solar System — all 8 planets):
|
||||
Scene3D(height="500px", background="#000010", cameraPosition=[0,30,60]) >
|
||||
Stars(count=5000, fade=true)
|
||||
AmbientLight(intensity=0.2)
|
||||
PointLight(position=[0,0,0], intensity=2)
|
||||
Sphere(args=[2.5,32,32], color="#FDB813", emissive="#FDB813", emissiveIntensity=1) — Sun
|
||||
Group3D(animation={rotate:[0,0.003,0]}) > Sphere(position=[5,0,0], args=[0.3,16,16], color="#8C7853") — Mercury
|
||||
Group3D(animation={rotate:[0,0.002,0]}) > Sphere(position=[8,0,0], args=[0.7,16,16], color="#FFC649") — Venus
|
||||
Group3D(animation={rotate:[0,0.0015,0]}) > [Sphere(position=[12,0,0], args=[0.8,16,16], color="#4B7BE5"), Group3D(position=[12,0,0], animation={rotate:[0,0.008,0]}) > Sphere(position=[1.5,0,0], args=[0.2,12,12], color="#CCC")] — Earth + Moon
|
||||
Group3D(animation={rotate:[0,0.001,0]}) > Sphere(position=[16,0,0], args=[0.5,16,16], color="#E27B58") — Mars
|
||||
Group3D(animation={rotate:[0,0.0005,0]}) > Sphere(position=[22,0,0], args=[2,20,20], color="#C88B3A") — Jupiter
|
||||
Group3D(animation={rotate:[0,0.0003,0]}) > Sphere(position=[28,0,0], args=[1.7,20,20], color="#FAD5A5") — Saturn
|
||||
Group3D(animation={rotate:[0,0.0002,0]}) > Sphere(position=[34,0,0], args=[1.2,16,16], color="#ACE5EE") — Uranus
|
||||
Group3D(animation={rotate:[0,0.00015,0]}) > Sphere(position=[40,0,0], args=[1.1,16,16], color="#5B5EA6") — Neptune
|
||||
Ring(rotation=[-1.5708,0,0], args=[inner,outer,64], color="#ffffff", opacity=0.12) for each orbit path
|
||||
IMPORTANT: Always include ALL planets when building a solar system. Do not truncate to just 4.
|
||||
|
||||
MIXING 2D AND 3D:
|
||||
- You can combine 3D scenes with regular 2D components in the same spec. For example, use a Stack or Card at the root with a Scene3D plus Text, Callout, Accordion, etc. as siblings. This lets you build a rich educational experience with both an interactive 3D visualization and text content.
|
||||
|
||||
DATA BINDING:
|
||||
- The state model is the single source of truth. Put fetched data in /state, then reference it with { "$state": "/json/pointer" } in any prop.
|
||||
- $state works on ANY prop at ANY nesting level. The renderer resolves expressions before components receive props.
|
||||
- Scalar binding: "title": { "$state": "/quiz/title" }
|
||||
- Array binding: "items": { "$state": "/quiz/questions" } (for Accordion, Timeline, etc.)
|
||||
- For Table, BarChart, LineChart, and PieChart, use { "$state": "/path" } on the data prop to bind read-only data from state.
|
||||
- Always emit /state patches BEFORE the elements that reference them, so data is available when the UI renders.
|
||||
- Always use the { "$state": "/foo" } object syntax for data binding.
|
||||
|
||||
INTERACTIVITY:
|
||||
- You can use visible, repeat, on.press, and $cond/$then/$else freely.
|
||||
- visible: Conditionally show/hide elements based on state. e.g. "visible": { "$state": "/q1/answer", "eq": "a" }
|
||||
- repeat: Iterate over state arrays. e.g. "repeat": { "statePath": "/items" }
|
||||
- on.press: Trigger actions on button clicks. e.g. "on": { "press": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }
|
||||
- $cond/$then/$else: Conditional prop values. e.g. { "$cond": { "$state": "/correct" }, "$then": "Correct!", "$else": "Try again" }
|
||||
|
||||
BUILT-IN ACTIONS (use with on.press):
|
||||
- setState: Set a value at a state path. params: { statePath: "/foo", value: "bar" }
|
||||
- pushState: Append to an array. params: { statePath: "/items", value: { ... } }
|
||||
- removeState: Remove by index. params: { statePath: "/items", index: 0 }
|
||||
|
||||
INPUT COMPONENTS:
|
||||
- RadioGroup: Renders radio buttons. Writes selected value to statePath automatically.
|
||||
- SelectInput: Dropdown select. Writes selected value to statePath automatically.
|
||||
- TextInput: Text input field. Writes entered value to statePath automatically.
|
||||
- Button: Clickable button. Use on.press to trigger actions.
|
||||
|
||||
PATTERN — INTERACTIVE QUIZZES:
|
||||
When the user asks for a quiz, test, or Q&A, build an interactive experience:
|
||||
1. Initialize state for each question's answer and submission status:
|
||||
{"op":"add","path":"/state/q1","value":""}
|
||||
{"op":"add","path":"/state/q1_submitted","value":false}
|
||||
2. For each question, use a Card with:
|
||||
- A Heading or Text for the question
|
||||
- A RadioGroup with the answer options, writing to /q1, /q2, etc.
|
||||
- A Button with on.press to set the submitted flag: {"action":"setState","params":{"statePath":"/q1_submitted","value":true}}
|
||||
- A Text (or Callout) showing feedback, using visible to show only after submission:
|
||||
"visible": [{"$state":"/q1_submitted","eq":true},{"$state":"/q1","eq":"correct_value"}]
|
||||
- Show correct/incorrect feedback using separate visible conditions on different elements.
|
||||
3. Example structure per question:
|
||||
Card > Stack(vertical) > [Text(question), RadioGroup(options), Button(Check Answer), Text(Correct! visible when right), Callout(Wrong, visible when wrong & submitted)]
|
||||
4. You can also add a final score section that becomes visible when all questions are submitted.
|
||||
|
||||
${explorerCatalog.prompt({
|
||||
mode: "chat",
|
||||
customRules: [
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
|
||||
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
|
||||
"Use Metric components for key numbers instead of plain Text.",
|
||||
"Put chart data arrays in /state and reference them with { $state: '/path' } on the data prop.",
|
||||
"Keep the UI clean and information-dense — no excessive padding or empty space.",
|
||||
"For educational prompts ('teach me about', 'explain', 'what is'), use a mix of Callout, Accordion, Timeline, and charts to make the content visually rich.",
|
||||
],
|
||||
})}`;
|
||||
|
||||
export const agent = new ToolLoopAgent({
|
||||
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
|
||||
instructions: AGENT_INSTRUCTIONS,
|
||||
tools: {
|
||||
getWeather,
|
||||
getGitHubRepo,
|
||||
getGitHubPullRequests,
|
||||
getCryptoPrice,
|
||||
getCryptoPriceHistory,
|
||||
getHackerNewsTop,
|
||||
webSearch,
|
||||
},
|
||||
stopWhen: stepCountIs(5),
|
||||
temperature: 0.7,
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Shared 3D schemas
|
||||
// =============================================================================
|
||||
|
||||
const vec3 = z.array(z.number());
|
||||
|
||||
const animation3D = z
|
||||
.object({
|
||||
rotate: vec3.nullable(),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
const transform3DProps = {
|
||||
position: vec3.nullable(),
|
||||
rotation: vec3.nullable(),
|
||||
scale: vec3.nullable(),
|
||||
};
|
||||
|
||||
const material3DProps = {
|
||||
color: z.string().nullable(),
|
||||
metalness: z.number().nullable(),
|
||||
roughness: z.number().nullable(),
|
||||
emissive: z.string().nullable(),
|
||||
emissiveIntensity: z.number().nullable(),
|
||||
wireframe: z.boolean().nullable(),
|
||||
opacity: z.number().nullable(),
|
||||
};
|
||||
|
||||
const mesh3DProps = {
|
||||
...transform3DProps,
|
||||
...material3DProps,
|
||||
args: z.array(z.number()).nullable(),
|
||||
animation: animation3D,
|
||||
};
|
||||
|
||||
/**
|
||||
* json-render + AI SDK Example Catalog
|
||||
*
|
||||
* Components for rendering data dashboards generated by the ToolLoopAgent.
|
||||
* Data flows in through tools (weather, GitHub, crypto, HN), not user actions.
|
||||
*/
|
||||
export const explorerCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Layout
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
wrap: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex layout container",
|
||||
example: { direction: "vertical", gap: "md", wrap: null },
|
||||
},
|
||||
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Card container with optional title and description",
|
||||
example: { title: "Weather", description: "Current conditions" },
|
||||
},
|
||||
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.enum(["1", "2", "3", "4"]).nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Responsive grid layout container",
|
||||
example: { columns: "3", gap: "md" },
|
||||
},
|
||||
|
||||
// Typography
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Section heading",
|
||||
example: { text: "Data Explorer", level: "h1" },
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
muted: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Text content",
|
||||
example: { content: "Here is your data overview." },
|
||||
},
|
||||
|
||||
// Data display
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
example: { text: "Live", variant: "default" },
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
variant: z.enum(["default", "destructive"]).nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
description: "Alert or info message",
|
||||
},
|
||||
|
||||
Separator: {
|
||||
props: z.object({}),
|
||||
description: "Visual divider",
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
detail: z.string().nullable(),
|
||||
trend: z.enum(["up", "down", "neutral"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Single metric display with label, value, and optional trend indicator",
|
||||
example: {
|
||||
label: "Temperature",
|
||||
value: "72F",
|
||||
detail: "Feels like 68F",
|
||||
trend: "up",
|
||||
},
|
||||
},
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
emptyMessage: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Data table. Use { "$state": "/path" } to bind read-only data from state.',
|
||||
example: {
|
||||
data: { $state: "/stories" },
|
||||
columns: [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "score", label: "Score" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
href: z.string(),
|
||||
}),
|
||||
description: "External link that opens in a new tab",
|
||||
example: { text: "View on GitHub", href: "https://github.com" },
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Bar chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the category field, yKey is the numeric value field.',
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Line chart visualization. Use { "$state": "/path" } to bind read-only data. xKey is the x-axis field, yKey is the numeric value field.',
|
||||
},
|
||||
|
||||
// Interactive
|
||||
Tabs: {
|
||||
props: z.object({
|
||||
defaultValue: z.string().nullable(),
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Tabbed content container",
|
||||
},
|
||||
|
||||
TabContent: {
|
||||
props: z.object({
|
||||
value: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Content for a specific tab",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
}),
|
||||
description: "Progress bar",
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
props: z.object({
|
||||
width: z.string().nullable(),
|
||||
height: z.string().nullable(),
|
||||
}),
|
||||
description: "Loading placeholder",
|
||||
},
|
||||
|
||||
// Educational / Rich content
|
||||
Callout: {
|
||||
props: z.object({
|
||||
type: z.enum(["info", "tip", "warning", "important"]).nullable(),
|
||||
title: z.string().nullable(),
|
||||
content: z.string(),
|
||||
}),
|
||||
description:
|
||||
"Highlighted callout box for tips, warnings, notes, or key information",
|
||||
example: {
|
||||
type: "tip",
|
||||
title: "Did you know?",
|
||||
content: "The sun is about 93 million miles from Earth.",
|
||||
},
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Collapsible accordion sections for organizing detailed content",
|
||||
example: {
|
||||
items: [{ title: "Overview", content: "A brief introduction." }],
|
||||
type: "multiple",
|
||||
},
|
||||
},
|
||||
|
||||
Timeline: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
date: z.string().nullable(),
|
||||
status: z.enum(["completed", "current", "upcoming"]).nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
"Vertical timeline showing ordered events, steps, or historical milestones",
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
title: "Discovery",
|
||||
description: "Initial breakthrough",
|
||||
date: "1905",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
PieChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
nameKey: z.string(),
|
||||
valueKey: z.string(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Pie/donut chart for proportional data. Use { "$state": "/path" } to bind read-only data. nameKey is the label field, valueKey is the numeric value field.',
|
||||
},
|
||||
|
||||
// Interactive / Input
|
||||
RadioGroup: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
'Radio button group for single selection. Use { "$bindState": "/path" } for two-way binding. Use for multiple-choice questions, settings, or any single-select input.',
|
||||
example: {
|
||||
label: "Choose one",
|
||||
value: { $bindState: "/answer" },
|
||||
options: [
|
||||
{ value: "a", label: "Option A" },
|
||||
{ value: "b", label: "Option B" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
SelectInput: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
'Dropdown select input. Use { "$bindState": "/path" } for two-way binding. Use when there are many options and a dropdown is more compact than radio buttons.',
|
||||
example: {
|
||||
label: "Country",
|
||||
value: { $bindState: "/selectedCountry" },
|
||||
placeholder: "Select a country",
|
||||
options: [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
TextInput: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
type: z.enum(["text", "email", "number", "password", "url"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
'Text input field. Use { "$bindState": "/path" } for two-way binding. Use for free-text entry like names, emails, search, etc.',
|
||||
example: {
|
||||
label: "Your name",
|
||||
value: { $bindState: "/userName" },
|
||||
placeholder: "Enter your name",
|
||||
type: "text",
|
||||
},
|
||||
},
|
||||
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline", "ghost"])
|
||||
.nullable(),
|
||||
size: z.enum(["default", "sm", "lg"]).nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. Use with on.press to trigger actions like setState, pushState, etc. Can be used for quiz submissions, form actions, navigation, and more.",
|
||||
example: {
|
||||
label: "Submit",
|
||||
variant: "default",
|
||||
size: "default",
|
||||
disabled: null,
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// 3D Scene Components (React Three Fiber)
|
||||
// =========================================================================
|
||||
|
||||
// Containers
|
||||
Scene3D: {
|
||||
props: z.object({
|
||||
height: z.string().nullable(),
|
||||
background: z.string().nullable(),
|
||||
cameraPosition: vec3.nullable(),
|
||||
cameraFov: z.number().nullable(),
|
||||
autoRotate: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"3D scene container with orbit controls. All 3D components (Sphere, Box, lights, etc.) must be children of a Scene3D. height is a CSS value like '500px'.",
|
||||
example: {
|
||||
height: "500px",
|
||||
background: "#000010",
|
||||
cameraPosition: [0, 25, 45],
|
||||
cameraFov: null,
|
||||
autoRotate: null,
|
||||
},
|
||||
},
|
||||
|
||||
Group3D: {
|
||||
props: z.object({
|
||||
...transform3DProps,
|
||||
animation: animation3D,
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"3D group for positioning, rotating, and animating children together. Use to create orbits: position a planet inside a Group3D and animate the group's rotation.",
|
||||
example: {
|
||||
position: null,
|
||||
rotation: null,
|
||||
scale: null,
|
||||
animation: { rotate: [0, 0.005, 0] },
|
||||
},
|
||||
},
|
||||
|
||||
// Geometry primitives
|
||||
Box: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D box/cube mesh. args: [width, height, depth]. Supports on.press for click interaction.",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#4488ff",
|
||||
args: [1, 1, 1],
|
||||
},
|
||||
},
|
||||
|
||||
Sphere: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D sphere mesh. args: [radius, widthSegments, heightSegments]. Use higher segment counts (32+) for smooth spheres.",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#4B7BE5",
|
||||
args: [1, 32, 32],
|
||||
},
|
||||
},
|
||||
|
||||
Cylinder: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D cylinder mesh. args: [radiusTop, radiusBottom, height, radialSegments].",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#88aa44",
|
||||
args: [1, 1, 2, 32],
|
||||
},
|
||||
},
|
||||
|
||||
Cone: {
|
||||
props: z.object(mesh3DProps),
|
||||
description: "3D cone mesh. args: [radius, height, radialSegments].",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#ff8844",
|
||||
args: [1, 2, 32],
|
||||
},
|
||||
},
|
||||
|
||||
Torus: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D torus (donut) mesh. args: [radius, tube, radialSegments, tubularSegments].",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
color: "#aa44ff",
|
||||
args: [1, 0.4, 16, 100],
|
||||
},
|
||||
},
|
||||
|
||||
Plane: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D flat plane mesh. args: [width, height]. Useful for ground planes or flat surfaces.",
|
||||
example: {
|
||||
position: [0, -1, 0],
|
||||
rotation: [-Math.PI / 2, 0, 0],
|
||||
color: "#334455",
|
||||
args: [10, 10],
|
||||
},
|
||||
},
|
||||
|
||||
Ring: {
|
||||
props: z.object(mesh3DProps),
|
||||
description:
|
||||
"3D flat ring mesh. args: [innerRadius, outerRadius, thetaSegments]. Great for orbit path indicators.",
|
||||
example: {
|
||||
position: [0, 0, 0],
|
||||
rotation: [-Math.PI / 2, 0, 0],
|
||||
color: "#ffffff",
|
||||
opacity: 0.2,
|
||||
args: [14.8, 15.2, 64],
|
||||
},
|
||||
},
|
||||
|
||||
// Lights
|
||||
AmbientLight: {
|
||||
props: z.object({
|
||||
color: z.string().nullable(),
|
||||
intensity: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Ambient light that illuminates all objects equally. Use for base scene illumination.",
|
||||
example: { color: null, intensity: 0.3 },
|
||||
},
|
||||
|
||||
PointLight: {
|
||||
props: z.object({
|
||||
position: vec3.nullable(),
|
||||
color: z.string().nullable(),
|
||||
intensity: z.number().nullable(),
|
||||
distance: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Point light that emits from a position in all directions. Use for suns, lamps, etc.",
|
||||
example: { position: [0, 0, 0], intensity: 2 },
|
||||
},
|
||||
|
||||
DirectionalLight: {
|
||||
props: z.object({
|
||||
position: vec3.nullable(),
|
||||
color: z.string().nullable(),
|
||||
intensity: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Directional light like sunlight. Position sets direction, not location.",
|
||||
example: { position: [5, 10, 5], intensity: 1 },
|
||||
},
|
||||
|
||||
// Helpers (drei)
|
||||
Stars: {
|
||||
props: z.object({
|
||||
radius: z.number().nullable(),
|
||||
depth: z.number().nullable(),
|
||||
count: z.number().nullable(),
|
||||
factor: z.number().nullable(),
|
||||
fade: z.boolean().nullable(),
|
||||
speed: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Starfield background for space scenes. Renders thousands of tiny points around the scene.",
|
||||
example: { count: 5000, fade: true },
|
||||
},
|
||||
|
||||
Label3D: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
position: vec3.nullable(),
|
||||
rotation: vec3.nullable(),
|
||||
color: z.string().nullable(),
|
||||
fontSize: z.number().nullable(),
|
||||
anchorX: z.enum(["left", "center", "right"]).nullable(),
|
||||
anchorY: z.enum(["top", "middle", "bottom"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Text label rendered in 3D space. Always faces the camera (billboard). Use for labeling objects in a scene.",
|
||||
example: {
|
||||
text: "Earth",
|
||||
position: [15, 2, 0],
|
||||
color: "#ffffff",
|
||||
fontSize: 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
actions: {},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRenderer,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { registry, Fallback } from "./registry";
|
||||
|
||||
// =============================================================================
|
||||
// ExplorerRenderer
|
||||
// =============================================================================
|
||||
|
||||
interface ExplorerRendererProps {
|
||||
spec: Spec | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const fallback: ComponentRenderer = ({ element }) => (
|
||||
<Fallback type={element.type} />
|
||||
);
|
||||
|
||||
export function ExplorerRenderer({
|
||||
spec,
|
||||
loading,
|
||||
}: ExplorerRendererProps): ReactNode {
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={spec.state ?? {}}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallback}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function handleFetchError(res: Response, coinId: string) {
|
||||
if (res.status === 404) {
|
||||
return { error: `Cryptocurrency not found: ${coinId}` };
|
||||
}
|
||||
if (res.status === 429) {
|
||||
return { error: "CoinGecko rate limit exceeded. Try again in a minute." };
|
||||
}
|
||||
return { error: `Failed to fetch crypto data: ${res.statusText}` };
|
||||
}
|
||||
|
||||
function sampleTimeSeries(
|
||||
prices: [number, number][],
|
||||
maxPoints: number,
|
||||
): Array<{ date: string; price: number }> {
|
||||
const step = Math.max(1, Math.floor(prices.length / maxPoints));
|
||||
return prices
|
||||
.filter((_, i) => i % step === 0)
|
||||
.map(([timestamp, price]) => ({
|
||||
date: new Date(timestamp).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
price: Math.round(price * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// getCryptoPrice — current market data + 7-day sparkline
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get cryptocurrency market data from CoinGecko.
|
||||
* Free public API, no API key required.
|
||||
* https://docs.coingecko.com/reference/introduction
|
||||
*/
|
||||
export const getCryptoPrice = tool({
|
||||
description:
|
||||
"Get current price, market cap, 24h change, and 7-day sparkline for a cryptocurrency. For longer price history (30d, 90d, 365d), use getCryptoPriceHistory instead.",
|
||||
inputSchema: z.object({
|
||||
coinId: z
|
||||
.string()
|
||||
.describe(
|
||||
"CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin', 'cardano')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ coinId }) => {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=true`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) return handleFetchError(res, coinId);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
market_data: {
|
||||
current_price: { usd: number };
|
||||
market_cap: { usd: number };
|
||||
total_volume: { usd: number };
|
||||
price_change_percentage_24h: number;
|
||||
price_change_percentage_7d: number;
|
||||
price_change_percentage_30d: number;
|
||||
high_24h: { usd: number };
|
||||
low_24h: { usd: number };
|
||||
ath: { usd: number };
|
||||
ath_date: { usd: string };
|
||||
circulating_supply: number;
|
||||
total_supply: number | null;
|
||||
sparkline_7d: { price: number[] };
|
||||
};
|
||||
market_cap_rank: number;
|
||||
};
|
||||
|
||||
const md = data.market_data;
|
||||
|
||||
// Convert sparkline (hourly array) to dated points
|
||||
const now = Date.now();
|
||||
const sparkline = md.sparkline_7d.price;
|
||||
const step = Math.max(1, Math.floor(sparkline.length / 14));
|
||||
const sparklineData = sparkline
|
||||
.filter((_, i) => i % step === 0)
|
||||
.map((price, i) => {
|
||||
const hourIndex = i * step;
|
||||
const ts = now - (sparkline.length - hourIndex) * 3600_000;
|
||||
return {
|
||||
date: new Date(ts).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
price: Math.round(price * 100) / 100,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
symbol: data.symbol.toUpperCase(),
|
||||
name: data.name,
|
||||
rank: data.market_cap_rank,
|
||||
price: md.current_price.usd,
|
||||
marketCap: md.market_cap.usd,
|
||||
volume24h: md.total_volume.usd,
|
||||
change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
|
||||
change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
|
||||
change30d: Math.round(md.price_change_percentage_30d * 100) / 100,
|
||||
high24h: md.high_24h.usd,
|
||||
low24h: md.low_24h.usd,
|
||||
allTimeHigh: md.ath.usd,
|
||||
allTimeHighDate: md.ath_date.usd,
|
||||
circulatingSupply: md.circulating_supply,
|
||||
totalSupply: md.total_supply,
|
||||
sparkline7d: sparklineData,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// getCryptoPriceHistory — flexible date range price history
|
||||
// =============================================================================
|
||||
|
||||
export const getCryptoPriceHistory = tool({
|
||||
description:
|
||||
"Get historical price data for a cryptocurrency over a specified number of days (e.g., 30, 90, 365). Returns date-labeled data points suitable for charting.",
|
||||
inputSchema: z.object({
|
||||
coinId: z
|
||||
.string()
|
||||
.describe("CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana')"),
|
||||
days: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.describe("Number of days of history to fetch (e.g., 30, 90, 365)"),
|
||||
}),
|
||||
execute: async ({ coinId, days }) => {
|
||||
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}/market_chart?vs_currency=usd&days=${days}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) return handleFetchError(res, coinId);
|
||||
|
||||
const data = (await res.json()) as {
|
||||
prices: [number, number][];
|
||||
};
|
||||
|
||||
const priceHistory = sampleTimeSeries(data.prices, 20);
|
||||
|
||||
return {
|
||||
coinId,
|
||||
days,
|
||||
priceHistory,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ghHeaders = { Accept: "application/vnd.github.v3+json" };
|
||||
|
||||
function handleGitHubError(res: Response, context: string) {
|
||||
if (res.status === 404) return { error: `Not found: ${context}` };
|
||||
if (res.status === 403)
|
||||
return { error: "GitHub API rate limit exceeded. Try again later." };
|
||||
return { error: `Failed to fetch ${context}: ${res.statusText}` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getGitHubRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get public GitHub repository information.
|
||||
* Uses the public GitHub REST API (no auth, 60 req/hr rate limit).
|
||||
*/
|
||||
export const getGitHubRepo = tool({
|
||||
description:
|
||||
"Get information about a public GitHub repository including stars, forks, open issues, description, language, and recent activity.",
|
||||
inputSchema: z.object({
|
||||
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
|
||||
repo: z.string().describe("Repository name (e.g., 'next.js')"),
|
||||
}),
|
||||
execute: async ({ owner, repo }) => {
|
||||
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||
|
||||
const [repoRes, languagesRes] = await Promise.all([
|
||||
fetch(repoUrl, { headers: ghHeaders }),
|
||||
fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
|
||||
]);
|
||||
|
||||
if (!repoRes.ok) {
|
||||
return handleGitHubError(repoRes, `${owner}/${repo}`);
|
||||
}
|
||||
|
||||
const repoData = (await repoRes.json()) as {
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
open_issues_count: number;
|
||||
watchers_count: number;
|
||||
language: string | null;
|
||||
license: { spdx_id: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
pushed_at: string;
|
||||
topics: string[];
|
||||
size: number;
|
||||
default_branch: string;
|
||||
archived: boolean;
|
||||
fork: boolean;
|
||||
};
|
||||
|
||||
const languages: Record<string, number> = languagesRes.ok
|
||||
? ((await languagesRes.json()) as Record<string, number>)
|
||||
: {};
|
||||
|
||||
const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
|
||||
const languageBreakdown = Object.entries(languages)
|
||||
.map(([lang, bytes]) => ({
|
||||
language: lang,
|
||||
percentage: Math.round((bytes / totalBytes) * 100),
|
||||
bytes,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, 8);
|
||||
|
||||
return {
|
||||
name: repoData.full_name,
|
||||
description: repoData.description,
|
||||
url: repoData.html_url,
|
||||
stars: repoData.stargazers_count,
|
||||
forks: repoData.forks_count,
|
||||
openIssues: repoData.open_issues_count,
|
||||
watchers: repoData.watchers_count,
|
||||
primaryLanguage: repoData.language,
|
||||
license: repoData.license?.spdx_id ?? "None",
|
||||
createdAt: repoData.created_at,
|
||||
updatedAt: repoData.updated_at,
|
||||
lastPush: repoData.pushed_at,
|
||||
topics: repoData.topics,
|
||||
defaultBranch: repoData.default_branch,
|
||||
archived: repoData.archived,
|
||||
isFork: repoData.fork,
|
||||
languages: languageBreakdown,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getGitHubPullRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type GitHubPR = {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
html_url: string;
|
||||
user: { login: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
merged_at: string | null;
|
||||
comments: number;
|
||||
labels: Array<{ name: string }>;
|
||||
draft: boolean;
|
||||
};
|
||||
|
||||
type GitHubPRReview = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
type GitHubPRReaction = {
|
||||
total_count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get pull requests from a public GitHub repository.
|
||||
* Supports filtering by state and sorting by various criteria.
|
||||
* Fetches comment counts and reactions for ranking "most popular" PRs.
|
||||
*/
|
||||
export const getGitHubPullRequests = tool({
|
||||
description:
|
||||
"Get pull requests from a public GitHub repository. Returns titles, authors, state, comment counts, and reactions. Use sort='popularity' to find the most discussed / reacted PRs.",
|
||||
inputSchema: z.object({
|
||||
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
|
||||
repo: z.string().describe("Repository name (e.g., 'next.js')"),
|
||||
state: z
|
||||
.enum(["open", "closed", "all"])
|
||||
.nullable()
|
||||
.describe("Filter by state. Defaults to 'open'."),
|
||||
sort: z
|
||||
.enum(["created", "updated", "popularity", "long-running"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Sort order. 'popularity' sorts by reactions+comments, 'long-running' sorts by age. Defaults to 'created'.",
|
||||
),
|
||||
perPage: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.nullable()
|
||||
.describe("Number of PRs to return (1-30). Defaults to 10."),
|
||||
}),
|
||||
execute: async ({ owner, repo, state, sort, perPage }) => {
|
||||
const count = perPage ?? 10;
|
||||
const prState = state ?? "open";
|
||||
|
||||
// GitHub API sort param: 'popularity' and 'long-running' are API-native
|
||||
const apiSort =
|
||||
sort === "popularity"
|
||||
? "popularity"
|
||||
: sort === "long-running"
|
||||
? "long-running"
|
||||
: sort === "updated"
|
||||
? "updated"
|
||||
: "created";
|
||||
|
||||
const url = new URL(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
|
||||
);
|
||||
url.searchParams.set("state", prState);
|
||||
url.searchParams.set("sort", apiSort);
|
||||
url.searchParams.set("direction", "desc");
|
||||
url.searchParams.set("per_page", String(count));
|
||||
|
||||
const res = await fetch(url.toString(), { headers: ghHeaders });
|
||||
|
||||
if (!res.ok) {
|
||||
return handleGitHubError(res, `${owner}/${repo} pull requests`);
|
||||
}
|
||||
|
||||
const prs = (await res.json()) as GitHubPR[];
|
||||
|
||||
// Fetch review + reaction counts in parallel for richer data
|
||||
const enriched = await Promise.all(
|
||||
prs.map(async (pr) => {
|
||||
const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${pr.number}`;
|
||||
|
||||
const [reviewsRes, reactionsRes] = await Promise.all([
|
||||
fetch(`${base}/reviews?per_page=100`, { headers: ghHeaders }),
|
||||
fetch(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${pr.number}/reactions`,
|
||||
{
|
||||
headers: {
|
||||
...ghHeaders,
|
||||
Accept: "application/vnd.github.squirrel-girl-preview+json",
|
||||
},
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const reviews: GitHubPRReview[] = reviewsRes.ok
|
||||
? ((await reviewsRes.json()) as GitHubPRReview[])
|
||||
: [];
|
||||
|
||||
let reactionCount = 0;
|
||||
if (reactionsRes.ok) {
|
||||
const reactions = (await reactionsRes.json()) as GitHubPRReaction[];
|
||||
reactionCount = reactions.length;
|
||||
}
|
||||
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.merged_at ? "merged" : pr.state,
|
||||
author: pr.user?.login ?? "unknown",
|
||||
url: pr.html_url,
|
||||
createdAt: pr.created_at,
|
||||
updatedAt: pr.updated_at,
|
||||
comments: pr.comments,
|
||||
reviews: reviews.length,
|
||||
reactions: reactionCount,
|
||||
labels: pr.labels.map((l) => l.name),
|
||||
draft: pr.draft,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
repository: `${owner}/${repo}`,
|
||||
state: prState,
|
||||
count: enriched.length,
|
||||
pullRequests: enriched,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Get top stories from Hacker News.
|
||||
* Uses the official HN Firebase API. Free, no auth required.
|
||||
* https://github.com/HackerNewsAPI/API
|
||||
*/
|
||||
export const getHackerNewsTop = tool({
|
||||
description:
|
||||
"Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
|
||||
inputSchema: z.object({
|
||||
count: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(30)
|
||||
.describe("Number of top stories to fetch (1-30)"),
|
||||
}),
|
||||
execute: async ({ count }) => {
|
||||
const topUrl =
|
||||
"https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty";
|
||||
const topRes = await fetch(topUrl);
|
||||
|
||||
if (!topRes.ok) {
|
||||
return { error: "Failed to fetch Hacker News top stories" };
|
||||
}
|
||||
|
||||
const topIds = (await topRes.json()) as number[];
|
||||
const storyIds = topIds.slice(0, count);
|
||||
|
||||
const stories = await Promise.all(
|
||||
storyIds.map(async (id) => {
|
||||
const storyRes = await fetch(
|
||||
`https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
|
||||
);
|
||||
if (!storyRes.ok) return null;
|
||||
|
||||
const story = (await storyRes.json()) as {
|
||||
id: number;
|
||||
title: string;
|
||||
url?: string;
|
||||
score: number;
|
||||
by: string;
|
||||
time: number;
|
||||
descendants?: number;
|
||||
type: string;
|
||||
};
|
||||
|
||||
return {
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
url: story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
|
||||
score: story.score,
|
||||
author: story.by,
|
||||
comments: story.descendants ?? 0,
|
||||
postedAt: new Date(story.time * 1000).toISOString(),
|
||||
hnUrl: `https://news.ycombinator.com/item?id=${story.id}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
stories: stories.filter(Boolean),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { tool, generateText } from "ai";
|
||||
import { gateway } from "@ai-sdk/gateway";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Web search tool using Perplexity Sonar via AI Gateway.
|
||||
*
|
||||
* Perplexity Sonar models have built-in internet access and return
|
||||
* synthesized answers with citations. This is wrapped as a regular tool
|
||||
* (with an `execute` function) so that ToolLoopAgent can loop: it calls
|
||||
* the model, gets results, and feeds them back for the next step.
|
||||
*/
|
||||
export const webSearch = tool({
|
||||
description:
|
||||
"Search the web for current information on any topic. Use this when the user asks about something not covered by the specialized tools (weather, crypto, GitHub, Hacker News). Returns a synthesized answer based on real-time web data.",
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"The search query — be specific and include relevant context for better results",
|
||||
),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: gateway("perplexity/sonar"),
|
||||
prompt: query,
|
||||
});
|
||||
return { content: text };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Get current weather and 7-day forecast for a city using Open-Meteo API.
|
||||
* Free, no API key required.
|
||||
* https://open-meteo.com/
|
||||
*/
|
||||
export const getWeather = tool({
|
||||
description:
|
||||
"Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
|
||||
inputSchema: z.object({
|
||||
city: z
|
||||
.string()
|
||||
.describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
|
||||
}),
|
||||
execute: async ({ city }) => {
|
||||
// Step 1: Geocode the city name to coordinates
|
||||
const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
|
||||
const geocodeRes = await fetch(geocodeUrl);
|
||||
|
||||
if (!geocodeRes.ok) {
|
||||
return { error: `Failed to geocode city: ${city}` };
|
||||
}
|
||||
|
||||
const geocodeData = (await geocodeRes.json()) as {
|
||||
results?: Array<{
|
||||
name: string;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezone: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!geocodeData.results || geocodeData.results.length === 0) {
|
||||
return { error: `City not found: ${city}` };
|
||||
}
|
||||
|
||||
const location = geocodeData.results[0]!;
|
||||
|
||||
// Step 2: Get weather data
|
||||
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
|
||||
|
||||
const weatherRes = await fetch(weatherUrl);
|
||||
|
||||
if (!weatherRes.ok) {
|
||||
return { error: "Failed to fetch weather data" };
|
||||
}
|
||||
|
||||
const weather = (await weatherRes.json()) as {
|
||||
current: {
|
||||
temperature_2m: number;
|
||||
relative_humidity_2m: number;
|
||||
apparent_temperature: number;
|
||||
weather_code: number;
|
||||
wind_speed_10m: number;
|
||||
};
|
||||
daily: {
|
||||
time: string[];
|
||||
weather_code: number[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
precipitation_sum: number[];
|
||||
};
|
||||
};
|
||||
|
||||
const weatherDescription = describeWeatherCode(
|
||||
weather.current.weather_code,
|
||||
);
|
||||
|
||||
const forecast = weather.daily.time.map((date, i) => ({
|
||||
date,
|
||||
day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
}),
|
||||
high: Math.round(weather.daily.temperature_2m_max[i]!),
|
||||
low: Math.round(weather.daily.temperature_2m_min[i]!),
|
||||
condition: describeWeatherCode(weather.daily.weather_code[i]!),
|
||||
precipitation: weather.daily.precipitation_sum[i]!,
|
||||
}));
|
||||
|
||||
return {
|
||||
city: location.name,
|
||||
country: location.country,
|
||||
current: {
|
||||
temperature: Math.round(weather.current.temperature_2m),
|
||||
feelsLike: Math.round(weather.current.apparent_temperature),
|
||||
humidity: weather.current.relative_humidity_2m,
|
||||
windSpeed: Math.round(weather.current.wind_speed_10m),
|
||||
condition: weatherDescription,
|
||||
},
|
||||
forecast,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function describeWeatherCode(code: number): string {
|
||||
const descriptions: Record<number, string> = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Foggy",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
71: "Slight snow",
|
||||
73: "Moderate snow",
|
||||
75: "Heavy snow",
|
||||
77: "Snow grains",
|
||||
80: "Slight rain showers",
|
||||
81: "Moderate rain showers",
|
||||
82: "Violent rain showers",
|
||||
85: "Slight snow showers",
|
||||
86: "Heavy snow showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm with slight hail",
|
||||
99: "Thunderstorm with heavy hail",
|
||||
};
|
||||
return descriptions[code] ?? "Unknown";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
"@ai-sdk/react": "^3.0.84",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@streamdown/code": "^1.0.2",
|
||||
"ai": "^6.0.33",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"eslint": "^9.39.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../packages/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
state: context?.data,
|
||||
state: context?.state,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { getWidgets, createWidget } from "@/lib/db/store";
|
||||
|
||||
export async function GET() {
|
||||
const widgetList = await getWidgets();
|
||||
return Response.json({ data: widgetList, total: widgetList.length });
|
||||
try {
|
||||
const widgetList = await getWidgets();
|
||||
return Response.json({ data: widgetList, total: widgetList.length });
|
||||
} catch {
|
||||
return Response.json({ data: [], total: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
|
||||
@@ -38,6 +38,7 @@ function DashboardContent() {
|
||||
async function loadWidgets() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/widgets");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setSavedWidgets(data.data || []);
|
||||
} catch (err) {
|
||||
@@ -53,7 +54,7 @@ function DashboardContent() {
|
||||
const handleWidgetSaved = useCallback((_id: string) => {
|
||||
// Reload saved widgets to get the new one
|
||||
fetch("/api/v1/widgets")
|
||||
.then((res) => res.json())
|
||||
.then((res) => (res.ok ? res.json() : { data: [] }))
|
||||
.then((data) => {
|
||||
setSavedWidgets(data.data || []);
|
||||
// Remove the new widget slot since it's now saved
|
||||
|
||||
@@ -57,14 +57,15 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
type: z.enum(["text", "email", "password", "number", "tel"]).nullable(),
|
||||
}),
|
||||
description: "Text input field",
|
||||
description:
|
||||
"Text input field. Use value with $bindState for two-way binding",
|
||||
example: {
|
||||
label: "Email",
|
||||
valuePath: "/form/email",
|
||||
value: { $bindState: "/form/email" },
|
||||
placeholder: "you@example.com",
|
||||
type: "email",
|
||||
},
|
||||
@@ -118,10 +119,11 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Checkbox input",
|
||||
description:
|
||||
"Checkbox input. Use checked with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Dialog: {
|
||||
@@ -194,7 +196,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
|
||||
RadioGroup: {
|
||||
props: z.object({
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
@@ -203,12 +205,13 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
}),
|
||||
description: "Radio button group",
|
||||
description:
|
||||
"Radio button group. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
@@ -217,7 +220,8 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Dropdown select input",
|
||||
description:
|
||||
"Dropdown select input. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
@@ -238,10 +242,11 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Toggle switch",
|
||||
description:
|
||||
"Toggle switch. Use checked with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
@@ -269,11 +274,12 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
}),
|
||||
description: "Multi-line text input",
|
||||
description:
|
||||
"Multi-line text input. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
@@ -286,7 +292,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
@@ -312,9 +318,10 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
.nullable(),
|
||||
emptyMessage: z.string().nullable(),
|
||||
}),
|
||||
description: "Data table with optional row actions (delete, edit, etc.)",
|
||||
description:
|
||||
"Data table with optional row actions. Use { $state } on data to bind to an array of objects.",
|
||||
example: {
|
||||
statePath: "/customers/data",
|
||||
data: { $state: "/customers/data" },
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "email", label: "Email" },
|
||||
@@ -345,7 +352,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
@@ -353,13 +360,13 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Bar chart visualization. statePath points to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
"Bar chart visualization. Use { $state } on data to point to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
@@ -367,7 +374,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Line chart visualization. statePath points to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
"Line chart visualization. Use { $state } on data to point to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { findFormValue, getByPath } from "@json-render/core";
|
||||
import { useStateStore, defineRegistry } from "@json-render/react";
|
||||
import { findFormValue } from "@json-render/core";
|
||||
import { useBoundProp, defineRegistry } from "@json-render/react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
@@ -136,22 +136,25 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<Button
|
||||
variant={props.variant ?? "default"}
|
||||
disabled={loading || (props.disabled ?? false)}
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{loading ? "..." : props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
Input: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Input
|
||||
type={props.type ?? "text"}
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -161,7 +164,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
emit?.("submit");
|
||||
emit("submit");
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -193,21 +196,23 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</Avatar>
|
||||
),
|
||||
|
||||
Checkbox: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const checked =
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
Checkbox: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isChecked = checked ?? props.defaultChecked ?? false;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={props.valuePath}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => set(props.valuePath, value)}
|
||||
id={bindings?.checked ?? "checkbox"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(value) => setChecked(value === true)}
|
||||
/>
|
||||
{props.label ? (
|
||||
<Label htmlFor={props.valuePath}>{props.label}</Label>
|
||||
<Label htmlFor={bindings?.checked ?? "checkbox"}>
|
||||
{props.label}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -303,17 +308,14 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<Progress value={props.value} max={props.max ?? 100} />
|
||||
),
|
||||
|
||||
RadioGroup: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const value =
|
||||
(getByPath(state, props.valuePath) as string) ??
|
||||
props.defaultValue ??
|
||||
"";
|
||||
RadioGroup: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const current = value ?? props.defaultValue ?? "";
|
||||
return (
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={(v) => set(props.valuePath, v)}
|
||||
>
|
||||
<RadioGroup value={current} onValueChange={(v) => setValue(v)}>
|
||||
{props.options.map((option) => (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value} id={option.value} />
|
||||
@@ -324,11 +326,13 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const value = (getByPath(state, props.valuePath) as string) ?? "";
|
||||
Select: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
|
||||
<Select value={value ?? ""} onValueChange={(v) => setValue(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={props.placeholder ?? "Select..."} />
|
||||
</SelectTrigger>
|
||||
@@ -359,21 +363,23 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const checked =
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
Switch: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isChecked = checked ?? props.defaultChecked ?? false;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={props.valuePath}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => set(props.valuePath, value)}
|
||||
id={bindings?.checked ?? "switch"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(value) => setChecked(value)}
|
||||
/>
|
||||
{props.label ? (
|
||||
<Label htmlFor={props.valuePath}>{props.label}</Label>
|
||||
<Label htmlFor={bindings?.checked ?? "switch"}>
|
||||
{props.label}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -396,16 +402,19 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
<TabsContent value={props.value}>{children}</TabsContent>
|
||||
),
|
||||
|
||||
Textarea: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
Textarea: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Textarea
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -430,9 +439,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
Table: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const rawData = props.data;
|
||||
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -497,9 +504,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const rawData = props.data;
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -564,9 +569,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
LineChart: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const rawData = props.data;
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
*
|
||||
* Generic React Native rules (layout, tabs, navigation, element integrity)
|
||||
* are baked into the @json-render/react-native schema as defaultRules.
|
||||
* Core concepts (initial state, $path, Repeat, pushState/removeState)
|
||||
* Core concepts (initial state, $state, Repeat, pushState/removeState)
|
||||
* are covered by the core system prompt.
|
||||
*
|
||||
* Only app-specific preferences belong here.
|
||||
|
||||
@@ -286,7 +286,7 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
placeholder: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
type: z
|
||||
.enum(["text", "email", "password", "number", "tel", "url"])
|
||||
.default("text"),
|
||||
@@ -294,11 +294,12 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
disabled: z.boolean().nullable(),
|
||||
required: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Text input field with label, validation, and data binding",
|
||||
description:
|
||||
"Text input field with label, validation. Use $bindState on value for two-way binding.",
|
||||
example: {
|
||||
label: "Email",
|
||||
placeholder: "customer@example.com",
|
||||
valuePath: "/form/email",
|
||||
value: { $bindState: "/form/email" },
|
||||
type: "email",
|
||||
},
|
||||
},
|
||||
@@ -309,12 +310,13 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
placeholder: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
rows: z.number().default(3),
|
||||
disabled: z.boolean().nullable(),
|
||||
required: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Multi-line text input with configurable rows",
|
||||
description:
|
||||
"Multi-line text input with configurable rows. Use $bindState on value for two-way binding.",
|
||||
},
|
||||
|
||||
Select: {
|
||||
@@ -322,13 +324,14 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
description: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
options: z.array(z.object({ value: z.string(), label: z.string() })),
|
||||
size: z.enum(["small", "medium", "large"]).default("medium"),
|
||||
disabled: z.boolean().nullable(),
|
||||
required: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Dropdown select input with configurable options",
|
||||
description:
|
||||
"Dropdown select input with configurable options. Use $bindState on value for two-way binding.",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
@@ -336,34 +339,37 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
description: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Checkbox input with label and description",
|
||||
description:
|
||||
"Checkbox input with label and description. Use $bindState on checked for two-way binding.",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
description: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string(),
|
||||
value: z.string().nullable(),
|
||||
optionValue: z.string(),
|
||||
name: z.string(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Radio button input for selecting one option from a group",
|
||||
description:
|
||||
"Radio button input for selecting one option from a group. Use $bindState on value for two-way binding.",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
description: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Toggle switch for boolean values",
|
||||
description:
|
||||
"Toggle switch for boolean values. Use $bindState on checked for two-way binding.",
|
||||
},
|
||||
|
||||
DateField: {
|
||||
@@ -371,11 +377,12 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
label: z.string(),
|
||||
description: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
valuePath: z.string(),
|
||||
value: z.string().nullable(),
|
||||
size: z.enum(["small", "medium", "large"]).default("medium"),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Date input field with date picker",
|
||||
description:
|
||||
"Date input field with date picker. Use $bindState on value for two-way binding.",
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
@@ -424,7 +431,7 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
// =========================================================================
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.unknown())).nullable(),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
colorKey: z.string().nullable(),
|
||||
@@ -433,12 +440,13 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
showLegend: z.boolean().nullable(),
|
||||
showTooltip: z.boolean().default(true),
|
||||
}),
|
||||
description: "Bar chart visualization from data path",
|
||||
description:
|
||||
"Bar chart visualization. Use $state on data to bind to state array.",
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.unknown())).nullable(),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
colorKey: z.string().nullable(),
|
||||
@@ -447,17 +455,19 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
showLegend: z.boolean().nullable(),
|
||||
showTooltip: z.boolean().default(true),
|
||||
}),
|
||||
description: "Line chart visualization from data path",
|
||||
description:
|
||||
"Line chart visualization. Use $state on data to bind to state array.",
|
||||
},
|
||||
|
||||
Sparkline: {
|
||||
props: z.object({
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.unknown())).nullable(),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
showTooltip: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Compact sparkline chart for inline data visualization",
|
||||
description:
|
||||
"Compact sparkline chart for inline data visualization. Use $state on data to bind to state array.",
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
@@ -466,16 +476,16 @@ export const stripeCatalog = defineCatalog(schema, {
|
||||
DataTable: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
data: z.array(z.record(z.unknown())).nullable(),
|
||||
columns: z.array(z.object({ key: z.string(), label: z.string() })),
|
||||
emptyMessage: z.string().nullable(),
|
||||
rowAction: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Data table with configurable columns and optional row actions",
|
||||
"Data table with configurable columns and optional row actions. Use $state on data to bind to state array.",
|
||||
example: {
|
||||
title: "Recent Payments",
|
||||
statePath: "/payments/data",
|
||||
data: { $state: "/payments/data" },
|
||||
columns: [
|
||||
{ key: "amount", label: "Amount" },
|
||||
{ key: "status", label: "Status" },
|
||||
|
||||
@@ -1432,8 +1432,8 @@ export const actionHandlers: Record<
|
||||
// Navigation Actions
|
||||
// ===========================================================================
|
||||
navigate: async (params) => {
|
||||
if (params?.path) {
|
||||
console.log("Navigate to:", params.path);
|
||||
if (params?.statePath) {
|
||||
console.log("Navigate to:", params.statePath);
|
||||
// In a real app, this would use the router
|
||||
}
|
||||
},
|
||||
@@ -1481,8 +1481,11 @@ export const actionHandlers: Record<
|
||||
},
|
||||
|
||||
setFormValue: async (params, setState) => {
|
||||
if (params?.path) {
|
||||
setState((prev) => ({ ...prev, [params.path as string]: params?.value }));
|
||||
if (params?.statePath) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[params.statePath as string]: params?.value,
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -331,11 +331,7 @@ export const List: FunctionComponent<ExtendedRenderProps> = ({
|
||||
children,
|
||||
emit,
|
||||
}) => {
|
||||
return (
|
||||
<UIList onAction={emit ? (_id) => emit("select") : undefined}>
|
||||
{children}
|
||||
</UIList>
|
||||
);
|
||||
return <UIList onAction={(_id) => emit("select")}>{children}</UIList>;
|
||||
};
|
||||
|
||||
export const ListItem: FunctionComponent<ExtendedRenderProps> = ({
|
||||
@@ -404,7 +400,7 @@ export const TaskListItem: FunctionComponent<ExtendedRenderProps> = ({
|
||||
<UITaskListItem
|
||||
title={String(title || "")}
|
||||
status={status as "not-started" | "in-progress" | "blocked" | "complete"}
|
||||
onPress={action && emit ? () => emit("press") : undefined}
|
||||
onPress={action ? () => emit("press") : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -423,7 +419,7 @@ export const Menu: FunctionComponent<ExtendedRenderProps> = ({
|
||||
trigger={
|
||||
<UIButton type="secondary">{String(triggerLabel || "Menu")}</UIButton>
|
||||
}
|
||||
onAction={emit ? (_id) => emit("select") : undefined}
|
||||
onAction={(_id) => emit("select")}
|
||||
>
|
||||
{children}
|
||||
</UIMenu>
|
||||
@@ -442,7 +438,7 @@ export const MenuItem: FunctionComponent<ExtendedRenderProps> = ({
|
||||
<UIMenuItem
|
||||
id={String(id || "")}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
onAction={action && emit ? () => emit("select") : undefined}
|
||||
onAction={action ? () => emit("select") : undefined}
|
||||
>
|
||||
{String(label || "")}
|
||||
</UIMenuItem>
|
||||
@@ -460,20 +456,17 @@ export const MenuGroup: FunctionComponent<ExtendedRenderProps> = ({
|
||||
// =========================================================================
|
||||
export const TextField: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
const {
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
error,
|
||||
valuePath,
|
||||
value = "",
|
||||
size = "medium",
|
||||
disabled,
|
||||
required,
|
||||
} = element.props as Record<string, unknown>;
|
||||
const value =
|
||||
getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
|
||||
|
||||
return (
|
||||
<UITextField
|
||||
@@ -481,7 +474,7 @@ export const TextField: FunctionComponent<ExtendedRenderProps> = ({
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
description={description ? String(description) : undefined}
|
||||
error={error ? String(error) : undefined}
|
||||
value={value || ""}
|
||||
value={String(value ?? "")}
|
||||
size={size as "small" | "medium" | "large"}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
required={Boolean(required) || undefined}
|
||||
@@ -492,20 +485,17 @@ export const TextField: FunctionComponent<ExtendedRenderProps> = ({
|
||||
|
||||
export const TextArea: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
const {
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
error,
|
||||
valuePath,
|
||||
value = "",
|
||||
rows = 3,
|
||||
disabled,
|
||||
required,
|
||||
} = element.props as Record<string, unknown>;
|
||||
const value =
|
||||
getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
|
||||
|
||||
return (
|
||||
<UITextArea
|
||||
@@ -513,7 +503,7 @@ export const TextArea: FunctionComponent<ExtendedRenderProps> = ({
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
description={description ? String(description) : undefined}
|
||||
error={error ? String(error) : undefined}
|
||||
value={value || ""}
|
||||
value={String(value ?? "")}
|
||||
rows={Number(rows)}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
required={Boolean(required) || undefined}
|
||||
@@ -522,22 +512,17 @@ export const TextArea: FunctionComponent<ExtendedRenderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const Select: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
export const Select: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
valuePath,
|
||||
value = "",
|
||||
options = [],
|
||||
size = "medium",
|
||||
disabled,
|
||||
required,
|
||||
} = element.props as Record<string, unknown>;
|
||||
const value =
|
||||
getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
|
||||
const opts = options as Array<{ value: string; label: string }>;
|
||||
|
||||
return (
|
||||
@@ -545,7 +530,7 @@ export const Select: FunctionComponent<ExtendedRenderProps> = ({
|
||||
label={String(label || "")}
|
||||
description={description ? String(description) : undefined}
|
||||
error={error ? String(error) : undefined}
|
||||
value={value || ""}
|
||||
value={String(value ?? "")}
|
||||
size={size as "small" | "medium" | "large"}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
required={Boolean(required) || undefined}
|
||||
@@ -562,65 +547,56 @@ export const Select: FunctionComponent<ExtendedRenderProps> = ({
|
||||
|
||||
export const Checkbox: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
const { label, description, error, valuePath, defaultChecked, disabled } =
|
||||
const { label, description, error, checked, defaultChecked, disabled } =
|
||||
element.props as Record<string, unknown>;
|
||||
const checked =
|
||||
getValue && valuePath
|
||||
? (getValue(String(valuePath)) as boolean)
|
||||
: Boolean(defaultChecked);
|
||||
const isChecked = checked ?? defaultChecked;
|
||||
|
||||
return (
|
||||
<UICheckbox
|
||||
label={String(label || "")}
|
||||
description={description ? String(description) : undefined}
|
||||
error={error ? String(error) : undefined}
|
||||
checked={checked || undefined}
|
||||
checked={Boolean(isChecked) || undefined}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Radio: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
const { label, description, valuePath, value, name, disabled } =
|
||||
element.props as Record<string, unknown>;
|
||||
const currentValue =
|
||||
getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
|
||||
export const Radio: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
value: selectedValue,
|
||||
optionValue,
|
||||
name,
|
||||
disabled,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<UIRadio
|
||||
label={String(label || "")}
|
||||
description={description ? String(description) : undefined}
|
||||
value={String(value || "")}
|
||||
value={String(optionValue ?? "")}
|
||||
name={String(name || "")}
|
||||
checked={currentValue === value}
|
||||
checked={String(selectedValue ?? "") === String(optionValue ?? "")}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Switch: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
const { label, description, valuePath, defaultChecked, disabled } =
|
||||
export const Switch: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
const { label, description, checked, defaultChecked, disabled } =
|
||||
element.props as Record<string, unknown>;
|
||||
const checked =
|
||||
getValue && valuePath
|
||||
? (getValue(String(valuePath)) as boolean)
|
||||
: Boolean(defaultChecked);
|
||||
const isChecked = checked ?? defaultChecked;
|
||||
|
||||
return (
|
||||
<UISwitch
|
||||
label={String(label || "")}
|
||||
description={description ? String(description) : undefined}
|
||||
checked={checked || undefined}
|
||||
checked={Boolean(isChecked) || undefined}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
@@ -629,25 +605,22 @@ export const Switch: FunctionComponent<ExtendedRenderProps> = ({
|
||||
|
||||
export const DateField: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
getValue,
|
||||
}) => {
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
valuePath,
|
||||
value = "",
|
||||
size = "medium",
|
||||
disabled,
|
||||
} = element.props as Record<string, unknown>;
|
||||
const value =
|
||||
getValue && valuePath ? (getValue(String(valuePath)) as string) : "";
|
||||
|
||||
return (
|
||||
<UIDateField
|
||||
label={String(label || "")}
|
||||
description={description ? String(description) : undefined}
|
||||
error={error ? String(error) : undefined}
|
||||
value={value || ""}
|
||||
value={String(value ?? "")}
|
||||
size={size as "small" | "medium" | "large"}
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
onChange={() => undefined}
|
||||
@@ -680,7 +653,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
|
||||
disabled={Boolean(disabled) || undefined}
|
||||
pending={Boolean(pending) || undefined}
|
||||
href={href ? String(href) : undefined}
|
||||
onPress={action && emit ? () => emit("press") : undefined}
|
||||
onPress={action ? () => emit("press") : undefined}
|
||||
>
|
||||
{String(label || "")}
|
||||
</UIButton>
|
||||
@@ -716,10 +689,9 @@ export const Link: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
|
||||
// =========================================================================
|
||||
export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
state,
|
||||
}) => {
|
||||
const {
|
||||
statePath,
|
||||
data = [],
|
||||
xKey,
|
||||
yKey,
|
||||
colorKey,
|
||||
@@ -729,11 +701,7 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
showTooltip = true,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
// Get data from path
|
||||
const chartData =
|
||||
statePath && state
|
||||
? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
|
||||
: [];
|
||||
const chartData = Array.isArray(data) ? data : [];
|
||||
|
||||
if (!chartData || !Array.isArray(chartData) || chartData.length === 0) {
|
||||
return (
|
||||
@@ -757,10 +725,9 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
|
||||
export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
state,
|
||||
}) => {
|
||||
const {
|
||||
statePath,
|
||||
data = [],
|
||||
xKey,
|
||||
yKey,
|
||||
colorKey,
|
||||
@@ -770,10 +737,7 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
showTooltip = true,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
const chartData =
|
||||
statePath && state
|
||||
? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
|
||||
: [];
|
||||
const chartData = Array.isArray(data) ? data : [];
|
||||
|
||||
if (!chartData || !Array.isArray(chartData) || chartData.length === 0) {
|
||||
return (
|
||||
@@ -797,17 +761,15 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
|
||||
|
||||
export const Sparkline: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
state,
|
||||
}) => {
|
||||
const { statePath, xKey, yKey, showTooltip } = element.props as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const {
|
||||
data = [],
|
||||
xKey,
|
||||
yKey,
|
||||
showTooltip,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
const chartData =
|
||||
statePath && state
|
||||
? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
|
||||
: [];
|
||||
const chartData = Array.isArray(data) ? data : [];
|
||||
|
||||
if (!chartData || !Array.isArray(chartData) || chartData.length === 0) {
|
||||
return <Box css={{ color: "secondary", font: "caption" }}>—</Box>;
|
||||
@@ -828,19 +790,15 @@ export const Sparkline: FunctionComponent<ExtendedRenderProps> = ({
|
||||
// =========================================================================
|
||||
export const DataTable: FunctionComponent<ExtendedRenderProps> = ({
|
||||
element,
|
||||
state,
|
||||
}) => {
|
||||
const {
|
||||
title,
|
||||
statePath,
|
||||
data = [],
|
||||
columns = [],
|
||||
emptyMessage,
|
||||
} = element.props as Record<string, unknown>;
|
||||
|
||||
const tableData =
|
||||
statePath && state
|
||||
? (getNestedValue(state, String(statePath)) as Record<string, unknown>[])
|
||||
: [];
|
||||
const tableData = Array.isArray(data) ? data : [];
|
||||
const cols = columns as Array<{ key: string; label: string }>;
|
||||
|
||||
if (!tableData || tableData.length === 0) {
|
||||
@@ -922,7 +880,7 @@ export const CustomerCard: FunctionComponent<ExtendedRenderProps> = ({
|
||||
</UIBadge>
|
||||
</Box>
|
||||
<Box css={{ color: "secondary" }}>{String(email || "")}</Box>
|
||||
{customerId && emit && (
|
||||
{customerId && (
|
||||
<UIButton type="secondary" size="small" onPress={() => emit("press")}>
|
||||
View Details
|
||||
</UIButton>
|
||||
@@ -975,7 +933,7 @@ export const PaymentCard: FunctionComponent<ExtendedRenderProps> = ({
|
||||
{description && (
|
||||
<Box css={{ color: "secondary" }}>{String(description)}</Box>
|
||||
)}
|
||||
{paymentId && emit && (
|
||||
{paymentId && (
|
||||
<Box css={{ stack: "x", gap: "small" }}>
|
||||
<UIButton type="secondary" size="small" onPress={() => emit("press")}>
|
||||
View
|
||||
@@ -1101,7 +1059,7 @@ export const InvoiceCard: FunctionComponent<ExtendedRenderProps> = ({
|
||||
Due: {String(dueDate)}
|
||||
</Box>
|
||||
)}
|
||||
{status === "open" && emit && (
|
||||
{status === "open" && (
|
||||
<UIButton type="primary" size="small" onPress={() => emit("press")}>
|
||||
Send Invoice
|
||||
</UIButton>
|
||||
@@ -1258,7 +1216,7 @@ export const Chip: FunctionComponent<ExtendedRenderProps> = ({
|
||||
<UIChip
|
||||
label={String(label || "")}
|
||||
value={value ? String(value) : undefined}
|
||||
onClose={removable && action && emit ? () => emit("remove") : undefined}
|
||||
onClose={removable && action ? () => emit("remove") : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1311,22 +1269,6 @@ export const Fallback: FunctionComponent<ExtendedRenderProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Utility Functions
|
||||
// =========================================================================
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.replace(/^\//, "").split("/");
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === "object" && part in current) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Component Map Export
|
||||
// =========================================================================
|
||||
|
||||
@@ -47,6 +47,7 @@ function buildRegistry(
|
||||
const registry: ComponentRegistry = {};
|
||||
|
||||
for (const [name, Component] of Object.entries(components)) {
|
||||
const noop = () => {};
|
||||
registry[name] = (renderProps: {
|
||||
element: { type: string; props: Record<string, unknown> };
|
||||
children?: ReactNode;
|
||||
@@ -55,7 +56,7 @@ function buildRegistry(
|
||||
Component({
|
||||
element: renderProps.element,
|
||||
children: renderProps.children,
|
||||
emit: renderProps.emit,
|
||||
emit: renderProps.emit ?? noop,
|
||||
loading,
|
||||
state: dataRef.current,
|
||||
getValue: (path: string) => {
|
||||
|
||||
@@ -29,10 +29,13 @@
|
||||
"@changesets/cli": "2.29.8",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/react": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"turbo": "^2.7.4",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "^4.0.17"
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @json-render/core@0.6.1
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ea97aff]
|
||||
- @json-render/core@0.6.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 06b8745: Chat mode (inline GenUI), AI SDK integration, two-way binding, and expression-based visibility/props.
|
||||
|
||||
### New: Chat Mode (Inline GenUI)
|
||||
|
||||
Two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets AI respond conversationally with embedded UI specs — ideal for chatbots and copilot experiences.
|
||||
- `catalog.prompt({ mode: "chat" })` generates a chat-aware system prompt
|
||||
- `pipeJsonRender()` server-side transform separates text from JSONL patches in a mixed stream
|
||||
- `createJsonRenderTransform()` low-level TransformStream for custom pipelines
|
||||
|
||||
### New: AI SDK Integration
|
||||
|
||||
First-class Vercel AI SDK support with typed data parts and stream utilities.
|
||||
- `SpecDataPart` type for `data-spec` stream parts (patch, flat, nested payloads)
|
||||
- `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` constants for type-safe part filtering
|
||||
- `createMixedStreamParser()` for parsing mixed text + JSONL streams
|
||||
|
||||
### New: React Chat Hooks
|
||||
- `useChatUI()` — full chat hook with message history, streaming, and spec extraction
|
||||
- `useJsonRenderMessage()` — extract spec + text from a message's parts array
|
||||
- `buildSpecFromParts()` / `getTextFromParts()` — utilities for working with AI SDK message parts
|
||||
- `useBoundProp()` — two-way binding hook for `$bindState` / `$bindItem` expressions
|
||||
|
||||
### New: Two-Way Binding
|
||||
|
||||
Props can now use `$bindState` and `$bindItem` expressions for two-way data binding. The renderer resolves bindings and passes a `bindings` map to components, enabling write-back to state.
|
||||
|
||||
### New: Expression-Based Props and Visibility
|
||||
|
||||
Replaced string token rewriting with structured expression objects:
|
||||
- Props: `{ $state: "/path" }`, `{ $item: "field" }`, `{ $index: true }`
|
||||
- Visibility: `{ $state: "/path", eq: "value" }`, `{ $item: "active" }`, `{ $index: true, gt: 0 }`
|
||||
- Logic: `{ $and: [...] }`, `{ $or: [...] }`, and implicit AND via arrays
|
||||
- Comparison operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`
|
||||
|
||||
### New: Utilities
|
||||
- `applySpecPatch()` — typed convenience wrapper for applying a single patch to a Spec
|
||||
- `nestedToFlat()` — convert nested tree specs to flat `{ root, elements }` format
|
||||
- `resolveBindings()` / `resolveActionParam()` — resolve binding paths and action params
|
||||
|
||||
### New: Chat Example
|
||||
|
||||
Full-featured chat example (`examples/chat`) with AI agent, tool calls (crypto, GitHub, Hacker News, weather, search), theme toggle, and streaming UI generation.
|
||||
|
||||
### Improved: Renderer
|
||||
- `ElementRenderer` is now `React.memo`'d for better performance in repeat lists
|
||||
- `emit` is always defined (never `undefined`) — no more optional chaining needed
|
||||
- Action params are resolved through `resolveActionParam` supporting `$item`, `$index`, `$state`
|
||||
- Repeat scope now passes the actual item object instead of requiring token rewriting
|
||||
|
||||
### Breaking Changes
|
||||
- **Expressions renamed**: `{ $path }` / `{ path }` replaced by `{ $state }`, `{ $item }`, `{ $index }`
|
||||
- **Visibility conditions**: `{ path }` → `{ $state }`, `{ and/or/not }` → `{ $and/$or }` with `not` as operator flag
|
||||
- **DynamicValue**: `{ path: string }` → `{ $state: string }`
|
||||
- **Repeat field**: `repeat.path` → `repeat.statePath`
|
||||
- **Action params**: `path` → `statePath` in setState action params
|
||||
- **Provider props**: `actionHandlers` → `handlers` on `JSONUIProvider`/`ActionProvider`
|
||||
- **Auth removed**: `AuthState` type and `{ auth }` visibility conditions removed — model auth as regular state
|
||||
- **Legacy catalog removed**: `createCatalog`, `generateCatalogPrompt`, `generateSystemPrompt`, `ComponentDefinition`, `CatalogConfig`, `SystemPromptOptions` removed
|
||||
- **React exports removed**: `createRendererFromCatalog`, `rewriteRepeatTokens`
|
||||
- **Codegen**: `traverseTree` → `traverseSpec`, `SpecVisitor` → `TreeVisitor`
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [06b8745]
|
||||
- @json-render/core@0.6.0
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+12
-12
@@ -17,23 +17,23 @@ pnpm add @json-render/codegen
|
||||
### Tree Traversal
|
||||
|
||||
```typescript
|
||||
import { traverseTree, collectUsedComponents, collectStatePaths, collectActions } from '@json-render/codegen';
|
||||
import { traverseSpec, collectUsedComponents, collectStatePaths, collectActions } from '@json-render/codegen';
|
||||
|
||||
// Walk the tree depth-first
|
||||
traverseTree(tree, (element, depth, parent) => {
|
||||
console.log(`${' '.repeat(depth * 2)}${element.type}`);
|
||||
// Walk the spec depth-first
|
||||
traverseSpec(spec, (element, key, depth, parent) => {
|
||||
console.log(`${' '.repeat(depth * 2)}${key}: ${element.type}`);
|
||||
});
|
||||
|
||||
// Get all component types used
|
||||
const components = collectUsedComponents(tree);
|
||||
const components = collectUsedComponents(spec);
|
||||
// Set { 'Card', 'Metric', 'Button' }
|
||||
|
||||
// Get all state paths referenced
|
||||
const statePaths = collectStatePaths(tree);
|
||||
const statePaths = collectStatePaths(spec);
|
||||
// Set { 'analytics/revenue', 'user/name' }
|
||||
|
||||
// Get all action names
|
||||
const actions = collectActions(tree);
|
||||
const actions = collectActions(spec);
|
||||
// Set { 'submit_form', 'refresh_data' }
|
||||
```
|
||||
|
||||
@@ -49,8 +49,8 @@ serializePropValue("hello");
|
||||
serializePropValue(42);
|
||||
// { value: '42', needsBraces: true }
|
||||
|
||||
serializePropValue({ path: 'user/name' });
|
||||
// { value: '{ path: "user/name" }', needsBraces: true }
|
||||
serializePropValue({ $state: '/user/name' });
|
||||
// { value: '{ $state: "/user/name" }', needsBraces: true }
|
||||
|
||||
// Serialize props for JSX
|
||||
serializeProps({ title: "Dashboard", columns: 3, disabled: true });
|
||||
@@ -64,7 +64,7 @@ import type { GeneratedFile, CodeGenerator } from '@json-render/codegen';
|
||||
|
||||
// Implement your own code generator
|
||||
const myGenerator: CodeGenerator = {
|
||||
generate(tree) {
|
||||
generate(spec) {
|
||||
return [
|
||||
{ path: 'package.json', content: '...' },
|
||||
{ path: 'app/page.tsx', content: '...' },
|
||||
@@ -81,7 +81,7 @@ See the `examples/dashboard` for a complete example of building a Next.js code g
|
||||
import {
|
||||
collectUsedComponents,
|
||||
collectStatePaths,
|
||||
traverseTree,
|
||||
traverseSpec,
|
||||
serializeProps,
|
||||
type GeneratedFile
|
||||
} from '@json-render/codegen';
|
||||
@@ -89,7 +89,7 @@ import type { Spec } from '@json-render/core';
|
||||
|
||||
export function generateNextJSProject(spec: Spec): GeneratedFile[] {
|
||||
const files: GeneratedFile[] = [];
|
||||
const components = collectUsedComponents(tree);
|
||||
const components = collectUsedComponents(spec);
|
||||
|
||||
// Generate package.json
|
||||
files.push({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.5.2",
|
||||
"version": "0.6.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -77,13 +77,13 @@ export function serializePropValue(
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
// Check for path reference
|
||||
// Check for $state reference
|
||||
if (
|
||||
"path" in value &&
|
||||
typeof (value as { path: unknown }).path === "string"
|
||||
"$state" in value &&
|
||||
typeof (value as { $state: unknown }).$state === "string"
|
||||
) {
|
||||
return {
|
||||
value: `{ path: ${q}${escapeString((value as { path: string }).path, opts.quotes)}${q} }`,
|
||||
value: `{ $state: ${q}${escapeString((value as { $state: string }).$state, opts.quotes)}${q} }`,
|
||||
needsBraces: true,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user