From fe2cfd43c256ccfdfad3c8dfb7df57436fd33622 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Sat, 28 Mar 2026 10:10:36 +0000 Subject: [PATCH 1/3] feat: add openui-artifact-demo example New example app demonstrating the OpenUI artifact system for displaying AI-generated code in a resizable side panel. - Custom ArtifactCodeBlock genui component with inline preview and artifact panel view - Extends openuiChatLibrary with artifact-specific component and prompt rules - Syntax highlighting via react-syntax-highlighter (Prism, vscDarkPlus) - Copy-to-clipboard, language badges, filename headers - Multiple artifacts per conversation, one active at a time - Code-focused conversation starters (React, Python, CSS, SQL) --- examples/openui-artifact-demo/.dockerignore | 39 +++ examples/openui-artifact-demo/.gitignore | 41 +++ examples/openui-artifact-demo/Dockerfile | 73 +++++ examples/openui-artifact-demo/README.md | 46 +++ .../openui-artifact-demo/eslint.config.mjs | 18 ++ examples/openui-artifact-demo/next.config.ts | 9 + examples/openui-artifact-demo/package.json | 36 +++ .../openui-artifact-demo/postcss.config.mjs | 7 + .../src/app/api/chat/route.ts | 303 ++++++++++++++++++ .../openui-artifact-demo/src/app/globals.css | 2 + .../openui-artifact-demo/src/app/layout.tsx | 22 ++ .../openui-artifact-demo/src/app/page.tsx | 57 ++++ .../ArtifactCodeBlock/ArtifactView.tsx | 93 ++++++ .../ArtifactCodeBlock/InlinePreview.tsx | 71 ++++ .../components/ArtifactCodeBlock/index.tsx | 37 +++ .../components/ArtifactCodeBlock/schema.ts | 10 + .../src/generated/system-prompt.txt | 219 +++++++++++++ .../src/hooks/use-system-theme.tsx | 41 +++ examples/openui-artifact-demo/src/library.ts | 60 ++++ examples/openui-artifact-demo/tsconfig.json | 34 ++ pnpm-lock.yaml | 175 ++++++++-- 21 files changed, 1361 insertions(+), 32 deletions(-) create mode 100644 examples/openui-artifact-demo/.dockerignore create mode 100644 examples/openui-artifact-demo/.gitignore create mode 100644 examples/openui-artifact-demo/Dockerfile create mode 100644 examples/openui-artifact-demo/README.md create mode 100644 examples/openui-artifact-demo/eslint.config.mjs create mode 100644 examples/openui-artifact-demo/next.config.ts create mode 100644 examples/openui-artifact-demo/package.json create mode 100644 examples/openui-artifact-demo/postcss.config.mjs create mode 100644 examples/openui-artifact-demo/src/app/api/chat/route.ts create mode 100644 examples/openui-artifact-demo/src/app/globals.css create mode 100644 examples/openui-artifact-demo/src/app/layout.tsx create mode 100644 examples/openui-artifact-demo/src/app/page.tsx create mode 100644 examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx create mode 100644 examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx create mode 100644 examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx create mode 100644 examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts create mode 100644 examples/openui-artifact-demo/src/generated/system-prompt.txt create mode 100644 examples/openui-artifact-demo/src/hooks/use-system-theme.tsx create mode 100644 examples/openui-artifact-demo/src/library.ts create mode 100644 examples/openui-artifact-demo/tsconfig.json diff --git a/examples/openui-artifact-demo/.dockerignore b/examples/openui-artifact-demo/.dockerignore new file mode 100644 index 000000000..999ffc10a --- /dev/null +++ b/examples/openui-artifact-demo/.dockerignore @@ -0,0 +1,39 @@ +# Node +node_modules +.pnpm-store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Next.js +.next +out + +# Git +.git +.gitignore + +# Logs +logs +*.log + +# Env files +.env +.env.* +!.env.example + +# OS files +.DS_Store +Thumbs.db + +# Build / cache +dist +build +.turbo +.cache +coverage + +# Editor +.vscode +.idea \ No newline at end of file diff --git a/examples/openui-artifact-demo/.gitignore b/examples/openui-artifact-demo/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/examples/openui-artifact-demo/.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/openui-artifact-demo/Dockerfile b/examples/openui-artifact-demo/Dockerfile new file mode 100644 index 000000000..b3a36dfe1 --- /dev/null +++ b/examples/openui-artifact-demo/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1.7 +# -------------------------------------------------- +# Build stage +# -------------------------------------------------- +FROM node:20-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache libc6-compat +ARG PNPM_VERSION=9.12.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.json ./ + +COPY packages/openui-cli/package.json ./packages/openui-cli/ +COPY packages/react-ui/package.json ./packages/react-ui/ +COPY packages/react-headless/package.json ./packages/react-headless/ +COPY packages/react-lang/package.json ./packages/react-lang/ +COPY examples/openui-chat/package.json ./examples/openui-chat/ + +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --ignore-scripts + +COPY packages/openui-cli ./packages/openui-cli +COPY packages/react-ui ./packages/react-ui +COPY packages/react-headless ./packages/react-headless +COPY packages/react-lang ./packages/react-lang +COPY examples/openui-chat ./examples/openui-chat + +RUN pnpm --filter @openuidev/cli build +RUN pnpm --filter @openuidev/react-ui build +RUN pnpm --filter @openuidev/react-headless build +RUN pnpm --filter @openuidev/react-lang build + +WORKDIR /app/examples/openui-chat +RUN node /app/packages/openui-cli/dist/index.js generate src/library.ts --out src/generated/system-prompt.txt \ + && pnpm build + + + +# -------------------------------------------------- +# Runtime stage +# -------------------------------------------------- +FROM node:20-alpine AS runner + +WORKDIR /app + +RUN apk add --no-cache libc6-compat + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 HOSTNAME=0.0.0.0 + +RUN addgroup -S nodejs && adduser -S nextjs -G nodejs +USER nextjs + +# Copy full standalone output to avoid brittle partial-copy assumptions +COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/.next/standalone ./ + +# Static assets expected by Next at runtime +COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/.next/static ./examples/openui-chat/.next/static + +# If your app has a public directory, include this line +# COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/public ./examples/openui-chat/public + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "examples/openui-chat/server.js"] \ No newline at end of file diff --git a/examples/openui-artifact-demo/README.md b/examples/openui-artifact-demo/README.md new file mode 100644 index 000000000..e2f54d200 --- /dev/null +++ b/examples/openui-artifact-demo/README.md @@ -0,0 +1,46 @@ +# OpenUI Artifact Demo + +A demo application showcasing the OpenUI artifact system for displaying generated code in a resizable side panel. + +## Features + +- **Artifact Code Blocks**: AI-generated code appears as compact previews in chat +- **Side Panel**: Click "View Code" to open the full code in a resizable artifact panel +- **Syntax Highlighting**: Full Prism-based syntax highlighting in the artifact panel +- **Multiple Artifacts**: Multiple code blocks per conversation, one active at a time +- **Copy to Clipboard**: One-click code copying from the artifact panel + +## Getting Started + +```bash +# Install dependencies (from repo root) +pnpm install + +# Generate the system prompt +pnpm --filter openui-artifact-demo generate:prompt + +# Start the development server +pnpm --filter openui-artifact-demo dev +``` + +Set your OpenAI API key: +```bash +export OPENAI_API_KEY=your-key-here +``` + +## How It Works + +This example extends the standard OpenUI chat library with a custom `ArtifactCodeBlock` component that integrates with the OpenUI artifact system: + +1. User asks for code (e.g., "Build me a React login form") +2. AI generates a response using `ArtifactCodeBlock` components +3. Each code block shows an inline preview in the chat +4. Clicking "View Code" opens the full code in the artifact side panel +5. The panel is resizable and supports syntax highlighting + copy + +## Architecture + +- `src/components/ArtifactCodeBlock/` — Custom genui component with inline preview and artifact panel view +- `src/library.ts` — Extended component library with ArtifactCodeBlock +- `src/app/page.tsx` — Main page using FullScreen layout +- `src/app/api/chat/route.ts` — API route for OpenAI streaming diff --git a/examples/openui-artifact-demo/eslint.config.mjs b/examples/openui-artifact-demo/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/examples/openui-artifact-demo/eslint.config.mjs @@ -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; diff --git a/examples/openui-artifact-demo/next.config.ts b/examples/openui-artifact-demo/next.config.ts new file mode 100644 index 000000000..d6663987d --- /dev/null +++ b/examples/openui-artifact-demo/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + turbopack: {}, + transpilePackages: ["@openuidev/react-ui", "@openuidev/react-headless", "@openuidev/react-lang"], +}; + +export default nextConfig; diff --git a/examples/openui-artifact-demo/package.json b/examples/openui-artifact-demo/package.json new file mode 100644 index 000000000..567500334 --- /dev/null +++ b/examples/openui-artifact-demo/package.json @@ -0,0 +1,36 @@ +{ + "name": "openui-artifact-demo", + "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": { + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "lucide-react": "^0.575.0", + "react-syntax-highlighter": "^15.6.1", + "zod": "^4.0.0", + "next": "16.1.6", + "openai": "^6.22.0", + "react": "19.2.3", + "react-dom": "19.2.3" + }, + "devDependencies": { + "@openuidev/cli": "workspace:*", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/openui-artifact-demo/postcss.config.mjs b/examples/openui-artifact-demo/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/openui-artifact-demo/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/openui-artifact-demo/src/app/api/chat/route.ts b/examples/openui-artifact-demo/src/app/api/chat/route.ts new file mode 100644 index 000000000..40ad28c27 --- /dev/null +++ b/examples/openui-artifact-demo/src/app/api/chat/route.ts @@ -0,0 +1,303 @@ +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 ── + +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, + }; + 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" }, + ], + })); + }, 800); + }); +} + +function getStockPrice({ symbol }: { symbol: string }): Promise { + return new Promise((resolve) => { + 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, + }; + 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)), + })); + }, 600); + }); +} + +function calculate({ expression }: { expression: string }): Promise { + return new Promise((resolve) => { + setTimeout(() => { + try { + const sanitized = expression.replace(/[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, ""); + + const result = new Function(`return (${sanitized})`)(); + resolve(JSON.stringify({ expression, result: Number(result) })); + } catch { + resolve(JSON.stringify({ expression, error: "Invalid expression" })); + } + }, 300); + }); +} + +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}.` }, + ], + })); + }, 1000); + }); +} + +// ── Tool definitions ── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const tools: any[] = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get current weather for a location.", + parameters: { + type: "object", + properties: { location: { type: "string", description: "City name" } }, + required: ["location"], + }, + function: getWeather, + parse: JSON.parse, + }, + }, + { + type: "function", + function: { + name: "get_stock_price", + description: "Get stock price for a ticker symbol.", + parameters: { + type: "object", + properties: { symbol: { type: "string", description: "Ticker symbol, e.g. AAPL" } }, + required: ["symbol"], + }, + function: getStockPrice, + parse: JSON.parse, + }, + }, + { + type: "function", + function: { + name: "calculate", + description: "Evaluate a math expression.", + parameters: { + type: "object", + properties: { expression: { type: "string", description: "Math expression to evaluate" } }, + required: ["expression"], + }, + function: calculate, + parse: JSON.parse, + }, + }, + { + type: "function", + function: { + name: "search_web", + description: "Search the web for information.", + parameters: { + type: "object", + properties: { query: { type: "string", description: "Search query" } }, + required: ["query"], + }, + function: searchWeb, + parse: JSON.parse, + }, + }, +]; + +// ── SSE helpers ── + +function sseToolCallStart( + encoder: TextEncoder, + tc: { id: string; function: { name: string } }, + index: number, +) { + return encoder.encode( + `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: "" } }], + }, + finish_reason: null, + }], + })}\n\n`, + ); +} + +function sseToolCallArgs( + encoder: TextEncoder, + tc: { id: string; function: { arguments: string } }, + result: string, + index: number, +) { + let enrichedArgs: string; + try { + enrichedArgs = JSON.stringify({ _request: JSON.parse(tc.function.arguments), _response: JSON.parse(result) }); + } catch { + enrichedArgs = tc.function.arguments; + } + return encoder.encode( + `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, + }], + })}\n\n`, + ); +} + +// ── Route handler ── + +export async function POST(req: NextRequest) { + const { messages } = await req.json(); + + const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + const MODEL = process.env.OPENAI_MODEL || "gpt-4o"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cleanMessages = (messages as any[]) + .filter((m) => m.role !== "tool") + .map((m) => { + if (m.role === "assistant" && m.tool_calls?.length) { + // Strip tool_calls (runTools re-runs the agentic loop server-side) + // but preserve content so prior replies remain in context. + const { tool_calls: _tc, ...rest } = m; // eslint-disable-line @typescript-eslint/no-unused-vars + return rest; + } + return m; + }); + + const chatMessages: ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...cleanMessages, + ]; + + const encoder = new TextEncoder(); + let controllerClosed = false; + + const readable = new ReadableStream({ + 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 */ } + }; + + const pendingCalls: Array<{ id: string; name: string; arguments: string }> = []; + let callIdx = 0; + let resultIdx = 0; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const runner = (client.chat.completions as any).runTools({ + model: MODEL, + messages: chatMessages, + tools, + stream: true + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runner.on("functionToolCall", (fc: any) => { + const id = `tc-${callIdx}`; + pendingCalls.push({ id, name: fc.name, arguments: fc.arguments }); + enqueue(sseToolCallStart(encoder, { id, function: { name: fc.name } }, callIdx)); + callIdx++; + }); + + runner.on("functionToolCallResult", (result: string) => { + const tc = pendingCalls[resultIdx]; + if (tc) { + enqueue(sseToolCallArgs(encoder, { id: tc.id, function: { arguments: tc.arguments } }, result, resultIdx)); + } + resultIdx++; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runner.on("chunk", (chunk: any) => { + const choice = chunk.choices?.[0]; + const delta = choice?.delta; + if (!delta) return; + if (delta.content) { + enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + if (choice?.finish_reason === "stop") { + enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + }); + + runner.on("end", () => { + enqueue(encoder.encode("data: [DONE]\n\n")); + close(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runner.on("error", (err: any) => { + 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`)); + close(); + }); + }, + }); + + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/examples/openui-artifact-demo/src/app/globals.css b/examples/openui-artifact-demo/src/app/globals.css new file mode 100644 index 000000000..3d552a61f --- /dev/null +++ b/examples/openui-artifact-demo/src/app/globals.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; + diff --git a/examples/openui-artifact-demo/src/app/layout.tsx b/examples/openui-artifact-demo/src/app/layout.tsx new file mode 100644 index 000000000..7e44b0451 --- /dev/null +++ b/examples/openui-artifact-demo/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { ThemeProvider } from "@/hooks/use-system-theme"; +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/openui-artifact-demo/src/app/page.tsx b/examples/openui-artifact-demo/src/app/page.tsx new file mode 100644 index 000000000..a68e6c6f9 --- /dev/null +++ b/examples/openui-artifact-demo/src/app/page.tsx @@ -0,0 +1,57 @@ +"use client"; +import "@openuidev/react-ui/components.css"; + +import { useTheme } from "@/hooks/use-system-theme"; +import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; +import { FullScreen } from "@openuidev/react-ui"; +import { artifactDemoLibrary } from "@/library"; + +export default function Page() { + const mode = useTheme(); + + return ( +
+ { + return fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: openAIMessageFormat.toApi(messages), + }), + signal: abortController.signal, + }); + }} + streamProtocol={openAIAdapter()} + componentLibrary={artifactDemoLibrary} + agentName="Artifact Demo" + theme={{ mode }} + conversationStarters={{ + variant: "short", + options: [ + { + displayText: "React login form", + prompt: + "Build me a React login form with email and password validation", + }, + { + displayText: "Python REST API", + prompt: + "Create a FastAPI REST API with CRUD endpoints for a todo app", + }, + { + displayText: "CSS animation", + prompt: + "Write a CSS animation for a bouncing loading indicator", + }, + { + displayText: "SQL schema", + prompt: + "Design a SQL schema for a blog with users, posts, and comments", + }, + ], + }} + /> +
+ ); +} diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx new file mode 100644 index 000000000..bd5d85b2a --- /dev/null +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/ArtifactView.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { Copy, CheckCheck } from "lucide-react"; + +interface ArtifactViewProps { + language: string; + codeString: string; + title: string; +} + +export function ArtifactView({ language, codeString, title }: ArtifactViewProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(codeString); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for environments where clipboard API is unavailable + const textarea = document.createElement("textarea"); + textarea.value = codeString; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [codeString]); + + return ( +
+ {/* Toolbar */} +
+ {title} +
+ + {language} + + +
+
+ + {/* Code */} +
+ + {codeString} + +
+
+ ); +} diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx new file mode 100644 index 000000000..8a35bba57 --- /dev/null +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useArtifact } from "@openuidev/react-headless"; + +interface InlinePreviewProps { + artifactId: string; + language: string; + title: string; + codeString: string; +} + +export function InlinePreview({ + artifactId, + language, + title, + codeString, +}: InlinePreviewProps) { + const { open, isActive } = useArtifact(artifactId); + + const truncatedCode = codeString.split("\n").slice(0, 6).join("\n"); + + return ( +
+ {/* Header */} +
+
+ + + + + {title} +
+ + {language} + +
+ + {/* Code preview */} +
+
+          {truncatedCode}
+        
+ {/* Gradient fade */} +
+
+ + {/* Footer */} +
+ +
+
+ ); +} diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx new file mode 100644 index 000000000..c2f5b300d --- /dev/null +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { defineComponent } from "@openuidev/react-lang"; +import { ArtifactPanel } from "@openuidev/react-ui"; +import { ArtifactCodeBlockSchema } from "./schema"; +import { InlinePreview } from "./InlinePreview"; +import { ArtifactView } from "./ArtifactView"; + +export { ArtifactCodeBlockSchema } from "./schema"; +export type { ArtifactCodeBlockProps } from "./schema"; + +export const ArtifactCodeBlock = defineComponent({ + name: "ArtifactCodeBlock", + props: ArtifactCodeBlockSchema, + description: + "Code block that opens in the artifact side panel for full viewing", + component: ({ props }) => ( + <> + + + + + + ), +}); diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts new file mode 100644 index 000000000..f0893ceb6 --- /dev/null +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const ArtifactCodeBlockSchema = z.object({ + artifactId: z.string(), + language: z.string(), + title: z.string(), + codeString: z.string(), +}); + +export type ArtifactCodeBlockProps = z.infer; diff --git a/examples/openui-artifact-demo/src/generated/system-prompt.txt b/examples/openui-artifact-demo/src/generated/system-prompt.txt new file mode 100644 index 000000000..25b350797 --- /dev/null +++ b/examples/openui-artifact-demo/src/generated/system-prompt.txt @@ -0,0 +1,219 @@ +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 +ArtifactCodeBlock(artifactId: string, language: string, title: string, codeString: string) — Code block that opens in the artifact side panel for full viewing + +### 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")]) + +Example — Code generation with artifacts: +root = Card([intro, code1, explanation, code2, followUps]) +intro = TextContent("Here's a React login form with validation:", "default") +code1 = ArtifactCodeBlock("login-form", "typescript", "LoginForm.tsx", "import React, { useState } from 'react';\n\nexport function LoginForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n\n return (\n
\n setEmail(e.target.value)} />\n setPassword(e.target.value)} />\n \n
\n );\n}") +explanation = TextContent("And the validation helper:", "default") +code2 = ArtifactCodeBlock("validation", "typescript", "validate.ts", "export function validateEmail(email: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}") +followUps = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Add password strength indicator") +fu2 = FollowUpItem("Add form styling with Tailwind") + +## 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. +- ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock. +- Each ArtifactCodeBlock MUST have a unique artifactId (use descriptive ids like 'login-form', 'api-route', 'validation-utils'). +- Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql'). +- Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css'). +- You can include multiple ArtifactCodeBlocks in one response — each with a unique artifactId. +- Surround code blocks with TextContent for explanations. diff --git a/examples/openui-artifact-demo/src/hooks/use-system-theme.tsx b/examples/openui-artifact-demo/src/hooks/use-system-theme.tsx new file mode 100644 index 000000000..7c110c21d --- /dev/null +++ b/examples/openui-artifact-demo/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/openui-artifact-demo/src/library.ts b/examples/openui-artifact-demo/src/library.ts new file mode 100644 index 000000000..ecdb9872e --- /dev/null +++ b/examples/openui-artifact-demo/src/library.ts @@ -0,0 +1,60 @@ +import type { ComponentGroup, PromptOptions } from "@openuidev/react-lang"; +import { createLibrary } from "@openuidev/react-lang"; +import { + openuiChatLibrary, + openuiChatPromptOptions, + openuiChatComponentGroups, +} from "@openuidev/react-ui/genui-lib"; + +import { ArtifactCodeBlock } from "./components/ArtifactCodeBlock"; + +// ── Component Groups — extend chat groups, add ArtifactCodeBlock to Content ── + +const artifactComponentGroups: ComponentGroup[] = openuiChatComponentGroups.map((group) => { + if (group.name === "Content") { + return { + ...group, + components: [...group.components, "ArtifactCodeBlock"], + }; + } + return group; +}); + +// ── Library — all chat components + ArtifactCodeBlock ── + +export const artifactDemoLibrary = createLibrary({ + root: "Card", + componentGroups: artifactComponentGroups, + components: [...Object.values(openuiChatLibrary.components), ArtifactCodeBlock], +}); + +// ── Prompt Options — extend chat rules with artifact-specific instructions ── + +export const artifactDemoPromptOptions: PromptOptions = { + additionalRules: [ + ...(openuiChatPromptOptions.additionalRules ?? []), + "ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock.", + "Each ArtifactCodeBlock MUST have a unique artifactId (use descriptive ids like 'login-form', 'api-route', 'validation-utils').", + "Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql').", + "Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css').", + "You can include multiple ArtifactCodeBlocks in one response — each with a unique artifactId.", + "Surround code blocks with TextContent for explanations.", + ], + examples: [ + ...(openuiChatPromptOptions.examples ?? []), + `Example — Code generation with artifacts: +root = Card([intro, code1, explanation, code2, followUps]) +intro = TextContent("Here's a React login form with validation:", "default") +code1 = ArtifactCodeBlock("login-form", "typescript", "LoginForm.tsx", "import React, { useState } from 'react';\\n\\nexport function LoginForm() {\\n const [email, setEmail] = useState('');\\n const [password, setPassword] = useState('');\\n\\n return (\\n
\\n setEmail(e.target.value)} />\\n setPassword(e.target.value)} />\\n \\n
\\n );\\n}") +explanation = TextContent("And the validation helper:", "default") +code2 = ArtifactCodeBlock("validation", "typescript", "validate.ts", "export function validateEmail(email: string): boolean {\\n return /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/.test(email);\\n}") +followUps = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Add password strength indicator") +fu2 = FollowUpItem("Add form styling with Tailwind")`, + ], +}; + +// ── CLI exports — the generate:prompt script expects `library` and `promptOptions` ── + +export { artifactDemoLibrary as library }; +export { artifactDemoPromptOptions as promptOptions }; diff --git a/examples/openui-artifact-demo/tsconfig.json b/examples/openui-artifact-demo/tsconfig.json new file mode 100644 index 000000000..cf9c65d3e --- /dev/null +++ b/examples/openui-artifact-demo/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/pnpm-lock.yaml b/pnpm-lock.yaml index 8d18e02ed..00e2625b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,70 @@ importers: specifier: ^5 version: 5.9.3 + examples/openui-artifact-demo: + dependencies: + '@openuidev/react-headless': + specifier: workspace:* + version: link:../../packages/react-headless + '@openuidev/react-lang': + specifier: workspace:* + version: link:../../packages/react-lang + '@openuidev/react-ui': + specifier: workspace:* + version: link:../../packages/react-ui + lucide-react: + specifier: ^0.575.0 + version: 0.575.0(react@19.2.3) + next: + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.89.2) + openai: + specifier: ^6.22.0 + version: 6.22.0(ws@8.18.2)(zod@4.3.6) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + react-syntax-highlighter: + specifier: ^15.6.1 + version: 15.6.1(react@19.2.3) + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@openuidev/cli': + specifier: workspace:* + version: link:../../packages/openui-cli + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.1 + '@types/node': + specifier: ^20 + version: 20.19.35 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + '@types/react-syntax-highlighter': + specifier: ^15.5.13 + version: 15.5.13 + eslint: + specifier: ^9 + version: 9.29.0(jiti@2.6.1) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.1 + typescript: + specifier: ^5 + version: 5.9.3 + examples/openui-chat: dependencies: '@openuidev/react-headless': @@ -2304,89 +2368,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2613,10 +2693,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} - '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -2624,10 +2700,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} @@ -2637,9 +2709,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2693,48 +2762,56 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.1.6': resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.12': resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.12': resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.12': resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.12': resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} @@ -2886,36 +2963,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -4810,56 +4893,67 @@ packages: resolution: {integrity: sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.43.0': resolution: {integrity: sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.43.0': resolution: {integrity: sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.43.0': resolution: {integrity: sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.43.0': resolution: {integrity: sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.43.0': resolution: {integrity: sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.43.0': resolution: {integrity: sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.43.0': resolution: {integrity: sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.43.0': resolution: {integrity: sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.43.0': resolution: {integrity: sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.43.0': resolution: {integrity: sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.43.0': resolution: {integrity: sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==} @@ -5151,24 +5245,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -5218,24 +5316,28 @@ packages: engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@takumi-rs/core-linux-arm64-musl@0.68.17': resolution: {integrity: sha512-4CiEF518wDnujF0fjql2XN6uO+OXl0svy0WgAF2656dCx2gJtWscaHytT2rsQ0ZmoFWE0dyWcDW1g/FBVPvuvA==} engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@takumi-rs/core-linux-x64-gnu@0.68.17': resolution: {integrity: sha512-jm8lTe2E6Tfq2b97GJC31TWK1JAEv+MsVbvL9DCLlYcafgYFlMXDUnOkZFMjlrmh0HcFAYDaBkniNDgIQfXqzg==} engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@takumi-rs/core-linux-x64-musl@0.68.17': resolution: {integrity: sha512-nbdzQgC4ywzltDDV1fer1cKswwGE+xXZHdDiacdd7RM5XBng209Bmo3j1iv9dsX+4xXhByzCCGbxdWhhHqVXmw==} engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@takumi-rs/core-win32-arm64-msvc@0.68.17': resolution: {integrity: sha512-kE4F0LRmuhSwiNkFG7dTY9ID8+B7zb97QedyN/IO2fBJmRQDkqCGcip2gloh8YPPhCuKGjCqqqh2L+Tg9PKW7w==} @@ -5555,41 +5657,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -7991,24 +8101,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -10670,8 +10784,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@asamuzakjp/css-color@5.0.1': dependencies: @@ -10758,8 +10872,8 @@ snapshots: dependencies: '@babel/parser': 7.27.5 '@babel/types': 7.27.6 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/generator@7.29.1': @@ -12455,21 +12569,13 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/gen-mapping@0.3.8': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.6': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -12479,11 +12585,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -19661,7 +19762,7 @@ snapshots: magic-string@0.27.0: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 magic-string@0.30.17: dependencies: @@ -21637,6 +21738,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-syntax-highlighter@15.6.1(react@19.2.3): + dependencies: + '@babel/runtime': 7.27.6 + highlight.js: 10.7.3 + highlightjs-vue: 1.0.0 + lowlight: 1.20.0 + prismjs: 1.30.0 + react: 19.2.3 + refractor: 3.6.0 + react-syntax-highlighter@15.6.1(react@19.2.4): dependencies: '@babel/runtime': 7.27.6 @@ -22408,7 +22519,7 @@ snapshots: sucrase@3.35.0: dependencies: - '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 glob: 10.4.5 lines-and-columns: 1.2.4 From 2a3da5c52fdffc99570f6df35f4ba2263ec0bb3c Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Tue, 31 Mar 2026 14:04:24 +0530 Subject: [PATCH 2/3] styling fix for artifact panel --- .../src/components/_shared/artifact/artifactPanel.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss b/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss index cbc0bb181..634ce066d 100644 --- a/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss +++ b/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss @@ -7,6 +7,10 @@ gap: cssUtils.$space-s; padding: cssUtils.$space-s cssUtils.$space-m; border-bottom: 1px solid cssUtils.$border-default; + background-color: cssUtils.$foreground; + position: sticky; + top: 0; + z-index: 1; } .openui-artifact-panel__title { From f53ff2a1f4c86d4cd8c84031c6af1f37c0206262 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Wed, 1 Apr 2026 17:41:25 +0530 Subject: [PATCH 3/3] feat: add Artifact() factory function in react-ui, migrate artifact demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Artifact() to @openuidev/react-ui — a factory that returns a ComponentRenderer with automatic useId, useArtifact, and ArtifactPanel wiring. Consumers provide preview/panel render functions; no manual artifact boilerplate needed. No changes to lang-core or react-lang. - react-ui: new Artifact() function with ArtifactControls injection - example: migrate ArtifactCodeBlock to use Artifact() - example: remove artifactId from schema and prompt, simplify InlinePreview Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ArtifactCodeBlock/InlinePreview.tsx | 16 +--- .../components/ArtifactCodeBlock/index.tsx | 35 ++++---- .../components/ArtifactCodeBlock/schema.ts | 1 - .../src/generated/system-prompt.txt | 9 +- examples/openui-artifact-demo/src/library.ts | 12 ++- packages/react-ui/src/artifact/Artifact.tsx | 83 +++++++++++++++++++ packages/react-ui/src/artifact/index.ts | 2 + packages/react-ui/src/index.ts | 4 + 8 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 packages/react-ui/src/artifact/Artifact.tsx create mode 100644 packages/react-ui/src/artifact/index.ts diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx index 8a35bba57..b75b860e5 100644 --- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/InlinePreview.tsx @@ -1,22 +1,14 @@ "use client"; -import { useArtifact } from "@openuidev/react-headless"; - interface InlinePreviewProps { - artifactId: string; language: string; title: string; codeString: string; + open: () => void; + isActive: boolean; } -export function InlinePreview({ - artifactId, - language, - title, - codeString, -}: InlinePreviewProps) { - const { open, isActive } = useArtifact(artifactId); - +export function InlinePreview({ language, title, codeString, open, isActive }: InlinePreviewProps) { const truncatedCode = codeString.split("\n").slice(0, 6).join("\n"); return ( @@ -49,7 +41,7 @@ export function InlinePreview({ {truncatedCode} {/* Gradient fade */} -
+
{/* Footer */} diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx index c2f5b300d..ecf780567 100644 --- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx @@ -1,10 +1,10 @@ "use client"; import { defineComponent } from "@openuidev/react-lang"; -import { ArtifactPanel } from "@openuidev/react-ui"; -import { ArtifactCodeBlockSchema } from "./schema"; -import { InlinePreview } from "./InlinePreview"; +import { Artifact } from "@openuidev/react-ui"; import { ArtifactView } from "./ArtifactView"; +import { InlinePreview } from "./InlinePreview"; +import { ArtifactCodeBlockSchema } from "./schema"; export { ArtifactCodeBlockSchema } from "./schema"; export type { ArtifactCodeBlockProps } from "./schema"; @@ -13,25 +13,24 @@ export const ArtifactCodeBlock = defineComponent({ name: "ArtifactCodeBlock", props: ArtifactCodeBlockSchema, description: - "Code block that opens in the artifact side panel for full viewing", - component: ({ props }) => ( - <> + "Code block that opens in the artifact side panel for full viewing with syntax highlighting", + component: Artifact({ + title: (props) => props.title as string, + preview: (props, { open, isActive }) => ( - ( + - - - - ), + /> + ), + }), }); diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts index f0893ceb6..02ce83ac7 100644 --- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts +++ b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts @@ -1,7 +1,6 @@ import { z } from "zod"; export const ArtifactCodeBlockSchema = z.object({ - artifactId: z.string(), language: z.string(), title: z.string(), codeString: z.string(), diff --git a/examples/openui-artifact-demo/src/generated/system-prompt.txt b/examples/openui-artifact-demo/src/generated/system-prompt.txt index 25b350797..61e4d87c1 100644 --- a/examples/openui-artifact-demo/src/generated/system-prompt.txt +++ b/examples/openui-artifact-demo/src/generated/system-prompt.txt @@ -28,7 +28,7 @@ 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 -ArtifactCodeBlock(artifactId: string, language: string, title: string, codeString: string) — Code block that opens in the artifact side panel for full viewing +ArtifactCodeBlock(language: string, title: string, codeString: string) — Code block that opens in the artifact side panel for full viewing with syntax highlighting ### Tables Table(columns: Col[], rows: (string | number | boolean)[][]) — Data table @@ -183,9 +183,9 @@ btns = Buttons([Button("Submit", { type: "continue_conversation" }, "primary")]) Example — Code generation with artifacts: root = Card([intro, code1, explanation, code2, followUps]) intro = TextContent("Here's a React login form with validation:", "default") -code1 = ArtifactCodeBlock("login-form", "typescript", "LoginForm.tsx", "import React, { useState } from 'react';\n\nexport function LoginForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n\n return (\n
\n setEmail(e.target.value)} />\n setPassword(e.target.value)} />\n \n
\n );\n}") +code1 = ArtifactCodeBlock("typescript", "LoginForm.tsx", "import React, { useState } from 'react';\n\nexport function LoginForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n\n return (\n
\n setEmail(e.target.value)} />\n setPassword(e.target.value)} />\n \n
\n );\n}") explanation = TextContent("And the validation helper:", "default") -code2 = ArtifactCodeBlock("validation", "typescript", "validate.ts", "export function validateEmail(email: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}") +code2 = ArtifactCodeBlock("typescript", "validate.ts", "export function validateEmail(email: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}") followUps = FollowUpBlock([fu1, fu2]) fu1 = FollowUpItem("Add password strength indicator") fu2 = FollowUpItem("Add form styling with Tailwind") @@ -212,8 +212,7 @@ fu2 = FollowUpItem("Add form styling with Tailwind") - For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields). - Never nest Form inside Form. - ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock. -- Each ArtifactCodeBlock MUST have a unique artifactId (use descriptive ids like 'login-form', 'api-route', 'validation-utils'). - Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql'). - Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css'). -- You can include multiple ArtifactCodeBlocks in one response — each with a unique artifactId. +- You can include multiple ArtifactCodeBlocks in one response — each with a unique title. - Surround code blocks with TextContent for explanations. diff --git a/examples/openui-artifact-demo/src/library.ts b/examples/openui-artifact-demo/src/library.ts index ecdb9872e..0ab637d04 100644 --- a/examples/openui-artifact-demo/src/library.ts +++ b/examples/openui-artifact-demo/src/library.ts @@ -1,9 +1,9 @@ import type { ComponentGroup, PromptOptions } from "@openuidev/react-lang"; import { createLibrary } from "@openuidev/react-lang"; import { + openuiChatComponentGroups, openuiChatLibrary, openuiChatPromptOptions, - openuiChatComponentGroups, } from "@openuidev/react-ui/genui-lib"; import { ArtifactCodeBlock } from "./components/ArtifactCodeBlock"; @@ -34,10 +34,9 @@ export const artifactDemoPromptOptions: PromptOptions = { additionalRules: [ ...(openuiChatPromptOptions.additionalRules ?? []), "ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock.", - "Each ArtifactCodeBlock MUST have a unique artifactId (use descriptive ids like 'login-form', 'api-route', 'validation-utils').", "Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql').", "Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css').", - "You can include multiple ArtifactCodeBlocks in one response — each with a unique artifactId.", + "You can include multiple ArtifactCodeBlocks in one response — each with a unique title.", "Surround code blocks with TextContent for explanations.", ], examples: [ @@ -45,9 +44,9 @@ export const artifactDemoPromptOptions: PromptOptions = { `Example — Code generation with artifacts: root = Card([intro, code1, explanation, code2, followUps]) intro = TextContent("Here's a React login form with validation:", "default") -code1 = ArtifactCodeBlock("login-form", "typescript", "LoginForm.tsx", "import React, { useState } from 'react';\\n\\nexport function LoginForm() {\\n const [email, setEmail] = useState('');\\n const [password, setPassword] = useState('');\\n\\n return (\\n
\\n setEmail(e.target.value)} />\\n setPassword(e.target.value)} />\\n \\n
\\n );\\n}") +code1 = ArtifactCodeBlock("typescript", "LoginForm.tsx", "import React, { useState } from 'react';\\n\\nexport function LoginForm() {\\n const [email, setEmail] = useState('');\\n const [password, setPassword] = useState('');\\n\\n return (\\n
\\n setEmail(e.target.value)} />\\n setPassword(e.target.value)} />\\n \\n
\\n );\\n}") explanation = TextContent("And the validation helper:", "default") -code2 = ArtifactCodeBlock("validation", "typescript", "validate.ts", "export function validateEmail(email: string): boolean {\\n return /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/.test(email);\\n}") +code2 = ArtifactCodeBlock("typescript", "validate.ts", "export function validateEmail(email: string): boolean {\\n return /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/.test(email);\\n}") followUps = FollowUpBlock([fu1, fu2]) fu1 = FollowUpItem("Add password strength indicator") fu2 = FollowUpItem("Add form styling with Tailwind")`, @@ -56,5 +55,4 @@ fu2 = FollowUpItem("Add form styling with Tailwind")`, // ── CLI exports — the generate:prompt script expects `library` and `promptOptions` ── -export { artifactDemoLibrary as library }; -export { artifactDemoPromptOptions as promptOptions }; +export { artifactDemoLibrary as library, artifactDemoPromptOptions as promptOptions }; diff --git a/packages/react-ui/src/artifact/Artifact.tsx b/packages/react-ui/src/artifact/Artifact.tsx new file mode 100644 index 000000000..1ef54edd2 --- /dev/null +++ b/packages/react-ui/src/artifact/Artifact.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useArtifact } from "@openuidev/react-headless"; +import type { ComponentRenderer } from "@openuidev/react-lang"; +import { useId, type ReactNode } from "react"; +import { ArtifactPanel, type ArtifactPanelProps } from "../components/_shared/artifact"; + +/** + * Controls injected into `preview` and `panel` render functions. + */ +export interface ArtifactControls { + /** Whether this artifact is the currently active (visible) one. */ + isActive: boolean; + /** Activates this artifact. */ + open: () => void; + /** Deactivates this artifact. */ + close: () => void; + /** Toggles this artifact: opens if closed, closes if open. */ + toggle: () => void; +} + +/** + * Configuration for {@link Artifact}. + */ +export interface ArtifactConfig

