diff --git a/.gitignore b/.gitignore index f1718dadf..ba9580689 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,11 @@ CLAUDE.local.md node_modules .pnpm-store/ +# Environment +.env +.env.local +.env*.local + # Build output dist .next @@ -45,4 +50,4 @@ typings clean-install.sh lint-fix.sh -draft/ \ No newline at end of file +draft/ diff --git a/examples/openui-dashboard/.gitignore b/examples/openui-dashboard/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/examples/openui-dashboard/.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-dashboard/README.md b/examples/openui-dashboard/README.md new file mode 100644 index 000000000..ad2d96af8 --- /dev/null +++ b/examples/openui-dashboard/README.md @@ -0,0 +1,44 @@ +# OpenUI Dashboard Example + +A live dashboard builder powered by [OpenUI](https://openui.com) and openui-lang. Chat with an LLM to create interactive dashboards with real-time data from MCP tools. + +## Features + +- **Conversational dashboard building** — describe what you want, get a live dashboard +- **MCP tool integration** — Query live data sources (PostHog, server health, tickets) +- **Streaming rendering** — dashboards appear progressively as the LLM generates code +- **Edit support** — refine dashboards through follow-up messages +- **16 built-in tools** — analytics, monitoring, ticket management, and more + +## Getting Started + +```bash +# Set your LLM API key +export OPENAI_API_KEY=sk-... +# Or use any OpenAI-compatible provider: +# export LLM_API_KEY=your-key +# export LLM_BASE_URL=https://openrouter.ai/api/v1 +# export LLM_MODEL=your-model + +# Install dependencies +pnpm install + +# Run the development server +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) to start building dashboards. + +## Optional: PostHog Integration + +For real analytics data, set PostHog credentials: + +```bash +export POSTHOG_API_KEY=phx_... +export POSTHOG_PROJECT_ID=12345 +``` + +## Learn More + +- [OpenUI Documentation](https://openui.com/docs) +- [OpenUI GitHub](https://github.com/thesysdev/openui) diff --git a/examples/openui-dashboard/eslint.config.mjs b/examples/openui-dashboard/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/examples/openui-dashboard/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-dashboard/next.config.ts b/examples/openui-dashboard/next.config.ts new file mode 100644 index 000000000..d6663987d --- /dev/null +++ b/examples/openui-dashboard/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-dashboard/package.json b/examples/openui-dashboard/package.json new file mode 100644 index 000000000..be4cc7525 --- /dev/null +++ b/examples/openui-dashboard/package.json @@ -0,0 +1,36 @@ +{ + "name": "openui-dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "generate:prompt": "pnpm --filter @openuidev/cli build && pnpm exec openui generate src/library.ts --json-schema --out src/generated/component-spec.json", + "dev": "pnpm generate:prompt && next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@openuidev/cli": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "@openuidev/lang-core": "workspace:*", + "@openuidev/react-headless": "workspace:*", + "@modelcontextprotocol/sdk": "^1.27.1", + "@openuidev/react-lang": "workspace:*", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "openai": "^6.22.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "zod": "^4.0.0" + }, + "devDependencies": { + "@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/openui-dashboard/postcss.config.mjs b/examples/openui-dashboard/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/openui-dashboard/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/openui-dashboard/src/app/api/chat/route.ts b/examples/openui-dashboard/src/app/api/chat/route.ts new file mode 100644 index 000000000..6502d2350 --- /dev/null +++ b/examples/openui-dashboard/src/app/api/chat/route.ts @@ -0,0 +1,244 @@ +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; +import { generatePrompt, type McpToolSpec } from "@openuidev/lang-core"; +import { promptSpec } from "../../../prompt-config"; +import { tools as toolDefs } from "../../../tools"; +import { z } from "zod"; + +// ── Convert shared Zod registry → OpenAI function-calling format ── + +function zodToJsonSchema(schema: Record): Record { + const properties: Record = {}; + const required: string[] = []; + for (const [key, zodSchema] of Object.entries(schema)) { + const jsonSchema = z.toJSONSchema(zodSchema); + properties[key] = jsonSchema; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const def = (zodSchema as any)?._zod?.def; + if (def?.type !== "optional") { + required.push(key); + } + } + return { type: "object", properties, ...(required.length ? { required } : {}) }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const tools: any[] = toolDefs.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: zodToJsonSchema(t.inputSchema), + function: async (args: Record) => JSON.stringify(await t.execute(args)), + 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`, + ); +} + +// ── Dynamic system prompt ── + +function buildSystemPrompt(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mcpTools: McpToolSpec[] = tools.map((t: any) => ({ + name: t.function.name, + description: t.function.description, + inputSchema: t.function.parameters, + })); + + return generatePrompt({ + ...promptSpec, + tools: mcpTools, + }); +} + +// ── Route handler using runTools ── + +export async function POST(req: NextRequest) { + const { messages } = await req.json(); + + // LLM config from env — supports OpenAI-compatible providers (OpenAI, OpenRouter, etc.) + const apiKey = process.env.LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? ""; + const baseURL = process.env.LLM_BASE_URL; // e.g. "https://openrouter.ai/api/v1" + const model = process.env.LLM_MODEL ?? "openai/gpt-5.4-mini"; + + if (!apiKey) { + return new Response( + JSON.stringify({ error: "Set LLM_API_KEY or OPENAI_API_KEY env var" }), + { status: 500 }, + ); + } + + const client = new OpenAI({ + apiKey, + ...(baseURL ? { baseURL } : {}), + }); + + const cleanMessages = (messages as any[]) + .filter((m: any) => m.role !== "tool") + .map((m: any) => { + if (m.role === "assistant" && m.tool_calls?.length) { + const { tool_calls: _tc, ...rest } = m; + return rest; + } + return m; + }); + + const systemPrompt = buildSystemPrompt(); + const chatMessages: ChatCompletionMessageParam[] = [ + { role: "system" as const, 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 { /* closed */ } + }; + const close = () => { + if (controllerClosed) return; + controllerClosed = true; + try { controller.close(); } catch { /* 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 runOpts: any = { + model, + messages: chatMessages, + tools, + stream: true, + reasoning: { + effort: 'low' + } + }; + const runner = (client.chat.completions as any).runTools(runOpts); + + // 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 + let accumulator = ""; + runner.on("chunk", (chunk: any) => { + const choice = chunk.choices?.[0]; + const delta = choice?.delta; + if (!delta) return; + if (delta.content) { + accumulator += 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] 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-dashboard/src/app/api/mcp/route.ts b/examples/openui-dashboard/src/app/api/mcp/route.ts new file mode 100644 index 000000000..28636b7d5 --- /dev/null +++ b/examples/openui-dashboard/src/app/api/mcp/route.ts @@ -0,0 +1,60 @@ +/** + * MCP Server — exposes all tools via MCP protocol. + * + * Tool definitions live in src/tools.ts (shared with /api/chat). + * This file only sets up the MCP transport and registers tools. + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { tools } from "../../../tools"; + +// ── MCP Server factory ─────────────────────────────────────────────────────── + +function createServer(): McpServer { + const server = new McpServer( + { name: "openui-tools", version: "1.0.0" }, + ); + + for (const tool of tools) { + server.registerTool(tool.name, { + description: tool.description, + inputSchema: tool.inputSchema, + }, async (args) => ({ + content: [{ type: "text" as const, text: JSON.stringify(await tool.execute(args)) }], + })); + } + + return server; +} + +// ── Request handler ────────────────────────────────────────────────────────── + +async function handleMcpRequest(request: Request): Promise { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless + enableJsonResponse: true, + }); + const server = createServer(); + await server.connect(transport); + + try { + return await transport.handleRequest(request); + } finally { + await transport.close(); + await server.close(); + } +} + +// ── Next.js route exports ──────────────────────────────────────────────────── + +export async function POST(req: Request) { + return handleMcpRequest(req); +} + +export async function GET(req: Request) { + return handleMcpRequest(req); +} + +export async function DELETE(req: Request) { + return handleMcpRequest(req); +} diff --git a/examples/openui-dashboard/src/app/dashboard/page.tsx b/examples/openui-dashboard/src/app/dashboard/page.tsx new file mode 100644 index 000000000..43067c78f --- /dev/null +++ b/examples/openui-dashboard/src/app/dashboard/page.tsx @@ -0,0 +1,729 @@ +"use client"; + +import "@openuidev/react-ui/components.css"; +import { Renderer, createMcpTransport, mergeStatements } from "@openuidev/react-lang"; +import type { Transport, McpConnection } from "@openuidev/react-lang"; +import { openuiLibrary } from "@openuidev/react-ui/genui-lib"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ThemeProvider, MarkDownRenderer } from "@openuidev/react-ui"; + +// ── MCP Transport with tool call tracking ──────────────────────────────────── + +type ToolCallEntry = { tool: string; status: "pending" | "done" | "error" }; +type ToolCallListener = (calls: ToolCallEntry[]) => void; + +let toolCallListener: ToolCallListener | null = null; +const activeCalls: ToolCallEntry[] = []; + +function notifyToolCalls() { + toolCallListener?.([...activeCalls]); +} + +function wrapTransport(inner: Transport): Transport { + return { + callTool: async (toolName, args) => { + const entry: ToolCallEntry = { tool: toolName, status: "pending" }; + activeCalls.push(entry); + notifyToolCalls(); + try { + const data = await inner.callTool(toolName, args); + entry.status = "done"; + notifyToolCalls(); + return data; + } catch { + entry.status = "error"; + notifyToolCalls(); + return null; + } + }, + }; +} + +// ── Streaming SSE ─────────────────────────────────────────────────────────── + +type LLMToolCall = { id: string; name: string; status: "calling" | "done"; result?: string }; +type LLMToolCallListener = (calls: LLMToolCall[]) => void; +let llmToolCallListener: LLMToolCallListener | null = null; +const llmActiveCalls: LLMToolCall[] = []; + +function notifyLLMToolCalls() { + llmToolCallListener?.([...llmActiveCalls]); +} + +async function streamChat( + messages: Array<{ role: string; content: string }>, + onChunk: (text: string) => void, + onDone: (usage?: { prompt_tokens?: number; completion_tokens?: number }) => void, + signal?: AbortSignal, + onFirstChunk?: () => void, +) { + llmActiveCalls.length = 0; + notifyLLMToolCalls(); + + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages }), + signal, + }); + + if (!res.ok) { + const err = await res.text(); + onChunk(`Error: ${err}`); + onDone(); + return; + } + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastUsage: { prompt_tokens?: number; completion_tokens?: number } | undefined; + let firstChunkFired = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") { + for (const tc of llmActiveCalls) { + if (tc.status === "calling") tc.status = "done"; + } + notifyLLMToolCalls(); + onDone(lastUsage); + return; + } + try { + const chunk = JSON.parse(data); + const tcDeltas = chunk.choices?.[0]?.delta?.tool_calls; + if (tcDeltas) { + for (const tc of tcDeltas) { + if (tc.id && tc.function?.name) { + llmActiveCalls.push({ id: tc.id, name: tc.function.name, status: "calling" }); + notifyLLMToolCalls(); + } else if (tc.function?.arguments) { + const existing = llmActiveCalls[tc.index]; + if (existing) { + existing.status = "done"; + try { + const parsed = JSON.parse(tc.function.arguments); + if (parsed._response) { + existing.result = JSON.stringify(parsed._response).slice(0, 2000); + } + } catch { /* ignore parse errors */ } + notifyLLMToolCalls(); + } + } + } + } + const content = chunk.choices?.[0]?.delta?.content; + if (content) { + if (!firstChunkFired) { firstChunkFired = true; onFirstChunk?.(); } + onChunk(content); + } + if (chunk.usage) lastUsage = chunk.usage; + } catch { /* skip */ } + } + } + onDone(lastUsage); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract ONLY the code portions from a response (fenced blocks or pure code) */ +function extractCodeOnly(response: string): string | null { + // Extract fenced code blocks + const fenceRegex = /```[\w-]*\n([\s\S]*?)```/g; + const blocks: string[] = []; + let match; + while ((match = fenceRegex.exec(response)) !== null) { + blocks.push(match[1].trim()); + } + if (blocks.length > 0) return blocks.join("\n"); + + // Check for unclosed fence (streaming) + const unclosedMatch = response.match(/```[\w-]*\n([\s\S]*)$/); + if (unclosedMatch) return unclosedMatch[1].trim() || null; + + // If pure code (no fences), return as-is + if (isPureCode(response)) return response; + + return null; +} + +function extractText(response: string): string { + // Remove fenced code blocks, keep surrounding text + const withoutFences = response.replace(/```[\w-]*\n[\s\S]*?```/g, "").trim(); + // Also handle unclosed fences (streaming) + const withoutUnclosed = withoutFences.replace(/```[\w-]*\n[\s\S]*$/g, "").trim(); + // If what remains looks like pure code (not conversational text), return empty + if (withoutUnclosed && isPureCode(withoutUnclosed)) return ""; + return withoutUnclosed; +} + +function responseHasCode(response: string): boolean { + // Has fenced code blocks + if (/```[\w-]*\n/.test(response)) return true; + // Or looks like pure openui-lang (starts with identifier = expression pattern) + const trimmed = response.trim(); + if (/^[a-zA-Z_$][\w$]*\s*=\s*/.test(trimmed)) return true; + return false; +} + +/** Check if response is pure code (no fences, no conversational text) */ +function isPureCode(response: string): boolean { + const trimmed = response.trim(); + // Pure code: every non-empty line matches `identifier = expression` or is a $variable declaration + if (/```/.test(trimmed)) return false; // has fences = not pure code + const lines = trimmed.split("\n").filter(l => l.trim()); + if (lines.length === 0) return false; + // If most lines look like statements, it's pure code + const stmtPattern = /^[a-zA-Z_$][\w$]*\s*=/; + const stmtCount = lines.filter(l => stmtPattern.test(l.trim())).length; + return stmtCount / lines.length > 0.7; +} + +// ── Starters ──────────────────────────────────────────────────────────────── + +const STARTERS = [ + { label: "Web Analytics", prompt: "Show me pageviews and unique users over the last 14 days with a date range filter", icon: "📊" }, + { label: "Top Events", prompt: "What are the top 10 events by volume this week?", icon: "🔥" }, + { label: "Full Dashboard", prompt: "Build a web analytics dashboard with KPIs, trend chart, top pages table, and traffic sources", icon: "📈" }, + { label: "Ticket Tracker", prompt: "Build a ticket tracker with a create form (title + priority dropdown) and a table of all tickets below that refreshes after creating one", icon: "🎫" }, + { label: "Server Health", prompt: "Create a server monitoring dashboard that auto-refreshes every 30 seconds showing CPU, memory, and latency", icon: "🖥️" }, +]; + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface ChatMessage { + role: "user" | "assistant"; + content: string; + text?: string; + hasCode: boolean; + llmTools?: LLMToolCall[]; + runtimeTools?: ToolCallEntry[]; +} + +// ── Component ─────────────────────────────────────────────────────────────── + +export default function LLMTestPage() { + const [input, setInput] = useState(""); + const [dashboardCode, setDashboardCode] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + const [showSource, setShowSource] = useState(false); + const [startTime, setStartTime] = useState(null); + const [elapsed, setElapsed] = useState(null); + const [toolCalls, setToolCalls] = useState([]); + const [llmTools, setLlmTools] = useState([]); + const [conversation, setConversation] = useState([]); + const [streamingText, setStreamingText] = useState(""); + const abortRef = useRef(null); + const responseRef = useRef(""); + const inputRef = useRef(null); + const chatEndRef = useRef(null); + const [transport, setTransport] = useState(null); + const mcpRef = useRef(null); + + // MCP setup + useEffect(() => { + let cancelled = false; + createMcpTransport({ url: "/api/mcp" }).then((mcp) => { + if (cancelled) { mcp.disconnect(); return; } + mcpRef.current = mcp; + setTransport(wrapTransport(mcp.transport)); + }).catch((err) => console.error("[mcp] Failed:", err)); + return () => { cancelled = true; mcpRef.current?.disconnect(); }; + }, []); + + useEffect(() => { + toolCallListener = (calls) => { + setToolCalls([...calls]); + // Update the latest assistant message with runtime tool calls + setConversation((prev) => { + if (prev.length === 0) return prev; + const last = prev[prev.length - 1]; + if (last.role !== "assistant") return prev; + const updated = { ...last, runtimeTools: [...calls] }; + return [...prev.slice(0, -1), updated]; + }); + }; + llmToolCallListener = (calls) => setLlmTools([...calls]); + return () => { toolCallListener = null; llmToolCallListener = null; }; + }, []); + + useEffect(() => { + const p = new URLSearchParams(window.location.search).get("code"); + if (p) { try { setDashboardCode(atob(p)); } catch { /* */ } } + }, []); + + useEffect(() => { inputRef.current?.focus(); }, [isStreaming]); + useEffect(() => { + if (!isStreaming || !startTime) return; + const iv = setInterval(() => setElapsed(Date.now() - startTime), 100); + return () => clearInterval(iv); + }, [isStreaming, startTime]); + useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [conversation]); + + const send = useCallback( + async (text: string) => { + if (!text.trim() || isStreaming) return; + const trimmed = text.trim(); + setInput(""); + setIsStreaming(true); + setStartTime(null); + setElapsed(null); + activeCalls.length = 0; + setToolCalls([]); + responseRef.current = ""; + setStreamingText(""); + let streamStartTime: number | null = null; + + const userMsg: ChatMessage = { role: "user", content: trimmed, hasCode: false }; + const updated = [...conversation, userMsg]; + setConversation(updated); + const existingCode = dashboardCode; + + // Build API messages — append current dashboard state to the latest user message + const apiMessages = updated.map((m, i) => { + if (m.role === "assistant" && m.llmTools?.length) { + const toolSummary = m.llmTools.map(tc => { + const resultSnippet = tc.result ? ` → ${tc.result.slice(0, 500)}` : " → completed"; + return `[Tool: ${tc.name}${resultSnippet}]`; + }).join("\n"); + return { role: m.role, content: `${toolSummary}\n\n${m.content}` }; + } + // Append current dashboard state to the LAST user message + if (m.role === "user" && i === updated.length - 1 && existingCode) { + return { + role: m.role, + content: `${m.content}\n\n\n${existingCode}\n`, + }; + } + return { role: m.role, content: m.content }; + }); + const controller = new AbortController(); + abortRef.current = controller; + + await streamChat( + apiMessages, + (chunk) => { + responseRef.current += chunk; + const raw = responseRef.current; + // Update streaming text in real-time + const liveText = extractText(raw); + setStreamingText(liveText || ""); + // During streaming: feed raw response to Renderer directly. + // The Renderer's parser handles stripFences + dedup internally. + // For edits: concat existing + raw so parser sees all statements. + if (existingCode) { + setDashboardCode(existingCode + "\n" + raw); + } else { + setDashboardCode(raw); + } + }, + () => { + setIsStreaming(false); + setStreamingText(""); + if (streamStartTime) setElapsed(Date.now() - streamStartTime); + + const raw = responseRef.current; + const hasCode = responseHasCode(raw); + const pureCode = isPureCode(raw); + const text = pureCode ? undefined : extractText(raw) || undefined; + + const assistantMsg: ChatMessage = { + role: "assistant", + content: raw, + text, + hasCode, + llmTools: llmActiveCalls.length > 0 ? [...llmActiveCalls] : undefined, + runtimeTools: activeCalls.length > 0 ? [...activeCalls] : undefined, + }; + setConversation((prev) => [...prev, assistantMsg]); + + if (hasCode) { + const newCode = pureCode ? raw : extractCodeOnly(raw); + if (newCode) { + // Use mergeStatements for clean dedup + GC instead of string concat + const merged = existingCode ? mergeStatements(existingCode, newCode) : newCode; + setDashboardCode(merged); + } + } + }, + controller.signal, + () => { streamStartTime = Date.now(); setStartTime(streamStartTime); }, + ); + }, + [isStreaming, conversation, dashboardCode], + ); + + const clear = () => { + abortRef.current?.abort(); + setDashboardCode(null); + setConversation([]); + setIsStreaming(false); + setStartTime(null); + setElapsed(null); + responseRef.current = ""; + }; + + const pendingTools = toolCalls.filter((t) => t.status === "pending"); + const canSend = input.trim().length > 0 && !isStreaming; + const hasDashboard = dashboardCode !== null; + + return ( +
+ {/* Top bar */} +
+

openui-lang

+ Live Demo +
+ {["Live Data", "Streaming", "Conversational"].map((label, i) => ( + {label} + ))} +
+ {(hasDashboard || conversation.length > 0) && ( + + )} +
+ + {/* Main layout */} +
+ {/* Left: Dashboard artifact */} +
+ {/* Starters */} + {conversation.length === 0 && !hasDashboard && ( +
+
+
+
Build a dashboard
+
Pick a starter or type your own prompt
+
+
+ {STARTERS.map((s) => ( + + ))} +
+
+ )} + + {/* No floating tool pills — they're now in the chat panel */} + + {/* Meta + source toggle */} + {hasDashboard && !isStreaming && ( +
+ {elapsed && {(elapsed / 1000).toFixed(1)}s} + +
+ )} + + {hasDashboard && showSource && ( +
{dashboardCode}
+ )} + + {/* Dashboard renderer */} + {hasDashboard && ( +
+ + + } + onAction={(event) => { + console.log("[action]", event); + if (event.type === "continue_conversation") { + const contextText = typeof event.params?.context === "string" + ? event.params.context + : ""; + const text = contextText || event.humanFriendlyMessage || ""; + if (text) send(text); + } + }} + /> + +
+ )} + + {/* Streaming placeholder */} + {isStreaming && !hasDashboard && ( +
+
Generating dashboard...
+ {elapsed &&
{(elapsed / 1000).toFixed(1)}s
} +
+ )} +
+ + {/* Right: Conversation panel */} + {(conversation.length > 0 || isStreaming) && ( +
+
+ Conversation +
+ + {/* Messages */} +
+ {conversation.map((msg, i) => ( +
+ {msg.role === "user" ? ( +
{msg.content}
+ ) : ( +
+ {/* LLM tool calls — shown as distinct step before text */} + {msg.llmTools && msg.llmTools.length > 0 && ( +
+
+ 🔍 Queried data +
+
+ {msg.llmTools.map((tc, j) => ( + ✓ {tc.name} + ))} +
+
+ )} + {/* Text response — rendered as markdown */} + {msg.text && ( +
+ +
+ )} + {/* Dashboard updated badge */} + {msg.hasCode && ( +
✓ dashboard updated
+ )} + {/* Runtime tool calls — live data fetched */} + {msg.runtimeTools && msg.runtimeTools.length > 0 && ( +
+
+ Live data fetched +
+
+ {msg.runtimeTools.map((tc, j) => ( + {tc.status === "done" ? "✓" : tc.status === "error" ? "✗" : "⏳"} {tc.tool} + ))} +
+
+ )} + {/* Empty response indicator */} + {!msg.text && !msg.hasCode && !msg.llmTools?.length && ( +
+ (empty response) +
+ )} +
+ )} +
+ ))} + {/* Streaming indicator */} + {isStreaming && ( +
+ {/* Show live LLM tool calls while streaming */} + {llmTools.length > 0 && llmTools.some(t => t.status === "calling") && ( +
+ 🔍 + Querying {llmTools.filter(t => t.status === "calling").length} tool{llmTools.filter(t => t.status === "calling").length > 1 ? "s" : ""}... +
+ )} + {/* Live streaming text */} + {streamingText ? ( +
+ +
+ ) : ( +
+ {llmTools.length > 0 && llmTools.some(t => t.status === "calling") + ? "fetching data before generating..." + : elapsed + ? `${(elapsed / 1000).toFixed(1)}s — ${responseHasCode(responseRef.current) ? "writing code..." : "thinking..."}` + : "thinking..."} +
+ )} + {/* Dashboard update indicator during streaming */} + {responseHasCode(responseRef.current) && ( +
⟳ updating dashboard...
+ )} + {/* Live runtime tool calls during streaming */} + {toolCalls.length > 0 && ( +
+
+ + {pendingTools.length > 0 ? "Fetching" : "Loaded"} + + {toolCalls.map((tc, j) => ( + {tc.status === "pending" ? "⏳" : "✓"} {tc.tool} + ))} +
+
+ )} +
+ )} +
+
+ + {/* Input at bottom of conversation panel */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && canSend) send(input); }} + placeholder={hasDashboard ? "Ask or edit..." : "Describe a dashboard..."} + disabled={isStreaming} + style={{ + flex: 1, padding: "8px 12px", border: "1px solid #d1d5db", + borderRadius: "8px", fontSize: "13px", outline: "none", + }} + /> + +
+
+
+ )} +
+ + {/* Input bar when no conversation yet (centered) */} + {conversation.length === 0 && !isStreaming && !hasDashboard && ( +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && canSend) send(input); }} + placeholder="Describe a dashboard..." + style={{ + flex: 1, padding: "14px 18px", border: "1px solid #d1d5db", + borderRadius: "12px", fontSize: "14px", outline: "none", + boxShadow: "0 2px 8px rgba(0,0,0,0.08)", + }} + /> + +
+
+ )} +
+ ); +} diff --git a/examples/openui-dashboard/src/app/globals.css b/examples/openui-dashboard/src/app/globals.css new file mode 100644 index 000000000..975bcec94 --- /dev/null +++ b/examples/openui-dashboard/src/app/globals.css @@ -0,0 +1,6 @@ +@import "tailwindcss"; + +@keyframes openui-loading-bar { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/examples/openui-dashboard/src/app/layout.tsx b/examples/openui-dashboard/src/app/layout.tsx new file mode 100644 index 000000000..7e44b0451 --- /dev/null +++ b/examples/openui-dashboard/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-dashboard/src/app/page.tsx b/examples/openui-dashboard/src/app/page.tsx new file mode 100644 index 000000000..2faa5d843 --- /dev/null +++ b/examples/openui-dashboard/src/app/page.tsx @@ -0,0 +1 @@ +export { default } from "./dashboard/page"; diff --git a/examples/openui-dashboard/src/generated/component-spec.json b/examples/openui-dashboard/src/generated/component-spec.json new file mode 100644 index 000000000..93dfb3a9f --- /dev/null +++ b/examples/openui-dashboard/src/generated/component-spec.json @@ -0,0 +1,372 @@ +{ + "root": "Stack", + "components": { + "Card": { + "signature": "Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | Tabs | Carousel | Stack)[], variant?: \"card\" | \"sunk\" | \"clear\", direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", + "description": "Styled container. variant: \"card\" (default, elevated) | \"sunk\" (recessed) | \"clear\" (transparent). Always full width. Accepts all Stack flex params (default: direction \"column\"). Cards flex to share space in row/wrap layouts." + }, + "CardHeader": { + "signature": "CardHeader(title?: string, subtitle?: string)", + "description": "Header with optional title and subtitle" + }, + "TextContent": { + "signature": "TextContent(text: string, size?: \"small\" | \"default\" | \"large\" | \"small-heavy\" | \"large-heavy\")", + "description": "Text block. Supports markdown. Optional size: \"small\" | \"default\" | \"large\" | \"small-heavy\" | \"large-heavy\"." + }, + "MarkDownRenderer": { + "signature": "MarkDownRenderer(textMarkdown: string, variant?: \"clear\" | \"card\" | \"sunk\")", + "description": "Renders markdown text with optional container variant" + }, + "Callout": { + "signature": "Callout(variant: \"info\" | \"warning\" | \"error\" | \"success\" | \"neutral\", title: string, description: string, visible?: $binding)", + "description": "Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false." + }, + "TextCallout": { + "signature": "TextCallout(variant?: \"neutral\" | \"info\" | \"warning\" | \"success\" | \"danger\", title?: string, description?: string)", + "description": "Text callout with variant, title, and description" + }, + "Image": { + "signature": "Image(alt: string, src?: string)", + "description": "Image with alt text and optional URL" + }, + "ImageBlock": { + "signature": "ImageBlock(src: string, alt?: string)", + "description": "Image block with loading state" + }, + "ImageGallery": { + "signature": "ImageGallery(images: {src: string, alt?: string, details?: string}[])", + "description": "Gallery grid of images with modal preview" + }, + "CodeBlock": { + "signature": "CodeBlock(language: string, codeString: string)", + "description": "Syntax-highlighted code block" + }, + "Table": { + "signature": "Table(columns: Col[])", + "description": "Data table — column-oriented. Each Col holds its own data array." + }, + "Col": { + "signature": "Col(label: string, data, type?: \"string\" | \"number\" | \"action\")", + "description": "Column definition — holds label + data array" + }, + "BarChart": { + "signature": "BarChart(labels: string[], series: Series[], variant?: \"grouped\" | \"stacked\", xLabel?: string, yLabel?: string)", + "description": "Vertical bars; use for comparing values across categories with one or more series" + }, + "LineChart": { + "signature": "LineChart(labels: string[], series: Series[], variant?: \"linear\" | \"natural\" | \"step\", xLabel?: string, yLabel?: string)", + "description": "Lines over categories; use for trends and continuous data over time" + }, + "AreaChart": { + "signature": "AreaChart(labels: string[], series: Series[], variant?: \"linear\" | \"natural\" | \"step\", xLabel?: string, yLabel?: string)", + "description": "Filled area under lines; use for cumulative totals or volume trends over time" + }, + "RadarChart": { + "signature": "RadarChart(labels: string[], series: Series[])", + "description": "Spider/web chart; use for comparing multiple variables across one or more entities" + }, + "HorizontalBarChart": { + "signature": "HorizontalBarChart(labels: string[], series: Series[], variant?: \"grouped\" | \"stacked\", xLabel?: string, yLabel?: string)", + "description": "Horizontal bars; prefer when category labels are long or for ranked lists" + }, + "Series": { + "signature": "Series(category: string, values: number[])", + "description": "One data series" + }, + "PieChart": { + "signature": "PieChart(labels: string[], values: number[], variant?: \"pie\" | \"donut\")", + "description": "Circular slices; use plucked arrays: PieChart(data.categories, data.values)" + }, + "RadialChart": { + "signature": "RadialChart(labels: string[], values: number[])", + "description": "Radial bars; use plucked arrays: RadialChart(data.categories, data.values)" + }, + "SingleStackedBarChart": { + "signature": "SingleStackedBarChart(labels: string[], values: number[])", + "description": "Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values)" + }, + "Slice": { + "signature": "Slice(category: string, value: number)", + "description": "One slice with label and numeric value" + }, + "ScatterChart": { + "signature": "ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string)", + "description": "X/Y scatter plot; use for correlations, distributions, and clustering" + }, + "ScatterSeries": { + "signature": "ScatterSeries(name: string, points: Point[])", + "description": "Named dataset" + }, + "Point": { + "signature": "Point(x: number, y: number, z?: number)", + "description": "Data point with numeric coordinates" + }, + "Form": { + "signature": "Form(name: string, buttons: Buttons, fields)", + "description": "Form container with fields and explicit action buttons" + }, + "FormControl": { + "signature": "FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string)", + "description": "Field with label, input component, and optional hint text" + }, + "Label": { + "signature": "Label(text: string)", + "description": "Text label" + }, + "Input": { + "signature": "Input(name: string, value?: $binding, 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})", + "description": "" + }, + "TextArea": { + "signature": "TextArea(name: string, value?: $binding, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})", + "description": "" + }, + "Select": { + "signature": "Select(name: string, value?: $binding, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})", + "description": "" + }, + "SelectItem": { + "signature": "SelectItem(value: string, label: string)", + "description": "Option for Select" + }, + "DatePicker": { + "signature": "DatePicker(name: string, value?, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})", + "description": "" + }, + "Slider": { + "signature": "Slider(name: string, value?: $binding, 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})", + "description": "Numeric slider input; supports continuous and discrete (stepped) variants" + }, + "CheckBoxGroup": { + "signature": "CheckBoxGroup(name: string, value?, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})", + "description": "" + }, + "CheckBoxItem": { + "signature": "CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean)", + "description": "" + }, + "RadioGroup": { + "signature": "RadioGroup(name: string, value?: $binding, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string})", + "description": "" + }, + "RadioItem": { + "signature": "RadioItem(label: string, description: string, value: string)", + "description": "" + }, + "SwitchGroup": { + "signature": "SwitchGroup(name: string, value?, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\")", + "description": "Group of switch toggles" + }, + "SwitchItem": { + "signature": "SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean)", + "description": "Individual switch toggle" + }, + "Button": { + "signature": "Button(label: string, action?: ActionExpression, variant?: \"primary\" | \"secondary\" | \"tertiary\", type?: \"normal\" | \"destructive\", size?: \"extra-small\" | \"small\" | \"medium\" | \"large\")", + "description": "Clickable button" + }, + "Buttons": { + "signature": "Buttons(buttons: Button[], direction?: \"row\" | \"column\")", + "description": "Group of Button components. direction: \"row\" (default) | \"column\"." + }, + "Stack": { + "signature": "Stack([children], direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", + "description": "Flex container. direction: \"row\"|\"column\" (default \"column\"). gap: \"none\"|\"xs\"|\"s\"|\"m\"|\"l\"|\"xl\"|\"2xl\" (default \"m\"). align: \"start\"|\"center\"|\"end\"|\"stretch\"|\"baseline\". justify: \"start\"|\"center\"|\"end\"|\"between\"|\"around\"|\"evenly\"." + }, + "Tabs": { + "signature": "Tabs(items: TabItem[])", + "description": "Tabbed container" + }, + "TabItem": { + "signature": "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)[])", + "description": "value is unique id, trigger is tab label, content is array of components" + }, + "Accordion": { + "signature": "Accordion(items: AccordionItem[])", + "description": "Collapsible sections" + }, + "AccordionItem": { + "signature": "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)[])", + "description": "value is unique id, trigger is section title" + }, + "Steps": { + "signature": "Steps(items: StepsItem[])", + "description": "Step-by-step guide" + }, + "StepsItem": { + "signature": "StepsItem(title: string, details: string)", + "description": "title and details text for one step" + }, + "Carousel": { + "signature": "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\")", + "description": "Horizontal scrollable carousel" + }, + "Separator": { + "signature": "Separator(orientation?: \"horizontal\" | \"vertical\", decorative?: boolean)", + "description": "Visual divider between content sections" + }, + "TagBlock": { + "signature": "TagBlock(tags: string[])", + "description": "tags is an array of strings" + }, + "Tag": { + "signature": "Tag(text: string, icon?: string, size?: \"sm\" | \"md\" | \"lg\", variant?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\")", + "description": "Styled tag/badge with optional icon and variant" + }, + "Modal": { + "signature": "Modal(title: string, open?: $binding, 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)[], size?: \"sm\" | \"md\" | \"lg\")", + "description": "Modal dialog. open is a reactive $boolean binding — set to true to open, X/Escape/backdrop auto-closes. Put Form with buttons inside children." + } + }, + "componentGroups": [ + { + "name": "Layout", + "components": [ + "Stack", + "Tabs", + "TabItem", + "Accordion", + "AccordionItem", + "Steps", + "StepsItem", + "Carousel", + "Separator", + "Modal" + ], + "notes": [ + "- For grid-like layouts, use Stack with direction \"row\" and wrap set to true.", + "- Prefer justify \"start\" (or omit justify) with wrap=true for stable columns instead of uneven gutters.", + "- Use nested Stacks when you need explicit rows/sections.", + "- Show/hide sections: $editId != \"\" ? Card([editForm]) : null", + "- Modal: Modal(\"Title\", $showModal, [content]) — $showModal is boolean, X/Escape auto-closes. Put Form with its own buttons inside children.", + "- Use Tabs for alternative views (chart types, data sections) — no $variable needed", + "- Shared filter across Tabs: same $days binding in Query args works across all TabItems" + ] + }, + { + "name": "Content", + "components": [ + "Card", + "CardHeader", + "TextContent", + "MarkDownRenderer", + "Callout", + "TextCallout", + "Image", + "ImageBlock", + "ImageGallery", + "CodeBlock" + ], + "notes": [ + "- Use Cards to group related KPIs or sections. Stack with direction \"row\" for side-by-side layouts.", + "- Success toast: Callout(\"success\", \"Saved\", \"Done.\", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == \"error\" ? Callout(\"error\", \"Failed\", result.error) : null", + "- KPI card: Card([TextContent(\"Label\", \"small\"), TextContent(\"\" + @Count(@Filter(data.rows, \"field\", \"==\", \"value\")), \"large-heavy\")])" + ] + }, + { + "name": "Tables", + "components": [ + "Table", + "Col" + ], + "notes": [ + "- Table is COLUMN-oriented: Table([Col(\"Label\", dataArray), Col(\"Count\", countArray, \"number\")]). Use array pluck for data: data.rows.fieldName", + "- Col data can be component arrays for styled cells: Col(\"Status\", @Each(data.rows, \"item\", Tag(item.status, null, \"sm\", item.status == \"open\" ? \"success\" : \"danger\")))", + "- Row actions: Col(\"Actions\", @Each(data.rows, \"t\", Button(\"Edit\", Action([@Set($showEdit, true), @Set($editId, t.id)]))))", + "- Sortable: sorted = @Sort(data.rows, $sortField, \"desc\"). Bind $sortField to Select. Use sorted.fieldName for Col data", + "- Searchable: filtered = @Filter(data.rows, \"title\", \"contains\", $search). Bind $search to Input", + "- Chain sort + filter: filtered = @Filter(...) then sorted = @Sort(filtered, ...) — use sorted for both Table and Charts", + "- Empty state: @Count(data.rows) > 0 ? Table([...]) : TextContent(\"No data yet\")" + ] + }, + { + "name": "Charts (2D)", + "components": [ + "BarChart", + "LineChart", + "AreaChart", + "RadarChart", + "HorizontalBarChart", + "Series" + ], + "notes": [ + "- Charts accept column arrays: LineChart(labels, [Series(\"Name\", values)]). Use array pluck: LineChart(data.rows.day, [Series(\"Views\", data.rows.views)])", + "- Use Cards to wrap charts with CardHeader for titled sections", + "- Chart + Table from same source: use @Sort or @Filter result for both LineChart and Table Col data", + "- Multiple chart views: use Tabs — Tabs([TabItem(\"line\", \"Line\", [LineChart(...)]), TabItem(\"bar\", \"Bar\", [BarChart(...)])])" + ] + }, + { + "name": "Charts (1D)", + "components": [ + "PieChart", + "RadialChart", + "SingleStackedBarChart", + "Slice" + ], + "notes": [ + "- PieChart and BarChart need NUMBERS, not objects. For list data, use @Count(@Filter(...)) to aggregate:", + "- PieChart from list: `PieChart([\"Low\", \"Med\", \"High\"], [@Count(@Filter(data.rows, \"priority\", \"==\", \"low\")), @Count(@Filter(data.rows, \"priority\", \"==\", \"medium\")), @Count(@Filter(data.rows, \"priority\", \"==\", \"high\"))], \"donut\")`", + "- KPI from count: `TextContent(\"\" + @Count(@Filter(data.rows, \"status\", \"==\", \"open\")), \"large-heavy\")`" + ] + }, + { + "name": "Charts (Scatter)", + "components": [ + "ScatterChart", + "ScatterSeries", + "Point" + ] + }, + { + "name": "Forms", + "components": [ + "Form", + "FormControl", + "Label", + "Input", + "TextArea", + "Select", + "SelectItem", + "DatePicker", + "Slider", + "CheckBoxGroup", + "CheckBoxItem", + "RadioGroup", + "RadioItem", + "SwitchGroup", + "SwitchItem" + ], + "notes": [ + "- For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming.", + "- NEVER nest Form inside Form — each Form should be a standalone container.", + "- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.", + "- rules is an optional object: {required: true, email: true, minLength: 8, maxLength: 100}", + "- Available rules: required, email, min, max, minLength, maxLength, pattern, url, numeric", + "- The renderer shows error messages automatically — do NOT generate error text in the UI", + "- Conditional fields: $country == \"US\" ? stateField : $country == \"UK\" ? postcodeField : addressField", + "- Edit form in Modal: Modal(\"Edit\", $showEdit, [Form(\"edit\", Buttons([saveBtn, cancelBtn]), [fields...])]). Save button should include @Set($showEdit, false) to close modal." + ] + }, + { + "name": "Buttons", + "components": [ + "Button", + "Buttons" + ], + "notes": [ + "- Toggle in @Each: @Each(rows, \"t\", Button(t.status == \"open\" ? \"Close\" : \"Reopen\", Action([...])))" + ] + }, + { + "name": "Data Display", + "components": [ + "TagBlock", + "Tag" + ], + "notes": [ + "- Color-mapped Tag: Tag(value, null, \"sm\", value == \"high\" ? \"danger\" : value == \"medium\" ? \"warning\" : \"neutral\")" + ] + } + ] +} diff --git a/examples/openui-dashboard/src/hooks/use-system-theme.tsx b/examples/openui-dashboard/src/hooks/use-system-theme.tsx new file mode 100644 index 000000000..2a95a534d --- /dev/null +++ b/examples/openui-dashboard/src/hooks/use-system-theme.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, useContext, useEffect, 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); + + useEffect(() => { + 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); + }, []); + + useEffect(() => { + 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-dashboard/src/library.ts b/examples/openui-dashboard/src/library.ts new file mode 100644 index 000000000..121d5fd5a --- /dev/null +++ b/examples/openui-dashboard/src/library.ts @@ -0,0 +1 @@ +export { openuiLibrary as library } from "@openuidev/react-ui/genui-lib"; diff --git a/examples/openui-dashboard/src/prompt-config.ts b/examples/openui-dashboard/src/prompt-config.ts new file mode 100644 index 000000000..fe3cdb9ad --- /dev/null +++ b/examples/openui-dashboard/src/prompt-config.ts @@ -0,0 +1,84 @@ +// Prompt configuration for the chat API route. +// This file has NO React dependencies — safe to import from server-only routes. +// +// Component specs are generated by `pnpm generate:prompt` and imported from the +// generated JSON file. Tools are injected at request time from MCP. + +import type { PromptSpec } from "@openuidev/react-lang"; +import componentSpec from "./generated/component-spec.json"; + +export const promptSpec: PromptSpec = { + ...componentSpec as PromptSpec, + editMode: true, + inlineMode: true, + toolExamples: [ + `Example — PostHog Dashboard (PREFERRED pattern): +root = Stack([header, controls, kpiRow, chart, topEvents]) +header = CardHeader("Analytics Dashboard", "Live data from PostHog") +$days = "7" +controls = Stack([filterRow, refreshBtn], "row", "m", "end", "between") +filterRow = FormControl("Date Range", Select("days", $days, [r7, r14, r30])) +refreshBtn = Button("Refresh", Action([@Run(daily), @Run(topEventsData)]), "secondary") +r7 = SelectItem("7", "Last 7 days") +r14 = SelectItem("14", "Last 14 days") +r30 = SelectItem("30", "Last 30 days") +daily = Query("posthog_query", {sql: "SELECT toDate(timestamp) as day, count() as events, count(distinct distinct_id) as users FROM events WHERE event = '$pageview' AND timestamp > now() - interval " + $days + " day GROUP BY day ORDER BY day"}, {columns: [], rows: []}) +kpiRow = Stack([kpi1, kpi2], "row") +kpi1 = Card([TextContent("Total Pageviews", "small"), TextContent("" + @Sum(daily.rows.events), "large-heavy")]) +kpi2 = Card([TextContent("Unique Users", "small"), TextContent("" + @Sum(daily.rows.users), "large-heavy")]) +chart = LineChart(daily.rows.day, [Series("Pageviews", daily.rows.events), Series("Users", daily.rows.users)]) +topEventsData = Query("posthog_query", {sql: "SELECT event, count() as count FROM events WHERE timestamp > now() - interval " + $days + " day GROUP BY event ORDER BY count DESC LIMIT 10"}, {columns: [], rows: []}) +topEvents = Table([Col("Event", topEventsData.rows.event), Col("Count", topEventsData.rows.count, "number")])`, + `Example — Server Health (SPECIALIZED tool, not PostHog SQL): +root = Stack([header, kpiRow, trendCard]) +header = CardHeader("Server Monitoring Dashboard", "Auto-refreshes every 30 seconds") +health = Query("get_server_health", {}, {cpu: 0, memory: 0, latencyP95: 0, errorRate: 0, timeseries: []}, 30) +kpiRow = Stack([cpuCard, memoryCard, latencyCard, errorRateCard], "row", "m", "stretch", "start", true) +cpuCard = Card([TextContent("CPU", "small"), TextContent("" + @Round(health.cpu, 1) + "%", "large-heavy")]) +memoryCard = Card([TextContent("Memory", "small"), TextContent("" + @Round(health.memory, 1) + "%", "large-heavy")]) +latencyCard = Card([TextContent("Latency", "small"), TextContent("" + @Round(health.latencyP95, 0) + " ms", "large-heavy")]) +errorRateCard = Card([TextContent("Error Rate", "small"), TextContent("" + @Round(health.errorRate, 2) + "%", "large-heavy")]) +trendCard = Card([CardHeader("24-Hour Trend"), LineChart(health.timeseries.time, [Series("CPU", health.timeseries.cpu), Series("Memory", health.timeseries.memory), Series("Latency", health.timeseries.latencyP95)])])`, + `Example — CRUD form with edit modal: +$title = "" +$priority = "medium" +$showEdit = false +$editId = "" +createResult = Mutation("create_ticket", {title: $title, priority: $priority}) +tickets = Query("list_tickets", {}, {rows: []}) +submitBtn = Button("Create", Action([@Run(createResult), @Run(tickets), @Set($createSuccess, true), @Reset($title, $priority)])) +form = Form("create", submitBtn, [FormControl("Title", Input("title", $title, "Description", "text", {required: true})), FormControl("Priority", Select("priority", $priority, [SelectItem("low", "Low"), SelectItem("medium", "Medium"), SelectItem("high", "High")]))]) +$createSuccess = false +statusMsg = Callout("success", "Created", "Ticket added.", $createSuccess) +errorMsg = createResult.status == "error" ? Callout("error", "Failed", createResult.error) : null +tbl = Table([Col("Title", tickets.rows.title), Col("Priority", @Each(tickets.rows, "t", Tag(t.priority, null, "sm", t.priority == "high" ? "danger" : "neutral"))), Col("Actions", @Each(tickets.rows, "t", Button("Edit", Action([@Set($showEdit, true), @Set($editId, t.id)]))))]) +updateResult = Mutation("update_ticket", {id: $editId, title: $editTitle, priority: $editPriority}) +editBtns = Buttons([Button("Save", Action([@Run(updateResult), @Run(tickets), @Set($showEdit, false)]), "primary"), Button("Cancel", Action([@Set($showEdit, false)]), "secondary")]) +editForm = Form("edit", editBtns, [FormControl("Title", Input("editTitle", $editTitle, "Title", "text", {required: true})), FormControl("Priority", Select("editPriority", $editPriority, [SelectItem("low", "Low"), SelectItem("medium", "Medium"), SelectItem("high", "High")]))]) +editModal = Modal("Edit Ticket", $showEdit, [editForm]) +root = Stack([CardHeader("Tickets"), form, statusMsg, errorMsg, tbl, editModal])`, + ], + additionalRules: [ + "For analytics, prefer posthog_query with HogQL SQL", + "For server monitoring / CPU / memory / latency requests, use get_server_health instead of SQL", + 'dateRange values are numeric strings: "7", "14", "30", "90". Do not use suffixes like "d" or "h".', + "For dynamic date ranges, concatenate $days into the SQL: \"... interval \" + $days + \" day ...\"", + "Prefer including a date range filter by default for analytics dashboards", + ], + preamble: `You are an AI assistant that builds dashboards using openui-lang, a declarative UI language. + +## PostHog HogQL Reference + +HogQL is SQL for PostHog. Key tables: +- \`events\` — all tracked events. Columns: event (string), timestamp (datetime), distinct_id (string), properties (object) +- Common event types: "$pageview", "$autocapture", "$pageleave", "$screen", custom events + +Useful patterns: +- Daily counts: \`SELECT toDate(timestamp) as day, count() as cnt FROM events WHERE event = '$pageview' AND timestamp > now() - interval 7 day GROUP BY day ORDER BY day\` +- Unique users: \`count(distinct distinct_id)\` +- Top events: \`SELECT event, count() as cnt FROM events WHERE timestamp > now() - interval 7 day GROUP BY event ORDER BY cnt DESC LIMIT 10\` +- Filter by days: use \`now() - interval N day\` where N comes from $dateRange binding via string concat + +IMPORTANT for dynamic date range: Build the SQL string using concatenation with $dateRange: +\`{sql: "SELECT toDate(timestamp) as day, count() as cnt FROM events WHERE event = '$pageview' AND timestamp > now() - interval " + $days + " day GROUP BY day ORDER BY day"}\``, +}; diff --git a/examples/openui-dashboard/src/tools.ts b/examples/openui-dashboard/src/tools.ts new file mode 100644 index 000000000..951f67ebc --- /dev/null +++ b/examples/openui-dashboard/src/tools.ts @@ -0,0 +1,386 @@ +/** + * Shared tool registry — single source of truth for all tools. + * + * Consumed by: + * - /api/mcp/route.ts (MCP server, uses Zod inputSchema directly) + * - /api/chat/route.ts (OpenAI function-calling, converts to JSON Schema) + */ +import { z } from "zod"; + +// ── PostHog config ─────────────────────────────────────────────────────────── + +const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY ?? ""; +const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID ?? ""; +const POSTHOG_HOST = process.env.POSTHOG_HOST ?? "https://us.posthog.com"; + +// ── PostHog live query ─────────────────────────────────────────────────────── + +async function executePostHogQuery(sql: string) { + if (!POSTHOG_API_KEY || !POSTHOG_PROJECT_ID) { + return { error: "POSTHOG_API_KEY and POSTHOG_PROJECT_ID env vars required" }; + } + + let res: Response | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + res = await fetch( + `${POSTHOG_HOST}/api/projects/${POSTHOG_PROJECT_ID}/query/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${POSTHOG_API_KEY}`, + }, + body: JSON.stringify({ query: { kind: "HogQLQuery", query: sql } }), + }, + ); + + if (!res.ok) { + const err = await res.text(); + if (err.includes("504") && attempt < 2) continue; + console.error("[tools/posthog] API error:", err.substring(0, 300)); + return { error: `PostHog API error ${res.status}` }; + } + break; + } + + const data = await res?.json(); + const columns: string[] = data.columns ?? []; + const rawResults: unknown[][] = data.results ?? []; + const rows = rawResults.map((row: unknown[]) => { + const obj: Record = {}; + for (let i = 0; i < columns.length; i++) obj[columns[i]] = row[i]; + return obj; + }); + + return { columns, rows }; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function dateOffset(daysAgo: number): string { + const d = new Date(); + d.setDate(d.getDate() + daysAgo); + return d.toISOString().slice(0, 10); +} + +function generateTimeseries(count: number, fn: (index: number) => T): T[] { + return Array.from({ length: count }, (_, i) => fn(i)); +} + +// ── Mock data generators ───────────────────────────────────────────────────── + +function getUsageMetrics(args: Record) { + const days = Number(args.dateRange ?? args.days ?? 14); + return { + totalEvents: 48200 + Math.floor(days * 120), + totalUsers: 3200 + Math.floor(days * 40), + totalErrors: 142 + Math.floor(days * 3), + totalCost: 1250.5 + days * 15, + data: generateTimeseries(days, (i) => ({ + day: dateOffset(-days + i), + events: 2800 + Math.floor(Math.random() * 1200), + users: 180 + Math.floor(Math.random() * 80), + errors: 5 + Math.floor(Math.random() * 15), + cost: 70 + Math.random() * 30, + })), + }; +} + +function getTopEndpoints(args: Record) { + const limit = Number(args.limit ?? 10); + const paths = ["/api/users", "/api/events", "/api/auth", "/api/data", "/api/search", "/api/upload", "/api/export", "/api/notify", "/api/billing", "/api/health"]; + return { + endpoints: Array.from({ length: limit }, (_, i) => ({ + path: paths[i % 10], + requests: 12000 - i * 900 + Math.floor(Math.random() * 400), + avgLatency: 45 + i * 12 + Math.floor(Math.random() * 20), + errorRate: Math.round((0.5 + i * 0.3 + Math.random() * 0.5) * 100) / 100, + })), + }; +} + +function getResourceBreakdown() { + return { + resources: [ + { name: "API", events: 22000, users: 1800, cost: 450 }, + { name: "Web App", events: 18000, users: 2400, cost: 380 }, + { name: "Mobile", events: 8200, users: 900, cost: 220 }, + { name: "Webhook", events: 3500, users: 120, cost: 95 }, + ], + }; +} + +function getErrorBreakdown() { + return { + errors: [ + { category: "TimeoutError", count: 45 }, + { category: "AuthError", count: 32 }, + { category: "RateLimitError", count: 28 }, + { category: "ValidationError", count: 22 }, + { category: "NotFoundError", count: 15 }, + ], + }; +} + +function getServerHealth() { + return { + cpu: 42 + Math.floor(Math.random() * 20), + memory: 68 + Math.floor(Math.random() * 15), + latencyP95: 120 + Math.floor(Math.random() * 60), + errorRate: Math.round((1.2 + Math.random() * 0.8) * 100) / 100, + timeseries: generateTimeseries(24, (i) => ({ + time: `${String(i).padStart(2, "0")}:00`, + cpu: 35 + Math.floor(Math.random() * 30), + memory: 60 + Math.floor(Math.random() * 20), + latencyP95: 80 + Math.floor(Math.random() * 80), + })), + }; +} + +function getCustomerSegments() { + return { + segments: [ + { segment: "Enterprise", customers: 120, revenue: 450000 }, + { segment: "Pro", customers: 850, revenue: 280000 }, + { segment: "Starter", customers: 3200, revenue: 96000 }, + { segment: "Free", customers: 12000, revenue: 0 }, + ], + }; +} + +function getSalesSummary(args: Record) { + const days = Number(args.dateRange ?? args.days ?? 14); + return { + revenue: 842000, + orders: 3420, + avgOrderValue: 246, + trend: generateTimeseries(days, (i) => ({ + period: dateOffset(-days + i), + revenue: 50000 + Math.floor(Math.random() * 20000), + orders: 200 + Math.floor(Math.random() * 80), + })), + }; +} + +function getExperimentResults() { + return { + variants: [ + { variant: "Control", conversionRate: 3.2, users: 5200 }, + { variant: "Variant A", conversionRate: 4.1, users: 5150 }, + { variant: "Variant B", conversionRate: 3.8, users: 5100 }, + ], + }; +} + +function getTicketSummary() { + return { + totalOpen: 47, + totalClosed: 183, + avgResolutionHours: 18.5, + byPriority: [ + { priority: "Critical", count: 5 }, + { priority: "High", count: 12 }, + { priority: "Medium", count: 18 }, + { priority: "Low", count: 12 }, + ], + recentTickets: [ + { id: "T-1042", title: "Login timeout on mobile", priority: "High", status: "Open" }, + { id: "T-1041", title: "Export CSV broken", priority: "Medium", status: "In Progress" }, + { id: "T-1040", title: "Dashboard loading slow", priority: "Low", status: "Open" }, + { id: "T-1039", title: "Payment webhook failing", priority: "Critical", status: "Open" }, + { id: "T-1038", title: "Search not returning results", priority: "High", status: "Closed" }, + ], + }; +} + +function getGeoUsage() { + return { + regions: [ + { region: "North America", users: 4200, events: 18000 }, + { region: "Europe", users: 3100, events: 14000 }, + { region: "Asia Pacific", users: 1800, events: 8000 }, + { region: "Latin America", users: 600, events: 2800 }, + { region: "Africa", users: 200, events: 900 }, + ], + }; +} + +function getFunnelMetrics() { + return { + steps: [ + { step: "Visit", users: 10000 }, + { step: "Sign Up", users: 3200 }, + { step: "Activate", users: 1800 }, + { step: "Subscribe", users: 450 }, + { step: "Retain (30d)", users: 320 }, + ], + }; +} + +function getInventoryStatus() { + return { + items: [ + { sku: "WDG-001", name: "Widget Pro", category: "Electronics", stock: 142, reorderPoint: 50, status: "In Stock" }, + { sku: "WDG-002", name: "Widget Lite", category: "Electronics", stock: 23, reorderPoint: 30, status: "Low Stock" }, + { sku: "GAD-001", name: "Gadget X", category: "Accessories", stock: 0, reorderPoint: 20, status: "Out of Stock" }, + { sku: "GAD-002", name: "Gadget Y", category: "Accessories", stock: 89, reorderPoint: 25, status: "In Stock" }, + { sku: "SRV-001", name: "Server Rack", category: "Infrastructure", stock: 12, reorderPoint: 5, status: "In Stock" }, + ], + }; +} + +// ── Issue tracker mock data (stateful per-process for testing mutations) ───── + +let mockTickets: Array> = [ + { id: "T-1001", title: "Fix login timeout", priority: "high", status: "open", created: "2026-03-27" }, + { id: "T-1002", title: "Update dashboard layout", priority: "medium", status: "open", created: "2026-03-27" }, + { id: "T-1003", title: "Add export button", priority: "low", status: "closed", created: "2026-03-26" }, +]; +let nextTicketId = 1004; + +function listTickets(args: Record) { + const status = args.status as string | undefined; + const filtered = status ? mockTickets.filter(t => t.status === status) : mockTickets; + return { + columns: ["id", "title", "priority", "status", "created"], + rows: filtered, + total: filtered.length, + }; +} + +function createTicket(args: Record) { + const ticket = { + id: `T-${nextTicketId++}`, + title: args.title ?? "Untitled", + priority: args.priority ?? "medium", + status: "open", + created: new Date().toISOString().slice(0, 10), + }; + mockTickets.unshift(ticket); + return { success: true, ticket }; +} + +function updateTicket(args: Record) { + const ticket = mockTickets.find(t => t.id === args.id); + if (!ticket) return { success: false, error: `Ticket ${args.id} not found` }; + if (args.title) ticket.title = args.title; + if (args.priority) ticket.priority = args.priority; + if (args.status) ticket.status = args.status; + return { success: true, ticket }; +} + +// ── Tool registry ─────────────────────────────────────────────────────────── + +export interface ToolDef { + name: string; + description: string; + inputSchema: Record; + execute: (args: Record) => Promise; +} + +export const tools: ToolDef[] = [ + { + name: "posthog_query", + description: "Run a HogQL SQL query against PostHog analytics", + inputSchema: { sql: z.string().describe("HogQL SQL query to execute") }, + execute: async (args) => executePostHogQuery(args.sql as string), + }, + { + name: "get_usage_metrics", + description: "Get usage metrics for the specified date range", + inputSchema: { dateRange: z.string().optional(), days: z.string().optional(), resource: z.string().optional() }, + execute: async (args) => getUsageMetrics(args), + }, + { + name: "get_top_endpoints", + description: "Get top API endpoints by request count", + inputSchema: { limit: z.number().optional(), dateRange: z.string().optional() }, + execute: async (args) => getTopEndpoints(args), + }, + { + name: "get_resource_breakdown", + description: "Get resource usage breakdown by type", + inputSchema: {}, + execute: async () => getResourceBreakdown(), + }, + { + name: "get_error_breakdown", + description: "Get error breakdown by category", + inputSchema: {}, + execute: async () => getErrorBreakdown(), + }, + { + name: "get_server_health", + description: "Get current server health metrics (CPU, memory, latency)", + inputSchema: {}, + execute: async () => getServerHealth(), + }, + { + name: "get_customer_segments", + description: "Get customer segment breakdown", + inputSchema: {}, + execute: async () => getCustomerSegments(), + }, + { + name: "get_sales_summary", + description: "Get sales summary with revenue and orders", + inputSchema: { dateRange: z.string().optional(), days: z.string().optional() }, + execute: async (args) => getSalesSummary(args), + }, + { + name: "get_experiment_results", + description: "Get A/B experiment results with conversion rates", + inputSchema: {}, + execute: async () => getExperimentResults(), + }, + { + name: "get_ticket_summary", + description: "Get support ticket summary and recent tickets", + inputSchema: {}, + execute: async () => getTicketSummary(), + }, + { + name: "get_geo_usage", + description: "Get geographic usage breakdown by region", + inputSchema: {}, + execute: async () => getGeoUsage(), + }, + { + name: "get_funnel_metrics", + description: "Get conversion funnel metrics", + inputSchema: {}, + execute: async () => getFunnelMetrics(), + }, + { + name: "get_inventory_status", + description: "Get inventory status for all products", + inputSchema: {}, + execute: async () => getInventoryStatus(), + }, + { + name: "list_tickets", + description: "List all tickets. Optionally filter by status.", + inputSchema: { status: z.string().optional().describe("Filter by status: open, closed") }, + execute: async (args) => listTickets(args), + }, + { + name: "create_ticket", + description: "Create a new ticket. Returns the created ticket.", + inputSchema: { + title: z.string().describe("Ticket title"), + priority: z.enum(["low", "medium", "high"]).optional().describe("Priority level"), + }, + execute: async (args) => createTicket(args), + }, + { + name: "update_ticket", + description: "Update an existing ticket's status, title, or priority.", + inputSchema: { + id: z.string().describe("Ticket ID (e.g. T-1001)"), + title: z.string().optional(), + priority: z.enum(["low", "medium", "high"]).optional(), + status: z.enum(["open", "closed", "in_progress"]).optional(), + }, + execute: async (args) => updateTicket(args), + }, +]; diff --git a/examples/openui-dashboard/tsconfig.json b/examples/openui-dashboard/tsconfig.json new file mode 100644 index 000000000..cf9c65d3e --- /dev/null +++ b/examples/openui-dashboard/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/lang-core/package.json b/packages/lang-core/package.json index ee5e500b9..c1f0454ea 100644 --- a/packages/lang-core/package.json +++ b/packages/lang-core/package.json @@ -31,6 +31,14 @@ "dependencies": { "zod": "^4.0.0" }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, "keywords": [ "openui", "openui-lang", @@ -53,6 +61,7 @@ }, "author": "engineering@thesys.dev", "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "vitest": "^4.0.18" } } diff --git a/packages/lang-core/src/index.ts b/packages/lang-core/src/index.ts index f0696373d..2f8cde703 100644 --- a/packages/lang-core/src/index.ts +++ b/packages/lang-core/src/index.ts @@ -6,16 +6,72 @@ export type { DefinedComponent, Library, LibraryDefinition, + LibraryJSONSchema, PromptOptions, SubComponentOf, + ToolDescriptor, } from "./library"; // ── Parser ── export { createParser, createStreamingParser, parse } from "./parser"; -export type { LibraryJSONSchema, Parser, StreamParser } from "./parser"; +export type { Parser, StreamParser } from "./parser"; +export { isASTNode, isRuntimeExpr } from "./parser/ast"; +export type { ASTNode, CallNode, RuntimeExprNode, Statement } from "./parser/ast"; +export { + ACTION_NAMES, + ACTION_STEPS, + BUILTINS, + BUILTIN_NAMES, + LAZY_BUILTINS, + isBuiltin, + toNumber, +} from "./parser/builtins"; +export type { BuiltinDef } from "./parser/builtins"; +export { mergeStatements } from "./parser/merge"; export { generatePrompt } from "./parser/prompt"; +export type { ComponentPromptSpec, McpToolSpec, PromptSpec } from "./parser/prompt"; export { BuiltinActionType } from "./parser/types"; -export type { ActionEvent, ElementNode, ParseResult, ValidationErrorCode } from "./parser/types"; +export type { + ActionEvent, + ActionPlan, + ActionStep, + ElementNode, + MutationStatementInfo, + OpenUIError, + ParseResult, + QueryStatementInfo, + ValidationError, + ValidationErrorCode, +} from "./parser/types"; + +// ── Reactive schema marker ── +export { isReactiveSchema, markReactive } from "./reactive"; + +// ── Runtime ── +export { evaluateElementProps } from "./runtime/evaluate-tree"; +export type { EvalContext } from "./runtime/evaluate-tree"; +export { evaluate, isReactiveAssign, stripReactiveAssign } from "./runtime/evaluator"; +export type { EvaluationContext, ReactiveAssign } from "./runtime/evaluator"; +export { createMcpTransport } from "./runtime/mcp-transport"; +export type { + McpClientLike, + McpConnection, + McpTool, + McpTransportConfig, +} from "./runtime/mcp-transport"; +export { createQueryManager } from "./runtime/queryManager"; +export type { + MutationNode, + MutationResult, + QueryManager, + QueryNode, + QuerySnapshot, + Transport, +} from "./runtime/queryManager"; +export { resolveStateField } from "./runtime/state-field"; +export type { InferStateFieldValue, StateField } from "./runtime/state-field"; +export { createStore } from "./runtime/store"; +export type { Store } from "./runtime/store"; // ── Validation ── export { builtInValidators, parseRules, parseStructuredRules, validate } from "./utils/validation"; diff --git a/packages/lang-core/src/library.ts b/packages/lang-core/src/library.ts index 8d0a4b18c..44e682378 100644 --- a/packages/lang-core/src/library.ts +++ b/packages/lang-core/src/library.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import type { ComponentPromptSpec, McpToolSpec, PromptSpec } from "./parser/prompt"; import { generatePrompt } from "./parser/prompt"; +import type { LibraryJSONSchema } from "./parser/types"; +import { isReactiveSchema } from "./reactive"; + +export type { LibraryJSONSchema } from "./parser/types"; // ─── Sub-component type ────────────────────────────────────────────────────── @@ -62,7 +67,7 @@ export function defineComponent, C>(config: { }; } -// ─── Groups & Prompt ────────────────────────────────────────────────────────── +// ─── Groups & Prompt Options ───────────────────────────────────────────────── export interface ComponentGroup { name: string; @@ -70,13 +75,202 @@ export interface ComponentGroup { notes?: string[]; } +/** Tool descriptor for prompt generation — simple string or rich McpToolSpec. */ +export type ToolDescriptor = string | McpToolSpec; + export interface PromptOptions { preamble?: string; additionalRules?: string[]; + /** Examples shown when no tools are present (static/layout patterns). */ examples?: string[]; + /** Examples shown when tools ARE present (Query/Mutation patterns). Takes priority over `examples`. */ + toolExamples?: string[]; + /** Available tools for Query() — string names or rich McpToolSpec descriptors injected into the prompt. */ + tools?: ToolDescriptor[]; + /** Enable edit-mode instructions in the prompt. */ + editMode?: boolean; + /** Enable inline mode — LLM can respond with text + optional openui-lang fenced code. */ + inlineMode?: boolean; +} + +// ─── Zod introspection ────────────────────────────────────────────────────── + +function getZodDef(schema: unknown): any { + return (schema as any)?._zod?.def; +} + +function getZodType(schema: unknown): string | undefined { + return getZodDef(schema)?.type; +} + +function isOptionalType(schema: unknown): boolean { + return getZodType(schema) === "optional"; +} + +function unwrapOptional(schema: unknown): unknown { + const def = getZodDef(schema); + if (def?.type === "optional") return def.innerType; + return schema; +} + +function unwrap(schema: unknown): unknown { + return unwrapOptional(schema); +} + +function isArrayType(schema: unknown): boolean { + const s = unwrap(schema); + return getZodType(s) === "array"; +} + +function getArrayInnerType(schema: unknown): unknown | undefined { + const s = unwrap(schema); + const def = getZodDef(s); + if (def?.type === "array") return def.element ?? def.innerType; + return undefined; +} + +function getEnumValues(schema: unknown): string[] | undefined { + const s = unwrap(schema); + const def = getZodDef(s); + if (def?.type !== "enum") return undefined; + if (Array.isArray(def.values)) return def.values; + if (def.entries && typeof def.entries === "object") return Object.keys(def.entries); + return undefined; +} + +function getSchemaId(schema: unknown): string | undefined { + try { + const meta = z.globalRegistry.get(schema as z.ZodType); + return meta?.id; + } catch { + return undefined; + } +} + +function getUnionOptions(schema: unknown): unknown[] | undefined { + const def = getZodDef(schema); + if (def?.type === "union" && Array.isArray(def.options)) return def.options; + return undefined; +} + +function getObjectShape(schema: unknown): Record | undefined { + const def = getZodDef(schema); + if (def?.type === "object" && def.shape && typeof def.shape === "object") + return def.shape as Record; + return undefined; +} + +/** + * Resolve the type annotation for a schema field. + * Returns a human-readable type string for the schema. + * If the schema is marked reactive(), prefixes with "$binding<...>". + */ +function resolveTypeAnnotation(schema: unknown): string | undefined { + const isReactive = isReactiveSchema(schema); + const inner = unwrap(schema); + + const baseType = resolveBaseType(inner); + if (!baseType) return undefined; + return isReactive ? `$binding<${baseType}>` : baseType; } -// ─── Library ────────────────────────────────────────────────────────────────── +function resolveBaseType(inner: unknown): string | undefined { + const directId = getSchemaId(inner); + if (directId) return directId; + + const unionOpts = getUnionOptions(inner); + if (unionOpts) { + const resolved = unionOpts.map((o) => resolveTypeAnnotation(o)); + const names = resolved.filter(Boolean) as string[]; + if (names.length > 0) return names.join(" | "); + } + + if (isArrayType(inner)) { + const arrayInner = getArrayInnerType(inner); + if (!arrayInner) return undefined; + const innerType = resolveTypeAnnotation(arrayInner); + if (innerType) { + const isUnion = getUnionOptions(unwrap(arrayInner)) !== undefined; + return isUnion ? `(${innerType})[]` : `${innerType}[]`; + } + return undefined; + } + + const zodType = getZodType(inner); + if (zodType === "string") return "string"; + if (zodType === "number") return "number"; + if (zodType === "boolean") return "boolean"; + + const enumVals = getEnumValues(inner); + if (enumVals) return enumVals.map((v) => `"${v}"`).join(" | "); + + if (zodType === "literal") { + const vals = getZodDef(inner)?.values; + if (Array.isArray(vals) && vals.length === 1) { + const v = vals[0]; + return typeof v === "string" ? `"${v}"` : String(v); + } + } + + const shape = getObjectShape(inner); + if (shape) { + const fields = Object.entries(shape).map(([name, fieldSchema]) => { + const opt = isOptionalType(fieldSchema) ? "?" : ""; + const fieldType = resolveTypeAnnotation(fieldSchema as z.ZodType); + return fieldType ? `${name}${opt}: ${fieldType}` : `${name}${opt}`; + }); + return `{${fields.join(", ")}}`; + } + + return undefined; +} + +// ─── Field analysis & signature generation ────────────────────────────────── + +interface FieldInfo { + name: string; + isOptional: boolean; + isArray: boolean; + typeAnnotation?: string; +} + +function analyzeFields(shape: Record): FieldInfo[] { + return Object.entries(shape).map(([name, schema]) => ({ + name, + isOptional: isOptionalType(schema), + isArray: isArrayType(schema), + typeAnnotation: resolveTypeAnnotation(schema), + })); +} + +function buildSignature(componentName: string, fields: FieldInfo[]): string { + const params = fields.map((f) => { + if (f.typeAnnotation) { + return f.isOptional ? `${f.name}?: ${f.typeAnnotation}` : `${f.name}: ${f.typeAnnotation}`; + } + if (f.isArray) { + return f.isOptional ? `[${f.name}]?` : `[${f.name}]`; + } + return f.isOptional ? `${f.name}?` : f.name; + }); + return `${componentName}(${params.join(", ")})`; +} + +function buildComponentSpecs( + components: Record>, +): Record { + const specs: Record = {}; + for (const [name, def] of Object.entries(components)) { + const fields = analyzeFields(def.props.shape); + specs[name] = { + signature: buildSignature(name, fields), + description: def.description, + }; + } + return specs; +} + +// ─── Library ──────────────────────────────────────────────────────────────── export interface Library { readonly components: Record>; @@ -84,7 +278,8 @@ export interface Library { readonly root: string | undefined; prompt(options?: PromptOptions): string; - toJSONSchema(): object; + toSpec(): PromptSpec; + toJSONSchema(): LibraryJSONSchema; } export interface LibraryDefinition { @@ -118,10 +313,24 @@ export function createLibrary(input: LibraryDefinition): Library root: input.root, prompt(options?: PromptOptions): string { - return generatePrompt(library, options); + const spec: PromptSpec = { + root: input.root, + components: buildComponentSpecs(componentsRecord), + componentGroups: input.componentGroups, + ...options, + }; + return generatePrompt(spec); + }, + + toSpec(): PromptSpec { + return { + root: input.root, + components: buildComponentSpecs(componentsRecord), + componentGroups: input.componentGroups, + }; }, - toJSONSchema(): object { + toJSONSchema(): LibraryJSONSchema { const combinedSchema = z.object( Object.fromEntries(Object.entries(componentsRecord).map(([k, v]) => [k, v.props])) as any, ); diff --git a/packages/lang-core/src/parser/ast.ts b/packages/lang-core/src/parser/ast.ts new file mode 100644 index 000000000..d39a567a7 --- /dev/null +++ b/packages/lang-core/src/parser/ast.ts @@ -0,0 +1,167 @@ +// ───────────────────────────────────────────────────────────────────────────── +// AST node types for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Discriminated union representing every value that can appear in an + * openui-lang expression. The `k` field is the discriminant. + * + * Literal & structural nodes: + * - `Comp` — a component call: `Header("Hello", "Subtitle")` + * - `Str` — a string literal: `"hello"` + * - `Num` — a number literal: `42` or `3.14` + * - `Bool` — a boolean literal: `true` or `false` + * - `Null` — the null literal + * - `Arr` — an array: `[a, b, c]` + * - `Obj` — an object: `{ key: value }` + * - `Ref` — a reference to another statement: `myTable` + * - `Ph` — a placeholder for an unresolvable reference + * + * Reactive & expression nodes: + * - `StateRef` — a reactive state variable reference: `$count` + * - `RuntimeRef` — a reference resolved at runtime (e.g. Query results) + * - `BinOp` — a binary operation: `a + b`, `x == y` + * - `UnaryOp` — a unary operation: `!flag` + * - `Ternary` — a conditional expression: `cond ? a : b` + * - `Member` — dot member access: `obj.field` + * - `Index` — bracket index access: `arr[0]` + * - `Assign` — state assignment: `$count = $count + 1` + */ +export type ASTNode = + | { k: "Comp"; name: string; args: ASTNode[]; mappedProps?: Record } + | { k: "Str"; v: string } + | { k: "Num"; v: number } + | { k: "Bool"; v: boolean } + | { k: "Null" } + | { k: "Arr"; els: ASTNode[] } + | { k: "Obj"; entries: [string, ASTNode][] } + | { k: "Ref"; n: string } + | { k: "Ph"; n: string } + | { k: "StateRef"; n: string } + | { k: "RuntimeRef"; n: string; refType: "query" | "mutation" } + | { k: "BinOp"; op: string; left: ASTNode; right: ASTNode } + | { k: "UnaryOp"; op: string; operand: ASTNode } + | { k: "Ternary"; cond: ASTNode; then: ASTNode; else: ASTNode } + | { k: "Member"; obj: ASTNode; field: string } + | { k: "Index"; obj: ASTNode; index: ASTNode } + | { k: "Assign"; target: string; value: ASTNode }; + +/** + * Subset of ASTNode that must be preserved for runtime evaluation. + * These nodes survive parser lowering and are resolved by the evaluator. + */ +export type RuntimeExprNode = Extract< + ASTNode, + | { k: "StateRef" } + | { k: "RuntimeRef" } + | { k: "BinOp" } + | { k: "UnaryOp" } + | { k: "Ternary" } + | { k: "Member" } + | { k: "Index" } + | { k: "Assign" } +>; + +/** Type guard for runtime expression nodes that survive parser lowering. */ +export function isRuntimeExpr(node: ASTNode): node is RuntimeExprNode { + switch (node.k) { + case "StateRef": + case "RuntimeRef": + case "BinOp": + case "UnaryOp": + case "Ternary": + case "Member": + case "Index": + case "Assign": + return true; + default: + return false; + } +} + +/** Valid AST discriminant values. */ +const AST_KINDS = new Set([ + "Comp", + "Ref", + "StateRef", + "RuntimeRef", + "BinOp", + "UnaryOp", + "Ternary", + "Member", + "Index", + "Assign", + "Str", + "Num", + "Bool", + "Null", + "Arr", + "Obj", + "Ph", +]); + +/** Check if a value is an AST node (has a valid `k` discriminant field). */ +export function isASTNode(value: unknown): value is ASTNode { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return AST_KINDS.has((value as Record).k as string); +} + +export function walkAST(node: ASTNode, visit: (node: ASTNode) => void): void { + const walk = (current: ASTNode) => { + visit(current); + + switch (current.k) { + case "Comp": + current.args.forEach(walk); + Object.values(current.mappedProps ?? {}).forEach(walk); + break; + case "Arr": + current.els.forEach(walk); + break; + case "Obj": + current.entries.forEach(([, value]) => walk(value)); + break; + case "BinOp": + walk(current.left); + walk(current.right); + break; + case "UnaryOp": + walk(current.operand); + break; + case "Ternary": + walk(current.cond); + walk(current.then); + walk(current.else); + break; + case "Member": + walk(current.obj); + break; + case "Index": + walk(current.obj); + walk(current.index); + break; + case "Assign": + walk(current.value); + break; + } + }; + + walk(node); +} + +// ─── Typed Statement model ───────────────────────────────────────────────── +// Classification determined at parse time from token type + expression shape. +// Eliminates id.startsWith("$") and ast.name === "Query" hacks downstream. + +/** Tool/Query call shape extracted from Comp nodes */ +export interface CallNode { + callee: string; + args: ASTNode[]; +} + +/** Typed statement — kind known at parse time */ +export type Statement = + | { kind: "value"; id: string; expr: ASTNode } + | { kind: "state"; id: string; init: ASTNode } + | { kind: "query"; id: string; call: CallNode; expr: ASTNode; deps?: string[] } + | { kind: "mutation"; id: string; call: CallNode; expr: ASTNode }; diff --git a/packages/lang-core/src/parser/builtins.ts b/packages/lang-core/src/parser/builtins.ts new file mode 100644 index 000000000..4003a6ba1 --- /dev/null +++ b/packages/lang-core/src/parser/builtins.ts @@ -0,0 +1,230 @@ +/** + * Shared parser/runtime registry hub for: + * - Runtime data builtins (evaluator.ts imports `.fn`) + * - Prompt builtin docs (prompt.ts imports `.signature` + `.description`) + * - Parser/runtime call classification (`isBuiltin`, action names, reserved calls) + */ + +export interface BuiltinDef { + /** PascalCase name matching the openui-lang syntax: Count, Sum, etc. */ + name: string; + /** Signature for prompt docs: "@Count(array) → number" */ + signature: string; + /** One-line description for prompt docs */ + description: string; + /** Runtime implementation */ + fn: (...args: unknown[]) => unknown; +} + +/** Resolve a field path on an object. Supports dot-paths: "state.name" → obj.state.name */ +function resolveField(obj: any, path: string): unknown { + if (!path || obj == null) return undefined; + if (!path.includes(".")) return obj[path]; + let cur = obj; + for (const p of path.split(".")) { + if (cur == null) return undefined; + cur = cur[p]; + } + return cur; +} + +function toNumber(val: unknown): number { + if (typeof val === "number") return val; + if (typeof val === "string") { + const n = Number(val); + return isNaN(n) ? 0 : n; + } + if (typeof val === "boolean") return val ? 1 : 0; + return 0; +} + +export const BUILTINS: Record = { + Count: { + name: "Count", + signature: "Count(array) → number", + description: "Returns array length", + fn: (arr) => (Array.isArray(arr) ? arr.length : 0), + }, + First: { + name: "First", + signature: "First(array) → element", + description: "Returns first element of array", + fn: (arr) => (Array.isArray(arr) ? (arr[0] ?? null) : null), + }, + Last: { + name: "Last", + signature: "Last(array) → element", + description: "Returns last element of array", + fn: (arr) => (Array.isArray(arr) ? (arr[arr.length - 1] ?? null) : null), + }, + Sum: { + name: "Sum", + signature: "Sum(numbers[]) → number", + description: "Sum of numeric array", + fn: (arr) => + Array.isArray(arr) ? arr.reduce((a: number, b: unknown) => a + toNumber(b), 0) : 0, + }, + Avg: { + name: "Avg", + signature: "Avg(numbers[]) → number", + description: "Average of numeric array", + fn: (arr) => + Array.isArray(arr) && arr.length + ? (arr.reduce((a: number, b: unknown) => a + toNumber(b), 0) as number) / arr.length + : 0, + }, + Min: { + name: "Min", + signature: "Min(numbers[]) → number", + description: "Minimum value in array", + fn: (arr) => + Array.isArray(arr) && arr.length + ? arr.reduce((acc: number, b: unknown) => Math.min(acc, toNumber(b)), toNumber(arr[0])) + : 0, + }, + Max: { + name: "Max", + signature: "Max(numbers[]) → number", + description: "Maximum value in array", + fn: (arr) => + Array.isArray(arr) && arr.length + ? arr.reduce((acc: number, b: unknown) => Math.max(acc, toNumber(b)), toNumber(arr[0])) + : 0, + }, + Sort: { + name: "Sort", + signature: "Sort(array, field, direction?) → sorted array", + description: 'Sort array by field. Direction: "asc" (default) or "desc"', + fn: (arr, field, dir) => { + if (!Array.isArray(arr)) return arr; + const f = String(field ?? ""); + const desc = String(dir ?? "asc") === "desc"; + return [...arr].sort((a: any, b: any) => { + const av = f ? resolveField(a, f) : a; + const bv = f ? resolveField(b, f) : b; + const aIsNumeric = + typeof av === "number" || (typeof av === "string" && !isNaN(Number(av)) && av !== ""); + const bIsNumeric = + typeof bv === "number" || (typeof bv === "string" && !isNaN(Number(bv)) && bv !== ""); + if (aIsNumeric && bIsNumeric) { + const diff = toNumber(av) - toNumber(bv); + return desc ? -diff : diff; + } + const cmp = String(av ?? "").localeCompare(String(bv ?? "")); + return desc ? -cmp : cmp; + }); + }, + }, + Filter: { + name: "Filter", + signature: + 'Filter(array, field, operator: "==" | "!=" | ">" | "<" | ">=" | "<=" | "contains", value) → filtered array', + description: "Filter array by field value", + fn: (arr, field, op, value) => { + if (!Array.isArray(arr)) return []; + const f = String(field ?? ""); + const o = String(op ?? "=="); + return arr.filter((item: any) => { + const v = f ? resolveField(item, f) : item; + switch (o) { + case "==": + // eslint-disable-next-line eqeqeq + return v == value; + case "!=": + // eslint-disable-next-line eqeqeq + return v != value; + case ">": + return toNumber(v) > toNumber(value); + case "<": + return toNumber(v) < toNumber(value); + case ">=": + return toNumber(v) >= toNumber(value); + case "<=": + return toNumber(v) <= toNumber(value); + case "contains": + return String(v ?? "").includes(String(value ?? "")); + default: + return false; + } + }); + }, + }, + Round: { + name: "Round", + signature: "Round(number, decimals?) → number", + description: "Round to N decimal places (default 0)", + fn: (n, decimals) => { + const num = toNumber(n); + const d = decimals != null ? toNumber(decimals) : 0; + const factor = Math.pow(10, d); + return Math.round(num * factor) / factor; + }, + }, + Abs: { + name: "Abs", + signature: "Abs(number) → number", + description: "Absolute value", + fn: (n) => Math.abs(toNumber(n)), + }, + Floor: { + name: "Floor", + signature: "Floor(number) → number", + description: "Round down to nearest integer", + fn: (n) => Math.floor(toNumber(n)), + }, + Ceil: { + name: "Ceil", + signature: "Ceil(number) → number", + description: "Round up to nearest integer", + fn: (n) => Math.ceil(toNumber(n)), + }, +}; + +/** + * Lazy builtins — these receive AST nodes (not evaluated values) and + * control their own evaluation. Handled specially in evaluator.ts. + */ +export const LAZY_BUILTINS: Set = new Set(["Each"]); + +export const LAZY_BUILTIN_DEFS: Record = { + Each: { + signature: "Each(array, varName, template)", + description: + "Evaluate template for each element. varName is the loop variable — use it ONLY inside the template expression (inline). Do NOT create a separate statement for the template.", + }, +}; + +/** Maps parser-level action step names → runtime step type values. Single source of truth. */ +export const ACTION_STEPS = { + Run: "run", + ToAssistant: "continue_conversation", + OpenUrl: "open_url", + Set: "set", + Reset: "reset", +} as const; + +/** All action expression names (steps + the Action container) */ +export const ACTION_NAMES: Set = new Set(["Action", ...Object.keys(ACTION_STEPS)]); + +/** Set of builtin names for fast lookup (includes action expressions) */ +export const BUILTIN_NAMES: Set = new Set([ + ...Object.keys(BUILTINS), + ...LAZY_BUILTINS, + ...ACTION_NAMES, +]); + +/** Check if a name is a builtin function (not a component) */ +export function isBuiltin(name: string): boolean { + return BUILTIN_NAMES.has(name); +} + +/** Reserved statement-level call names — not builtins, not components */ +export const RESERVED_CALLS = { Query: "Query", Mutation: "Mutation" } as const; + +/** Check if a name is a reserved statement call (Query, Mutation) */ +export function isReservedCall(name: string): boolean { + return name in RESERVED_CALLS; +} + +/** Re-export toNumber for evaluator compatibility */ +export { toNumber }; diff --git a/packages/lang-core/src/parser/expressions.ts b/packages/lang-core/src/parser/expressions.ts new file mode 100644 index 000000000..8efc8e69f --- /dev/null +++ b/packages/lang-core/src/parser/expressions.ts @@ -0,0 +1,331 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Pratt precedence expression parser for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +import type { ASTNode } from "./ast"; +import { isBuiltin } from "./builtins"; +import { T, type Token } from "./tokens"; + +// ── Precedence levels (from spec Section 2.11) ───────────────────────────── +const PREC_TERNARY = 1; +const PREC_OR = 2; +const PREC_AND = 3; +const PREC_EQ = 4; +const PREC_CMP = 5; +const PREC_ADD = 6; +const PREC_MUL = 7; +const PREC_UNARY = 8; +const PREC_MEMBER = 9; + +/** + * Parse a token array into an AST node using a Pratt (top-down operator + * precedence) parser. + */ +export function parseExpression(tokens: Token[]): ASTNode { + let pos = 0; + + const cur = (): Token => tokens[pos] ?? { t: T.EOF }; + const adv = (): Token => { + const tok = cur(); + pos++; + return tok; + }; + const eat = (kind: T): void => { + if (cur().t === kind) pos++; + }; + + // ── Infix precedence lookup ───────────────────────────────────────────── + function getInfixPrec(tok: Token): number { + switch (tok.t) { + case T.Question: + return PREC_TERNARY; + case T.Or: + return PREC_OR; + case T.And: + return PREC_AND; + case T.EqEq: + case T.NotEq: + return PREC_EQ; + case T.Greater: + case T.Less: + case T.GreaterEq: + case T.LessEq: + return PREC_CMP; + case T.Plus: + case T.Minus: + return PREC_ADD; + case T.Star: + case T.Slash: + case T.Percent: + return PREC_MUL; + case T.Dot: + case T.LBrack: + return PREC_MEMBER; + default: + return 0; + } + } + + // ── Main Pratt loop ───────────────────────────────────────────────────── + function parseExpr(minPrec: number = 0): ASTNode { + let left = parsePrefix(); + while (getInfixPrec(cur()) > minPrec) { + left = parseInfix(left); + } + return left; + } + + // ── Prefix / atoms ───────────────────────────────────────────────────── + function parsePrefix(): ASTNode { + const tok = cur(); + + // String literal + if (tok.t === T.Str) { + adv(); + return { k: "Str", v: tok.v as string }; + } + + // Number literal + if (tok.t === T.Num) { + adv(); + return { k: "Num", v: tok.v as number }; + } + + // Boolean literals + if (tok.t === T.True) { + adv(); + return { k: "Bool", v: true }; + } + if (tok.t === T.False) { + adv(); + return { k: "Bool", v: false }; + } + + // Null literal + if (tok.t === T.Null) { + adv(); + return { k: "Null" }; + } + + // Array + if (tok.t === T.LBrack) return parseArr(); + + // Object + if (tok.t === T.LBrace) return parseObj(); + + // State variable — may be assignment or reference + if (tok.t === T.StateVar) { + const name = tok.v as string; + adv(); + // Check for assignment: $var = expr (Equals, NOT EqEq) + if (cur().t === T.Equals) { + adv(); // consume = + const value = parseExpr(0); + return { k: "Assign", target: name, value }; + } + return { k: "StateRef", n: name }; + } + + // PascalCase — component call or reference + if (tok.t === T.Type) { + const name = tok.v as string; + // Builtins (Count, Each, Set, Run, etc.) require @-prefix — only Action is exempt + if (tokens[pos + 1]?.t === T.LParen && (!isBuiltin(name) || name === "Action")) + return parseComp(); + adv(); + return { k: "Ref", n: name }; + } + + // @-prefixed builtin call: @Count(...), @Each(...), @Set(...), etc. + if (tok.t === T.BuiltinCall) { + if (tokens[pos + 1]?.t === T.LParen) return parseComp(); + adv(); + return { k: "Ref", n: tok.v as string }; + } + + // Lowercase identifier — reference + if (tok.t === T.Ident) { + adv(); + return { k: "Ref", n: tok.v as string }; + } + + // Unary NOT + if (tok.t === T.Not) { + adv(); + return { k: "UnaryOp", op: "!", operand: parseExpr(PREC_UNARY) }; + } + + // Unary negation + if (tok.t === T.Minus) { + adv(); + return { k: "UnaryOp", op: "-", operand: parseExpr(PREC_UNARY) }; + } + + // Grouped expression + if (tok.t === T.LParen) { + adv(); // skip ( + const inner = parseExpr(0); + eat(T.RParen); + return inner; + } + + // Unknown token — skip and return Null + adv(); + return { k: "Null" }; + } + + // ── Infix / postfix ──────────────────────────────────────────────────── + function parseInfix(left: ASTNode): ASTNode { + const tok = cur(); + + // Arithmetic: + - + if (tok.t === T.Plus) { + adv(); + return { k: "BinOp", op: "+", left, right: parseExpr(PREC_ADD) }; + } + if (tok.t === T.Minus) { + adv(); + return { k: "BinOp", op: "-", left, right: parseExpr(PREC_ADD) }; + } + + // Arithmetic: * / % + if (tok.t === T.Star) { + adv(); + return { k: "BinOp", op: "*", left, right: parseExpr(PREC_MUL) }; + } + if (tok.t === T.Slash) { + adv(); + return { k: "BinOp", op: "/", left, right: parseExpr(PREC_MUL) }; + } + if (tok.t === T.Percent) { + adv(); + return { k: "BinOp", op: "%", left, right: parseExpr(PREC_MUL) }; + } + + // Equality: == != + if (tok.t === T.EqEq) { + adv(); + return { k: "BinOp", op: "==", left, right: parseExpr(PREC_EQ) }; + } + if (tok.t === T.NotEq) { + adv(); + return { k: "BinOp", op: "!=", left, right: parseExpr(PREC_EQ) }; + } + + // Comparison: > < >= <= + if (tok.t === T.Greater) { + adv(); + return { k: "BinOp", op: ">", left, right: parseExpr(PREC_CMP) }; + } + if (tok.t === T.Less) { + adv(); + return { k: "BinOp", op: "<", left, right: parseExpr(PREC_CMP) }; + } + if (tok.t === T.GreaterEq) { + adv(); + return { k: "BinOp", op: ">=", left, right: parseExpr(PREC_CMP) }; + } + if (tok.t === T.LessEq) { + adv(); + return { k: "BinOp", op: "<=", left, right: parseExpr(PREC_CMP) }; + } + + // Logical AND + if (tok.t === T.And) { + adv(); + return { k: "BinOp", op: "&&", left, right: parseExpr(PREC_AND) }; + } + + // Logical OR + if (tok.t === T.Or) { + adv(); + return { k: "BinOp", op: "||", left, right: parseExpr(PREC_OR) }; + } + + // Ternary: cond ? then : else (right-associative) + if (tok.t === T.Question) { + adv(); // consume ? + const then = parseExpr(0); + eat(T.Colon); + const els = parseExpr(0); // right-assoc: parse at lowest prec + return { k: "Ternary", cond: left, then, else: els }; + } + + // Member access: obj.field + if (tok.t === T.Dot) { + adv(); // consume . + const fieldTok = cur(); + const field = + fieldTok.t === T.Ident || + fieldTok.t === T.Type || + fieldTok.t === T.Str || + fieldTok.t === T.Num + ? (adv(), String(fieldTok.v)) + : fieldTok.t === T.StateVar + ? (adv(), (fieldTok.v as string).replace(/^\$/, "")) + : (adv(), "?"); + return { k: "Member", obj: left, field }; + } + + // Index access: obj[expr] + if (tok.t === T.LBrack) { + adv(); // consume [ + const index = parseExpr(0); + eat(T.RBrack); + return { k: "Index", obj: left, index }; + } + + // Fallback — should not be reached if getInfixPrec is correct + return left; + } + + // ── Compound parsers ─────────────────────────────────────────────────── + + /** Parse `TypeName(arg1, arg2, ...)` */ + function parseComp(): ASTNode { + const name = cur().v as string; + adv(); // consume TypeName + eat(T.LParen); + const args: ASTNode[] = []; + while (cur().t !== T.RParen && cur().t !== T.EOF) { + args.push(parseExpr(0)); + if (cur().t === T.Comma) adv(); + } + eat(T.RParen); + return { k: "Comp", name, args }; + } + + /** Parse `[elem1, elem2, ...]` */ + function parseArr(): ASTNode { + adv(); // skip [ + const els: ASTNode[] = []; + while (cur().t !== T.RBrack && cur().t !== T.EOF) { + els.push(parseExpr(0)); + if (cur().t === T.Comma) adv(); + } + eat(T.RBrack); + return { k: "Arr", els }; + } + + /** Parse `{ key: value, ... }` */ + function parseObj(): ASTNode { + adv(); // skip { + const entries: [string, ASTNode][] = []; + while (cur().t !== T.RBrace && cur().t !== T.EOF) { + const kt = cur(); + const key = + kt.t === T.Ident || kt.t === T.Str || kt.t === T.Type || kt.t === T.Num + ? (adv(), String(kt.v)) + : kt.t === T.StateVar + ? (adv(), (kt.v as string).replace(/^\$/, "")) + : (adv(), "?"); + eat(T.Colon); + entries.push([key, parseExpr(0)]); + if (cur().t === T.Comma) adv(); + } + eat(T.RBrace); + return { k: "Obj", entries }; + } + + return parseExpr(0); +} diff --git a/packages/lang-core/src/parser/index.ts b/packages/lang-core/src/parser/index.ts index 1dc9018ee..054a1c220 100644 --- a/packages/lang-core/src/parser/index.ts +++ b/packages/lang-core/src/parser/index.ts @@ -2,12 +2,29 @@ export { BuiltinActionType } from "./types"; export type { ActionEvent, ElementNode, + LibraryJSONSchema, + MutationStatementInfo, OpenUIError, + ParamDef, + ParamMap, ParseResult, + QueryStatementInfo, + ValidationError, ValidationErrorCode, } from "./types"; export { createParser, createStreamingParser, parse } from "./parser"; -export type { LibraryJSONSchema, Parser, StreamParser } from "./parser"; +export type { Parser, StreamParser } from "./parser"; export { generatePrompt } from "./prompt"; +export type { ComponentGroup, ComponentPromptSpec, McpToolSpec, PromptSpec } from "./prompt"; + +export { mergeStatements } from "./merge"; + +// Shared builtin registry +export { BUILTINS, BUILTIN_NAMES, isBuiltin } from "./builtins"; +export type { BuiltinDef } from "./builtins"; + +// Typed statement model + AST utilities +export { isASTNode, isRuntimeExpr } from "./ast"; +export type { ASTNode, CallNode, RuntimeExprNode, Statement } from "./ast"; diff --git a/packages/lang-core/src/parser/lexer.ts b/packages/lang-core/src/parser/lexer.ts new file mode 100644 index 000000000..61df2389a --- /dev/null +++ b/packages/lang-core/src/parser/lexer.ts @@ -0,0 +1,380 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Lexer for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +import { T, type Token } from "./tokens"; + +/** + * Tokenize an openui-lang source string into a flat token array. + * + * Handles all token types: identifiers, literals, operators, + * state variables ($name), dot access, ternary. + */ +export function tokenize(src: string): Token[] { + const tokens: Token[] = []; + let i = 0; + const n = src.length; + + while (i < n) { + // Skip horizontal whitespace (not newlines — they're significant) + while (i < n && (src[i] === " " || src[i] === "\t" || src[i] === "\r")) i++; + if (i >= n) break; + + const c = src[i]; + + // ── Newline ──────────────────────────────────────────────────────── + if (c === "\n") { + tokens.push({ t: T.Newline }); + i++; + continue; + } + + // ── Single-character punctuation (brackets, comma, colon) ───────── + if (c === "(") { + tokens.push({ t: T.LParen }); + i++; + continue; + } + if (c === ")") { + tokens.push({ t: T.RParen }); + i++; + continue; + } + if (c === "[") { + tokens.push({ t: T.LBrack }); + i++; + continue; + } + if (c === "]") { + tokens.push({ t: T.RBrack }); + i++; + continue; + } + if (c === "{") { + tokens.push({ t: T.LBrace }); + i++; + continue; + } + if (c === "}") { + tokens.push({ t: T.RBrace }); + i++; + continue; + } + if (c === ",") { + tokens.push({ t: T.Comma }); + i++; + continue; + } + if (c === ":") { + tokens.push({ t: T.Colon }); + i++; + continue; + } + + // ── Equals / EqEq ───────────────────────────────────────────────── + if (c === "=") { + if (i + 1 < n && src[i + 1] === "=") { + tokens.push({ t: T.EqEq }); + i += 2; + } else { + tokens.push({ t: T.Equals }); + i++; + } + continue; + } + + // ── Not / NotEq ─────────────────────────────────────────────────── + if (c === "!") { + if (i + 1 < n && src[i + 1] === "=") { + tokens.push({ t: T.NotEq }); + i += 2; + } else { + tokens.push({ t: T.Not }); + i++; + } + continue; + } + + // ── Greater / GreaterEq ─────────────────────────────────────────── + if (c === ">") { + if (i + 1 < n && src[i + 1] === "=") { + tokens.push({ t: T.GreaterEq }); + i += 2; + } else { + tokens.push({ t: T.Greater }); + i++; + } + continue; + } + + // ── Less / LessEq ──────────────────────────────────────────────── + if (c === "<") { + if (i + 1 < n && src[i + 1] === "=") { + tokens.push({ t: T.LessEq }); + i += 2; + } else { + tokens.push({ t: T.Less }); + i++; + } + continue; + } + + // ── And (&&) ────────────────────────────────────────────────────── + if (c === "&") { + if (i + 1 < n && src[i + 1] === "&") { + tokens.push({ t: T.And }); + i += 2; + } else { + tokens.push({ t: T.And }); + i++; + } + continue; + } + + // ── Or (||) ─────────────────────────────────────────────────────── + if (c === "|") { + if (i + 1 < n && src[i + 1] === "|") { + tokens.push({ t: T.Or }); + i += 2; + } else { + tokens.push({ t: T.Or }); + i++; + } + continue; + } + + // ── Dot ─────────────────────────────────────────────────────────── + if (c === ".") { + tokens.push({ t: T.Dot }); + i++; + continue; + } + + // ── Question ────────────────────────────────────────────────────── + if (c === "?") { + tokens.push({ t: T.Question }); + i++; + continue; + } + + // ── Plus ────────────────────────────────────────────────────────── + if (c === "+") { + tokens.push({ t: T.Plus }); + i++; + continue; + } + + // ── Star ────────────────────────────────────────────────────────── + if (c === "*") { + tokens.push({ t: T.Star }); + i++; + continue; + } + + // ── Slash ───────────────────────────────────────────────────────── + if (c === "/") { + tokens.push({ t: T.Slash }); + i++; + continue; + } + + // ── Percent ─────────────────────────────────────────────────────── + if (c === "%") { + tokens.push({ t: T.Percent }); + i++; + continue; + } + + // ── String literal: "..." ───────────────────────────────────────── + if (c === '"') { + const start = i; + i++; // skip opening quote + let isClosed = false; + // Fast-forward to the closing quote, respecting escapes + while (i < n) { + if (src[i] === "\\") { + i += 2; // skip backslash and the escaped character + } else if (src[i] === '"') { + i++; // include the closing quote + isClosed = true; + break; + } else { + i++; + } + } + const rawString = src.slice(start, i); + try { + // Let JavaScript's native JSON parser handle all unescaping (\n, \t, \uXXXX, etc.) + // If the string is incomplete (streaming), we add a closing quote to parse what we have so far. + const validJsonString = isClosed ? rawString : rawString + '"'; + tokens.push({ t: T.Str, v: JSON.parse(validJsonString) }); + } catch { + // Fallback if JSON.parse fails (e.g., malformed unicode escape during streaming) + // Strip the quotes and return the raw text so the UI doesn't crash + const stripped = rawString.replace(/^"|"$/g, ""); + tokens.push({ t: T.Str, v: stripped }); + } + continue; + } + + // ── String literal: '...' (single quotes) ──────────────────────── + if (c === "'") { + i++; // skip opening quote + let result = ""; + let isClosed = false; + while (i < n) { + if (src[i] === "\\") { + i++; // skip backslash + if (i < n) { + const esc = src[i]; + if (esc === "'") result += "'"; + else if (esc === "\\") result += "\\"; + else if (esc === "n") result += "\n"; + else if (esc === "t") result += "\t"; + else result += esc; // pass through other escaped chars + i++; + } + } else if (src[i] === "'") { + i++; // skip closing quote + isClosed = true; + break; + } else { + result += src[i]; + i++; + } + } + void isClosed; // consumed for streaming parity with double-quote path + tokens.push({ t: T.Str, v: result }); + continue; + } + + // ── Minus: negative number literal or subtraction operator ──────── + if (c === "-") { + const prev = tokens.length > 0 ? tokens[tokens.length - 1] : null; + const afterValue = + prev != null && + (prev.t === T.Num || + prev.t === T.Str || + prev.t === T.Ident || + prev.t === T.Type || + prev.t === T.RParen || + prev.t === T.RBrack || + prev.t === T.True || + prev.t === T.False || + prev.t === T.Null || + prev.t === T.StateVar || + prev.t === T.BuiltinCall); + + if (!afterValue && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9") { + // Negative number literal — fall through to number parsing below + } else { + // Binary subtraction operator (or unary minus handled by expression parser) + tokens.push({ t: T.Minus }); + i++; + continue; + } + } + + // ── Number literal: 42, -3, 1.5, 1e10 ──────────────────────────── + const isDigit = c >= "0" && c <= "9"; + const isNegDigit = c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9"; + if (isDigit || isNegDigit) { + const start = i; + if (src[i] === "-") i++; // optional minus + while (i < n && src[i] >= "0" && src[i] <= "9") i++; // integer part + if (i < n && src[i] === "." && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9") { + // optional decimal — only if a digit follows the dot + i++; + while (i < n && src[i] >= "0" && src[i] <= "9") i++; + } + if (i < n && (src[i] === "e" || src[i] === "E")) { + // optional exponent + i++; + if (i < n && (src[i] === "+" || src[i] === "-")) i++; + while (i < n && src[i] >= "0" && src[i] <= "9") i++; + } + tokens.push({ t: T.Num, v: +src.slice(start, i) }); + continue; + } + + // ── State variable: $identifier ─────────────────────────────────── + if ( + c === "$" && + i + 1 < n && + ((src[i + 1] >= "a" && src[i + 1] <= "z") || + (src[i + 1] >= "A" && src[i + 1] <= "Z") || + src[i + 1] === "_") + ) { + const start = i; + i++; // skip $ + while ( + i < n && + ((src[i] >= "a" && src[i] <= "z") || + (src[i] >= "A" && src[i] <= "Z") || + (src[i] >= "0" && src[i] <= "9") || + src[i] === "_") + ) + i++; + tokens.push({ t: T.StateVar, v: src.slice(start, i) }); + continue; + } + + // ── Keyword or identifier ───────────────────────────────────────── + const isAlpha = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_"; + if (isAlpha) { + const start = i; + while ( + i < n && + ((src[i] >= "a" && src[i] <= "z") || + (src[i] >= "A" && src[i] <= "Z") || + (src[i] >= "0" && src[i] <= "9") || + src[i] === "_") + ) + i++; + const word = src.slice(start, i); + if (word === "true") { + tokens.push({ t: T.True }); + continue; + } + if (word === "false") { + tokens.push({ t: T.False }); + continue; + } + if (word === "null") { + tokens.push({ t: T.Null }); + continue; + } + // PascalCase → component type name; lowercase → variable reference + const kind = c >= "A" && c <= "Z" ? T.Type : T.Ident; + tokens.push({ t: kind, v: word }); + continue; + } + + // ── Builtin call: @identifier ─────────────────────────────────── + if ( + c === "@" && + i + 1 < n && + ((src[i + 1] >= "a" && src[i + 1] <= "z") || + (src[i + 1] >= "A" && src[i + 1] <= "Z") || + src[i + 1] === "_") + ) { + i++; // skip @ + const start = i; + while ( + i < n && + ((src[i] >= "a" && src[i] <= "z") || + (src[i] >= "A" && src[i] <= "Z") || + (src[i] >= "0" && src[i] <= "9") || + src[i] === "_") + ) + i++; + tokens.push({ t: T.BuiltinCall, v: src.slice(start, i) }); + continue; + } + + i++; // skip any other character (e.g. #, emojis) + } + + tokens.push({ t: T.EOF }); + return tokens; +} diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts new file mode 100644 index 000000000..2a450112d --- /dev/null +++ b/packages/lang-core/src/parser/materialize.ts @@ -0,0 +1,302 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Schema-aware materialization — single-pass lowering +// ───────────────────────────────────────────────────────────────────────────── + +import type { ASTNode } from "./ast"; +import { isASTNode, isRuntimeExpr } from "./ast"; +import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; +import { isElementNode, type ParamMap, type ValidationError } from "./types"; + +/** + * Recursively check if a prop value contains any AST nodes that need runtime + * evaluation. Walks into arrays, ElementNode children, and plain objects. + */ +export function containsDynamicValue(v: unknown): boolean { + if (v == null || typeof v !== "object") return false; + if (isASTNode(v)) return true; + if (Array.isArray(v)) return v.some(containsDynamicValue); + if (isElementNode(v)) { + return Object.values(v.props).some(containsDynamicValue); + } + const obj = v as Record; + return Object.values(obj).some(containsDynamicValue); +} + +export interface MaterializeCtx { + syms: Map; + cat: ParamMap | undefined; + errors: ValidationError[]; + unres: string[]; + visited: Set; + partial: boolean; +} + +/** + * Resolve a Ref node: inline from symbol table, detect cycles, emit RuntimeRef + * for Query/Mutation declarations. Shared by materializeValue and materializeExpr. + */ +function resolveRef(name: string, ctx: MaterializeCtx, mode: "value" | "expr"): unknown | ASTNode { + if (ctx.visited.has(name)) { + ctx.unres.push(name); + return mode === "expr" ? { k: "Ph", n: name } : null; + } + if (!ctx.syms.has(name)) { + ctx.unres.push(name); + return mode === "expr" ? { k: "Ph", n: name } : null; + } + const target = ctx.syms.get(name)!; + // Query/Mutation declarations → RuntimeRef (resolved at runtime by evaluator) + if (target.k === "Comp" && isReservedCall(target.name)) { + const refType = + target.name === RESERVED_CALLS.Mutation ? ("mutation" as const) : ("query" as const); + return { k: "RuntimeRef", n: name, refType }; + } + ctx.visited.add(name); + try { + const result = mode === "value" ? materializeValue(target, ctx) : materializeExpr(target, ctx); + // Tag ElementNode with its source statement name + if (mode === "value" && isElementNode(result)) { + (result as import("./types").ElementNode).statementId = name; + } + return result; + } finally { + ctx.visited.delete(name); + } +} + +/** + * If node is a lazy builtin like Each(arr, varName, template), temporarily + * scope the iterator variable during materialization so template refs resolve. + * Returns the materialized Comp node, or null if not a lazy builtin. + */ +function materializeLazyBuiltin( + node: ASTNode & { k: "Comp" }, + ctx: MaterializeCtx, + scopedRefs: ReadonlySet, +): ASTNode | null { + if (!LAZY_BUILTINS.has(node.name) || node.args.length < 3) return null; + const varArg = node.args[1]; + const varName = varArg.k === "Ref" ? varArg.n : varArg.k === "Str" ? varArg.v : null; + if (!varName) return null; + + const nextScopedRefs = new Set(scopedRefs); + nextScopedRefs.add(varName); + // Skip args[1] (the iterator declaration) but preserve scoped refs elsewhere. + const recursedArgs = node.args.map((a, i) => + i === 1 ? a : materializeExprInternal(a, ctx, nextScopedRefs), + ); + return { ...node, args: recursedArgs }; +} + +function materializeExprInternal( + node: ASTNode, + ctx: MaterializeCtx, + scopedRefs: ReadonlySet, +): ASTNode { + switch (node.k) { + case "Ref": + return scopedRefs.has(node.n) ? node : (resolveRef(node.n, ctx, "expr") as ASTNode); + + case "Ph": + return node; + + case "Comp": { + const lazy = materializeLazyBuiltin(node, ctx, scopedRefs); + if (lazy) return lazy; + const recursedArgs = node.args.map((a) => materializeExprInternal(a, ctx, scopedRefs)); + // Builtins, reserved calls, and action calls: recurse args, keep as-is + if (isBuiltin(node.name) || isReservedCall(node.name)) { + return { ...node, args: recursedArgs }; + } + // Catalog component: add mappedProps for the evaluator + const def = ctx.cat?.get(node.name); + if (def) { + const mappedProps: Record = {}; + for (let i = 0; i < def.params.length && i < recursedArgs.length; i++) { + mappedProps[def.params[i].name] = recursedArgs[i]; + } + return { ...node, args: recursedArgs, mappedProps }; + } + // Unknown component: recurse args + return { ...node, args: recursedArgs }; + } + + case "Arr": + return { ...node, els: node.els.map((e) => materializeExprInternal(e, ctx, scopedRefs)) }; + case "Obj": + return { + ...node, + entries: node.entries.map( + ([k, v]) => [k, materializeExprInternal(v, ctx, scopedRefs)] as [string, ASTNode], + ), + }; + case "BinOp": + return { + ...node, + left: materializeExprInternal(node.left, ctx, scopedRefs), + right: materializeExprInternal(node.right, ctx, scopedRefs), + }; + case "UnaryOp": + return { ...node, operand: materializeExprInternal(node.operand, ctx, scopedRefs) }; + case "Ternary": + return { + ...node, + cond: materializeExprInternal(node.cond, ctx, scopedRefs), + then: materializeExprInternal(node.then, ctx, scopedRefs), + else: materializeExprInternal(node.else, ctx, scopedRefs), + }; + case "Member": + return { ...node, obj: materializeExprInternal(node.obj, ctx, scopedRefs) }; + case "Index": + return { + ...node, + obj: materializeExprInternal(node.obj, ctx, scopedRefs), + index: materializeExprInternal(node.index, ctx, scopedRefs), + }; + case "Assign": + return { ...node, value: materializeExprInternal(node.value, ctx, scopedRefs) }; + + // Literals, StateRef, RuntimeRef — pass through unchanged + default: + return node; + } +} + +/** + * Normalize an AST node for use inside runtime expressions. + * Resolves Refs, adds mappedProps to catalog Comp nodes. + * Returns ASTNode — structure preserved for runtime evaluation by the evaluator. + */ +export function materializeExpr(node: ASTNode, ctx: MaterializeCtx): ASTNode { + return materializeExprInternal(node, ctx, new Set()); +} + +/** + * Schema-aware materialization: resolves refs, normalizes catalog component args + * to named props, validates required props, applies defaults, converts literals + * to plain values, and preserves runtime expressions as AST nodes — all in a + * single recursive traversal. + * + * Returns: + * - ElementNode for catalog/unknown components + * - ASTNode for builtins and runtime expression nodes + * - Plain values for literals, arrays, objects + * - null for placeholders + */ +export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { + switch (node.k) { + // ── Ref resolution ─────────────────────────────────────────────────── + case "Ref": + return resolveRef(node.n, ctx, "value"); + + // ── Literals → plain values ────────────────────────────────────────── + case "Str": + return node.v; + case "Num": + return node.v; + case "Bool": + return node.v; + case "Null": + return null; + case "Ph": + return null; + + // ── Collections ────────────────────────────────────────────────────── + case "Arr": { + const items: unknown[] = []; + for (const e of node.els) { + if (e.k === "Ph") continue; + items.push(materializeValue(e, ctx)); + } + return items; + } + case "Obj": { + const o: Record = {}; + for (const [k, v] of node.entries) o[k] = materializeValue(v, ctx); + return o; + } + + // ── Component nodes ────────────────────────────────────────────────── + case "Comp": { + const { name, args } = node; + + // Builtins (Sum, Count, Filter, Action, etc.) → preserve as ASTNode for runtime + if (isBuiltin(name)) { + const lazy = materializeLazyBuiltin(node, ctx, new Set()); + if (lazy) return lazy; + return { ...node, args: args.map((a) => materializeExpr(a, ctx)) }; + } + + // Inline Query/Mutation (not from a statement-level declaration) → validation error + if (isReservedCall(name)) { + ctx.errors.push({ + component: name, + path: "", + message: `${name}() must be declared as a top-level statement, not used inline as a value`, + }); + return null; + } + + const def = ctx.cat?.get(name); + const props: Record = {}; + + if (def) { + // Catalog component: map positional args → named props + for (let i = 0; i < def.params.length && i < args.length; i++) { + props[def.params[i].name] = materializeValue(args[i], ctx); + } + + // Validate required props — try defaultValue first before dropping + const missingRequired = def.params.filter( + (p) => p.required && (!(p.name in props) || props[p.name] === null), + ); + if (missingRequired.length) { + const stillInvalid = missingRequired.filter((p) => { + if (p.defaultValue !== undefined) { + props[p.name] = p.defaultValue; + return false; + } + return true; + }); + if (stillInvalid.length) { + for (const p of stillInvalid) { + ctx.errors.push({ + component: name, + path: `/${p.name}`, + message: + p.name in props + ? `required field "${p.name}" cannot be null` + : `missing required field "${p.name}"`, + }); + } + return null; + } + } + } else { + // Unknown component: push validation warning, use indexed arg keys for graceful degradation + if (!isBuiltin(name) && !isReservedCall(name)) { + ctx.errors.push({ + component: name, + path: "", + message: `Unknown component "${name}" — not found in catalog or builtins`, + }); + } + for (let i = 0; i < args.length; i++) { + props[`arg${i}`] = materializeValue(args[i], ctx); + } + } + + const hasDynamicProps = Object.values(props).some((v) => containsDynamicValue(v)); + return { type: "element", typeName: name, props, partial: ctx.partial, hasDynamicProps }; + } + + // ── Runtime expression nodes → preserve as ASTNode, normalize children ─ + default: { + if (isRuntimeExpr(node)) { + return materializeExpr(node, ctx); + } + // Unreachable for well-formed AST, but preserve the value defensively. + return node; + } + } +} diff --git a/packages/lang-core/src/parser/merge.ts b/packages/lang-core/src/parser/merge.ts new file mode 100644 index 000000000..61579ca4d --- /dev/null +++ b/packages/lang-core/src/parser/merge.ts @@ -0,0 +1,191 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Edit/Merge for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +import type { ASTNode } from "./ast"; +import { walkAST } from "./ast"; +import { parseExpression } from "./expressions"; +import { tokenize } from "./lexer"; +import { stripFences } from "./parser"; +import { split } from "./statements"; + +interface ParsedStatement { + id: string; + ast: ASTNode; + raw: string; +} + +function splitStatementSource(input: string): string[] { + const stmts: string[] = []; + let depth = 0; + let inStr = false; + let esc = false; + let start = 0; + + for (let i = 0; i < input.length; i++) { + const c = input[i]; + + if (esc) { + esc = false; + continue; + } + if (c === "\\" && inStr) { + esc = true; + continue; + } + if (c === '"') { + inStr = !inStr; + continue; + } + if (inStr) continue; + + if (c === "(" || c === "[" || c === "{") depth++; + else if (c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1); + else if (c === "\n" && depth <= 0) { + const stmt = input.slice(start, i).trim(); + if (stmt) stmts.push(stmt); + start = i + 1; + } + } + + const tail = input.slice(start).trim(); + if (tail) stmts.push(tail); + return stmts; +} + +function parseStatements(input: string): ParsedStatement[] { + const trimmed = input.trim(); + if (!trimmed) return []; + + const result: ParsedStatement[] = []; + for (const raw of splitStatementSource(trimmed)) { + const stmt = split(tokenize(raw))[0]; + if (!stmt) continue; + result.push({ + id: stmt.id, + ast: parseExpression(stmt.tokens), + raw, + }); + } + + return result; +} + +/** + * Recursively collect all Ref names from an AST node. + */ +function collectRefs(node: ASTNode, out: Set): void { + walkAST(node, (current) => { + if (current.k === "Ref") out.add(current.n); + if (current.k === "RuntimeRef") out.add(current.n); + }); +} + +/** + * Remove statements unreachable from `root` (garbage collection). + * Walks the AST graph from root, collecting all referenced statement IDs. + * $state variables are always kept (they're referenced at runtime, not by Ref nodes). + */ +function gcUnreachable( + order: string[], + merged: Map, + asts: Map, + rootId = "root", +): void { + const rootAst = asts.get(rootId); + if (!rootAst) return; // no root → can't GC + + // BFS from root to find all reachable statements + const reachable = new Set([rootId]); + const queue: string[] = [rootId]; + + while (queue.length > 0) { + const id = queue.pop()!; + const ast = asts.get(id); + if (!ast) continue; + + const refs = new Set(); + collectRefs(ast, refs); + + for (const ref of refs) { + if (!reachable.has(ref) && asts.has(ref)) { + reachable.add(ref); + queue.push(ref); + } + } + } + + // Keep $state variables — they're bound at runtime, not via Ref + for (const id of order) { + if (id.startsWith("$")) reachable.add(id); + } + + // Remove unreachable statements + for (let i = order.length - 1; i >= 0; i--) { + if (!reachable.has(order[i])) { + merged.delete(order[i]); + order.splice(i, 1); + } + } +} + +/** + * Merge an existing program with a patch (partial update). + * Patch statements override existing ones by name. + * Unreachable statements are automatically garbage-collected. + * Returns the merged program as a string. + */ +export function mergeStatements(existing: string, patch: string, rootId = "root"): string { + const existingStmts = parseStatements(existing); + const patchStmts = parseStatements(stripFences(patch)); + + if (!existingStmts.length) { + return patchStmts.map((stmt) => stmt.raw).join("\n"); + } + if (!patchStmts.length) return existing; + + // Rewrite guard: if patch re-emits >80% of existing statements, warn + const overlapCount = patchStmts.filter((p) => existingStmts.some((e) => e.id === p.id)).length; + const overlapRatio = existingStmts.length > 0 ? overlapCount / existingStmts.length : 0; + + if (overlapRatio > 0.8 && patchStmts.length >= existingStmts.length * 0.8) { + console.warn( + `[openui merge] Patch re-emits ${Math.round(overlapRatio * 100)}% of existing statements — this looks like a full rewrite, not an edit.`, + ); + } + + // Merge: patch statements override existing by name + const merged = new Map(); + const asts = new Map(); + const order: string[] = []; + + for (const stmt of existingStmts) { + merged.set(stmt.id, stmt.raw); + asts.set(stmt.id, stmt.ast); + order.push(stmt.id); + } + + for (const stmt of patchStmts) { + if (stmt.ast.k === "Null") { + // `name = null` in a patch means "delete this statement" + merged.delete(stmt.id); + asts.delete(stmt.id); + const idx = order.indexOf(stmt.id); + if (idx !== -1) order.splice(idx, 1); + continue; + } + if (!merged.has(stmt.id)) { + order.push(stmt.id); + } + merged.set(stmt.id, stmt.raw); + asts.set(stmt.id, stmt.ast); + } + + // GC: remove statements unreachable from root + gcUnreachable(order, merged, asts, rootId); + + return order + .filter((id) => merged.has(id)) + .map((id) => merged.get(id)!) + .join("\n"); +} diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index de99071b9..e195038b9 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -1,629 +1,237 @@ -import type { ParseResult } from "./types"; - -/** - * The JSON Schema document produced by `library.toJSONSchema()`. - * All component schemas live in `$defs`, keyed by component name. - */ -export interface LibraryJSONSchema { - $defs?: Record< - string, - { - properties?: Record; - required?: string[]; - } - >; -} - -export interface ParamDef { - /** Parameter name, e.g. "title", "columns". */ - name: string; - /** Whether the parameter is required by the component. */ - required: boolean; - /** Default value from JSON Schema — used when the required field is missing/null. */ - defaultValue?: unknown; -} - -/** - * Internal parameter map. - */ -export type ParamMap = Map; +import type { ASTNode, Statement } from "./ast"; +import { isASTNode, walkAST } from "./ast"; +import { isBuiltin, RESERVED_CALLS } from "./builtins"; +import { parseExpression } from "./expressions"; +import { tokenize } from "./lexer"; +import { materializeValue, type MaterializeCtx } from "./materialize"; +import { autoClose, split, type RawStmt } from "./statements"; +import { T } from "./tokens"; +import { + isElementNode, + type LibraryJSONSchema, + type MutationStatementInfo, + type ParamMap, + type ParseResult, + type QueryStatementInfo, + type ValidationError, +} from "./types"; // ───────────────────────────────────────────────────────────────────────────── -// AST node types +// Result building // ───────────────────────────────────────────────────────────────────────────── -/** - * Discriminated union representing every value that can appear in an - * openui-lang expression. The `k` field is the discriminant. - * - * - `Comp` — a component call: `Header("Hello", "Subtitle")` - * - `Str` — a string literal: `"hello"` - * - `Num` — a number literal: `42` or `3.14` - * - `Bool` — a boolean literal: `true` or `false` - * - `Null` — the null literal - * - `Arr` — an array: `[a, b, c]` - * - `Obj` — an object: `{ key: value }` - * - `Ref` — a reference to another statement: `myTable` (resolved later) - * - `Ph` — a placeholder for an unresolvable reference (dropped as null in output) - */ -type ASTNode = - | { k: "Comp"; name: string; args: ASTNode[] } - | { k: "Str"; v: string } - | { k: "Num"; v: number } - | { k: "Bool"; v: boolean } - | { k: "Null" } - | { k: "Arr"; els: ASTNode[] } - | { k: "Obj"; entries: [string, ASTNode][] } - | { k: "Ref"; n: string } - | { k: "Ph"; n: string }; - -const enum T { - Newline = 0, - LParen = 1, // ( - RParen = 2, // ) - LBrack = 3, // [ - RBrack = 4, // ] - LBrace = 5, // { - RBrace = 6, // } - Comma = 7, // , - Colon = 8, // : - Equals = 9, // = - True = 10, - False = 11, - Null = 12, - EOF = 13, - Str = 14, // carries string value - Num = 15, // carries numeric value - Ident = 16, // lowercase identifier — becomes a reference - Type = 17, // PascalCase identifier — becomes a component name or reference -} - -type Token = { t: T; v?: string | number }; - -function autoClose(input: string): { text: string; wasIncomplete: boolean } { - const stack: string[] = []; - let inStr = false, - esc = false; - - for (let i = 0; i < input.length; i++) { - const c = input[i]; - - if (esc) { - esc = false; - continue; - } - if (c === "\\" && inStr) { - esc = true; - continue; - } - if (c === '"') { - inStr = !inStr; - continue; - } - if (inStr) continue; - - if (c === "(" || c === "[" || c === "{") stack.push(c); - else if (c === ")" && stack[stack.length - 1] === "(") stack.pop(); - else if (c === "]" && stack[stack.length - 1] === "[") stack.pop(); - else if (c === "}" && stack[stack.length - 1] === "{") stack.pop(); - } - - const wasIncomplete = inStr || stack.length > 0; - if (!wasIncomplete) return { text: input, wasIncomplete: false }; - - let out = input; - if (inStr) { - if (esc) out += "\\"; - out += '"'; - } // close open string - for ( - let j = stack.length - 1; - j >= 0; - j-- // close brackets in reverse - ) - out += stack[j] === "(" ? ")" : stack[j] === "[" ? "]" : "}"; - - return { text: out, wasIncomplete: true }; -} - -// lexer -function tokenize(src: string): Token[] { - const tokens: Token[] = []; - let i = 0; - const n = src.length; - - while (i < n) { - // Skip horizontal whitespace (not newlines — they're significant) - while (i < n && (src[i] === " " || src[i] === "\t" || src[i] === "\r")) i++; - if (i >= n) break; - - const c = src[i]; - - // ── Single-character punctuation ────────────────────────────────────── - if (c === "\n") { - tokens.push({ t: T.Newline }); - i++; - continue; - } - if (c === "(") { - tokens.push({ t: T.LParen }); - i++; - continue; - } - if (c === ")") { - tokens.push({ t: T.RParen }); - i++; - continue; - } - if (c === "[") { - tokens.push({ t: T.LBrack }); - i++; - continue; - } - if (c === "]") { - tokens.push({ t: T.RBrack }); - i++; - continue; - } - if (c === "{") { - tokens.push({ t: T.LBrace }); - i++; - continue; - } - if (c === "}") { - tokens.push({ t: T.RBrace }); - i++; - continue; - } - if (c === ",") { - tokens.push({ t: T.Comma }); - i++; - continue; - } - if (c === ":") { - tokens.push({ t: T.Colon }); - i++; - continue; - } - if (c === "=") { - tokens.push({ t: T.Equals }); - i++; - continue; - } - - // string literal: "..." - if (c === '"') { - const start = i; - i++; // skip opening quote - - let isClosed = false; - // Fast-forward to the closing quote, respecting escapes - while (i < n) { - if (src[i] === "\\") { - i += 2; // skip backslash and the escaped character - } else if (src[i] === '"') { - i++; // include the closing quote - isClosed = true; - break; - } else { - i++; - } - } - - const rawString = src.slice(start, i); - - try { - // Let JavaScript's native JSON parser handle all unescaping (\n, \t, \uXXXX, etc.) - // If the string is incomplete (streaming), we add a closing quote to parse what we have so far. - const validJsonString = isClosed ? rawString : rawString + '"'; - - tokens.push({ t: T.Str, v: JSON.parse(validJsonString) }); - } catch { - // Fallback if JSON.parse fails (e.g., malformed unicode escape during streaming) - // Strip the quotes and return the raw text so the UI doesn't crash - const stripped = rawString.replace(/^"|"$/g, ""); - tokens.push({ t: T.Str, v: stripped }); - } - continue; - } - - // number literal: 42, -3, 1.5 - const isDigit = c >= "0" && c <= "9"; - const isNegDigit = c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9"; - if (isDigit || isNegDigit) { - const start = i; - if (src[i] === "-") i++; // optional minus - while (i < n && src[i] >= "0" && src[i] <= "9") i++; // integer part - if (i < n && src[i] === ".") { - // optional decimal - i++; - while (i < n && src[i] >= "0" && src[i] <= "9") i++; - } - if (i < n && (src[i] === "e" || src[i] === "E")) { - // optional exponent - i++; - if (i < n && (src[i] === "+" || src[i] === "-")) i++; - while (i < n && src[i] >= "0" && src[i] <= "9") i++; - } - tokens.push({ t: T.Num, v: +src.slice(start, i) }); - continue; - } - - // keyword or identifier - const isAlpha = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_"; - if (isAlpha) { - const start = i; - while ( - i < n && - ((src[i] >= "a" && src[i] <= "z") || - (src[i] >= "A" && src[i] <= "Z") || - (src[i] >= "0" && src[i] <= "9") || - src[i] === "_") - ) - i++; - - const word = src.slice(start, i); - - if (word === "true") { - tokens.push({ t: T.True }); - continue; - } - if (word === "false") { - tokens.push({ t: T.False }); - continue; - } - if (word === "null") { - tokens.push({ t: T.Null }); - continue; - } - - // PascalCase → component type name; lowercase → variable reference - const kind = c >= "A" && c <= "Z" ? T.Type : T.Ident; - tokens.push({ t: kind, v: word }); - continue; - } - - i++; // skip any other character (e.g. @, #, emojis) - } - - tokens.push({ t: T.EOF }); - return tokens; -} - -interface RawStmt { - id: string; - tokens: Token[]; +function emptyResult(incomplete = true): ParseResult { + return { + root: null, + meta: { + incomplete, + unresolved: [], + statementCount: 0, + validationErrors: [], + }, + stateDeclarations: {}, + queryStatements: [], + mutationStatements: [], + }; } /** - * Splits the flat token stream into individual statements. - * - * Each statement has the form `identifier = expression`. Statements are - * separated by newlines at depth 0 (newlines inside brackets are ignored). - * - * Example input tokens for: - * `root = Root([tbl])\ntbl = Table(...)` - * - * Produces two RawStmts: - * { id: "root", tokens: [Root, (, [, tbl, ], )] } - * { id: "tbl", tokens: [Table, (, ..., )] } - * - * Invalid lines (no `=`, or no identifier) are silently skipped. + * Walk an AST node to collect all StateRef ($variable) names referenced + * within. Used at parse time to pre-compute per-query state dependencies. */ -function split(tokens: Token[]): RawStmt[] { - const stmts: RawStmt[] = []; - let pos = 0; - - while (pos < tokens.length) { - // Skip blank lines - while (pos < tokens.length && tokens[pos].t === T.Newline) pos++; - if (pos >= tokens.length || tokens[pos].t === T.EOF) break; - - // Expect: Ident|Type = expression - const tok = tokens[pos]; - if (tok.t !== T.Ident && tok.t !== T.Type) { - while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) pos++; - continue; - } - const id = tok.v as string; - pos++; - - // Must be followed by `=` - if (pos >= tokens.length || tokens[pos].t !== T.Equals) { - while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) pos++; - continue; - } - pos++; - - // Collect expression tokens until a depth-0 newline or EOF - const expr: Token[] = []; - let depth = 0; - while (pos < tokens.length && tokens[pos].t !== T.EOF) { - const tt = tokens[pos].t; - if (tt === T.Newline && depth <= 0) break; // statement boundary - if (tt === T.Newline) { - pos++; - continue; - } // newline inside bracket — skip - if (tt === T.LParen || tt === T.LBrack || tt === T.LBrace) depth++; - else if (tt === T.RParen || tt === T.RBrack || tt === T.RBrace) depth--; - expr.push(tokens[pos++]); - } - - if (expr.length) stmts.push({ id, tokens: expr }); - } - - return stmts; +export function collectQueryDeps(node: unknown): string[] { + if (!isASTNode(node)) return []; + const refs = new Set(); + walkAST(node, (current) => { + if (current.k === "StateRef") refs.add(current.n); + }); + return [...refs]; } -function parseTokens(tokens: Token[]): ASTNode { - let pos = 0; - const cur = (): Token => tokens[pos] ?? { t: T.EOF }; - const adv = () => pos++; - const eat = (kind: T) => { - if (cur().t === kind) adv(); - }; - - function parseExpr(): ASTNode { - const tok = cur(); - - if (tok.t === T.Type) { - // PascalCase followed by `(` → component call; otherwise a reference - return tokens[pos + 1]?.t === T.LParen - ? parseComp() - : (adv(), { k: "Ref", n: tok.v as string }); - } - if (tok.t === T.Str) { - adv(); - return { k: "Str", v: tok.v as string }; - } - if (tok.t === T.Num) { - adv(); - return { k: "Num", v: tok.v as number }; - } - if (tok.t === T.True) { - adv(); - return { k: "Bool", v: true }; - } - if (tok.t === T.False) { - adv(); - return { k: "Bool", v: false }; - } - if (tok.t === T.Null) { - adv(); - return { k: "Null" }; - } - if (tok.t === T.LBrack) return parseArr(); - if (tok.t === T.LBrace) return parseObj(); - if (tok.t === T.Ident) { - adv(); - return { k: "Ref", n: tok.v as string }; - } - - adv(); - return { k: "Null" }; // unknown token — treat as null - } - - /** Parse `TypeName(arg1, arg2, ...)` */ - function parseComp(): ASTNode { - const name = cur().v as string; - adv(); - eat(T.LParen); - const args: ASTNode[] = []; - while (cur().t !== T.RParen && cur().t !== T.EOF) { - args.push(parseExpr()); - if (cur().t === T.Comma) adv(); - } - eat(T.RParen); - return { k: "Comp", name, args }; - } - - /** Parse `[elem1, elem2, ...]` */ - function parseArr(): ASTNode { - adv(); // skip [ - const els: ASTNode[] = []; - while (cur().t !== T.RBrack && cur().t !== T.EOF) { - els.push(parseExpr()); - if (cur().t === T.Comma) adv(); - } - eat(T.RBrack); - return { k: "Arr", els }; - } - - /** Parse `{ key: value, ... }` */ - function parseObj(): ASTNode { - adv(); // skip { - const entries: [string, ASTNode][] = []; - while (cur().t !== T.RBrace && cur().t !== T.EOF) { - const kt = cur(); - const key = - kt.t === T.Ident || kt.t === T.Str || kt.t === T.Type || kt.t === T.Num - ? (adv(), String(kt.v)) - : (adv(), "?"); - eat(T.Colon); - entries.push([key, parseExpr()]); - if (cur().t === T.Comma) adv(); - } - eat(T.RBrace); - return { k: "Obj", entries }; - } - - return parseExpr(); -} - -function resolveNode( - node: ASTNode, - syms: Map, - unres: string[], - visited: Set, -): ASTNode { - if (node.k === "Ref") { - const { n } = node; - if (visited.has(n)) { - unres.push(n); - return { k: "Ph", n }; - } // cycle - if (!syms.has(n)) { - unres.push(n); - return { k: "Ph", n }; - } // missing - - visited.add(n); - const resolved = resolveNode(syms.get(n)!, syms, unres, visited); - visited.delete(n); - return resolved; - } - - if (node.k === "Comp") - return { - ...node, - args: node.args.map((a) => resolveNode(a, syms, unres, visited)), - }; - if (node.k === "Arr") +/** + * Classify a raw statement + parsed expression into a typed Statement. + * Determined at parse time from token type + expression shape. + */ +function classifyStatement(raw: RawStmt, expr: ASTNode): Statement { + // Query(...) → query declaration — check BEFORE $var to handle `$foo = Query(...)` correctly + if (expr.k === "Comp" && expr.name === RESERVED_CALLS.Query) { + const deps = collectQueryDeps(expr.args[1]); return { - ...node, - els: node.els.map((e) => resolveNode(e, syms, unres, visited)), + kind: "query", + id: raw.id, + call: { callee: RESERVED_CALLS.Query, args: expr.args }, + expr, + deps: deps.length > 0 ? deps : undefined, }; - if (node.k === "Obj") + } + // Mutation(...) → mutation declaration + if (expr.k === "Comp" && expr.name === RESERVED_CALLS.Mutation) { return { - ...node, - entries: node.entries.map(([k, v]) => [k, resolveNode(v, syms, unres, visited)]), + kind: "mutation", + id: raw.id, + call: { callee: RESERVED_CALLS.Mutation, args: expr.args }, + expr, }; - - // Literals and placeholders pass through unchanged - return node; + } + // $variables → state declaration + if (raw.idTokenType === T.StateVar) { + return { kind: "state", id: raw.id, init: expr }; + } + // Everything else → value declaration + return { kind: "value", id: raw.id, expr }; } -type JsonVal = string | number | boolean | null | JsonVal[] | { [k: string]: JsonVal }; - -function toJson( - node: ASTNode, - partial: boolean, - errors: ParseResult["meta"]["errors"], - cat: ParamMap | undefined, -): JsonVal { - if (node.k === "Str") return node.v; - if (node.k === "Num") return node.v; - if (node.k === "Bool") return node.v; - if (node.k === "Null") return null; - if (node.k === "Arr") { - const items: JsonVal[] = []; - for (const e of node.els) { - // Drop unresolved references from arrays to avoid null entries like [null, element] - if (e.k === "Ph") continue; - const value = toJson(e, partial, errors, cat); - // Drop invalid component entries from arrays (e.g. incomplete required props while streaming) - if (e.k === "Comp" && value === null) continue; - items.push(value); +/** Build a symbol table (Map) from typed statements for materializeValue. */ +function buildSymbolTable(stmtMap: Map): Map { + const m = new Map(); + for (const [id, stmt] of stmtMap) { + switch (stmt.kind) { + case "value": + m.set(id, stmt.expr); + break; + case "state": + m.set(id, stmt.init); + break; + case "query": + m.set(id, stmt.expr); + break; + case "mutation": + m.set(id, stmt.expr); + break; } - return items; - } - if (node.k === "Obj") { - const o: { [k: string]: JsonVal } = {}; - for (const [k, v] of node.entries) o[k] = toJson(v, partial, errors, cat); - return o; } - if (node.k === "Comp") return mapNode(node, partial, errors, cat) as unknown as JsonVal; - if (node.k === "Ph") return null; - return null; + return m; } -function mapNode( - node: ASTNode, - partial: boolean, - errors: ParseResult["meta"]["errors"], - cat: ParamMap | undefined, -): ParseResult["root"] { - if (node.k === "Ph") return null; - if (node.k !== "Comp") return null; - - const { name, args } = node; - const def = cat?.get(name); - const props: { [k: string]: JsonVal } = {}; - - if (def) { - // Map positional args → named props using library param order - for (let i = 0; i < def.params.length && i < args.length; i++) - props[def.params[i].name] = toJson(args[i], partial, errors, cat); - - // Report extra positional args that have no corresponding param - if (args.length > def.params.length) { - errors.push({ - type: "validation", - code: "excess-args", - component: name, - path: "", - message: `${name} takes ${def.params.length} arg(s), got ${args.length}`, - }); +/** + * Extract typed statements from the symbol table. + * State defaults are materialized to plain values (no raw AST in output). + */ +function extractStatements( + stmts: Statement[], + ctx: MaterializeCtx, +): { + stateDeclarations: Record; + queryStatements: QueryStatementInfo[]; + mutationStatements: MutationStatementInfo[]; +} { + const stateDeclarations: Record = {}; + const queryStatements: QueryStatementInfo[] = []; + const mutationStatements: MutationStatementInfo[] = []; + + for (const stmt of stmts) { + switch (stmt.kind) { + case "state": + stateDeclarations[stmt.id] = materializeValue(stmt.init, ctx); + break; + case "query": + queryStatements.push({ + statementId: stmt.id, + toolAST: stmt.call.args[0] ?? null, + argsAST: stmt.call.args[1] ?? null, + defaultsAST: stmt.call.args[2] ?? null, + refreshAST: stmt.call.args[3] ?? null, + deps: stmt.deps, + complete: true, + }); + break; + case "mutation": + mutationStatements.push({ + statementId: stmt.id, + toolAST: stmt.call.args[0] ?? null, + argsAST: stmt.call.args[1] ?? null, + }); + break; } + } - // Validate required props — try defaultValue first before dropping - const missingRequired = def.params.filter( - (p) => p.required && (!(p.name in props) || props[p.name] === null), - ); - if (missingRequired.length) { - const stillInvalid = missingRequired.filter((p) => { - if (p.defaultValue !== undefined) { - props[p.name] = p.defaultValue as JsonVal; - return false; + // Auto-declare: any $var referenced in code but not explicitly declared → null default. + // collectQueryDeps already walks AST for StateRef nodes — reuse it here. + for (const stmt of stmts) { + const nodes = + stmt.kind === "state" + ? [stmt.init] + : stmt.kind === "value" + ? [stmt.expr] + : stmt.kind === "query" || stmt.kind === "mutation" + ? stmt.call.args + : []; + for (const node of nodes) { + for (const dep of collectQueryDeps(node)) { + if (!(dep in stateDeclarations)) { + stateDeclarations[dep] = null; } - return true; - }); - if (stillInvalid.length) { - for (const p of stillInvalid) - errors.push({ - type: "validation", - code: p.name in props ? "null-required" : "missing-required", - component: name, - path: `/${p.name}`, - message: - p.name in props - ? `required field "${p.name}" cannot be null` - : `missing required field "${p.name}"`, - }); - return null; } } - } else { - // Component name not found in schema — report and preserve args for debugging - errors.push({ - type: "validation", - code: "unknown-component", - component: name, - path: "", - message: `unknown component "${name}"`, - }); - props._args = args.map((a) => toJson(a, partial, errors, cat)); } - return { type: "element", typeName: name, props, partial }; + return { stateDeclarations, queryStatements, mutationStatements }; } -function emptyResult(incomplete = true): ParseResult { - return { - root: null, - meta: { - incomplete, - unresolved: [], - statementCount: 0, - errors: [], - }, - }; +const DEFAULT_ROOT_STATEMENT_ID = "root"; + +function isComponentStatement( + stmt: Statement, +): stmt is Extract & { expr: Extract } { + return ( + stmt.kind === "value" && + stmt.expr.k === "Comp" && + !isBuiltin(stmt.expr.name) && + stmt.expr.name !== RESERVED_CALLS.Query && + stmt.expr.name !== RESERVED_CALLS.Mutation + ); +} + +function pickEntryId( + stmtMap: Map, + typedStmts: Statement[], + firstId: string, + rootName?: string, +): string { + if (stmtMap.has(DEFAULT_ROOT_STATEMENT_ID)) return DEFAULT_ROOT_STATEMENT_ID; + if (rootName && stmtMap.has(rootName)) return rootName; + + const preferredComponent = rootName + ? typedStmts.find((stmt) => isComponentStatement(stmt) && stmt.expr.name === rootName) + : undefined; + if (preferredComponent) return preferredComponent.id; + + const firstComponent = typedStmts.find(isComponentStatement); + return firstComponent?.id ?? firstId; } function buildResult( - syms: Map, + stmtMap: Map, + typedStmts: Statement[], firstId: string, wasIncomplete: boolean, stmtCount: number, cat: ParamMap | undefined, + rootName?: string, ): ParseResult { - if (!syms.has(firstId)) return emptyResult(wasIncomplete); + const entryId = pickEntryId(stmtMap, typedStmts, firstId, rootName); + if (!stmtMap.has(entryId)) return emptyResult(wasIncomplete); + const syms = buildSymbolTable(stmtMap); const unres: string[] = []; - const resolved = resolveNode(syms.get(firstId)!, syms, unres, new Set()); - const errors: ParseResult["meta"]["errors"] = []; - const root = mapNode(resolved, wasIncomplete, errors, cat); + const errors: ValidationError[] = []; + const ctx: MaterializeCtx = { + syms, + cat, + errors, + unres, + visited: new Set(), + partial: wasIncomplete, + }; + const materialized = materializeValue(syms.get(entryId)!, ctx); + + const root = isElementNode(materialized) ? materialized : null; + if (root) root.statementId = entryId; + + const { stateDeclarations, queryStatements, mutationStatements } = extractStatements( + typedStmts, + ctx, + ); return { root, @@ -631,11 +239,140 @@ function buildResult( incomplete: wasIncomplete, unresolved: unres, statementCount: stmtCount, - errors, + validationErrors: errors, }, + stateDeclarations, + queryStatements, + mutationStatements, }; } +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** Extract code from markdown fences, or return as-is if no fences found. + * String-context-aware: skips ``` inside double-quoted strings. */ +export function stripFences(input: string): string { + const blocks: string[] = []; + let i = 0; + + while (i < input.length) { + // Look for opening ``` + const fenceStart = input.indexOf("```", i); + if (fenceStart === -1) break; + + // Skip language tag until newline + let j = fenceStart + 3; + while (j < input.length && input[j] !== "\n") j++; + if (j >= input.length) { + // No newline after opening fence (streaming) — take everything after fence marker + lang tag + blocks.push(input.slice(fenceStart + 3).replace(/^[^\n]*\n?/, "")); + i = input.length; + break; + } + j++; // skip the newline + + // Scan for closing ``` while tracking string context + let inStr = false; + let closePos = -1; + let k = j; + while (k < input.length) { + const c = input[k]; + if (inStr) { + if (c === "\\" && k + 1 < input.length) { + k += 2; // skip escaped character + continue; + } + if (c === '"') inStr = false; + k++; + continue; + } + // Not in string + if (c === '"') { + inStr = true; + k++; + continue; + } + if ( + c === "`" && + k + 1 < input.length && + input[k + 1] === "`" && + k + 2 < input.length && + input[k + 2] === "`" + ) { + closePos = k; + break; + } + k++; + } + + if (closePos !== -1) { + blocks.push(input.slice(j, closePos)); + i = closePos + 3; + } else { + // No closing fence found (streaming) — take everything after opening fence + blocks.push(input.slice(j)); + i = input.length; + } + } + + if (blocks.length > 0) return blocks.join("\n"); + + // Fallback: if input starts with ``` but wasn't matched (e.g. no newline after fence) + if (input.startsWith("```")) { + let j = 3; + while (j < input.length && input[j] !== "\n") j++; + const start = j < input.length ? j + 1 : 3; + // Try to strip trailing ``` + const body = input.slice(start); + const trailingFence = body.lastIndexOf("```"); + if (trailingFence !== -1) { + return body.slice(0, trailingFence); + } + return body; + } + + return input; +} + +/** Strip // and # line comments outside of strings. */ +function stripComments(input: string): string { + return input + .split("\n") + .map((line) => { + let inStr = false; + for (let i = 0; i < line.length; i++) { + if (line[i] === '"') { + let backslashes = 0; + let bi = i - 1; + while (bi >= 0 && line[bi] === "\\") { + backslashes++; + bi--; + } + if (backslashes % 2 === 0) inStr = !inStr; + } + if (!inStr) { + // // style comments + if (line[i] === "/" && line[i + 1] === "/") { + return line.substring(0, i).trimEnd(); + } + // # style comments (Python/YAML style — LLMs sometimes use these) + if (line[i] === "#") { + return line.substring(0, i).trimEnd(); + } + } + } + return line; + }) + .join("\n"); +} + +/** Clean LLM response: strip fences, comments, whitespace. */ +function preprocess(input: string): string { + return stripComments(stripFences(input.trim())).trim(); +} + /** * Parse a complete openui-lang string in one pass. * @@ -643,42 +380,59 @@ function buildResult( * @param cat - Optional param map for positional-arg → named-prop mapping * @returns ParseResult with root ElementNode (or null) and metadata */ -export function parse(input: string, cat?: ParamMap): ParseResult { - const trimmed = input.trim(); +export function parse(input: string, cat?: ParamMap, rootName?: string): ParseResult { + const trimmed = preprocess(input); if (!trimmed) return emptyResult(); const { text, wasIncomplete } = autoClose(trimmed); const stmts = split(tokenize(text)); if (!stmts.length) return emptyResult(wasIncomplete); - const syms = new Map(); + const stmtMap = new Map(); let firstId = ""; for (const s of stmts) { - syms.set(s.id, parseTokens(s.tokens)); + const expr = parseExpression(s.tokens); + const stmt = classifyStatement(s, expr); + if (stmtMap.has(s.id)) { + console.warn( + `[openui parse] Duplicate statement ID "${s.id}" — later definition overwrites earlier one.`, + ); + } + stmtMap.set(s.id, stmt); if (!firstId) firstId = s.id; } + // Derive from map to deduplicate — Map.set overwrites duplicates + const typedStmts = [...stmtMap.values()]; - return buildResult(syms, firstId, wasIncomplete, stmts.length, cat); + return buildResult(stmtMap, typedStmts, firstId, wasIncomplete, stmtMap.size, cat, rootName); } export interface StreamParser { /** Feed the next SSE/stream chunk and get the latest ParseResult. */ push(chunk: string): ParseResult; + /** Set the full text — diffs against internal buffer, pushes only the delta. + * Resets automatically if the text was replaced (not appended). */ + set(fullText: string): ParseResult; /** Get the latest ParseResult without consuming new data. */ getResult(): ParseResult; } -export function createStreamParser(cat?: ParamMap): StreamParser { +export function createStreamParser(cat?: ParamMap, rootName?: string): StreamParser { let buf = ""; let completedEnd = 0; - const completedSyms = new Map(); + const completedStmtMap = new Map(); let completedCount = 0; let firstId = ""; function addStmt(text: string) { - for (const s of split(tokenize(text))) { - completedSyms.set(s.id, parseTokens(s.tokens)); + // Strip comments and skip fence markers + const cleaned = stripComments(text).trim(); + if (!cleaned || /^```/.test(cleaned)) return; + for (const s of split(tokenize(cleaned))) { + const expr = parseExpression(s.tokens); + const stmt = classifyStatement(s, expr); + completedStmtMap.set(s.id, stmt); completedCount++; if (!firstId) firstId = s.id; } @@ -686,6 +440,7 @@ export function createStreamParser(cat?: ParamMap): StreamParser { function scanNewCompleted(): number { let depth = 0, + ternaryDepth = 0, inStr = false, esc = false; let stmtStart = completedEnd; @@ -707,8 +462,22 @@ export function createStreamParser(cat?: ParamMap): StreamParser { if (inStr) continue; if (c === "(" || c === "[" || c === "{") depth++; - else if (c === ")" || c === "]" || c === "}") depth--; - else if (c === "\n" && depth <= 0) { + else if (c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1); + // Track ternary ? and : at bracket depth 0 (colons inside {} are object key separators) + else if (c === "?" && depth === 0) ternaryDepth++; + else if (c === ":" && depth === 0 && ternaryDepth > 0) ternaryDepth--; + else if (c === "\n" && depth <= 0 && ternaryDepth <= 0) { + // Before splitting, look ahead past whitespace to see if the next + // meaningful character is `?` or `:` — ternary continuation. + let peek = i + 1; + while ( + peek < buf.length && + (buf[peek] === " " || buf[peek] === "\t" || buf[peek] === "\r" || buf[peek] === "\n") + ) + peek++; + if (peek < buf.length && (buf[peek] === "?" || (buf[peek] === ":" && ternaryDepth > 0))) { + continue; // ternary continuation — don't split + } // Depth-0 newline = end of a statement const t = buf.slice(stmtStart, i).trim(); if (t) addStmt(t); @@ -727,25 +496,80 @@ export function createStreamParser(cat?: ParamMap): StreamParser { // No pending text — all statements are complete if (!pendingText) { if (completedCount === 0) return emptyResult(); - return buildResult(completedSyms, firstId, false, completedCount, cat); + return buildResult( + completedStmtMap, + [...completedStmtMap.values()], + firstId, + false, + completedCount, + cat, + rootName, + ); + } + + // Apply same cleanup as parse() — strip fences, comments, whitespace + const cleaned = stripComments(stripFences(pendingText)).trim(); + if (!cleaned) { + if (completedCount === 0) return emptyResult(); + return buildResult( + completedStmtMap, + [...completedStmtMap.values()], + firstId, + false, + completedCount, + cat, + rootName, + ); } - // Autoclose the incomplete last statement so it's syntactically valid - const { text: closed, wasIncomplete } = autoClose(pendingText); + const { text: closed, wasIncomplete } = autoClose(cleaned); const stmts = split(tokenize(closed)); if (!stmts.length) { if (completedCount === 0) return emptyResult(wasIncomplete); - return buildResult(completedSyms, firstId, wasIncomplete, completedCount, cat); - } - - // Merge: completed cache + re-parsed pending statement - // (Map spread is cheap since completedSyms only grows by one entry at a time) - const allSyms = new Map(completedSyms); - for (const s of stmts) allSyms.set(s.id, parseTokens(s.tokens)); + return buildResult( + completedStmtMap, + [...completedStmtMap.values()], + firstId, + wasIncomplete, + completedCount, + cat, + rootName, + ); + } + + // Merge: completed cache + re-parsed pending statement. + // Pending statements can only add NEW IDs — they cannot overwrite completed ones. + // This prevents mid-stream partial text (e.g. `root = Card`) from corrupting + // existing completed statements during edit streaming. + const allStmtMap = new Map(completedStmtMap); + for (const s of stmts) { + if (completedStmtMap.has(s.id)) continue; + const expr = parseExpression(s.tokens); + const stmt = classifyStatement(s, expr); + allStmtMap.set(s.id, stmt); + } + // Derive from map to deduplicate + const allTypedStmts = [...allStmtMap.values()]; const fid = firstId || stmts[0].id; - return buildResult(allSyms, fid, wasIncomplete, completedCount + stmts.length, cat); + return buildResult( + allStmtMap, + allTypedStmts, + fid, + wasIncomplete, + completedCount + stmts.length, + cat, + rootName, + ); + } + + function reset() { + buf = ""; + completedEnd = 0; + completedStmtMap.clear(); + completedCount = 0; + firstId = ""; } return { @@ -753,6 +577,14 @@ export function createStreamParser(cat?: ParamMap): StreamParser { buf += chunk; return currentResult(); }, + set(fullText) { + if (fullText.length < buf.length || !fullText.startsWith(buf)) { + reset(); + } + const delta = fullText.slice(buf.length); + if (delta) buf += delta; + return currentResult(); + }, getResult: currentResult, }; } @@ -761,6 +593,13 @@ export interface Parser { parse(input: string): ParseResult; } +function getSchemaDefaultValue(property: unknown): unknown { + if (!property || typeof property !== "object" || Array.isArray(property)) { + return undefined; + } + return (property as { default?: unknown }).default; +} + function compileSchema(schema: LibraryJSONSchema): ParamMap { const map: ParamMap = new Map(); const defs = schema.$defs ?? {}; @@ -768,10 +607,10 @@ function compileSchema(schema: LibraryJSONSchema): ParamMap { for (const [name, def] of Object.entries(defs)) { const properties = def.properties ?? {}; const required = def.required ?? []; - const params = Object.keys(properties).map((k) => ({ - name: k, - required: required.includes(k), - defaultValue: (properties[k] as any)?.default, + const params = Object.keys(properties).map((key) => ({ + name: key, + required: required.includes(key), + defaultValue: getSchemaDefaultValue(properties[key]), })); map.set(name, { params }); } @@ -789,11 +628,11 @@ function compileSchema(schema: LibraryJSONSchema): ParamMap { * const result = parser.parse(openuiLangString); * ``` */ -export function createParser(schema: LibraryJSONSchema): Parser { +export function createParser(schema: LibraryJSONSchema, rootName?: string): Parser { const paramMap = compileSchema(schema); return { parse(input: string): ParseResult { - return parse(input, paramMap); + return parse(input, paramMap, rootName); }, }; } @@ -802,6 +641,6 @@ export function createParser(schema: LibraryJSONSchema): Parser { * Create a streaming parser from a library JSON Schema document. * Pass `library.toJSONSchema()` to get the schema. */ -export function createStreamingParser(schema: LibraryJSONSchema): StreamParser { - return createStreamParser(compileSchema(schema)); +export function createStreamingParser(schema: LibraryJSONSchema, rootName?: string): StreamParser { + return createStreamParser(compileSchema(schema), rootName); } diff --git a/packages/lang-core/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts index 11119d678..915b60c0a 100644 --- a/packages/lang-core/src/parser/prompt.ts +++ b/packages/lang-core/src/parser/prompt.ts @@ -1,318 +1,590 @@ -import { z } from "zod"; -import type { DefinedComponent, Library, PromptOptions } from "../library"; +import { BUILTINS, LAZY_BUILTIN_DEFS } from "./builtins"; -const PREAMBLE = `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.`; +// ─── PromptSpec types (JSON-serializable, no Zod deps) ────────────────────── -function syntaxRules(rootName: string): string { - return `## Syntax Rules - -1. Each statement is on its own line: \`identifier = Expression\` -2. \`root\` is the entry point — every program must define \`root = ${rootName}(...)\` -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`; +export interface McpToolSpec { + name: string; + description?: string; + inputSchema?: Record; + outputSchema?: Record; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; } -function streamingRules(rootName: string): string { - return `## Hoisting & Streaming (CRITICAL) +export interface ComponentPromptSpec { + signature: string; // pre-built: "Card(children: Component[], title?: string)" + description?: string; +} -openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. +export interface ComponentGroup { + name: string; + components: string[]; + notes?: string[]; +} -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. +export interface PromptSpec { + root?: string; + components: Record; + componentGroups?: ComponentGroup[]; + tools?: (string | McpToolSpec)[]; + editMode?: boolean; + inlineMode?: boolean; + preamble?: string; + /** Examples shown when no tools are present (static/layout patterns). */ + examples?: string[]; + /** Examples shown when tools ARE present (Query/Mutation patterns). Takes priority over `examples` when tools exist. */ + toolExamples?: string[]; + additionalRules?: string[]; +} -**Recommended statement order for optimal streaming:** -1. \`root = ${rootName}(...)\` — UI shell appears immediately -2. Component definitions — fill in as they stream -3. Data values — leaf content last +// ─── JSON Schema → type string helper ─────────────────────────────────────── -Always write the root = ${rootName}(...) statement first so the UI shell appears immediately, even before child data has streamed in.`; -} +function jsonSchemaTypeStr(schema: Record): string { + const type = schema.type as string | undefined; -function importantRules(rootName: string): string { - return `## Important Rules -- ALWAYS start with root = ${rootName}(...) -- 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.`; -} + if (type === "string") { + const enumVals = schema.enum as string[] | undefined; + if (enumVals) return enumVals.map((v) => `"${v}"`).join(" | "); + return "string"; + } + if (type === "number" || type === "integer") return "number"; + if (type === "boolean") return "boolean"; + if (type === "array") { + const items = schema.items as Record | undefined; + if (items) return `${jsonSchemaTypeStr(items)}[]`; + return "any[]"; + } + if (type === "object") { + const props = schema.properties as Record> | undefined; + if (props && Object.keys(props).length > 0) { + const required = (schema.required as string[]) ?? []; + const fields = Object.entries(props).map(([k, v]) => { + const opt = required.includes(k) ? "" : "?"; + return `${k}${opt}: ${jsonSchemaTypeStr(v)}`; + }); + return `{${fields.join(", ")}}`; + } + return "object"; + } -function getZodDef(schema: unknown): any { - return (schema as any)?._zod?.def; + return "any"; } -function getZodType(schema: unknown): string | undefined { - return getZodDef(schema)?.type; +/** Generate a default-values hint object for an output schema. */ +function defaultForSchema(schema: Record): unknown { + const type = schema.type as string | undefined; + if (type === "string") return ""; + if (type === "number" || type === "integer") return 0; + if (type === "boolean") return false; + if (type === "array") return []; + if (type === "object") { + const props = schema.properties as Record> | undefined; + if (props && Object.keys(props).length > 0) { + const result: Record = {}; + for (const [k, v] of Object.entries(props)) { + result[k] = defaultForSchema(v); + } + return result; + } + return {}; + } + return null; } -function isOptionalType(schema: unknown): boolean { - return getZodType(schema) === "optional"; -} +// ─── Section generators ───────────────────────────────────────────────────── -function unwrapOptional(schema: unknown): unknown { - const def = getZodDef(schema); - if (def?.type === "optional") return def.innerType; - return schema; -} +const PREAMBLE = `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.`; + +function syntaxRules(rootName: string, hasTools: boolean): string { + const lines = [ + "## Syntax Rules", + "", + "1. Each statement is on its own line: `identifier = Expression`", + `2. \`root\` is the entry point — every program must define \`root = ${rootName}(...)\``, + '3. Expressions are: strings ("..."), numbers, booleans (true/false), null, 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). Write `Stack([children], "row", "l")` NOT `Stack([children], direction: "row", gap: "l")` — colon syntax is NOT supported and silently breaks', + "7. Optional arguments can be omitted from the end", + ]; + + if (hasTools) { + lines.push( + "8. Declare mutable state with `$varName = defaultValue`. Components marked with `$binding` can read/write these. Undeclared $variables are auto-created with null default.", + '9. String concatenation: `"text" + $var + "more"`', + "10. Dot member access: `query.field` reads a field; on arrays it extracts that field from every element", + "11. Index access: `arr[0]`, `data[index]`", + "12. Arithmetic operators: +, -, *, /, % (work on numbers; + is string concat when either side is a string)", + "13. Comparison: ==, !=, >, <, >=, <=", + "14. Logical: &&, ||, ! (prefix)", + "15. Ternary: `condition ? valueIfTrue : valueIfFalse`", + "16. Parentheses for grouping: `(a + b) * c`", + ); + } + + lines.push("- Strings use double quotes with backslash escaping"); -/** Strip optional wrapper to reach the core schema. */ -function unwrap(schema: unknown): unknown { - return unwrapOptional(schema); + return lines.join("\n"); } -function isArrayType(schema: unknown): boolean { - const s = unwrap(schema); - return getZodType(s) === "array"; +function builtinFunctionsSection(): string { + // Auto-generated from shared builtin registry — single source of truth + const builtinLines = Object.values(BUILTINS).map((b) => `@${b.signature} — ${b.description}`); + const lazyLines = Object.values(LAZY_BUILTIN_DEFS).map( + (b) => `@${b.signature} — ${b.description}`, + ); + const lines = [...builtinLines, ...lazyLines].join("\n"); + + return `## Built-in Functions + +Data functions prefixed with \`@\` to distinguish from components. These are the ONLY functions available — do NOT invent new ones. +Use @-prefixed built-in functions (@Count, @Sum, @Avg, @Min, @Max, @Round) on Query results — do NOT hardcode computed values. + +${lines} + +Builtins compose — output of one is input to the next: +\`@Count(@Filter(data.rows, "field", "==", "val"))\` for KPIs/chart values, \`@Round(@Avg(data.rows.score), 1)\`, \`@Each(data.rows, "item", Comp(item.field))\` for per-item rendering. +Array pluck: \`data.rows.field\` extracts a field from every row → use with @Sum, @Avg, charts, tables. + +IMPORTANT @Each rule: The loop variable (e.g. "item") is ONLY available inside the @Each template expression. Always inline the template — do NOT extract it to a separate statement. +CORRECT: \`Col("Actions", @Each(rows, "t", Button("Edit", Action([@Set($id, t.id)]))))\` +WRONG: \`myBtn = Button("Edit", Action([@Set($id, t.id)]))\` then \`Col("Actions", @Each(rows, "t", myBtn))\` — t is undefined in myBtn.`; } -function getArrayInnerType(schema: unknown): unknown | undefined { - const s = unwrap(schema); - const def = getZodDef(s); - if (def?.type === "array") return def.element ?? def.innerType; - return undefined; +function querySection(): string { + return `## Query — Live Data Fetching + +Fetch data from available tools. Returns defaults instantly, swaps in real data when it arrives. + +\`\`\` +metrics = Query("tool_name", {arg1: value, arg2: $binding}, {defaultField: 0, defaultData: []}, refreshInterval?) +\`\`\` + +- First arg: tool name (string) +- Second arg: arguments object (may reference $bindings — re-fetches automatically on change) +- Third arg: default data (rendered immediately before fetch resolves) +- Fourth arg (optional): refresh interval in seconds (e.g. 30 for auto-refresh every 30s) +- Use dot access on results: metrics.totalEvents, metrics.data.day (array pluck) +- Query results must use regular identifiers: \`metrics = Query(...)\`, NOT \`$metrics = Query(...)\` +- Manual refresh: \`Button("Refresh", Action([@Run(query1), @Run(query2)]), "secondary")\` — re-fetches the listed queries +- Refresh all queries: create Action with @Run for each query`; } -function getEnumValues(schema: unknown): string[] | undefined { - const s = unwrap(schema); - const def = getZodDef(s); - if (def?.type !== "enum") return undefined; - if (Array.isArray(def.values)) return def.values; - if (def.entries && typeof def.entries === "object") return Object.keys(def.entries); - return undefined; +function mutationSection(): string { + return `## Mutation — Write Operations + +Execute state-changing tool calls (create, update, delete). Unlike Query (auto-fetches on render), Mutation fires only on button click via Action. + +\`\`\` +result = Mutation("tool_name", {arg1: $binding, arg2: "value"}) +\`\`\` + +- First arg: tool name (string) +- Second arg: arguments object (evaluated with current $binding values at click time) +- result.status: "idle" | "loading" | "success" | "error" +- result.data: tool response on success +- result.error: error message on failure +- Mutation results use regular identifiers: \`result = Mutation(...)\`, NOT \`$result\` +- Show loading state: \`result.status == "loading" ? TextContent("Saving...") : null\``; } -function getSchemaId(schema: unknown): string | undefined { - try { - const meta = z.globalRegistry.get(schema as z.ZodType); - return meta?.id; - } catch { - return undefined; +function actionSection(hasTools: boolean): string { + const steps = [ + '- @ToAssistant("message") — Send a message to the assistant (for conversational buttons like "Tell me more", "Explain this")', + '- @OpenUrl("https://...") — Navigate to a URL', + "- @Set($variable, value) — Set a $variable to a specific value", + '- @Reset($var1, $var2, ...) — Reset $variables to their declared defaults (e.g. @Reset($title, $priority) restores $title="" and $priority="medium")', + ]; + + if (hasTools) { + steps.unshift( + "- @Run(queryOrMutationRef) — Execute a Mutation or re-fetch a Query (ref must be a declared Query/Mutation)", + ); + } + + const examples: string[] = []; + if (hasTools) { + examples.push(`Example — mutation + refresh + reset (PREFERRED pattern): +\`\`\` +$binding = "default" +result = Mutation("tool_name", {field: $binding}) +data = Query("tool_name", {}, {rows: []}) +onSubmit = Action([@Run(result), @Run(data), @Reset($binding)]) +\`\`\``); } + + examples.push(`Example — simple nav: +\`\`\` +viewBtn = Button("View", Action([@OpenUrl("https://example.com")])) +\`\`\``); + + const rules = [ + '- Action can be assigned to a variable or inlined: Button("Go", onSubmit) and Button("Go", Action([...])) both work', + ]; + if (hasTools) { + rules.push( + "- If a @Run(mutation) step fails, remaining steps are skipped (halt on failure)", + "- @Run(queryRef) re-fetches the query (fire-and-forget, cannot fail)", + ); + } + + return `## Action — Button Behavior + +Action([@steps...]) wires button clicks to operations. Steps are @-prefixed built-in actions. Steps execute in order. +Buttons without an explicit Action prop automatically send their label to the assistant (equivalent to Action([@ToAssistant(label)])). + +Available steps: +${steps.join("\n")} + +${examples.join("\n\n")} + +${rules.join("\n")}`; } -function getUnionOptions(schema: unknown): unknown[] | undefined { - const def = getZodDef(schema); - if (def?.type === "union" && Array.isArray(def.options)) return def.options; - return undefined; +function interactiveFiltersSection(): string { + return `## Interactive Filters + +To let the user filter data with a dropdown: +1. Declare a $variable with a default: \`$dateRange = "14"\` +2. Create a Select with name, binding, and items: \`Select("dateRange", $dateRange, [SelectItem("7", "Last 7 days"), ...])\` +3. Wrap in FormControl for a label: \`FormControl("Date Range", Select(...))\` +4. Pass $dateRange in Query args: \`Query("tool", {dateRange: $dateRange}, {defaults})\` +5. When the user changes the Select, $dateRange updates and the Query automatically re-fetches + +FILTER WIRING RULE: If a $binding filter is visible in the UI, EVERY relevant Query MUST reference that $binding in its args. Never show a filter dropdown while hardcoding the query args. + +Rules for $variables: +- $variables hold simple values (strings or numbers), NOT arrays or objects +- $variables must be bound to a Select/Input component via the value argument to be interactive +- Queries must use regular identifiers (NOT $variables): \`metrics = Query(...)\` not \`$metrics = Query(...)\` +- **Auto-declare**: You do NOT need to explicitly declare $variables. If you use \`$foo\` without declaring it, the parser auto-creates \`$foo = null\`. You can still declare explicitly to set a default: \`$days = "14"\` + +## Forms + +Simple form — no $bindings needed. Field values are managed internally by the Form via the name prop: +\`\`\` +contactForm = Form("contact", submitBtn, [nameField, emailField]) +nameField = FormControl("Name", Input("name", null, "Your name", "text", {required: true})) +emailField = FormControl("Email", Input("email", null, "your@email.com", "email", {required: true, email: true})) +submitBtn = Button("Submit") +\`\`\` + +Use $bindings when you need to read field values elsewhere (in Action context, Query args, or conditionals). They are auto-declared: +\`\`\` +$role = "engineer" +contactForm = Form("contact", submitBtn, [nameField, emailField, roleField]) +nameField = FormControl("Name", Input("name", $name, "Enter your name", "text", {required: true})) +emailField = FormControl("Email", Input("email", $email, "Enter your email", "email", {required: true, email: true})) +roleField = FormControl("Role", Select("role", $role, [SelectItem("engineer", "Engineer"), SelectItem("designer", "Designer"), SelectItem("pm", "PM")], null, {required: true})) +submitBtn = Button("Submit") +\`\`\` + +For form + mutation patterns (create, refresh, reset), see the Action section example above. + +IMPORTANT: Always add validation rules to form fields used with Mutations. Use OBJECT syntax: {required: true, email: true, minLength: 8}. The renderer shows error messages automatically and blocks submit when validation fails.`; } -function getObjectShape(schema: unknown): Record | undefined { - const def = getZodDef(schema); - if (def?.type === "object" && def.shape && typeof def.shape === "object") - return def.shape as Record; - return undefined; +function editModeSection(): string { + return `## Edit Mode + +The runtime merges by statement name: same name = replace, new name = append. +Output ONLY statements that changed or are new. Everything else is kept automatically. + +### Delete +To remove a component, update the parent to exclude it from its children array. Orphaned statements are automatically garbage-collected. +Example — remove chart: \`root = Stack([header, kpiRow, table])\` — chart is no longer in the children list, so it and any statements only it referenced are auto-deleted. + +### Patch size guide +- Changing a title or label: 1 statement +- Adding a component: 2-3 statements (the new component + parent update) +- Removing a component: 1 statement (re-declare parent without the removed child) +- Adding a filter + wiring to query: 3-5 statements +- Restructuring into tabs: 5-10 statements + +### Rules +- Reuse existing statement names exactly — do not rename +- Do NOT re-emit unchanged statements — the runtime keeps them +- A typical edit patch is 1-10 statements, not 20+ +- If the existing code already satisfies the request, output only the root statement +- NEVER output the entire program as a patch. Only output what actually changes +- If you are about to output more than 10 statements, reconsider — most edits need fewer`; } -/** - * Resolve the type annotation for a schema field. - * Returns a human-readable type string for the schema. - * - * Examples: - * - z.string() → "string" - * - z.number() → "number" - * - z.boolean() → "boolean" - * - z.enum(["a","b"]) → '"a" | "b"' - * - z.array(TabItemSchema) → "TabItem[]" - * - z.union([Input, TextArea]) → "Input | TextArea" - * - z.array(z.union([A, B])) → "(A | B)[]" - * - ButtonGroupSchema → "ButtonGroup" - * - z.object({src: z.string()}) → "{src: string}" (inline when unregistered) - */ -function resolveTypeAnnotation(schema: unknown): string | undefined { - const inner = unwrap(schema); - - const directId = getSchemaId(inner); - if (directId) return directId; - - const unionOpts = getUnionOptions(inner); - if (unionOpts) { - const resolved = unionOpts.map((o) => resolveTypeAnnotation(o)); - const names = resolved.filter(Boolean) as string[]; - if (names.length > 0) { - if (names.length < unionOpts.length) { - console.warn( - `[prompt] Partially resolved union: ${names.length}/${unionOpts.length} options resolved`, - ); - } - return names.join(" | "); - } - } +function streamingRules(rootName: string): string { + return `## Hoisting & Streaming (CRITICAL) - if (isArrayType(schema)) { - const arrayInner = getArrayInnerType(schema); - if (!arrayInner) return undefined; +openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. - const innerType = resolveTypeAnnotation(arrayInner); - if (innerType) { - const isUnion = getUnionOptions(unwrap(arrayInner)) !== undefined; - return isUnion ? `(${innerType})[]` : `${innerType}[]`; - } +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. - console.warn( - `[prompt] Could not resolve array element type (inner zod type: "${getZodType(arrayInner) ?? "unknown"}")`, +**Recommended statement order for optimal streaming:** +1. \`root = ${rootName}(...)\` — UI shell appears immediately +2. $variable declarations — state ready for bindings +3. Query statements — defaults resolve immediately so components render with data +4. Component definitions — fill in with data already available +5. Data values — leaf content last + +Always write the root = ${rootName}(...) statement first so the UI shell appears immediately, even before child data has streamed in.`; +} + +function inlineModeSection(): string { + return `## Inline Mode + +You are in inline mode. You can respond in two ways: + +### 1. Code response (when the user wants to CREATE or CHANGE the UI) +Wrap openui-lang code in triple-backtick fences. You can include explanatory text before/after: + +Here's your dashboard: + +\`\`\`openui-lang +root = RootComp([header, content]) +header = SomeHeader("Title") +content = SomeContent("Hello world") +\`\`\` + +I created a simple layout with a header. + +### 2. Text-only response (when the user asks a QUESTION) +If the user asks "what is this?", "explain the chart", "how does this work", etc. — respond with plain text. Do NOT output any openui-lang code. The existing dashboard stays unchanged. + +### Rules +- When the user asks for changes, output ONLY the changed/new statements in a fenced block +- When the user asks a question, respond with text only — NO code. The dashboard stays unchanged. +- The parser extracts code from fences automatically. Text outside fences is shown as chat.`; +} + +function toolWorkflowSection(): string { + return `## Data Workflow + +When tools are available, follow this workflow: +1. FIRST: Call the most relevant tool to inspect the real data shape before generating code +2. Use Query() for READ operations (data that should stay live) — NEVER hardcode tool results as literal arrays or objects +3. Use Mutation() for WRITE operations (create, update, delete) — triggered by button clicks via Action([@Run(mutationRef)]) +4. Use the real data from step 1 as condensed Query defaults (3-5 rows) so the UI renders immediately +5. Use @-prefixed builtins (@Count, @Filter, @Sort, @Sum) on Query results for KPIs and aggregations — the runtime evaluates these live on every refresh +6. Hardcoded arrays are ONLY for static display data (labels, options) where no tool exists + +WRONG — you called a tool and got data back, but you inlined the results: +\`\`\` +openCount = 2 +item1 = SomeComp("first item title") +item2 = SomeComp("second item title") +list = Stack([item1, item2]) +chart = SomeChart(["A", "B"], [12, 8]) +\`\`\` +This is static — it shows stale data and won't update. Creating item1, item2, item3... manually is ALWAYS wrong when a tool exists. + +RIGHT — use Query() for live data, Mutation() for writes, @builtins to derive values: +\`\`\` +data = Query("tool_name", {}, {rows: []}) +openCount = @Count(@Filter(data.rows, "field", "==", "value")) +list = @Each(data.rows, "item", SomeComp(item.title, item.field)) +createResult = Mutation("create_tool", {title: $title}) +submitBtn = Button("Create", Action([@Run(createResult), @Run(data), @Reset($title)])) +\`\`\` +Everything derives from the Query — when data refreshes, the entire dashboard updates automatically.`; +} + +function importantRules(rootName: string, hasTools: boolean): string { + const verifyLines = [ + `1. root = ${rootName}(...) is the FIRST line (for optimal streaming).`, + "2. Every referenced name is defined. Every defined name (other than root) is reachable from root.", + ]; + if (hasTools) { + verifyLines.push( + "3. Every Query result is referenced by at least one component.", + "4. Every $binding appears in at least one component or expression.", ); - return undefined; } - const zodType = getZodType(inner); - if (zodType === "string") return "string"; - if (zodType === "number") return "number"; - if (zodType === "boolean") return "boolean"; + return `## Important Rules +- 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.) - const enumVals = getEnumValues(inner); - if (enumVals) return enumVals.map((v) => `"${v}"`).join(" | "); +## Final Verification +Before finishing, walk your output and verify: +${verifyLines.join("\n")}`; +} - if (zodType === "literal") { - const vals = getZodDef(inner)?.values; - if (Array.isArray(vals) && vals.length === 1) { - const v = vals[0]; - return typeof v === "string" ? `"${v}"` : String(v); +// ─── Tool rendering ───────────────────────────────────────────────────────── + +function renderToolSignature(tool: McpToolSpec): string { + let args = ""; + if (tool.inputSchema) { + const props = (tool.inputSchema as any).properties as + | Record> + | undefined; + const required = ((tool.inputSchema as any).required as string[]) ?? []; + if (props && Object.keys(props).length > 0) { + args = Object.entries(props) + .map(([k, v]) => { + const opt = required.includes(k) ? "" : "?"; + return `${k}${opt}: ${jsonSchemaTypeStr(v)}`; + }) + .join(", "); } } - const shape = getObjectShape(inner); - if (shape) { - const fields = Object.entries(shape).map(([name, fieldSchema]) => { - const opt = isOptionalType(fieldSchema) ? "?" : ""; - const fieldType = resolveTypeAnnotation(fieldSchema as z.ZodType); - return fieldType ? `${name}${opt}: ${fieldType}` : `${name}${opt}`; - }); - return `{${fields.join(", ")}}`; + let returnType = ""; + if (tool.outputSchema) { + returnType = ` → ${jsonSchemaTypeStr(tool.outputSchema as Record)}`; } - if (zodType === "lazy") { - console.warn( - `[prompt] z.lazy() schemas are not resolved — remove z.lazy() wrapper from the schema`, - ); - } else if (zodType) { - console.warn(`[prompt] Unresolved schema type: "${zodType}"`); + let line = `- ${tool.name}(${args})${returnType}`; + if (tool.description) { + line += `\n ${tool.description}`; } - - return undefined; + return line; } -// ─── Field analysis ─── +function renderToolsSection(tools: (string | McpToolSpec)[]): string { + const lines: string[] = []; -interface FieldInfo { - name: string; - isOptional: boolean; - isArray: boolean; - typeAnnotation?: string; -} + const stringTools: string[] = []; + const specTools: McpToolSpec[] = []; -function analyzeFields(shape: Record): FieldInfo[] { - return Object.entries(shape).map(([name, schema]) => ({ - name, - isOptional: isOptionalType(schema), - isArray: isArrayType(schema), - typeAnnotation: resolveTypeAnnotation(schema), - })); -} + for (const tool of tools) { + if (typeof tool === "string") { + stringTools.push(tool); + } else { + specTools.push(tool); + } + } -// ─── Signature generation ─── + lines.push("## Available Tools"); + lines.push(""); + lines.push( + "Use these with Query() for read operations or Mutation() for write operations. The LLM decides which is appropriate based on the tool's purpose.", + ); + lines.push(""); + for (const t of stringTools) { + lines.push(`- ${t}`); + } + for (const t of specTools) { + lines.push(renderToolSignature(t)); + } -function buildSignature(componentName: string, fields: FieldInfo[]): string { - const params = fields.map((f) => { - if (f.typeAnnotation) { - return f.isOptional ? `${f.name}?: ${f.typeAnnotation}` : `${f.name}: ${f.typeAnnotation}`; - } - if (f.isArray) { - return f.isOptional ? `[${f.name}]?` : `[${f.name}]`; + // Default values hint for McpToolSpec tools with outputSchema + const toolsWithOutput = specTools.filter((t) => t.outputSchema); + if (toolsWithOutput.length > 0) { + lines.push(""); + lines.push("### Default values for Query results"); + lines.push(""); + lines.push("Use these shapes as minimal Query defaults:"); + for (const t of toolsWithOutput) { + const defaults = defaultForSchema(t.outputSchema as Record); + lines.push(`- ${t.name}: \`${JSON.stringify(defaults)}\``); } - return f.isOptional ? `${f.name}?` : f.name; - }); - return `${componentName}(${params.join(", ")})`; -} - -function buildComponentLine(componentName: string, def: DefinedComponent): string { - const fields = analyzeFields(def.props.shape); - const sig = buildSignature(componentName, fields); - if (def.description) { - return `${sig} — ${def.description}`; } - return sig; + + lines.push(""); + lines.push( + "CRITICAL: Use ONLY the tools listed above in Query() and Mutation() calls. Do NOT invent or guess tool names. If the user asks for functionality that doesn't match any available tool, use realistic mock data instead of fabricating a tool call.", + ); + + return lines.join("\n"); } -// ─── Prompt assembly ─── +// ─── Component signatures ─────────────────────────────────────────────────── + +function generateComponentSignatures(spec: PromptSpec): string { + const hasTools = !!spec.tools?.length; + const actionHint = hasTools + ? "Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Action section for available steps (@Run, @ToAssistant, @OpenUrl, @Set, @Reset)." + : "Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Action section for available steps (@ToAssistant, @OpenUrl, @Set, @Reset)."; -function generateComponentSignatures(library: Library): string { - const lines: string[] = [ + const lines = [ "## 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).", + actionHint, + "Props marked `$binding` accept a `$variable` reference for two-way binding.", ]; - if (library.componentGroups?.length) { - const groupedComponents = new Set(); - - for (const group of library.componentGroups) { - lines.push(""); - lines.push(`### ${group.name}`); + if (spec.componentGroups?.length) { + const grouped = new Set(); + for (const group of spec.componentGroups) { + lines.push("", `### ${group.name}`); for (const name of group.components) { - if (groupedComponents.has(name)) { - console.warn( - `[prompt] Component "${name}" appears in multiple groups; keeping the first occurrence only.`, - ); - continue; - } - const def = library.components[name]; - if (!def) { - console.warn( - `[prompt] Component "${name}" listed in group "${group.name}" was not found in the library and will be omitted from the prompt.`, - ); - continue; - } - groupedComponents.add(name); - lines.push(buildComponentLine(name, def)); + if (grouped.has(name)) continue; + const comp = spec.components[name]; + if (!comp) continue; + grouped.add(name); + lines.push(comp.description ? `${comp.signature} — ${comp.description}` : comp.signature); } if (group.notes?.length) { - for (const note of group.notes) { - lines.push(note); - } + for (const note of group.notes) lines.push(note); } } - - const ungrouped = Object.keys(library.components).filter( - (name) => !groupedComponents.has(name), - ); + const ungrouped = Object.keys(spec.components).filter((n) => !grouped.has(n)); if (ungrouped.length) { - lines.push(""); - lines.push("### Ungrouped"); + lines.push("", "### Other"); for (const name of ungrouped) { - const def = library.components[name]; - lines.push(buildComponentLine(name, def)); + const comp = spec.components[name]; + lines.push(comp.description ? `${comp.signature} — ${comp.description}` : comp.signature); } } } else { lines.push(""); - for (const [name, def] of Object.entries(library.components)) { - lines.push(buildComponentLine(name, def)); + for (const [, comp] of Object.entries(spec.components)) { + lines.push(comp.description ? `${comp.signature} — ${comp.description}` : comp.signature); } } - return lines.join("\n"); } -export function generatePrompt(library: Library, options?: PromptOptions): string { - const rootName = library.root ?? "Root"; +// ─── Prompt assembly ──────────────────────────────────────────────────────── + +export function generatePrompt(spec: PromptSpec): string { + const rootName = spec.root ?? "Root"; + const hasTools = !!spec.tools?.length; const parts: string[] = []; - parts.push(options?.preamble ?? PREAMBLE); + parts.push(spec.preamble ?? PREAMBLE); + parts.push(""); + parts.push(syntaxRules(rootName, hasTools)); + parts.push(""); + parts.push(generateComponentSignatures(spec)); + + // Built-in functions — always included parts.push(""); - parts.push(syntaxRules(rootName)); + parts.push(builtinFunctionsSection()); + + // Query + Mutation sections (only with tools) + if (hasTools) { + parts.push(""); + parts.push(querySection()); + parts.push(""); + parts.push(mutationSection()); + } + + // Action section — always included (ToAssistant, OpenUrl, Set work without tools) parts.push(""); - parts.push(generateComponentSignatures(library)); + parts.push(actionSection(hasTools)); + + // Interactive filters + forms (only with tools) + if (hasTools) { + parts.push(""); + parts.push(interactiveFiltersSection()); + } + + // Tool workflow + if (hasTools) { + parts.push(""); + parts.push(toolWorkflowSection()); + } + + // Tools list + if (spec.tools?.length) { + parts.push(""); + parts.push(renderToolsSection(spec.tools)); + } + parts.push(""); parts.push(streamingRules(rootName)); - const examples = options?.examples; + // Show tool examples when tools are present, otherwise show general examples + const examples = hasTools && spec.toolExamples?.length ? spec.toolExamples : spec.examples; if (examples?.length) { parts.push(""); parts.push("## Examples"); @@ -323,11 +595,23 @@ export function generatePrompt(library: Library, options?: PromptOptions): strin } } - parts.push(importantRules(rootName)); + // Edit mode instructions + if (spec.editMode) { + parts.push(""); + parts.push(editModeSection()); + } + + // Inline mode instructions + if (spec.inlineMode) { + parts.push(""); + parts.push(inlineModeSection()); + } + + parts.push(importantRules(rootName, hasTools)); - if (options?.additionalRules?.length) { + if (spec.additionalRules?.length) { parts.push(""); - for (const rule of options.additionalRules) { + for (const rule of spec.additionalRules) { parts.push(`- ${rule}`); } } diff --git a/packages/lang-core/src/parser/statements.ts b/packages/lang-core/src/parser/statements.ts new file mode 100644 index 000000000..49f38aa80 --- /dev/null +++ b/packages/lang-core/src/parser/statements.ts @@ -0,0 +1,134 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Statement splitter for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +import { T, type Token } from "./tokens"; + +export interface RawStmt { + id: string; + /** Token type of the LHS identifier — used to classify statement kind */ + idTokenType: T; + tokens: Token[]; +} + +/** + * Auto-close unclosed strings and brackets so that partial/streaming input + * can be parsed without syntax errors. + */ +export function autoClose(input: string): { text: string; wasIncomplete: boolean } { + const stack: string[] = []; + let inStr = false, + esc = false; + + for (let i = 0; i < input.length; i++) { + const c = input[i]; + if (esc) { + esc = false; + continue; + } + if (c === "\\" && inStr) { + esc = true; + continue; + } + if (c === '"') { + inStr = !inStr; + continue; + } + if (inStr) continue; + if (c === "(" || c === "[" || c === "{") stack.push(c); + else if (c === ")" && stack[stack.length - 1] === "(") stack.pop(); + else if (c === "]" && stack[stack.length - 1] === "[") stack.pop(); + else if (c === "}" && stack[stack.length - 1] === "{") stack.pop(); + } + + const wasIncomplete = inStr || stack.length > 0; + if (!wasIncomplete) return { text: input, wasIncomplete: false }; + + let out = input; + if (inStr) { + if (esc) out += "\\"; + out += '"'; + } // close open string + for (let j = stack.length - 1; j >= 0; j--) + out += stack[j] === "(" ? ")" : stack[j] === "[" ? "]" : "}"; + + return { text: out, wasIncomplete: true }; +} + +/** + * Splits the flat token stream into individual statements. + * + * Each statement has the form `identifier = expression`. Statements are + * separated by newlines at depth 0 (newlines inside brackets are ignored). + * + * Accepts `Ident`, `Type`, and `StateVar` as statement identifiers. + * For StateVar, the id is the full token value including $ (e.g., "$count"). + * + * Invalid lines (no `=`, or no identifier) are silently skipped. + */ +export function split(tokens: Token[]): RawStmt[] { + const stmts: RawStmt[] = []; + let pos = 0; + + while (pos < tokens.length) { + // Skip blank lines + while (pos < tokens.length && tokens[pos].t === T.Newline) pos++; + if (pos >= tokens.length || tokens[pos].t === T.EOF) break; + + // Expect: Ident|Type|StateVar = expression + const tok = tokens[pos]; + if (tok.t !== T.Ident && tok.t !== T.Type && tok.t !== T.StateVar) { + while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) pos++; + continue; + } + const id = tok.v as string; + const idTokenType = tok.t; + pos++; + + // Must be followed by `=` + if (pos >= tokens.length || tokens[pos].t !== T.Equals) { + while (pos < tokens.length && tokens[pos].t !== T.Newline && tokens[pos].t !== T.EOF) pos++; + continue; + } + pos++; + + // Collect expression tokens until a depth-0 newline or EOF. + // Track both bracket depth and ternary depth so that multiline + // ternary expressions (condition on one line, ? ... : on the next) + // are not incorrectly split into separate statements. + const expr: Token[] = []; + let depth = 0; + let ternaryDepth = 0; + while (pos < tokens.length && tokens[pos].t !== T.EOF) { + const tt = tokens[pos].t; + if (tt === T.Newline && depth <= 0 && ternaryDepth <= 0) { + // Before breaking, look ahead past whitespace/newlines to see if + // the next meaningful token is `?` or `:` — if so, the ternary + // continues on the next line and we should NOT split here. + let peek = pos + 1; + while (peek < tokens.length && tokens[peek].t === T.Newline) peek++; + const nextT = peek < tokens.length ? tokens[peek].t : T.EOF; + if (nextT === T.Question || (nextT === T.Colon && ternaryDepth > 0)) { + // Ternary continuation — skip the newline and keep collecting + pos++; + continue; + } + break; // statement boundary + } + if (tt === T.Newline) { + pos++; + continue; + } // newline inside bracket/ternary — skip + if (tt === T.LParen || tt === T.LBrack || tt === T.LBrace) depth++; + else if ((tt === T.RParen || tt === T.RBrack || tt === T.RBrace) && depth > 0) depth--; + // Track ternary ? and : at bracket depth 0 (colons inside {} are object key separators) + else if (tt === T.Question && depth === 0) ternaryDepth++; + else if (tt === T.Colon && depth === 0 && ternaryDepth > 0) ternaryDepth--; + expr.push(tokens[pos++]); + } + + if (expr.length) stmts.push({ id, idTokenType, tokens: expr }); + } + + return stmts; +} diff --git a/packages/lang-core/src/parser/tokens.ts b/packages/lang-core/src/parser/tokens.ts new file mode 100644 index 000000000..4202a955b --- /dev/null +++ b/packages/lang-core/src/parser/tokens.ts @@ -0,0 +1,52 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Token types for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Token type discriminant. Uses `const enum` for zero-cost at runtime + * (TypeScript inlines the numeric values). + */ +export const enum T { + Newline = 0, + LParen = 1, // ( + RParen = 2, // ) + LBrack = 3, // [ + RBrack = 4, // ] + LBrace = 5, // { + RBrace = 6, // } + Comma = 7, // , + Colon = 8, // : + Equals = 9, // = + True = 10, + False = 11, + Null = 12, + EOF = 13, + Str = 14, // carries string value + Num = 15, // carries numeric value + Ident = 16, // lowercase identifier — becomes a reference + Type = 17, // PascalCase identifier — becomes a component name or reference + // Reactive & expression tokens + StateVar = 18, // $identifier — reactive state reference + Dot = 19, // . + Plus = 20, // + + Minus = 21, // - + Star = 22, // * + Slash = 23, // / + Percent = 24, // % + EqEq = 25, // == + NotEq = 26, // != + Greater = 27, // > + Less = 28, // < + GreaterEq = 29, // >= + LessEq = 30, // <= + And = 31, // && + Or = 32, // || + Not = 33, // ! + Question = 34, // ? + BuiltinCall = 35, // @identifier — builtin function call +} + +export type Token = { + t: T; + v?: string | number; +}; diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index 9c20c8a45..0dcd537e3 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -1,3 +1,31 @@ +import type { ASTNode } from "./ast"; + +/** + * The JSON Schema document produced by `library.toJSONSchema()`. + * All component schemas live in `$defs`, keyed by component name. + */ +export interface LibraryJSONSchema { + $defs?: Record< + string, + { + properties?: Record; + required?: string[]; + } + >; +} + +export interface ParamDef { + /** Parameter name, e.g. "title", "columns". */ + name: string; + /** Whether the parameter is required by the component. */ + required: boolean; + /** Default value from JSON Schema — used when the required field is missing/null. */ + defaultValue?: unknown; +} + +/** Internal parameter map for positional-arg to named-prop mapping. */ +export type ParamMap = Map; + /** * A fully resolved component node from the parser. * @@ -7,24 +35,38 @@ */ export interface ElementNode { type: "element"; + /** Source variable name (e.g. "header" from `header = TextContent(...)`). Undefined for inline components. */ + statementId?: string; /** Component name as defined in the library (e.g. "Table", "BarChart"). */ typeName: string; - /** Named props produced by positional-to-named mapping in the Rust parser. */ + /** Named props produced by positional-to-named mapping in the parser. */ props: Record; /** * True when the parser hasn't received all tokens for this node yet * (streaming in progress). */ partial: boolean; + /** + * False when all props are static literals — evaluation can be skipped. + * Undefined is treated as true (dynamic) for backward compatibility. + */ + hasDynamicProps?: boolean; +} + +export function isElementNode(value: unknown): value is ElementNode { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const node = value as Record; + return ( + node.type === "element" && + typeof node.typeName === "string" && + typeof node.props === "object" && + node.props !== null && + typeof node.partial === "boolean" + ); } /** * Validation error codes for schema-related issues. - * - * - `missing-required` — a required prop was absent and has no default value - * - `null-required` — a required prop was explicitly null with no default - * - `unknown-component` — the component name is not in the library schema - * - `excess-args` — more positional args were passed than the schema defines */ export type ValidationErrorCode = | "missing-required" @@ -34,46 +76,103 @@ export type ValidationErrorCode = /** * Structured error from the parser. - * - * Currently only `type: "validation"` exists; future versions may add - * `type: "parser"` for diagnostics like incomplete input. */ export type OpenUIError = { type: "validation"; - /** Which validation rule produced this error. */ code: ValidationErrorCode; + component: string; + path: string; + message: string; +}; + +/** + * A prop validation error. Components with missing required props are + * dropped from the output tree and errors are recorded here. + */ +export interface ValidationError { /** Component type name, e.g. "Header", "BarChart". */ component: string; /** JSON Pointer path within the props object, e.g. "/title", "". */ path: string; /** Human-readable error message. */ message: string; -}; +} /** - * Built-in action types for interactive components. + * Built-in action types for host app events. */ export enum BuiltinActionType { ContinueConversation = "continue_conversation", OpenUrl = "open_url", } +/** + * A single step in an ActionPlan. + * Step type values match ACTION_STEPS in builtins.ts (single source of truth). + */ +export type ActionStep = + | { type: "run"; statementId: string; refType: "query" | "mutation" } + | { type: "continue_conversation"; message: string; context?: string } + | { type: "open_url"; url: string } + | { type: "set"; target: string; valueAST: ASTNode } + | { type: "reset"; targets: string[] }; + +/** + * An ordered sequence of steps to execute when a button is clicked. + * Produced by evaluating an Action() expression at runtime. + */ +export interface ActionPlan { + steps: ActionStep[]; +} + /** * Structured action event fired by interactive components. */ export interface ActionEvent { /** Action type. See `BuiltinActionType` for built-in types. */ - type: string; + type: BuiltinActionType | (string & {}); /** Action-specific params (e.g. { url } for OpenUrl, custom params for Custom). */ - params: Record; + params: Record; /** Human-readable label for the action (displayed as user message in chat). */ humanFriendlyMessage: string; /** Raw form state at the time of the action — all field values. */ - formState?: Record; + formState?: Record; /** The form name that triggered this action, if any. */ formName?: string; } +/** + * Extracted info about a Query() call from the parsed program. + */ +export interface QueryStatementInfo { + /** Statement name that holds this query (e.g. "metrics"). */ + statementId: string; + /** First arg AST — the tool name (should evaluate to a string). */ + toolAST: ASTNode | null; + /** Second arg AST — the arguments object (may contain $var refs). */ + argsAST: ASTNode | null; + /** Third arg AST — default data returned before fetch resolves. */ + defaultsAST: ASTNode | null; + /** Fourth arg AST — refresh interval in seconds. */ + refreshAST: ASTNode | null; + /** Pre-computed $variable deps from argsAST (extracted at parse time). */ + deps?: string[]; + /** False while the Query() call is still being streamed. */ + complete: boolean; +} + +/** + * Extracted info about a Mutation() call from the parsed program. + */ +export interface MutationStatementInfo { + /** Statement name that holds this mutation (e.g. "createResult"). */ + statementId: string; + /** First arg AST — the tool name (should evaluate to a string). */ + toolAST: ASTNode | null; + /** Second arg AST — the arguments object (may contain $var refs). */ + argsAST: ASTNode | null; +} + /** * The output of a single `parser.parse(text)` call. * @@ -92,9 +191,15 @@ export interface ParseResult { /** Total number of `identifier = Expression` statements parsed. */ statementCount: number; /** - * Structured errors from the parser. Components with missing required - * props are redacted (dropped as null) and listed here. + * Prop validation errors. Components with missing required props are + * redacted (dropped as null) and listed here. */ - errors: OpenUIError[]; + validationErrors: ValidationError[]; }; + /** $variable declarations — maps "$varName" to its materialized default value. */ + stateDeclarations: Record; + /** Extracted Query() calls with their positional args as AST nodes. */ + queryStatements: QueryStatementInfo[]; + /** Extracted Mutation() calls with their positional args as AST nodes. */ + mutationStatements: MutationStatementInfo[]; } diff --git a/packages/lang-core/src/reactive.ts b/packages/lang-core/src/reactive.ts new file mode 100644 index 000000000..caf77e2f1 --- /dev/null +++ b/packages/lang-core/src/reactive.ts @@ -0,0 +1,17 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Reactive schema marker — shared between lang-core (introspection) and +// framework adapters (runtime binding). +// ───────────────────────────────────────────────────────────────────────────── + +/** WeakSet tracks reactive schemas without mutating the schema objects. */ +const reactiveSchemas = new WeakSet(); + +/** Mark a schema as reactive. Called by framework adapters' reactive() function. */ +export function markReactive(schema: object): void { + reactiveSchemas.add(schema); +} + +/** Check if a schema was marked reactive. Used by Zod introspection for $binding<> prefix. */ +export function isReactiveSchema(schema: unknown): boolean { + return typeof schema === "object" && schema !== null && reactiveSchemas.has(schema as object); +} diff --git a/packages/lang-core/src/runtime/evaluate-tree.ts b/packages/lang-core/src/runtime/evaluate-tree.ts new file mode 100644 index 000000000..b20f5911b --- /dev/null +++ b/packages/lang-core/src/runtime/evaluate-tree.ts @@ -0,0 +1,128 @@ +/** + * Evaluate-tree: entry point for evaluating ElementNode prop trees. + * + * Delegates to the evaluator's schema-aware evaluation when schema context + * is available. This ensures reactive schemas, ActionPlan preservation, + * Each loop variable substitution, and ternary resolution all happen in + * a single unified pass. + */ + +import type { Library } from "../library"; +import { isASTNode } from "../parser/ast"; +import type { ElementNode } from "../parser/types"; +import { isElementNode } from "../parser/types"; +import { isReactiveSchema } from "../reactive"; +import type { EvaluationContext, SchemaContext } from "./evaluator"; +import { evaluate, isReactiveAssign } from "./evaluator"; +import type { Store } from "./store"; + +/** Context passed through the evaluation chain — no module-level state. */ +export interface EvalContext { + /** AST evaluation context (getState, resolveRef) */ + ctx: EvaluationContext; + /** Component library for reactive schema lookup */ + library: Library; + /** Reactive binding store (null in v1 mode) */ + store: Store | null; +} + +/** + * Evaluate all AST nodes in an ElementNode tree's props. + * Returns a new ElementNode with all props resolved to concrete values. + * + * Uses the unified evaluator with schema context for reactive-aware evaluation. + */ +export function evaluateElementProps(el: ElementNode, evalCtx: EvalContext): ElementNode { + if (el.hasDynamicProps === false) return el; + + const schemaCtx: SchemaContext = { library: evalCtx.library }; + const def = evalCtx.library.components[el.typeName]; + const evaluated: Record = {}; + + for (const [key, value] of Object.entries(el.props)) { + const propSchema = def?.props?.shape?.[key]; + evaluated[key] = evaluatePropValue(value, evalCtx, schemaCtx, propSchema); + } + + return { ...el, props: evaluated }; +} + +/** + * Evaluate a single prop value with schema awareness. + */ +function evaluatePropValue( + value: unknown, + evalCtx: EvalContext, + schemaCtx: SchemaContext, + reactiveSchema?: unknown, +): unknown { + if (value == null) return value; + if (typeof value !== "object") return value; + + // AST node — evaluate with schema context + if (isASTNode(value)) { + // StateRef on reactive prop → ReactiveAssign marker + if (value.k === "StateRef" && reactiveSchema && isReactiveSchema(reactiveSchema)) { + return { + __reactive: "assign" as const, + target: value.n, + expr: { k: "StateRef" as const, n: "$value" }, + }; + } + // Evaluate with schema context — handles Comp, Each, ternary with reactive awareness + const result = evaluate(value, evalCtx.ctx, schemaCtx); + // ElementNode result (from ternary/Comp) → recurse into its props + if (isElementNode(result)) { + return evaluateElementProps(result as ElementNode, evalCtx); + } + // Array result (from Each) → recurse into any ElementNodes + if (Array.isArray(result)) { + return result.map((item) => + isElementNode(item) ? evaluateElementProps(item as ElementNode, evalCtx) : item, + ); + } + // Strip ReactiveAssign from non-reactive props + if (isReactiveAssign(result) && !(reactiveSchema && isReactiveSchema(reactiveSchema))) { + return evalCtx.ctx.getState(result.target) ?? null; + } + return result; + } + + // String on reactive schema → pass through (useStateField resolves at render time) + if (typeof value === "string" && reactiveSchema && isReactiveSchema(reactiveSchema)) { + return value; + } + + // Array — recurse + if (Array.isArray(value)) { + return value.map((v) => evaluatePropValue(v, evalCtx, schemaCtx, reactiveSchema)); + } + + // ElementNode — recurse with schema + if (isElementNode(value)) { + return evaluateElementProps(value as ElementNode, evalCtx); + } + + // ActionPlan / ActionStep — preserve as-is (deferred click-time evaluation) + const obj = value as Record; + if ("steps" in obj && Array.isArray(obj.steps)) return value; + if ("type" in obj && "valueAST" in obj) return value; + + // Plain data object — recurse if contains nested objects + let needsEval = false; + for (const val of Object.values(obj)) { + if (typeof val === "object" && val !== null) { + needsEval = true; + break; + } + } + if (needsEval) { + const result: Record = {}; + for (const [key, val] of Object.entries(obj)) { + result[key] = evaluatePropValue(val, evalCtx, schemaCtx); + } + return result; + } + + return value; +} diff --git a/packages/lang-core/src/runtime/evaluator.ts b/packages/lang-core/src/runtime/evaluator.ts new file mode 100644 index 000000000..93c9708b6 --- /dev/null +++ b/packages/lang-core/src/runtime/evaluator.ts @@ -0,0 +1,574 @@ +// ───────────────────────────────────────────────────────────────────────────── +// AST evaluator — resolves AST nodes to runtime values. +// Framework-agnostic. No React imports. +// ───────────────────────────────────────────────────────────────────────────── + +import type { ASTNode } from "../parser/ast"; +import { isASTNode } from "../parser/ast"; +import { ACTION_NAMES, ACTION_STEPS, BUILTINS, LAZY_BUILTINS, toNumber } from "../parser/builtins"; +import type { ActionPlan, ActionStep, ElementNode } from "../parser/types"; +import { isElementNode } from "../parser/types"; +import { isReactiveSchema } from "../reactive"; + +/** Optional schema context for reactive-aware evaluation. */ +export interface SchemaContext { + /** Component library — used to look up reactive schemas per prop. */ + library: { components: Record } }> }; +} + +export interface EvaluationContext { + /** Read $variable from the store */ + getState(name: string): unknown; + /** Resolve a reference to another declaration's evaluated value */ + resolveRef(name: string): unknown; + /** Extra scope for $value injection during reactive prop evaluation */ + extraScope?: Record; +} + +export interface ReactiveAssign { + __reactive: "assign"; + target: string; + expr: ASTNode; +} + +export function isReactiveAssign(value: unknown): value is ReactiveAssign { + return typeof value === "object" && value !== null && (value as any).__reactive === "assign"; +} + +/** + * Evaluate an AST node to a runtime value. + */ +export function evaluate( + node: ASTNode, + context: EvaluationContext, + schemaCtx?: SchemaContext, +): unknown { + switch (node.k) { + // ── Literals ────────────────────────────────────────────────────────── + case "Str": + return node.v; + case "Num": + return node.v; + case "Bool": + return node.v; + case "Null": + return null; + case "Ph": + return null; + + // ── State references ────────────────────────────────────────────────── + case "StateRef": + return context.extraScope?.[node.n] ?? context.getState(node.n); + + // ── References ──────────────────────────────────────────────────────── + case "Ref": + case "RuntimeRef": + return context.resolveRef(node.n); + + // ── Collections ─────────────────────────────────────────────────────── + case "Arr": + return node.els.map((el) => evaluate(el, context)); + case "Obj": + return Object.fromEntries(node.entries.map(([k, v]) => [k, evaluate(v, context)])); + + // ── Component ───────────────────────────────────────────────────────── + case "Comp": { + // Lazy builtins — control their own evaluation + if (LAZY_BUILTINS.has(node.name)) { + return evaluateLazyBuiltin(node.name, node.args, context, schemaCtx); + } + // Check shared builtin registry first + const builtin = BUILTINS[node.name]; + if (builtin) { + const args = node.args.map((a) => evaluate(a, context)); + return builtin.fn(...args); + } + // Action calls → evaluate to ActionPlan/ActionStep + if (ACTION_NAMES.has(node.name)) { + return evaluateActionCall(node.name, node.args, context); + } + // If parser already mapped args→props (via materializeExpr), use named props. + // With schema context: emit ReactiveAssign for StateRef on reactive props. + // Without schema context: preserve StateRef as AST for evaluate-tree to handle. + if (node.mappedProps) { + const def = schemaCtx?.library.components[node.name]; + const props: Record = {}; + for (const [key, val] of Object.entries(node.mappedProps)) { + const propSchema = def?.props?.shape?.[key]; + if (val.k === "StateRef" && propSchema && isReactiveSchema(propSchema)) { + // Reactive schema + StateRef → emit ReactiveAssign marker + props[key] = { + __reactive: "assign" as const, + target: val.n, + expr: { k: "StateRef" as const, n: "$value" }, + }; + } else if (val.k === "StateRef") { + // Non-reactive StateRef or no schema context → preserve for evaluate-tree + props[key] = schemaCtx ? context.getState(val.n) : val; + } else { + props[key] = evaluate(val, context, schemaCtx); + } + } + const result: ElementNode = { + type: "element", + typeName: node.name, + props, + partial: false, + hasDynamicProps: true, + }; + // If we have schema context, recursively evaluate nested ElementNodes in props + if (schemaCtx) { + for (const [key, val] of Object.entries(props)) { + if (isElementNode(val)) { + props[key] = evaluateElementInline(val, context, schemaCtx); + } else if (Array.isArray(val)) { + props[key] = val.map((item) => + isElementNode(item) ? evaluateElementInline(item, context, schemaCtx) : item, + ); + } + } + } + return result; + } + // After materializeValue, all catalog/unknown components are lowered to + // ElementNode at parse time. Only builtins and mappedProps Comp nodes + // reach here. If we somehow get an unmapped Comp, warn and return null. + console.warn(`[openui] Unexpected unmapped Comp node: ${node.name}`); + return null; + } + + // ── Binary operators ────────────────────────────────────────────────── + case "BinOp": { + // Short-circuit operators evaluate lazily + if (node.op === "&&") { + const left = evaluate(node.left, context); + return left ? evaluate(node.right, context) : left; + } + if (node.op === "||") { + const left = evaluate(node.left, context); + return left ? left : evaluate(node.right, context); + } + + const left = evaluate(node.left, context); + const right = evaluate(node.right, context); + + switch (node.op) { + case "+": + if (typeof left === "string" || typeof right === "string") { + // Treat null/undefined as "" for string concat to avoid "textnull" + return String(left ?? "") + String(right ?? ""); + } + return toNumber(left) + toNumber(right); + case "-": + return toNumber(left) - toNumber(right); + case "*": + return toNumber(left) * toNumber(right); + case "/": + // DSL design choice: division by zero returns 0 instead of JavaScript's Infinity/NaN. + return toNumber(right) === 0 ? 0 : toNumber(left) / toNumber(right); + case "%": + return toNumber(right) === 0 ? 0 : toNumber(left) % toNumber(right); + case "==": + // Use loose equality so that e.g. 5 == "5" is true, + // consistent with the toNumber coercion used by comparison operators. + // eslint-disable-next-line eqeqeq + return left == right; + case "!=": + // eslint-disable-next-line eqeqeq + return left != right; + case ">": + return toNumber(left) > toNumber(right); + case "<": + return toNumber(left) < toNumber(right); + case ">=": + return toNumber(left) >= toNumber(right); + case "<=": + return toNumber(left) <= toNumber(right); + default: + return null; + } + } + + // ── Unary operators ─────────────────────────────────────────────────── + case "UnaryOp": + if (node.op === "!") { + return !evaluate(node.operand, context); + } + if (node.op === "-") { + return -toNumber(evaluate(node.operand, context)); + } + return null; + + // ── Ternary ─────────────────────────────────────────────────────────── + case "Ternary": { + const cond = evaluate(node.cond, context); + return cond ? evaluate(node.then, context) : evaluate(node.else, context); + } + + // ── Member access ───────────────────────────────────────────────────── + case "Member": { + const obj = evaluate(node.obj, context) as any; + if (obj == null) return null; + // Array pluck: if obj is an array, extract field from every element + if (Array.isArray(obj)) { + if (node.field === "length") return obj.length; + return obj.map((item: any) => item?.[node.field] ?? null); + } + return obj[node.field]; + } + + // ── Index access ────────────────────────────────────────────────────── + case "Index": { + const obj = evaluate(node.obj, context) as any; + const idx = evaluate(node.index, context); + if (obj == null || idx == null) return null; + if (Array.isArray(obj)) { + return obj[toNumber(idx)]; + } + return obj[String(idx)]; + } + + // ── Assignment ──────────────────────────────────────────────────────── + case "Assign": + return { + __reactive: "assign" as const, + target: node.target, + expr: node.value, + }; + } +} + +/** + * Strip a ReactiveAssign to its current value in a non-reactive context. + * When transport args or non-reactive props contain a ReactiveAssign, this + * resolves it to the current state value (or null if getState is unavailable). + */ +export function stripReactiveAssign(value: unknown, context: EvaluationContext): unknown { + if (!isReactiveAssign(value)) return value; + return context.getState(value.target) ?? null; +} + +/** + * Evaluate an ElementNode's props with schema awareness. Used by evaluate() + * when schema context is available and a Comp produces an ElementNode that + * needs its own props evaluated with reactive schema detection. + */ +function evaluateElementInline( + el: ElementNode, + context: EvaluationContext, + schemaCtx: SchemaContext, +): ElementNode { + if (el.hasDynamicProps === false) return el; + const def = schemaCtx.library.components[el.typeName]; + const evaluated: Record = {}; + + for (const [key, value] of Object.entries(el.props)) { + const propSchema = def?.props?.shape?.[key]; + evaluated[key] = evaluatePropInline(value, context, schemaCtx, propSchema); + } + return { ...el, props: evaluated }; +} + +/** + * Evaluate a single prop value with schema awareness. Handles AST nodes, + * ReactiveAssign markers, nested ElementNodes, arrays, and ActionPlans. + */ +function evaluatePropInline( + value: unknown, + context: EvaluationContext, + schemaCtx: SchemaContext, + reactiveSchema?: unknown, +): unknown { + if (value == null) return value; + if (typeof value !== "object") return value; + + // AST node + if (isASTNode(value)) { + if (value.k === "StateRef" && reactiveSchema && isReactiveSchema(reactiveSchema)) { + return { + __reactive: "assign" as const, + target: value.n, + expr: { k: "StateRef" as const, n: "$value" }, + }; + } + const result = evaluate(value, context, schemaCtx); + if (isElementNode(result)) return evaluateElementInline(result, context, schemaCtx); + if (Array.isArray(result)) { + return result.map((item) => + isElementNode(item) ? evaluateElementInline(item, context, schemaCtx) : item, + ); + } + if (isReactiveAssign(result) && !(reactiveSchema && isReactiveSchema(reactiveSchema))) { + return context.getState(result.target) ?? null; + } + return result; + } + + // String on reactive schema → pass through (component's useStateField resolves it) + if (typeof value === "string" && reactiveSchema && isReactiveSchema(reactiveSchema)) { + return value; + } + + // Array + if (Array.isArray(value)) { + return value.map((v) => evaluatePropInline(v, context, schemaCtx, reactiveSchema)); + } + + // ElementNode → recurse with schema + if (isElementNode(value)) { + return evaluateElementInline(value, context, schemaCtx); + } + + // ActionPlan / ActionStep — preserve as-is + const obj = value as Record; + if ("steps" in obj && Array.isArray(obj.steps)) return value; + if ("type" in obj && "valueAST" in obj) return value; + + // Plain data object — recurse + let needsEval = false; + for (const val of Object.values(obj)) { + if (typeof val === "object" && val !== null) { + needsEval = true; + break; + } + } + if (needsEval) { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + result[k] = evaluatePropInline(v, context, schemaCtx); + } + return result; + } + + return value; +} + +/** + * Check if an AST node contains any Ref nodes (loop variables like `t` from Each). + * These must be resolved eagerly because they won't exist in scope at click time. + */ +function containsRef(node: ASTNode): boolean { + switch (node.k) { + case "Ref": + return true; + case "Member": + return isASTNode(node.obj) ? containsRef(node.obj as ASTNode) : false; + case "Index": + return ( + (isASTNode(node.obj) && containsRef(node.obj as ASTNode)) || + (isASTNode(node.index) && containsRef(node.index as ASTNode)) + ); + case "BinOp": + return containsRef(node.left) || containsRef(node.right); + case "UnaryOp": + return containsRef(node.operand); + case "Ternary": + return containsRef(node.cond) || containsRef(node.then) || containsRef(node.else); + case "Arr": + return node.els.some(containsRef); + case "Obj": + return node.entries.some(([, v]) => containsRef(v)); + case "Comp": + return node.args.some(containsRef); + default: + return false; + } +} + +/** Convert a resolved runtime value back to a literal AST node for deferred evaluation. */ +function toLiteralAST(value: unknown): ASTNode { + if (value === null || value === undefined) return { k: "Null" }; + if (typeof value === "string") return { k: "Str", v: value }; + if (typeof value === "number") return { k: "Num", v: value }; + if (typeof value === "boolean") return { k: "Bool", v: value }; + if (Array.isArray(value)) return { k: "Arr", els: value.map(toLiteralAST) }; + if (typeof value === "object") { + return { + k: "Obj", + entries: Object.entries(value).map(([k, v]) => [k, toLiteralAST(v)] as [string, ASTNode]), + }; + } + return { k: "Null" }; +} + +/** + * Evaluate Action/Run/ToAssistant/OpenUrl Comp nodes into ActionPlan/ActionStep values. + */ +function evaluateActionCall( + name: string, + args: ASTNode[], + context: EvaluationContext, +): ActionPlan | ActionStep | null { + switch (name) { + case "Action": { + // Action([step1, step2, ...]) → ActionPlan + const stepsArg = args.length > 0 ? evaluate(args[0], context) : []; + const rawSteps = Array.isArray(stepsArg) ? stepsArg : []; + const steps: ActionStep[] = rawSteps.filter( + (s): s is ActionStep => s != null && typeof s === "object" && "type" in s, + ); + return { steps }; + } + case "Run": { + // Run(runtimeRef) → ActionStep { type: "run", statementId, refType } + if (args.length === 0) return null; + const refNode = args[0]; + if (refNode.k === "RuntimeRef") { + return { type: ACTION_STEPS.Run, statementId: refNode.n, refType: refNode.refType }; + } + // Unresolved Ref — skip (filtered out by Action's step array) + return null; + } + case "ToAssistant": { + // ToAssistant("message") or ToAssistant("message", "context") + const message = args.length > 0 ? String(evaluate(args[0], context) ?? "") : ""; + const ctx = args.length > 1 ? String(evaluate(args[1], context) ?? "") : undefined; + return { type: ACTION_STEPS.ToAssistant, message, context: ctx }; + } + case "OpenUrl": { + // OpenUrl("url") + const url = args.length > 0 ? String(evaluate(args[0], context) ?? "") : ""; + return { type: ACTION_STEPS.OpenUrl, url }; + } + case "Set": { + // Set($varName, value) → ActionStep { type: "set", target, valueAST } + // First arg must be a StateRef (the $variable), second arg is the value expression. + // valueAST is preserved as-is and evaluated at click time by triggerAction. + // Loop variables (e.g. t.id from Each) are pre-resolved by Each's substituteRef. + if (args.length < 2) return null; + const targetNode = args[0]; + if (targetNode.k !== "StateRef") return null; + return { type: ACTION_STEPS.Set, target: targetNode.n, valueAST: args[1] }; + } + case "Reset": { + // Reset($var1, $var2, ...) → ActionStep { type: "reset", targets: [...] } + // All args must be StateRef nodes. Restores to declared defaults at runtime. + const targets = args + .filter((a): a is ASTNode & { k: "StateRef" } => a.k === "StateRef") + .map((a) => a.n); + if (targets.length === 0) return null; + return { type: ACTION_STEPS.Reset, targets }; + } + default: + return null; + } +} + +/** + * Substitute all Ref(varName) nodes in an AST tree with a literal value. + * This pre-resolves loop variables so deferred expressions (like Action steps) + * don't lose scope when evaluated later at click time. + */ +function substituteRef(node: ASTNode, varName: string, value: unknown): ASTNode { + switch (node.k) { + case "Ref": + return node.n === varName ? toLiteralAST(value) : node; + case "Member": { + // Member access on the loop var: t.id → resolve t, then access .id + if (isASTNode(node.obj)) { + const subObj = substituteRef(node.obj as ASTNode, varName, value); + // If obj resolved to a literal, we can inline the member access result + if (subObj.k === "Obj") { + const entry = subObj.entries.find(([k]) => k === node.field); + if (entry) return entry[1]; + } + return { ...node, obj: subObj }; + } + return node; + } + case "Index": + return { + ...node, + obj: isASTNode(node.obj) ? substituteRef(node.obj as ASTNode, varName, value) : node.obj, + index: isASTNode(node.index) + ? substituteRef(node.index as ASTNode, varName, value) + : node.index, + }; + case "BinOp": + return { + ...node, + left: substituteRef(node.left, varName, value), + right: substituteRef(node.right, varName, value), + }; + case "UnaryOp": + return { ...node, operand: substituteRef(node.operand, varName, value) }; + case "Ternary": + return { + ...node, + cond: substituteRef(node.cond, varName, value), + then: substituteRef(node.then, varName, value), + else: substituteRef(node.else, varName, value), + }; + case "Arr": + return { ...node, els: node.els.map((e) => substituteRef(e, varName, value)) }; + case "Obj": + return { + ...node, + entries: node.entries.map( + ([k, v]) => [k, substituteRef(v, varName, value)] as [string, ASTNode], + ), + }; + case "Comp": { + const result = { ...node, args: node.args.map((a) => substituteRef(a, varName, value)) }; + // Also substitute in mappedProps (added by materializer for catalog components) + if (node.mappedProps) { + const subProps: Record = {}; + for (const [k, v] of Object.entries(node.mappedProps)) { + subProps[k] = substituteRef(v, varName, value); + } + (result as any).mappedProps = subProps; + } + return result; + } + case "Assign": + return { ...node, value: substituteRef(node.value, varName, value) }; + default: + return node; + } +} + +/** + * Each(array, varName, template) — evaluate template once per array item. + * varName is user-defined (e.g. `issue`, `ticket`) — no $ prefix collision. + * + * Before evaluation, substitutes all Ref(varName) in the template with the + * current item's literal value. This ensures deferred expressions (like + * Action/Set steps) capture concrete values instead of dangling loop refs. + */ +function evaluateLazyBuiltin( + name: string, + args: ASTNode[], + context: EvaluationContext, + schemaCtx?: SchemaContext, +): unknown { + if (name === "Each") { + if (args.length < 3) return []; + const arr = evaluate(args[0], context); + if (!Array.isArray(arr)) return []; + + const varName = + args[1].k === "Ref" ? args[1].n : args[1].k === "Str" ? (args[1] as any).v : null; + if (!varName) return []; + const template = args[2]; + + return arr.map((item, idx) => { + // Pre-substitute loop variable refs with concrete values in the template AST. + // This captures the item for deferred expressions (Action steps evaluated at click time). + const substituted = substituteRef(template, varName, item); + const childCtx: EvaluationContext = { + ...context, + resolveRef: (refName: string) => { + if (refName === varName) return item; + return context.resolveRef(refName); + }, + }; + const result = evaluate(substituted, childCtx, schemaCtx); + // If schema context is available and result is an ElementNode, evaluate its props + if (schemaCtx && isElementNode(result)) { + return evaluateElementInline(result as ElementNode, childCtx, schemaCtx); + } + return result; + }); + } + return null; +} diff --git a/packages/lang-core/src/runtime/index.ts b/packages/lang-core/src/runtime/index.ts new file mode 100644 index 000000000..c6729dee3 --- /dev/null +++ b/packages/lang-core/src/runtime/index.ts @@ -0,0 +1,24 @@ +export { createStore } from "./store"; +export type { Store } from "./store"; + +export { evaluate, isReactiveAssign, stripReactiveAssign } from "./evaluator"; +export type { EvaluationContext, ReactiveAssign } from "./evaluator"; + +export { createQueryManager } from "./queryManager"; +export type { + MutationNode, + MutationResult, + QueryManager, + QueryNode, + QuerySnapshot, + Transport, +} from "./queryManager"; + +export { evaluateElementProps } from "./evaluate-tree"; +export type { EvalContext } from "./evaluate-tree"; + +export { resolveStateField } from "./state-field"; +export type { InferStateFieldValue, StateField } from "./state-field"; + +export { createMcpTransport } from "./mcp-transport"; +export type { McpClientLike, McpConnection, McpTool, McpTransportConfig } from "./mcp-transport"; diff --git a/packages/lang-core/src/runtime/mcp-transport.ts b/packages/lang-core/src/runtime/mcp-transport.ts new file mode 100644 index 000000000..e8ede111c --- /dev/null +++ b/packages/lang-core/src/runtime/mcp-transport.ts @@ -0,0 +1,201 @@ +/** + * MCP Transport — connects openui-lang Query() to any MCP server. + * + * Two modes: + * 1. URL mode: pass a URL, we create the MCP Client + transport internally + * 2. Client mode: pass a pre-configured MCP Client + * + * Both speak proper MCP protocol (JSON-RPC). Both provide tool discovery + * via listTools() for auto-generating LLM prompts. + * + * @example + * ```tsx + * // Mode 1: URL (we handle everything) + * const mcp = await createMcpTransport({ url: "https://my-api.com/mcp" }); + * + * // Mode 2: Pre-configured client (you handle auth, transport choice) + * import { Client } from "@modelcontextprotocol/sdk/client"; + * import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp"; + * const client = new Client({ name: "my-app", version: "1.0.0" }); + * await client.connect(new StreamableHTTPClientTransport(new URL("https://my-api.com/mcp"))); + * const mcp = await createMcpTransport({ client }); + * + * // Use with Renderer + * const tools = await mcp.listTools(); + * const prompt = library.prompt({ tools: tools.map(t => ({ name: t.name, description: t.description })) }); + * + * + * // Cleanup + * await mcp.disconnect(); + * ``` + */ + +import type { Transport } from "./queryManager"; + +/** Tool schema from MCP server — used for prompt generation */ +export interface McpTool { + name: string; + description?: string; + inputSchema?: Record; +} + +export interface McpTransportConfig { + /** MCP server URL — creates Client + StreamableHTTPClientTransport internally */ + url?: string; + /** Pre-configured MCP Client instance (from @modelcontextprotocol/sdk) */ + client?: McpClientLike; + /** Optional headers for URL mode (e.g. auth tokens) */ + headers?: Record; +} + +/** Result of createMcpTransport — transport + tool discovery + cleanup */ +export interface McpConnection { + /** Transport for Renderer/QueryManager — just callTool() */ + transport: Transport; + /** Discover available tools for prompt generation */ + listTools(): Promise; + /** Disconnect from MCP server */ + disconnect(): Promise; +} + +/** + * Minimal shape of an MCP Client — matches @modelcontextprotocol/sdk Client + * without requiring it as a hard import. Users can pass any object that + * implements these methods. + */ +export interface McpClientLike { + callTool( + params: { name: string; arguments?: Record }, + options?: unknown, + ): Promise<{ + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + structuredContent?: unknown; + isError?: boolean; + }>; + listTools(params?: { cursor?: string }): Promise<{ + tools: McpTool[]; + nextCursor?: string; + }>; + close?(): Promise; +} + +/** + * Extract the actual data from an MCP callTool result. + * Prefers structuredContent (machine-readable JSON), falls back to parsing text content. + */ +function extractToolResult(result: { + content: Array<{ type: string; text?: string; [key: string]: unknown }>; + structuredContent?: unknown; + isError?: boolean; +}): unknown { + if (result.isError) { + const errorText = result.content + ?.filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + throw new Error(`MCP tool error: ${errorText || "Unknown error"}`); + } + + // Prefer structuredContent (JSON data, no parsing needed) + if (result.structuredContent != null) { + return result.structuredContent; + } + + // Fall back to text content — try to parse as JSON + const textParts = result.content?.filter((c) => c.type === "text").map((c) => c.text ?? ""); + if (textParts?.length) { + const text = textParts.join(""); + try { + return JSON.parse(text); + } catch { + return text; + } + } + + return null; +} + +/** + * Create an MCP connection with transport + tool discovery. + * + * @param config - URL or pre-configured MCP Client + * @returns McpConnection with transport, listTools(), and disconnect() + */ +export async function createMcpTransport(config: McpTransportConfig): Promise { + let client: McpClientLike; + let ownsClient = false; + + if (config.client) { + // Mode 2: User provides pre-configured client + client = config.client; + } else if (config.url) { + // Mode 1: Create client + transport from URL + try { + // Dynamic import — @modelcontextprotocol/sdk is an optional peer dep + // eslint-disable-next-line @typescript-eslint/no-var-requires + const clientMod = await import("@modelcontextprotocol/sdk/client/index.js"); + const transportMod = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); + const { Client } = clientMod; + const { StreamableHTTPClientTransport } = transportMod; + + const mcpClient = new Client({ name: "openui-lang", version: "1.0.0" }); + const base = + typeof globalThis.location !== "undefined" ? globalThis.location.href : undefined; + const url = new URL(config.url, base); + const transportOpts: Record = {}; + if (config.headers) { + transportOpts.requestInit = { headers: config.headers }; + } + const mcpTransport = new StreamableHTTPClientTransport(url, transportOpts); + await mcpClient.connect(mcpTransport); + + client = mcpClient as unknown as McpClientLike; + ownsClient = true; + } catch (err: any) { + throw new Error( + `Failed to create MCP transport. Make sure @modelcontextprotocol/sdk is installed:\n` + + ` pnpm add @modelcontextprotocol/sdk\n\n` + + `Original error: ${err.message}`, + ); + } + } else { + throw new Error("createMcpTransport requires either { url } or { client }"); + } + + // Build the Transport interface for QueryManager + const transport: Transport = { + async callTool(toolName: string, args: Record): Promise { + const result = await client.callTool({ name: toolName, arguments: args }); + return extractToolResult(result); + }, + }; + + return { + transport, + + async listTools(): Promise { + const allTools: McpTool[] = []; + let cursor: string | undefined; + const seenCursors = new Set(); + let iterations = 0; + const MAX_PAGES = 100; + do { + if (++iterations > MAX_PAGES) break; + const result = await client.listTools(cursor ? { cursor } : undefined); + allTools.push(...result.tools); + cursor = result.nextCursor; + if (cursor) { + if (seenCursors.has(cursor)) break; // cursor loop detected + seenCursors.add(cursor); + } + } while (cursor); + return allTools; + }, + + async disconnect(): Promise { + if (ownsClient && client.close) { + await client.close(); + } + }, + }; +} diff --git a/packages/lang-core/src/runtime/queryManager.ts b/packages/lang-core/src/runtime/queryManager.ts new file mode 100644 index 000000000..f315f17b2 --- /dev/null +++ b/packages/lang-core/src/runtime/queryManager.ts @@ -0,0 +1,479 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Query Manager — reactive data fetching for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Transport interface for Query() and Mutation() tool calls. + * Framework-agnostic — works with MCP, REST, GraphQL, or any backend. + * + * @example + * ```ts + * // MCP + * const transport: Transport = createMcpTransport({ url: "https://api.com/mcp/sse" }); + * + * // REST + * const transport: Transport = { + * callTool: (name, args) => fetch(`/api/${name}`, { method: "POST", body: JSON.stringify(args) }).then(r => r.json()), + * }; + * + * // Mock + * const transport: Transport = { + * callTool: async (name, args) => ({ items: [{ id: 1, name: "Test" }] }), + * }; + * ``` + */ +export interface Transport { + callTool(toolName: string, args: Record): Promise; +} + +export interface QueryNode { + statementId: string; + toolName: string; + args: unknown; + defaults: unknown; + /** Evaluated dependency value — included in cache key to force re-fetch on change. */ + deps: unknown; + /** Auto-refresh interval in seconds. */ + refreshInterval?: number; + complete: boolean; +} + +export interface MutationNode { + statementId: string; + toolName: string; +} + +export interface MutationResult { + status: "idle" | "loading" | "success" | "error"; + data?: unknown; + error?: unknown; +} + +export interface QuerySnapshot extends Record { + __openui_loading: string[]; + __openui_refetching: string[]; +} + +export interface QueryManager { + evaluateQueries(queryNodes: QueryNode[]): void; + getResult(statementId: string): unknown; + /** Returns true if the given statement is currently fetching. */ + isLoading(statementId: string): boolean; + /** Returns true if ANY query is currently fetching. */ + isAnyLoading(): boolean; + /** Re-fetch specific queries by statementId, or all if no ids provided. */ + invalidate(statementIds?: string[]): void; + /** Register mutation declarations from the parser. */ + registerMutations(nodes: MutationNode[]): void; + /** Fire a mutation with evaluated args. Called on button click. Returns true on success, false on error. */ + fireMutation( + statementId: string, + evaluatedArgs: Record, + refreshQueryIds?: string[], + ): Promise; + /** Get the current result of a mutation (idle/loading/success/error). */ + getMutationResult(statementId: string): MutationResult | null; + subscribe(listener: () => void): () => void; + getSnapshot(): QuerySnapshot; + /** Re-activate after dispose (needed for React Strict Mode double-mount). */ + activate(): void; + dispose(): void; +} + +/** JSON.stringify with stable key ordering at all nesting levels. */ +function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key: string, val: unknown) => { + if (val && typeof val === "object" && !Array.isArray(val)) { + const sorted: Record = {}; + for (const k of Object.keys(val as object).sort()) { + sorted[k] = (val as any)[k]; + } + return sorted; + } + // Serialize non-JSON-safe primitives to stable strings + if (val === undefined) return "__undefined__"; + if (typeof val === "number") { + if (Number.isNaN(val)) return "__NaN__"; + if (val === Infinity) return "__Inf__"; + if (val === -Infinity) return "__-Inf__"; + } + return val; + }); +} + +// ── Factory ────────────────────────────────────────────────────────────────── + +export function createQueryManager(transport: Transport | null): QueryManager { + const results = new Map(); + const defaults = new Map(); + const pending = new Set(); + /** Maps statementId → current cache key */ + const lastKeys = new Map(); + /** Maps statementId → previous cache key (for fallback while loading) */ + const prevKeys = new Map(); + /** Maps statementId → {toolName, args} for reliable invalidation (avoids parsing cache keys) */ + const queryMeta = new Map(); + /** Tracks which statementIds are currently loading */ + const loadingStmts = new Set(); + /** Tracks which stmtIds have completed at least one successful fetch */ + const hasEverFetched = new Set(); + const listeners = new Set<() => void>(); + const timers = new Map>(); + const lastIntervals = new Map(); + // ── Mutation state ─── + const mutationMeta = new Map(); + const mutationResults = new Map(); + let snapshot: QuerySnapshot = { __openui_loading: [], __openui_refetching: [] }; + let snapshotJson = JSON.stringify(snapshot); + let disposed = false; + let generation = 0; + const needsRefetch = new Map(); + const removedQueryIds = new Set(); + + /** + * Rebuild the snapshot from current state. Returns true if snapshot content + * changed. Preserves snapshot identity when content is unchanged to avoid + * infinite re-render loops with useSyncExternalStore. + */ + function rebuildSnapshot(): boolean { + const out: QuerySnapshot = { __openui_loading: [], __openui_refetching: [] }; + for (const [sid, cacheKey] of lastKeys) { + if (results.has(cacheKey)) { + out[sid] = results.get(cacheKey); + } else { + // Fall back to previous result while new fetch is in-flight + const prev = prevKeys.get(sid); + if (prev && results.has(prev)) { + out[sid] = results.get(prev); + } else { + out[sid] = defaults.get(sid) ?? null; + } + } + } + // Include mutation results in snapshot (keyed by statementId) + for (const [sid, mr] of mutationResults) { + out[sid] = mr; + } + // Include loading state in snapshot so React detects changes + out.__openui_loading = [...loadingStmts]; + out.__openui_refetching = [...loadingStmts].filter((id) => hasEverFetched.has(id)); + + // Compare against cached JSON string (avoids double-stringify) + try { + const outJson = JSON.stringify(out); + if (outJson === snapshotJson) return false; + snapshot = out; + snapshotJson = outJson; + } catch { + // Circular refs or other serialization issues — force a new snapshot identity + snapshot = out; + snapshotJson = ""; + } + return true; + } + + function notify() { + for (const listener of [...listeners]) { + listener(); + } + } + + async function executeFetch( + cacheKey: string, + statementId: string, + toolName: string, + args: unknown, + ) { + // Check transport before setting loading state to prevent loading flash + if (!transport) return; + + pending.add(cacheKey); + loadingStmts.add(statementId); + rebuildSnapshot(); + notify(); + try { + const data = await transport.callTool(toolName, (args as Record) ?? {}); + if (disposed) return; + // Skip result write if this query was removed while fetch was in-flight + if (removedQueryIds.has(statementId)) { + removedQueryIds.delete(statementId); + return; + } + // Normalize undefined results to null + results.set(cacheKey, data ?? null); + hasEverFetched.add(statementId); + // Clean up old cached result now that new one has arrived + const prev = prevKeys.get(statementId); + if (prev && prev !== cacheKey) { + results.delete(prev); + } + prevKeys.delete(statementId); + } catch (err) { + console.error(`Query "${toolName}" failed:`, err); + } finally { + pending.delete(cacheKey); + loadingStmts.delete(statementId); + if (rebuildSnapshot()) notify(); + // If invalidation occurred while this fetch was in-flight, re-fetch + if (needsRefetch.get(statementId)) { + needsRefetch.delete(statementId); + const meta = queryMeta.get(statementId); + const key = lastKeys.get(statementId); + if (meta && key) { + executeFetch(key, statementId, meta.toolName, meta.args); + } + } + } + } + + function evaluateQueries(queryNodes: QueryNode[]) { + if (disposed) return; + + removedQueryIds.clear(); + + // Clean up timers and state for queries that no longer exist + const activeIds = new Set(queryNodes.map((n) => n.statementId)); + for (const [sid, timer] of timers) { + if (!activeIds.has(sid)) { + clearInterval(timer); + timers.delete(sid); + } + } + // Remove all state for deleted queries. + // Collect active cache keys first so we don't delete shared entries. + const activeCacheKeys = new Set(); + for (const [sid, key] of lastKeys) { + if (activeIds.has(sid)) activeCacheKeys.add(key); + } + for (const [sid, key] of prevKeys) { + if (activeIds.has(sid)) activeCacheKeys.add(key); + } + for (const sid of [...lastKeys.keys()]) { + if (!activeIds.has(sid)) { + removedQueryIds.add(sid); + const key = lastKeys.get(sid); + if (key && !activeCacheKeys.has(key)) results.delete(key); + const prevKey = prevKeys.get(sid); + if (prevKey && !activeCacheKeys.has(prevKey)) results.delete(prevKey); + lastKeys.delete(sid); + prevKeys.delete(sid); + queryMeta.delete(sid); + defaults.delete(sid); + loadingStmts.delete(sid); + hasEverFetched.delete(sid); + lastIntervals.delete(sid); + } + } + + for (const node of queryNodes) { + if (!node.complete) continue; + + const depsKey = node.deps != null ? "::" + stableStringify(node.deps) : ""; + const cacheKey = node.toolName + "::" + stableStringify(node.args) + depsKey; + + defaults.set(node.statementId, node.defaults); + queryMeta.set(node.statementId, { toolName: node.toolName, args: node.args }); + + // Track previous key for fallback (keep old data visible while loading) + const prevKey = lastKeys.get(node.statementId); + if (prevKey && prevKey !== cacheKey) { + prevKeys.set(node.statementId, prevKey); + // DON'T delete old result — keep it for fallback display + } + lastKeys.set(node.statementId, cacheKey); + + // Fire fetch if not cached, not pending, and transport is available + if (transport && !results.has(cacheKey) && !pending.has(cacheKey)) { + executeFetch(cacheKey, node.statementId, node.toolName, node.args); + } + + // Setup / reconfigure auto-refresh timer + const currentInterval = node.refreshInterval ?? 0; + const previousInterval = lastIntervals.get(node.statementId) ?? 0; + + if (currentInterval !== previousInterval) { + // Clear old timer if it exists + const existingTimer = timers.get(node.statementId); + if (existingTimer) { + clearInterval(existingTimer); + timers.delete(node.statementId); + } + + // Create new timer if interval is positive + if (currentInterval > 0) { + timers.set( + node.statementId, + setInterval(() => { + if (disposed || !transport) return; + const k = lastKeys.get(node.statementId); + if (k && !pending.has(k)) { + const meta = queryMeta.get(node.statementId); + if (meta) { + executeFetch(k, node.statementId, meta.toolName, meta.args); + } + } + }, currentInterval * 1000), + ); + } + + lastIntervals.set(node.statementId, currentInterval); + } + } + + if (rebuildSnapshot()) notify(); + } + + function getResult(statementId: string): unknown { + const key = lastKeys.get(statementId); + if (key && results.has(key)) return results.get(key); + // Fall back to previous result while loading + const prev = prevKeys.get(statementId); + if (prev && results.has(prev)) return results.get(prev); + return defaults.get(statementId) ?? null; + } + + function isLoading(statementId: string): boolean { + return loadingStmts.has(statementId); + } + + function isAnyLoading(): boolean { + return loadingStmts.size > 0; + } + + function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + function getSnapshot(): QuerySnapshot { + return snapshot; + } + + function invalidate(statementIds?: string[]) { + if (disposed || !transport) return; + // Re-fetch targeted queries (or all if no ids provided). + // Keep old data visible until new fetches complete. + const targets = statementIds?.length + ? statementIds.filter((sid) => lastKeys.has(sid)) + : [...lastKeys.keys()]; + + for (const sid of targets) { + const cacheKey = lastKeys.get(sid); + const meta = queryMeta.get(sid); + if (!cacheKey || !meta) continue; + if (pending.has(cacheKey)) { + // Fetch is already in-flight — flag for re-fetch when it completes + needsRefetch.set(sid, true); + } else { + executeFetch(cacheKey, sid, meta.toolName, meta.args); + } + } + } + + // ── Mutation methods ───────────────────────────────────────────────────── + + function registerMutations(nodes: MutationNode[]) { + // Clean up removed mutations + const activeIds = new Set(nodes.map((n) => n.statementId)); + for (const sid of [...mutationMeta.keys()]) { + if (!activeIds.has(sid)) { + mutationMeta.delete(sid); + mutationResults.delete(sid); + } + } + // Register active mutations + for (const node of nodes) { + const prev = mutationMeta.get(node.statementId); + // Reset result when the backing tool changes (declaration was edited) + if (prev && prev.toolName !== node.toolName) { + mutationResults.set(node.statementId, { status: "idle", data: null, error: null }); + } + mutationMeta.set(node.statementId, { toolName: node.toolName }); + if (!mutationResults.has(node.statementId)) { + mutationResults.set(node.statementId, { status: "idle" }); + } + } + if (rebuildSnapshot()) notify(); + } + + async function fireMutation( + statementId: string, + evaluatedArgs: Record, + refreshQueryIds?: string[], + ): Promise { + if (disposed || !transport) return false; + const meta = mutationMeta.get(statementId); + if (!meta) return false; + + // Capture generation to detect stale writes after dispose/re-activate + const gen = generation; + + // Set loading state + mutationResults.set(statementId, { status: "loading" }); + rebuildSnapshot(); + notify(); + + let success = false; + try { + const data = await transport.callTool(meta.toolName, evaluatedArgs); + if (disposed || gen !== generation) return false; + mutationResults.set(statementId, { status: "success", data }); + success = true; + } catch (err: any) { + if (disposed || gen !== generation) return false; + mutationResults.set(statementId, { + status: "error", + error: err?.message ?? String(err), + }); + } + + rebuildSnapshot(); + notify(); + + // Auto-refresh queries after mutation completes + if (success && refreshQueryIds?.length) { + invalidate(refreshQueryIds); + } + + return success; + } + + function getMutationResult(statementId: string): MutationResult | null { + return mutationResults.get(statementId) ?? null; + } + + function activate() { + disposed = false; + } + + function dispose() { + disposed = true; + generation++; + loadingStmts.clear(); + listeners.clear(); + timers.forEach((t) => clearInterval(t)); + timers.clear(); + // Preserve lastIntervals across dispose/activate so timers can be + // correctly reconfigured on re-mount (React Strict Mode). + mutationResults.clear(); + mutationMeta.clear(); + // Keep results, defaults, lastKeys, prevKeys, queryMeta for Strict Mode re-attach + } + + return { + evaluateQueries, + getResult, + isLoading, + isAnyLoading, + invalidate, + registerMutations, + fireMutation, + getMutationResult, + subscribe, + getSnapshot, + activate, + dispose, + }; +} diff --git a/packages/lang-core/src/runtime/state-field.ts b/packages/lang-core/src/runtime/state-field.ts new file mode 100644 index 000000000..347af83d2 --- /dev/null +++ b/packages/lang-core/src/runtime/state-field.ts @@ -0,0 +1,42 @@ +import type { EvaluationContext } from "./evaluator"; +import { evaluate, isReactiveAssign } from "./evaluator"; +import type { Store } from "./store"; + +export interface StateField { + name: string; + value: T; + setValue: (newValue: T) => void; + isReactive: boolean; +} + +export type InferStateFieldValue = T extends StateField ? U : T; + +export function resolveStateField( + name: string, + bindingValue: unknown, + store: Store | null, + evaluationContext: EvaluationContext | null, + fieldGetter: (fieldName: string) => unknown, + fieldSetter: (fieldName: string, value: unknown) => void, +): StateField { + if (isReactiveAssign(bindingValue) && store && evaluationContext) { + const { target, expr } = bindingValue; + return { + name, + value: store.get(target) as T, + setValue: (value: T) => { + const extraScope: Record = { $value: value }; + const nextValue = evaluate(expr, { ...evaluationContext, extraScope }); + store.set(target, nextValue); + }, + isReactive: true, + }; + } + + return { + name, + value: (fieldGetter(name) ?? bindingValue) as T, + setValue: (value: T) => fieldSetter(name, value), + isReactive: false, + }; +} diff --git a/packages/lang-core/src/runtime/store.ts b/packages/lang-core/src/runtime/store.ts new file mode 100644 index 000000000..c42683644 --- /dev/null +++ b/packages/lang-core/src/runtime/store.ts @@ -0,0 +1,99 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Reactive state store for openui-lang +// ───────────────────────────────────────────────────────────────────────────── + +export interface Store { + get(name: string): unknown; + set(name: string, value: unknown): void; + subscribe(listener: () => void): () => void; + getSnapshot(): Record; + initialize(defaults: Record, persisted: Record): void; + dispose(): void; +} + +export function createStore(): Store { + const state = new Map(); + const listeners = new Set<() => void>(); + let snapshot: Record = {}; + + function notify() { + const currentListeners = [...listeners]; + for (const listener of currentListeners) { + listener(); + } + } + + function rebuildSnapshot() { + snapshot = Object.fromEntries(state); + } + + function get(name: string): unknown { + return state.get(name); + } + + function set(name: string, value: unknown): void { + const existing = state.get(name); + if (Object.is(existing, value)) return; + // Shallow-compare plain objects (form data) + if ( + value && + existing && + typeof value === "object" && + typeof existing === "object" && + !Array.isArray(value) && + !Array.isArray(existing) + ) { + const nk = Object.keys(value as Record); + const ok = Object.keys(existing as Record); + if ( + nk.length === ok.length && + nk.every((k) => + Object.is( + (value as Record)[k], + (existing as Record)[k], + ), + ) + ) { + return; + } + } + state.set(name, value); + rebuildSnapshot(); + notify(); + } + + function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + function getSnapshot(): Record { + return snapshot; + } + + function initialize(defaults: Record, persisted: Record): void { + // Remove stale $binding keys not present in new declarations + for (const key of [...state.keys()]) { + if (key.startsWith("$") && !(key in defaults) && !(key in persisted)) { + state.delete(key); + } + } + // Set all binding defaults and persisted values + const allKeys = new Set([...Object.keys(defaults), ...Object.keys(persisted)]); + for (const key of allKeys) { + state.set(key, key in persisted ? persisted[key] : defaults[key]); + } + rebuildSnapshot(); + notify(); + } + + function dispose(): void { + state.clear(); + listeners.clear(); + snapshot = {}; + } + + return { get, set, subscribe, getSnapshot, initialize, dispose }; +} diff --git a/packages/lang-core/src/utils/validation.ts b/packages/lang-core/src/utils/validation.ts index 3d3870681..c3e6fcadd 100644 --- a/packages/lang-core/src/utils/validation.ts +++ b/packages/lang-core/src/utils/validation.ts @@ -10,14 +10,16 @@ export interface ParsedRule { * "minLength:3" → { type: "minLength", arg: 3 } * "pattern:^[a-z]" → { type: "pattern", arg: "^[a-z]" } */ +const NUMERIC_RULES = new Set(["min", "max", "minLength", "maxLength"]); + export function parseRule(rule: string): ParsedRule { const colonIdx = rule.indexOf(":"); if (colonIdx === -1) return { type: rule }; const type = rule.slice(0, colonIdx); const rawArg = rule.slice(colonIdx + 1); - const num = Number(rawArg); - return { type, arg: Number.isFinite(num) && rawArg !== "" ? num : rawArg }; + const arg = NUMERIC_RULES.has(type) && !Number.isNaN(Number(rawArg)) ? Number(rawArg) : rawArg; + return { type, arg }; } export function parseRules(rules: unknown): ParsedRule[] { @@ -135,7 +137,10 @@ export function validate( ): string | undefined { for (const rule of rules) { const validator = customValidators?.[rule.type] ?? builtInValidators[rule.type]; - if (!validator) continue; + if (!validator) { + console.warn(`[openui] Unknown validation rule type: "${rule.type}"`); + continue; + } const error = validator(value, rule.arg); if (error) return error; } diff --git a/packages/openui-cli/src/commands/generate-worker.ts b/packages/openui-cli/src/commands/generate-worker.ts index 1bf20fefd..3be5c1172 100644 --- a/packages/openui-cli/src/commands/generate-worker.ts +++ b/packages/openui-cli/src/commands/generate-worker.ts @@ -17,6 +17,7 @@ import * as esbuild from "esbuild"; interface Library { prompt(options?: unknown): string; + toSpec(): object; toJSONSchema(): object; } @@ -50,7 +51,7 @@ function createAssetStubPlugin(): esbuild.Plugin { function isLibrary(value: unknown): value is Library { if (typeof value !== "object" || value === null) return false; const obj = value as Record; - return typeof obj["prompt"] === "function" && typeof obj["toJSONSchema"] === "function"; + return typeof obj["prompt"] === "function" && typeof obj["toSpec"] === "function"; } function findLibrary(mod: Record, exportName?: string): Library | undefined { @@ -172,7 +173,8 @@ async function main(): Promise { let output: string; if (jsonSchema) { - output = JSON.stringify(library.toJSONSchema(), null, 2); + // Output a PromptSpec-compatible JSON with component signatures, groups, and JSON schema. + output = JSON.stringify(library.toSpec(), null, 2); } else { const promptOptions = findPromptOptions(mod, promptOptionsName); output = library.prompt(promptOptions); diff --git a/packages/openui-cli/src/index.ts b/packages/openui-cli/src/index.ts index d0e642203..180ac44c2 100644 --- a/packages/openui-cli/src/index.ts +++ b/packages/openui-cli/src/index.ts @@ -30,7 +30,7 @@ program .description("Generate system prompt or JSON schema from a library definition") .argument("[entry]", "Path to a file that exports a createLibrary() result") .option("-o, --out ", "Write output to a file instead of stdout") - .option("--json-schema", "Output JSON schema instead of the system prompt") + .option("--json-schema", "Output JSON schema with component signatures for standalone prompt generation") .option("--export ", "Name of the export to use (auto-detected by default)") .option( "--prompt-options ", diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json index 77c6c95ae..336b5e943 100644 --- a/packages/react-lang/package.json +++ b/packages/react-lang/package.json @@ -59,9 +59,16 @@ "zod": "^4.0.0" }, "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0", "react": ">=19.0.0" }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@types/react": "^19.0.0", "vitest": "^4.0.18" } diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index fdd8c6eb9..f942cabae 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -1,5 +1,5 @@ -import type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core"; -import React, { Component, Fragment, useEffect } from "react"; +import type { ActionEvent, ElementNode, ParseResult, Transport } from "@openuidev/lang-core"; +import React, { Component, Fragment, useEffect, useInsertionEffect, useRef } from "react"; import { OpenUIContext, useOpenUI, useRenderNode } from "./context"; import { useOpenUIState } from "./hooks/useOpenUIState"; import type { ComponentRenderer, Library } from "./library"; @@ -17,14 +17,19 @@ export interface RendererProps { * Called whenever a form field value changes. Receives the raw form state map. * The consumer decides how to persist this (e.g. embed in message, store separately). */ - onStateUpdate?: (state: Record) => void; + onStateUpdate?: (state: Record) => void; /** * Initial form state to hydrate on load (e.g. from a previously persisted message). - * Shape: { formName: { fieldName: { value, componentType } } } + * Shape: { formName: { fieldName: { value, componentType } }, $varName: value } + * $-prefixed keys are treated as reactive bindings, everything else is form state. */ initialState?: Record; /** Called whenever the parse result changes. */ onParseResult?: (result: ParseResult | null) => void; + /** Transport for Query() data fetching — MCP, REST, GraphQL, or any backend. */ + transport?: Transport | null; + /** Custom loading indicator shown while queries are fetching. Defaults to a spinner. */ + queryLoader?: React.ReactNode; } // ─── Error boundary ─── @@ -37,6 +42,12 @@ interface ErrorBoundaryState { hasError: boolean; } +/** + * Error boundary that intentionally shows the last successfully rendered + * children when a render error occurs. This "show last good state" behavior + * prevents the UI from going blank during streaming or transient evaluation + * errors, and auto-recovers when new valid children arrive. + */ class ElementErrorBoundary extends Component { private lastValidChildren: React.ReactNode = null; @@ -119,34 +130,44 @@ function RenderNode({ node }: { node: ElementNode }) { } /** - * Renders a resolved element using its renderer. Gets renderNode from context. + * Renders a resolved element using its renderer. + * Props are already evaluated by evaluate-tree — no AST awareness needed. */ function RenderNodeInner({ el, Comp }: { el: ElementNode; Comp: ComponentRenderer }) { const renderNode = useRenderNode(); - const { library } = useOpenUI(); + return ; +} - // Handle elements that have positional `args` instead of named `props` - let props = el.props; - if (!props) { - const args = (el as any).args as unknown[] | undefined; - if (args) { - const def = library.components[el.typeName]; - if (def) { - const fieldNames = Object.keys(def.props.shape); - props = {}; - for (let i = 0; i < fieldNames.length && i < args.length; i++) { - props[fieldNames[i]] = args[i]; - } - } - } - props = props ?? {}; - } +// ─── Loading style injection (once per document) ─── - return ; +let loadingStyleInjected = false; +function ensureLoadingStyle() { + if (loadingStyleInjected || typeof document === "undefined") return; + loadingStyleInjected = true; + const style = document.createElement("style"); + style.textContent = `@keyframes openui-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`; + document.head.appendChild(style); } // ─── Public component ─── +const DefaultQueryLoader = () => ( +
+); + export function Renderer({ response, library, @@ -155,8 +176,17 @@ export function Renderer({ onStateUpdate, initialState, onParseResult, + transport, + queryLoader, }: RendererProps) { - const { result, contextValue } = useOpenUIState( + useInsertionEffect(() => { + ensureLoadingStyle(); + }, []); + + const onParseResultRef = useRef(onParseResult); + onParseResultRef.current = onParseResult; + + const { result, parseResult, contextValue, isQueryLoading } = useOpenUIState( { response, library, @@ -164,13 +194,16 @@ export function Renderer({ onAction, onStateUpdate, initialState, + transport, }, renderDeep, ); + // Fire onParseResult with the RAW parse result (not evaluated), + // so hosts only see changes when the parser output actually changes. useEffect(() => { - onParseResult?.(result); - }, [result, onParseResult]); + onParseResultRef.current?.(parseResult); + }, [parseResult]); if (!result?.root) { return null; @@ -178,7 +211,12 @@ export function Renderer({ return ( - +
+ {isQueryLoading && (queryLoader ?? )} +
+ +
+
); } diff --git a/packages/react-lang/src/context.ts b/packages/react-lang/src/context.ts index f521f9b73..3c9b05303 100644 --- a/packages/react-lang/src/context.ts +++ b/packages/react-lang/src/context.ts @@ -1,10 +1,8 @@ +import type { ActionPlan, EvaluationContext, Store } from "@openuidev/lang-core"; import type { ReactNode } from "react"; import { createContext, useContext, useEffect } from "react"; import type { Library } from "./library"; -/** - * Shared context provided by to all rendered components. - */ export interface OpenUIContextValue { /** The active component library (schema + renderers). */ library: Library; @@ -15,32 +13,31 @@ export interface OpenUIContextValue { renderNode: (value: unknown) => ReactNode; /** - * Trigger an action. Components call this to fire structured ActionEvents. - * - * @param userMessage Human-readable label ("Submit Application") - * @param formName Optional form name — if provided, form state for this form is included - * @param action Optional custom action config { type, params } + * Trigger an action. Accepts either: + * - ActionPlan (v0.5): runs steps sequentially (Run, Set, ToAssistant, OpenUrl) + * - Legacy action config (v0.4): { type?: string, params?: Record } + * - Nothing: fires ContinueConversation with the label */ triggerAction: ( userMessage: string, formName?: string, - action?: { type?: string; params?: Record }, - ) => void; + action?: ActionPlan | { type?: string; params?: Record }, + ) => void | Promise; /** Whether the LLM is currently streaming content. */ isStreaming: boolean; - /** Get a form field value. Returns undefined if not set. */ - getFieldValue: (formName: string | undefined, name: string) => any; + /** Get a field value. Top-level for $bindings, nested under formName for form fields. */ + getFieldValue: (formName: string | undefined, name: string) => unknown; /** * Set a form field value. * * @param formName The form's name prop - * @param componentType The component type (e.g. "Input", "Select", "RadioGroup") + * @param componentType The component type (e.g. "Input", "Select") — optional * @param name The field's name prop * @param value The new value - * @param shouldTriggerSaveCallback When true, persists the updated state via updateMessage. + * @param shouldTriggerSaveCallback When true, persists state via onStateUpdate. * Text inputs should pass `false` on change and `true` on blur. * Discrete inputs (Select, RadioGroup, etc.) should always pass `true`. */ @@ -48,9 +45,15 @@ export interface OpenUIContextValue { formName: string | undefined, componentType: string | undefined, name: string, - value: any, + value: unknown, shouldTriggerSaveCallback?: boolean, ) => void; + + /** Reactive binding store for $variables and form data. */ + store: Store; + + /** AST evaluation context used by runtime expression evaluation. */ + evaluationContext: EvaluationContext; } export const OpenUIContext = createContext(null); @@ -112,7 +115,7 @@ export function useGetFieldValue() { * @example * ```tsx * const setFieldValue = useSetFieldValue(); - * setFieldValue("contactForm", "Input", "name", e.target.value)} /> + * setFieldValue("contactForm", "Input", "name", e.target.value, false)} /> * ``` */ export function useSetFieldValue() { @@ -152,10 +155,10 @@ export function useSetDefaultValue({ shouldTriggerSaveCallback = false, }: { formName?: string; - componentType: string; + componentType?: string; name: string; - existingValue: any; - defaultValue: any; + existingValue: unknown; + defaultValue: unknown; shouldTriggerSaveCallback?: boolean; }) { const setFieldValue = useSetFieldValue(); @@ -165,5 +168,14 @@ export function useSetDefaultValue({ if (!isStreaming && existingValue === undefined && defaultValue !== undefined) { setFieldValue(formName, componentType, name, defaultValue, shouldTriggerSaveCallback); } - }, [existingValue, defaultValue, isStreaming]); + }, [ + defaultValue, + existingValue, + formName, + componentType, + name, + isStreaming, + setFieldValue, + shouldTriggerSaveCallback, + ]); } diff --git a/packages/react-lang/src/hooks/useFormValidation.ts b/packages/react-lang/src/hooks/useFormValidation.ts index 073448b36..0d8aadbea 100644 --- a/packages/react-lang/src/hooks/useFormValidation.ts +++ b/packages/react-lang/src/hooks/useFormValidation.ts @@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useMemo, useRef, useState } fro export interface FormValidationContextValue { errors: Record; + getFieldError: (name: string) => string | undefined; validateField: (name: string, value: unknown, rules: ParsedRule[]) => boolean; registerField: (name: string, rules: ParsedRule[], getValue: () => unknown) => void; unregisterField: (name: string) => void; @@ -12,7 +13,7 @@ export interface FormValidationContextValue { export const FormValidationContext = createContext(null); -export function useFormValidation() { +export function useFormValidation(): FormValidationContextValue | null { return useContext(FormValidationContext); } @@ -23,8 +24,12 @@ interface FieldRegistration { export function useCreateFormValidation(): FormValidationContextValue { const [errors, setErrors] = useState>({}); + const errorsRef = useRef(errors); + errorsRef.current = errors; const fieldsRef = useRef>({}); + const getFieldError = useCallback((name: string) => errorsRef.current[name], []); + const validateField = useCallback( (name: string, value: unknown, rules: ParsedRule[]): boolean => { const error = validate(value, rules); @@ -79,15 +84,24 @@ export function useCreateFormValidation(): FormValidationContextValue { }); }, []); - return useMemo( + return useMemo( () => ({ errors, + getFieldError, validateField, registerField, unregisterField, validateForm, clearFieldError, }), - [errors, validateField, registerField, unregisterField, validateForm, clearFieldError], + [ + errors, + getFieldError, + validateField, + registerField, + unregisterField, + validateForm, + clearFieldError, + ], ); } diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index f10875088..acdce3ba7 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -1,89 +1,222 @@ import { + ACTION_STEPS, BuiltinActionType, - createParser, + createQueryManager, + createStore, + createStreamingParser, + evaluate, + evaluateElementProps, type ActionEvent, + type ActionPlan, + type EvalContext, + type EvaluationContext, type ParseResult, + type QueryManager, + type QuerySnapshot, + type Store, + type Transport, } from "@openuidev/lang-core"; import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import type { OpenUIContextValue } from "../context"; import type { Library } from "../library"; +/** Unwrap { value, componentType } wrapper from form field entries. Returns raw value. */ +function unwrapFieldValue(v: unknown): unknown { + if ( + v && + typeof v === "object" && + !Array.isArray(v) && + "value" in (v as Record) + ) { + return (v as Record).value; + } + return v; +} + export interface UseOpenUIStateOptions { response: string | null; library: Library; isStreaming: boolean; onAction?: (event: ActionEvent) => void; - onStateUpdate?: (state: Record) => void; + onStateUpdate?: (state: Record) => void; initialState?: Record; + /** Transport for Query data fetching — MCP, REST, GraphQL, or any backend. */ + transport?: Transport | null; } export interface OpenUIState { + /** Evaluated result (props resolved to concrete values). Used by Renderer. */ result: ParseResult | null; + /** Raw parse result (AST nodes in props). Used by onParseResult callback. */ + parseResult: ParseResult | null; contextValue: OpenUIContextValue; + /** Whether any Query is currently fetching data. */ + isQueryLoading: boolean; } /** * Core state hook — extracts all form state, action handling, parser * management, and context assembly out of the Renderer component. * - * - propsRef avoids stale closures in callbacks - * - stable getFieldValue/setFieldValue/triggerAction via useCallback([]) - * - setFieldValue accepts shouldTriggerSaveCallback (blur-based persistence for text inputs) + * Store holds everything: $bindings as top-level keys, form fields nested + * under formName as plain values. */ export function useOpenUIState( - { response, library, isStreaming, onAction, onStateUpdate, initialState }: UseOpenUIStateOptions, + { + response, + library, + isStreaming, + onAction, + onStateUpdate, + initialState, + transport, + }: UseOpenUIStateOptions, renderDeep: (value: unknown) => React.ReactNode, ): OpenUIState { - // ─── Parser (synchronous, created once from the library's JSON schema) ─── - const parser = useMemo(() => createParser(library.toJSONSchema()), []); // intentionally empty deps — parser is created once + // ─── Streaming parser (incremental — caches completed statements) ─── + const sp = useMemo(() => createStreamingParser(library.toJSONSchema(), library.root), [library]); // ─── Parse result ─── const result = useMemo(() => { if (!response) return null; try { - return parser.parse(response); + return sp.set(response); } catch (e) { console.error("[openui] Parse error:", e); return null; } - }, [parser, response]); + }, [sp, response]); + + // ─── Store (holds everything: $bindings + form fields) ─── + const store = useMemo(() => createStore(), []); + + // ─── QueryManager ─── + const queryManager = useMemo( + () => createQueryManager(transport ?? null), + [transport], + ); - // Log the final parsed tree once streaming ends (dev debugging). - const prevIsStreaming = useRef(isStreaming); useEffect(() => { - prevIsStreaming.current = isStreaming; - }, [isStreaming, result]); + queryManager.activate(); + return () => queryManager.dispose(); + }, [queryManager]); - // ─── Form state ─── - const [formState, setFormState] = useState>(() => initialState ?? {}); + // ─── Initialize Store ─── + const storeInitKeyRef = useRef(Symbol()); + useEffect(() => { + if (!result?.stateDeclarations && !initialState) return; + const key = `${JSON.stringify(result?.stateDeclarations)}::${JSON.stringify(initialState)}`; + if (storeInitKeyRef.current === key) return; + storeInitKeyRef.current = key; - // Sync if initialState changes (e.g. loading a different message) - const prevInitialFormState = useRef(initialState); + // Split initialState: $-prefixed keys are bindings, everything else is form state + const bindingDefaults: Record = {}; + if (initialState) { + for (const [key, value] of Object.entries(initialState)) { + if (key.startsWith("$")) { + bindingDefaults[key] = value; + } else { + // Form state — restore as-is (preserves { value, componentType } wrapper) + store.set(key, value); + } + } + } + store.initialize(result?.stateDeclarations ?? {}, bindingDefaults); + }, [result?.stateDeclarations, store, initialState]); + + // ─── Subscribe to Store and QueryManager for re-renders ─── + const storeSnapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + const querySnapshot = useSyncExternalStore( + queryManager.subscribe, + queryManager.getSnapshot, + queryManager.getSnapshot, + ) as QuerySnapshot; + + // ─── Build EvaluationContext ─── + const evaluationContext = useMemo( + () => ({ + getState: (name: string) => unwrapFieldValue(store.get(name)), + resolveRef: (name: string) => { + const mutResult = queryManager.getMutationResult(name); + if (mutResult) return mutResult; + return queryManager.getResult(name); + }, + }), + [store, queryManager], + ); + + // ─── Evaluate and submit queries ─── useEffect(() => { - if (prevInitialFormState.current !== initialState) { - prevInitialFormState.current = initialState; - setFormState(initialState ?? {}); + if (isStreaming) return; + if (!result?.queryStatements?.length) return; + + const evaluatedNodes = result.queryStatements.map((qn) => { + const relevantDeps: Record = {}; + if (qn.deps) { + for (const ref of qn.deps) { + relevantDeps[ref] = storeSnapshot[ref]; + } + } + return { + statementId: qn.statementId, + toolName: qn.toolAST ? (evaluate(qn.toolAST, evaluationContext) as string) : "", + args: qn.argsAST ? evaluate(qn.argsAST, evaluationContext) : null, + defaults: qn.defaultsAST ? evaluate(qn.defaultsAST, evaluationContext) : null, + refreshInterval: qn.refreshAST + ? (evaluate(qn.refreshAST, evaluationContext) as number) + : undefined, + deps: Object.keys(relevantDeps).length > 0 ? relevantDeps : undefined, + complete: qn.complete, + }; + }); + + queryManager.evaluateQueries(evaluatedNodes); + }, [isStreaming, result?.queryStatements, evaluationContext, queryManager, storeSnapshot]); + + // ─── Register mutations ─── + useEffect(() => { + if (isStreaming) return; + if (!result?.mutationStatements?.length) { + return; } - }, [initialState]); + const nodes = result.mutationStatements.map((mn) => ({ + statementId: mn.statementId, + toolName: mn.toolAST ? (evaluate(mn.toolAST, evaluationContext) as string) : "", + })); + queryManager.registerMutations(nodes); + }, [isStreaming, result?.mutationStatements, evaluationContext, queryManager]); // ─── Ref for stable callbacks ─── - const propsRef = useRef({ - formState, - onAction, - onStateUpdate, - }); - propsRef.current = { - formState, - onAction, - onStateUpdate, - }; + const propsRef = useRef({ onAction, onStateUpdate }); + propsRef.current = { onAction, onStateUpdate }; + + const resultRef = useRef(result); + resultRef.current = result; + + // ─── Fire onStateUpdate when Store changes ─── + const lastInitSnapshotRef = useRef | null>(null); + useEffect(() => { + lastInitSnapshotRef.current = store.getSnapshot(); + const unsub = store.subscribe(() => { + const currentSnapshot = store.getSnapshot(); + if (currentSnapshot === lastInitSnapshotRef.current) return; + lastInitSnapshotRef.current = null; + propsRef.current.onStateUpdate?.(currentSnapshot); + }); + return unsub; + }, [store]); // ─── getFieldValue ─── - const getFieldValue = useCallback((formName: string | undefined, name: string) => { - const state = propsRef.current.formState; - return formName ? state[formName]?.[name]?.value : state[name]?.value; - }, []); + const getFieldValue = useCallback( + (formName: string | undefined, name: string) => { + if (!formName) return unwrapFieldValue(store.get(name)); + const formData = store.get(formName); + if (!formData || typeof formData !== "object" || Array.isArray(formData)) return undefined; + return unwrapFieldValue((formData as Record)[name]); + }, + [store], + ); // ─── setFieldValue ─── const setFieldValue = useCallback( @@ -91,61 +224,133 @@ export function useOpenUIState( formName: string | undefined, componentType: string | undefined, name: string, - value: any, + value: unknown, shouldTriggerSaveCallback: boolean = true, ) => { - const { formState: currentState } = propsRef.current; - const newState = { ...currentState }; - - if (formName) { - newState[formName] = { - ...newState[formName], - [name]: { value, componentType }, - }; + const wrapped = { value, componentType }; + if (!formName) { + store.set(name, wrapped); } else { - newState[name] = { value, componentType }; + const raw = store.get(formName); + const formData = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + store.set(formName, { ...formData, [name]: wrapped }); } - - setFormState(newState); - propsRef.current.formState = newState; - if (shouldTriggerSaveCallback) { - propsRef.current.onStateUpdate?.(newState); + propsRef.current.onStateUpdate?.(store.getSnapshot()); + } + }, + [store], + ); + + // ─── Materialize form payload ─── + const getFormPayload = useCallback( + (formName?: string): Record | undefined => { + if (formName) { + const raw = store.get(formName); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return { [formName]: raw }; + } } + return store.getSnapshot(); }, - [], + [store], ); // ─── triggerAction ─── const triggerAction = useCallback( - ( + async ( userMessage: string, formName?: string, - action?: { type?: string; params?: Record }, + action?: ActionPlan | { type?: string; params?: Record }, ) => { - const { formState: currentState, onAction: handler } = propsRef.current; - const actionType = action?.type || BuiltinActionType.ContinueConversation; - const actionParams = action?.params; - - // Collect relevant form state - let relevantState: Record | undefined; - if (formName && currentState[formName]) { - relevantState = { [formName]: currentState[formName] }; - } else if (Object.keys(currentState).length > 0) { - relevantState = currentState; + const formPayload = getFormPayload(formName); + const { onAction: handler } = propsRef.current; + + // Legacy action config path (v0.4 compat) — { type?, params? } + if (action && !("steps" in action)) { + const actionType = action.type || BuiltinActionType.ContinueConversation; + handler?.({ + type: actionType, + params: action.params || {}, + humanFriendlyMessage: userMessage, + formState: formPayload, + formName, + }); + return; } - if (!handler) return; + // ActionPlan path (v0.5) — sequential steps with halt-on-mutation-failure + const actionPlan = action as ActionPlan | undefined; + if (actionPlan?.steps) { + for (const step of actionPlan.steps) { + switch (step.type) { + case ACTION_STEPS.Run: { + if (step.refType === "mutation") { + const mn = resultRef.current?.mutationStatements?.find( + (m) => m.statementId === step.statementId, + ); + const evaluatedArgs = mn?.argsAST + ? (evaluate(mn.argsAST, evaluationContext) as Record) + : {}; + const ok = await queryManager.fireMutation(step.statementId, evaluatedArgs); + if (!ok) return; // halt on failure + } else { + queryManager.invalidate([step.statementId]); + } + break; + } + case ACTION_STEPS.ToAssistant: + handler?.({ + type: BuiltinActionType.ContinueConversation, + params: step.context ? { context: step.context } : {}, + humanFriendlyMessage: step.message, + formState: formPayload, + formName, + }); + break; + case ACTION_STEPS.OpenUrl: + handler?.({ + type: BuiltinActionType.OpenUrl, + params: { url: step.url }, + humanFriendlyMessage: "", + formState: formPayload, + formName, + }); + break; + case ACTION_STEPS.Set: { + if (!step.valueAST) { + console.warn(`[openui] Set action for ${step.target} has no valueAST — skipping`); + break; + } + const value = evaluate(step.valueAST, evaluationContext); + store.set(step.target, value); + break; + } + case ACTION_STEPS.Reset: { + const decls = resultRef.current?.stateDeclarations ?? {}; + for (const target of step.targets) { + store.set(target, decls[target] ?? null); + } + break; + } + } + } + return; + } - handler({ - type: actionType, - params: actionParams || {}, + // Default — ContinueConversation with label + handler?.({ + type: BuiltinActionType.ContinueConversation, + params: {}, humanFriendlyMessage: userMessage, - formState: relevantState, + formState: formPayload, formName, }); }, - [], + [queryManager, evaluationContext, getFormPayload], ); // ─── Context value ─── @@ -157,9 +362,43 @@ export function useOpenUIState( isStreaming, getFieldValue, setFieldValue, + store, + evaluationContext, + }), + [ + library, + renderDeep, + isStreaming, + triggerAction, + getFieldValue, + setFieldValue, + store, + evaluationContext, + ], + ); + + // ─── Evaluate props ─── + const evalContext = useMemo( + () => ({ + ctx: evaluationContext, + library, + store, }), - [library, renderDeep, isStreaming, triggerAction, getFieldValue, setFieldValue, formState], + [evaluationContext, library, store], ); - return { result, contextValue }; + const evaluatedResult = useMemo(() => { + if (!result?.root) return result; + try { + const evaluatedRoot = evaluateElementProps(result.root, evalContext); + return { ...result, root: evaluatedRoot }; + } catch (e) { + console.error("[openui] Prop evaluation error:", e); + return result; + } + }, [result, evalContext, storeSnapshot, querySnapshot]); + + const isQueryLoading = querySnapshot.__openui_loading.length > 0; + + return { result: evaluatedResult, parseResult: result, contextValue, isQueryLoading }; } diff --git a/packages/react-lang/src/hooks/useStateField.ts b/packages/react-lang/src/hooks/useStateField.ts new file mode 100644 index 000000000..ac0d4d2da --- /dev/null +++ b/packages/react-lang/src/hooks/useStateField.ts @@ -0,0 +1,23 @@ +import { + resolveStateField, + type InferStateFieldValue, + type StateField, +} from "@openuidev/lang-core"; +import { useFormName, useOpenUI } from "../context"; + +export function useStateField( + name: string, + value?: T, +): StateField> { + const ctx = useOpenUI(); + const formName = useFormName(); + + return resolveStateField>( + name, + value, + ctx.store ?? null, + ctx.evaluationContext ?? null, + (fieldName) => ctx.getFieldValue(formName, fieldName), + (fieldName, nextValue) => ctx.setFieldValue(formName, undefined, fieldName, nextValue), + ); +} diff --git a/packages/react-lang/src/index.ts b/packages/react-lang/src/index.ts index 9ef8bdce5..565ba1653 100644 --- a/packages/react-lang/src/index.ts +++ b/packages/react-lang/src/index.ts @@ -9,6 +9,7 @@ export type { LibraryDefinition, PromptOptions, SubComponentOf, + ToolDescriptor, } from "./library"; // openui-lang renderer @@ -16,11 +17,24 @@ export { Renderer } from "./Renderer"; export type { RendererProps } from "./Renderer"; // openui-lang action types -export { BuiltinActionType } from "@openuidev/lang-core"; -export type { ActionEvent, ElementNode, ParseResult } from "@openuidev/lang-core"; +export { ACTION_STEPS, BuiltinActionType } from "@openuidev/lang-core"; +export type { + ActionEvent, + ActionPlan, + ActionStep, + ElementNode, + ParseResult, +} from "@openuidev/lang-core"; // openui-lang parser (server-side use) -export { createParser, createStreamingParser, type LibraryJSONSchema } from "@openuidev/lang-core"; +export { createParser, createStreamingParser } from "@openuidev/lang-core"; + +// Standalone prompt generation (no Zod deps — usable on backend) +export { generatePrompt } from "@openuidev/lang-core"; +export type { ComponentPromptSpec, McpToolSpec, PromptSpec } from "@openuidev/lang-core"; + +// openui-lang edit/merge +export { mergeStatements } from "@openuidev/lang-core"; // openui-lang context hooks (for use inside component renderers) export { @@ -34,6 +48,23 @@ export { useTriggerAction, } from "./context"; +// Runtime — reactive bindings, store, evaluator, query manager, field binding +export { createMcpTransport, isReactiveAssign } from "@openuidev/lang-core"; +export type { + EvaluationContext, + McpClientLike, + McpConnection, + McpTool, + McpTransportConfig, + ReactiveAssign, + StateField, + Transport, +} from "@openuidev/lang-core"; +export { reactive } from "./runtime"; + +// Unified field state hook — component authors use this +export { useStateField } from "./hooks/useStateField"; + // openui-lang form validation export { FormValidationContext, diff --git a/packages/react-lang/src/library.ts b/packages/react-lang/src/library.ts index e0cdc8109..2c64bc9b0 100644 --- a/packages/react-lang/src/library.ts +++ b/packages/react-lang/src/library.ts @@ -10,7 +10,13 @@ import type { ReactNode } from "react"; import { z } from "zod"; // Re-export framework-agnostic types unchanged -export type { ComponentGroup, PromptOptions, SubComponentOf } from "@openuidev/lang-core"; +export type { + ComponentGroup, + LibraryJSONSchema, + PromptOptions, + SubComponentOf, + ToolDescriptor, +} from "@openuidev/lang-core"; // ─── React-specific types ─────────────────────────────────────────────────── @@ -30,29 +36,6 @@ export type LibraryDefinition = CoreLibraryDefinition>; // ─── defineComponent (React) ──────────────────────────────────────────────── -/** - * Define a component with name, schema, description, and renderer. - * Registers the Zod schema globally and returns a `.ref` for parent schemas. - * - * @example - * ```ts - * const TabItem = defineComponent({ - * name: "TabItem", - * props: z.object({ value: z.string(), trigger: z.string(), content: z.array(ContentChildUnion) }), - * description: "Tab panel", - * component: () => null, - * }); - * - * const Tabs = defineComponent({ - * name: "Tabs", - * props: z.object({ items: z.array(TabItem.ref) }), - * description: "Tabbed container", - * component: ({ props, renderNode }) => { - * props.items.map(item => renderNode(item.props.content)); - * }, - * }); - * ``` - */ export function defineComponent>(config: { name: string; props: T; @@ -64,17 +47,6 @@ export function defineComponent>(config: { // ─── createLibrary (React) ────────────────────────────────────────────────── -/** - * Create a component library from an array of defined components. - * - * @example - * ```ts - * const library = createLibrary({ - * components: [TabItem, Tabs, Card], - * root: "Card", - * }); - * ``` - */ export function createLibrary(input: LibraryDefinition): Library { return coreCreateLibrary>(input) as Library; } diff --git a/packages/react-lang/src/runtime/index.ts b/packages/react-lang/src/runtime/index.ts new file mode 100644 index 000000000..b0f2bf762 --- /dev/null +++ b/packages/react-lang/src/runtime/index.ts @@ -0,0 +1,2 @@ +// React-specific reactive() wrapper — marks schemas for $binding support +export { isReactiveSchema, reactive } from "./reactive"; diff --git a/packages/react-lang/src/runtime/reactive.ts b/packages/react-lang/src/runtime/reactive.ts new file mode 100644 index 000000000..1a149fc3f --- /dev/null +++ b/packages/react-lang/src/runtime/reactive.ts @@ -0,0 +1,21 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Reactive schema marker for openui-lang (React adapter) +// ───────────────────────────────────────────────────────────────────────────── + +import type { StateField } from "@openuidev/lang-core"; +import { markReactive } from "@openuidev/lang-core"; +import type { z } from "zod"; + +// Re-export for internal use +export { isReactiveSchema } from "@openuidev/lang-core"; + +/** + * Mark a schema prop as reactive so runtime evaluation can preserve $bindings. + * + * The widened return type carries the eventual value shape into helpers like + * `useStateField()`. The actual bound value is still resolved at render time. + */ +export function reactive(schema: T): z.ZodType>> { + markReactive(schema); + return schema as unknown as z.ZodType>>; +} diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index aae3ec22d..36bed9f03 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -66,8 +66,8 @@ "ci": "pnpm run lint:check && pnpm run format:check" }, "peerDependencies": { - "@openuidev/react-lang": "workspace:^", "@openuidev/react-headless": "workspace:^", + "@openuidev/react-lang": "workspace:^", "react": ">=19.0.0", "react-dom": ">=19.0.0", "zustand": "^4.5.5" diff --git a/packages/react-ui/src/components/Callout/Callout.tsx b/packages/react-ui/src/components/Callout/Callout.tsx index 305e6905f..a6112e6db 100644 --- a/packages/react-ui/src/components/Callout/Callout.tsx +++ b/packages/react-ui/src/components/Callout/Callout.tsx @@ -7,6 +7,8 @@ export interface CalloutProps extends Omit, variant?: CalloutVariant; title?: React.ReactNode; description?: React.ReactNode; + /** Auto-dismiss after N milliseconds. CSS-only fade + collapse. */ + duration?: number; } const variantMap: Record = { @@ -18,10 +20,24 @@ const variantMap: Record = { }; export const Callout = React.forwardRef((props, ref) => { - const { className, variant = "neutral", title, description, ...rest } = props; + const { className, variant = "neutral", title, description, duration, style, ...rest } = props; + + const dismissStyle = duration + ? ({ ...style, "--callout-duration": `${duration}ms` } as React.CSSProperties) + : style; return ( -
+
{title && {title}} {description && {description}}
diff --git a/packages/react-ui/src/components/Callout/callout.scss b/packages/react-ui/src/components/Callout/callout.scss index 90fe6ba0f..3431be8bc 100644 --- a/packages/react-ui/src/components/Callout/callout.scss +++ b/packages/react-ui/src/components/Callout/callout.scss @@ -51,4 +51,31 @@ @include cssUtils.typography(body, default); color: cssUtils.$text-neutral-primary; } + + &-autodismiss { + animation: openui-callout-fadeout 0.4s ease-in forwards; + animation-delay: var(--callout-duration, 3000ms); + } +} + +@keyframes openui-callout-fadeout { + 0% { + opacity: 1; + max-height: 200px; + padding-top: cssUtils.$space-000; + padding-bottom: cssUtils.$space-000; + margin-bottom: 0; + } + 70% { + opacity: 0; + max-height: 200px; + } + 100% { + opacity: 0; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + margin-bottom: 0; + overflow: hidden; + } } diff --git a/packages/react-ui/src/components/Modal/Modal.scss b/packages/react-ui/src/components/Modal/Modal.scss new file mode 100644 index 000000000..f6a857b98 --- /dev/null +++ b/packages/react-ui/src/components/Modal/Modal.scss @@ -0,0 +1,117 @@ +@use "../../cssUtils" as cssUtils; + +.openui-modal-root { + position: relative; + z-index: 9999; +} + +.openui-modal-overlay { + position: fixed; + inset: 0; + background: cssUtils.$overlay; + z-index: 9999; + animation: openui-modal-fade-in 0.15s ease-out; +} + +.openui-modal-content { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10000; + background: cssUtils.$foreground; + border: 1px solid cssUtils.$border-default; + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + max-height: 85vh; + animation: openui-modal-slide-in 0.15s ease-out; + + &:focus { + outline: none; + } +} + +.openui-modal-sm { + width: 400px; + max-width: 90vw; +} +.openui-modal-md { + width: 560px; + max-width: 90vw; +} +.openui-modal-lg { + width: 720px; + max-width: 90vw; +} + +.openui-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: cssUtils.$space-m; + border-bottom: 1px solid cssUtils.$border-default; +} + +.openui-modal-title { + @include cssUtils.typography(body, heavy); + margin: 0; +} + +.openui-modal-close { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + color: cssUtils.$text-neutral-secondary; + + &:hover { + color: cssUtils.$text-neutral-primary; + } +} + +.openui-modal-body { + padding: cssUtils.$space-m; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: cssUtils.$space-m; +} + +.openui-modal-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@keyframes openui-modal-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes openui-modal-slide-in { + from { + opacity: 0; + transform: translate(-50%, -48%); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} diff --git a/packages/react-ui/src/components/Modal/Modal.tsx b/packages/react-ui/src/components/Modal/Modal.tsx new file mode 100644 index 000000000..2f8df2916 --- /dev/null +++ b/packages/react-ui/src/components/Modal/Modal.tsx @@ -0,0 +1,76 @@ +"use client"; + +import clsx from "clsx"; +import { X } from "lucide-react"; +import React, { useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { useTheme } from "../ThemeProvider"; + +export interface ModalProps { + title: string; + open: boolean; + onOpenChange: (open: boolean) => void; + size?: "sm" | "md" | "lg"; + children?: React.ReactNode; +} + +const sizeClass: Record = { + sm: "openui-modal-sm", + md: "openui-modal-md", + lg: "openui-modal-lg", +}; + +export const Modal: React.FC = ({ + title, + open, + onOpenChange, + size = "md", + children, +}) => { + const { portalThemeClassName } = useTheme(); + const contentRef = useRef(null); + + const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]); + + // Escape key + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") handleClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, handleClose]); + + // Focus trap — focus the content on open + useEffect(() => { + if (open && contentRef.current) { + contentRef.current.focus(); + } + }, [open]); + + if (!open) return null; + + return createPortal( +
+
+
+
+

{title}

+ +
+
{children}
+
+
, + document.body, + ); +}; diff --git a/packages/react-ui/src/components/Modal/index.ts b/packages/react-ui/src/components/Modal/index.ts new file mode 100644 index 000000000..1ae2df910 --- /dev/null +++ b/packages/react-ui/src/components/Modal/index.ts @@ -0,0 +1,2 @@ +export { Modal } from "./Modal"; +export type { ModalProps } from "./Modal"; diff --git a/packages/react-ui/src/components/Select/select.scss b/packages/react-ui/src/components/Select/select.scss index ebc2979d0..a596ecb65 100644 --- a/packages/react-ui/src/components/Select/select.scss +++ b/packages/react-ui/src/components/Select/select.scss @@ -66,7 +66,7 @@ .openui-select-content { box-sizing: border-box; position: relative; - z-index: 50; + z-index: 99999; max-height: 372px; min-width: var(--radix-select-trigger-width); overflow: hidden; diff --git a/packages/react-ui/src/components/index.scss b/packages/react-ui/src/components/index.scss index e2d3d5808..661dc1456 100644 --- a/packages/react-ui/src/components/index.scss +++ b/packages/react-ui/src/components/index.scss @@ -56,3 +56,4 @@ @forward "./ToolCall/toolCall.scss"; @forward "./ToolResult/toolResult.scss"; @forward "./_shared/shared.scss"; +@forward "./Modal/Modal.scss"; diff --git a/packages/react-ui/src/genui-lib/Action/schema.ts b/packages/react-ui/src/genui-lib/Action/schema.ts index 84b2d6b74..038b85ec5 100644 --- a/packages/react-ui/src/genui-lib/Action/schema.ts +++ b/packages/react-ui/src/genui-lib/Action/schema.ts @@ -1,24 +1,5 @@ -import { BuiltinActionType } from "@openuidev/react-lang"; import { z } from "zod"; -const continueConversationAction = z.object({ - type: z.literal(BuiltinActionType.ContinueConversation), - /** Extra context string passed to the LLM — useful for carousel/list item data. */ - context: z.string().optional(), -}); - -const openUrlAction = z.object({ - type: z.literal(BuiltinActionType.OpenUrl), - url: z.string(), -}); - -const customAction = z.object({ - type: z.string(), - params: z.record(z.string(), z.any()).optional(), -}); - -export const actionSchema = z - .union([openUrlAction, continueConversationAction, customAction]) - .optional(); - -export type ActionSchema = z.infer; +/** Shared action prop schema — shows as `ActionExpression` in prompt signatures. */ +export const actionPropSchema = z.any(); +actionPropSchema.register(z.globalRegistry, { id: "ActionExpression" }); diff --git a/packages/react-ui/src/genui-lib/Button/index.tsx b/packages/react-ui/src/genui-lib/Button/index.tsx index f3090d4b7..6f2023a63 100644 --- a/packages/react-ui/src/genui-lib/Button/index.tsx +++ b/packages/react-ui/src/genui-lib/Button/index.tsx @@ -1,7 +1,8 @@ "use client"; +import type { ActionPlan } from "@openuidev/react-lang"; import { - BuiltinActionType, + ACTION_STEPS, defineComponent, useFormName, useFormValidation, @@ -38,33 +39,22 @@ export const Button = defineComponent({ buttonType={props.type as "normal" | "destructive"} disabled={isStreaming} onClick={() => { - const action = props.action as - | { type?: string; url?: string; context?: string; params?: Record } - | undefined; - const actionType = action?.type ?? BuiltinActionType.ContinueConversation; + const action = props.action as ActionPlan | undefined; - // Only validate for primary buttons with continue_conversation action (form submit). - // Secondary/tertiary buttons (e.g. "Ask to customize") skip validation. - const variant = (props.variant as string) || "primary"; - if ( - formValidation && - variant === "primary" && - actionType === BuiltinActionType.ContinueConversation - ) { - const valid = formValidation.validateForm(); - if (!valid) return; + // Validate form for primary buttons with mutation/ToAssistant steps + if (action?.steps && formValidation) { + const variant = (props.variant as string) || "primary"; + if (variant === "primary") { + const needsValidation = action.steps.some( + (s) => + s.type === ACTION_STEPS.ToAssistant || + (s.type === ACTION_STEPS.Run && s.refType === "mutation"), + ); + if (needsValidation && !formValidation.validateForm()) return; + } } - const actionParams = - actionType === BuiltinActionType.OpenUrl - ? { url: action?.url } - : { - ...(action?.params ?? {}), - ...(action?.context ? { context: action.context } : {}), - }; - triggerAction(label, formName, { - type: actionType, - params: actionParams, - }); + + triggerAction(label, formName, action); }} > {label} diff --git a/packages/react-ui/src/genui-lib/Button/schema.ts b/packages/react-ui/src/genui-lib/Button/schema.ts index 1d012a5f1..aeb67c970 100644 --- a/packages/react-ui/src/genui-lib/Button/schema.ts +++ b/packages/react-ui/src/genui-lib/Button/schema.ts @@ -1,11 +1,9 @@ import { z } from "zod"; -import { actionSchema } from "../Action/schema"; - -export { actionSchema, type ActionSchema } from "../Action/schema"; +import { actionPropSchema } from "../Action/schema"; export const ButtonSchema = z.object({ label: z.string(), - action: actionSchema, + action: actionPropSchema.optional(), variant: z.enum(["primary", "secondary", "tertiary"]).optional(), type: z.enum(["normal", "destructive"]).optional(), size: z.enum(["extra-small", "small", "medium", "large"]).optional(), diff --git a/packages/react-ui/src/genui-lib/Callout/index.tsx b/packages/react-ui/src/genui-lib/Callout/index.tsx index c736c9e40..8f338af02 100644 --- a/packages/react-ui/src/genui-lib/Callout/index.tsx +++ b/packages/react-ui/src/genui-lib/Callout/index.tsx @@ -1,6 +1,7 @@ "use client"; -import { defineComponent } from "@openuidev/react-lang"; +import { defineComponent, useStateField } from "@openuidev/react-lang"; +import React from "react"; import { Callout as OpenUICallout } from "../../components/Callout"; import { MarkDownRenderer } from "../../components/MarkDownRenderer"; import { CalloutSchema } from "./schema"; @@ -10,8 +11,27 @@ export { CalloutSchema } from "./schema"; export const Callout = defineComponent({ name: "Callout", props: CalloutSchema, - description: "Callout banner with variant, title, and description", + description: + "Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false.", component: ({ props }) => { + const field = useStateField("visible", props.visible); + + const hasVisibleBinding = field.isReactive; + const isVisible = hasVisibleBinding + ? field.value === true || (field.value as any) === "true" + : true; + + // Auto-dismiss: set $visible = false after 3s + React.useEffect(() => { + if (!hasVisibleBinding || !isVisible) return; + const timer = setTimeout(() => { + field.setValue(false); + }, 3000); + return () => clearTimeout(timer); + }, [hasVisibleBinding, isVisible, field]); + + if (!isVisible) return null; + const variantMap: Record = { info: "info", warning: "warning", @@ -24,6 +44,7 @@ export const Callout = defineComponent({ variant={variantMap[props.variant as string] || "info"} title={props.title as string} description={} + duration={hasVisibleBinding ? 3000 : undefined} /> ); }, diff --git a/packages/react-ui/src/genui-lib/Callout/schema.ts b/packages/react-ui/src/genui-lib/Callout/schema.ts index a21791c53..eb85448b8 100644 --- a/packages/react-ui/src/genui-lib/Callout/schema.ts +++ b/packages/react-ui/src/genui-lib/Callout/schema.ts @@ -1,7 +1,9 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; export const CalloutSchema = z.object({ variant: z.enum(["info", "warning", "error", "success", "neutral"]), title: z.string(), description: z.string(), + visible: reactive(z.boolean().optional()), }); diff --git a/packages/react-ui/src/genui-lib/Charts/PieChart.ts b/packages/react-ui/src/genui-lib/Charts/PieChart.ts index 71feb636d..a98c9da2f 100644 --- a/packages/react-ui/src/genui-lib/Charts/PieChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/PieChart.ts @@ -4,28 +4,50 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod"; import { PieChart as PieChartComponent } from "../../components/Charts"; -import { buildSliceData, hasAllProps } from "../helpers"; -import { SliceSchema } from "./Slice"; +import { asArray, buildSliceData } from "../helpers"; export const PieChartSchema = z.object({ - slices: z.array(SliceSchema), + labels: z.array(z.string()), + values: z.array(z.number()), variant: z.enum(["pie", "donut"]).optional(), }); export const PieChart = defineComponent({ name: "PieChart", props: PieChartSchema, - description: "Circular slices showing part-to-whole proportions; supports pie and donut variants", + description: "Circular slices; use plucked arrays: PieChart(data.categories, data.values)", component: ({ props }) => { - if (!hasAllProps(props as Record, "slices")) return null; - const data = buildSliceData(props.slices); - if (!data.length) return null; - return React.createElement(PieChartComponent, { - data, - categoryKey: "category", - dataKey: "value", - variant: props.variant as "pie" | "donut" | undefined, - isAnimationActive: false, - }); + const labels = asArray(props.labels) as string[]; + const values = asArray(props.values) as number[]; + + // New format: labels[] + values[] + if (labels.length > 0 && values.length > 0) { + const data = labels.map((cat, i) => ({ + category: cat, + value: typeof values[i] === "number" ? values[i] : 0, + })); + if (!data.length) return null; + return React.createElement(PieChartComponent, { + data, + categoryKey: "category", + dataKey: "value", + variant: props.variant as "pie" | "donut" | undefined, + isAnimationActive: false, + }); + } + + // Legacy fallback: Slice[] objects (backwards compat) + const sliceData = buildSliceData(props.labels); + if (sliceData.length) { + return React.createElement(PieChartComponent, { + data: sliceData, + categoryKey: "category", + dataKey: "value", + variant: props.variant as "pie" | "donut" | undefined, + isAnimationActive: false, + }); + } + + return null; }, }); diff --git a/packages/react-ui/src/genui-lib/Charts/RadialChart.ts b/packages/react-ui/src/genui-lib/Charts/RadialChart.ts index 31f0da5fb..d76801f0a 100644 --- a/packages/react-ui/src/genui-lib/Charts/RadialChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/RadialChart.ts @@ -4,26 +4,45 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod"; import { RadialChart as RadialChartComponent } from "../../components/Charts"; -import { buildSliceData, hasAllProps } from "../helpers"; -import { SliceSchema } from "./Slice"; +import { asArray, buildSliceData } from "../helpers"; export const RadialChartSchema = z.object({ - slices: z.array(SliceSchema), + labels: z.array(z.string()), + values: z.array(z.number()), }); export const RadialChart = defineComponent({ name: "RadialChart", props: RadialChartSchema, - description: "Radial bars showing proportional distribution across named segments", + description: "Radial bars; use plucked arrays: RadialChart(data.categories, data.values)", component: ({ props }) => { - if (!hasAllProps(props as Record, "slices")) return null; - const data = buildSliceData((props as any).slices); - if (!data.length) return null; - return React.createElement(RadialChartComponent, { - data, - categoryKey: "category", - dataKey: "value", - isAnimationActive: false, - }); + const labels = asArray(props.labels) as string[]; + const values = asArray(props.values) as number[]; + + if (labels.length > 0 && values.length > 0) { + const data = labels.map((cat, i) => ({ + category: cat, + value: typeof values[i] === "number" ? values[i] : 0, + })); + if (!data.length) return null; + return React.createElement(RadialChartComponent, { + data, + categoryKey: "category", + dataKey: "value", + isAnimationActive: false, + }); + } + + const sliceData = buildSliceData(props.labels); + if (sliceData.length) { + return React.createElement(RadialChartComponent, { + data: sliceData, + categoryKey: "category", + dataKey: "value", + isAnimationActive: false, + }); + } + + return null; }, }); diff --git a/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts b/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts index a1a049b91..3712efdfc 100644 --- a/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts +++ b/packages/react-ui/src/genui-lib/Charts/SingleStackedBarChart.ts @@ -4,26 +4,44 @@ import { defineComponent } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod"; import { SingleStackedBar as SingleStackedBarChartComponent } from "../../components/Charts"; -import { buildSliceData, hasAllProps } from "../helpers"; -import { SliceSchema } from "./Slice"; +import { asArray, buildSliceData } from "../helpers"; export const SingleStackedBarChartSchema = z.object({ - slices: z.array(SliceSchema), + labels: z.array(z.string()), + values: z.array(z.number()), }); export const SingleStackedBarChart = defineComponent({ name: "SingleStackedBarChart", props: SingleStackedBarChartSchema, description: - "Single horizontal stacked bar; use for showing part-to-whole proportions in one row", + "Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values)", component: ({ props }) => { - if (!hasAllProps(props as Record, "slices")) return null; - const data = buildSliceData((props as any).slices); - if (!data.length) return null; - return React.createElement(SingleStackedBarChartComponent, { - data, - categoryKey: "category", - dataKey: "value", - }); + const labels = asArray(props.labels) as string[]; + const values = asArray(props.values) as number[]; + + if (labels.length > 0 && values.length > 0) { + const data = labels.map((cat, i) => ({ + category: cat, + value: typeof values[i] === "number" ? values[i] : 0, + })); + if (!data.length) return null; + return React.createElement(SingleStackedBarChartComponent, { + data, + categoryKey: "category", + dataKey: "value", + }); + } + + const sliceData = buildSliceData(props.labels); + if (sliceData.length) { + return React.createElement(SingleStackedBarChartComponent, { + data: sliceData, + categoryKey: "category", + dataKey: "value", + }); + } + + return null; }, }); diff --git a/packages/react-ui/src/genui-lib/CheckBoxGroup/index.tsx b/packages/react-ui/src/genui-lib/CheckBoxGroup/index.tsx index 61d6af5af..7e32f427c 100644 --- a/packages/react-ui/src/genui-lib/CheckBoxGroup/index.tsx +++ b/packages/react-ui/src/genui-lib/CheckBoxGroup/index.tsx @@ -3,19 +3,15 @@ import { defineComponent, parseStructuredRules, - useFormName, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, type SubComponentOf, } from "@openuidev/react-lang"; import React from "react"; -import { z } from "zod"; import { CheckBoxGroup as OpenUICheckBoxGroup } from "../../components/CheckBoxGroup"; import { CheckBoxItem as OpenUICheckBoxItem } from "../../components/CheckBoxItem"; -import { rulesSchema } from "../rules"; -import { CheckBoxItemSchema } from "./schema"; +import { CheckBoxItemSchema, createCheckBoxGroupSchema } from "./schema"; export { CheckBoxItemSchema } from "./schema"; @@ -35,49 +31,34 @@ export const CheckBoxItem = defineComponent({ export const CheckBoxGroup = defineComponent({ name: "CheckBoxGroup", - props: z.object({ - name: z.string(), - items: z.array(CheckBoxItem.ref), - rules: rulesSchema, - }), + props: createCheckBoxGroupSchema(CheckBoxItem), description: "", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); - const fieldName = props.name as string; + const field = useStateField(props.name, props.value); const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); + const hasRules = rules.length > 0; const items = (props.items ?? []) as Array>; + // Aggregate: map of item name → checked boolean const getAggregate = React.useCallback((): Record => { - const stored = getFieldValue(formName, fieldName) as Record | undefined; + const stored = field.value; const result: Record = {}; for (const item of items) { result[item.props.name] = stored?.[item.props.name] ?? item.props.defaultChecked ?? false; } return result; - }, [formName, fieldName, items, getFieldValue]); + }, [field.value, items]); React.useEffect(() => { - if (!isStreaming && items.length > 0 && getFieldValue(formName, fieldName) == null) { - const initial: Record = {}; - for (const item of items) { - initial[item.props.name] = item.props.defaultChecked ?? false; - } - setFieldValue(formName, "CheckBoxGroup", fieldName, initial, false); - } - }, [isStreaming]); - - React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(fieldName, rules, () => getFieldValue(formName, fieldName)); - return () => formValidation.unregisterField(fieldName); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); if (!items.length) return null; @@ -94,9 +75,9 @@ export const CheckBoxGroup = defineComponent({ checked={aggregate[item.props.name] ?? item.props.defaultChecked ?? false} onChange={(val: boolean) => { const newAggregate = { ...getAggregate(), [item.props.name]: val }; - setFieldValue(formName, "CheckBoxGroup", fieldName, newAggregate, true); - if (rules.length > 0) { - formValidation?.validateField(fieldName, newAggregate, rules); + field.setValue(newAggregate); + if (hasRules) { + formValidation?.validateField(field.name, newAggregate, rules); } }} disabled={isStreaming} diff --git a/packages/react-ui/src/genui-lib/CheckBoxGroup/schema.ts b/packages/react-ui/src/genui-lib/CheckBoxGroup/schema.ts index f02dc15af..7ecdb21f9 100644 --- a/packages/react-ui/src/genui-lib/CheckBoxGroup/schema.ts +++ b/packages/react-ui/src/genui-lib/CheckBoxGroup/schema.ts @@ -1,6 +1,9 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; import { rulesSchema } from "../rules"; +type RefComponent = { ref: z.ZodTypeAny }; + export const CheckBoxItemSchema = z.object({ label: z.string(), description: z.string(), @@ -8,8 +11,11 @@ export const CheckBoxItemSchema = z.object({ defaultChecked: z.boolean().optional(), }); -export const CheckBoxGroupSchema = z.object({ - name: z.string(), - items: z.array(z.any()), // filled by CheckBoxItem.ref in index - rules: rulesSchema, -}); +export function createCheckBoxGroupSchema(CheckBoxItem: RefComponent) { + return z.object({ + name: z.string(), + value: reactive(z.record(z.string(), z.boolean()).optional()), + items: z.array(CheckBoxItem.ref), + rules: rulesSchema, + }); +} diff --git a/packages/react-ui/src/genui-lib/DatePicker/index.tsx b/packages/react-ui/src/genui-lib/DatePicker/index.tsx index 0bc27f41e..3ce388cb7 100644 --- a/packages/react-ui/src/genui-lib/DatePicker/index.tsx +++ b/packages/react-ui/src/genui-lib/DatePicker/index.tsx @@ -3,11 +3,9 @@ import { defineComponent, parseStructuredRules, - useFormName, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, } from "@openuidev/react-lang"; import React from "react"; import { DatePicker as OpenUIDatePicker } from "../../components/DatePicker"; @@ -20,29 +18,26 @@ export const DatePicker = defineComponent({ props: DatePickerSchema, description: "", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); - const fieldName = (props.name as string) || "date"; + const field = useStateField(props.name, props.value); const mode = (props.mode as "single" | "range") || "single"; const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); - const value = getFieldValue(formName, fieldName); + const hasRules = rules.length > 0; React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(fieldName, rules, () => getFieldValue(formName, fieldName)); - return () => formValidation.unregisterField(fieldName); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); const handleChange = (val: unknown) => { - setFieldValue(formName, "DatePicker", fieldName, val, true); - if (rules.length > 0) { - formValidation?.validateField(fieldName, val, rules); + field.setValue(val); + if (hasRules) { + formValidation?.validateField(field.name, val, rules); } }; @@ -50,7 +45,7 @@ export const DatePicker = defineComponent({ return ( ); @@ -59,7 +54,7 @@ export const DatePicker = defineComponent({ return ( ); diff --git a/packages/react-ui/src/genui-lib/DatePicker/schema.ts b/packages/react-ui/src/genui-lib/DatePicker/schema.ts index f3ff46773..6eafd87fd 100644 --- a/packages/react-ui/src/genui-lib/DatePicker/schema.ts +++ b/packages/react-ui/src/genui-lib/DatePicker/schema.ts @@ -1,8 +1,10 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; import { rulesSchema } from "../rules"; export const DatePickerSchema = z.object({ name: z.string(), - mode: z.enum(["single", "range"]), + value: reactive(z.unknown().optional()), + mode: z.enum(["single", "range"]).optional(), rules: rulesSchema, }); diff --git a/packages/react-ui/src/genui-lib/FormControl/index.tsx b/packages/react-ui/src/genui-lib/FormControl/index.tsx index de2b358b5..f18aaa82d 100644 --- a/packages/react-ui/src/genui-lib/FormControl/index.tsx +++ b/packages/react-ui/src/genui-lib/FormControl/index.tsx @@ -16,8 +16,10 @@ export const FormControl = defineComponent({ component: ({ props, renderNode }) => { const formValidation = useFormValidation(); const inputObj = props.input as any; + // Extract the field name from the rendered input element props. + const rawName = inputObj?.type === "element" ? inputObj.props?.name : undefined; const fieldName = - inputObj?.type === "element" ? (inputObj.props?.name as string | undefined) : undefined; + typeof rawName === "object" && rawName?.name ? rawName.name : (rawName as string | undefined); const error = fieldName ? formValidation?.errors[fieldName] : undefined; const isRequired = inputObj?.type === "element" && inputObj.props?.rules?.required === true; diff --git a/packages/react-ui/src/genui-lib/Input/index.tsx b/packages/react-ui/src/genui-lib/Input/index.tsx index a9c0b10f9..fa4b4e098 100644 --- a/packages/react-ui/src/genui-lib/Input/index.tsx +++ b/packages/react-ui/src/genui-lib/Input/index.tsx @@ -3,11 +3,9 @@ import { defineComponent, parseStructuredRules, - useFormName, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, } from "@openuidev/react-lang"; import React from "react"; import { Input as OpenUIInput } from "../../components/Input"; @@ -20,39 +18,39 @@ export const Input = defineComponent({ props: InputSchema, description: "", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); - const fieldName = props.name as string; + const field = useStateField(props.name, props.value); const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); - const savedValue = getFieldValue(formName, fieldName) ?? ""; + const hasRules = rules.length > 0; React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(fieldName, rules, () => getFieldValue(formName, fieldName)); - return () => formValidation.unregisterField(fieldName); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); return ( formValidation?.clearFieldError(fieldName)} - onBlur={(e: React.FocusEvent) => { + value={field.value ?? ""} + onFocus={() => formValidation?.clearFieldError(field.name)} + onChange={(e: React.ChangeEvent) => { const val = e.target.value; - if (val !== savedValue) { - setFieldValue(formName, "Input", fieldName, val, true); + field.setValue(val); + if (hasRules) { + formValidation?.clearFieldError(field.name); } - if (rules.length > 0) { - formValidation?.validateField(fieldName, val, rules); + }} + onBlur={(e: React.FocusEvent) => { + if (hasRules) { + formValidation?.validateField(field.name, e.target.value, rules); } }} disabled={isStreaming} diff --git a/packages/react-ui/src/genui-lib/Input/schema.ts b/packages/react-ui/src/genui-lib/Input/schema.ts index 948938a86..e232298df 100644 --- a/packages/react-ui/src/genui-lib/Input/schema.ts +++ b/packages/react-ui/src/genui-lib/Input/schema.ts @@ -1,8 +1,10 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; import { rulesSchema } from "../rules"; export const InputSchema = z.object({ name: z.string(), + value: reactive(z.string().optional()), placeholder: z.string().optional(), type: z.enum(["text", "email", "password", "number", "url"]).optional(), rules: rulesSchema, diff --git a/packages/react-ui/src/genui-lib/ListBlock/index.tsx b/packages/react-ui/src/genui-lib/ListBlock/index.tsx index e3f581b99..275f00e22 100644 --- a/packages/react-ui/src/genui-lib/ListBlock/index.tsx +++ b/packages/react-ui/src/genui-lib/ListBlock/index.tsx @@ -1,11 +1,11 @@ "use client"; -import { BuiltinActionType, defineComponent, useTriggerAction } from "@openuidev/react-lang"; +import type { ActionPlan } from "@openuidev/react-lang"; +import { defineComponent, useTriggerAction } from "@openuidev/react-lang"; import { ChevronRight } from "lucide-react"; import { z } from "zod"; import { ListBlock as OpenUIListBlock } from "../../components/ListBlock"; import { ListItem as OpenUIListItem } from "../../components/ListItem"; -import { ActionSchema } from "../Action/schema"; import { ListItem } from "../ListItem"; export const ListBlock = defineComponent({ @@ -29,24 +29,11 @@ export const ListBlock = defineComponent({ const subtitle = item?.props?.subtitle ? String(item.props.subtitle) : undefined; const image = item?.props?.image as { src: string; alt: string } | undefined; const actionLabel = item?.props?.actionLabel ? String(item.props.actionLabel) : undefined; - const action = item?.props?.action as ActionSchema; + const action = item?.props?.action; const hasAction = !!action; const handleClick = hasAction - ? () => { - const actionType = action.type ?? BuiltinActionType.ContinueConversation; - const actionParams = - actionType === BuiltinActionType.OpenUrl - ? { url: (action as any).url } - : { - ...((action as any).params ?? {}), - ...((action as any).context ? { context: (action as any).context } : {}), - }; - triggerAction(title, undefined, { - type: actionType, - params: actionParams, - }); - } + ? () => triggerAction(title, undefined, action as ActionPlan | undefined) : undefined; return ( diff --git a/packages/react-ui/src/genui-lib/ListItem/index.tsx b/packages/react-ui/src/genui-lib/ListItem/index.tsx index 92229b3a9..7ec9d0be3 100644 --- a/packages/react-ui/src/genui-lib/ListItem/index.tsx +++ b/packages/react-ui/src/genui-lib/ListItem/index.tsx @@ -2,7 +2,7 @@ import { defineComponent } from "@openuidev/react-lang"; import { z } from "zod"; -import { actionSchema } from "../Action/schema"; +import { actionPropSchema } from "../Action/schema"; export const ListItem = defineComponent({ name: "ListItem", @@ -16,7 +16,7 @@ export const ListItem = defineComponent({ }) .optional(), actionLabel: z.string().optional(), - action: actionSchema, + action: actionPropSchema.optional(), }), description: "Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable.", diff --git a/packages/react-ui/src/genui-lib/Modal/index.tsx b/packages/react-ui/src/genui-lib/Modal/index.tsx new file mode 100644 index 000000000..f32668622 --- /dev/null +++ b/packages/react-ui/src/genui-lib/Modal/index.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { defineComponent, useStateField } from "@openuidev/react-lang"; +import { Modal as OpenUIModal } from "../../components/Modal"; +import { ModalSchema } from "./schema"; + +export { ModalSchema } from "./schema"; + +export const Modal = defineComponent({ + name: "Modal", + props: ModalSchema, + description: + "Modal dialog. open is a reactive $boolean binding — set to true to open, X/Escape/backdrop auto-closes. Put Form with buttons inside children.", + component: ({ props, renderNode }) => { + const field = useStateField("open", props.open); + + const isOpen = field.value === true || (field.value as any) === "true"; + + const handleOpenChange = (open: boolean) => { + if (!open) { + field.setValue(false); + } + }; + + return ( + + {renderNode(props.children)} + + ); + }, +}); diff --git a/packages/react-ui/src/genui-lib/Modal/schema.ts b/packages/react-ui/src/genui-lib/Modal/schema.ts new file mode 100644 index 000000000..3e6cd70bd --- /dev/null +++ b/packages/react-ui/src/genui-lib/Modal/schema.ts @@ -0,0 +1,10 @@ +import { reactive } from "@openuidev/react-lang"; +import { z } from "zod"; +import { ContentChildUnion } from "../unions"; + +export const ModalSchema = z.object({ + title: z.string(), + open: reactive(z.boolean().optional()), + children: z.array(ContentChildUnion), + size: z.enum(["sm", "md", "lg"]).optional(), +}); diff --git a/packages/react-ui/src/genui-lib/RadioGroup/index.tsx b/packages/react-ui/src/genui-lib/RadioGroup/index.tsx index 67b9896e7..ee8b12e83 100644 --- a/packages/react-ui/src/genui-lib/RadioGroup/index.tsx +++ b/packages/react-ui/src/genui-lib/RadioGroup/index.tsx @@ -3,11 +3,10 @@ import { defineComponent, parseStructuredRules, - useFormName, + reactive, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, } from "@openuidev/react-lang"; import React from "react"; import { z } from "zod"; @@ -29,54 +28,43 @@ export const RadioGroup = defineComponent({ name: "RadioGroup", props: z.object({ name: z.string(), + value: reactive(z.string().optional()), items: z.array(RadioItem.ref), defaultValue: z.string().optional(), rules: rulesSchema, }), description: "", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); - const fieldName = props.name as string; + const field = useStateField(props.name, props.value); const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); + const hasRules = rules.length > 0; const items = (props.items ?? []) as Array<{ props: { value: string; label?: string; description?: string }; }>; - const value = (getFieldValue(formName, fieldName) ?? props.defaultValue) as string | undefined; + const value = field.value ?? props.defaultValue; React.useEffect(() => { - if ( - !isStreaming && - props.defaultValue != null && - getFieldValue(formName, fieldName) == null - ) { - setFieldValue(formName, "RadioGroup", fieldName, props.defaultValue, false); - } - }, [isStreaming]); - - React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(fieldName, rules, () => getFieldValue(formName, fieldName)); - return () => formValidation.unregisterField(fieldName); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); if (!items.length) return null; return ( { - setFieldValue(formName, "RadioGroup", fieldName, val, true); - if (rules.length > 0) { - formValidation?.validateField(fieldName, val, rules); + field.setValue(val); + if (hasRules) { + formValidation?.validateField(field.name, val, rules); } }} disabled={isStreaming} diff --git a/packages/react-ui/src/genui-lib/Select/index.tsx b/packages/react-ui/src/genui-lib/Select/index.tsx index 5a7a8f8b1..a47147775 100644 --- a/packages/react-ui/src/genui-lib/Select/index.tsx +++ b/packages/react-ui/src/genui-lib/Select/index.tsx @@ -3,11 +3,9 @@ import { defineComponent, parseStructuredRules, - useFormName, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, } from "@openuidev/react-lang"; import React from "react"; import { @@ -33,36 +31,42 @@ export const Select = defineComponent({ props: createSelectSchema(SelectItem), description: "", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); + const field = useStateField(props.name, props.value); + const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); + const hasRules = rules.length > 0; const items = ( (props.items ?? []) as Array<{ props: { value: string; label?: string } }> - ).filter((item) => item.props.value); - const value = getFieldValue(formName, props.name); + ).filter((item) => item?.props?.value); + + const value = field.value ?? ""; + + const handleChange = React.useCallback( + (val: string) => { + field.setValue(val); + if (hasRules) { + formValidation?.validateField(field.name, val, rules); + } + }, + [field, formValidation, hasRules, rules], + ); React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(props.name, rules, () => getFieldValue(formName, props.name)); - return () => formValidation.unregisterField(props.name); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); return ( { - setFieldValue(formName, "Select", props.name, val, true); - if (rules.length > 0) { - formValidation?.validateField(props.name, val, rules); - } - }} + name={field.name} + value={value} + onValueChange={handleChange} disabled={isStreaming} > diff --git a/packages/react-ui/src/genui-lib/Select/schema.ts b/packages/react-ui/src/genui-lib/Select/schema.ts index 1d09c1cba..c0e8655c7 100644 --- a/packages/react-ui/src/genui-lib/Select/schema.ts +++ b/packages/react-ui/src/genui-lib/Select/schema.ts @@ -1,3 +1,4 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; import { rulesSchema } from "../rules"; @@ -11,6 +12,7 @@ export const SelectItemSchema = z.object({ export function createSelectSchema(SelectItem: RefComponent) { return z.object({ name: z.string(), + value: reactive(z.string().optional()), items: z.array(SelectItem.ref), placeholder: z.string().optional(), rules: rulesSchema, diff --git a/packages/react-ui/src/genui-lib/Slider/index.tsx b/packages/react-ui/src/genui-lib/Slider/index.tsx index a46d0da24..0873f567e 100644 --- a/packages/react-ui/src/genui-lib/Slider/index.tsx +++ b/packages/react-ui/src/genui-lib/Slider/index.tsx @@ -3,12 +3,9 @@ import { defineComponent, parseStructuredRules, - useFormName, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetDefaultValue, - useSetFieldValue, + useStateField, } from "@openuidev/react-lang"; import React from "react"; import { SliderBlock as OpenUISliderBlock } from "../../components/Slider"; @@ -21,48 +18,35 @@ export const Slider = defineComponent({ props: SliderSchema, description: "Numeric slider input; supports continuous and discrete (stepped) variants", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); - const fieldName = props.name as string; + const field = useStateField(props.name, props.value); const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); - const existingValue = getFieldValue(formName, fieldName); - const defaultVal = props.defaultValue; - - useSetDefaultValue({ - formName, - componentType: "Slider", - name: fieldName, - existingValue, - defaultValue: defaultVal, - }); + const hasRules = rules.length > 0; + const value = field.value ?? props.defaultValue; React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(fieldName, rules, () => getFieldValue(formName, fieldName)); - return () => formValidation.unregisterField(fieldName); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); - - const value = existingValue ?? defaultVal; + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); return ( { - setFieldValue(formName, "Slider", fieldName, vals, true); - if (rules.length > 0) { - formValidation?.validateField(fieldName, vals[0], rules); + field.setValue(vals); + if (hasRules) { + formValidation?.validateField(field.name, vals[0], rules); } }} disabled={isStreaming} diff --git a/packages/react-ui/src/genui-lib/Slider/schema.ts b/packages/react-ui/src/genui-lib/Slider/schema.ts index 3e7f4079e..77383fc40 100644 --- a/packages/react-ui/src/genui-lib/Slider/schema.ts +++ b/packages/react-ui/src/genui-lib/Slider/schema.ts @@ -1,8 +1,10 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; import { rulesSchema } from "../rules"; export const SliderSchema = z.object({ name: z.string(), + value: reactive(z.array(z.number()).optional()), variant: z.enum(["continuous", "discrete"]), min: z.number(), max: z.number(), diff --git a/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx b/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx index 2fa1fc7ea..4233d3a54 100644 --- a/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx +++ b/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx @@ -2,17 +2,14 @@ import { defineComponent, - useFormName, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, type SubComponentOf, } from "@openuidev/react-lang"; import React from "react"; -import { z } from "zod"; import { SwitchGroup as OpenUISwitchGroup } from "../../components/SwitchGroup"; import { SwitchItem as OpenUISwitchItem } from "../../components/SwitchItem"; -import { SwitchItemSchema } from "./schema"; +import { SwitchItemSchema, createSwitchGroupSchema } from "./schema"; export { SwitchItemSchema } from "./schema"; @@ -32,29 +29,23 @@ export const SwitchItem = defineComponent({ export const SwitchGroup = defineComponent({ name: "SwitchGroup", - props: z.object({ - name: z.string(), - items: z.array(SwitchItem.ref), - variant: z.enum(["clear", "card", "sunk"]).optional(), - }), + props: createSwitchGroupSchema(SwitchItem), description: "Group of switch toggles", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); - const fieldName = props.name as string; + const field = useStateField(props.name, props.value); const items = (props.items ?? []) as Array>; + // Aggregate: map of item name → checked boolean const getAggregate = React.useCallback((): Record => { - const stored = getFieldValue(formName, fieldName) as Record | undefined; + const stored = field.value; const result: Record = {}; for (const item of items) { result[item.props.name] = stored?.[item.props.name] ?? item.props.defaultChecked ?? false; } return result; - }, [formName, fieldName, items, getFieldValue]); + }, [field.value, items]); if (!items.length) return null; @@ -71,7 +62,7 @@ export const SwitchGroup = defineComponent({ checked={aggregate[item.props.name] ?? item.props.defaultChecked ?? false} onChange={(val: boolean) => { const newAggregate = { ...getAggregate(), [item.props.name]: val }; - setFieldValue(formName, "SwitchGroup", fieldName, newAggregate, true); + field.setValue(newAggregate); }} disabled={isStreaming} /> diff --git a/packages/react-ui/src/genui-lib/SwitchGroup/schema.ts b/packages/react-ui/src/genui-lib/SwitchGroup/schema.ts index 3ec0ebf54..133ba7dbd 100644 --- a/packages/react-ui/src/genui-lib/SwitchGroup/schema.ts +++ b/packages/react-ui/src/genui-lib/SwitchGroup/schema.ts @@ -1,5 +1,8 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; +type RefComponent = { ref: z.ZodTypeAny }; + export const SwitchItemSchema = z.object({ label: z.string().optional(), description: z.string().optional(), @@ -7,8 +10,11 @@ export const SwitchItemSchema = z.object({ defaultChecked: z.boolean().optional(), }); -export const SwitchGroupSchema = z.object({ - name: z.string(), - items: z.array(z.any()), // filled by SwitchItem.ref in index - variant: z.enum(["clear", "card", "sunk"]).optional(), -}); +export function createSwitchGroupSchema(SwitchItem: RefComponent) { + return z.object({ + name: z.string(), + value: reactive(z.record(z.string(), z.boolean()).optional()), + items: z.array(SwitchItem.ref), + variant: z.enum(["clear", "card", "sunk"]).optional(), + }); +} diff --git a/packages/react-ui/src/genui-lib/Table/index.tsx b/packages/react-ui/src/genui-lib/Table/index.tsx index 2dfb85534..2f7d3bd0f 100644 --- a/packages/react-ui/src/genui-lib/Table/index.tsx +++ b/packages/react-ui/src/genui-lib/Table/index.tsx @@ -18,7 +18,7 @@ export { ColSchema } from "./schema"; export const Col = defineComponent({ name: "Col", props: ColSchema, - description: "Column definition", + description: "Column definition — holds label + data array", component: () => null, }); @@ -26,39 +26,48 @@ export const Table = defineComponent({ name: "Table", props: z.object({ columns: z.array(Col.ref), - rows: z.array(z.array(z.union([z.string(), z.number(), z.boolean()]))), }), - description: "Data table", + description: "Data table — column-oriented. Each Col holds its own data array.", component: ({ props, renderNode }) => { const columns = props.columns ?? []; - const rows = asArray(props.rows) as unknown[][]; - if (!columns.length) return null; + // Extract column data arrays and labels (filter null children from streaming) + const colDefs = columns + .filter((c: any) => c != null && c.props) + .map((c: any) => ({ + label: c.props?.label ?? "", + data: asArray(c.props?.data ?? []), + })); + if (!colDefs.length) return null; + + // Transpose columns → rows: row count = max column length + const rowCount = Math.max(...colDefs.map((c) => c.data.length), 0); + return ( - {columns.map((c, i) => ( - {c.props.label} + {colDefs.map((c, i) => ( + {c.label} ))} - {rows.map((row, ri) => { - const cells = asArray(row); - return ( - - {cells.map((cell, ci) => ( + {Array.from({ length: rowCount }, (_, ri) => ( + + {colDefs.map((col, ci) => { + const cell = col.data[ri]; + return ( {typeof cell === "object" && cell !== null ? renderNode(cell) : String(cell ?? "")} - ))} - - ); - })} + ); + })} + + ))} ); diff --git a/packages/react-ui/src/genui-lib/Table/schema.ts b/packages/react-ui/src/genui-lib/Table/schema.ts index c40291c8e..10a2224af 100644 --- a/packages/react-ui/src/genui-lib/Table/schema.ts +++ b/packages/react-ui/src/genui-lib/Table/schema.ts @@ -1,6 +1,10 @@ import { z } from "zod"; export const ColSchema = z.object({ + /** Column header label */ label: z.string(), + /** Column data — array of values or components (one per row). Use array pluck for text, Each() for styled cells like Tag. */ + data: z.any(), + /** Optional display type hint */ type: z.enum(["string", "number", "action"]).optional(), }); diff --git a/packages/react-ui/src/genui-lib/TextArea/index.tsx b/packages/react-ui/src/genui-lib/TextArea/index.tsx index c6baa1f19..758b43005 100644 --- a/packages/react-ui/src/genui-lib/TextArea/index.tsx +++ b/packages/react-ui/src/genui-lib/TextArea/index.tsx @@ -3,11 +3,9 @@ import { defineComponent, parseStructuredRules, - useFormName, useFormValidation, - useGetFieldValue, useIsStreaming, - useSetFieldValue, + useStateField, } from "@openuidev/react-lang"; import React from "react"; import { TextArea as OpenUITextArea } from "../../components/TextArea"; @@ -20,38 +18,38 @@ export const TextArea = defineComponent({ props: TextAreaSchema, description: "", component: ({ props }) => { - const formName = useFormName(); - const getFieldValue = useGetFieldValue(); - const setFieldValue = useSetFieldValue(); const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); - const fieldName = props.name as string; + const field = useStateField(props.name, props.value); const rules = React.useMemo(() => parseStructuredRules(props.rules), [props.rules]); - const savedValue = getFieldValue(formName, fieldName) ?? ""; + const hasRules = rules.length > 0; React.useEffect(() => { - if (!isStreaming && rules.length > 0 && formValidation) { - formValidation.registerField(fieldName, rules, () => getFieldValue(formName, fieldName)); - return () => formValidation.unregisterField(fieldName); + if (!isStreaming && hasRules && formValidation) { + formValidation.registerField(field.name, rules, () => field.value); + return () => formValidation.unregisterField(field.name); } return undefined; - }, [isStreaming, rules.length > 0]); + }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); return ( formValidation?.clearFieldError(fieldName)} - onBlur={(e: React.FocusEvent) => { + value={field.value ?? ""} + onFocus={() => formValidation?.clearFieldError(field.name)} + onChange={(e: React.ChangeEvent) => { const val = e.target.value; - if (val !== savedValue) { - setFieldValue(formName, "TextArea", fieldName, val, true); + field.setValue(val); + if (hasRules) { + formValidation?.clearFieldError(field.name); } - if (rules.length > 0) { - formValidation?.validateField(fieldName, val, rules); + }} + onBlur={(e: React.FocusEvent) => { + if (hasRules) { + formValidation?.validateField(field.name, e.target.value, rules); } }} disabled={isStreaming} diff --git a/packages/react-ui/src/genui-lib/TextArea/schema.ts b/packages/react-ui/src/genui-lib/TextArea/schema.ts index 376136872..8e48a43a5 100644 --- a/packages/react-ui/src/genui-lib/TextArea/schema.ts +++ b/packages/react-ui/src/genui-lib/TextArea/schema.ts @@ -1,8 +1,10 @@ +import { reactive } from "@openuidev/react-lang"; import { z } from "zod"; import { rulesSchema } from "../rules"; export const TextAreaSchema = z.object({ name: z.string(), + value: reactive(z.string().optional()), placeholder: z.string().optional(), rows: z.number().optional(), rules: rulesSchema, diff --git a/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx b/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx index 5ec9dc737..37abc7b42 100644 --- a/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx +++ b/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx @@ -212,16 +212,19 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ export const openuiChatExamples: string[] = [ `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]] +tbl = Table([Col("Language", langs), Col("Users (M)", users), Col("Year", years)]) +langs = ["Python", "JavaScript", "Java"] +users = [15.7, 14.2, 12.1] +years = [1991, 1995, 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]) @@ -230,6 +233,7 @@ 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") @@ -250,13 +254,14 @@ 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")])`, +btns = Buttons([Button("Submit", Action([ToAssistant("Submit")]), "primary")])`, ]; export const openuiChatAdditionalRules: string[] = [ diff --git a/packages/react-ui/src/genui-lib/openuiLibrary.tsx b/packages/react-ui/src/genui-lib/openuiLibrary.tsx index 3758e770a..048c09854 100644 --- a/packages/react-ui/src/genui-lib/openuiLibrary.tsx +++ b/packages/react-ui/src/genui-lib/openuiLibrary.tsx @@ -62,6 +62,9 @@ import { Col, Table } from "./Table"; import { Tag } from "./Tag"; import { TagBlock } from "./TagBlock"; +// Modal +import { Modal } from "./Modal"; + // ── Component Groups ── export const openuiComponentGroups: ComponentGroup[] = [ @@ -77,11 +80,16 @@ export const openuiComponentGroups: ComponentGroup[] = [ "StepsItem", "Carousel", "Separator", + "Modal", ], notes: [ '- For grid-like layouts, use Stack with direction "row" and wrap set to true.', '- Prefer justify "start" (or omit justify) with wrap=true for stable columns instead of uneven gutters.', "- Use nested Stacks when you need explicit rows/sections.", + '- Show/hide sections: $editId != "" ? Card([editForm]) : null', + '- Modal: Modal("Title", $showModal, [content]) — $showModal is boolean, X/Escape auto-closes. Put Form with its own buttons inside children.', + "- Use Tabs for alternative views (chart types, data sections) — no $variable needed", + "- Shared filter across Tabs: same $days binding in Query args works across all TabItems", ], }, { @@ -98,10 +106,24 @@ export const openuiComponentGroups: ComponentGroup[] = [ "ImageGallery", "CodeBlock", ], + notes: [ + '- Use Cards to group related KPIs or sections. Stack with direction "row" for side-by-side layouts.', + '- Success toast: Callout("success", "Saved", "Done.", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == "error" ? Callout("error", "Failed", result.error) : null', + '- KPI card: Card([TextContent("Label", "small"), TextContent("" + @Count(@Filter(data.rows, "field", "==", "value")), "large-heavy")])', + ], }, { name: "Tables", components: ["Table", "Col"], + notes: [ + '- Table is COLUMN-oriented: Table([Col("Label", dataArray), Col("Count", countArray, "number")]). Use array pluck for data: data.rows.fieldName', + '- Col data can be component arrays for styled cells: Col("Status", @Each(data.rows, "item", Tag(item.status, null, "sm", item.status == "open" ? "success" : "danger")))', + '- Row actions: Col("Actions", @Each(data.rows, "t", Button("Edit", Action([@Set($showEdit, true), @Set($editId, t.id)]))))', + '- Sortable: sorted = @Sort(data.rows, $sortField, "desc"). Bind $sortField to Select. Use sorted.fieldName for Col data', + '- Searchable: filtered = @Filter(data.rows, "title", "contains", $search). Bind $search to Input', + "- Chain sort + filter: filtered = @Filter(...) then sorted = @Sort(filtered, ...) — use sorted for both Table and Charts", + '- Empty state: @Count(data.rows) > 0 ? Table([...]) : TextContent("No data yet")', + ], }, { name: "Charts (2D)", @@ -113,10 +135,21 @@ export const openuiComponentGroups: ComponentGroup[] = [ "HorizontalBarChart", "Series", ], + notes: [ + '- Charts accept column arrays: LineChart(labels, [Series("Name", values)]). Use array pluck: LineChart(data.rows.day, [Series("Views", data.rows.views)])', + "- Use Cards to wrap charts with CardHeader for titled sections", + "- Chart + Table from same source: use @Sort or @Filter result for both LineChart and Table Col data", + '- Multiple chart views: use Tabs — Tabs([TabItem("line", "Line", [LineChart(...)]), TabItem("bar", "Bar", [BarChart(...)])])', + ], }, { name: "Charts (1D)", components: ["PieChart", "RadialChart", "SingleStackedBarChart", "Slice"], + notes: [ + "- PieChart and BarChart need NUMBERS, not objects. For list data, use @Count(@Filter(...)) to aggregate:", + '- PieChart from list: `PieChart(["Low", "Med", "High"], [@Count(@Filter(data.rows, "priority", "==", "low")), @Count(@Filter(data.rows, "priority", "==", "medium")), @Count(@Filter(data.rows, "priority", "==", "high"))], "donut")`', + '- KPI from count: `TextContent("" + @Count(@Filter(data.rows, "status", "==", "open")), "large-heavy")`', + ], }, { name: "Charts (Scatter)", @@ -145,32 +178,43 @@ export const openuiComponentGroups: ComponentGroup[] = [ "- For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming.", "- NEVER nest Form inside Form — each Form should be a standalone container.", "- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.", - '- rules is an optional array of validation strings: ["required", "email", "min:8", "maxLength:100"]', - "- Available rules: required, email, min:N, max:N, minLength:N, maxLength:N, pattern:REGEX, url, numeric", + "- rules is an optional object: {required: true, email: true, minLength: 8, maxLength: 100}", + "- Available rules: required, email, min, max, minLength, maxLength, pattern, url, numeric", "- The renderer shows error messages automatically — do NOT generate error text in the UI", + '- Conditional fields: $country == "US" ? stateField : $country == "UK" ? postcodeField : addressField', + '- Edit form in Modal: Modal("Edit", $showEdit, [Form("edit", Buttons([saveBtn, cancelBtn]), [fields...])]). Save button should include @Set($showEdit, false) to close modal.', ], }, { name: "Buttons", components: ["Button", "Buttons"], + notes: [ + '- Toggle in @Each: @Each(rows, "t", Button(t.status == "open" ? "Close" : "Reopen", Action([...])))', + ], }, { name: "Data Display", components: ["TagBlock", "Tag"], + notes: [ + '- Color-mapped Tag: Tag(value, null, "sm", value == "high" ? "danger" : value == "medium" ? "warning" : "neutral")', + ], }, ]; // ── Examples ── export const openuiExamples: string[] = [ - `Example 1 — Table: + `Example 1 — Table (column-oriented): + root = Stack([title, tbl]) title = TextContent("Top Languages", "large-heavy") -tbl = Table(cols, rows) -cols = [Col("Language", "string"), Col("Users (M)", "number"), Col("Year", "number")] -rows = [["Python", 15.7, 1991], ["JavaScript", 14.2, 1995], ["Java", 12.1, 1995], ["TypeScript", 8.5, 2012], ["Go", 5.2, 2009]]`, +tbl = Table([Col("Language", langs), Col("Users (M)", users), Col("Year", years)]) +langs = ["Python", "JavaScript", "Java", "TypeScript", "Go"] +users = [15.7, 14.2, 12.1, 8.5, 5.2] +years = [1991, 1995, 1995, 2012, 2009]`, `Example 2 — Bar chart: + root = Stack([title, chart]) title = TextContent("Q4 Revenue", "large-heavy") chart = BarChart(labels, [s1, s2], "grouped") @@ -179,6 +223,7 @@ s1 = Series("Product A", [120, 150, 180]) s2 = Series("Product B", [90, 110, 140])`, `Example 3 — Form with validation: + root = Stack([title, form]) title = TextContent("Contact Us", "large-heavy") form = Form("contact", btns, [nameField, emailField, countryField, msgField]) @@ -187,9 +232,10 @@ emailField = FormControl("Email", Input("email", "you@example.com", "email", { r countryField = FormControl("Country", Select("country", countryOpts, "Select...", { required: true })) msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) countryOpts = [SelectItem("us", "United States"), SelectItem("uk", "United Kingdom"), SelectItem("de", "Germany")] -btns = Buttons([Button("Submit", { type: "continue_conversation" }, "primary"), Button("Cancel", { type: "continue_conversation" }, "secondary")])`, +btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary"), Button("Cancel", Action([@ToAssistant("Cancel")]), "secondary")])`, `Example 4 — Tabs with mixed content: + root = Stack([title, tabs]) title = TextContent("React vs Vue", "large-heavy") tabs = Tabs([tabReact, tabVue]) @@ -204,6 +250,10 @@ export const openuiAdditionalRules: string[] = [ "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.", + 'Use @Reset($var1, $var2) after form submit to restore defaults — not @Set($var, "")', + "Multi-query refresh: Action([@Run(mutation), @Run(query1), @Run(query2), @Reset(...)])", + "$variables are reactive: changing via Select or @Set re-evaluates all Queries and expressions referencing them", + "Use existing components (Tabs, Accordion, Modal) before inventing ternary show/hide patterns", ]; export const openuiPromptOptions: PromptOptions = { @@ -280,5 +330,7 @@ export const openuiLibrary = createLibrary({ // Data Display TagBlock, Tag, + // Modal + Modal, ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c9ac0652..ee4373af7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,13 +61,13 @@ importers: version: 0.68.17 fumadocs-core: specifier: 16.6.5 - version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) fumadocs-mdx: specifier: 14.2.8 - version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(sass@1.89.2)) fumadocs-ui: specifier: 16.6.5 - version: 16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + version: 16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) gpt-tokenizer: specifier: ^3.4.0 version: 3.4.0 @@ -79,7 +79,7 @@ importers: version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 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.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -149,7 +149,7 @@ importers: 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) + version: 16.1.6(@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.20.0)(zod@4.3.6) @@ -207,7 +207,7 @@ importers: 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) + version: 16.1.6(@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.20.0)(zod@4.3.6) @@ -246,13 +246,77 @@ importers: specifier: ^5 version: 5.9.3 + examples/openui-dashboard: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) + '@openuidev/cli': + specifier: workspace:* + version: link:../../packages/openui-cli + '@openuidev/lang-core': + specifier: workspace:* + version: link:../../packages/lang-core + '@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(@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.20.0)(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) + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@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) + 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-react-native: {} examples/openui-react-native/backend: dependencies: next: specifier: ^15.2.3 - version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + version: 15.5.12(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) openai: specifier: ^4.90.0 version: 4.104.0(ws@8.20.0)(zod@4.3.6) @@ -347,7 +411,7 @@ importers: version: 0.562.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) + version: 16.1.6(@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.20.0)(zod@4.3.6) @@ -480,7 +544,7 @@ importers: 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) + version: 16.1.6(@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.20.0)(zod@4.3.6) @@ -617,7 +681,7 @@ importers: 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) + version: 16.1.6(@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) react: specifier: 19.2.3 version: 19.2.3 @@ -705,6 +769,9 @@ importers: specifier: ^4.0.0 version: 4.3.6 devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) vitest: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) @@ -787,6 +854,9 @@ importers: specifier: ^4.0.0 version: 4.3.6 devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -2698,6 +2768,12 @@ packages: peerDependencies: tailwindcss: '>=4.0.0' + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2748,105 +2824,89 @@ 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==} @@ -3129,6 +3189,16 @@ packages: '@types/react': '>=16' react: '>=16' + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3176,56 +3246,48 @@ 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==} @@ -3469,56 +3531,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-C3zapJconWpl2Y7LR3GkRkH6jxpuV2iVUfkFcHT5Ffn4Zu7l88mZa2dhcfdULZDybN1Phka/P34YUzuskUUrXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-minify/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-2T/Bm+3/qTfuNS4gKSzL8qbiYk+ErHW2122CtDx+ilZAzvWcJ8IbqdZIbEWOlwwe03lESTxPwTBLFqVgQU2OeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-MKLjpldYkeoB4T+yAi4aIAb0waifxUjLcKkCUDmYAY3RqBJTvWK34KtfaKZL0IBMIXfD92CbKkcxQirDUS9Xcg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-UFVcbPvKUStry6JffriobBp8BHtjmLLPl4bCY+JMxIn/Q3pykCpZzRwFTcDurG/kY8tm+uSNfKKdRNa5Nh9A7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-minify/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-B9GyPQ1NKbvpETVAMyJMfRlD3c6UJ7kiuFUAlx9LTYiQL+YIyT6vpuRlq1zgsXxavZluVrfeJv6x0owV4KDx4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-fXfhtr+WWBGNy4M5GjAF5vu/lpulR4Me34FjTyaK9nDrTZs7LM595UDsP1wliksqp4hD/KdoqHGmbCrC+6d4vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-minify/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-jFBgGbx1oLadb83ntJmy1dWlAHSQanXTS21G4PgkxyONmxZdZ/UMKr7KsADzMuoPsd2YhJHxzRpwJd9U+4BFBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-minify/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-nxPd9vx1vYz8IlIMdl9HFdOK/ood1H5hzbSFsyO8JU55tkcJoBL8TLCbuFf9pHpOy27l2gcPyV6z3p4eAcTH5Q==} @@ -3596,56 +3650,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-QagKTDF4lrz8bCXbUi39Uq5xs7C7itAseKm51f33U+Dyar9eJY/zGKqfME9mKLOiahX7Fc1J3xMWVS0AdDXLPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-RPddpcE/0xxWaommWy0c5i/JdrXcXAkxBS2GOrAUh5LKmyCh03hpJedOAWszG4ADsKQwoUQQ1/tZVGRhZIWtKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-ur/WVZF9FSOiZGxyP+nfxZzuv6r5OJDYoVxJnUR7fM/hhXLh4V/be6rjbzm9KLCDBRwYCEKJtt+XXNccwd06IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-ujGcAx8xAMvhy7X5sBFi3GXML1EtyORuJZ5z2T6UV3U416WgDX/4OCi3GnoteeenvxIf6JgP45B+YTHpt71vpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-hbsfKjUwRjcMZZvvmpZSc+qS0bHcHRu8aV/I3Ikn9BzOA0ZAgUE7ctPtce5zCU7bM8dnTLi4sJ1Pi9YHdx6Urw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-1QrTrf8rige7UPJrYuDKJLQOuJlgkt+nRSJLBMHWNm9TdivzP48HaK3f4q18EjNlglKtn03lgjMu4fryDm8X4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-gRvK6HPzF5ITRL68fqb2WYYs/hGviPIbkV84HWCgiJX+LkaOpp+HIHQl3zVZdyKHwopXToTbXbtx/oFjDjl8pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-QPJvFbnnDZZY7xc+xpbIBWLThcGBakwaYA9vKV8b3+oS5MGfAZUoTFJcix5+Zg2Ri46sOfrUim6Y6jsKNcssAQ==} @@ -3726,56 +3772,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-ykxpPQp0eAcSmhy0Y3qKvdanHY4d8THPonDfmCoktUXb6r0X6qnjpJB3V+taN1wevW55bOEZd97kxtjTKjqhmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-Rvspti4Kr7eq6zSrURK5WjscfWQPvmy/KjJZV45neRKW8RLonE3r9+NgrwSLGoHvQ3F24fbqlkplox1RtlhH5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-Dr2ZW9ZZ4l1eQ5JUEUY3smBh4JFPCPuybWaDZTLn3ADZjyd8ZtNXEjeMT8rQbbhbgSL9hEgbwaqraole3FNThQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-oD1Bnes1bIC3LVBSrWEoSUBj6fvatESPwAVWfJVGVQlqWuOs/ZBn1e4Nmbipo3KGPHK7DJY75r/j7CQCxhrOFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-qT//IAPLvse844t99Kff5j055qEbXfwzWgvCMb0FyjisnB8foy25iHZxZIocNBe6qwrCYWUP1M8rNrB/WyfS1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-2YEO5X+KgNzFqRVO5dAkhjcI5gwxus4NSWVl/+cs2sI6P0MNPjqE3VWPawl4RTC11LvetiiZdHcujUCPM8aaUw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-3wqWbTSaIFZvDr1aqmTul4cg8PRWYh6VC52E8bLI7ytgS/BwJLW+sDUU2YaGIds4sAf/1yKeJRmudRCDPW9INg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-Ebxx6NPqhzlrjvx4+PdSqbOq+li0f7X59XtJljDghkbJsbnkHvhLmPR09ifHt5X32UlZN63ekjwcg/nbmHLLlA==} @@ -3835,42 +3873,36 @@ 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-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -5882,145 +5914,121 @@ packages: resolution: {integrity: sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.60.1': resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} 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-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} 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-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} 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-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] 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-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] '@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-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} 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-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} 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-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} 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-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} 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-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -6434,56 +6442,48 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} 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-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} 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-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} 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-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -6566,28 +6566,24 @@ 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==} @@ -6937,49 +6933,41 @@ 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==} @@ -7344,6 +7332,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -7690,6 +7686,10 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -8064,6 +8064,14 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -8076,10 +8084,18 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} @@ -8089,6 +8105,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -8880,6 +8900,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -8960,6 +8984,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -9066,6 +9100,10 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -9110,6 +9148,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -9509,6 +9551,10 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + hono@4.12.8: + resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -9666,6 +9712,14 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -9809,6 +9863,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -9950,6 +10007,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -10019,6 +10079,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -10160,56 +10223,48 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} 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-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} 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-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} 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-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -10466,6 +10521,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -10475,6 +10534,10 @@ packages: memoizerific@1.11.3: resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -11310,6 +11373,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -11662,6 +11729,10 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -11670,6 +11741,10 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -11705,6 +11780,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -12138,6 +12217,10 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -12891,6 +12974,10 @@ packages: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + type-level-regexp@0.1.17: resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} @@ -13800,6 +13887,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -13948,7 +14040,7 @@ snapshots: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.2.6 + lru-cache: 11.2.7 optional: true '@asamuzakjp/dom-selector@6.8.1': @@ -13957,7 +14049,7 @@ snapshots: bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.6 + lru-cache: 11.2.7 optional: true '@asamuzakjp/nwsapi@2.3.9': @@ -15517,6 +15609,10 @@ snapshots: transitivePeerDependencies: - tailwind-merge + '@hono/node-server@1.19.11(hono@4.12.8)': + dependencies: + hono: 4.12.8 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -15950,6 +16046,28 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.8) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.8 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -21454,6 +21572,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@5.1.0(ajv@8.17.1): dependencies: ajv: 8.17.1 @@ -21866,6 +21988,20 @@ snapshots: birpc@4.0.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bplist-creator@0.1.0: @@ -22193,7 +22329,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.52.0 + mime-db: 1.54.0 compression@1.8.1: dependencies: @@ -22241,6 +22377,10 @@ snapshots: consola@3.4.2: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -22249,8 +22389,12 @@ snapshots: cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} + cookie@0.6.0: {} + cookie@0.7.2: {} + core-js-compat@3.48.0: dependencies: browserslist: 4.28.1 @@ -22259,6 +22403,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -22379,7 +22528,7 @@ snapshots: '@asamuzakjp/css-color': 5.0.1 '@csstools/css-syntax-patches-for-csstree': 1.1.0 css-tree: 3.2.1 - lru-cache: 11.2.6 + lru-cache: 11.2.7 optional: true csstype@3.2.3: {} @@ -22975,7 +23124,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@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)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -22997,7 +23146,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@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)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -23238,6 +23387,10 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -23345,6 +23498,44 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} extend@3.0.2: {} @@ -23458,6 +23649,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -23505,6 +23707,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -23527,7 +23731,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): + fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@orama/orama': 3.1.18 @@ -23559,21 +23763,21 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.14 lucide-react: 0.570.0(react@19.2.4) - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 4.3.6 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3)): + fumadocs-mdx@14.2.8(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react@19.2.4)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(sass@1.89.2)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -23590,13 +23794,13 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.13 '@types/react': 19.2.14 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) react: 19.2.4 vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) transitivePeerDependencies: - supports-color - fumadocs-ui@16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): + fumadocs-ui@16.6.5(@takumi-rs/image-response@0.68.17)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): dependencies: '@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.1) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -23610,7 +23814,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.5(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) lucide-react: 0.570.0(react@19.2.4) motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -23625,7 +23829,7 @@ snapshots: optionalDependencies: '@takumi-rs/image-response': 0.68.17 '@types/react': 19.2.14 - next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) + next: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -23996,6 +24200,8 @@ snapshots: highlightjs-vue@1.0.0: {} + hono@4.12.8: {} + hookable@5.5.3: {} hookable@6.1.0: {} @@ -24166,6 +24372,10 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + iron-webcrypto@1.2.1: {} is-alphabetical@1.0.4: {} @@ -24296,6 +24506,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -24478,6 +24690,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -24574,6 +24788,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -25096,6 +25312,8 @@ snapshots: mdn-data@2.27.1: {} + media-typer@1.1.0: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} @@ -25104,6 +25322,8 @@ snapshots: dependencies: map-or-similar: 1.5.0 + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -25850,7 +26070,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2): + next@15.5.12(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2): dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 @@ -25858,7 +26078,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 15.5.12 '@next/swc-darwin-x64': 15.5.12 @@ -25876,7 +26096,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@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): + next@16.1.6(@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): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -25885,7 +26105,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -25903,7 +26123,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2): + next@16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.89.2): dependencies: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 @@ -25912,7 +26132,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.6 '@next/swc-darwin-x64': 16.1.6 @@ -26599,6 +26819,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -26947,10 +27169,19 @@ snapshots: '@types/node': 22.15.32 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} qrcode-terminal@0.11.0: {} + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} query-selector-shadow-dom@1.0.1: {} @@ -27032,6 +27263,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -27876,6 +28114,16 @@ snapshots: rou3@0.8.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.8.0: {} run-applescript@7.1.0: {} @@ -28374,19 +28622,15 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.29.0 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 - optionalDependencies: - '@babel/core': 7.29.0 stylehacks@7.0.8(postcss@8.5.8): dependencies: @@ -28746,6 +28990,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + type-level-regexp@0.1.17: {} typed-array-buffer@1.0.3: @@ -29701,6 +29951,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6