Compare commits

...
Author SHA1 Message Date
Chris Tate 7e467d33a2 fixes 2026-01-28 09:37:41 -06:00
Chris Tate 00ef6fd851 fixes 2026-01-28 09:21:26 -06:00
Chris Tate 5b357909ba better
generate prompt

update examples
2026-01-28 09:03:22 -06:00
Chris Tate b527f8d3c8 fix lint 2026-01-25 13:34:46 -06:00
Chris Tate 7df7864f5b add rate limits 2026-01-25 13:30:46 -06:00
20 changed files with 704 additions and 152 deletions
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
+5
View File
@@ -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=
+1
View File
@@ -35,3 +35,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
+37 -74
View File
@@ -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;
+14 -4
View File
@@ -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: &quot;Create a login form&quot; or &quot;Build a feedback form
with rating&quot;
<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>
+1 -1
View File
@@ -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>
);
}
+11 -7
View File
@@ -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";
+32 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
);
}
+18 -2
View File
@@ -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>
))
+262
View File
@@ -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",
});
+21
View File
@@ -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",
});
+3 -1
View File
@@ -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",
+3 -55
View File
@@ -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";
+1 -1
View File
@@ -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"
+237
View File
@@ -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");
}
+6 -1
View File
@@ -86,6 +86,11 @@ export type {
CatalogConfig,
Catalog,
InferCatalogComponentProps,
SystemPromptOptions,
} from "./catalog";
export { createCatalog, generateCatalogPrompt } from "./catalog";
export {
createCatalog,
generateCatalogPrompt,
generateSystemPrompt,
} from "./catalog";
+13 -1
View File
@@ -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();
+36
View File
@@ -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
View File
@@ -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"],