mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360507ee93 | ||
|
|
21b789eb58 |
@@ -0,0 +1,16 @@
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,11 @@ export default function Home() {
|
||||
{/* Hero */}
|
||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
||||
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||
Predictable. Guardrailed. Fast.
|
||||
AI → json-render → UI
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
Let users generate dashboards, widgets, apps, and data visualizations
|
||||
from prompts — safely constrained to components you define.
|
||||
Define a component catalog. Users prompt. AI outputs JSON constrained
|
||||
to your catalog. Your components render it.
|
||||
</p>
|
||||
|
||||
<Demo />
|
||||
@@ -77,18 +77,40 @@ EXAMPLE (Blog with responsive grid):
|
||||
|
||||
Generate JSONL:`;
|
||||
|
||||
const MAX_PROMPT_LENGTH = 140;
|
||||
const MAX_PROMPT_LENGTH = 500;
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
const { prompt, context } = await req.json();
|
||||
const previousTree = context?.previousTree;
|
||||
|
||||
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
|
||||
|
||||
// Build the user prompt, including previous tree for iteration
|
||||
let userPrompt = sanitizedPrompt;
|
||||
if (
|
||||
previousTree &&
|
||||
previousTree.root &&
|
||||
Object.keys(previousTree.elements || {}).length > 0
|
||||
) {
|
||||
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
|
||||
${JSON.stringify(previousTree, null, 2)}
|
||||
|
||||
USER REQUEST: ${sanitizedPrompt}
|
||||
|
||||
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
|
||||
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
|
||||
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
|
||||
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
|
||||
- To add children: update the parent element with new children array
|
||||
|
||||
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: sanitizedPrompt,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
@@ -76,13 +74,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<ThemeProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function PlaygroundLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +1,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Playground } from "@/components/playground";
|
||||
|
||||
export const metadata = {
|
||||
title: "Playground | json-render",
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-16">
|
||||
<h1 className="text-3xl font-bold mb-4">Playground</h1>
|
||||
<p className="text-muted-foreground mb-12">
|
||||
Try json-render with a live example.
|
||||
</p>
|
||||
|
||||
<div className="space-y-12">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Run locally</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Clone the repository and run the example dashboard.
|
||||
</p>
|
||||
<pre className="text-sm mb-4">
|
||||
<code>{`git clone https://github.com/vercel-labs/json-render
|
||||
cd json-render
|
||||
pnpm install
|
||||
pnpm dev`}</code>
|
||||
</pre>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open <code>http://localhost:3001</code> for the example dashboard.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Example prompts</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Try these prompts in the example dashboard:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
"Create a revenue dashboard with monthly metrics",
|
||||
"Build a user management panel with a table",
|
||||
"Design a settings form with text inputs",
|
||||
"Make a notification center with alerts",
|
||||
].map((prompt) => (
|
||||
<div
|
||||
key={prompt}
|
||||
className="p-3 border border-border rounded text-sm font-mono"
|
||||
>
|
||||
{prompt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
A browser-based playground is coming soon.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href="https://github.com/vercel-labs/json-render"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Star on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Playground />;
|
||||
}
|
||||
|
||||
@@ -159,9 +159,28 @@ type Phase = "typing" | "streaming" | "complete";
|
||||
type Tab = "stream" | "json";
|
||||
type RenderView = "dynamic" | "static";
|
||||
|
||||
export function Demo() {
|
||||
const [mode, setMode] = useState<Mode>("simulation");
|
||||
const [phase, setPhase] = useState<Phase>("typing");
|
||||
interface DemoProps {
|
||||
fullscreen?: boolean;
|
||||
skipSimulation?: boolean;
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
"Create a login form with email and password",
|
||||
"Build a feedback form with rating stars",
|
||||
"Design a contact card with avatar",
|
||||
"Make a settings panel with toggles",
|
||||
];
|
||||
|
||||
export function Demo({
|
||||
fullscreen = false,
|
||||
skipSimulation = false,
|
||||
}: DemoProps) {
|
||||
const [mode, setMode] = useState<Mode>(
|
||||
skipSimulation ? "interactive" : "simulation",
|
||||
);
|
||||
const [phase, setPhase] = useState<Phase>(
|
||||
skipSimulation ? "complete" : "typing",
|
||||
);
|
||||
const [typedPrompt, setTypedPrompt] = useState("");
|
||||
const [userPrompt, setUserPrompt] = useState("");
|
||||
const [stageIndex, setStageIndex] = useState(-1);
|
||||
@@ -909,10 +928,19 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
const isStreamingSimulation = mode === "simulation" && phase === "streaming";
|
||||
const showLoadingDots = isStreamingSimulation || isStreaming;
|
||||
|
||||
const handleExampleClick = useCallback((prompt: string) => {
|
||||
setMode("interactive");
|
||||
setPhase("complete");
|
||||
setUserPrompt(prompt);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto text-left">
|
||||
<div
|
||||
className={`w-full text-left ${fullscreen ? "h-full flex flex-col" : "max-w-5xl mx-auto"}`}
|
||||
>
|
||||
{/* Prompt input */}
|
||||
<div className="mb-6">
|
||||
<div className={fullscreen ? "mb-4" : "mb-6"}>
|
||||
<div
|
||||
className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
|
||||
onClick={() => {
|
||||
@@ -1000,16 +1028,32 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{fullscreen ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2 justify-center">
|
||||
{EXAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => handleExampleClick(prompt)}
|
||||
className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`grid lg:grid-cols-2 gap-4 ${fullscreen ? "flex-1 min-h-0" : ""}`}
|
||||
>
|
||||
{/* Tabbed code/stream/json panel */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-4 mb-2 h-6">
|
||||
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
|
||||
<div className="flex items-center gap-4 mb-2 h-6 shrink-0">
|
||||
{(["json", "stream"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -1024,7 +1068,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border border-border rounded bg-background font-mono text-xs h-96 text-left grid relative group">
|
||||
<div
|
||||
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
>
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyButton
|
||||
text={
|
||||
@@ -1034,7 +1080,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-auto h-full ${activeTab === "stream" ? "" : "hidden"}`}
|
||||
className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
|
||||
>
|
||||
{streamLines.length > 0 ? (
|
||||
<>
|
||||
@@ -1059,7 +1105,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-auto h-full ${activeTab === "json" ? "" : "hidden"}`}
|
||||
className={`overflow-auto ${activeTab === "json" ? "" : "hidden"}`}
|
||||
>
|
||||
<CodeBlock
|
||||
code={jsonCode}
|
||||
@@ -1072,8 +1118,8 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
</div>
|
||||
|
||||
{/* Rendered output using json-render */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between mb-2 h-6">
|
||||
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
|
||||
<div className="flex items-center justify-between mb-2 h-6 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
{(
|
||||
[
|
||||
@@ -1126,7 +1172,9 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded bg-background h-96 grid relative group">
|
||||
<div
|
||||
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
>
|
||||
{renderView === "static" && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyButton
|
||||
|
||||
@@ -47,6 +47,12 @@ export function Header() {
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/playground"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Playground
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
|
||||
import type { UITree } from "@json-render/core";
|
||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import {
|
||||
demoRegistry,
|
||||
fallbackComponent,
|
||||
useInteractiveState,
|
||||
} from "./demo/index";
|
||||
|
||||
type Tab = "json" | "stream";
|
||||
type RenderView = "preview" | "code";
|
||||
type MobilePane = "chat" | "code" | "preview";
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
prompt: string;
|
||||
tree: UITree | null;
|
||||
status: "generating" | "complete";
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
"Create a login form",
|
||||
"Build a pricing page",
|
||||
"Design a user profile card",
|
||||
"Make a contact form",
|
||||
];
|
||||
|
||||
export function Playground() {
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [streamLines, setStreamLines] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("json");
|
||||
const [renderView, setRenderView] = useState<RenderView>("preview");
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track the currently generating version ID
|
||||
const generatingVersionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Track the current tree for use as previousTree in next generation
|
||||
const currentTreeRef = useRef<UITree | null>(null);
|
||||
|
||||
const {
|
||||
tree: apiTree,
|
||||
isStreaming,
|
||||
send,
|
||||
clear,
|
||||
} = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err: Error) => console.error("Generation error:", err),
|
||||
} as Parameters<typeof useUIStream>[0]);
|
||||
|
||||
useInteractiveState();
|
||||
|
||||
// Get the selected version
|
||||
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
||||
|
||||
// Determine which tree to display:
|
||||
// - If streaming and selected version is the generating one, show apiTree
|
||||
// - Otherwise show the selected version's tree
|
||||
const isSelectedVersionGenerating =
|
||||
selectedVersionId === generatingVersionIdRef.current && isStreaming;
|
||||
const hasValidApiTree =
|
||||
apiTree && apiTree.root && Object.keys(apiTree.elements).length > 0;
|
||||
|
||||
const currentTree =
|
||||
isSelectedVersionGenerating && hasValidApiTree
|
||||
? apiTree
|
||||
: (selectedVersion?.tree ??
|
||||
(isSelectedVersionGenerating ? apiTree : null));
|
||||
|
||||
// Keep the ref updated with the current tree for use in handleSubmit
|
||||
if (
|
||||
currentTree &&
|
||||
currentTree.root &&
|
||||
Object.keys(currentTree.elements).length > 0
|
||||
) {
|
||||
currentTreeRef.current = currentTree;
|
||||
}
|
||||
|
||||
// Scroll to bottom when versions change
|
||||
useEffect(() => {
|
||||
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [versions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiTree) {
|
||||
const streamLine = JSON.stringify({ tree: apiTree });
|
||||
if (
|
||||
!streamLines.includes(streamLine) &&
|
||||
Object.keys(apiTree.elements).length > 0
|
||||
) {
|
||||
setStreamLines((prev) => {
|
||||
const lastLine = prev[prev.length - 1];
|
||||
if (lastLine !== streamLine) {
|
||||
return [...prev, streamLine];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [apiTree, streamLines]);
|
||||
|
||||
// Update version when streaming completes
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isStreaming &&
|
||||
apiTree &&
|
||||
apiTree.root &&
|
||||
generatingVersionIdRef.current
|
||||
) {
|
||||
const completedVersionId = generatingVersionIdRef.current;
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === completedVersionId
|
||||
? { ...v, tree: apiTree, status: "complete" as const }
|
||||
: v,
|
||||
),
|
||||
);
|
||||
generatingVersionIdRef.current = null;
|
||||
}
|
||||
}, [isStreaming, apiTree]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!inputValue.trim() || isStreaming) return;
|
||||
|
||||
const newVersionId = Date.now().toString();
|
||||
const newVersion: Version = {
|
||||
id: newVersionId,
|
||||
prompt: inputValue.trim(),
|
||||
tree: null,
|
||||
status: "generating",
|
||||
};
|
||||
|
||||
generatingVersionIdRef.current = newVersionId;
|
||||
setVersions((prev) => [...prev, newVersion]);
|
||||
setSelectedVersionId(newVersionId);
|
||||
setInputValue("");
|
||||
setStreamLines([]); // Reset stream lines for new generation
|
||||
|
||||
// Pass the current tree as context so the API can iterate on it
|
||||
await send(inputValue.trim(), { previousTree: currentTreeRef.current });
|
||||
}, [inputValue, isStreaming, send]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(
|
||||
window as unknown as { __demoAction?: (text: string) => void }
|
||||
).__demoAction = (text: string) => {
|
||||
toast(text);
|
||||
};
|
||||
return () => {
|
||||
delete (window as unknown as { __demoAction?: (text: string) => void })
|
||||
.__demoAction;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const jsonCode = currentTree
|
||||
? JSON.stringify(currentTree, null, 2)
|
||||
: "// waiting...";
|
||||
|
||||
const generatedCode = useMemo(() => {
|
||||
if (!currentTree || !currentTree.root) {
|
||||
return "// Generate a UI to see the code";
|
||||
}
|
||||
|
||||
const tree = currentTree;
|
||||
const components = collectUsedComponents(tree);
|
||||
|
||||
function generateJSX(key: string, indent: number): string {
|
||||
const element = tree.elements[key];
|
||||
if (!element) return "";
|
||||
|
||||
const spaces = " ".repeat(indent);
|
||||
const componentName = element.type;
|
||||
|
||||
const propsObj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(element.props)) {
|
||||
if (v !== null && v !== undefined) {
|
||||
propsObj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const propsStr = serializeProps(propsObj);
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
|
||||
if (!hasChildren) {
|
||||
return propsStr
|
||||
? `${spaces}<${componentName} ${propsStr} />`
|
||||
: `${spaces}<${componentName} />`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
propsStr
|
||||
? `${spaces}<${componentName} ${propsStr}>`
|
||||
: `${spaces}<${componentName}>`,
|
||||
);
|
||||
|
||||
for (const childKey of element.children!) {
|
||||
lines.push(generateJSX(childKey, indent + 1));
|
||||
}
|
||||
|
||||
lines.push(`${spaces}</${componentName}>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const jsx = generateJSX(tree.root, 2);
|
||||
const imports = Array.from(components).sort().join(", ");
|
||||
|
||||
return `"use client";
|
||||
|
||||
import { ${imports} } from "@/components/ui";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="min-h-screen p-8 flex items-center justify-center">
|
||||
${jsx}
|
||||
</div>
|
||||
);
|
||||
}`;
|
||||
}, [currentTree]);
|
||||
|
||||
// Chat pane content
|
||||
const chatPane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
versions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
|
||||
>
|
||||
{versions.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Describe what you want to build, then iterate on it.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{EXAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => {
|
||||
setInputValue(prompt);
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(
|
||||
prompt.length,
|
||||
prompt.length,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
versions.map((version, index) => (
|
||||
<button
|
||||
key={version.id}
|
||||
onClick={() => setSelectedVersionId(version.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
|
||||
selectedVersionId === version.id
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-muted-foreground/70 shrink-0">
|
||||
v{index + 1}
|
||||
</span>
|
||||
<span className="truncate flex-1">{version.prompt}</span>
|
||||
{version.status === "generating" && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 animate-pulse">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<div ref={versionsEndRef} />
|
||||
</div>
|
||||
<div
|
||||
className="border-t border-border p-3 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
// Focus textarea unless clicking a button or the textarea itself
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Describe changes..."
|
||||
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
{versions.length > 0 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
setStreamLines([]);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={() => clear()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Code pane content
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(["json", "stream"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<CopyButton
|
||||
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "stream" ? (
|
||||
streamLines.length > 0 ? (
|
||||
<CodeBlock
|
||||
code={streamLines.join("\n")}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{isStreaming ? "streaming..." : "// waiting for generation"}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Preview pane content
|
||||
const previewPane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(
|
||||
[
|
||||
{ key: "preview", label: "preview" },
|
||||
{ key: "code", label: "code" },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setRenderView(key)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
renderView === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{renderView === "code" && (
|
||||
<CopyButton text={generatedCode} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{renderView === "preview" ? (
|
||||
currentTree && currentTree.root ? (
|
||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||
<JSONUIProvider
|
||||
registry={
|
||||
demoRegistry as Parameters<
|
||||
typeof JSONUIProvider
|
||||
>[0]["registry"]
|
||||
}
|
||||
>
|
||||
<Renderer
|
||||
tree={currentTree!}
|
||||
registry={
|
||||
demoRegistry as Parameters<typeof Renderer>[0]["registry"]
|
||||
}
|
||||
loading={isStreaming}
|
||||
fallback={
|
||||
fallbackComponent as Parameters<
|
||||
typeof Renderer
|
||||
>[0]["fallback"]
|
||||
}
|
||||
/>
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
|
||||
{isStreaming
|
||||
? "generating..."
|
||||
: "// enter a prompt to generate UI"}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CodeBlock
|
||||
code={generatedCode}
|
||||
lang="tsx"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
|
||||
{/* Desktop: 3-pane resizable layout */}
|
||||
<div className="hidden lg:flex flex-1 min-h-0">
|
||||
<ResizablePanelGroup className="flex-1">
|
||||
<ResizablePanel defaultSize={25} minSize={15}>
|
||||
{chatPane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={35} minSize={20}>
|
||||
{codePane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
{previewPane}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Single pane with bottom tabs */}
|
||||
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||
{/* Panes - all in DOM, visibility controlled */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
|
||||
>
|
||||
{chatPane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
|
||||
>
|
||||
{codePane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
|
||||
>
|
||||
{previewPane}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tab bar */}
|
||||
<div className="border-t border-border flex shrink-0">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "chat",
|
||||
label: "Chat",
|
||||
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "Preview",
|
||||
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMobilePane(key)}
|
||||
className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
|
||||
mobilePane === key ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
||||
</svg>
|
||||
<span className="text-xs">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import { Group, Panel, Separator } from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Group>) {
|
||||
return (
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
@@ -29,6 +29,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"shiki": "^3.21.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -132,8 +132,12 @@ export function useUIStream({
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
|
||||
// Start with an empty tree
|
||||
let currentTree: UITree = { root: "", elements: {} };
|
||||
// Start with previous tree if provided, otherwise empty tree
|
||||
const previousTree = context?.previousTree as UITree | undefined;
|
||||
let currentTree: UITree =
|
||||
previousTree && previousTree.root
|
||||
? { ...previousTree, elements: { ...previousTree.elements } }
|
||||
: { root: "", elements: {} };
|
||||
setTree(currentTree);
|
||||
|
||||
try {
|
||||
|
||||
Generated
+14
@@ -89,6 +89,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
react-resizable-panels:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
shiki:
|
||||
specifier: ^3.21.0
|
||||
version: 3.21.0
|
||||
@@ -2700,6 +2703,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-resizable-panels@4.4.1:
|
||||
resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5621,6 +5630,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.3
|
||||
|
||||
react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.3)(react@19.2.3):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
|
||||
Reference in New Issue
Block a user