diff --git a/examples/mastra-chat/.gitignore b/examples/mastra-chat/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/examples/mastra-chat/.gitignore @@ -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 diff --git a/examples/mastra-chat/README.md b/examples/mastra-chat/README.md new file mode 100644 index 000000000..442cbab76 --- /dev/null +++ b/examples/mastra-chat/README.md @@ -0,0 +1,43 @@ +# mastra-chat + +An [OpenUI](https://openui.com) example showing how to wire a [Mastra](https://mastra.ai) agent backend to OpenUI's generative UI frontend. + +## What this demonstrates + +- Using `mastraAdapter` as the `streamProtocol` on OpenUI's `ChatProvider` +- Using `mastraMessageFormat` to keep the chat history compatible with Mastra's expected message shape +- A real Mastra `Agent` with `createTool` tools (weather and stock price) running in a Next.js API route + +## Getting started + +1. Copy the example environment file and add your OpenAI key: + +```bash +cp .env.example .env.local +# then edit .env.local and set OPENAI_API_KEY=sk-... +``` + +2. Install dependencies from the monorepo root: + +```bash +pnpm install +``` + +3. Run the dev server from this directory: + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see the chat interface. + +## How it works + +The frontend (`src/app/page.tsx`) passes `streamProtocol={mastraAdapter()}` and `mastraMessageFormat` to ``. Mastra handles the agentic loop (including multi-step tool calls) on the server, and the adapter converts the AI SDK SSE stream into OpenUI's internal event format. + +To add more tools, define them with `createTool` in `src/app/api/chat/route.ts` and pass them to the `Agent`. + +## Learn more + +- [OpenUI documentation](https://openui.com/docs) +- [Mastra documentation](https://mastra.ai/docs) diff --git a/examples/mastra-chat/eslint.config.mjs b/examples/mastra-chat/eslint.config.mjs new file mode 100644 index 000000000..2b4967513 --- /dev/null +++ b/examples/mastra-chat/eslint.config.mjs @@ -0,0 +1,18 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from "eslint/config"; + +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; diff --git a/examples/mastra-chat/next.config.ts b/examples/mastra-chat/next.config.ts new file mode 100644 index 000000000..69512fdcd --- /dev/null +++ b/examples/mastra-chat/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + turbopack: {}, + transpilePackages: ["@openuidev/react-ui", "@openuidev/react-headless", "@openuidev/react-lang"], +}; + +export default nextConfig; diff --git a/examples/mastra-chat/package.json b/examples/mastra-chat/package.json new file mode 100644 index 000000000..f9b6babc9 --- /dev/null +++ b/examples/mastra-chat/package.json @@ -0,0 +1,35 @@ +{ + "name": "mastra-chat", + "version": "0.1.0", + "private": true, + "scripts": { + "generate:prompt": "pnpm --filter @openuidev/cli build && node ../../packages/openui-cli/dist/index.js 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": { + "@ag-ui/mastra": "^1.0.1", + "@mastra/core": "1.15.0", + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "zod": "^3.23.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" + } +} diff --git a/examples/mastra-chat/postcss.config.mjs b/examples/mastra-chat/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/mastra-chat/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/mastra-chat/src/app/api/chat/route.ts b/examples/mastra-chat/src/app/api/chat/route.ts new file mode 100644 index 000000000..58adc4c39 --- /dev/null +++ b/examples/mastra-chat/src/app/api/chat/route.ts @@ -0,0 +1,216 @@ +import { MastraAgent } from "@ag-ui/mastra"; +import { Agent } from "@mastra/core/agent"; +import { createTool } from "@mastra/core/tools"; +import { EventType, type Message, type TextInputContent } from "@openuidev/react-headless"; +import { readFileSync } from "fs"; +import { NextRequest } from "next/server"; +import { join } from "path"; +import { z } from "zod"; + +const systemPromptFile = readFileSync( + join(process.cwd(), "src/generated/system-prompt.txt"), + "utf-8", +); + +const getWeather = createTool({ + id: "get_weather", + description: "Get current weather for a city.", + inputSchema: z.object({ location: z.string().describe("City name") }), + execute: async ({ location }) => { + const knownTemps: Record = { + tokyo: 22, + "san francisco": 18, + london: 14, + "new york": 25, + paris: 19, + sydney: 27, + mumbai: 33, + berlin: 16, + }; + const temp = knownTemps[location.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5); + return { location, temperature_celsius: temp, condition: "Clear" }; + }, +}); + +const getStockPrice = createTool({ + id: "get_stock_price", + description: "Get current stock price for a ticker symbol.", + inputSchema: z.object({ symbol: z.string().describe("Ticker symbol, e.g. AAPL") }), + execute: async ({ symbol }) => { + const prices: Record = { + AAPL: 189.84, + GOOGL: 141.8, + TSLA: 248.42, + MSFT: 378.91, + NVDA: 875.28, + }; + const s = symbol.toUpperCase(); + const price = prices[s] ?? Math.floor(Math.random() * 500 + 50); + return { symbol: s, price }; + }, +}); + +type AgentRunInput = Parameters[0]; +type AgentMessage = AgentRunInput["messages"][number]; + +const agentTools: AgentRunInput["tools"] = [ + { + name: getWeather.id, + description: getWeather.description, + parameters: getWeather.inputSchema, + }, + { + name: getStockPrice.id, + description: getStockPrice.description, + parameters: getStockPrice.inputSchema, + }, +]; + +function getAgent() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error( + "OPENAI_API_KEY is not set. Please provide it in your environment variables to run this example.", + ); + } + + const baseAgent = new Agent({ + id: "openui-agent", + name: "OpenUI Agent", + instructions: `You are a helpful assistant. Use tools when relevant and help the user with their requests. Always format your responses cleanly.\n\n${systemPromptFile}`, + model: { + id: (process.env.OPENAI_MODEL as `${string}/${string}`) || "openai/gpt-4o", + apiKey: apiKey, + url: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1", + }, + tools: { getWeather, getStockPrice }, + }); + + return new MastraAgent({ + agent: baseAgent, + resourceId: "chat-user", + }); +} + +function toAgentMessage(message: Message): AgentMessage | null { + switch (message.role) { + case "developer": + case "system": + return { + id: message.id, + role: message.role, + content: message.content, + }; + case "user": + return { + id: message.id, + role: "user", + content: + typeof message.content === "string" + ? message.content + : message.content.find( + (content): content is TextInputContent => content.type === "text", + )?.text || "", + }; + case "assistant": + return { + id: message.id, + role: "assistant", + content: message.content, + toolCalls: message.toolCalls, + }; + case "tool": + return { + id: message.id, + role: "tool", + content: message.content, + toolCallId: message.toolCallId, + error: message.error, + }; + default: + return null; + } +} + +export async function POST(req: NextRequest) { + try { + const { messages, threadId }: { messages: Message[]; threadId: string } = await req.json(); + + const convertedMessages = messages + .map(toAgentMessage) + .filter((message): message is AgentMessage => message !== null); + + const agent = getAgent(); + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + start(controller) { + const subscription = agent + .run({ + messages: convertedMessages, + threadId, + runId: crypto.randomUUID(), + tools: agentTools, + context: [], + }) + .subscribe({ + next: (event) => { + if ( + (event.type === EventType.TEXT_MESSAGE_CHUNK || + event.type === EventType.TEXT_MESSAGE_CONTENT) && + event.delta + ) { + const translatedEvent = { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: event.messageId || "current-message", + delta: event.delta, + }; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(translatedEvent)}\n\n`)); + } else if (event.type === EventType.RUN_ERROR) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: EventType.RUN_ERROR, + message: event.message || "An error occurred during the agent run", + })}\n\n`, + ), + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + } + }, + complete: () => { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + error: (error: unknown) => { + const message = + error instanceof Error ? error.message : "Unknown Mastra stream error"; + console.error("Mastra stream error:", error); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + + req.signal.addEventListener("abort", () => { + subscription.unsubscribe(); + }); + }, + }); + + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown route error"; + console.error("Route error:", error); + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/examples/mastra-chat/src/app/globals.css b/examples/mastra-chat/src/app/globals.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/examples/mastra-chat/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/examples/mastra-chat/src/app/layout.tsx b/examples/mastra-chat/src/app/layout.tsx new file mode 100644 index 000000000..7a82406ab --- /dev/null +++ b/examples/mastra-chat/src/app/layout.tsx @@ -0,0 +1,22 @@ +import { ThemeProvider } from "@/hooks/use-system-theme"; +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "OpenUI Chat", + description: "Generative UI Chat with OpenAI SDK", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/examples/mastra-chat/src/app/page.tsx b/examples/mastra-chat/src/app/page.tsx new file mode 100644 index 000000000..750010bc7 --- /dev/null +++ b/examples/mastra-chat/src/app/page.tsx @@ -0,0 +1,49 @@ +"use client"; +import "@openuidev/react-ui/components.css"; + +import { useTheme } from "@/hooks/use-system-theme"; +import { agUIAdapter } from "@openuidev/react-headless"; +import { FullScreen } from "@openuidev/react-ui"; +import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib"; + +export default function Page() { + const mode = useTheme(); + + return ( +
+ { + return fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages, threadId }), + signal: abortController.signal, + }); + }} + streamProtocol={agUIAdapter()} + componentLibrary={openuiChatLibrary} + agentName="OpenUI Chat" + theme={{ mode }} + conversationStarters={{ + variant: "short", + options: [ + { + displayText: "Weather in Tokyo", + prompt: "What's the weather like in Tokyo right now?", + }, + { 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: "Data table", + prompt: + "Show me a table of the top 5 programming languages by popularity with year created.", + }, + ], + }} + /> +
+ ); +} diff --git a/examples/mastra-chat/src/generated/system-prompt.txt b/examples/mastra-chat/src/generated/system-prompt.txt new file mode 100644 index 000000000..9a444471c --- /dev/null +++ b/examples/mastra-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[], label?: string, 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/mastra-chat/src/hooks/use-system-theme.tsx b/examples/mastra-chat/src/hooks/use-system-theme.tsx new file mode 100644 index 000000000..7c110c21d --- /dev/null +++ b/examples/mastra-chat/src/hooks/use-system-theme.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, useContext, useLayoutEffect, useState } from "react"; + +type ThemeMode = "light" | "dark"; + +interface ThemeContextType { + mode: ThemeMode; +} + +const ThemeContext = createContext(undefined); + +function getSystemMode(): ThemeMode { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [mode, setMode] = useState(getSystemMode); + + useLayoutEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + useLayoutEffect(() => { + document.body.setAttribute("data-theme", mode); + }, [mode]); + + return {children}; +} + +export function useTheme(): ThemeMode { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx.mode; +} diff --git a/examples/mastra-chat/src/library.ts b/examples/mastra-chat/src/library.ts new file mode 100644 index 000000000..316f65a7d --- /dev/null +++ b/examples/mastra-chat/src/library.ts @@ -0,0 +1,4 @@ +export { + openuiChatLibrary as library, + openuiChatPromptOptions as promptOptions, +} from "@openuidev/react-ui/genui-lib"; diff --git a/examples/mastra-chat/tsconfig.json b/examples/mastra-chat/tsconfig.json new file mode 100644 index 000000000..cf9c65d3e --- /dev/null +++ b/examples/mastra-chat/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index c37984d7c..bd291cd78 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -8,11 +8,12 @@ export { ArtifactContext, useArtifactStore } from "./store/ArtifactContext"; export { ChatProvider } from "./store/ChatProvider"; export { agUIAdapter, + mastraAdapter, openAIAdapter, openAIReadableStreamAdapter, openAIResponsesAdapter, } from "./stream/adapters"; -export { openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats"; +export { mastraMessageFormat, openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; export type { ArtifactActions, ArtifactState } from "./store/artifactTypes"; diff --git a/packages/react-headless/src/stream/adapters/index.ts b/packages/react-headless/src/stream/adapters/index.ts index a3e2e4d74..9afe7612c 100644 --- a/packages/react-headless/src/stream/adapters/index.ts +++ b/packages/react-headless/src/stream/adapters/index.ts @@ -1,4 +1,5 @@ export * from "./ag-ui"; +export * from "./mastra"; export * from "./openai-completions"; export * from "./openai-readable-stream"; export * from "./openai-responses"; diff --git a/packages/react-headless/src/stream/adapters/mastra.ts b/packages/react-headless/src/stream/adapters/mastra.ts new file mode 100644 index 000000000..d43d94350 --- /dev/null +++ b/packages/react-headless/src/stream/adapters/mastra.ts @@ -0,0 +1,86 @@ +import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; + +export const mastraAdapter = (): StreamProtocolAdapter => ({ + async *parse(response: Response): AsyncIterable { + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + const messageId = crypto.randomUUID(); + + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: "assistant", + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") { + if (data === "[DONE]") { + yield { type: EventType.TEXT_MESSAGE_END, messageId }; + } + continue; + } + + try { + const event = JSON.parse(data); + + if (event.type === "text-delta" || event.textDelta) { + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: event.textDelta || event.text || "", + }; + } else if (event.type === "tool-call") { + yield { + type: EventType.TOOL_CALL_START, + toolCallId: event.toolCallId, + toolCallName: event.toolName, + }; + if (event.argsTextDelta || event.args) { + const deltaArgs = event.argsTextDelta || (typeof event.args === "string" ? event.args : JSON.stringify(event.args)); + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: event.toolCallId, + delta: deltaArgs, + }; + yield { + type: EventType.TOOL_CALL_END, + toolCallId: event.toolCallId, + }; + } + } else if (event.type === "tool-call-delta") { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: event.toolCallId, + delta: event.argsTextDelta, + }; + } else if (event.type === "finish") { + yield { type: EventType.TEXT_MESSAGE_END, messageId }; + } else if (typeof event === "object" && typeof event.text === "string") { + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: event.text, + }; + } + } catch (e) { + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: data, + }; + } + } + } + }, +}); diff --git a/packages/react-headless/src/stream/formats/index.ts b/packages/react-headless/src/stream/formats/index.ts index ed4cdd377..dfac42149 100644 --- a/packages/react-headless/src/stream/formats/index.ts +++ b/packages/react-headless/src/stream/formats/index.ts @@ -1,2 +1,3 @@ +export * from "./mastra"; export * from "./openai-conversation-message-format"; export * from "./openai-message-format"; diff --git a/packages/react-headless/src/stream/formats/mastra.ts b/packages/react-headless/src/stream/formats/mastra.ts new file mode 100644 index 000000000..af4097c33 --- /dev/null +++ b/packages/react-headless/src/stream/formats/mastra.ts @@ -0,0 +1,24 @@ +import type { Message } from "../../types/message"; +import type { MessageFormat } from "../../types/messageFormat"; + +export const mastraMessageFormat: MessageFormat = { + toApi: (messages: Message[]) => { + return messages.map((m) => { + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + const textContent = m.content.find((c) => c.type === "text"); + text = textContent?.text ?? ""; + } + + return { + role: m.role, + content: text, + }; + }); + }, + fromApi: (data: unknown) => { + return Array.isArray(data) ? (data as Message[]) : []; + }, +};