mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e467d33a2 | ||
|
|
00ef6fd851 | ||
|
|
5b357909ba | ||
|
|
b527f8d3c8 | ||
|
|
7df7864f5b |
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
.env*.local
|
||||
|
||||
@@ -7,3 +7,8 @@ AI_GATEWAY_API_KEY=
|
||||
# Override the default model used for UI generation
|
||||
# Default: anthropic/claude-haiku-4.5
|
||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
|
||||
# Vercel KV (Rate Limiting)
|
||||
# Automatically populated when you add Vercel KV to your project
|
||||
KV_REST_API_URL=
|
||||
KV_REST_API_TOKEN=
|
||||
|
||||
@@ -35,3 +35,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
||||
@@ -1,86 +1,49 @@
|
||||
import { streamText } from "ai";
|
||||
import { headers } from "next/headers";
|
||||
import { generateSystemPrompt } from "@json-render/core";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
import { playgroundCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
|
||||
|
||||
AVAILABLE COMPONENTS (22):
|
||||
|
||||
Layout:
|
||||
- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
|
||||
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
|
||||
- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
|
||||
- Divider: {} - Horizontal separator line
|
||||
|
||||
Form Inputs:
|
||||
- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
|
||||
- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
|
||||
- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
|
||||
- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
|
||||
- Radio: { label: string, name: string, options: string[] } - Radio button group
|
||||
- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
|
||||
|
||||
Actions:
|
||||
- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
|
||||
- Link: { label: string, href: string } - Anchor link
|
||||
|
||||
Typography:
|
||||
- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
|
||||
- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
|
||||
|
||||
Data Display:
|
||||
- Image: { src: string, alt: string, width?: number, height?: number } - Image
|
||||
- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
|
||||
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
|
||||
- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
|
||||
- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
|
||||
- Rating: { value: number, max?: number, label?: string } - Star rating display
|
||||
|
||||
Charts:
|
||||
- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
|
||||
- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
|
||||
|
||||
OUTPUT FORMAT (JSONL):
|
||||
{"op":"set","path":"/root","value":"element-key"}
|
||||
{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
|
||||
|
||||
ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
|
||||
|
||||
RULES:
|
||||
1. First line sets /root to root element key
|
||||
2. Add elements with /elements/{key}
|
||||
3. Children array contains string keys, not objects
|
||||
4. Parent first, then children
|
||||
5. Each element needs: key, type, props
|
||||
6. Use className for custom Tailwind styling when needed
|
||||
|
||||
FORBIDDEN CLASSES (NEVER USE):
|
||||
- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
|
||||
- bg-gray-50, bg-slate-50 or any page background colors - container already has background
|
||||
|
||||
MOBILE-FIRST RESPONSIVE:
|
||||
- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
|
||||
- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
|
||||
- DO NOT put page headers/titles inside Card - use Stack with Heading directly
|
||||
- Horizontal stacks that may overflow should use className:["flex-wrap"]
|
||||
- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
|
||||
|
||||
EXAMPLE (Blog with responsive grid):
|
||||
{"op":"set","path":"/root","value":"page"}
|
||||
{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
|
||||
{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
|
||||
{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
|
||||
{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
|
||||
{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
|
||||
{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
|
||||
{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
|
||||
|
||||
Generate JSONL:`;
|
||||
const SYSTEM_PROMPT = generateSystemPrompt(playgroundCatalog, {
|
||||
customRules: [
|
||||
"For forms: Card should be the root element, not wrapped in a centering Stack",
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
|
||||
"NEVER use page background colors (bg-gray-50) - container has its own background",
|
||||
],
|
||||
});
|
||||
|
||||
const MAX_PROMPT_LENGTH = 500;
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Get client IP for rate limiting
|
||||
const headersList = await headers();
|
||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
// Check rate limits (minute and daily)
|
||||
const [minuteResult, dailyResult] = await Promise.all([
|
||||
minuteRateLimit.limit(ip),
|
||||
dailyRateLimit.limit(ip),
|
||||
]);
|
||||
|
||||
if (!minuteResult.success || !dailyResult.success) {
|
||||
const isMinuteLimit = !minuteResult.success;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit exceeded",
|
||||
message: isMinuteLimit
|
||||
? "Too many requests. Please wait a moment before trying again."
|
||||
: "Daily limit reached. Please try again tomorrow.",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { prompt, context } = await req.json();
|
||||
const previousTree = context?.previousTree;
|
||||
|
||||
|
||||
@@ -219,7 +219,10 @@ export function Demo({
|
||||
clear,
|
||||
} = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err: Error) => console.error("Generation error:", err),
|
||||
onError: (err: Error) => {
|
||||
console.error("Generation error:", err);
|
||||
toast.error(err.message || "Generation failed. Please try again.");
|
||||
},
|
||||
} as Parameters<typeof useUIStream>[0]);
|
||||
|
||||
// Initialize interactive state for Select components
|
||||
@@ -1041,9 +1044,16 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-xs text-muted-foreground text-center">
|
||||
Try: "Create a login form" or "Build a feedback form
|
||||
with rating"
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 justify-center">
|
||||
{EXAMPLE_PROMPTS.slice(0, 2).map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => handleExampleClick(prompt)}
|
||||
className="text-xs px-2 py-1 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Badge({ element }: ComponentRenderProps) {
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
|
||||
>
|
||||
{props.text as string}
|
||||
{(props.text ?? props.label) as string}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ export function Grid({ element, children }: ComponentRenderProps) {
|
||||
const hasCustomCols = customClass.includes("grid-cols-");
|
||||
const cols = hasCustomCols
|
||||
? ""
|
||||
: props.columns === 4
|
||||
? "grid-cols-4"
|
||||
: props.columns === 3
|
||||
? "grid-cols-3"
|
||||
: props.columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-1";
|
||||
: props.columns === 6
|
||||
? "grid-cols-6"
|
||||
: props.columns === 5
|
||||
? "grid-cols-5"
|
||||
: props.columns === 4
|
||||
? "grid-cols-4"
|
||||
: props.columns === 3
|
||||
? "grid-cols-3"
|
||||
: props.columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-1";
|
||||
const gridGap =
|
||||
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
|
||||
|
||||
|
||||
@@ -7,12 +7,41 @@ export function Stack({ element, children }: ComponentRenderProps) {
|
||||
const { props } = element;
|
||||
const customClass = getCustomClass(props);
|
||||
const isHorizontal = props.direction === "horizontal";
|
||||
const stackGap =
|
||||
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
|
||||
|
||||
const gapClass =
|
||||
props.gap === "lg"
|
||||
? "gap-3"
|
||||
: props.gap === "md"
|
||||
? "gap-2"
|
||||
: props.gap === "sm"
|
||||
? "gap-1"
|
||||
: props.gap === "none"
|
||||
? "gap-0"
|
||||
: "gap-2";
|
||||
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "stretch"
|
||||
? "items-stretch"
|
||||
: "items-start";
|
||||
|
||||
const justifyClass =
|
||||
props.justify === "center"
|
||||
? "justify-center"
|
||||
: props.justify === "end"
|
||||
? "justify-end"
|
||||
: props.justify === "between"
|
||||
? "justify-between"
|
||||
: props.justify === "around"
|
||||
? "justify-around"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass} ${baseClass} ${customClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Text({ element }: ComponentRenderProps) {
|
||||
|
||||
return (
|
||||
<p className={`${textClass} text-left ${baseClass} ${customClass}`}>
|
||||
{props.content as string}
|
||||
{(props.text ?? props.content) as string}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ interface Version {
|
||||
id: string;
|
||||
prompt: string;
|
||||
tree: UITree | null;
|
||||
status: "generating" | "complete";
|
||||
status: "generating" | "complete" | "error";
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
@@ -64,7 +64,20 @@ export function Playground() {
|
||||
clear,
|
||||
} = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err: Error) => console.error("Generation error:", err),
|
||||
onError: (err: Error) => {
|
||||
console.error("Generation error:", err);
|
||||
toast.error(err.message || "Generation failed. Please try again.");
|
||||
// Mark the version as errored
|
||||
if (generatingVersionIdRef.current) {
|
||||
const erroredVersionId = generatingVersionIdRef.current;
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
|
||||
),
|
||||
);
|
||||
generatingVersionIdRef.current = null;
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof useUIStream>[0]);
|
||||
|
||||
useInteractiveState();
|
||||
@@ -307,6 +320,9 @@ ${jsx}
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
{version.status === "error" && (
|
||||
<span className="text-xs text-red-500 shrink-0">failed</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createCatalog } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Web playground component catalog
|
||||
*
|
||||
* This defines the components available for AI generation in the playground.
|
||||
*/
|
||||
export const playgroundCatalog = createCatalog({
|
||||
name: "playground",
|
||||
components: {
|
||||
// Layout Components
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "full"]).optional(),
|
||||
centered: z.boolean().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
hasChildren: true,
|
||||
description:
|
||||
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
|
||||
},
|
||||
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).optional(),
|
||||
gap: z.enum(["none", "sm", "md", "lg"]).optional(),
|
||||
align: z.enum(["start", "center", "end", "stretch"]).optional(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
hasChildren: true,
|
||||
description: "Flex container for layouts",
|
||||
},
|
||||
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z
|
||||
.union([
|
||||
z.literal(1),
|
||||
z.literal(2),
|
||||
z.literal(3),
|
||||
z.literal(4),
|
||||
z.literal(5),
|
||||
z.literal(6),
|
||||
])
|
||||
.optional(),
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
hasChildren: true,
|
||||
description: "Grid layout (1-6 columns)",
|
||||
},
|
||||
|
||||
Divider: {
|
||||
props: z.object({
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Horizontal separator line",
|
||||
},
|
||||
|
||||
// Form Inputs
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Text input field",
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
rows: z.number().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Multi-line text input",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Dropdown select input",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Checkbox input",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Radio button group",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Toggle switch input",
|
||||
},
|
||||
|
||||
// Actions
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||
actionText: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. actionText is shown in toast on click (defaults to label)",
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Anchor link",
|
||||
},
|
||||
|
||||
// Typography
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z
|
||||
.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
|
||||
.optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Heading text (h1-h4)",
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["body", "caption", "muted"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
},
|
||||
|
||||
// Data Display
|
||||
Image: {
|
||||
props: z.object({
|
||||
alt: z.string(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Placeholder image (displays alt text in a styled box)",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().optional(),
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "User avatar with fallback initials",
|
||||
},
|
||||
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["default", "success", "warning", "danger"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string().optional(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Alert banner",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Progress bar (value 0-100)",
|
||||
},
|
||||
|
||||
Rating: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Star rating display",
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarGraph: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Vertical bar chart",
|
||||
},
|
||||
|
||||
LineGraph: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Line chart with points",
|
||||
},
|
||||
},
|
||||
validation: "strict",
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
const redis = new Redis({
|
||||
url: process.env.KV_REST_API_URL!,
|
||||
token: process.env.KV_REST_API_TOKEN!,
|
||||
});
|
||||
|
||||
// 10 requests per minute (sliding window)
|
||||
export const minuteRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(10, "1 m"),
|
||||
prefix: "ratelimit:minute",
|
||||
});
|
||||
|
||||
// 100 requests per day (fixed window)
|
||||
export const dailyRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.fixedWindow(100, "1 d"),
|
||||
prefix: "ratelimit:daily",
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3000",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
@@ -19,6 +19,8 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^6.0.33",
|
||||
|
||||
@@ -1,62 +1,10 @@
|
||||
import { streamText } from "ai";
|
||||
import { componentList } from "@/lib/catalog";
|
||||
import { generateSystemPrompt } from "@json-render/core";
|
||||
import { dashboardCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
const SYSTEM_PROMPT = `You are a dashboard widget generator that outputs JSONL (JSON Lines) patches.
|
||||
|
||||
AVAILABLE COMPONENTS:
|
||||
${componentList.join(", ")}
|
||||
|
||||
COMPONENT DETAILS:
|
||||
- Card: { title?: string, description?: string, padding?: "sm"|"md"|"lg" } - Container with optional title
|
||||
- Grid: { columns?: 1-4, gap?: "sm"|"md"|"lg" } - Grid layout
|
||||
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg", align?: "start"|"center"|"end"|"stretch" } - Flex layout
|
||||
- Metric: { label: string, valuePath: string, format?: "number"|"currency"|"percent", trend?: "up"|"down"|"neutral", trendValue?: string }
|
||||
- Chart: { type: "bar"|"line"|"pie"|"area", dataPath: string, title?: string, height?: number }
|
||||
- Table: { dataPath: string, columns: [{ key: string, label: string, format?: "text"|"currency"|"date"|"badge" }] }
|
||||
- Button: { label: string, action: string, variant?: "primary"|"secondary"|"danger"|"ghost" }
|
||||
- Heading: { text: string, level?: "h1"|"h2"|"h3"|"h4" }
|
||||
- Text: { content: string, variant?: "body"|"caption"|"label", color?: "default"|"muted"|"success"|"warning"|"danger" }
|
||||
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger"|"info" }
|
||||
- Alert: { type: "info"|"success"|"warning"|"error", title: string, message?: string }
|
||||
|
||||
DATA BINDING:
|
||||
- valuePath: "/analytics/revenue" (for single values like Metric)
|
||||
- dataPath: "/analytics/salesByRegion" (for arrays like Chart, Table)
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Output JSONL where each line is a patch operation. Use a FLAT key-based structure:
|
||||
|
||||
OPERATIONS:
|
||||
- {"op":"set","path":"/root","value":"main-card"} - Set the root element key
|
||||
- {"op":"add","path":"/elements/main-card","value":{...}} - Add an element by unique key
|
||||
|
||||
ELEMENT STRUCTURE:
|
||||
{
|
||||
"key": "unique-key",
|
||||
"type": "ComponentType",
|
||||
"props": { ... },
|
||||
"children": ["child-key-1", "child-key-2"] // Array of child element keys
|
||||
}
|
||||
|
||||
RULES:
|
||||
1. First set /root to the root element's key
|
||||
2. Add each element with a unique key using /elements/{key}
|
||||
3. Parent elements list child keys in their "children" array
|
||||
4. Stream elements progressively - parent first, then children
|
||||
5. Each element must have: key, type, props
|
||||
6. Children array contains STRING KEYS, not nested objects
|
||||
|
||||
EXAMPLE - Revenue Dashboard:
|
||||
{"op":"set","path":"/root","value":"main-card"}
|
||||
{"op":"add","path":"/elements/main-card","value":{"key":"main-card","type":"Card","props":{"title":"Revenue Dashboard","padding":"md"},"children":["metrics-grid","chart"]}}
|
||||
{"op":"add","path":"/elements/metrics-grid","value":{"key":"metrics-grid","type":"Grid","props":{"columns":2,"gap":"md"},"children":["revenue-metric","growth-metric"]}}
|
||||
{"op":"add","path":"/elements/revenue-metric","value":{"key":"revenue-metric","type":"Metric","props":{"label":"Total Revenue","valuePath":"/analytics/revenue","format":"currency","trend":"up","trendValue":"+15%"}}}
|
||||
{"op":"add","path":"/elements/growth-metric","value":{"key":"growth-metric","type":"Metric","props":{"label":"Growth Rate","valuePath":"/analytics/growth","format":"percent"}}}
|
||||
{"op":"add","path":"/elements/chart","value":{"key":"chart","type":"Chart","props":{"type":"bar","dataPath":"/analytics/salesByRegion","title":"Sales by Region"}}}
|
||||
|
||||
Generate JSONL patches now:`;
|
||||
const SYSTEM_PROMPT = generateSystemPrompt(dashboardCatalog);
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3001",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0"
|
||||
|
||||
@@ -295,3 +295,240 @@ export type InferCatalogComponentProps<
|
||||
> = {
|
||||
[K in keyof C["components"]]: z.infer<C["components"][K]["props"]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal Zod definition type for introspection
|
||||
*/
|
||||
interface ZodDefInternal {
|
||||
typeName?: string;
|
||||
value?: unknown;
|
||||
values?: unknown;
|
||||
type?: z.ZodTypeAny;
|
||||
shape?: () => Record<string, z.ZodTypeAny>;
|
||||
innerType?: z.ZodTypeAny;
|
||||
options?: z.ZodTypeAny[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Zod type into a human-readable string for prompts
|
||||
*/
|
||||
function formatZodType(schema: z.ZodTypeAny, isOptional = false): string {
|
||||
const def = schema._def as unknown as ZodDefInternal;
|
||||
const typeName = def.typeName ?? "";
|
||||
|
||||
let result: string;
|
||||
|
||||
switch (typeName) {
|
||||
case "ZodString":
|
||||
result = "string";
|
||||
break;
|
||||
case "ZodNumber":
|
||||
result = "number";
|
||||
break;
|
||||
case "ZodBoolean":
|
||||
result = "boolean";
|
||||
break;
|
||||
case "ZodLiteral":
|
||||
result = JSON.stringify(def.value);
|
||||
break;
|
||||
case "ZodEnum":
|
||||
result = (def.values as string[]).map((v) => `"${v}"`).join("|");
|
||||
break;
|
||||
case "ZodNativeEnum":
|
||||
result = Object.values(def.values as Record<string, string>)
|
||||
.map((v) => `"${v}"`)
|
||||
.join("|");
|
||||
break;
|
||||
case "ZodArray":
|
||||
result = def.type
|
||||
? `Array<${formatZodType(def.type)}>`
|
||||
: "Array<unknown>";
|
||||
break;
|
||||
case "ZodObject": {
|
||||
if (!def.shape) {
|
||||
result = "object";
|
||||
break;
|
||||
}
|
||||
const shape = def.shape();
|
||||
const props = Object.entries(shape)
|
||||
.map(([key, value]) => {
|
||||
const innerDef = value._def as unknown as ZodDefInternal;
|
||||
const innerOptional =
|
||||
innerDef.typeName === "ZodOptional" ||
|
||||
innerDef.typeName === "ZodNullable";
|
||||
return `${key}${innerOptional ? "?" : ""}: ${formatZodType(value)}`;
|
||||
})
|
||||
.join(", ");
|
||||
result = `{ ${props} }`;
|
||||
break;
|
||||
}
|
||||
case "ZodOptional":
|
||||
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
|
||||
case "ZodNullable":
|
||||
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
|
||||
case "ZodDefault":
|
||||
return def.innerType
|
||||
? formatZodType(def.innerType, isOptional)
|
||||
: "unknown";
|
||||
case "ZodUnion":
|
||||
result = def.options
|
||||
? def.options.map((opt) => formatZodType(opt)).join("|")
|
||||
: "unknown";
|
||||
break;
|
||||
case "ZodNull":
|
||||
result = "null";
|
||||
break;
|
||||
case "ZodUndefined":
|
||||
result = "undefined";
|
||||
break;
|
||||
case "ZodAny":
|
||||
result = "any";
|
||||
break;
|
||||
case "ZodUnknown":
|
||||
result = "unknown";
|
||||
break;
|
||||
default:
|
||||
result = "unknown";
|
||||
}
|
||||
|
||||
return isOptional ? `${result}?` : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract props from a Zod object schema as formatted entries
|
||||
*/
|
||||
function extractPropsFromSchema(
|
||||
schema: z.ZodTypeAny,
|
||||
): Array<{ name: string; type: string; optional: boolean }> {
|
||||
const def = schema._def as unknown as ZodDefInternal;
|
||||
const typeName = def.typeName ?? "";
|
||||
|
||||
if (typeName !== "ZodObject" || !def.shape) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shape = def.shape();
|
||||
return Object.entries(shape).map(([name, value]) => {
|
||||
const innerDef = value._def as unknown as ZodDefInternal;
|
||||
const optional =
|
||||
innerDef.typeName === "ZodOptional" ||
|
||||
innerDef.typeName === "ZodNullable";
|
||||
return {
|
||||
name,
|
||||
type: formatZodType(value),
|
||||
optional,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format component props as a compact object notation
|
||||
*/
|
||||
function formatPropsCompact(
|
||||
props: Array<{ name: string; type: string; optional: boolean }>,
|
||||
): string {
|
||||
if (props.length === 0) return "{}";
|
||||
const entries = props.map(
|
||||
(p) => `${p.name}${p.optional ? "?" : ""}: ${p.type}`,
|
||||
);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating system prompts
|
||||
*/
|
||||
export interface SystemPromptOptions {
|
||||
/** System message intro (replaces default) */
|
||||
system?: string;
|
||||
/** Additional rules to append to the rules section */
|
||||
customRules?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete system prompt for AI that can generate UI from a catalog.
|
||||
* This produces a ready-to-use prompt that stays in sync with the catalog definition.
|
||||
*/
|
||||
export function generateSystemPrompt<
|
||||
TComponents extends Record<string, ComponentDefinition>,
|
||||
TActions extends Record<string, ActionDefinition>,
|
||||
TFunctions extends Record<string, ValidationFunction>,
|
||||
>(
|
||||
catalog: Catalog<TComponents, TActions, TFunctions>,
|
||||
options: SystemPromptOptions = {},
|
||||
): string {
|
||||
const {
|
||||
system = "You are a UI generator that outputs JSONL (JSON Lines) patches.",
|
||||
customRules = [],
|
||||
} = options;
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// System intro
|
||||
lines.push(system);
|
||||
lines.push("");
|
||||
|
||||
// Components section
|
||||
const componentCount = catalog.componentNames.length;
|
||||
lines.push(`AVAILABLE COMPONENTS (${componentCount}):`);
|
||||
lines.push("");
|
||||
|
||||
for (const name of catalog.componentNames) {
|
||||
const def = catalog.components[name]!;
|
||||
const props = extractPropsFromSchema(def.props);
|
||||
const propsStr = formatPropsCompact(props);
|
||||
const hasChildrenStr = def.hasChildren ? " Has children." : "";
|
||||
const descStr = def.description ? ` ${def.description}` : "";
|
||||
|
||||
lines.push(`- ${String(name)}: ${propsStr}${descStr}${hasChildrenStr}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Actions section
|
||||
if (catalog.actionNames.length > 0) {
|
||||
lines.push("AVAILABLE ACTIONS:");
|
||||
lines.push("");
|
||||
for (const name of catalog.actionNames) {
|
||||
const def = catalog.actions[name]!;
|
||||
lines.push(
|
||||
`- ${String(name)}${def.description ? `: ${def.description}` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Output format
|
||||
lines.push("OUTPUT FORMAT (JSONL):");
|
||||
lines.push('{"op":"set","path":"/root","value":"element-key"}');
|
||||
lines.push(
|
||||
'{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// Rules
|
||||
lines.push("RULES:");
|
||||
const baseRules = [
|
||||
"First line sets /root to root element key",
|
||||
"Add elements with /elements/{key}",
|
||||
"Children array contains string keys, not objects",
|
||||
"Parent first, then children",
|
||||
"Each element needs: key, type, props",
|
||||
"ONLY use props listed above - never invent new props",
|
||||
];
|
||||
const allRules = [...baseRules, ...customRules];
|
||||
allRules.forEach((rule, i) => {
|
||||
lines.push(`${i + 1}. ${rule}`);
|
||||
});
|
||||
lines.push("");
|
||||
|
||||
// Custom validation functions (only if catalog has them)
|
||||
if (catalog.functionNames.length > 0) {
|
||||
lines.push("CUSTOM VALIDATION FUNCTIONS:");
|
||||
lines.push(catalog.functionNames.map(String).join(", "));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// End with prompt
|
||||
lines.push("Generate JSONL:");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ export type {
|
||||
CatalogConfig,
|
||||
Catalog,
|
||||
InferCatalogComponentProps,
|
||||
SystemPromptOptions,
|
||||
} from "./catalog";
|
||||
|
||||
export { createCatalog, generateCatalogPrompt } from "./catalog";
|
||||
export {
|
||||
createCatalog,
|
||||
generateCatalogPrompt,
|
||||
generateSystemPrompt,
|
||||
} from "./catalog";
|
||||
|
||||
@@ -153,7 +153,19 @@ export function useUIStream({
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status}`);
|
||||
// Try to parse JSON error response for better error messages
|
||||
let errorMessage = `HTTP error: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parsing errors, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
Generated
+36
@@ -59,6 +59,12 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@upstash/ratelimit':
|
||||
specifier: ^2.0.8
|
||||
version: 2.0.8(@upstash/redis@1.36.1)
|
||||
'@upstash/redis':
|
||||
specifier: ^1.36.1
|
||||
version: 1.36.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
@@ -1421,6 +1427,18 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@upstash/core-analytics@0.0.10':
|
||||
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@upstash/ratelimit@2.0.8':
|
||||
resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==}
|
||||
peerDependencies:
|
||||
'@upstash/redis': ^1.34.3
|
||||
|
||||
'@upstash/redis@1.36.1':
|
||||
resolution: {integrity: sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==}
|
||||
|
||||
'@vercel/analytics@1.6.1':
|
||||
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
|
||||
peerDependencies:
|
||||
@@ -3127,6 +3145,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
uncrypto@0.1.3:
|
||||
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -4241,6 +4262,19 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@upstash/core-analytics@0.0.10':
|
||||
dependencies:
|
||||
'@upstash/redis': 1.36.1
|
||||
|
||||
'@upstash/ratelimit@2.0.8(@upstash/redis@1.36.1)':
|
||||
dependencies:
|
||||
'@upstash/core-analytics': 0.0.10
|
||||
'@upstash/redis': 1.36.1
|
||||
|
||||
'@upstash/redis@1.36.1':
|
||||
dependencies:
|
||||
uncrypto: 0.1.3
|
||||
|
||||
'@vercel/analytics@1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
|
||||
optionalDependencies:
|
||||
next: 16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -6154,6 +6188,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
uncrypto@0.1.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://turborepo.dev/schema.json",
|
||||
"ui": "tui",
|
||||
"globalEnv": ["AI_GATEWAY_MODEL"],
|
||||
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user