diff --git a/examples/multi-tool-dashboard/.gitignore b/examples/multi-tool-dashboard/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/examples/multi-tool-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/multi-tool-dashboard/README.md b/examples/multi-tool-dashboard/README.md new file mode 100644 index 000000000..2c4cb519b --- /dev/null +++ b/examples/multi-tool-dashboard/README.md @@ -0,0 +1,240 @@ +# Multi-Tool Dashboard + +A CEO dashboard builder powered by [OpenUI](https://openui.com) and openui-lang. Chat with an LLM to create live dashboards pulling real data from **Stripe**, **PostHog**, **GitHub**, and **Google Calendar**. + +> Describe what you want in plain English. Get a live, interactive dashboard with real data. + +## Data Sources + +| Source | What it provides | Auth method | +|--------|-----------------|-------------| +| **Stripe** | Revenue, charges, balance, subscriptions (MRR) | Secret key (`sk_test_...` or `sk_live_...`) | +| **PostHog** | Product analytics — DAU, pageviews, funnels, top events | Personal API key (`phx_...`) | +| **GitHub** | Engineering velocity — repos, commits, activity, contributors | Personal access token (`ghp_...`) | +| **Google Calendar** | Meeting schedule, daily agenda, upcoming events | OAuth via `gws` CLI | + +All data sources are optional. The dashboard works with any combination — just configure the ones you have. + +## Quick Start + +```bash +# From the monorepo root +pnpm install + +# Configure your data sources (see setup below) +cp examples/multi-tool-dashboard/.env.example examples/multi-tool-dashboard/.env +# Edit .env with your keys + +# Run the dev server +cd examples/multi-tool-dashboard +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) and pick a starter prompt or type your own. + +## Environment Variables + +Create a `.env` file in the project root: + +```bash +# ── LLM (required) ────────────────────────────────────────────────────────── +# Any OpenAI-compatible API. At least one of these is required. +LLM_API_KEY=sk-... +# LLM_BASE_URL=https://api.openai.com/v1 # default +# LLM_MODEL=gpt-5.4 # default + +# ── Stripe (revenue & financials) ─────────────────────────────────────────── +STRIPE_SECRET_KEY=sk_test_... + +# ── PostHog (product analytics) ───────────────────────────────────────────── +POSTHOG_API_KEY=phx_... +POSTHOG_PROJECT_ID=12345 +# POSTHOG_HOST=https://us.posthog.com # default (use https://eu.posthog.com for EU) + +# ── GitHub (engineering velocity) ─────────────────────────────────────────── +GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... +# GITHUB_ORG=your-org # optional: scope to a specific org + +# ── Google Calendar ───────────────────────────────────────────────────────── +# No env var needed — uses gws CLI credentials (see setup below) +``` + +## Data Source Setup + +### Stripe + +1. Go to [Stripe Dashboard > Developers > API keys](https://dashboard.stripe.com/apikeys) +2. Copy your **Secret key** (starts with `sk_test_` for test mode or `sk_live_` for production) +3. Add to `.env`: + ``` + STRIPE_SECRET_KEY=sk_test_... + ``` + +That's it. The dashboard can now query your balance, charges, revenue transactions, and subscriptions. + +**Available tools:** `get_stripe_balance`, `get_stripe_charges`, `get_stripe_revenue`, `get_stripe_subscriptions` + +### PostHog + +1. Go to PostHog > click your avatar (top right) > **Settings** +2. Scroll to **Personal API Keys** +3. Click **Create personal API key**, select scope `insight:read` +4. Copy the key (starts with `phx_`) +5. Get your **Project ID** from **Project Settings** (numeric ID in the URL) +6. Add to `.env`: + ``` + POSTHOG_API_KEY=phx_... + POSTHOG_PROJECT_ID=12345 + ``` + +> **Note:** The project API key (`phc_...`) is write-only and will NOT work. You need a personal API key (`phx_...`) with read access. + +**Available tools:** `get_product_trends`, `get_top_events`, `get_conversion_funnel`, `posthog_query` + +### GitHub + +1. Go to [GitHub > Settings > Developer settings > Personal access tokens > Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) +2. Create a token with **Repository access** for the repos/org you want +3. Grant permissions: `Contents: Read`, `Metadata: Read` +4. Add to `.env`: + ``` + GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... + ``` + +To scope to a specific organization (e.g., only show repos from `your-org`): +``` +GITHUB_ORG=your-org +``` + +Without `GITHUB_ORG`, it shows the authenticated user's personal repos (including private ones the token has access to). + +**Available tools:** `get_my_repos`, `get_recent_activity`, `get_commit_activity`, `get_contributors` + +### Google Calendar + +Google Calendar uses the [`gws` CLI](https://github.com/googleworkspace/cli) for OAuth authentication. + +**1. Install the CLI:** +```bash +npm install -g @googleworkspace/cli +``` + +**2. Set up a Google Cloud project:** +```bash +gws auth setup +``` +This creates a GCP project and OAuth credentials. Requires the `gcloud` CLI. + +**3. Add yourself as a test user:** + +Since the OAuth app is in testing mode, you must add your Google account as a test user: +1. Open the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) for your project +2. Go to **Test users** > **Add users** +3. Enter your Google account email +4. Save + +**4. Authenticate:** +```bash +gws auth login -s calendar +``` +This opens a browser for OAuth consent. If you see "Google hasn't verified this app", click **Advanced** > **Go to [app name] (unsafe)** — this is safe for personal use. + +**5. Verify it works:** +```bash +gws calendar +agenda +``` + +No `.env` variable needed — `gws` stores encrypted credentials in `~/.config/gws/` and the dashboard reads them automatically. + +**Available tools:** `get_my_agenda`, `get_calendar_events` + +## Architecture + +``` +src/ +├── tools.ts # All tool definitions (single source of truth) +├── prompt-config.ts # System prompt with tool docs and rules +├── starters.ts # Starter prompts shown on the home screen +├── lib/ +│ ├── tool-def.ts # ToolDef class (shared schema for MCP + OpenAI) +│ ├── stripe-bridge.ts # Stripe REST API client +│ ├── posthog-bridge.ts # PostHog Query API client +│ ├── github-octokit.ts # GitHub API via Octokit (with cache + rate limiting) +│ ├── gws-bridge.ts # Google Workspace CLI subprocess wrapper +│ ├── sse-stream.ts # SSE streaming helper +│ └── ... +├── app/ +│ ├── api/ +│ │ ├── chat/route.ts # LLM chat endpoint (OpenAI function-calling) +│ │ └── mcp/route.ts # MCP server (exposes tools via MCP protocol) +│ └── dashboard/page.tsx # Main dashboard page +└── components/ + └── OpenUIDashboard/ # Dashboard UI components +``` + +**How it works:** + +1. User sends a prompt (e.g., "How's the business doing?") +2. The chat route sends it to the LLM with all tool definitions + system prompt +3. The LLM calls tools (e.g., `get_stripe_balance`, `get_product_trends`) via OpenAI function-calling +4. Tool results come back with real data from Stripe, PostHog, GitHub, or Calendar +5. The LLM generates openui-lang code that renders a live dashboard with the data +6. The frontend renders the dashboard using the OpenUI component library + +## Sample Prompts + +- "How's the business doing?" +- "Morning briefing — what do I need to know?" +- "Give me the TL;DR on my company today" +- "Show me MRR from Stripe, DAU from PostHog, and deploy status from GitHub" +- "Revenue + product metrics side by side, one view" +- "Who are my highest-paying customers and what features are they using?" +- "Give me a board-ready snapshot — revenue, growth, engineering velocity, and my calendar for prep time" +- "Build me an investor update dashboard I can screenshot" +- "Am I making money or just deploying code?" +- "Show me the truth about my startup" + +## Tool Reference + +### Stripe + +| Tool | Description | +|------|-------------| +| `get_stripe_balance` | Current account balance (available + pending). Amounts in cents. | +| `get_stripe_charges` | Recent charges/payments. Optional `limit`, `created_gte`. | +| `get_stripe_revenue` | Balance transactions (net revenue, fees, refunds). Optional `limit`, `type`, `created_gte`. | +| `get_stripe_subscriptions` | Active subscriptions for MRR. Optional `status`, `limit`. | + +### PostHog + +| Tool | Description | +|------|-------------| +| `get_product_trends` | Time-series trends (DAU, pageviews, any event). Optional `event`, `math`, `dateFrom`, `interval`. | +| `get_top_events` | Most common events. Returns `{ rows: [{ event, count }] }`. Optional `days`, `limit`. | +| `get_conversion_funnel` | Funnel analysis. Requires `steps` (event name array). Optional `dateFrom`. | +| `posthog_query` | Custom HogQL SQL. Tables: `events`, `persons`, `sessions`. | + +### GitHub + +| Tool | Description | +|------|-------------| +| `get_my_repos` | Authenticated user's repos (includes private). Sorted by recent push. Optional `perPage`. | +| `get_recent_activity` | Recent events: pushes, PRs, issues, reviews + summary counts. | +| `get_commit_activity` | Weekly commit counts for a repo (52 weeks). Requires `owner`, `repo`. | +| `get_contributors` | Top contributors by commit count. Requires `owner`, `repo`. | + +### Google Calendar + +| Tool | Description | +|------|-------------| +| `get_my_agenda` | Today's or upcoming events. Optional `today`, `days`. | +| `get_calendar_events` | Events for a date range. Optional `timeMin`, `timeMax`, `maxResults`. | + +## Learn More + +- [OpenUI Documentation](https://openui.com/docs) +- [OpenUI GitHub](https://github.com/thesysdev/openui) +- [Stripe API Reference](https://docs.stripe.com/api) +- [PostHog API Queries](https://posthog.com/docs/api/queries) +- [Octokit REST API](https://octokit.github.io/rest.js) +- [Google Workspace CLI](https://github.com/googleworkspace/cli) diff --git a/examples/multi-tool-dashboard/eslint.config.mjs b/examples/multi-tool-dashboard/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/examples/multi-tool-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/multi-tool-dashboard/next.config.ts b/examples/multi-tool-dashboard/next.config.ts new file mode 100644 index 000000000..7e1f0ae21 --- /dev/null +++ b/examples/multi-tool-dashboard/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + turbopack: {}, +}; + +export default nextConfig; diff --git a/examples/multi-tool-dashboard/package.json b/examples/multi-tool-dashboard/package.json new file mode 100644 index 000000000..8b9b8e99c --- /dev/null +++ b/examples/multi-tool-dashboard/package.json @@ -0,0 +1,37 @@ +{ + "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": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@openuidev/cli": "workspace:*", + "@openuidev/lang-core": "workspace:*", + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "octokit": "^5.0.5", + "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/multi-tool-dashboard/postcss.config.mjs b/examples/multi-tool-dashboard/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/multi-tool-dashboard/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/multi-tool-dashboard/src/app/api/chat/route.ts b/examples/multi-tool-dashboard/src/app/api/chat/route.ts new file mode 100644 index 000000000..6b5ca8086 --- /dev/null +++ b/examples/multi-tool-dashboard/src/app/api/chat/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; +import { generatePrompt } from "@openuidev/lang-core"; +import { promptSpec } from "@/prompt-config"; +import { tools as toolDefs } from "@/tools"; +import { sseResponseFromRunner } from "@/lib/sse-stream"; + +const tools = toolDefs.map((t) => t.toOpenAITool()); + +function buildSystemPrompt(): string { + return generatePrompt({ + ...promptSpec, + tools: toolDefs.map((t) => t.toToolSpec()), + }); +} + +export async function POST(req: NextRequest) { + const { messages } = (await req.json()) as { messages: ChatCompletionMessageParam[] }; + + const apiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || ""; + const baseURL = process.env.LLM_BASE_URL || "https://api.openai.com/v1"; + const model = process.env.LLM_MODEL || "gpt-5.4"; + + 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 }); + const runner = client.chat.completions.runTools({ + model, + messages: [{ role: "system" as const, content: buildSystemPrompt() }, ...messages], + tools, + stream: true, + }); + + return sseResponseFromRunner(runner); +} diff --git a/examples/multi-tool-dashboard/src/app/api/mcp/route.ts b/examples/multi-tool-dashboard/src/app/api/mcp/route.ts new file mode 100644 index 000000000..d12e4b3d5 --- /dev/null +++ b/examples/multi-tool-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/multi-tool-dashboard/src/app/dashboard/page.tsx b/examples/multi-tool-dashboard/src/app/dashboard/page.tsx new file mode 100644 index 000000000..bd986aa9b --- /dev/null +++ b/examples/multi-tool-dashboard/src/app/dashboard/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { library } from "@/library"; +import { OpenUIDashboard } from "@/components/OpenUIDashboard"; +import { DEFAULT_DASHBOARD } from "@/default-dashboard"; + +export default function DashboardPage() { + return ; +} diff --git a/examples/multi-tool-dashboard/src/app/globals.css b/examples/multi-tool-dashboard/src/app/globals.css new file mode 100644 index 000000000..975bcec94 --- /dev/null +++ b/examples/multi-tool-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/multi-tool-dashboard/src/app/layout.tsx b/examples/multi-tool-dashboard/src/app/layout.tsx new file mode 100644 index 000000000..7e44b0451 --- /dev/null +++ b/examples/multi-tool-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/multi-tool-dashboard/src/app/page.tsx b/examples/multi-tool-dashboard/src/app/page.tsx new file mode 100644 index 000000000..2faa5d843 --- /dev/null +++ b/examples/multi-tool-dashboard/src/app/page.tsx @@ -0,0 +1 @@ +export { default } from "./dashboard/page"; diff --git a/examples/multi-tool-dashboard/src/components/CalendarView/index.tsx b/examples/multi-tool-dashboard/src/components/CalendarView/index.tsx new file mode 100644 index 000000000..fb147eebf --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/CalendarView/index.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { defineComponent } from "@openuidev/react-lang"; +import React from "react"; +import { CalendarViewSchema } from "./schema"; + +export { CalendarViewSchema } from "./schema"; + +interface ParsedEvent { + summary: string; + date: Date; + status: string; +} + +function parseEventDate(start: unknown): Date | null { + if (!start) return null; + if (typeof start === "string") return new Date(start); + if (typeof start === "object" && start !== null) { + const s = start as Record; + const raw = s.dateTime ?? s.date; + if (typeof raw === "string") return new Date(raw); + } + return null; +} + +function parseEvents(events: unknown[]): ParsedEvent[] { + const result: ParsedEvent[] = []; + for (const ev of events) { + if (!ev || typeof ev !== "object") continue; + const e = ev as Record; + const date = parseEventDate(e.start); + if (!date || isNaN(date.getTime())) continue; + result.push({ + summary: String(e.summary ?? "Event"), + date, + status: String(e.status ?? "confirmed"), + }); + } + return result; +} + +function isSameDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +function CalendarGrid({ events }: { events: ParsedEvent[] }) { + const today = new Date(); + const [viewMonth, setViewMonth] = React.useState(today.getMonth()); + const [viewYear, setViewYear] = React.useState(today.getFullYear()); + const [selectedDay, setSelectedDay] = React.useState(null); + + const firstDay = new Date(viewYear, viewMonth, 1); + const startOffset = firstDay.getDay(); + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + + const eventsInMonth = events.filter( + (e) => e.date.getMonth() === viewMonth && e.date.getFullYear() === viewYear, + ); + + const eventsByDay = new Map(); + for (const e of eventsInMonth) { + const d = e.date.getDate(); + if (!eventsByDay.has(d)) eventsByDay.set(d, []); + eventsByDay.get(d)!.push(e); + } + + const totalCells = startOffset + daysInMonth; + const rows = Math.ceil(totalCells / 7); + + const prevMonth = () => { + if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } + else setViewMonth((m) => m - 1); + setSelectedDay(null); + }; + const nextMonth = () => { + if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } + else setViewMonth((m) => m + 1); + setSelectedDay(null); + }; + + const isToday = (day: number) => + today.getDate() === day && today.getMonth() === viewMonth && today.getFullYear() === viewYear; + + const selectedEvents = selectedDay ? eventsByDay.get(selectedDay) ?? [] : []; + + return ( +
+ {/* Month navigation */} +
+ + + {MONTHS[viewMonth]} {viewYear} + + +
+ + {/* Day-of-week headers */} +
+ {DAYS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Calendar grid */} +
+ {Array.from({ length: rows * 7 }, (_, i) => { + const dayNum = i - startOffset + 1; + const isValid = dayNum >= 1 && dayNum <= daysInMonth; + const dayEvents = isValid ? eventsByDay.get(dayNum) : undefined; + const hasEvents = dayEvents && dayEvents.length > 0; + const todayCell = isValid && isToday(dayNum); + const isSelected = isValid && selectedDay === dayNum; + + return ( +
{ if (isValid) setSelectedDay(isSelected ? null : dayNum); }} + style={{ + minHeight: "40px", + padding: "4px", + borderRadius: "6px", + cursor: isValid ? "pointer" : "default", + background: isSelected ? "#eff6ff" : todayCell ? "#f0fdf4" : isValid ? "#fafbfc" : "transparent", + border: isSelected ? "1px solid #3b82f6" : todayCell ? "1px solid #86efac" : "1px solid transparent", + transition: "all 0.1s", + position: "relative", + }} + > + {isValid && ( + <> +
+ {dayNum} +
+ {hasEvents && ( +
+ {dayEvents.slice(0, 3).map((_, j) => ( +
+ ))} + {dayEvents.length > 3 && ( + +{dayEvents.length - 3} + )} +
+ )} + + )} +
+ ); + })} +
+ + {/* Selected day event list */} + {selectedDay !== null && ( +
+
+ {MONTHS[viewMonth]} {selectedDay} +
+ {selectedEvents.length === 0 ? ( +
No events
+ ) : ( +
+ {selectedEvents.map((ev, i) => ( +
+
+
+
+ {ev.summary} +
+
+ {ev.date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+
+
+ ))} +
+ )} +
+ )} +
+ ); +} + +const navBtnStyle: React.CSSProperties = { + background: "none", + border: "1px solid #e5e7eb", + borderRadius: "6px", + padding: "4px 8px", + cursor: "pointer", + color: "#374151", + display: "flex", + alignItems: "center", +}; + +export const CalendarView = defineComponent({ + name: "CalendarView", + props: CalendarViewSchema, + description: + "Month-grid calendar view that displays events as dots on day cells. Click a day to see event details. Pass an array of event objects with summary and start (dateTime or date string).", + component: ({ props }) => { + const raw = Array.isArray(props.events) ? props.events : []; + const parsed = parseEvents(raw); + return ; + }, +}); diff --git a/examples/multi-tool-dashboard/src/components/CalendarView/schema.ts b/examples/multi-tool-dashboard/src/components/CalendarView/schema.ts new file mode 100644 index 000000000..2446a9091 --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/CalendarView/schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const CalendarViewSchema = z.object({ + events: z + .array( + z.object({ + summary: z.string().optional(), + start: z.any().optional(), + status: z.string().optional(), + }), + ) + .describe("Array of calendar event objects with summary, start (dateTime or date), and status"), + title: z.string().optional().describe("Optional header title shown above the calendar"), +}); diff --git a/examples/multi-tool-dashboard/src/components/OpenUIDashboard/ConversationPanel.tsx b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/ConversationPanel.tsx new file mode 100644 index 000000000..41464accd --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/ConversationPanel.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { MarkDownRenderer } from "@openuidev/react-ui"; +import { useDashboard } from "./context"; + +const SUGGESTIONS = [ + { label: "Revenue trend", prompt: "Add a revenue trend chart showing Stripe balance transactions over the last 30 days" }, + { label: "Conversion funnel", prompt: "Add a conversion funnel widget using PostHog with pageview, sign_up, and purchase steps" }, + { label: "Commit velocity", prompt: "Add a commit activity chart for my most active GitHub repo over the past year" }, + { label: "Weekly calendar", prompt: "Add a calendar widget showing my events for the next 7 days" }, + { label: "Recent charges", prompt: "Add a table of the 10 most recent Stripe charges with amount, status, and description" }, + { label: "Team contributors", prompt: "Add a contributor breakdown chart for my most active GitHub repo" }, +]; + +export function ConversationPanel() { + const { + conversation, isStreaming, streamingText, streamingHasCode, + llmTools, toolCalls, elapsed, dashboardCode, send, + } = useDashboard(); + const [input, setInput] = useState(""); + const inputRef = useRef(null); + const chatEndRef = useRef(null); + + const hasDashboard = dashboardCode !== null; + const canSend = input.trim().length > 0 && !isStreaming; + const pendingTools = toolCalls.filter((t) => t.status === "pending"); + const isEmpty = conversation.length === 0 && !isStreaming; + + useEffect(() => { inputRef.current?.focus(); }, [isStreaming]); + useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [conversation]); + + const handleSend = () => { + if (!canSend) return; + send(input); + setInput(""); + }; + + return ( +
+
+ Conversation +
+ +
+ {isEmpty && ( +
+
+ Add a widget to your dashboard +
+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ )} + + {conversation.map((msg, i) => ( +
+ {msg.role === "user" ? ( +
{msg.content}
+ ) : ( +
+ {msg.llmTools && msg.llmTools.length > 0 && ( +
+
+ 🔍 Queried data +
+
+ {msg.llmTools.map((tc, j) => ( + ✓ {tc.name} + ))} +
+
+ )} + {msg.text && ( +
+ +
+ )} + {msg.hasCode && ( +
✓ dashboard updated
+ )} + {msg.runtimeTools && msg.runtimeTools.length > 0 && ( +
+
+ Live data fetched +
+
+ {msg.runtimeTools.map((tc, j) => ( + {tc.status === "done" ? "✓" : tc.status === "error" ? "✗" : "⏳"} {tc.tool} + ))} +
+
+ )} + {!msg.text && !msg.hasCode && !msg.llmTools?.length && ( +
+ (empty response) +
+ )} +
+ )} +
+ ))} + + {isStreaming && ( +
+ {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" : ""}... +
+ )} + {streamingText ? ( +
+ +
+ ) : ( +
+ {llmTools.length > 0 && llmTools.some((t) => t.status === "calling") + ? "fetching data before generating..." + : elapsed + ? `${(elapsed / 1000).toFixed(1)}s — ${streamingHasCode ? "writing code..." : "thinking..."}` + : "thinking..."} +
+ )} + {streamingHasCode && ( +
⟳ updating dashboard...
+ )} + {toolCalls.length > 0 && ( +
+
+ + {pendingTools.length > 0 ? "Fetching" : "Loaded"} + + {toolCalls.map((tc, j) => ( + {tc.status === "pending" ? "⏳" : "✓"} {tc.tool} + ))} +
+
+ )} +
+ )} +
+
+ +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSend(); }} + placeholder={hasDashboard ? "Add a widget or ask a question..." : "Describe a dashboard..."} + disabled={isStreaming} + style={{ + flex: 1, padding: "8px 12px", border: "1px solid #d1d5db", + borderRadius: "8px", fontSize: "13px", outline: "none", + }} + /> + +
+
+
+ ); +} diff --git a/examples/multi-tool-dashboard/src/components/OpenUIDashboard/DashboardCanvas.tsx b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/DashboardCanvas.tsx new file mode 100644 index 000000000..1a3d25433 --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/DashboardCanvas.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { Renderer } from "@openuidev/react-lang"; +import type { Library } from "@openuidev/react-lang"; +import { ThemeProvider } from "@openuidev/react-ui"; +import { useDashboard } from "./context"; + +interface DashboardCanvasProps { + library: Library; +} + +export function DashboardCanvas({ library }: DashboardCanvasProps) { + const { dashboardCode, isStreaming, elapsed, toolProvider, send } = useDashboard(); + const [showSource, setShowSource] = useState(false); + + if (!dashboardCode && !isStreaming) return null; + + return ( + <> + {dashboardCode && !isStreaming && ( +
+ {elapsed && {(elapsed / 1000).toFixed(1)}s} + +
+ )} + + {dashboardCode && showSource && ( +
{dashboardCode}
+ )} + + {dashboardCode && ( +
+ + + } + onAction={(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); + } + }} + /> + +
+ )} + + {isStreaming && !dashboardCode && ( +
+
Generating dashboard...
+ {elapsed &&
{(elapsed / 1000).toFixed(1)}s
} +
+ )} + + ); +} diff --git a/examples/multi-tool-dashboard/src/components/OpenUIDashboard/context.tsx b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/context.tsx new file mode 100644 index 000000000..f266d4d2d --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/context.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { McpClientLike } from "@openuidev/react-lang"; +import { mergeStatements } from "@openuidev/react-lang"; +import { wrapMcpClient } from "@/lib/mcp-tracker"; +import type { ToolCallEntry } from "@/lib/mcp-tracker"; +import { streamChat } from "@/lib/llm-stream"; +import type { LLMToolCall } from "@/lib/llm-stream"; +import { extractCodeOnly, extractText, responseHasCode, isPureCode } from "@/lib/response-parser"; +import type { ChatMessage } from "./types"; + +// ── Context shape ──────────────────────────────────────────────────────────── + +interface DashboardContextValue { + conversation: ChatMessage[]; + dashboardCode: string | null; + isStreaming: boolean; + streamingText: string; + streamingHasCode: boolean; + elapsed: number | null; + llmTools: LLMToolCall[]; + toolCalls: ToolCallEntry[]; + toolProvider: McpClientLike | null; + send: (text: string) => void; + clear: () => void; +} + +const DashboardContext = createContext(null); + +export function useDashboard(): DashboardContextValue { + const ctx = useContext(DashboardContext); + if (!ctx) throw new Error("useDashboard must be used within DashboardProvider"); + return ctx; +} + +// ── Provider ───────────────────────────────────────────────────────────────── + +interface DashboardProviderProps { + chatEndpoint: string; + mcpEndpoint: string; + initialDashboardCode?: string; + children: ReactNode; +} + +export function DashboardProvider({ chatEndpoint, mcpEndpoint, initialDashboardCode, children }: DashboardProviderProps) { + const [dashboardCode, setDashboardCode] = useState(initialDashboardCode ?? null); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingHasCode, setStreamingHasCode] = 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 [toolProvider, setToolProvider] = useState(null); + const abortRef = useRef(null); + const responseRef = useRef(""); + const llmToolCallsRef = useRef([]); + const toolCallsRef = useRef([]); + const clientRef = useRef(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); + const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); + const client = new Client({ name: "openui-dashboard", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(mcpEndpoint, globalThis.location.href)); + await client.connect(transport); + if (cancelled) { client.close?.(); return; } + const mcpClient = client as unknown as McpClientLike; + clientRef.current = mcpClient; + setToolProvider(wrapMcpClient(mcpClient, (calls) => { + toolCallsRef.current = calls; + setToolCalls(calls); + setConversation((prev) => { + if (prev.length === 0) return prev; + const last = prev[prev.length - 1]; + if (last.role !== "assistant") return prev; + return [...prev.slice(0, -1), { ...last, runtimeTools: calls }]; + }); + })); + } catch (err) { + console.error("[mcp] Failed:", err); + } + })(); + return () => { cancelled = true; clientRef.current?.close?.(); }; + }, [mcpEndpoint]); + + useEffect(() => { + const p = new URLSearchParams(window.location.search).get("code"); + if (p) { try { setDashboardCode(atob(p)); } catch { /* */ } } + }, []); + + useEffect(() => { + if (!isStreaming || !startTime) return; + const iv = setInterval(() => setElapsed(Date.now() - startTime), 100); + return () => clearInterval(iv); + }, [isStreaming, startTime]); + + const send = useCallback( + async (text: string) => { + if (!text.trim() || isStreaming) return; + const trimmed = text.trim(); + setIsStreaming(true); + setStreamingHasCode(false); + setStartTime(null); + setElapsed(null); + toolCallsRef.current = []; + setToolCalls([]); + responseRef.current = ""; + llmToolCallsRef.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; + + const apiMessages = updated.map((m, i) => { + if (m.role === "assistant" && m.llmTools?.length) { + const toolSummary = m.llmTools + .map((tc) => { + const snippet = tc.result ? ` → ${tc.result.slice(0, 500)}` : " → completed"; + return `[Tool: ${tc.name}${snippet}]`; + }) + .join("\n"); + return { role: m.role, content: `${toolSummary}\n\n${m.content}` }; + } + 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( + chatEndpoint, + apiMessages, + (chunk) => { + responseRef.current += chunk; + const raw = responseRef.current; + setStreamingText(extractText(raw) || ""); + if (responseHasCode(raw)) setStreamingHasCode(true); + setDashboardCode(existingCode ? existingCode + "\n" + raw : 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 llmToolsCurrent = llmToolCallsRef.current; + + setConversation((prev) => [ + ...prev, + { + role: "assistant", + content: raw, + text, + hasCode, + llmTools: llmToolsCurrent.length > 0 ? [...llmToolsCurrent] : undefined, + runtimeTools: toolCallsRef.current.length > 0 ? [...toolCallsRef.current] : undefined, + }, + ]); + + if (hasCode) { + const newCode = pureCode ? raw : extractCodeOnly(raw); + if (newCode) { + setDashboardCode(existingCode ? mergeStatements(existingCode, newCode) : newCode); + } + } + }, + (calls) => { + llmToolCallsRef.current = calls; + setLlmTools(calls); + }, + controller.signal, + () => { streamStartTime = Date.now(); setStartTime(streamStartTime); }, + ); + }, + [isStreaming, conversation, dashboardCode, chatEndpoint], + ); + + const clear = () => { + abortRef.current?.abort(); + setDashboardCode(initialDashboardCode ?? null); + setConversation([]); + setIsStreaming(false); + setStreamingHasCode(false); + setStartTime(null); + setElapsed(null); + responseRef.current = ""; + }; + + return ( + + {children} + + ); +} diff --git a/examples/multi-tool-dashboard/src/components/OpenUIDashboard/index.tsx b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/index.tsx new file mode 100644 index 000000000..d6e2cc8e7 --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/index.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { Library } from "@openuidev/react-lang"; +import "@openuidev/react-ui/components.css"; +import { DashboardProvider, useDashboard } from "./context"; +import { ConversationPanel } from "./ConversationPanel"; +import { DashboardCanvas } from "./DashboardCanvas"; + +export { useDashboard } from "./context"; + +// ── Internal layout ─────────────────────────────────────────────────────────── + +function DashboardLayout({ library }: { library: Library }) { + const { conversation, dashboardCode, isStreaming, clear } = useDashboard(); + 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: dashboard + always-visible chat sidebar */} +
+
+ +
+ +
+
+ ); +} + +// ── Public component ────────────────────────────────────────────────────────── + +export interface OpenUIDashboardProps { + library: Library; + initialDashboardCode?: string; + chatEndpoint?: string; + mcpEndpoint?: string; +} + +export function OpenUIDashboard({ + library, + initialDashboardCode, + chatEndpoint = "/api/chat", + mcpEndpoint = "/api/mcp", +}: OpenUIDashboardProps) { + return ( + + + + ); +} diff --git a/examples/multi-tool-dashboard/src/components/OpenUIDashboard/types.ts b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/types.ts new file mode 100644 index 000000000..e80ae93a9 --- /dev/null +++ b/examples/multi-tool-dashboard/src/components/OpenUIDashboard/types.ts @@ -0,0 +1,11 @@ +import type { LLMToolCall } from "@/lib/llm-stream"; +import type { ToolCallEntry } from "@/lib/mcp-tracker"; + +export interface ChatMessage { + role: "user" | "assistant"; + content: string; + text?: string; + hasCode: boolean; + llmTools?: LLMToolCall[]; + runtimeTools?: ToolCallEntry[]; +} diff --git a/examples/multi-tool-dashboard/src/default-dashboard.ts b/examples/multi-tool-dashboard/src/default-dashboard.ts new file mode 100644 index 000000000..ad32c423b --- /dev/null +++ b/examples/multi-tool-dashboard/src/default-dashboard.ts @@ -0,0 +1,59 @@ +/** + * Hardcoded openui-lang DSL for the default dashboard. + * + * Covers all four data sources: Stripe, PostHog, GitHub, and Google Calendar. + * Query() components fetch live data at runtime via the MCP toolProvider — + * no LLM call is needed for this initial view. + */ + +export const DEFAULT_DASHBOARD = `\ +root = Stack([header, kpiRow, mainRow1, mainRow2]) +header = CardHeader("Dashboard", "Live data from Stripe, PostHog, GitHub, and Google Calendar") + +bal = Query("get_stripe_balance", {}, {available: [{amount: 0, currency: "usd"}], pending: [{amount: 0, currency: "usd"}]}) +subs = Query("get_stripe_subscriptions", {limit: 20}, {data: []}) +$chargeLimit = "10" +charges = Query("get_stripe_charges", {limit: $chargeLimit}, {data: []}) +$dateFrom = "-30d" +$dauMetric = "dau" +trends = Query("get_product_trends", {event: "$pageview", math: $dauMetric, dateFrom: $dateFrom}, {results: []}) +repos = Query("get_my_repos", {perPage: 100}, {repos: []}) +activity = Query("get_recent_activity", {}, {rows: [], summary: {total: 0, push: 0, pr: 0, issues: 0, reviews: 0}}) +calEvents = Query("get_calendar_events", {maxResults: 20}, {events: {items: []}}) + +kpiRow = Stack([balCard, subsCard, activityCard, repoCountCard], "row", "m", "stretch", "start", true) +balCard = Card([TextContent("Stripe Balance", "small"), TextContent("$" + @Round(bal.available[0].amount / 100, 2), "large-heavy")]) +subsCard = Card([TextContent("Active Subscriptions", "small"), TextContent("" + subs.data.length, "large-heavy")]) +activityCard = Card([TextContent("GitHub Events", "small"), TextContent("" + activity.summary.total, "large-heavy")]) +repoCountCard = Card([TextContent("Active Repos", "small"), TextContent("" + repos.repos.length, "large-heavy")]) + +mainRow1 = Stack([dauCard, leaderboardCard], "row", "m", "stretch") +r7 = SelectItem("-7d", "Last 7 days") +r14 = SelectItem("-14d", "Last 14 days") +r30 = SelectItem("-30d", "Last 30 days") +r90 = SelectItem("-90d", "Last 90 days") +mDau = SelectItem("dau", "DAU") +mTotal = SelectItem("total", "Total") +mWeekly = SelectItem("weekly_active", "Weekly Active") +dauFilters = Stack([FormControl("Date Range", Select("dateFrom", [r7, r14, r30, r90], null, null, $dateFrom)), FormControl("Metric", Select("dauMetric", [mDau, mTotal, mWeekly], null, null, $dauMetric))], "row", "s", "end") +dauCard = Card([CardHeader("Product Analytics from PostHog"), dauFilters, AreaChart(trends.results[0].labels, [Series("Users", trends.results[0].data)], "natural")]) + +$sortField = "stars" +sortStars = SelectItem("stars", "Stars") +sortForks = SelectItem("forks", "Forks") +sortIssues = SelectItem("open_issues", "Open Issues") +$repoSearch = "" +repoFilters = Stack([FormControl("Sort by", Select("sortField", [sortStars, sortForks, sortIssues], null, null, $sortField)), FormControl("Search", Input("repoSearch", "Filter repos...", "text", null, $repoSearch))], "row", "s", "end") +sorted = @Sort(repos.repos, $sortField, "desc") +filtered = $repoSearch != "" ? @Filter(sorted, "name", "contains", $repoSearch) : sorted +leaderboardCard = Card([CardHeader("🏆 Repository Leaderboard from GitHub"), repoFilters, Table([Col("Repository", filtered.name), Col("Language", @Each(filtered, "r", Tag(r.language, null, "sm"))), Col("⭐ Stars", filtered.stars, "number"), Col("🍴 Forks", filtered.forks, "number"), Col("Open Issues", filtered.open_issues, "number"), Col("Open", @Each(filtered, "r", TextContent("[↗](https://github.com/" + r.full_name + ")")))], 5)]) + +mainRow2 = Stack([chargesCard, calendarCard], "row", "m", "stretch") +cl5 = SelectItem("5", "Last 5") +cl10 = SelectItem("10", "Last 10") +cl25 = SelectItem("25", "Last 25") +chargeFilter = FormControl("Show", Select("chargeLimit", [cl5, cl10, cl25], null, null, $chargeLimit)) +chargesCard = Card([CardHeader("Charges by Status from Stripe"), chargeFilter, PieChart(charges.data.status, charges.data.amount, "donut")]) + +calendarCard = Card([CardHeader("Upcoming Events from Google Calendar"), CalendarView(calEvents.events.items)]) +`; diff --git a/examples/multi-tool-dashboard/src/generated/component-spec.json b/examples/multi-tool-dashboard/src/generated/component-spec.json new file mode 100644 index 000000000..b911406c1 --- /dev/null +++ b/examples/multi-tool-dashboard/src/generated/component-spec.json @@ -0,0 +1,387 @@ +{ + "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[], pageSize?: number)", + "description": "Data table — column-oriented. Each Col holds its own data array. Optional pageSize controls rows per page." + }, + "Col": { + "signature": "Col(label: string, data: any, 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?: FormControl[])", + "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, 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}, value?: $binding)", + "description": "" + }, + "TextArea": { + "signature": "TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", + "description": "" + }, + "Select": { + "signature": "Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", + "description": "" + }, + "SelectItem": { + "signature": "SelectItem(value: string, label: string)", + "description": "Option for Select" + }, + "DatePicker": { + "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", + "description": "" + }, + "Slider": { + "signature": "Slider(name: string, variant: \"continuous\" | \"discrete\", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", + "description": "Numeric slider input; supports continuous and discrete (stepped) variants" + }, + "CheckBoxGroup": { + "signature": "CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>)", + "description": "" + }, + "CheckBoxItem": { + "signature": "CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean)", + "description": "" + }, + "RadioGroup": { + "signature": "RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", + "description": "" + }, + "RadioItem": { + "signature": "RadioItem(label: string, description: string, value: string)", + "description": "" + }, + "SwitchGroup": { + "signature": "SwitchGroup(name: string, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\", value?: $binding>)", + "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: any[], 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." + }, + "CalendarView": { + "signature": "CalendarView(events: {summary?: string, start?: any, status?: string}[], title?: string)", + "description": "Month-grid calendar view that displays events as dots on day cells. Click a day to see event details. Pass an array of event objects with summary and start (dateTime or date string)." + } + }, + "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\")" + ] + }, + { + "name": "Calendar", + "components": [ + "CalendarView" + ], + "notes": [ + "- CalendarView renders a month-grid calendar with event dots. Pass an array of event objects: CalendarView(events)", + "- Each event needs summary (string) and start (object with dateTime or date string)", + "- Click a day cell to see event details below the calendar" + ] + } + ] +} diff --git a/examples/multi-tool-dashboard/src/hooks/use-system-theme.tsx b/examples/multi-tool-dashboard/src/hooks/use-system-theme.tsx new file mode 100644 index 000000000..2a95a534d --- /dev/null +++ b/examples/multi-tool-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/multi-tool-dashboard/src/lib/github-octokit.ts b/examples/multi-tool-dashboard/src/lib/github-octokit.ts new file mode 100644 index 000000000..abe842d93 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/github-octokit.ts @@ -0,0 +1,298 @@ +/** + * GitHub API bridge via Octokit — direct REST calls with PAT auth. + * + * Modeled after docs/app/demo/github/github/tools.ts but adapted for + * server-side use with PAT authentication (supports private repos). + * + * Requires: GITHUB_PERSONAL_ACCESS_TOKEN env var. + * Gracefully returns { error: "..." } when missing. + */ +import { Octokit } from "octokit"; + +// ── Cache ────────────────────────────────────────────────────────────────── + +const cache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; + +function cached(key: string, fn: () => Promise): Promise { + const now = Date.now(); + const entry = cache.get(key); + if (entry && now - entry.ts < CACHE_TTL) return Promise.resolve(entry.data as T); + + if (cache.size > 50) { + for (const [k, v] of cache) { + if (now - v.ts >= CACHE_TTL) cache.delete(k); + } + } + + return fn().then((data) => { + cache.set(key, { data, ts: now }); + return data; + }); +} + +// ── Rate limit tracking ──────────────────────────────────────────────────── + +let rateLimitRemaining = 5000; // PAT gets 5000/hr vs 60 unauthenticated +let rateLimitReset = 0; + +function checkRateLimit(): string | null { + if (rateLimitRemaining <= 0 && Date.now() / 1000 < rateLimitReset) { + const mins = Math.ceil((rateLimitReset - Date.now() / 1000) / 60); + return `GitHub API rate limit exceeded. Try again in ${mins} minute${mins > 1 ? "s" : ""}.`; + } + return null; +} + +function updateRateLimit(response: { headers?: Record }) { + const h = response?.headers; + if (!h) return; + const remaining = h["x-ratelimit-remaining"]; + const reset = h["x-ratelimit-reset"]; + if (remaining != null) { + const n = parseInt(String(remaining), 10); + if (!isNaN(n)) rateLimitRemaining = n; + } + if (reset != null) { + const n = parseInt(String(reset), 10); + if (!isNaN(n)) rateLimitReset = n; + } +} + +// ── Retry for 202 (computing stats) ──────────────────────────────────────── + +async function fetchWithRetry( + octokit: Octokit, + route: string, + params: Record, + retries = 3, +): Promise { + for (let i = 0; i < retries; i++) { + const res = await octokit.request(route, params); + updateRateLimit(res as { headers?: Record }); + if (res.status !== 202) return res.data; + await new Promise((r) => setTimeout(r, 1500)); + } + return { error: "GitHub is still computing stats. Try again in a moment." }; +} + +// ── Octokit singleton ────────────────────────────────────────────────────── + +function getOctokit(): Octokit | null { + const token = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + if (!token) return null; + return new Octokit({ auth: token }); +} + +function getOrg(): string | undefined { + return process.env.GITHUB_ORG || undefined; +} + +let _usernameCache: string | null = null; +async function getAuthenticatedUsername(octokit: Octokit): Promise { + if (_usernameCache) return _usernameCache; + const res = await octokit.rest.users.getAuthenticated(); + updateRateLimit(res as { headers?: Record }); + _usernameCache = res.data.login; + return _usernameCache; +} + +// ── Exported tool functions ──────────────────────────────────────────────── + +export interface RepoRow { + name: string; + full_name: string; + description: string; + language: string; + stars: number; + forks: number; + open_issues: number; + updated_at: string; + is_private: boolean; +} + +export async function getMyRepos(perPage = 10): Promise<{ repos: RepoRow[]; error?: string }> { + const octokit = getOctokit(); + if (!octokit) return { repos: [], error: "Set GITHUB_PERSONAL_ACCESS_TOKEN env var" }; + const org = getOrg(); + + return cached(`repos:${org ?? "user"}:${perPage}`, async () => { + const limitErr = checkRateLimit(); + if (limitErr) return { repos: [], error: limitErr }; + + try { + let res; + if (org) { + try { + res = await octokit.rest.repos.listForOrg({ org, sort: "pushed", per_page: Math.min(perPage, 100) }); + } catch { + console.warn(`[github] Org repos failed for "${org}", falling back to user repos`); + res = await octokit.rest.repos.listForAuthenticatedUser({ sort: "pushed", per_page: Math.min(perPage, 100) }); + } + } else { + res = await octokit.rest.repos.listForAuthenticatedUser({ sort: "pushed", per_page: Math.min(perPage, 100) }); + } + updateRateLimit(res as { headers?: Record }); + + const repos: RepoRow[] = res.data.map((r) => ({ + name: r.name, + full_name: r.full_name, + description: r.description ?? "", + language: r.language ?? "", + stars: r.stargazers_count ?? 0, + forks: r.forks_count ?? 0, + open_issues: r.open_issues_count ?? 0, + updated_at: r.pushed_at?.slice(0, 10) ?? "", + is_private: r.private, + })); + return { repos }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { repos: [], error: msg }; + } + }); +} + +export interface ActivityRow { + type: string; + repo: string; + date: string; +} + +export interface ActivitySummary { + total: number; + push: number; + pr: number; + issues: number; + reviews: number; +} + +export async function getRecentActivity(): Promise<{ + rows: ActivityRow[]; + summary: ActivitySummary; + error?: string; +}> { + const octokit = getOctokit(); + const empty = { rows: [] as ActivityRow[], summary: { total: 0, push: 0, pr: 0, issues: 0, reviews: 0 } }; + if (!octokit) return { ...empty, error: "Set GITHUB_PERSONAL_ACCESS_TOKEN env var" }; + const org = getOrg(); + + return cached(`activity:${org ?? "user"}`, async () => { + const limitErr = checkRateLimit(); + if (limitErr) return { ...empty, error: limitErr }; + + try { + const username = await getAuthenticatedUsername(octokit); + let res; + if (org) { + try { + res = await octokit.rest.activity.listOrgEventsForAuthenticatedUser({ org, username, per_page: 100 }); + } catch { + console.warn(`[github] Org events failed for "${org}", falling back to user events`); + res = await octokit.rest.activity.listEventsForAuthenticatedUser({ username, per_page: 100 }); + } + } else { + res = await octokit.rest.activity.listEventsForAuthenticatedUser({ username, per_page: 100 }); + } + updateRateLimit(res as { headers?: Record }); + + const rows: ActivityRow[] = res.data.map((e) => ({ + type: e.type?.replace("Event", "") ?? "Unknown", + repo: (e.repo as { name?: string })?.name?.split("/")[1] ?? "", + date: e.created_at?.slice(0, 10) ?? "", + })); + + const summary: ActivitySummary = { total: rows.length, push: 0, pr: 0, issues: 0, reviews: 0 }; + for (const r of rows) { + if (r.type === "Push") summary.push++; + else if (r.type === "PullRequest") summary.pr++; + else if (r.type === "Issues") summary.issues++; + else if (r.type === "PullRequestReview") summary.reviews++; + } + + return { rows, summary }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { ...empty, error: msg }; + } + }); +} + +export interface CommitWeek { + week: string; + total: number; +} + +export async function getCommitActivity( + owner: string, + repo: string, +): Promise<{ rows: CommitWeek[]; error?: string }> { + const octokit = getOctokit(); + if (!octokit) return { rows: [], error: "Set GITHUB_PERSONAL_ACCESS_TOKEN env var" }; + + return cached(`commits:${owner}/${repo}`, async () => { + const limitErr = checkRateLimit(); + if (limitErr) return { rows: [], error: limitErr }; + + try { + const data = await fetchWithRetry( + octokit, + "GET /repos/{owner}/{repo}/stats/commit_activity", + { owner, repo }, + ); + + if (!Array.isArray(data)) return data as { rows: CommitWeek[]; error?: string }; + + const rows: CommitWeek[] = data.map((w: { week: number; total: number }) => ({ + week: new Date(w.week * 1000).toISOString().slice(0, 10), + total: w.total, + })); + + return { rows }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { rows: [], error: msg }; + } + }); +} + +export interface ContributorRow { + login: string; + total_commits: number; +} + +export async function getContributors( + owner: string, + repo: string, +): Promise<{ rows: ContributorRow[]; error?: string }> { + const octokit = getOctokit(); + if (!octokit) return { rows: [], error: "Set GITHUB_PERSONAL_ACCESS_TOKEN env var" }; + + return cached(`contrib:${owner}/${repo}`, async () => { + const limitErr = checkRateLimit(); + if (limitErr) return { rows: [], error: limitErr }; + + try { + const data = await fetchWithRetry( + octokit, + "GET /repos/{owner}/{repo}/stats/contributors", + { owner, repo }, + ); + + if (!Array.isArray(data)) return data as { rows: ContributorRow[]; error?: string }; + + const rows: ContributorRow[] = data + .map((c: { author?: { login?: string }; total?: number }) => ({ + login: c.author?.login ?? "unknown", + total_commits: c.total ?? 0, + })) + .sort((a: ContributorRow, b: ContributorRow) => b.total_commits - a.total_commits) + .slice(0, 20); + + return { rows }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { rows: [], error: msg }; + } + }); +} diff --git a/examples/multi-tool-dashboard/src/lib/gws-bridge.ts b/examples/multi-tool-dashboard/src/lib/gws-bridge.ts new file mode 100644 index 000000000..3306173e5 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/gws-bridge.ts @@ -0,0 +1,42 @@ +/** + * gws CLI bridge — executes Google Workspace CLI commands and parses JSON output. + * + * The `gws` binary must be installed (`npm i -g @googleworkspace/cli`) + * and authenticated (`gws auth setup && gws auth login -s calendar`). + * + * Gracefully degrades: returns an error string if gws is missing or auth fails. + */ +import { execFile } from "node:child_process"; + +export async function runGws(args: string[]): Promise { + return new Promise((resolve) => { + execFile("gws", args, { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) { + const msg = stderr?.trim() || err.message; + if (msg.includes("ENOENT") || msg.includes("not found")) { + resolve({ error: "gws CLI not found — run: npm install -g @googleworkspace/cli" }); + return; + } + if (msg.includes("auth") || msg.includes("credential") || msg.includes("token")) { + resolve({ error: "gws not authenticated — run: gws auth setup && gws auth login -s calendar" }); + return; + } + console.error(`[gws] Command failed: gws ${args.join(" ")}`, msg); + resolve({ error: `gws error: ${msg}` }); + return; + } + + const text = stdout.trim(); + if (!text) { + resolve({ error: "gws returned empty response" }); + return; + } + + try { + resolve(JSON.parse(text)); + } catch { + resolve({ raw: text }); + } + }); + }); +} diff --git a/examples/multi-tool-dashboard/src/lib/llm-stream.ts b/examples/multi-tool-dashboard/src/lib/llm-stream.ts new file mode 100644 index 000000000..afbadd8e2 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/llm-stream.ts @@ -0,0 +1,87 @@ +export type LLMToolCall = { id: string; name: string; status: "calling" | "done"; result?: string }; + +export async function streamChat( + endpoint: string, + messages: Array<{ role: string; content: string }>, + onChunk: (text: string) => void, + onDone: (usage?: { prompt_tokens?: number; completion_tokens?: number }) => void, + onToolCall: (calls: LLMToolCall[]) => void, + signal?: AbortSignal, + onFirstChunk?: () => void, +) { + const activeCalls: LLMToolCall[] = []; + onToolCall([]); + + const res = await fetch(endpoint, { + 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 activeCalls) { + if (tc.status === "calling") tc.status = "done"; + } + onToolCall([...activeCalls]); + 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) { + activeCalls.push({ id: tc.id, name: tc.function.name, status: "calling" }); + onToolCall([...activeCalls]); + } else if (tc.function?.arguments) { + const existing = activeCalls[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 */ } + onToolCall([...activeCalls]); + } + } + } + } + const content = chunk.choices?.[0]?.delta?.content; + if (content) { + if (!firstChunkFired) { firstChunkFired = true; onFirstChunk?.(); } + onChunk(content); + } + if (chunk.usage) lastUsage = chunk.usage; + } catch { /* skip malformed chunks */ } + } + } + onDone(lastUsage); +} diff --git a/examples/multi-tool-dashboard/src/lib/mcp-tracker.ts b/examples/multi-tool-dashboard/src/lib/mcp-tracker.ts new file mode 100644 index 000000000..ea5addd38 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/mcp-tracker.ts @@ -0,0 +1,28 @@ +import type { McpClientLike } from "@openuidev/react-lang"; + +export type ToolCallEntry = { tool: string; status: "pending" | "done" | "error" }; + +export function wrapMcpClient( + client: McpClientLike, + onToolCall: (calls: ToolCallEntry[]) => void, +): McpClientLike { + const activeCalls: ToolCallEntry[] = []; + return { + ...client, + callTool: async (params, options) => { + const entry: ToolCallEntry = { tool: params.name, status: "pending" }; + activeCalls.push(entry); + onToolCall([...activeCalls]); + try { + const result = await client.callTool(params, options); + entry.status = "done"; + onToolCall([...activeCalls]); + return result; + } catch (err) { + entry.status = "error"; + onToolCall([...activeCalls]); + throw err; + } + }, + }; +} diff --git a/examples/multi-tool-dashboard/src/lib/posthog-bridge.ts b/examples/multi-tool-dashboard/src/lib/posthog-bridge.ts new file mode 100644 index 000000000..ea55053c7 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/posthog-bridge.ts @@ -0,0 +1,47 @@ +/** + * PostHog API bridge — queries PostHog analytics via the Query API. + * + * Requires env vars: + * POSTHOG_API_KEY — personal API key (scope: insight:read) + * POSTHOG_PROJECT_ID — numeric project ID (from project settings) + * POSTHOG_HOST — API host (default: https://us.posthog.com) + * + * Gracefully degrades: returns an error object if credentials are missing. + */ + +function getConfig() { + const apiKey = process.env.POSTHOG_API_KEY; + const projectId = process.env.POSTHOG_PROJECT_ID; + const host = (process.env.POSTHOG_HOST || "https://us.posthog.com").replace(/\/$/, ""); + return { apiKey, projectId, host }; +} + +export async function posthogQuery(query: Record): Promise { + const { apiKey, projectId, host } = getConfig(); + if (!apiKey || !projectId) { + return { error: "PostHog not configured — set POSTHOG_API_KEY and POSTHOG_PROJECT_ID env vars" }; + } + + try { + const res = await fetch(`${host}/api/projects/${projectId}/query/`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[posthog] Query failed (${res.status}):`, text.slice(0, 500)); + return { error: `PostHog API ${res.status}: ${text.slice(0, 200)}` }; + } + + return await res.json(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[posthog] Request failed:", msg); + return { error: `PostHog request failed: ${msg}` }; + } +} diff --git a/examples/multi-tool-dashboard/src/lib/response-parser.ts b/examples/multi-tool-dashboard/src/lib/response-parser.ts new file mode 100644 index 000000000..9744e4030 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/response-parser.ts @@ -0,0 +1,39 @@ +export function extractCodeOnly(response: string): string | null { + 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"); + + const unclosedMatch = response.match(/```[\w-]*\n([\s\S]*)$/); + if (unclosedMatch) return unclosedMatch[1].trim() || null; + + if (isPureCode(response)) return response; + + return null; +} + +export function extractText(response: string): string { + const withoutFences = response.replace(/```[\w-]*\n[\s\S]*?```/g, "").trim(); + const withoutUnclosed = withoutFences.replace(/```[\w-]*\n[\s\S]*$/g, "").trim(); + if (withoutUnclosed && isPureCode(withoutUnclosed)) return ""; + return withoutUnclosed; +} + +export function responseHasCode(response: string): boolean { + if (/```[\w-]*\n/.test(response)) return true; + if (/^[a-zA-Z_$][\w$]*\s*=\s*/.test(response.trim())) return true; + return false; +} + +export function isPureCode(response: string): boolean { + const trimmed = response.trim(); + if (/```/.test(trimmed)) return false; + const lines = trimmed.split("\n").filter((l) => l.trim()); + if (lines.length === 0) return false; + const stmtPattern = /^[a-zA-Z_$][\w$]*\s*=/; + const stmtCount = lines.filter((l) => stmtPattern.test(l.trim())).length; + return stmtCount / lines.length > 0.7; +} diff --git a/examples/multi-tool-dashboard/src/lib/sse-stream.ts b/examples/multi-tool-dashboard/src/lib/sse-stream.ts new file mode 100644 index 000000000..aedfe4c50 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/sse-stream.ts @@ -0,0 +1,115 @@ +import type { ChatCompletionStreamingRunner } from "openai/lib/ChatCompletionStreamingRunner"; + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", +} as const; + +function sseEvent(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function toolCallStartChunk(id: string, name: string, index: number) { + return { + id: `chatcmpl-tc-${id}`, + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index, id, type: "function", function: { name, arguments: "" } }], + }, + finish_reason: null, + }, + ], + }; +} + +function toolCallArgsChunk(id: string, rawArgs: string, result: string, index: number) { + let enrichedArgs: string; + try { + enrichedArgs = JSON.stringify({ + _request: JSON.parse(rawArgs), + _response: JSON.parse(result), + }); + } catch { + enrichedArgs = rawArgs; + } + return { + id: `chatcmpl-tc-${id}-args`, + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { tool_calls: [{ index, function: { arguments: enrichedArgs } }] }, + finish_reason: null, + }, + ], + }; +} + +/** + * Wraps a ChatCompletionStreamingRunner in an SSE Response that + * streams tool-call events and content chunks to the client. + */ +export function sseResponseFromRunner( + runner: ChatCompletionStreamingRunner, +): Response { + const encoder = new TextEncoder(); + let closed = false; + + const readable = new ReadableStream({ + start(controller) { + const send = (text: string) => { + if (closed) return; + try { controller.enqueue(encoder.encode(text)); } catch { /* closed */ } + }; + const finish = () => { + if (closed) return; + closed = true; + try { controller.close(); } catch { /* closed */ } + }; + + const pendingCalls: Array<{ id: string; name: string; arguments: string }> = []; + let callIdx = 0; + let resultIdx = 0; + + runner.on("functionToolCall", (fc) => { + const id = `tc-${callIdx}`; + pendingCalls.push({ id, name: fc.name, arguments: fc.arguments }); + send(sseEvent(toolCallStartChunk(id, fc.name, callIdx))); + callIdx++; + }); + + runner.on("functionToolCallResult", (result) => { + const tc = pendingCalls[resultIdx]; + if (tc) { + send(sseEvent(toolCallArgsChunk(tc.id, tc.arguments, result, resultIdx))); + } + resultIdx++; + }); + + runner.on("chunk", (chunk) => { + const choice = chunk.choices?.[0]; + if (!choice?.delta) return; + if (choice.delta.content || choice.finish_reason === "stop") { + send(sseEvent(chunk)); + } + }); + + runner.on("end", () => { + send("data: [DONE]\n\n"); + finish(); + }); + + runner.on("error", (err) => { + console.error("[chat] Error:", err); + send(sseEvent({ error: err.message })); + finish(); + }); + }, + }); + + return new Response(readable, { headers: SSE_HEADERS }); +} diff --git a/examples/multi-tool-dashboard/src/lib/stripe-bridge.ts b/examples/multi-tool-dashboard/src/lib/stripe-bridge.ts new file mode 100644 index 000000000..17ae6b5b5 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/stripe-bridge.ts @@ -0,0 +1,50 @@ +/** + * Stripe API bridge — direct REST calls with secret key auth. + * + * Requires: STRIPE_SECRET_KEY env var (sk_test_... or sk_live_...). + * Uses the Stripe REST API directly — no SDK dependency needed. + * All responses are JSON. Gracefully returns { error } when key is missing. + */ + +const API_BASE = "https://api.stripe.com/v1"; + +function getKey(): string | null { + return process.env.STRIPE_SECRET_KEY || null; +} + +export async function stripeGet( + path: string, + params?: Record, +): Promise { + const key = getKey(); + if (!key) return { error: "Set STRIPE_SECRET_KEY env var" }; + + const url = new URL(`${API_BASE}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + + try { + const res = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: { message: res.statusText } })); + const msg = (body as { error?: { message?: string } }).error?.message ?? `HTTP ${res.status}`; + console.error(`[stripe] ${path} failed:`, msg); + return { error: `Stripe API error: ${msg}` }; + } + + return await res.json(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[stripe] Request failed:", msg); + return { error: `Stripe request failed: ${msg}` }; + } +} diff --git a/examples/multi-tool-dashboard/src/lib/tool-def.ts b/examples/multi-tool-dashboard/src/lib/tool-def.ts new file mode 100644 index 000000000..a32141da1 --- /dev/null +++ b/examples/multi-tool-dashboard/src/lib/tool-def.ts @@ -0,0 +1,62 @@ +import type { ToolSpec } from "@openuidev/lang-core"; +import type { RunnableToolFunction } from "openai/lib/RunnableFunction"; +import type { JSONSchema } from "openai/lib/jsonschema"; +import { z } from "zod"; + +interface ToolDefOptions { + name: string; + description: string; + inputSchema: z.ZodObject; + outputSchema: z.ZodType; + execute: (args: Record) => Promise; + /** Pre-built JSON Schema — used by remote MCP tools whose schema is discovered at runtime. */ + rawInputJsonSchema?: Record; +} + +export class ToolDef { + readonly name: string; + readonly description: string; + readonly inputSchema: z.ZodObject; + readonly outputSchema: z.ZodType; + readonly execute: (args: Record) => Promise; + readonly rawInputJsonSchema?: Record; + + constructor(opts: ToolDefOptions) { + this.name = opts.name; + this.description = opts.description; + this.inputSchema = opts.inputSchema; + this.outputSchema = opts.outputSchema; + this.execute = opts.execute; + this.rawInputJsonSchema = opts.rawInputJsonSchema; + } + + /** Resolved JSON Schema for the input — prefers raw when available. */ + getInputJsonSchema(): Record { + return this.rawInputJsonSchema ?? (z.toJSONSchema(this.inputSchema) as Record); + } + + /** Format accepted by `client.chat.completions.runTools()` */ + toOpenAITool(): RunnableToolFunction> { + return { + type: "function", + function: { + name: this.name, + description: this.description, + parameters: this.getInputJsonSchema() as JSONSchema, + function: async (args: Record) => + JSON.stringify(await this.execute(args)), + parse: JSON.parse, + }, + }; + } + + /** Format used by `generatePrompt()` for the system prompt */ + toToolSpec(): ToolSpec { + return { + name: this.name, + description: this.description, + inputSchema: this.getInputJsonSchema(), + outputSchema: z.toJSONSchema(this.outputSchema) as Record, + }; + } +} diff --git a/examples/multi-tool-dashboard/src/library.ts b/examples/multi-tool-dashboard/src/library.ts new file mode 100644 index 000000000..4612c9f8b --- /dev/null +++ b/examples/multi-tool-dashboard/src/library.ts @@ -0,0 +1,21 @@ +"use client"; + +import { createLibrary } from "@openuidev/react-lang"; +import { openuiLibrary, openuiComponentGroups } from "@openuidev/react-ui/genui-lib"; +import { CalendarView } from "./components/CalendarView"; + +export const library = createLibrary({ + root: "Stack", + componentGroups: [ + ...openuiComponentGroups, + { name: "Calendar", components: ["CalendarView"], notes: [ + '- CalendarView renders a month-grid calendar with event dots. Pass an array of event objects: CalendarView(events)', + '- Each event needs summary (string) and start (object with dateTime or date string)', + '- Click a day cell to see event details below the calendar', + ] }, + ], + components: [ + ...Object.values(openuiLibrary.components), + CalendarView, + ], +}); diff --git a/examples/multi-tool-dashboard/src/prompt-config.ts b/examples/multi-tool-dashboard/src/prompt-config.ts new file mode 100644 index 000000000..9562b11c4 --- /dev/null +++ b/examples/multi-tool-dashboard/src/prompt-config.ts @@ -0,0 +1,90 @@ +// 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 — Product Analytics Dashboard: +root = Stack([header, controls, kpiRow, trendCard]) +header = CardHeader("Product Analytics", "Pageviews and DAU over the last 30 days") +$dateFrom = "-30d" +controls = Stack([filterRow], "row", "m", "end", "between") +filterRow = FormControl("Date Range", Select("dateFrom", [r7, r14, r30], null, null, $dateFrom)) +r7 = SelectItem("-7d", "Last 7 days") +r14 = SelectItem("-14d", "Last 14 days") +r30 = SelectItem("-30d", "Last 30 days") +trends = Query("get_product_trends", {event: "$pageview", math: "dau", dateFrom: $dateFrom}, {results: []}) +kpiRow = Stack([kpi1], "row", "m", "stretch", "start", true) +kpi1 = Card([TextContent("DAU Trend", "small"), TextContent("See chart below", "large-heavy")]) +trendCard = Card([CardHeader("Daily Active Users"), LineChart(trends.results[0].labels, [Series("DAU", trends.results[0].data)])])`, + + `Example — Revenue + Engineering Dashboard: +root = Stack([header, row1, row2]) +header = CardHeader("Startup Dashboard", "Revenue, product, and engineering at a glance") +bal = Query("get_stripe_balance", {}, {available: [{amount: 0, currency: "usd"}], pending: [{amount: 0, currency: "usd"}]}) +subs = Query("get_stripe_subscriptions", {limit: 5}, {data: []}) +repos = Query("get_my_repos", {perPage: 5}, {repos: []}) +agenda = Query("get_my_agenda", {today: true}, {events: []}) +row1 = Stack([balCard, subCard], "row", "m", "stretch") +balCard = Card([TextContent("Available Balance", "small"), TextContent("$" + @Round(bal.available[0].amount / 100, 2), "large-heavy")]) +subCard = Card([TextContent("Active Subs", "small"), TextContent("" + subs.data.length, "large-heavy")]) +row2 = Stack([repoCard, agendaCard], "row", "m", "stretch") +repoCard = Card([CardHeader("Active Repos"), Table([Col("Repo", repos.repos.name), Col("Language", repos.repos.language), Col("Issues", repos.repos.open_issues, "number")])]) +agendaCard = Card([CardHeader("Today's Meetings"), Table([Col("Event", agenda.events.summary), Col("Start", agenda.events.start)])])`, + ], + additionalRules: [ + "The user already has a default dashboard with KPI cards (Stripe balance, subscriptions, GitHub events, repos), a DAU chart, top events chart, GitHub repos table, and calendar agenda. When the user asks for something, generate ONLY the new widget code to ADD to the existing dashboard. Do not regenerate existing widgets.", + "When adding a widget, append new variable assignments and add the new component reference into the existing root Stack's children array. The current dashboard code is provided in tags.", + "For revenue data, use Stripe tools: get_stripe_balance for cash position, get_stripe_revenue for transaction history, get_stripe_charges for recent payments, get_stripe_subscriptions for MRR.", + "Stripe amounts are in CENTS — always divide by 100 for display (e.g. bal.available[0].amount / 100). Use @Round for formatting.", + "For product analytics (DAU, pageviews, events over time), use get_product_trends with appropriate event and math parameters.", + "For understanding user behavior, use get_top_events to see which events are most common.", + "For conversion analysis, use get_conversion_funnel with an ordered list of event names.", + "For custom analytics queries, use posthog_query with HogQL SQL. Available tables: events, persons, sessions.", + "For deploy status or engineering velocity, use get_my_repos to find active repos, then get_recent_activity for a summary of pushes/PRs/issues, get_commit_activity for weekly commit charts, and get_contributors for team velocity.", + "For meeting schedules, daily agenda, or upcoming events, use get_my_agenda (quick view) or get_calendar_events (date range).", + "When the user asks broad questions like 'how is the business doing' or 'morning briefing', combine Stripe revenue, PostHog product data, GitHub engineering data, AND calendar events.", + "PostHog trends return results[0].labels (dates) and results[0].data (values) — use these for LineChart series.", + "GitHub repo lists, activity rows, Calendar events, and Stripe lists should be displayed in Tables. However, get_commit_activity returns numeric weekly data — use LineChart for that.", + "PostHog trend data and Stripe amounts ARE numeric — use LineChart or BarChart for trends, KPI Cards for totals. Use Tables for listing repos, events, subscriptions, etc.", + "CRITICAL: NEVER pass string/text data as chart Series values. BarChart and LineChart Series MUST have numeric arrays. If data has strings (names, titles, dates, descriptions), use Table instead. Passing non-numeric data to a chart will crash the renderer.", + "get_top_events returns rows with .event (string) and .count (number). Use BarChart with rows.event as labels and Series('Count', rows.count) for the values. The .count field is numeric and safe for charts.", + ], + preamble: `You are a startup CEO's AI dashboard assistant. The user already has a default dashboard with widgets from Stripe, PostHog, GitHub, and Google Calendar. Your job is to ADD new widgets to this dashboard when the user asks for them. Generate only the new openui-lang code needed — the system will merge it with the existing dashboard automatically. You have access to real data from all four sources. + +## Stripe Revenue & Financials (live data) + +- **get_stripe_balance** — current account balance. Returns \`{ available: [{ amount, currency }], pending: [{ amount, currency }] }\`. Amounts in cents — divide by 100 for dollars. +- **get_stripe_charges** — recent payments/charges. Optional \`limit\` (default 10), \`created_gte\` (Unix timestamp). Returns \`{ data: [{ id, amount, currency, status, description, created }] }\`. +- **get_stripe_revenue** — balance transactions (net revenue, fees, refunds). Optional \`limit\` (default 20), \`type\` ("charge"/"refund"/"payout"), \`created_gte\`. Returns \`{ data: [{ id, amount, net, fee, currency, type, created, description }] }\`. +- **get_stripe_subscriptions** — active subscriptions for MRR. Optional \`status\` ("active"/"past_due"/"canceled"/"all"), \`limit\` (default 20). Returns \`{ data: [{ id, status, current_period_start, current_period_end, customer, items }] }\`. + +## PostHog Product Analytics (live data) + +- **get_product_trends** — pageviews, DAU, or any event over time. Accepts \`event\` (default "$pageview"), \`math\` ("total", "dau", "weekly_active", "monthly_active"), \`dateFrom\` ("-7d", "-30d", "-90d"), \`interval\` ("day", "week", "month"). Returns \`results[]\` where each result has \`labels\` (dates) and \`data\` (values). +- **get_top_events** — most common events in PostHog. Accepts \`days\` (default 7) and \`limit\` (default 10). Returns rows with \`event\` and \`count\`. +- **get_conversion_funnel** — conversion funnel analysis. Requires \`steps\` (ordered event name array). Optional \`dateFrom\`. +- **posthog_query** — custom HogQL SQL query. Tables: \`events\`, \`persons\`, \`sessions\`. + +## GitHub Engineering Tools (live data via Octokit, includes private repos) + +- **get_my_repos** — repos sorted by recent push. Returns \`repos[]\` with \`name\`, \`full_name\`, \`description\`, \`language\`, \`stars\`, \`forks\`, \`open_issues\`, \`updated_at\`, \`is_private\`. Optional \`perPage\`. +- **get_recent_activity** — recent events (pushes, PRs, issues, reviews). Returns \`rows[]\` and \`summary\` with counts. +- **get_commit_activity** — weekly commit counts for a repo (52 weeks). Requires \`owner\` and \`repo\`. Returns \`rows[]\` with \`week\` and \`total\`. NUMERIC — good for LineChart. +- **get_contributors** — top contributors by commit count. Requires \`owner\` and \`repo\`. Returns \`rows[]\` with \`login\` and \`total_commits\`. + +## Google Calendar Tools (live data) + +- **get_my_agenda** — today's or upcoming events. Optional \`today\` (boolean) or \`days\` (number). +- **get_calendar_events** — events for a date range. Optional \`timeMin\`, \`timeMax\` (ISO 8601), \`maxResults\`. + +IMPORTANT: Stripe amounts are in CENTS — always divide by 100 for dollar display. Use LineChart/BarChart for PostHog trends and GitHub commit_activity. Use Table for listing repos, activity, calendar events, subscriptions, charges. Use KPI Cards for Stripe balance and summary totals.`, +}; diff --git a/examples/multi-tool-dashboard/src/tools.ts b/examples/multi-tool-dashboard/src/tools.ts new file mode 100644 index 000000000..749bb2161 --- /dev/null +++ b/examples/multi-tool-dashboard/src/tools.ts @@ -0,0 +1,269 @@ +/** + * Shared tool registry — single source of truth for all tools. + * + * Data sources: + * - Stripe (revenue — balance, charges, MRR) + * - PostHog (product analytics — trends, funnels, events) + * - GitHub/Octokit (engineering velocity — repos, commits, activity) + * - Google Calendar (meetings & schedule via gws CLI) + * + * Consumed by: + * - /api/mcp/route.ts (MCP server, uses Zod inputSchema directly) + * - /api/chat/route.ts (OpenAI function-calling, via toOpenAITool()) + */ +import { z } from "zod"; +import { ToolDef } from "./lib/tool-def"; +import { stripeGet } from "./lib/stripe-bridge"; +import { posthogQuery } from "./lib/posthog-bridge"; +import { getMyRepos, getRecentActivity, getCommitActivity, getContributors } from "./lib/github-octokit"; +import { runGws } from "./lib/gws-bridge"; + +// ── Tool registry ─────────────────────────────────────────────────────────── + +export { ToolDef } from "./lib/tool-def"; + +export const tools: ToolDef[] = [ + + // ── Stripe tools (revenue & financials) ───────────────────────────────────── + + new ToolDef({ + name: "get_stripe_balance", + description: "Get the current Stripe account balance — available and pending amounts by currency. Use this for cash-on-hand and treasury dashboards.", + inputSchema: z.object({}), + outputSchema: z.object({ available: z.unknown(), pending: z.unknown() }), + execute: async () => stripeGet("/balance"), + }), + + new ToolDef({ + name: "get_stripe_charges", + description: "Get recent Stripe charges (payments). Use this for revenue activity, recent transactions, and payment volume.", + inputSchema: z.object({ + limit: z.coerce.number().optional().describe("Number of charges to return (default 10, max 100)"), + created_gte: z.string().optional().describe("Only charges created after this Unix timestamp or ISO date"), + }), + outputSchema: z.object({ data: z.unknown() }), + execute: async (args) => { + const params: Record = { limit: String(args.limit ?? 10) }; + if (args.created_gte) params["created[gte]"] = args.created_gte as string; + return stripeGet("/charges", params); + }, + }), + + new ToolDef({ + name: "get_stripe_revenue", + description: "Get Stripe balance transactions over a time period — net revenue, fees, refunds. Use this for MRR, revenue trends, and investor updates.", + inputSchema: z.object({ + limit: z.coerce.number().optional().describe("Number of transactions to return (default 20, max 100)"), + type: z.string().optional().describe("Filter by type: 'charge', 'refund', 'payout', 'adjustment' (default: all)"), + created_gte: z.string().optional().describe("Only transactions after this Unix timestamp"), + }), + outputSchema: z.object({ data: z.unknown() }), + execute: async (args) => { + const params: Record = { limit: String(args.limit ?? 20) }; + if (args.type) params.type = args.type as string; + if (args.created_gte) params["created[gte]"] = args.created_gte as string; + return stripeGet("/balance_transactions", params); + }, + }), + + new ToolDef({ + name: "get_stripe_subscriptions", + description: "Get active Stripe subscriptions — MRR calculation, plan distribution, churn risk. Use this for recurring revenue dashboards.", + inputSchema: z.object({ + status: z.string().optional().describe("Filter: 'active' (default), 'past_due', 'canceled', 'all'"), + limit: z.coerce.number().optional().describe("Number of subscriptions (default 20, max 100)"), + }), + outputSchema: z.object({ data: z.unknown() }), + execute: async (args) => { + const params: Record = { limit: String(args.limit ?? 20) }; + const status = (args.status as string) ?? "active"; + if (status !== "all") params.status = status; + return stripeGet("/subscriptions", params); + }, + }), + + // ── PostHog tools (product analytics) ─────────────────────────────────────── + + new ToolDef({ + name: "get_product_trends", + description: "Get product analytics trends from PostHog — pageviews, DAU, or any event over time. Use this for usage dashboards, growth metrics, and investor updates.", + inputSchema: z.object({ + event: z.string().optional().describe("Event name to trend (default '$pageview'). Use '$pageview' for page views, '$autocapture' for interactions, or any custom event."), + math: z.string().optional().describe("Aggregation: 'total' (default), 'dau' for daily active users, 'weekly_active', 'monthly_active'"), + dateFrom: z.string().optional().describe("Start date: '-7d', '-30d', '-90d', or ISO date (default '-30d')"), + interval: z.string().optional().describe("Granularity: 'day' (default), 'hour', 'week', 'month'"), + }), + outputSchema: z.object({ results: z.unknown() }), + execute: async (args) => { + return posthogQuery({ + kind: "TrendsQuery", + series: [{ event: args.event ?? "$pageview", math: args.math ?? "total" }], + dateRange: { date_from: args.dateFrom ?? "-30d" }, + interval: args.interval ?? "day", + }); + }, + }), + + new ToolDef({ + name: "get_top_events", + description: "Get the most common events from PostHog over a time period. Use this to understand what users are doing in the product.", + inputSchema: z.object({ + days: z.coerce.number().optional().describe("Lookback period in days (default 7)"), + limit: z.coerce.number().optional().describe("Number of top events to return (default 10)"), + }), + outputSchema: z.object({ + rows: z.array(z.object({ event: z.string(), count: z.number() })), + }), + execute: async (args) => { + const days = args.days ?? 7; + const limit = args.limit ?? 10; + const raw = await posthogQuery({ + kind: "HogQLQuery", + query: `SELECT event, count() as count FROM events WHERE timestamp > now() - interval ${days} day GROUP BY event ORDER BY count DESC LIMIT ${limit}`, + }) as { results?: unknown[][]; columns?: string[]; error?: string }; + + if (raw.error || !raw.results) return { rows: [], error: raw.error }; + + const rows = raw.results.map((r) => ({ + event: String(r[0] ?? ""), + count: Number(r[1] ?? 0), + })); + return { rows }; + }, + }), + + new ToolDef({ + name: "get_conversion_funnel", + description: "Get conversion funnel analytics from PostHog. Provide an ordered list of event names representing funnel steps.", + inputSchema: z.object({ + steps: z.array(z.string()).describe("Ordered event names for funnel steps, e.g. ['$pageview', 'sign_up', 'purchase']"), + dateFrom: z.string().optional().describe("Start date: '-7d', '-30d', '-90d' (default '-30d')"), + }), + outputSchema: z.object({ results: z.unknown() }), + execute: async (args) => { + return posthogQuery({ + kind: "FunnelsQuery", + series: (args.steps as string[]).map((e: string) => ({ event: e, name: e })), + dateRange: { date_from: args.dateFrom ?? "-30d" }, + }); + }, + }), + + new ToolDef({ + name: "posthog_query", + description: "Run a custom HogQL (SQL) query against PostHog data. Use this for any analytics question not covered by other tools. Tables: events, persons, sessions.", + inputSchema: z.object({ + query: z.string().describe("HogQL SQL query, e.g. \"SELECT count() FROM events WHERE timestamp > now() - interval 7 day\""), + }), + outputSchema: z.object({ results: z.unknown() }), + execute: async (args) => { + return posthogQuery({ + kind: "HogQLQuery", + query: args.query, + }); + }, + }), + + // ── GitHub tools (engineering velocity via Octokit) ───────────────────────── + + new ToolDef({ + name: "get_my_repos", + description: "List the authenticated user's GitHub repositories (including private) sorted by most recently pushed. Use this for deploy status and engineering portfolio overview.", + inputSchema: z.object({ + perPage: z.coerce.number().optional().describe("Number of repos to return (default 10, max 100)"), + }), + outputSchema: z.object({ + repos: z.array(z.object({ + name: z.string(), + full_name: z.string(), + description: z.string(), + language: z.string(), + stars: z.number(), + forks: z.number(), + open_issues: z.number(), + updated_at: z.string(), + is_private: z.boolean(), + })), + }), + execute: async (args) => getMyRepos(Number(args.perPage ?? 10)), + }), + + new ToolDef({ + name: "get_recent_activity", + description: "Get the authenticated user's recent GitHub activity — pushes, PRs, issues, reviews. Returns event rows and a summary with counts. Use this for engineering velocity.", + inputSchema: z.object({}), + outputSchema: z.object({ + rows: z.array(z.object({ type: z.string(), repo: z.string(), date: z.string() })), + summary: z.object({ total: z.number(), push: z.number(), pr: z.number(), issues: z.number(), reviews: z.number() }), + }), + execute: async () => getRecentActivity(), + }), + + new ToolDef({ + name: "get_commit_activity", + description: "Get weekly commit counts for a specific repo over the past year (52 weeks). Use this for deploy cadence and engineering velocity charts.", + inputSchema: z.object({ + owner: z.string().describe("Repository owner (username or org)"), + repo: z.string().describe("Repository name"), + }), + outputSchema: z.object({ + rows: z.array(z.object({ week: z.string(), total: z.number() })), + }), + execute: async (args) => getCommitActivity(String(args.owner), String(args.repo)), + }), + + new ToolDef({ + name: "get_contributors", + description: "Get contributor stats for a specific repo — login and total commits. Use this for team velocity.", + inputSchema: z.object({ + owner: z.string().describe("Repository owner (username or org)"), + repo: z.string().describe("Repository name"), + }), + outputSchema: z.object({ + rows: z.array(z.object({ login: z.string(), total_commits: z.number() })), + }), + execute: async (args) => getContributors(String(args.owner), String(args.repo)), + }), + + // ── Google Calendar tools (via gws CLI) ───────────────────────────────────── + + new ToolDef({ + name: "get_my_agenda", + description: "Get the authenticated user's upcoming calendar events. Use this for daily briefings, meeting schedules, and time management.", + inputSchema: z.object({ + today: z.boolean().optional().describe("Only show today's events (default false — shows upcoming)"), + days: z.coerce.number().optional().describe("Number of days ahead to show (e.g. 3, 7)"), + }), + outputSchema: z.object({ events: z.unknown() }), + execute: async (args) => { + const cliArgs = ["calendar", "+agenda"]; + if (args.today) cliArgs.push("--today"); + else if (args.days) cliArgs.push("--days", String(args.days)); + return { events: await runGws(cliArgs) }; + }, + }), + + new ToolDef({ + name: "get_calendar_events", + description: "List calendar events for a date range from the primary calendar. Use this for weekly overviews and scheduling dashboards.", + inputSchema: z.object({ + timeMin: z.string().optional().describe("Start of date range in ISO 8601 (e.g. 2026-04-09T00:00:00Z). Defaults to now."), + timeMax: z.string().optional().describe("End of date range in ISO 8601 (e.g. 2026-04-16T00:00:00Z)"), + maxResults: z.coerce.number().optional().describe("Maximum events to return (default 20)"), + }), + outputSchema: z.object({ events: z.unknown() }), + execute: async (args) => { + const params: Record = { + calendarId: "primary", + singleEvents: true, + orderBy: "startTime", + maxResults: args.maxResults ?? 20, + }; + if (args.timeMin) params.timeMin = args.timeMin; + else params.timeMin = new Date().toISOString(); + if (args.timeMax) params.timeMax = args.timeMax; + + return { events: await runGws(["calendar", "events", "list", "--params", JSON.stringify(params)]) }; + }, + }), +]; diff --git a/examples/multi-tool-dashboard/tsconfig.json b/examples/multi-tool-dashboard/tsconfig.json new file mode 100644 index 000000000..cf9c65d3e --- /dev/null +++ b/examples/multi-tool-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/react-ui/src/genui-lib/Table/index.tsx b/packages/react-ui/src/genui-lib/Table/index.tsx index fb1ee3848..15140592c 100644 --- a/packages/react-ui/src/genui-lib/Table/index.tsx +++ b/packages/react-ui/src/genui-lib/Table/index.tsx @@ -31,10 +31,12 @@ export const Table = defineComponent({ name: "Table", props: z.object({ columns: z.array(Col.ref), + pageSize: z.coerce.number().optional().describe("Rows per page (default 10)"), }), - description: "Data table — column-oriented. Each Col holds its own data array.", + description: + "Data table — column-oriented. Each Col holds its own data array. Optional pageSize controls rows per page.", component: ({ props, renderNode }) => { - const effectivePageSize = DEFAULT_PAGE_SIZE; + const effectivePageSize = (props as any).pageSize ?? DEFAULT_PAGE_SIZE; const [currentPage, setCurrentPage] = React.useState(0); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ffd1a6c1..f2542a68f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,73 @@ importers: specifier: ^5 version: 5.9.3 + examples/multi-tool-dashboard: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.29.0(@cfworker/json-schema@4.1.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(@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) + octokit: + specifier: ^5.0.5 + version: 5.0.5 + 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.2 + typescript: + specifier: ^5 + version: 5.9.3 + examples/openui-artifact-demo: dependencies: '@openuidev/react-headless': @@ -26387,7 +26454,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15