Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions examples/form-generator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
210 changes: 210 additions & 0 deletions examples/form-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# HeroUI Form Generator Example

A full-stack **generative form builder** that demonstrates wiring [OpenUI Lang](https://www.openui.com/docs/openui-lang/overview) to a custom component library built on [HeroUI](https://www.heroui.com/) ([`@heroui/react`](https://www.npmjs.com/package/@heroui/react)).

Describe a form in plain language, get a live preview built from HeroUI components. Refine it with follow-up instructions — each iteration streams a fresh updated form.

[View source on GitHub →](https://github.com/thesysdev/openui/tree/main/examples/form-generator)

---

## How It Works

The LLM is prompted with a form-focused system prompt that describes every available form component — name, props, and when to use it. Instead of returning prose, the model responds in **OpenUI Lang**: a declarative markup syntax that maps directly to React components. For example:

```
root = Form("contact", "Contact Us", btns, [fc1, fc2, fc3])
fc1 = FormControl("Name", input1)
fc2 = FormControl("Email", input2, "We'll never share your email.")
fc3 = FormControl("Message", ta1)
input1 = Input("name", "Your name", "text", { required: true })
input2 = Input("email", "you@example.com", "email", { required: true, email: true })
ta1 = TextArea("message", "How can we help?", 4)
btns = Buttons([b1])
b1 = Button("Submit", { type: "continue_conversation" }, "primary")
```

On the client, `<Renderer>` from `@openuidev/react-lang` parses the incoming SSE stream and renders each OpenUI Lang node using `herouiChatLibrary` — the custom library defined in `src/lib/heroui-genui/`. The app shell is built entirely with `@heroui/react` (no `@openuidev/react-ui` dependency).

---

## Architecture

```
┌──────────────────────────────────────────┐ ┌────────────────────────────────────┐
│ Browser │ HTTP │ Next.js API Route │
│ │ ──────►│ │
│ • Left panel: HeroUI TextArea + Button │ │ • Loads system-prompt.txt │
│ • Iteration history (in-memory messages)│ │ • Calls LLM via OpenAI SDK │
│ • processStreamedMessage + openAIAdapter│◄────── │ • Streams response as SSE events │
│ • Right panel: <Renderer> preview │ SSE │ │
│ • formFieldSnapshot via onStateUpdate │ │ │
└──────────────────────────────────────────┘ └────────────────────────────────────┘
```

### Request / Response Flow

1. User types a form description and clicks **Generate Form** (or ⌘ Enter).
2. The page builds an `openAIMessageFormat.toApi(messages)` payload and `POST`s to `/api/chat`.
3. The API route reads `system-prompt.txt` and streams the LLM response as SSE events.
4. On the client, `processStreamedMessage` + `openAIAdapter()` accumulate the streamed text into an `AssistantMessage`.
5. `<Renderer response={code} library={herouiChatLibrary} isStreaming={...} />` parses the OpenUI Lang markup and progressively renders each node as a HeroUI-backed component.
6. The streamed assistant `content` (OpenUI Lang text only) is appended to the in-memory message list for the next iteration.

### Iterative Refinement

Submit a follow-up instruction (e.g. "add a phone number field" or "rename the submit button to 'Send'") and the page:

- Optionally prefixes the new user message with a JSON snapshot of current form values from `onStateUpdate`.
- POSTs the **full** message list so the model can see the previous form spec and return one complete updated `Form`.
- `<Renderer initialState={formFieldSnapshot}>` hydrates the preview with the latest field values, so filled data is not lost on re-render.

Click **Reset** to clear history and start a fresh form.

---

## Project Structure

```
form-generator/
├── src/
│ ├── app/
│ │ ├── api/chat/route.ts # Streaming endpoint (OpenAI SDK + SSE, no tools)
│ │ ├── page.tsx # Two-column form generator UI
│ │ ├── layout.tsx # Root layout (no external provider needed)
│ │ └── globals.css # Tailwind + HeroUI styles
│ ├── lib/
│ │ └── heroui-genui/ # Custom OpenUI component library
│ │ ├── index.tsx # Library + herouiFormPromptOptions export
│ │ ├── action.ts # Button action Zod schemas
│ │ ├── rules.ts # Form validation rule schemas
│ │ └── components/ # One file per component
│ ├── generated/
│ │ └── system-prompt.txt # Auto-generated — do not edit manually
│ └── library.ts # CLI entry — re-exports library + promptOptions
└── package.json
```

---

## Getting Started

### Prerequisites

- Node.js 18+
- pnpm
- An OpenAI API key

### 1. Install dependencies

```bash
cd examples/form-generator
pnpm install
```

### 2. Configure environment

Create a `.env.local` file in the `examples/form-generator/` directory:

```
OPENAI_API_KEY=sk-...
```

### 3. Start the dev server

```bash
pnpm dev
```

This runs `generate:prompt` first (builds the OpenUI CLI, compiles `src/library.ts` → `src/generated/system-prompt.txt`) then starts the Next.js dev server at `http://localhost:3000`.

---

## What's in This Example

### System Prompt Generation

Component definitions and form-focused prompt options live in `src/lib/heroui-genui/index.tsx` (`createLibrary()`, `herouiFormPromptOptions`). The file `src/library.ts` re-exports them as `library` and `promptOptions` for the OpenUI CLI.

When you run `pnpm dev` or `pnpm generate:prompt`, the CLI generates `src/generated/system-prompt.txt` — a file listing every form component's name, prop schema, description, and examples. This is the LLM's system prompt.

`herouiFormPromptOptions` includes:
- **No inline examples** — the system prompt is derived purely from component signatures and the additional rules below
- **Rules** that instruct the model to: always use `Form` as the root element, always include `Buttons` with a primary submit, define each `FormControl` as its own reference for streaming, treat follow-up instructions as refinements of the current form

Re-run generation any time you change component definitions:

```bash
pnpm generate:prompt
```

### `src/app/api/chat/route.ts` — Backend

Accepts `POST /api/chat` with a `messages` array, prepends the system prompt, and streams the LLM response as **Server-Sent Events (SSE)** compatible with `openAIAdapter()`.

Tools are intentionally omitted — the model focuses purely on generating OpenUI Lang form definitions.

### `src/app/page.tsx` — Frontend

Two-column layout built entirely with `@heroui/react`:

| Panel | Contents |
| ----- | -------- |
| **Left** | `TextArea` for instructions, `Button` to generate/refine, example `Chip`s, instruction history |
| **Right** | `<Renderer>` inside a `Card` — live OpenUI Lang preview, scrollable, updates on each iteration |

Key client-side state:

| State | Purpose |
| ----- | ------- |
| `messages` | In-memory OpenAI-shaped message list sent on every request |
| `latestCode` | Last assistant's OpenUI Lang text; passed as `Renderer` `response` |
| `streamingCode` | Accumulates tokens live; passed during streaming |
| `formFieldSnapshot` | Current form field values from `Renderer` `onStateUpdate`; used for `initialState` and optionally prepended to next user message |

### `src/lib/heroui-genui/` — Custom Component Library

Each component is defined with `defineComponent()` from `@openuidev/react-lang`:

- `name` — the OpenUI Lang node name the LLM will emit
- `props` — a Zod schema that validates and types the node's props as they stream in
- `description` — included in the system prompt
- `component` — the React render function; `renderNode()` recursively renders child nodes

#### Form Components

| Component | HeroUI Backing | Purpose |
| --------- | -------------- | ------- |
| `Form` | `Form`, `Surface` | Root container with title, validation context, and surface background |
| `FormRow` | `div` | Horizontal row for side-by-side fields |
| `FormControl` | `Label`, `Description` | Labeled field wrapper |
| `Input` | `Input` | Text, email, password, etc. |
| `TextArea` | `TextArea` | Multi-line text |
| `Select` / `SelectItem` | `Select`, `ListBox` | Dropdown |
| `NumberField` | `NumberField` | Numeric input with stepper |
| `Slider` | `Slider` | Range input |
| `CheckBoxGroup` / `CheckBoxItem` | `CheckboxGroup`, `Checkbox` | Multi-select |
| `RadioGroup` / `RadioItem` | `RadioGroup`, `Radio` | Single-select |
| `SwitchGroup` / `SwitchItem` | `Switch` | Toggle switches |
| `Button` / `Buttons` | `Button`, `ButtonGroup` | Submit and action buttons |

---

## Scripts

| Script | Description |
| ------ | ----------- |
| `pnpm dev` | Build OpenUI CLI, generate system prompt, start Next.js dev server |
| `pnpm generate:prompt` | Build `@openuidev/cli` and regenerate `system-prompt.txt` |
| `pnpm build` | Build for production |
| `pnpm start` | Start the production server |

---

## Learn More

- [OpenUI Lang overview](https://www.openui.com/docs/openui-lang/overview) — Library, Prompt Generator, Parser, Renderer
- [Defining Components](https://www.openui.com/docs/openui-lang/defining-components) — `defineComponent` and `createLibrary` API
- [HeroUI](https://www.heroui.com/) — the underlying React component library
- [`@openuidev/react-lang` package](../../packages/react-lang)
- [`@openuidev/react-headless` package](../../packages/react-headless)
18 changes: 18 additions & 0 deletions examples/form-generator/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);

export default eslintConfig;
8 changes: 8 additions & 0 deletions examples/form-generator/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
turbopack: {},
transpilePackages: ["@openuidev/react-headless", "@openuidev/react-lang"],
};

export default nextConfig;
35 changes: 35 additions & 0 deletions examples/form-generator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "form-generator",
"version": "0.1.0",
"private": true,
"scripts": {
"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": {
"@heroui/react": "^3.0.1",
"@heroui/styles": "^3.0.1",
"@openuidev/react-headless": "workspace:*",
"@openuidev/react-lang": "workspace:*",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"openai": "^6.22.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"zod": "^4.0.0"
},
"devDependencies": {
"@openuidev/cli": "workspace:*",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
7 changes: 7 additions & 0 deletions examples/form-generator/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};

export default config;
64 changes: 64 additions & 0 deletions examples/form-generator/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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");

export async function POST(req: NextRequest) {
const { messages } = await req.json();

const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});

const chatMessages: ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...messages,
];

const stream = await client.chat.completions.create({
model: "gpt-5.4",
messages: chatMessages,
stream: true,
});

const encoder = new TextEncoder();
let controllerClosed = false;

const readable = new ReadableStream({
async start(controller) {
const enqueue = (data: Uint8Array) => {
if (controllerClosed) return;
try { controller.enqueue(data); } catch { /* already closed */ }
};
const close = () => {
if (controllerClosed) return;
controllerClosed = true;
try { controller.close(); } catch { /* already closed */ }
};

try {
for await (const chunk of stream) {
enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
enqueue(encoder.encode("data: [DONE]\n\n"));
} catch (err) {
const msg = err instanceof Error ? err.message : "Stream error";
console.error("Chat route error:", msg);
enqueue(encoder.encode(`data: ${JSON.stringify({ error: msg })}\n\n`));
} finally {
close();
}
},
});

return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
Loading
Loading