> { + /** Panel title — static string or derived from props. */ + title: string | ((props: P) => string); + /** Renders the inline preview shown in the chat message. */ + preview: (props: P, controls: ArtifactControls) => ReactNode; + /** Renders the content inside the artifact side panel. */ + panel: (props: P, controls: ArtifactControls) => ReactNode; + /** Optional props forwarded to the underlying ``. */ + panelProps?: Pick; +} + +/** + * Factory that returns a `ComponentRenderer

` wiring up `useId`, `useArtifact`, + * and `` internally. Pass the result as `defineComponent`'s `component`. + * + * @example + * ```tsx + * export const ArtifactCodeBlock = defineComponent({ + * name: "ArtifactCodeBlock", + * props: ArtifactCodeBlockSchema, + * description: "Code block that opens in the artifact side panel", + * component: Artifact({ + * title: (props) => props.title, + * preview: (props, { open, isActive }) => ( + * + * ), + * panel: (props) => ( + * + * ), + * }), + * }); + * ``` + */ +export function Artifact

>( + config: ArtifactConfig

, +): ComponentRenderer

{ + const { title, preview, panel, panelProps } = config; + + const ArtifactComponent: ComponentRenderer

= ({ props }) => { + const artifactId = useId(); + const { isActive, open, close, toggle } = useArtifact(artifactId); + + const controls: ArtifactControls = { isActive, open, close, toggle }; + const resolvedTitle = typeof title === "function" ? title(props) : title; + + return ( + <> + {preview(props, controls)} + + {panel(props, controls)} + + + ); + }; + + ArtifactComponent.displayName = `Artifact(${typeof title === "string" ? title : "dynamic"})`; + + return ArtifactComponent; +} diff --git a/packages/react-ui/src/artifact/index.ts b/packages/react-ui/src/artifact/index.ts new file mode 100644 index 000000000..199e769a9 --- /dev/null +++ b/packages/react-ui/src/artifact/index.ts @@ -0,0 +1,2 @@ +export { Artifact } from "./Artifact"; +export type { ArtifactConfig, ArtifactControls } from "./Artifact"; diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index fac42b290..34d9cf8ed 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -2,6 +2,10 @@ export * from "./components/Accordion"; +// Artifact() factory — generates a ComponentRenderer with artifact wiring +export { Artifact } from "./artifact"; +export type { ArtifactConfig, ArtifactControls } from "./artifact"; + // Artifact exports (ArtifactPanel/ArtifactPortalTarget also available as Shell.*) export { useActiveArtifact, useArtifact } from "@openuidev/react-headless"; export {