Chris Tate 5 miesięcy temu
rodzic
commit
b82ff22680
2 zmienionych plików z 245 dodań i 80 usunięć
  1. 49 35
      apps/web/app/api/generate/route.ts
  2. 196 45
      apps/web/components/demo.tsx

+ 49 - 35
apps/web/app/api/generate/route.ts

@@ -3,44 +3,58 @@ import { gateway } from '@ai-sdk/gateway';
 
 export const maxDuration = 30;
 
-const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches to build a contact form.
-
-AVAILABLE COMPONENTS:
-- Form: { title?: string } - Form container with optional title. Has children.
-- Input: { label: string, name: string } - Text input field
-- Textarea: { label: string, name: string } - Multi-line text area
-- Button: { label: string, action: string } - Clickable button
-
-OUTPUT FORMAT:
-Output JSONL where each line is a patch operation:
-- {"op":"set","path":"/root","value":"element-key"} - Set root element
-- {"op":"add","path":"/elements/key","value":{...}} - Add an element
-
-ELEMENT STRUCTURE:
-{
-  "key": "unique-key",
-  "type": "ComponentType",
-  "props": { ... },
-  "children": ["child-key-1", "child-key-2"]
-}
+const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
+
+AVAILABLE COMPONENTS (20):
+
+Layout:
+- Card: { title?: string, description?: string } - Container card. Has children.
+- 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.
+- 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", action?: string } - Clickable button
+- 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
+
+OUTPUT FORMAT (JSONL):
+{"op":"set","path":"/root","value":"element-key"}
+{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
 
 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 - Contact Form:
-{"op":"set","path":"/root","value":"form"}
-{"op":"add","path":"/elements/form","value":{"key":"form","type":"Form","props":{"title":"Contact Us"},"children":["name","email","message","submit"]}}
+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
+
+EXAMPLE:
+{"op":"set","path":"/root","value":"card"}
+{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact"},"children":["name","submit"]}}
 {"op":"add","path":"/elements/name","value":{"key":"name","type":"Input","props":{"label":"Name","name":"name"}}}
-{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}
-{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}
-{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","action":"submit"}}}
+{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Submit","variant":"primary"}}}
 
-Generate JSONL patches for the user's request:`;
+Generate JSONL:`;
 
 const MAX_PROMPT_LENGTH = 140;
 
@@ -50,7 +64,7 @@ export async function POST(req: Request) {
   const sanitizedPrompt = String(prompt || '').slice(0, MAX_PROMPT_LENGTH);
 
   const result = streamText({
-    model: gateway('openai/gpt-4o-mini'),
+    model: gateway('anthropic/claude-haiku-4.5'),
     system: SYSTEM_PROMPT,
     prompt: sanitizedPrompt,
     temperature: 0.7,

+ 196 - 45
apps/web/components/demo.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useEffect, useState, useCallback, useRef } from "react";
+import React, { useEffect, useState, useCallback, useRef } from "react";
 import { CodeBlock } from "./code-block";
 
 const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
@@ -248,6 +248,198 @@ export function Demo() {
     setTimeout(() => setActionFired(false), 2000);
   };
 
+  // Render a single element
+  const renderElement = (element: UIElement, elements: Record<string, UIElement>): React.ReactNode => {
+    const { type, props, children: childKeys = [] } = element;
+    const renderChildren = () => childKeys.map((key) => {
+      const child = elements[key];
+      return child ? renderElement(child, elements) : null;
+    });
+
+    const baseClass = "animate-in fade-in slide-in-from-bottom-1 duration-200";
+
+    switch (type) {
+      // Layout
+      case "Card":
+        return (
+          <div key={element.key} className={`border border-border rounded-lg p-3 bg-background ${baseClass}`}>
+            {props.title && <div className="font-semibold text-sm mb-1">{props.title as string}</div>}
+            {props.description && <div className="text-[10px] text-muted-foreground mb-2">{props.description as string}</div>}
+            <div className="space-y-2">{renderChildren()}</div>
+          </div>
+        );
+      case "Stack":
+        const isHorizontal = props.direction === "horizontal";
+        const stackGap = props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
+        return (
+          <div key={element.key} className={`flex ${isHorizontal ? "flex-row items-center" : "flex-col"} ${stackGap} ${baseClass}`}>
+            {renderChildren()}
+          </div>
+        );
+      case "Grid":
+        const cols = props.columns === 4 ? "grid-cols-4" : props.columns === 3 ? "grid-cols-3" : "grid-cols-2";
+        const gridGap = props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
+        return (
+          <div key={element.key} className={`grid ${cols} ${gridGap} ${baseClass}`}>
+            {renderChildren()}
+          </div>
+        );
+      case "Divider":
+        return <hr key={element.key} className={`border-border my-2 ${baseClass}`} />;
+
+      // Form Inputs
+      case "Input":
+        return (
+          <div key={element.key} className={baseClass}>
+            {props.label && <label className="text-[10px] text-muted-foreground block mb-0.5">{props.label as string}</label>}
+            <div className="h-7 w-full bg-card border border-border rounded px-2 text-xs flex items-center text-muted-foreground/50">
+              {props.placeholder as string || ""}
+            </div>
+          </div>
+        );
+      case "Textarea":
+        const rows = (props.rows as number) || 3;
+        return (
+          <div key={element.key} className={baseClass}>
+            {props.label && <label className="text-[10px] text-muted-foreground block mb-0.5">{props.label as string}</label>}
+            <div className={`w-full bg-card border border-border rounded px-2 py-1 text-xs text-muted-foreground/50`} style={{ minHeight: rows * 16 }}>
+              {props.placeholder as string || ""}
+            </div>
+          </div>
+        );
+      case "Select":
+        return (
+          <div key={element.key} className={baseClass}>
+            {props.label && <label className="text-[10px] text-muted-foreground block mb-0.5">{props.label as string}</label>}
+            <div className="h-7 w-full bg-card border border-border rounded px-2 text-xs flex items-center justify-between text-muted-foreground/50">
+              <span>{props.placeholder as string || "Select..."}</span>
+              <span>v</span>
+            </div>
+          </div>
+        );
+      case "Checkbox":
+        return (
+          <label key={element.key} className={`flex items-center gap-2 text-xs ${baseClass}`}>
+            <div className={`w-3.5 h-3.5 border border-border rounded-sm ${props.checked ? "bg-foreground" : "bg-card"}`} />
+            {props.label as string}
+          </label>
+        );
+      case "Radio":
+        const options = (props.options as string[]) || [];
+        return (
+          <div key={element.key} className={`space-y-1 ${baseClass}`}>
+            {props.label && <div className="text-[10px] text-muted-foreground mb-1">{props.label as string}</div>}
+            {options.map((opt, i) => (
+              <label key={i} className="flex items-center gap-2 text-xs">
+                <div className={`w-3.5 h-3.5 border border-border rounded-full ${i === 0 ? "bg-foreground" : "bg-card"}`} />
+                {opt}
+              </label>
+            ))}
+          </div>
+        );
+      case "Switch":
+        return (
+          <label key={element.key} className={`flex items-center justify-between gap-2 text-xs ${baseClass}`}>
+            <span>{props.label as string}</span>
+            <div className={`w-8 h-4 rounded-full relative ${props.checked ? "bg-foreground" : "bg-border"}`}>
+              <div className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${props.checked ? "right-0.5" : "left-0.5"}`} />
+            </div>
+          </label>
+        );
+
+      // Actions
+      case "Button":
+        const variant = props.variant as string;
+        const btnClass = variant === "danger" ? "bg-red-500 text-white" : variant === "secondary" ? "bg-card border border-border text-foreground" : "bg-foreground text-background";
+        return (
+          <button key={element.key} onClick={handleAction} className={`px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass}`}>
+            {props.label as string}
+          </button>
+        );
+      case "Link":
+        return (
+          <span key={element.key} className={`text-xs text-blue-500 underline cursor-pointer ${baseClass}`}>
+            {props.label as string}
+          </span>
+        );
+
+      // Typography
+      case "Heading":
+        const level = (props.level as number) || 2;
+        const headingClass = level === 1 ? "text-lg font-bold" : level === 3 ? "text-xs font-semibold" : level === 4 ? "text-[10px] font-semibold" : "text-sm font-semibold";
+        return <div key={element.key} className={`${headingClass} ${baseClass}`}>{props.text as string}</div>;
+      case "Text":
+        const textVariant = props.variant as string;
+        const textClass = textVariant === "caption" ? "text-[10px]" : textVariant === "muted" ? "text-xs text-muted-foreground" : "text-xs";
+        return <p key={element.key} className={`${textClass} ${baseClass}`}>{props.content as string}</p>;
+
+      // Data Display
+      case "Image":
+        return (
+          <div key={element.key} className={`bg-card border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground ${baseClass}`} style={{ width: (props.width as number) || 80, height: (props.height as number) || 60 }}>
+            {props.alt as string || "img"}
+          </div>
+        );
+      case "Avatar":
+        const name = props.name as string || "?";
+        const initials = name.split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase();
+        const avatarSize = props.size === "lg" ? "w-10 h-10 text-sm" : props.size === "sm" ? "w-6 h-6 text-[8px]" : "w-8 h-8 text-[10px]";
+        return (
+          <div key={element.key} className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass}`}>
+            {initials}
+          </div>
+        );
+      case "Badge":
+        const badgeVariant = props.variant as string;
+        const badgeClass = badgeVariant === "success" ? "bg-green-100 text-green-800" : badgeVariant === "warning" ? "bg-yellow-100 text-yellow-800" : badgeVariant === "danger" ? "bg-red-100 text-red-800" : "bg-muted text-foreground";
+        return <span key={element.key} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass}`}>{props.text as string}</span>;
+      case "Alert":
+        const alertType = props.type as string;
+        const alertClass = alertType === "success" ? "bg-green-50 border-green-200" : alertType === "warning" ? "bg-yellow-50 border-yellow-200" : alertType === "error" ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200";
+        return (
+          <div key={element.key} className={`p-2 rounded border ${alertClass} ${baseClass}`}>
+            <div className="text-xs font-medium">{props.title as string}</div>
+            {props.message && <div className="text-[10px] mt-0.5">{props.message as string}</div>}
+          </div>
+        );
+      case "Progress":
+        const value = Math.min(100, Math.max(0, (props.value as number) || 0));
+        return (
+          <div key={element.key} className={baseClass}>
+            {props.label && <div className="text-[10px] text-muted-foreground mb-1">{props.label as string}</div>}
+            <div className="h-2 bg-muted rounded-full overflow-hidden">
+              <div className="h-full bg-foreground rounded-full transition-all" style={{ width: `${value}%` }} />
+            </div>
+          </div>
+        );
+      case "Rating":
+        const ratingValue = (props.value as number) || 0;
+        const maxRating = (props.max as number) || 5;
+        return (
+          <div key={element.key} className={baseClass}>
+            {props.label && <div className="text-[10px] text-muted-foreground mb-1">{props.label as string}</div>}
+            <div className="flex gap-0.5">
+              {Array.from({ length: maxRating }).map((_, i) => (
+                <span key={i} className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}>*</span>
+              ))}
+            </div>
+          </div>
+        );
+
+      // Fallback for Form type (legacy)
+      case "Form":
+        return (
+          <div key={element.key} className={`border border-border rounded-lg p-3 bg-background ${baseClass}`}>
+            {props.title && <div className="font-semibold text-sm mb-2">{props.title as string}</div>}
+            <div className="space-y-2">{renderChildren()}</div>
+          </div>
+        );
+
+      default:
+        return <div key={element.key} className={`text-[10px] text-muted-foreground ${baseClass}`}>[{type}]</div>;
+    }
+  };
+
   // Render preview from tree
   const renderPreview = () => {
     const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
@@ -259,53 +451,12 @@ export function Demo() {
     const root = currentTree.elements[currentTree.root];
     if (!root) return null;
 
-    const title = root.props.title as string | undefined;
-    const children = (root.children ?? []).map((key) => currentTree.elements[key]).filter(Boolean) as UIElement[];
-
     return (
-      <div className="text-center animate-in fade-in duration-200">
-        <div className="border border-border rounded-lg p-4 bg-background inline-block text-left w-56">
-          {title && <h3 className="font-semibold mb-3 text-sm">{title}</h3>}
-          <div className="space-y-2">
-            {children.map((child) => {
-              if (child.type === "Input") {
-                return (
-                  <div key={child.key} className="animate-in fade-in slide-in-from-bottom-1 duration-200">
-                    <label className="text-[10px] text-muted-foreground block mb-0.5">
-                      {child.props.label as string}
-                    </label>
-                    <div className="h-7 w-full bg-card border border-border rounded px-2 text-xs" />
-                  </div>
-                );
-              }
-              if (child.type === "Textarea") {
-                return (
-                  <div key={child.key} className="animate-in fade-in slide-in-from-bottom-1 duration-200">
-                    <label className="text-[10px] text-muted-foreground block mb-0.5">
-                      {child.props.label as string}
-                    </label>
-                    <div className="h-14 w-full bg-card border border-border rounded px-2 text-xs" />
-                  </div>
-                );
-              }
-              if (child.type === "Button") {
-                return (
-                  <button
-                    key={child.key}
-                    onClick={handleAction}
-                    className="w-full px-3 py-1.5 bg-foreground text-background rounded text-xs font-medium hover:opacity-90 transition-opacity animate-in fade-in slide-in-from-bottom-1 duration-200"
-                  >
-                    {child.props.label as string}
-                  </button>
-                );
-              }
-              return null;
-            })}
-          </div>
-        </div>
+      <div className="text-center animate-in fade-in duration-200 w-full max-w-[240px]">
+        {renderElement(root, currentTree.elements)}
         {actionFired && (
           <div className="mt-3 text-xs font-mono text-muted-foreground animate-in fade-in slide-in-from-bottom-2">
-            onAction(&quot;submit&quot;)
+            onAction()
           </div>
         )}
       </div>