diff --git a/docs/content/docs/openui-lang/examples/shadcn-chat.mdx b/docs/content/docs/openui-lang/examples/shadcn-chat.mdx new file mode 100644 index 000000000..9d54a6705 --- /dev/null +++ b/docs/content/docs/openui-lang/examples/shadcn-chat.mdx @@ -0,0 +1,231 @@ +--- +title: Shadcn Chat +description: A generative UI chat app that streams OpenUI Lang and renders it as shadcn/ui components. +--- + +A full-stack example that streams [OpenUI Lang](/docs/openui-lang/overview) into a Next.js chat interface using `@openuidev/react-ui`'s [FullScreen](/docs/chat/fullscreen) layout, then renders every response as rich [shadcn/ui](https://ui.shadcn.com/) components via a custom `shadcnChatLibrary`. + +[View source on GitHub →](https://github.com/thesysdev/crayon/tree/main/examples/shadcn-chat) + +
+
+ +The example includes: + +- A **Next.js frontend** with a FullScreen chat layout that adapts to the system light/dark theme +- A **Next.js API route** that calls OpenAI with streaming and server-side tool execution +- A **shadcn-genui component library** with 40+ components covering content, charts, forms, tables, buttons, layout, overlays, and more + +## Architecture + +``` +Browser (FullScreen) -- POST /api/chat --> Next.js route --> OpenAI + <-- SSE stream -- (OpenUI Lang + tool calls) +``` + +The client sends a conversation to `/api/chat`. The API route loads a generated `system-prompt.txt`, forwards the messages to the LLM with streaming and tool definitions, and returns SSE events. On the client side, `openAIAdapter()` parses the SSE stream, and `shadcnChatLibrary` maps each OpenUI Lang node to a shadcn/ui component that renders progressively as tokens arrive. + +The API route also supports **server-side tool execution**. When the model invokes a tool (weather, stock price, calculator, or web search), the route runs it and feeds the result back into the completion loop before streaming the final UI response. + +## Project layout + +``` +examples/shadcn-chat/ +|- src/app/ # Next.js app (layout, page, API route) +|- src/hooks/ # Theme detection and context +|- src/components/ui/ # Base shadcn/ui primitives +|- src/lib/shadcn-genui/ # Generative UI component library (40+ components) +|- src/generated/ # Generated system prompt +``` + +## Run the example + +Run these commands from `examples/shadcn-chat`. + +1. Install dependencies: + +```bash +cd examples/shadcn-chat +pnpm install +``` + +2. Create a `.env.local` file with your API key: + +```bash +OPENAI_API_KEY=sk-... +``` + +3. Start the dev server: + +```bash +pnpm dev +``` + +This automatically generates the system prompt from the library definition before starting Next.js. + +## What to expect + +Open the app and try one of the built-in conversation starters: + +- "Startup dashboard" — analytics dashboard with charts, tables, and progress bars +- "Travel planner" — calendar, accordion destinations, and a preferences form +- "Market watch" — fetches live stock prices and renders a comparison table with charts +- "Event RSVP" — a rich form with every input type +- "Chart showcase" — six chart types (Bar, Line, Area, Pie, Radar, Scatter) in tabbed view + +Instead of rendering plain markdown, the assistant response appears progressively as shadcn/ui components — cards, charts, forms, tables, badges, and more — all styled with the shadcn/ui design system and responsive to your system theme. + +## Key files + +### `src/hooks/use-system-theme.tsx` — theme detection + +A `ThemeProvider` wraps the app at the root layout. It detects the system color scheme via `prefers-color-scheme`, sets a `data-theme` attribute on ``, and exposes the resolved `"light"` or `"dark"` mode through a `useTheme()` hook. CSS variables in `globals.css` switch via the `[data-theme="dark"]` selector, and Tailwind's dark variant is configured to match. + +### `src/app/page.tsx` — FullScreen chat setup + +The page calls `useTheme()` to get the current mode and passes it to the `FullScreen` layout: + +```tsx + { + return fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: openAIMessageFormat.toApi(messages), + }), + signal: abortController.signal, + }); + }} + streamProtocol={openAIAdapter()} + componentLibrary={shadcnChatLibrary} + agentName="shadcn/ui Chat" + theme={{ mode }} + conversationStarters={{ + variant: "short", + options: [ + { displayText: "Startup dashboard", prompt: "Build a startup analytics dashboard ..." }, + { displayText: "Market watch", prompt: "Fetch stock prices for AAPL, NVDA ..." }, + // ... + ], + }} +/> +``` + +See [Chat FullScreen Layout](/docs/chat/fullscreen) and [Connecting to a Backend](/docs/chat/connecting) for the full API. + +### `src/lib/shadcn-genui/index.tsx` — component library + +Defines the complete generative UI library using `createLibrary()` from `@openuidev/react-lang`. The library declares a `Card` root component, 13 component groups, 12 prompt examples, and additional rules that guide the LLM: + +```tsx +export const shadcnChatLibrary = createLibrary({ + root: "Card", + componentGroups: shadcnComponentGroups, + components: [ + ChatCard, + CardHeader, + TextContent, + MarkDownRenderer, + Alert /* ... */, + Table, + Col, + BarChartCondensed, + LineChartCondensed /* ... */, + Form, + FormControl, + Input, + Select /* ... */, + Button, + Buttons, + Tabs, + Accordion, + Carousel, + // ... 40+ components total + ], +}); +``` + +The component groups organize components by category and include notes that become part of the generated system prompt: + +| Group | Components | +| ---------------- | ------------------------------------------------------------------------------------------------------------- | +| Content | CardHeader, TextContent, MarkDownRenderer, Alert, Badge, Avatar, CodeBlock, Image, Progress, Separator | +| Tables | Table, Col | +| Charts (2D) | BarChart, LineChart, AreaChart, RadarChart, Series | +| Charts (1D) | PieChart, RadialChart, Slice | +| Charts (Scatter) | ScatterChart, ScatterSeries, Point | +| Forms | Form, FormControl, Label, Input, TextArea, Select, DatePicker, Slider, CheckBoxGroup, RadioGroup, SwitchGroup | +| Buttons | Button, Buttons | +| Follow-ups | FollowUpBlock, FollowUpItem | +| Layout | Tabs, Accordion, Carousel | +| Data Display | TagBlock, Tag | +| Typography | Heading, Blockquote, InlineCode | +| Calendar | CalendarBlock | +| Navigation | PaginationBlock | +| Overlays | DialogBlock, AlertDialogBlock, DrawerBlock | + +See [Defining Components](/docs/openui-lang/defining-components) for the full `defineComponent` API. + +### `src/app/api/chat/route.ts` — SSE streaming with tool execution + +The API route reads the generated system prompt, calls the LLM with `runTools()` for server-side tool execution, and streams the response as SSE: + +```ts +const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); +const MODEL = "gpt-5.4"; + +const runner = client.chat.completions.runTools({ + model: MODEL, + messages: chatMessages, + tools, + stream: true, +}); + +runner.on("chunk", (chunk) => { + const delta = chunk.choices?.[0]?.delta; + if (delta?.content) { + enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } +}); +``` + +When the model calls a tool, the route executes it and enriches the response with both the request and result before continuing. This keeps the tool loop entirely server-side — the client only sees the final streamed UI. + +## Tools + +The API route defines four server-side tools that the LLM can invoke during a conversation: + +| Tool | Description | +| ----------------- | -------------------------------------------------------------------------------------- | +| `get_weather` | Returns temperature, conditions, humidity, wind, and a 2-day forecast for a given city | +| `get_stock_price` | Returns price, change, volume, and day range for a ticker symbol | +| `calculate` | Evaluates a math expression and returns the result | +| `search_web` | Returns mock search results for a query | + +These are demo implementations with simulated data. In a production app, you would replace them with real API calls. + +### `src/lib/shadcn-genui/helpers.ts` — chart data builders + +Utility functions that normalize chart data from OpenUI Lang element nodes into the format expected by Recharts: + +- `buildChartData(labels, series)` — transforms labels and series nodes into row-based chart data for 2D charts (bar, line, area, radar) +- `buildSliceData(slices)` — transforms slice nodes into data for 1D charts (pie, radial) + +## System prompt generation + +The `pnpm generate:prompt` script inspects the library definition in `src/lib/shadcn-genui/index.tsx` and generates `src/generated/system-prompt.txt`. This prompt tells the LLM about every available component, its props, and the OpenUI Lang syntax. Re-run it whenever you change component definitions, prop schemas, descriptions, or prompt rules. + +See [System Prompts](/docs/openui-lang/system-prompts) for how prompt generation works. diff --git a/docs/content/docs/openui-lang/meta.json b/docs/content/docs/openui-lang/meta.json index bda270263..1371143ea 100644 --- a/docs/content/docs/openui-lang/meta.json +++ b/docs/content/docs/openui-lang/meta.json @@ -14,6 +14,7 @@ "specification", "benchmarks", "---Examples---", - "examples/react-native" + "examples/react-native", + "examples/shadcn-chat" ] } diff --git a/docs/public/videos/shadcn-demo-chat.mp4 b/docs/public/videos/shadcn-demo-chat.mp4 new file mode 100644 index 000000000..789b16ab4 Binary files /dev/null and b/docs/public/videos/shadcn-demo-chat.mp4 differ diff --git a/examples/shadcn-chat/src/app/page.tsx b/examples/shadcn-chat/src/app/page.tsx index f65fec9f9..a59a3d9ee 100644 --- a/examples/shadcn-chat/src/app/page.tsx +++ b/examples/shadcn-chat/src/app/page.tsx @@ -30,18 +30,39 @@ export default function Page() { variant: "short", options: [ { - displayText: "Weather in Tokyo", - prompt: "What's the weather like in Tokyo right now?", + displayText: "Startup dashboard", + prompt: + "Build a startup analytics dashboard with tags, Tabs (Revenue BarChart, Growth LineChart, Breakdown PieChart), a key metrics table, a progress bar toward the annual goal, and follow-ups.", + }, + { + displayText: "Travel planner", + prompt: + "Design a trip planner with a range CalendarBlock (2 months), an Accordion for 3 destinations (Tokyo, Paris, New York) each with description, tags, and a budget progress bar, then a preferences form with select, slider, and checkboxes. Add follow-ups.", + }, + { + displayText: "Market watch", + prompt: + "Fetch stock prices for AAPL, NVDA, GOOGL, and TSLA. Show a market overview with tags, a comparison table, an alert for the biggest mover, and a DrawerBlock with a BarChart comparing all four. Add follow-ups.", }, - { displayText: "AAPL stock price", prompt: "What's the current Apple stock price?" }, { - displayText: "Contact form", - prompt: "Build me a contact form with name, email, topic, and message fields.", + displayText: "Event RSVP", + prompt: + "Create an event RSVP form for a tech summit with an info alert, and a form containing inputs for name and email, a select for ticket tier, radio group for diet, date picker, slider for group size, checkboxes for sessions, and switches for notifications. Add follow-ups.", + }, + { + displayText: "Team standup", + prompt: + "Generate a team standup board with a sprint progress bar, a task table (5 members), a warning alert for blockers, an Accordion (Yesterday, Today, Blockers), and a DialogBlock that opens sprint metrics with a PieChart and summary table. Add follow-ups.", + }, + { + displayText: "Recipe card", + prompt: + "Build a recipe card for Spicy Thai Basil Chicken with tags, Tabs (Ingredients table, Instructions accordion with 5 steps, Nutrition donut PieChart + table), a blockquote chef's tip, and buttons with a DialogBlock for the full recipe. Add follow-ups.", }, { - displayText: "Data table", + displayText: "Chart showcase", prompt: - "Show me a table of the top 5 programming languages by popularity with year created.", + "Build a 'Global Tech Industry Report 2025' with tags and Tabs containing six chart types: a grouped BarChart (quarterly revenue, 3 series), a LineChart (monthly trends, 2 series), an AreaChart (yearly adoption, 2 series), a donut PieChart (market share, 6 slices), a RadarChart (developer skills, 2 series), and a ScatterChart (funding vs revenue, 2 series with labeled points). Below add a summary table, a RadialChart for industry goals, and follow-ups.", }, ], }} diff --git a/examples/shadcn-chat/src/lib/shadcn-genui/components/tabs.tsx b/examples/shadcn-chat/src/lib/shadcn-genui/components/tabs.tsx index b507374f6..a12216245 100644 --- a/examples/shadcn-chat/src/lib/shadcn-genui/components/tabs.tsx +++ b/examples/shadcn-chat/src/lib/shadcn-genui/components/tabs.tsx @@ -30,33 +30,43 @@ export const Tabs = defineComponent({ description: "Tabbed content. items: TabItem[]. defaultValue: initially active tab.", component: ({ props, renderNode }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items = (props.items ?? []) as any[]; + const rawItems = (props.items ?? []) as any[]; + + const items = rawItems.filter( + (item) => item?.props?.value != null && item?.props?.trigger != null, + ); + + const [userSelected, setUserSelected] = React.useState(null); const firstValue = items[0]?.props?.value as string | undefined; - const resolvedDefault = props.defaultValue ?? firstValue; + const preferredDefault = props.defaultValue ?? firstValue; - const [activeTab, setActiveTab] = React.useState(resolvedDefault); + const userSelectionValid = + userSelected != null && items.some((item) => String(item?.props?.value) === userSelected); + const activeTab = userSelectionValid ? userSelected : (preferredDefault ?? ""); - React.useEffect(() => { - if (!activeTab && resolvedDefault) { - setActiveTab(resolvedDefault); - } - }, [activeTab, resolvedDefault]); + if (items.length === 0) return null; return ( - + - {items.map((item, i) => ( - - {String(item?.props?.trigger ?? "")} - - ))} + {items.map((item) => { + const val = String(item.props.value); + return ( + + {String(item.props.trigger)} + + ); + })} - {items.map((item, i) => ( - - {renderNode(item?.props?.content)} - - ))} + {items.map((item) => { + const val = String(item.props.value); + return ( + + {renderNode(item.props.content)} + + ); + })} ); },