diff --git a/README.md b/README.md index 3bd2c2507..306ab3f34 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenUI -[Docs](https://www.openui.com) · [Example App](./examples/openui-chat) · [Contributing](./CONTRIBUTING.md) · [License](./LICENSE) +[Docs](https://openui.com) · [Example App](./examples/openui-chat) · [Discord](https://discord.com/invite/Pbv5PsqUSv) · [Contributing](./CONTRIBUTING.md) · [License](./LICENSE) Build **LLM-powered user interfaces** with OpenUI Lang, streaming rendering, and generative UI. @@ -19,20 +19,22 @@ At the center of OpenUI is **OpenUI Lang**: a compact, streaming-first language **Core capabilities:** -- **OpenUI Lang** - A compact language for structured UI generation that is designed for streaming output. -- **Prompt generation from your component library** - Generate model instructions directly from the components you allow. -- **Streaming renderer** - Parse and render model output progressively in React as tokens arrive. -- **Generative UI** - Turn model output into real UI components instead of plain text responses. +- **OpenUI Lang** — A compact language for structured UI generation designed for streaming output. +- **Built-in component libraries** — Charts, forms, tables, layouts, and more — ready to use or extend. +- **Prompt generation from your component library** — Generate model instructions directly from the components you allow. +- **Streaming renderer** — Parse and render model output progressively in React as tokens arrive. - **Chat and app surfaces** - Use the same foundation for assistants, copilots, and broader interactive product flows. + ## Quick Start ```bash -npx @openuidev/cli@latest create +npx @openuidev/cli@latest create --name genui-chat-app cd genui-chat-app +echo "OPENAI_API_KEY=sk-your-key-here" > .env npm run dev ``` @@ -65,54 +67,41 @@ This creates a direct path from model output to UI without relying on brittle te -## Why OpenUI Lang - -OpenUI Lang is designed for model-generated UI that needs to be both structured and streamable. - -It is built for: +## Packages -- **Streaming output** - Emit UI incrementally as tokens arrive. -- **Token efficiency** - Use a compact representation instead of verbose JSON payloads. -- **Controlled rendering** - Restrict output to the components you define and register. -- **Typed component contracts** - Define component props and structure up front. +| Package | Description | +| :--- | :--- | +| [`@openuidev/react-lang`](./packages/react-lang) | Core runtime — component definitions, parser, renderer, prompt generation | +| [`@openuidev/react-headless`](./packages/react-headless) | Headless chat state, streaming adapters, message format converters | +| [`@openuidev/react-ui`](./packages/react-ui) | Prebuilt chat layouts and two built-in component libraries | +| [`@openuidev/cli`](./packages/openui-cli) | CLI for scaffolding apps and generating system prompts | -## Built for real product surfaces - -OpenUI is intended for more than demo chat windows. - -Use it to build: +```bash +npm install @openuidev/react-lang @openuidev/react-ui +``` -- structured AI interfaces -- assistants that render forms, charts, tables, and actions -- embedded copilots inside existing products -- full-page AI workflows -- custom applications backed by your own model APIs +## Why OpenUI Lang -## Chat is one application layer +OpenUI Lang is designed for model-generated UI that needs to be both structured and streamable. -OpenUI also includes ready-made chat surfaces and integration patterns, but chat is only one way to use the platform. +- **Streaming output** — Emit UI incrementally as tokens arrive. +- **Token efficiency** — Up to 67% fewer tokens than equivalent JSON. +- **Controlled rendering** — Restrict output to the components you define and register. +- **Typed component contracts** — Define component props and structure up front with Zod schemas. -If you want a complete starting point with backend wiring, streaming, and a built-in UI, use: +## Built for real product surfaces -```bash -npx @openuidev/cli@latest create -``` +OpenUI is intended for more than demo chat windows. Use it to build: -From there, you can keep the built-in experience or move toward more custom OpenUI Lang-driven interfaces. +- Assistants that render forms, charts, tables, and actions +- Embedded copilots inside existing products +- Full-page AI workflows +- Custom applications backed by your own model APIs ## Documentation Detailed documentation is available at [openui.com](https://openui.com). -The docs cover: - -- OpenUI Lang quick start -- component definitions and library design -- prompt generation and customization -- renderer behavior and streaming semantics -- end-to-end app setup -- backend connection patterns and API contracts - ## Explore the repo This repository contains the OpenUI monorepo, including the implementation, example app, and documentation source. diff --git a/docs/.gitignore b/docs/.gitignore index 9e429e498..8a11fd9b8 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -23,4 +23,4 @@ yarn-error.log* # others .env*.local .vercel -next-env.d.ts \ No newline at end of file +next-env.d.ts diff --git a/docs/app/(home)/components/BuildChatSection.tsx b/docs/app/(home)/components/BuildChatSection.tsx index 74f2d74dd..80280a6c1 100644 --- a/docs/app/(home)/components/BuildChatSection.tsx +++ b/docs/app/(home)/components/BuildChatSection.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import Image from "next/image"; import dashboardImg from "@/public/images/home/d67b5e94653944c1d0d4998c6b169c37f98060ad.png"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; import { CopyIcon } from "./shared"; // --------------------------------------------------------------------------- diff --git a/docs/app/(home)/components/Footer.tsx b/docs/app/(home)/components/Footer.tsx index 0b10bb40a..5e830d6b5 100644 --- a/docs/app/(home)/components/Footer.tsx +++ b/docs/app/(home)/components/Footer.tsx @@ -31,7 +31,6 @@ const SOCIAL_LINKS: SocialLink[] = [ href: "https://discord.com/invite/Pbv5PsqUSv", viewBox: "0 0 21.9611 17", path: svgPaths.p3885cd00, - }, { label: "YouTube", @@ -76,7 +75,13 @@ function SocialIcon({ link }: { link: SocialLink }) { ); return ( - + {link.wrapperClass ? (
{svgContent}
) : ( diff --git a/docs/app/(home)/components/StepsSection.tsx b/docs/app/(home)/components/StepsSection.tsx index 259ba6cac..99144c131 100644 --- a/docs/app/(home)/components/StepsSection.tsx +++ b/docs/app/(home)/components/StepsSection.tsx @@ -30,7 +30,7 @@ const STEPS: Step[] = [ number: 2, title: "OpenUI generates system prompt", description: - "Generate a system prompt from your library with OpenUI SDK and send it to the LLM.", + "Generate a system prompt from your library with the OpenUI CLI or library.prompt() and send it to the LLM.", details: [], }, { diff --git a/docs/app/api/chat/route.ts b/docs/app/api/chat/route.ts index 4fdd33a0f..58d49e738 100644 --- a/docs/app/api/chat/route.ts +++ b/docs/app/api/chat/route.ts @@ -1,6 +1,10 @@ +import { readFileSync } from "fs"; import { NextRequest } from "next/server"; import OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; +import { join } from "path"; + +const systemPrompt = readFileSync(join(process.cwd(), "generated/chat-system-prompt.txt"), "utf-8"); // ── Tool implementations ── @@ -8,23 +12,32 @@ function getWeather({ location }: { location: string }): Promise { return new Promise((resolve) => { setTimeout(() => { const knownTemps: Record = { - tokyo: 22, "san francisco": 18, london: 14, "new york": 25, - paris: 19, sydney: 27, mumbai: 33, berlin: 16, + tokyo: 22, + "san francisco": 18, + london: 14, + "new york": 25, + paris: 19, + sydney: 27, + mumbai: 33, + berlin: 16, }; const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear Skies"]; const temp = knownTemps[location.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5); const condition = conditions[Math.floor(Math.random() * conditions.length)]; - resolve(JSON.stringify({ - location, temperature_celsius: temp, - temperature_fahrenheit: Math.round(temp * 1.8 + 32), - condition, - humidity_percent: Math.floor(Math.random() * 40 + 40), - wind_speed_kmh: Math.floor(Math.random() * 25 + 5), - forecast: [ - { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" }, - { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" }, - ], - })); + resolve( + JSON.stringify({ + location, + temperature_celsius: temp, + temperature_fahrenheit: Math.round(temp * 1.8 + 32), + condition, + humidity_percent: Math.floor(Math.random() * 40 + 40), + wind_speed_kmh: Math.floor(Math.random() * 25 + 5), + forecast: [ + { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" }, + { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" }, + ], + }), + ); }, 800); }); } @@ -34,19 +47,27 @@ function getStockPrice({ symbol }: { symbol: string }): Promise { setTimeout(() => { const s = symbol.toUpperCase(); const knownPrices: Record = { - AAPL: 189.84, GOOGL: 141.8, TSLA: 248.42, MSFT: 378.91, - AMZN: 178.25, NVDA: 875.28, META: 485.58, + AAPL: 189.84, + GOOGL: 141.8, + TSLA: 248.42, + MSFT: 378.91, + AMZN: 178.25, + NVDA: 875.28, + META: 485.58, }; const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20); const change = parseFloat((Math.random() * 8 - 4).toFixed(2)); - resolve(JSON.stringify({ - symbol: s, - price: parseFloat((price + change).toFixed(2)), - change, change_percent: parseFloat(((change / price) * 100).toFixed(2)), - volume: `${(Math.random() * 50 + 10).toFixed(1)}M`, - day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)), - day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)), - })); + resolve( + JSON.stringify({ + symbol: s, + price: parseFloat((price + change).toFixed(2)), + change, + change_percent: parseFloat(((change / price) * 100).toFixed(2)), + volume: `${(Math.random() * 50 + 10).toFixed(1)}M`, + day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)), + day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)), + }), + ); }, 600); }); } @@ -55,7 +76,10 @@ function calculate({ expression }: { expression: string }): Promise { return new Promise((resolve) => { setTimeout(() => { try { - const sanitized = expression.replace(/[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, ""); + const sanitized = expression.replace( + /[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, + "", + ); const result = new Function(`return (${sanitized})`)(); resolve(JSON.stringify({ expression, result: Number(result) })); } catch { @@ -68,14 +92,25 @@ function calculate({ expression }: { expression: string }): Promise { function searchWeb({ query }: { query: string }): Promise { return new Promise((resolve) => { setTimeout(() => { - resolve(JSON.stringify({ - query, - results: [ - { title: `Top result for "${query}"`, snippet: `Comprehensive overview of ${query} with the latest information.` }, - { title: `${query} - Latest News`, snippet: `Recent developments and updates related to ${query}.` }, - { title: `Understanding ${query}`, snippet: `An in-depth guide explaining everything about ${query}.` }, - ], - })); + resolve( + JSON.stringify({ + query, + results: [ + { + title: `Top result for "${query}"`, + snippet: `Comprehensive overview of ${query} with the latest information.`, + }, + { + title: `${query} - Latest News`, + snippet: `Recent developments and updates related to ${query}.`, + }, + { + title: `Understanding ${query}`, + snippet: `An in-depth guide explaining everything about ${query}.`, + }, + ], + }), + ); }, 1000); }); } @@ -153,13 +188,22 @@ function sseToolCallStart( `data: ${JSON.stringify({ id: `chatcmpl-tc-${tc.id}`, object: "chat.completion.chunk", - choices: [{ - index: 0, - delta: { - tool_calls: [{ index, id: tc.id, type: "function", function: { name: tc.function.name, arguments: "" } }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: "" }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], })}\n\n`, ); } @@ -172,7 +216,10 @@ function sseToolCallArgs( ) { let enrichedArgs: string; try { - enrichedArgs = JSON.stringify({ _request: JSON.parse(tc.function.arguments), _response: JSON.parse(result) }); + enrichedArgs = JSON.stringify({ + _request: JSON.parse(tc.function.arguments), + _response: JSON.parse(result), + }); } catch { enrichedArgs = tc.function.arguments; } @@ -180,11 +227,13 @@ function sseToolCallArgs( `data: ${JSON.stringify({ id: `chatcmpl-tc-${tc.id}-args`, object: "chat.completion.chunk", - choices: [{ - index: 0, - delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] }, + finish_reason: null, + }, + ], })}\n\n`, ); } @@ -192,7 +241,7 @@ function sseToolCallArgs( // ── Route handler ── export async function POST(req: NextRequest) { - const { messages, systemPrompt } = await req.json(); + const { messages } = await req.json(); const client = new OpenAI({ apiKey: process.env.OPENROUTER_API_KEY, @@ -212,7 +261,7 @@ export async function POST(req: NextRequest) { }); const chatMessages: ChatCompletionMessageParam[] = [ - ...(systemPrompt ? [{ role: "system" as const, content: systemPrompt }] : []), + { role: "system" as const, content: systemPrompt }, ...cleanMessages, ]; @@ -223,12 +272,20 @@ export async function POST(req: NextRequest) { start(controller) { const enqueue = (data: Uint8Array) => { if (controllerClosed) return; - try { controller.enqueue(data); } catch { /* already closed */ } + try { + controller.enqueue(data); + } catch { + /* already closed */ + } }; const close = () => { if (controllerClosed) return; controllerClosed = true; - try { controller.close(); } catch { /* already closed */ } + try { + controller.close(); + } catch { + /* already closed */ + } }; const pendingCalls: Array<{ id: string; name: string; arguments: string }> = []; @@ -240,7 +297,7 @@ export async function POST(req: NextRequest) { model: MODEL, messages: chatMessages, tools, - stream: true + stream: true, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -254,7 +311,14 @@ export async function POST(req: NextRequest) { runner.on("functionToolCallResult", (result: string) => { const tc = pendingCalls[resultIdx]; if (tc) { - enqueue(sseToolCallArgs(encoder, { id: tc.id, function: { arguments: tc.arguments } }, result, resultIdx)); + enqueue( + sseToolCallArgs( + encoder, + { id: tc.id, function: { arguments: tc.arguments } }, + result, + resultIdx, + ), + ); } resultIdx++; }); diff --git a/docs/app/api/playground/stream/route.ts b/docs/app/api/playground/stream/route.ts index 633cc3622..3af686b88 100644 --- a/docs/app/api/playground/stream/route.ts +++ b/docs/app/api/playground/stream/route.ts @@ -1,8 +1,15 @@ -import { type NextRequest } from "next/server"; import { BASE_URL } from "@/lib/source"; +import { readFileSync } from "fs"; +import { type NextRequest } from "next/server"; +import { join } from "path"; + +const systemPrompt = readFileSync( + join(process.cwd(), "generated/playground-system-prompt.txt"), + "utf-8", +); export async function POST(req: NextRequest) { - const { model, prompt, systemPrompt } = await req.json(); + const { model, prompt } = await req.json(); const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { method: "POST", diff --git a/docs/app/docs/chat/page.tsx b/docs/app/docs/chat/page.tsx index 85f08d501..274a931a7 100644 --- a/docs/app/docs/chat/page.tsx +++ b/docs/app/docs/chat/page.tsx @@ -1,7 +1,6 @@ import { Button } from "@/components/button"; import { HeroBadge } from "@/components/hero-badge"; -import { FeatureCard, FeatureCards } from "@/components/overview-components"; -import { CodeBlock } from "@/components/overview-components"; +import { CodeBlock, FeatureCard, FeatureCards } from "@/components/overview-components"; import { Code2, Database, @@ -73,9 +72,24 @@ export default function ChatOverviewPage() { - } title="Copilot" description="A sidebar assistant that lives alongside your main application content." href="/docs/chat/copilot" /> - } title="Full Screen" description="A standalone, immersive chat page similar to ChatGPT or Claude." href="/docs/chat/fullscreen" /> - } title="Bottom Tray" description="A floating support-style widget that expands from the bottom corner." href="/docs/chat/bottom-tray" /> + } + title="Copilot" + description="A sidebar assistant that lives alongside your main application content." + href="/docs/chat/copilot" + /> + } + title="Full Screen" + description="A standalone, immersive chat page similar to ChatGPT or Claude." + href="/docs/chat/fullscreen" + /> + } + title="Bottom Tray" + description="A floating support-style widget that expands from the bottom corner." + href="/docs/chat/bottom-tray" + /> @@ -89,9 +103,24 @@ export default function ChatOverviewPage() { - } title="Streaming Native" description="Handles text deltas, optimistic updates, and loading states automatically." /> - } title="Thread Persistence" description="Built-in support for saving and loading conversation history via simple API contracts." /> - } title="Theming" description="Customize every color, radius, and font using CSS variables or Tailwind." /> + } + title="Streaming Native" + description="Handles text deltas, optimistic updates, and loading states automatically." + /> + } + title="Thread Persistence" + description="Built-in support for saving and loading conversation history via simple API contracts." + /> + } + title="Theming" + description="Customize every color, radius, and font using CSS variables or Tailwind." + /> diff --git a/docs/app/docs/openui-lang/streaming-comparison.tsx b/docs/app/docs/openui-lang/streaming-comparison.tsx index e4249be23..bca4e1cc1 100644 --- a/docs/app/docs/openui-lang/streaming-comparison.tsx +++ b/docs/app/docs/openui-lang/streaming-comparison.tsx @@ -1,7 +1,7 @@ "use client"; +import { Check, RotateCcw } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { RotateCcw, Check } from "lucide-react"; const JSON_CODE = `{ "component": { @@ -194,7 +194,7 @@ formButtons = Buttons([submitBtn, cancelBtn], "row") submitBtn = Button("Submit", "submit:contact", "primary") cancelBtn = Button("Cancel", "action:cancel_contact", "secondary")`; -// Calculated using openai tokenizer, https://platform.openai.com/tokenizer +// Calculated using openai tokenizer, https://platform.openai.com/tokenizer const JSON_TOKENS = 849; const OPENUI_TOKENS = 294; const TOKEN_RATE = 60; // tokens per second @@ -235,20 +235,24 @@ function StreamingCodeBlock({ } }, [charCount, state]); - const pillBg = variant === "red" - ? "bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400" - : "bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400"; + const pillBg = + variant === "red" + ? "bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400" + : "bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400"; - const doneBg = variant === "red" - ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" - : "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"; + const doneBg = + variant === "red" + ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" + : "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"; return (

{label}

- + {state === "streaming" ? `${currentTokens}/${totalTokens} tokens` : `${totalTokens} tokens`} @@ -404,7 +408,10 @@ export function StreamingComparison() { {JSON_TOKENS - OPENUI_TOKENS} fewer tokens.

)} -
+      
         {code}
       
diff --git a/docs/components/overview-components/feature-card.tsx b/docs/components/overview-components/feature-card.tsx index 37ef408b5..e22c5bd35 100644 --- a/docs/components/overview-components/feature-card.tsx +++ b/docs/components/overview-components/feature-card.tsx @@ -9,7 +9,13 @@ interface FeatureCardProps { direction?: "vertical" | "horizontal"; } -export function FeatureCard({ icon, title, description, href, direction = "vertical" }: FeatureCardProps) { +export function FeatureCard({ + icon, + title, + description, + href, + direction = "vertical", +}: FeatureCardProps) { let content; if (direction === "horizontal") { @@ -18,7 +24,9 @@ export function FeatureCard({ icon, title, description, href, direction = "verti
{icon && (
-
{icon}
+
+ {icon} +
)}
@@ -67,7 +75,12 @@ const colsMap: Record = { 4: "sm:grid-cols-4", }; -export function FeatureCards({ children, direction = "vertical", cols, className }: FeatureCardsProps) { +export function FeatureCards({ + children, + direction = "vertical", + cols, + className, +}: FeatureCardsProps) { let gridClass: string; if (direction === "horizontal") { @@ -77,9 +90,5 @@ export function FeatureCards({ children, direction = "vertical", cols, className gridClass = "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 items-stretch"; } - return ( -
- {children} -
- ); + return
{children}
; } diff --git a/docs/components/overview-components/overview-page.tsx b/docs/components/overview-components/overview-page.tsx index 713977178..3303366e6 100644 --- a/docs/components/overview-components/overview-page.tsx +++ b/docs/components/overview-components/overview-page.tsx @@ -3,14 +3,14 @@ import { Button } from "@/components/button"; import { CodeBlock, + FeatureCard, + FeatureCards, Separator, SimpleCard, Tabs, TabsContent, TabsList, TabsTrigger, - FeatureCard, - FeatureCards, } from "@/components/overview-components"; import { ArrowUpRight, Code2, MessageSquare, Package } from "lucide-react"; import { useState } from "react"; @@ -87,8 +87,8 @@ export function OverviewPage() { > A2UI
{" "} - that reduces token usage by up to 67.1%. Define your component library with Zod schemas, get - automatic system prompts, and parse LLM responses into renderable components. + that reduces token usage by up to 67.1%. Define your component library with Zod schemas, + get automatic system prompts, and parse LLM responses into renderable components.

@@ -324,10 +324,10 @@ import { FullScreen, openuiLibrary } from "@openuidev/react-ui"; +
); } diff --git a/docs/components/theme-toggle.tsx b/docs/components/theme-toggle.tsx index 4b21998f1..c60581c90 100644 --- a/docs/components/theme-toggle.tsx +++ b/docs/components/theme-toggle.tsx @@ -1,9 +1,9 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { Moon, Sun } from 'lucide-react'; -import { useTheme } from 'next-themes'; -import { cn } from '@/lib/cn'; +import { cn } from "@/lib/cn"; +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; export function ThemeToggle({ className }: { className?: string }) { const { setTheme, resolvedTheme } = useTheme(); @@ -18,24 +18,24 @@ export function ThemeToggle({ className }: { className?: string }) { return ( @@ -104,4 +104,4 @@ export const HowItWorks = () => {
); -} \ No newline at end of file +}; diff --git a/docs/content/docs/openui-lang/components/lang-example.tsx b/docs/content/docs/openui-lang/components/lang-example.tsx index 97c21a067..7ab270a76 100644 --- a/docs/content/docs/openui-lang/components/lang-example.tsx +++ b/docs/content/docs/openui-lang/components/lang-example.tsx @@ -9,8 +9,8 @@ import { TabsTrigger, } from "@/components/overview-components"; import { genuiOutput } from "@/components/overview-components/genui"; -import { openuiLibrary } from "@openuidev/react-ui"; import { Renderer } from "@openuidev/react-lang"; +import { openuiLibrary } from "@openuidev/react-ui"; const renderableOutput = `root = Stack([welcomeCard]) welcomeCard = Card([welcomeHeader, welcomeBody]) @@ -119,5 +119,5 @@ const completion = await client.chat.completions.create({
- ) -} + ); +}; diff --git a/docs/content/docs/openui-lang/components/try-it-out.tsx b/docs/content/docs/openui-lang/components/try-it-out.tsx index e4b7178b2..545fc3aff 100644 --- a/docs/content/docs/openui-lang/components/try-it-out.tsx +++ b/docs/content/docs/openui-lang/components/try-it-out.tsx @@ -1,39 +1,40 @@ "use client"; -import { useState } from "react"; +import { ChatModal } from "@/components/overview-components/chat-modal"; import { ArrowUpRight } from "lucide-react"; import Image from "next/image"; -import { ChatModal } from "@/components/overview-components/chat-modal"; +import { useState } from "react"; export const TryItOut = () => { const [isChatModalOpen, setIsChatModalOpen] = useState(false); - return (
setIsChatModalOpen(true)} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === "Enter" && setIsChatModalOpen(true)} - > -
- OpenUI Chat Demo - Click to try it live -
-
-
-
-

Try it out live

-

- Live interactive demo of OpenUI Chat in action -

+ return ( +
setIsChatModalOpen(true)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && setIsChatModalOpen(true)} + > +
+ OpenUI Chat Demo - Click to try it live +
+
+
+
+

Try it out live

+

+ Live interactive demo of OpenUI Chat in action +

+
+
- + {isChatModalOpen && setIsChatModalOpen(false)} />}
- {isChatModalOpen && setIsChatModalOpen(false)} />} -
); -} \ No newline at end of file +}; diff --git a/docs/content/docs/openui-lang/defining-components.mdx b/docs/content/docs/openui-lang/defining-components.mdx index c4a70b18d..f0e1075cb 100644 --- a/docs/content/docs/openui-lang/defining-components.mdx +++ b/docs/content/docs/openui-lang/defining-components.mdx @@ -84,6 +84,26 @@ const TabItemSchema = z.object({ }); ``` +## The `root` field + +The `root` option in `createLibrary` specifies which component the LLM must use as the entry point. The generated system prompt instructs the model to always start with `root = (...)`. + +```ts +const library = createLibrary({ + root: "Stack", // → prompt tells LLM: "every program must define root = Stack(...)" + components: [Stack, Card, TextContent], +}); +``` + +This serves two purposes: + +1. **Constrains the LLM** — the model always wraps its output in a known top-level component, making output predictable. +2. **Enables streaming** — because the root statement comes first, the UI shell renders immediately while child components stream in. + +The `root` must match the `name` of one of the components in your library. If omitted, the prompt uses "Root" as a placeholder. + +For the built-in libraries: `openuiLibrary` uses `Stack` (flexible layout container), while `openuiChatLibrary` uses `Card` (vertical container optimized for chat responses). + ## Notes on schema metadata - Positional mapping is driven by Zod object key order. @@ -103,6 +123,67 @@ const library = createLibrary({ }); ``` +### Why group components? + +`componentGroups` organize the generated system prompt into named sections (e.g., Layout, Forms, Charts). This helps the LLM locate relevant components quickly instead of scanning a flat list. Without groups, all component signatures appear under a single "Ungrouped" heading. + +Groups also let you co-locate related components so the LLM understands which components work together (e.g., `Form` with `FormControl`, `Input`, `Select`). + +### Adding group notes + +Each group can include a `notes` array — these strings are appended directly after the group's component signatures in the generated prompt. Use notes to give the LLM usage hints and constraints: + +```ts +componentGroups: [ + { + name: "Forms", + components: ["Form", "FormControl", "Input", "TextArea", "Select"], + notes: [ + "- Define EACH FormControl as its own reference for progressive streaming.", + "- NEVER nest Form inside Form.", + "- Form requires explicit buttons: Form(name, buttons, fields).", + ], + }, + { + name: "Layout", + components: ["Stack", "Tabs", "TabItem", "Accordion", "AccordionItem"], + notes: [ + '- For grid-like layouts, use Stack with direction "row" and wrap=true.', + ], + }, +], +``` + +Notes appear in the prompt output like this: + +``` +### Forms +Form(id: string, buttons: Buttons, controls: FormControl[]) — Form container +FormControl(label: string, field: Input | TextArea | Select) — Single field +... +- Define EACH FormControl as its own reference for progressive streaming. +- NEVER nest Form inside Form. +- Form requires explicit buttons: Form(name, buttons, fields). +``` + +### Prompt options + +When generating the system prompt, you can pass `PromptOptions` to customize the output further: + +```ts +import type { PromptOptions } from "@openuidev/react-lang"; + +const options: PromptOptions = { + preamble: "You are an assistant that outputs only OpenUI Lang.", + additionalRules: ["Always use Card as the root for chat responses."], + examples: [`root = Stack([title])\ntitle = TextContent("Hello", "large-heavy")`], +}; + +const prompt = library.prompt(options); +``` + +See [System Prompts](/docs/openui-lang/system-prompts) for full details on prompt generation. + ## Next Steps diff --git a/docs/content/docs/openui-lang/index.mdx b/docs/content/docs/openui-lang/index.mdx index 8d678f368..8d90e47d4 100644 --- a/docs/content/docs/openui-lang/index.mdx +++ b/docs/content/docs/openui-lang/index.mdx @@ -4,22 +4,19 @@ title: Introduction import { StreamingComparison } from "@/app/docs/openui-lang/streaming-comparison"; import { TryItOut } from "./components/try-it-out"; -import { LangExample } from "./components/lang-example"; -OpenUI is a framework for building Generative UI with a compact, streaming-first language that is up to **[67% more token-efficient](/docs/openui-lang/benchmarks)** than JSON, resulting in faster AI-generated interfaces. +OpenUI is a full-stack Generative UI framework — a compact streaming-first language, a React runtime with built-in component libraries, and ready-to-use chat interfaces — that is up to **[67% more token-efficient](/docs/openui-lang/benchmarks)** than JSON. ## What is Generative UI? - Most AI applications are limited to returning text (as markdown) or rendering pre-built UI responses. Markdown isn't interactive, and pre-built responses are rigid (they don't adapt to the context of the conversation). - Generative UI fundamentally changes this relationship. Instead of merely providing content, the AI composes the interface itself. It dynamically selects, configures, and composes components from a predefined library to create a purpose-built interface tailored to the user's immediate request, be it an interactive chart, a complex form, or a multi-tab dashboard. ## OpenUI Lang -OpenUI Lang is a compact, line-oriented language designed specifically for Large Language Models (LLMs) to generate user interfaces. It serves as a more efficient, predictable, and stream-friendly alternative to verbose formats like JSON. +OpenUI Lang is a compact, line-oriented language designed specifically for Large Language Models (LLMs) to generate user interfaces. It serves as a more efficient, predictable, and stream-friendly alternative to verbose formats like JSON. For the complete syntax reference, see the [Language Specification](/docs/openui-lang/specification). ### Why a New Language? @@ -37,22 +34,14 @@ OpenUI Lang was created to solve these core issues: ## How It Works -Architecture diagram - -Here is a breakdown of Generative UI workflow: - -1. **User Query:** The process begins when a user interacts with your application. In this example, they ask, "What did I spend on last month?". - -2. **Backend Processing:** The user's query is sent to your backend. Backend applies its own business logic (e.g., authenticating the user, fetching spending data from a database) and prepares a request for an LLM provider. - -3. **System Prompt to LLM:** Backend sends its system prompt to the request along with OpenUI Lang spec prompt. +Architecture diagram -4. **LLM Generates OpenUI Lang:** The LLM provider (like OpenAI, Anthropic, etc.) processes the prompt. Instead of returning plain text or JSON, it generates a response in **OpenUI Lang**, a token-efficient syntax designed for this purpose (e.g., `root = Stack([chart])`). +The key difference from a standard chat app is what happens at the LLM and rendering layers: -5. **Rendering:** On the client side, the `@openuidev/lang-react` library's `` component receives and parses the OpenUI Lang stream in real-time. As each line arrives, it safely maps the code to the corresponding React components you defined in your library and renders them. +1. **System prompt includes OpenUI Lang spec** — Your backend appends the generated component library prompt alongside your system prompt, instructing the LLM to respond in OpenUI Lang instead of plain text or JSON. -The final result is a rich, native UI—like the "Total expenses" card and interactive pie chart—that was dynamically generated by the AI, streamed efficiently, and rendered safely on the client's device. +2. **LLM generates OpenUI Lang** — Instead of returning markdown, the model outputs a compact, line-oriented syntax (e.g., `root = Stack([chart])`) constrained to your component library. -## Usage Example +3. **Streaming render** — On the client, the `` component parses each line as it arrives and maps it to your React components in real-time — structure first, then data fills in progressively. - \ No newline at end of file +The result is a native UI — like the "Total expenses" card and interactive pie chart above — dynamically composed by the AI, streamed efficiently, and rendered safely from your own components. diff --git a/docs/content/docs/openui-lang/interactivity.mdx b/docs/content/docs/openui-lang/interactivity.mdx index dd2324cea..795781a48 100644 --- a/docs/content/docs/openui-lang/interactivity.mdx +++ b/docs/content/docs/openui-lang/interactivity.mdx @@ -25,13 +25,13 @@ When a user clicks a button or follow-up, the component calls `triggerAction`. T ### `ActionEvent` -| Field | Type | Description | -| :--------------------- | :------------------------------ | :--------------------------------------------- | -| `type` | `string` | Action type (see built-in types below). | -| `params` | `Record` | Extra parameters from the component. | -| `humanFriendlyMessage` | `string` | Display label for the action. | -| `formState` | `Record \| undefined` | Raw field state at time of action. | -| `formName` | `string \| undefined` | Form that scoped the action, if any. | +| Field | Type | Description | +| :--------------------- | :--------------------------------- | :-------------------------------------- | +| `type` | `string` | Action type (see built-in types below). | +| `params` | `Record` | Extra parameters from the component. | +| `humanFriendlyMessage` | `string` | Display label for the action. | +| `formState` | `Record \| undefined` | Raw field state at time of action. | +| `formName` | `string \| undefined` | Form that scoped the action, if any. | ### Built-in action types @@ -91,12 +91,12 @@ Use `onStateUpdate` to persist field state (e.g. to a message in your thread sto Use these inside `defineComponent` renderers: -| Hook | Signature | Description | -| :------------------ | :------------------------------------------------------------------------------------------------- | :--------------------------------- | -| `useGetFieldValue` | `(formName: string \| undefined, name: string) => any` | Read a field's current value. | -| `useSetFieldValue` | `(formName: string \| undefined, componentType: string \| undefined, name: string, value: any, shouldTriggerSaveCallback?: boolean) => void` | Write a field value. | -| `useFormName` | `() => string \| undefined` | Get the enclosing form's name. | -| `useSetDefaultValue`| `(options: { formName?, componentType, name, existingValue, defaultValue, shouldTriggerSaveCallback? }) => void` | Set a default if no value exists. | +| Hook | Signature | Description | +| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------- | +| `useGetFieldValue` | `(formName: string \| undefined, name: string) => any` | Read a field's current value. | +| `useSetFieldValue` | `(formName: string \| undefined, componentType: string \| undefined, name: string, value: any, shouldTriggerSaveCallback?: boolean) => void` | Write a field value. | +| `useFormName` | `() => string \| undefined` | Get the enclosing form's name. | +| `useSetDefaultValue` | `(options: { formName?, componentType, name, existingValue, defaultValue, shouldTriggerSaveCallback? }) => void` | Set a default if no value exists. | --- diff --git a/docs/content/docs/openui-lang/meta.json b/docs/content/docs/openui-lang/meta.json index 129b06186..11bc1aede 100644 --- a/docs/content/docs/openui-lang/meta.json +++ b/docs/content/docs/openui-lang/meta.json @@ -5,6 +5,7 @@ "index", "quickstart", "---Core Concepts---", + "overview", "defining-components", "system-prompts", "renderer", diff --git a/docs/content/docs/openui-lang/overview.mdx b/docs/content/docs/openui-lang/overview.mdx new file mode 100644 index 000000000..98a32c6fe --- /dev/null +++ b/docs/content/docs/openui-lang/overview.mdx @@ -0,0 +1,98 @@ +--- +title: Overview +description: Key building blocks of the OpenUI framework and the built-in component libraries. +--- + +import { LangExample } from "./components/lang-example"; + +OpenUI is built around four core building blocks that work together to turn LLM output into rendered UI: + +- **Library** — A collection of components defined with Zod schemas and React renderers. The library is the contract between your app and the AI — it defines what components the LLM can use and how they render. + +- **Prompt Generator** — Converts your library into a system prompt that instructs the LLM to output valid OpenUI Lang. Includes syntax rules, component signatures, streaming guidelines, and your custom examples/rules. + +- **Parser** — Parses OpenUI Lang text (line-by-line, streaming-compatible) into a typed element tree. Validates against your library's JSON Schema and gracefully handles partial/invalid output. + +- **Renderer** — The `` React component takes parsed output and maps each element to your library's React components, rendering the UI progressively as the stream arrives. + +## Built-in Component Libraries + +OpenUI ships with two ready-to-use libraries via `@openuidev/react-ui`. Both include layouts, content blocks, charts, forms, tables, and more. + +### General-purpose library (`openuiLibrary`) + +Root component is `Stack`. Includes the full component suite with flexible layout primitives. Use this for standalone rendering, playgrounds, and non-chat interfaces. + +```ts +import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib"; +import { Renderer } from "@openuidev/react-lang"; + +// Generate system prompt +const systemPrompt = openuiLibrary.prompt(openuiPromptOptions); + +// Render streamed output + +``` + +### Chat-optimized library (`openuiChatLibrary`) + +Root component is `Card` (vertical container, no layout params). Adds chat-specific components like `FollowUpBlock`, `ListBlock`, and `SectionBlock`. Does not include `Stack` — responses are always single-card, vertically stacked. + +```ts +import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui/genui-lib"; +import { FullScreen } from "@openuidev/react-ui"; + +// Use with a chat layout + +``` + +Both libraries expose a `.prompt()` method to generate the system prompt your LLM needs. See [System Prompts](/docs/openui-lang/system-prompts) for CLI and programmatic generation options. + +### Extend a built-in library + +```ts +import { createLibrary, defineComponent } from "@openuidev/react-lang"; +import { openuiLibrary } from "@openuidev/react-ui/genui-lib"; +import { z } from "zod"; + +const ProductCard = defineComponent({ + name: "ProductCard", + description: "Product tile", + props: z.object({ + name: z.string(), + price: z.number(), + }), + component: ({ props }) =>
{props.name}: ${props.price}
, +}); + +const myLibrary = createLibrary({ + root: openuiLibrary.root ?? "Stack", + componentGroups: openuiLibrary.componentGroups, + components: [...Object.values(openuiLibrary.components), ProductCard], +}); +``` + +## Usage Example + + + +## Next Steps + + + + Create custom components with Zod schemas and React renderers. + + + Generate and customize LLM instructions from your library. + + + Parse and render streamed OpenUI Lang in React. + + + Build AI chat interfaces with prebuilt layouts. + + diff --git a/docs/content/docs/openui-lang/quickstart.mdx b/docs/content/docs/openui-lang/quickstart.mdx index 4fd33290b..fc19f1c02 100644 --- a/docs/content/docs/openui-lang/quickstart.mdx +++ b/docs/content/docs/openui-lang/quickstart.mdx @@ -1,28 +1,65 @@ --- title: Quick Start -description: Use the OpenUI library to render OpenUI Lang immediately. +description: Bootstrap a Generative UI chat app in under a minute. --- - -#### Bootstrap a GenUI Chat app +## Bootstrap a GenUI Chat app ```bash npx @openuidev/cli@latest create --name genui-chat-app cd genui-chat-app ``` -#### Add your API key +## Add your API key + +The generated app uses OpenAI by default, but works with any OpenAI-compatible provider (e.g., OpenRouter, Azure OpenAI, Anthropic via proxy). ```bash echo "OPENAI_API_KEY=sk-your-key-here" > .env ``` -#### Start the dev server +## Start the dev server ```bash npm run dev ``` -The generated app wires up a predefined component library in `src/app/page.tsx`. +## What's included + +The CLI generates a Next.js app with everything wired up: + +``` +src/ + app/ + page.tsx # FullScreen chat layout with the built-in component library + api/chat/ + route.ts # Backend route with OpenAI streaming + example tools + library.ts # Re-exports openuiChatLibrary and openuiChatPromptOptions + generated/ + system-prompt.txt # Auto-generated at build time via `openui generate` +``` + +- **`page.tsx`** — Renders the `FullScreen` chat layout with `openuiChatLibrary` for Generative UI rendering and `openAIAdapter()` for streaming. +- **`route.ts`** — A backend API route that sends the system prompt to the LLM and streams the response back. +- **`library.ts`** — Your component library entrypoint. The `openui generate` CLI reads this file to produce the system prompt. + +The `dev` and `build` scripts automatically regenerate the system prompt before starting: + +```json +"generate:prompt": "openui generate src/library.ts --out src/generated/system-prompt.txt", +"dev": "pnpm generate:prompt && next dev" +``` + +## Next Steps -Follow guide: [Define Your Components](/docs/openui-lang/defining-components) to learn how to create your own component library. + + + Understand the key building blocks and built-in component libraries. + + + Create your own custom component library. + + + Explore chat-specific layouts and configurations. + + diff --git a/docs/content/docs/openui-lang/renderer.mdx b/docs/content/docs/openui-lang/renderer.mdx index 9e528362e..58adcadba 100644 --- a/docs/content/docs/openui-lang/renderer.mdx +++ b/docs/content/docs/openui-lang/renderer.mdx @@ -24,15 +24,15 @@ export function AssistantMessage({ ## Props -| Prop | Type | Description | -| :--------------- | :-------------------------------------- | :------------------------------------------------------------- | -| `response` | `string \| null` | Raw OpenUI Lang response text. | -| `library` | `Library` | Library created by `createLibrary(...)`. | -| `isStreaming` | `boolean` | Indicates stream is in progress. | -| `onAction` | `(event: ActionEvent) => void` | Receives structured action events from interactive components. | -| `onStateUpdate` | `(state: Record) => void` | Called on form field changes with the raw field state map. | -| `initialState` | `Record` | Hydrates form state on load (e.g. from persisted message). | -| `onParseResult` | `(result: ParseResult \| null) => void` | Debug/inspect latest parse result. | +| Prop | Type | Description | +| :-------------- | :-------------------------------------- | :------------------------------------------------------------- | +| `response` | `string \| null` | Raw OpenUI Lang response text. | +| `library` | `Library` | Library created by `createLibrary(...)`. | +| `isStreaming` | `boolean` | Indicates stream is in progress. | +| `onAction` | `(event: ActionEvent) => void` | Receives structured action events from interactive components. | +| `onStateUpdate` | `(state: Record) => void` | Called on form field changes with the raw field state map. | +| `initialState` | `Record` | Hydrates form state on load (e.g. from persisted message). | +| `onParseResult` | `(result: ParseResult \| null) => void` | Debug/inspect latest parse result. | ## Streaming behavior diff --git a/docs/content/docs/openui-lang/standard-library.mdx b/docs/content/docs/openui-lang/standard-library.mdx index 0c71f9f74..dd754fa5d 100644 --- a/docs/content/docs/openui-lang/standard-library.mdx +++ b/docs/content/docs/openui-lang/standard-library.mdx @@ -23,6 +23,14 @@ import { openuiLibrary } from "@openuidev/react-ui"; ## Generate prompt +Use the CLI to generate the system prompt at build time: + +```bash +npx @openuidev/cli generate ./src/library.ts --out src/generated/system-prompt.txt +``` + +Or generate programmatically: + ```ts import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui"; diff --git a/docs/content/docs/openui-lang/system-prompts.mdx b/docs/content/docs/openui-lang/system-prompts.mdx index 8072cd4bd..fc522a348 100644 --- a/docs/content/docs/openui-lang/system-prompts.mdx +++ b/docs/content/docs/openui-lang/system-prompts.mdx @@ -3,9 +3,31 @@ title: System Prompts description: Generate and customize prompt instructions from your OpenUI library. --- -`library.prompt(...)` generates the instruction text your model needs to output valid OpenUI Lang. +`library.prompt(...)` generates the instruction text your model needs to output valid OpenUI Lang. You can generate it programmatically or with the CLI. -## Generate prompt +## Generate with the CLI + +The fastest way to generate a system prompt — works with any backend language: + +```bash +npx @openuidev/cli generate ./src/library.ts +``` + +Write to a file: + +```bash +npx @openuidev/cli generate ./src/library.ts --out system-prompt.txt +``` + +Generate JSON Schema instead: + +```bash +npx @openuidev/cli generate ./src/library.ts --json-schema +``` + +The CLI auto-detects exported `PromptOptions` (examples, rules) alongside your library. Use `--prompt-options ` to pick a specific export. + +## Generate programmatically ```ts import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui"; @@ -46,7 +68,7 @@ The generated prompt includes: ```ts import OpenAI from "openai"; -import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui"; +import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib"; const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); @@ -56,13 +78,12 @@ export async function POST(req: Request) { const completion = await client.chat.completions.create({ model: "gpt-5.2", stream: true, - messages: [ - { role: "system", content: openuiLibrary.prompt(openuiPromptOptions) }, - ...messages, - ], + messages: [{ role: "system", content: openuiLibrary.prompt(openuiPromptOptions) }, ...messages], }); - return new Response("stream here"); + return new Response(completion.toReadableStream(), { + headers: { "Content-Type": "text/event-stream" }, + }); } ``` diff --git a/docs/generated/chat-system-prompt.txt b/docs/generated/chat-system-prompt.txt new file mode 100644 index 000000000..b655c131e --- /dev/null +++ b/docs/generated/chat-system-prompt.txt @@ -0,0 +1,202 @@ +You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang. + +## Syntax Rules + +1. Each statement is on its own line: `identifier = Expression` +2. `root` is the entry point — every program must define `root = Card(...)` +3. Expressions are: strings ("..."), numbers, booleans (true/false), arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...) +4. Use references for readability: define `name = ...` on one line, then use `name` later +5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array. +6. Arguments are POSITIONAL (order matters, not names) +7. Optional arguments can be omitted from the end +8. No operators, no logic, no variables — only declarations +9. Strings use double quotes with backslash escaping + +## Component Signatures + +Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming. +The `action` prop type accepts: ContinueConversation (sends message to LLM), OpenUrl (navigates to URL), or Custom (app-defined). + +### Content +CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle +TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy". +MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant +Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string) — Callout banner with variant, title, and description +TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description +Image(alt: string, src?: string) — Image with alt text and optional URL +ImageBlock(src: string, alt?: string) — Image block with loading state +ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview +CodeBlock(language: string, codeString: string) — Syntax-highlighted code block +Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections + +### Tables +Table(columns: Col[], rows: (string | number | boolean)[][]) — Data table +Col(label: string, type?: "string" | "number" | "action") — Column definition + +### Charts (2D) +BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series +LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time +AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time +RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities +HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists +Series(category: string, values: number[]) — One data series + +### Charts (1D) +PieChart(slices: Slice[], variant?: "pie" | "donut") — Circular slices showing part-to-whole proportions; supports pie and donut variants +RadialChart(slices: Slice[]) — Radial bars showing proportional distribution across named segments +SingleStackedBarChart(slices: Slice[]) — Single horizontal stacked bar; use for showing part-to-whole proportions in one row +Slice(category: string, value: number) — One slice with label and numeric value + +### Charts (Scatter) +ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering +ScatterSeries(name: string, points: Point[]) — Named dataset +Point(x: number, y: number, z?: number) — Data point with numeric coordinates + +### Forms +Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text +Label(text: string) — Text label +Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +SelectItem(value: string, label: string) — Option for Select +DatePicker(name: string, mode: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) +RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +RadioItem(label: string, description: string, value: string) +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk") — Group of switch toggles +SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle +- Define EACH FormControl as its own reference — do NOT inline all controls in one array. +- NEVER nest Form inside Form. +- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument. +- rules is an optional object: { required: true, email: true, min: 8, maxLength: 100 } +- The renderer shows error messages automatically — do NOT generate error text in the UI + +### Buttons +Button(label: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button +Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column". + +### Lists & Follow-ups +ListBlock(items: ListItem[], variant?: "number" | "image") — A list of items with number or image indicators. Each item can optionally have an action. +ListItem(title: string, subtitle?: string, image?: {src: string, alt: string}, actionLabel?: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}) — Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable. +FollowUpBlock(items: FollowUpItem[]) — List of clickable follow-up suggestions placed at the end of a response +FollowUpItem(text: string) — Clickable follow-up suggestion — when clicked, sends text as user message +- Use ListBlock with ListItem references for numbered, clickable lists. +- Use FollowUpBlock with FollowUpItem references at the end of a response to suggest next actions. +- Clicking a ListItem or FollowUpItem sends its text to the LLM as a user message. +- Example: list = ListBlock([item1, item2]) item1 = ListItem("Option A", "Details about A") + +### Sections +SectionBlock(sections: SectionItem[], isFoldable?: boolean) — Collapsible accordion sections. Auto-opens sections as they stream in. Use SectionItem for each section. +SectionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock)[]) — Section with a label and collapsible content — used inside SectionBlock +- SectionBlock renders collapsible accordion sections that auto-open as they stream. +- Each section needs a unique `value` id, a `trigger` label, and a `content` array. +- Example: sections = SectionBlock([s1, s2]) s1 = SectionItem("intro", "Introduction", [content1]) +- Set isFoldable=false to render sections as flat headers instead of accordion. + +### Layout +Tabs(items: TabItem[]) — Tabbed container +TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components +Accordion(items: AccordionItem[]) — Collapsible sections +AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title +Steps(items: StepsItem[]) — Step-by-step guide +StepsItem(title: string, details: string) — title and details text for one step +Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel +- Use Tabs to present alternative views — each TabItem has a value id, trigger label, and content array. +- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]]) +- IMPORTANT: Every slide in a Carousel must have the same structure — same component types in the same order. +- For image carousels use: [[title, image, description, tags], ...] — every slide must follow this exact pattern. +- Use real, publicly accessible image URLs (e.g. https://picsum.photos/seed/KEYWORD/800/500). Never hallucinate image URLs. + +### Data Display +TagBlock(tags: string[]) — tags is an array of strings +Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant + +### Ungrouped +Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock | SectionBlock | Tabs | Carousel)[]) — Vertical container for all content in a chat response. Children stack top to bottom automatically. + +## Hoisting & Streaming (CRITICAL) + +openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. + +During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in. + +**Recommended statement order for optimal streaming:** +1. `root = Card(...)` — UI shell appears immediately +2. Component definitions — fill in as they stream +3. Data values — leaf content last + +Always write the root = Card(...) statement first so the UI shell appears immediately, even before child data has streamed in. + +## Examples + +Example 1 — Table with follow-ups: +root = Card([title, tbl, followUps]) +title = TextContent("Top Languages", "large-heavy") +tbl = Table(cols, rows) +cols = [Col("Language", "string"), Col("Users (M)", "number"), Col("Year", "number")] +rows = [["Python", 15.7, 1991], ["JavaScript", 14.2, 1995], ["Java", 12.1, 1995]] +followUps = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Tell me more about Python") +fu2 = FollowUpItem("Show me a JavaScript comparison") + +Example 2 — Clickable list: +root = Card([title, list]) +title = TextContent("Choose a topic", "large-heavy") +list = ListBlock([item1, item2, item3]) +item1 = ListItem("Getting started", "New to the platform? Start here.") +item2 = ListItem("Advanced features", "Deep dives into powerful capabilities.") +item3 = ListItem("Troubleshooting", "Common issues and how to fix them.") + +Example 3 — Image carousel with consistent slides + follow-ups: +root = Card([header, carousel, followups]) +header = CardHeader("Featured Destinations", "Discover highlights and best time to visit") +carousel = Carousel([[t1, img1, d1, tags1], [t2, img2, d2, tags2], [t3, img3, d3, tags3]], "card") +t1 = TextContent("Paris, France", "large-heavy") +img1 = ImageBlock("https://picsum.photos/seed/paris/800/500", "Eiffel Tower at night") +d1 = TextContent("City of light — best Apr–Jun and Sep–Oct.", "default") +tags1 = TagBlock(["Landmark", "City Break", "Culture"]) +t2 = TextContent("Kyoto, Japan", "large-heavy") +img2 = ImageBlock("https://picsum.photos/seed/kyoto/800/500", "Bamboo grove in Arashiyama") +d2 = TextContent("Temples and bamboo groves — best Mar–Apr and Nov.", "default") +tags2 = TagBlock(["Temples", "Autumn", "Culture"]) +t3 = TextContent("Machu Picchu, Peru", "large-heavy") +img3 = ImageBlock("https://picsum.photos/seed/machupicchu/800/500", "Inca citadel in the clouds") +d3 = TextContent("High-altitude Inca citadel — best May–Sep.", "default") +tags3 = TagBlock(["Andes", "Hike", "UNESCO"]) +followups = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Show me only beach destinations") +fu2 = FollowUpItem("Turn this into a comparison table") + +Example 4 — Form with validation: +root = Card([title, form]) +title = TextContent("Contact Us", "large-heavy") +form = Form("contact", btns, [nameField, emailField, msgField]) +nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 })) +emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true })) +msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) +btns = Buttons([Button("Submit", { type: "continue_conversation" }, "primary")]) + +## Important Rules +- ALWAYS start with root = Card(...) +- Write statements in TOP-DOWN order: root → components → data (leverages hoisting for progressive streaming) +- Each statement on its own line +- No trailing text or explanations — output ONLY openui-lang code +- When asked about data, generate realistic/plausible data +- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.) +- NEVER define a variable without referencing it from the tree. Every variable must be reachable from root, otherwise it will not render. + +- Every response is a single Card(children) — children stack vertically automatically. No layout params are needed on Card. +- Card is the only layout container. Do NOT use Stack. Use Tabs to switch between sections, Carousel for horizontal scroll. +- Use FollowUpBlock at the END of a Card to suggest what the user can do or ask next. +- Use ListBlock when presenting a set of options or steps the user can click to select. +- Use SectionBlock to group long responses into collapsible sections — good for reports, FAQs, and structured content. +- Use SectionItem inside SectionBlock: each item needs a unique value id, a trigger (header label), and a content array. +- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]]) +- IMPORTANT: Every slide in a Carousel must use the same component structure in the same order — e.g. all slides: [title, image, description, tags]. +- For image carousels, always use real accessible URLs like https://picsum.photos/seed/KEYWORD/800/500. Never hallucinate or invent image URLs. +- For forms, define one FormControl reference per field so controls can stream progressively. +- For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields). +- Never nest Form inside Form. diff --git a/docs/generated/playground-system-prompt.txt b/docs/generated/playground-system-prompt.txt new file mode 100644 index 000000000..382a5ecd8 --- /dev/null +++ b/docs/generated/playground-system-prompt.txt @@ -0,0 +1,157 @@ +You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang. + +## Syntax Rules + +1. Each statement is on its own line: `identifier = Expression` +2. `root` is the entry point — every program must define `root = Stack(...)` +3. Expressions are: strings ("..."), numbers, booleans (true/false), arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...) +4. Use references for readability: define `name = ...` on one line, then use `name` later +5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array. +6. Arguments are POSITIONAL (order matters, not names) +7. Optional arguments can be omitted from the end +8. No operators, no logic, no variables — only declarations +9. Strings use double quotes with backslash escaping + +## Component Signatures + +Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming. +The `action` prop type accepts: ContinueConversation (sends message to LLM), OpenUrl (navigates to URL), or Custom (app-defined). + +### Layout +Stack([children], direction?: "row" | "column", gap?: "none" | "xs" | "s" | "m" | "l" | "xl" | "2xl", align?: "start" | "center" | "end" | "stretch" | "baseline", justify?: "start" | "center" | "end" | "between" | "around" | "evenly", wrap?: boolean) — Flex container. direction: "row"|"column" (default "column"). gap: "none"|"xs"|"s"|"m"|"l"|"xl"|"2xl" (default "m"). align: "start"|"center"|"end"|"stretch"|"baseline". justify: "start"|"center"|"end"|"between"|"around"|"evenly". +Tabs(items: TabItem[]) — Tabbed container +TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components +Accordion(items: AccordionItem[]) — Collapsible sections +AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title +Steps(items: StepsItem[]) — Step-by-step guide +StepsItem(title: string, details: string) — title and details text for one step +Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel +Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections +- For grid-like layouts, use Stack with direction "row" and wrap set to true. +- Prefer justify "start" (or omit justify) with wrap=true for stable columns instead of uneven gutters. +- Use nested Stacks when you need explicit rows/sections. + +### Content +Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | Tabs | Carousel | Stack)[], variant?: "card" | "sunk" | "clear", direction?: "row" | "column", gap?: "none" | "xs" | "s" | "m" | "l" | "xl" | "2xl", align?: "start" | "center" | "end" | "stretch" | "baseline", justify?: "start" | "center" | "end" | "between" | "around" | "evenly", wrap?: boolean) — Styled container. variant: "card" (default, elevated) | "sunk" (recessed) | "clear" (transparent). Always full width. Accepts all Stack flex params (default: direction "column"). Cards flex to share space in row/wrap layouts. +CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle +TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy". +MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant +Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string) — Callout banner with variant, title, and description +TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description +Image(alt: string, src?: string) — Image with alt text and optional URL +ImageBlock(src: string, alt?: string) — Image block with loading state +ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview +CodeBlock(language: string, codeString: string) — Syntax-highlighted code block + +### Tables +Table(columns: Col[], rows: (string | number | boolean)[][]) — Data table +Col(label: string, type?: "string" | "number" | "action") — Column definition + +### Charts (2D) +BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series +LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time +AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time +RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities +HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists +Series(category: string, values: number[]) — One data series + +### Charts (1D) +PieChart(slices: Slice[], variant?: "pie" | "donut") — Circular slices showing part-to-whole proportions; supports pie and donut variants +RadialChart(slices: Slice[]) — Radial bars showing proportional distribution across named segments +SingleStackedBarChart(slices: Slice[]) — Single horizontal stacked bar; use for showing part-to-whole proportions in one row +Slice(category: string, value: number) — One slice with label and numeric value + +### Charts (Scatter) +ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering +ScatterSeries(name: string, points: Point[]) — Named dataset +Point(x: number, y: number, z?: number) — Data point with numeric coordinates + +### Forms +Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text +Label(text: string) — Text label +Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +SelectItem(value: string, label: string) — Option for Select +DatePicker(name: string, mode: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) +RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +RadioItem(label: string, description: string, value: string) +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk") — Group of switch toggles +SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle +- For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming. +- NEVER nest Form inside Form — each Form should be a standalone container. +- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument. +- rules is an optional array of validation strings: ["required", "email", "min:8", "maxLength:100"] +- Available rules: required, email, min:N, max:N, minLength:N, maxLength:N, pattern:REGEX, url, numeric +- The renderer shows error messages automatically — do NOT generate error text in the UI + +### Buttons +Button(label: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button +Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column". + +### Data Display +TagBlock(tags: string[]) — tags is an array of strings +Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant + +## Hoisting & Streaming (CRITICAL) + +openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. + +During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in. + +**Recommended statement order for optimal streaming:** +1. `root = Stack(...)` — UI shell appears immediately +2. Component definitions — fill in as they stream +3. Data values — leaf content last + +Always write the root = Stack(...) statement first so the UI shell appears immediately, even before child data has streamed in. + +## Examples + +Example 1 — Table: +root = Stack([title, tbl]) +title = TextContent("Top Languages", "large-heavy") +tbl = Table(cols, rows) +cols = [Col("Language", "string"), Col("Users (M)", "number"), Col("Year", "number")] +rows = [["Python", 15.7, 1991], ["JavaScript", 14.2, 1995], ["Java", 12.1, 1995], ["TypeScript", 8.5, 2012], ["Go", 5.2, 2009]] + +Example 2 — Bar chart: +root = Stack([title, chart]) +title = TextContent("Q4 Revenue", "large-heavy") +chart = BarChart(labels, [s1, s2], "grouped") +labels = ["Oct", "Nov", "Dec"] +s1 = Series("Product A", [120, 150, 180]) +s2 = Series("Product B", [90, 110, 140]) + +Example 3 — Form with validation: +root = Stack([title, form]) +title = TextContent("Contact Us", "large-heavy") +form = Form("contact", btns, [nameField, emailField, countryField, msgField]) +nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 })) +emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true })) +countryField = FormControl("Country", Select("country", countryOpts, "Select...", { required: true })) +msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) +countryOpts = [SelectItem("us", "United States"), SelectItem("uk", "United Kingdom"), SelectItem("de", "Germany")] +btns = Buttons([Button("Submit", { type: "continue_conversation" }, "primary"), Button("Cancel", { type: "continue_conversation" }, "secondary")]) + +Example 4 — Tabs with mixed content: +root = Stack([title, tabs]) +title = TextContent("React vs Vue", "large-heavy") +tabs = Tabs([tabReact, tabVue]) +tabReact = TabItem("react", "React", reactContent) +tabVue = TabItem("vue", "Vue", vueContent) +reactContent = [TextContent("React is a library by Meta for building UIs."), Callout("info", "Note", "React uses JSX syntax.")] +vueContent = [TextContent("Vue is a progressive framework by Evan You."), Callout("success", "Tip", "Vue has a gentle learning curve.")] + +## Important Rules +- ALWAYS start with root = Stack(...) +- Write statements in TOP-DOWN order: root → components → data (leverages hoisting for progressive streaming) +- Each statement on its own line +- No trailing text or explanations — output ONLY openui-lang code +- When asked about data, generate realistic/plausible data +- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.) +- NEVER define a variable without referencing it from the tree. Every variable must be reachable from root, otherwise it will not render. diff --git a/docs/imports/OpenUiGeneratesSchema.tsx b/docs/imports/OpenUiGeneratesSchema.tsx index 2c4a249f1..ab23e8742 100644 --- a/docs/imports/OpenUiGeneratesSchema.tsx +++ b/docs/imports/OpenUiGeneratesSchema.tsx @@ -13,10 +13,10 @@ function Group() {
-

{`import { library } from "./library" - -const systemPrompt = library.prompt() +

{`# generate system prompt from library +npx @openuidev/cli generate ./src/library.ts +# use in your backend const completion = await client.chat.completions.create({ model: "gpt-5.2", stream: true, diff --git a/docs/lib/chat-library.ts b/docs/lib/chat-library.ts new file mode 100644 index 000000000..316f65a7d --- /dev/null +++ b/docs/lib/chat-library.ts @@ -0,0 +1,4 @@ +export { + openuiChatLibrary as library, + openuiChatPromptOptions as promptOptions, +} from "@openuidev/react-ui/genui-lib"; diff --git a/docs/lib/playground-library.ts b/docs/lib/playground-library.ts new file mode 100644 index 000000000..5150c55c6 --- /dev/null +++ b/docs/lib/playground-library.ts @@ -0,0 +1,4 @@ +import { openuiExamples } from "@openuidev/react-ui/genui-lib"; + +export { openuiLibrary as library } from "@openuidev/react-ui/genui-lib"; +export const promptOptions = { examples: openuiExamples }; diff --git a/docs/package.json b/docs/package.json index 7a72c7254..676daa44e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,8 +3,9 @@ "version": "0.0.0", "private": true, "scripts": { + "generate:prompts": "pnpm --filter @openuidev/cli build && pnpm exec openui generate lib/chat-library.ts --out generated/chat-system-prompt.txt && pnpm exec openui generate lib/playground-library.ts --out generated/playground-system-prompt.txt", "build": "next build", - "dev": "next dev", + "dev": "pnpm generate:prompts && next dev", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", "postinstall": "fumadocs-mdx", @@ -13,6 +14,7 @@ "format:check": "prettier --check ." }, "dependencies": { + "@openuidev/cli": "workspace:*", "@openuidev/react-lang": "workspace:^", "@openuidev/react-headless": "workspace:^", "@openuidev/react-ui": "workspace:^", diff --git a/examples/openui-chat/package.json b/examples/openui-chat/package.json index 28bd6860c..0e46d9c90 100644 --- a/examples/openui-chat/package.json +++ b/examples/openui-chat/package.json @@ -3,12 +3,14 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "generate:prompt": "pnpm --filter @openuidev/cli build && pnpm exec openui generate src/library.ts --out src/generated/system-prompt.txt", + "dev": "pnpm generate:prompt && next dev", "build": "next build", "start": "next start", "lint": "eslint" }, "dependencies": { + "@openuidev/cli": "workspace:*", "@openuidev/react-ui": "workspace:*", "@openuidev/react-headless": "workspace:*", "@openuidev/react-lang": "workspace:*", diff --git a/examples/openui-chat/src/app/api/chat/route.ts b/examples/openui-chat/src/app/api/chat/route.ts index ec81a0adb..e7cf3b06f 100644 --- a/examples/openui-chat/src/app/api/chat/route.ts +++ b/examples/openui-chat/src/app/api/chat/route.ts @@ -1,6 +1,10 @@ +import { readFileSync } from "fs"; import { NextRequest } from "next/server"; import OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; +import { join } from "path"; + +const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8"); // ── Tool implementations ── @@ -193,7 +197,7 @@ function sseToolCallArgs( // ── Route handler ── export async function POST(req: NextRequest) { - const { messages, systemPrompt } = await req.json(); + const { messages } = await req.json(); const client = new OpenAI({ apiKey: process.env.OPENROUTER_API_KEY, @@ -215,7 +219,7 @@ export async function POST(req: NextRequest) { }); const chatMessages: ChatCompletionMessageParam[] = [ - ...(systemPrompt ? [{ role: "system" as const, content: systemPrompt }] : []), + { role: "system", content: systemPrompt }, ...cleanMessages, ]; diff --git a/examples/openui-chat/src/app/page.tsx b/examples/openui-chat/src/app/page.tsx index 1814f45d6..b45c90326 100644 --- a/examples/openui-chat/src/app/page.tsx +++ b/examples/openui-chat/src/app/page.tsx @@ -3,11 +3,9 @@ import "@openuidev/react-ui/components.css"; import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; import { FullScreen } from "@openuidev/react-ui"; -import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui/genui-lib"; +import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib"; import { useState } from "react"; -const systemPrompt = openuiChatLibrary.prompt(openuiChatPromptOptions); - export default function Page() { const [mode, setMode] = useState<"light" | "dark">("light"); return ( @@ -46,7 +44,6 @@ export default function Page() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages), - systemPrompt, }), signal: abortController.signal, }); diff --git a/examples/openui-chat/src/generated/system-prompt.txt b/examples/openui-chat/src/generated/system-prompt.txt new file mode 100644 index 000000000..b655c131e --- /dev/null +++ b/examples/openui-chat/src/generated/system-prompt.txt @@ -0,0 +1,202 @@ +You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang. + +## Syntax Rules + +1. Each statement is on its own line: `identifier = Expression` +2. `root` is the entry point — every program must define `root = Card(...)` +3. Expressions are: strings ("..."), numbers, booleans (true/false), arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...) +4. Use references for readability: define `name = ...` on one line, then use `name` later +5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array. +6. Arguments are POSITIONAL (order matters, not names) +7. Optional arguments can be omitted from the end +8. No operators, no logic, no variables — only declarations +9. Strings use double quotes with backslash escaping + +## Component Signatures + +Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming. +The `action` prop type accepts: ContinueConversation (sends message to LLM), OpenUrl (navigates to URL), or Custom (app-defined). + +### Content +CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle +TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy". +MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant +Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string) — Callout banner with variant, title, and description +TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description +Image(alt: string, src?: string) — Image with alt text and optional URL +ImageBlock(src: string, alt?: string) — Image block with loading state +ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview +CodeBlock(language: string, codeString: string) — Syntax-highlighted code block +Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections + +### Tables +Table(columns: Col[], rows: (string | number | boolean)[][]) — Data table +Col(label: string, type?: "string" | "number" | "action") — Column definition + +### Charts (2D) +BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series +LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time +AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time +RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities +HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists +Series(category: string, values: number[]) — One data series + +### Charts (1D) +PieChart(slices: Slice[], variant?: "pie" | "donut") — Circular slices showing part-to-whole proportions; supports pie and donut variants +RadialChart(slices: Slice[]) — Radial bars showing proportional distribution across named segments +SingleStackedBarChart(slices: Slice[]) — Single horizontal stacked bar; use for showing part-to-whole proportions in one row +Slice(category: string, value: number) — One slice with label and numeric value + +### Charts (Scatter) +ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering +ScatterSeries(name: string, points: Point[]) — Named dataset +Point(x: number, y: number, z?: number) — Data point with numeric coordinates + +### Forms +Form(name: string, buttons: Buttons, fields) — Form container with fields and explicit action buttons +FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text +Label(text: string) — Text label +Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +SelectItem(value: string, label: string) — Option for Select +DatePicker(name: string, mode: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) — Numeric slider input; supports continuous and discrete (stepped) variants +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) +RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}) +RadioItem(label: string, description: string, value: string) +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk") — Group of switch toggles +SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle +- Define EACH FormControl as its own reference — do NOT inline all controls in one array. +- NEVER nest Form inside Form. +- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument. +- rules is an optional object: { required: true, email: true, min: 8, maxLength: 100 } +- The renderer shows error messages automatically — do NOT generate error text in the UI + +### Buttons +Button(label: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button +Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column". + +### Lists & Follow-ups +ListBlock(items: ListItem[], variant?: "number" | "image") — A list of items with number or image indicators. Each item can optionally have an action. +ListItem(title: string, subtitle?: string, image?: {src: string, alt: string}, actionLabel?: string, action?: {type: "open_url", url: string} | {type: "continue_conversation", context?: string} | {type: string, params?}) — Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable. +FollowUpBlock(items: FollowUpItem[]) — List of clickable follow-up suggestions placed at the end of a response +FollowUpItem(text: string) — Clickable follow-up suggestion — when clicked, sends text as user message +- Use ListBlock with ListItem references for numbered, clickable lists. +- Use FollowUpBlock with FollowUpItem references at the end of a response to suggest next actions. +- Clicking a ListItem or FollowUpItem sends its text to the LLM as a user message. +- Example: list = ListBlock([item1, item2]) item1 = ListItem("Option A", "Details about A") + +### Sections +SectionBlock(sections: SectionItem[], isFoldable?: boolean) — Collapsible accordion sections. Auto-opens sections as they stream in. Use SectionItem for each section. +SectionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock)[]) — Section with a label and collapsible content — used inside SectionBlock +- SectionBlock renders collapsible accordion sections that auto-open as they stream. +- Each section needs a unique `value` id, a `trigger` label, and a `content` array. +- Example: sections = SectionBlock([s1, s2]) s1 = SectionItem("intro", "Introduction", [content1]) +- Set isFoldable=false to render sections as flat headers instead of accordion. + +### Layout +Tabs(items: TabItem[]) — Tabbed container +TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components +Accordion(items: AccordionItem[]) — Collapsible sections +AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title +Steps(items: StepsItem[]) — Step-by-step guide +StepsItem(title: string, details: string) — title and details text for one step +Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel +- Use Tabs to present alternative views — each TabItem has a value id, trigger label, and content array. +- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]]) +- IMPORTANT: Every slide in a Carousel must have the same structure — same component types in the same order. +- For image carousels use: [[title, image, description, tags], ...] — every slide must follow this exact pattern. +- Use real, publicly accessible image URLs (e.g. https://picsum.photos/seed/KEYWORD/800/500). Never hallucinate image URLs. + +### Data Display +TagBlock(tags: string[]) — tags is an array of strings +Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant + +### Ungrouped +Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock | SectionBlock | Tabs | Carousel)[]) — Vertical container for all content in a chat response. Children stack top to bottom automatically. + +## Hoisting & Streaming (CRITICAL) + +openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. + +During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in. + +**Recommended statement order for optimal streaming:** +1. `root = Card(...)` — UI shell appears immediately +2. Component definitions — fill in as they stream +3. Data values — leaf content last + +Always write the root = Card(...) statement first so the UI shell appears immediately, even before child data has streamed in. + +## Examples + +Example 1 — Table with follow-ups: +root = Card([title, tbl, followUps]) +title = TextContent("Top Languages", "large-heavy") +tbl = Table(cols, rows) +cols = [Col("Language", "string"), Col("Users (M)", "number"), Col("Year", "number")] +rows = [["Python", 15.7, 1991], ["JavaScript", 14.2, 1995], ["Java", 12.1, 1995]] +followUps = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Tell me more about Python") +fu2 = FollowUpItem("Show me a JavaScript comparison") + +Example 2 — Clickable list: +root = Card([title, list]) +title = TextContent("Choose a topic", "large-heavy") +list = ListBlock([item1, item2, item3]) +item1 = ListItem("Getting started", "New to the platform? Start here.") +item2 = ListItem("Advanced features", "Deep dives into powerful capabilities.") +item3 = ListItem("Troubleshooting", "Common issues and how to fix them.") + +Example 3 — Image carousel with consistent slides + follow-ups: +root = Card([header, carousel, followups]) +header = CardHeader("Featured Destinations", "Discover highlights and best time to visit") +carousel = Carousel([[t1, img1, d1, tags1], [t2, img2, d2, tags2], [t3, img3, d3, tags3]], "card") +t1 = TextContent("Paris, France", "large-heavy") +img1 = ImageBlock("https://picsum.photos/seed/paris/800/500", "Eiffel Tower at night") +d1 = TextContent("City of light — best Apr–Jun and Sep–Oct.", "default") +tags1 = TagBlock(["Landmark", "City Break", "Culture"]) +t2 = TextContent("Kyoto, Japan", "large-heavy") +img2 = ImageBlock("https://picsum.photos/seed/kyoto/800/500", "Bamboo grove in Arashiyama") +d2 = TextContent("Temples and bamboo groves — best Mar–Apr and Nov.", "default") +tags2 = TagBlock(["Temples", "Autumn", "Culture"]) +t3 = TextContent("Machu Picchu, Peru", "large-heavy") +img3 = ImageBlock("https://picsum.photos/seed/machupicchu/800/500", "Inca citadel in the clouds") +d3 = TextContent("High-altitude Inca citadel — best May–Sep.", "default") +tags3 = TagBlock(["Andes", "Hike", "UNESCO"]) +followups = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Show me only beach destinations") +fu2 = FollowUpItem("Turn this into a comparison table") + +Example 4 — Form with validation: +root = Card([title, form]) +title = TextContent("Contact Us", "large-heavy") +form = Form("contact", btns, [nameField, emailField, msgField]) +nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 })) +emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true })) +msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) +btns = Buttons([Button("Submit", { type: "continue_conversation" }, "primary")]) + +## Important Rules +- ALWAYS start with root = Card(...) +- Write statements in TOP-DOWN order: root → components → data (leverages hoisting for progressive streaming) +- Each statement on its own line +- No trailing text or explanations — output ONLY openui-lang code +- When asked about data, generate realistic/plausible data +- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.) +- NEVER define a variable without referencing it from the tree. Every variable must be reachable from root, otherwise it will not render. + +- Every response is a single Card(children) — children stack vertically automatically. No layout params are needed on Card. +- Card is the only layout container. Do NOT use Stack. Use Tabs to switch between sections, Carousel for horizontal scroll. +- Use FollowUpBlock at the END of a Card to suggest what the user can do or ask next. +- Use ListBlock when presenting a set of options or steps the user can click to select. +- Use SectionBlock to group long responses into collapsible sections — good for reports, FAQs, and structured content. +- Use SectionItem inside SectionBlock: each item needs a unique value id, a trigger (header label), and a content array. +- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]]) +- IMPORTANT: Every slide in a Carousel must use the same component structure in the same order — e.g. all slides: [title, image, description, tags]. +- For image carousels, always use real accessible URLs like https://picsum.photos/seed/KEYWORD/800/500. Never hallucinate or invent image URLs. +- For forms, define one FormControl reference per field so controls can stream progressively. +- For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields). +- Never nest Form inside Form. diff --git a/examples/openui-chat/src/library.ts b/examples/openui-chat/src/library.ts new file mode 100644 index 000000000..c7ceecfc1 --- /dev/null +++ b/examples/openui-chat/src/library.ts @@ -0,0 +1 @@ +export { openuiChatLibrary as library, openuiChatPromptOptions as promptOptions } from "@openuidev/react-ui/genui-lib"; diff --git a/packages/openui-cli/README.md b/packages/openui-cli/README.md index d2da4e5fd..88a5271fb 100644 --- a/packages/openui-cli/README.md +++ b/packages/openui-cli/README.md @@ -84,6 +84,7 @@ Options: - `-o, --out `: Write output to a file instead of stdout - `--json-schema`: Output JSON Schema instead of the system prompt - `--export `: Use a specific export name instead of auto-detecting the library export +- `--prompt-options `: Use a specific `PromptOptions` export name (auto-detected by default) - `--no-interactive`: Fail instead of prompting for a missing `entry` What it does: @@ -93,6 +94,7 @@ What it does: - supports both TypeScript and JavaScript entry files - stubs common asset imports such as CSS, SVG, images, and fonts during bundling - auto-detects the exported library by checking `library`, `default`, and then all exports +- auto-detects a `PromptOptions` export (with `examples`, `additionalRules`, or `preamble`) and passes it to `library.prompt()` Examples: @@ -101,6 +103,7 @@ openui generate ./src/library.ts openui generate ./src/library.ts --json-schema openui generate ./src/library.ts --export library openui generate ./src/library.ts --out ./artifacts/system-prompt.txt +openui generate ./src/library.ts --prompt-options myPromptOptions openui generate --no-interactive ./src/library.ts ``` @@ -114,6 +117,16 @@ If `--export` is not provided, it looks for exports in this order: 2. `default` 3. any other export that matches the expected library shape +### PromptOptions auto-detection + +If `--prompt-options` is not provided, the CLI looks for a `PromptOptions` export in this order: + +1. `promptOptions` +2. `options` +3. any export whose name ends with `PromptOptions` (case-insensitive) + +A valid `PromptOptions` object has at least one of: `examples` (string array), `additionalRules` (string array), or `preamble` (string). + ## Local Development Build the CLI locally: diff --git a/packages/openui-cli/package.json b/packages/openui-cli/package.json index be0b5cc19..5f5f84ebe 100644 --- a/packages/openui-cli/package.json +++ b/packages/openui-cli/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/cli", - "version": "0.0.2", + "version": "0.0.3", "description": "CLI for OpenUI", "bin": { "openui": "dist/index.js" diff --git a/packages/openui-cli/src/commands/generate-worker.ts b/packages/openui-cli/src/commands/generate-worker.ts index 6cee6eaf2..1bf20fefd 100644 --- a/packages/openui-cli/src/commands/generate-worker.ts +++ b/packages/openui-cli/src/commands/generate-worker.ts @@ -3,7 +3,7 @@ * prompt or JSON schema. Asset imports are stubbed during bundling so React * component modules can be evaluated without CSS/image/font loaders. * - * argv: [entryPath, exportName?, "--json-schema"?] + * argv: [entryPath, exportName?, "--json-schema"?, "--prompt-options", name?] * stdout: the prompt string or JSON schema */ @@ -70,16 +70,61 @@ function findLibrary(mod: Record, exportName?: string): Library return undefined; } +interface PromptOptions { + preamble?: string; + additionalRules?: string[]; + examples?: string[]; +} + +function isPromptOptions(value: unknown): value is PromptOptions { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + const hasExamples = Array.isArray(obj["examples"]); + const hasRules = Array.isArray(obj["additionalRules"]); + const hasPreamble = typeof obj["preamble"] === "string"; + return hasExamples || hasRules || hasPreamble; +} + +function findPromptOptions( + mod: Record, + exportName?: string, +): PromptOptions | undefined { + if (exportName) { + const val = mod[exportName]; + return isPromptOptions(val) ? val : undefined; + } + + // Check well-known names first + for (const name of ["promptOptions", "options"]) { + if (isPromptOptions(mod[name])) return mod[name] as PromptOptions; + } + + // Check any export ending with "PromptOptions" or "promptOptions" + for (const [key, val] of Object.entries(mod)) { + if (/[Pp]rompt[Oo]ptions$/.test(key) && isPromptOptions(val)) return val; + } + + return undefined; +} + async function main(): Promise { const args = process.argv.slice(2); const entryPath = args[0]; if (!entryPath) { - console.error("Usage: generate-worker [exportName] [--json-schema]"); + console.error( + "Usage: generate-worker [exportName] [--json-schema] [--prompt-options ]", + ); process.exit(1); } const jsonSchema = args.includes("--json-schema"); - const exportName = args.find((a) => a !== entryPath && a !== "--json-schema"); + const promptOptionsIdx = args.indexOf("--prompt-options"); + const promptOptionsName = promptOptionsIdx !== -1 ? args[promptOptionsIdx + 1] : undefined; + const reserved = new Set(["--json-schema", "--prompt-options"]); + if (promptOptionsName) reserved.add(promptOptionsName); + const exportName = args.find( + (a, i) => a !== entryPath && !reserved.has(a) && !(i > 0 && args[i - 1] === "--prompt-options"), + ); const bundleDir = fs.mkdtempSync(path.join(os.tmpdir(), "openui-generate-")); const bundlePath = path.join(bundleDir, "entry.cjs"); @@ -125,7 +170,13 @@ async function main(): Promise { process.exit(1); } - const output = jsonSchema ? JSON.stringify(library.toJSONSchema(), null, 2) : library.prompt(); + let output: string; + if (jsonSchema) { + output = JSON.stringify(library.toJSONSchema(), null, 2); + } else { + const promptOptions = findPromptOptions(mod, promptOptionsName); + output = library.prompt(promptOptions); + } process.stdout.write(output); } diff --git a/packages/openui-cli/src/commands/generate.ts b/packages/openui-cli/src/commands/generate.ts index e89260441..f6dab897b 100644 --- a/packages/openui-cli/src/commands/generate.ts +++ b/packages/openui-cli/src/commands/generate.ts @@ -6,6 +6,7 @@ export interface GenerateOptions { out?: string; jsonSchema?: boolean; export?: string; + promptOptions?: string; } export async function runGenerate(entry: string, options: GenerateOptions): Promise { @@ -21,6 +22,7 @@ export async function runGenerate(entry: string, options: GenerateOptions): Prom const workerArgs = [workerPath, entryPath]; if (options.export) workerArgs.push(options.export); if (options.jsonSchema) workerArgs.push("--json-schema"); + if (options.promptOptions) workerArgs.push("--prompt-options", options.promptOptions); let output: string; try { diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts index 36129cb1d..d60f26a53 100644 --- a/packages/openui-cli/src/index.ts +++ b/packages/openui-cli/src/index.ts @@ -26,11 +26,21 @@ program .option("-o, --out ", "Write output to a file instead of stdout") .option("--json-schema", "Output JSON schema instead of the system prompt") .option("--export ", "Name of the export to use (auto-detected by default)") + .option( + "--prompt-options ", + "Name of the PromptOptions export to use (auto-detected by default)", + ) .option("--no-interactive", "Fail with error if required args are missing") .action( async ( entry: string | undefined, - options: { out?: string; jsonSchema?: boolean; export?: string; interactive: boolean }, + options: { + out?: string; + jsonSchema?: boolean; + export?: string; + promptOptions?: string; + interactive: boolean; + }, ) => { const args = await resolveArgs( { diff --git a/packages/openui-cli/src/templates/openui-chat/.gitignore b/packages/openui-cli/src/templates/openui-chat/.gitignore index 5ef6a5207..aa8b41dec 100644 --- a/packages/openui-cli/src/templates/openui-chat/.gitignore +++ b/packages/openui-cli/src/templates/openui-chat/.gitignore @@ -36,6 +36,9 @@ yarn-error.log* # vercel .vercel +# generated +/src/generated/ + # typescript *.tsbuildinfo next-env.d.ts diff --git a/packages/openui-cli/src/templates/openui-chat/package.json b/packages/openui-cli/src/templates/openui-chat/package.json index d043fd702..87f7d36a9 100644 --- a/packages/openui-cli/src/templates/openui-chat/package.json +++ b/packages/openui-cli/src/templates/openui-chat/package.json @@ -3,8 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "generate:prompt": "openui generate src/library.ts --out src/generated/system-prompt.txt", + "dev": "npm run generate:prompt && next dev", + "build": "npm run generate:prompt && next build", "start": "next start", "lint": "eslint" }, @@ -18,6 +19,7 @@ "react-dom": "19.2.3" }, "devDependencies": { + "@openuidev/cli": "latest", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/packages/openui-cli/src/templates/openui-chat/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-chat/src/app/api/chat/route.ts index 404fd0276..3e6c0c9a3 100644 --- a/packages/openui-cli/src/templates/openui-chat/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-chat/src/app/api/chat/route.ts @@ -1,19 +1,21 @@ +import { readFileSync } from "fs"; +import { join } from "path"; import { NextRequest } from "next/server"; import OpenAI from "openai"; +const client = new OpenAI(); +const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8"); + export async function POST(req: NextRequest) { - const client = new OpenAI(); try { - const { messages, systemPrompt } = await req.json(); - - const chatMessages: OpenAI.ChatCompletionMessageParam[] = [ - ...(systemPrompt ? [{ role: "system" as const, content: systemPrompt }] : []), - ...messages, - ]; + const { messages } = await req.json(); const response = await client.chat.completions.create({ - model: "gpt-4o", - messages: chatMessages, + model: "gpt-5.2", + messages: [ + { role: "system", content: systemPrompt }, + ...messages, + ], stream: true, }); diff --git a/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx b/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx index b828b1d20..ddfd81526 100644 --- a/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx +++ b/packages/openui-cli/src/templates/openui-chat/src/app/page.tsx @@ -4,9 +4,7 @@ import "@openuidev/react-ui/styles/index.css"; import { openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless"; import { FullScreen } from "@openuidev/react-ui"; -import { openuiLibrary, openuiPromptOptions } from "@openuidev/react-ui/genui-lib"; - -const systemPrompt = openuiLibrary.prompt(openuiPromptOptions); +import { openuiLibrary } from "@openuidev/react-ui/genui-lib"; export default function Home() { return ( @@ -18,7 +16,6 @@ export default function Home() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: openAIMessageFormat.toApi(messages), - systemPrompt, }), signal: abortController.signal, }); diff --git a/packages/openui-cli/src/templates/openui-chat/src/library.ts b/packages/openui-cli/src/templates/openui-chat/src/library.ts new file mode 100644 index 000000000..8a2edfa62 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-chat/src/library.ts @@ -0,0 +1 @@ +export { openuiLibrary as library, openuiPromptOptions as promptOptions } from "@openuidev/react-ui/genui-lib"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d9ad148e..19085f6f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: docs: dependencies: + '@openuidev/cli': + specifier: workspace:* + version: link:../packages/openui-cli '@openuidev/react-headless': specifier: workspace:^ version: link:../packages/react-headless @@ -129,6 +132,9 @@ importers: examples/openui-chat: dependencies: + '@openuidev/cli': + specifier: workspace:* + version: link:../../packages/openui-cli '@openuidev/react-headless': specifier: workspace:* version: link:../../packages/react-headless