diff --git a/.changeset/generative-ui-live-data.md b/.changeset/generative-ui-live-data.md new file mode 100644 index 0000000000..a319af250f --- /dev/null +++ b/.changeset/generative-ui-live-data.md @@ -0,0 +1,26 @@ +--- +'@tanstack/ai-generative-ui': minor +--- + +Live data sources for Tier F iframe artifacts. + +A model can bind an artifact to a host-registered streaming data source via +`create_artifact`'s new `dataSource: { name, params }` field; the host subscribes +on mount and pushes each value into the sandboxed frame, where `useHostData()` +re-renders in place — real-time updates without regenerating the artifact. + +- New `DataSourceRegistry` + `describeDataSourcesForPrompt` (`@tanstack/ai-generative-ui`), + the streaming sibling of `ComputeRegistry`: the host registers named + `subscribe(params, emit) => unsubscribe` sources, surfaced to the model in the + system prompt the same way compute functions are. +- The binding persists on `ArtifactRecord.dataSource` (threaded through create and + revise) and is emitted on the `genui:artifact` pin event. +- `FrameArtifactRenderer` gains a `subscribeData` prop: subscribe on mount, push + each emission over the active bridge, unsubscribe on unmount; it also re-pushes + the static `data` prop when it changes (previously a one-shot snapshot). +- Live push uses the built-in `ogui/1` bridge (`host.data` → `useHostData`), which + delivers reliably into a sandboxed opaque-origin iframe. The MCP Apps standards + path still renders static artifacts. + +Live values stay host-authoritative, like `data` — the model renders what arrives, +it does not compute it. diff --git a/docs/config.json b/docs/config.json index ba16ab89da..6995739bb4 100644 --- a/docs/config.json +++ b/docs/config.json @@ -512,6 +512,28 @@ } ] }, + { + "label": "Generative UI", + "tab": "guides", + "children": [ + { + "label": "Overview", + "to": "generative-ui/index", + "tab": "guides", + "addedAt": "2026-10-20" + }, + { + "label": "Artifacts", + "to": "generative-ui/artifacts", + "addedAt": "2026-10-20" + }, + { + "label": "Live data", + "to": "generative-ui/live-data", + "addedAt": "2026-10-20" + } + ] + }, { "label": "Chat UI Recipes", "tab": "guides", diff --git a/docs/generative-ui/artifacts.md b/docs/generative-ui/artifacts.md new file mode 100644 index 0000000000..245249ace0 --- /dev/null +++ b/docs/generative-ui/artifacts.md @@ -0,0 +1,145 @@ +--- +title: Artifacts +id: generative-ui-artifacts +order: 2 +description: "Give the model the create_artifact tools with a chat middleware, then render the stored artifact inline in the conversation. Covers the server store and builder, and the client renderer." +keywords: + - tanstack ai + - generative ui + - generativeUiArtifacts + - create_artifact + - FrameArtifactStore + - makeReactBuilder + - FrameArtifactRenderer +--- + +You want the model to build a working UI during the chat, not only describe one. The model needs tools to create and store a UI, and your app needs to render what it made. This page wires both halves. + +## Server: give the model the tools + +Add the `generativeUiArtifacts` middleware to your `chat()` call. It injects four server-run tools the model can use mid-conversation: `create_artifact`, `revise_artifact`, `list_artifacts`, and `read_artifact`. + +The middleware needs three things from you: + +- **`store`** — where artifacts live. Use the in-memory store to start, or a blob-backed store to persist them. +- **`builder`** — turns a `react-tsx` source into a bundle. An `html-fragment` needs no build. +- **`registry`** — the modules a `react-tsx` artifact may import. `REFERENCE_REGISTRY` is a ready set. + +```ts ignore +// src/routes/api.chat.ts (TanStack Start) +import { createFileRoute } from '@tanstack/react-router' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { REFERENCE_REGISTRY, makeReactBuilder } from '@tanstack/ai-generative-ui' +import { + generativeUiArtifacts, + createInMemoryFrameArtifactStore, +} from '@tanstack/ai-generative-ui/artifacts' +import { transformWithEsbuild } from 'vite' + +const store = createInMemoryFrameArtifactStore() + +// Build a react-tsx source to ESM. Imports stay bare for the host import map. +const builder = makeReactBuilder(async ({ source }) => { + const out = await transformWithEsbuild(source, 'artifact.tsx', { + loader: 'tsx', + jsx: 'transform', + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + format: 'esm', + }) + return { code: out.code } +}) + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + middleware: [ + generativeUiArtifacts({ store, builder, registry: REFERENCE_REGISTRY }), + ], + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +On a successful create or revise, the middleware emits a `genui:artifact` custom event that carries the pinned `{ artifactId, revision }`. The client uses that pin to render the frame. A build failure comes back as the tool result, so the model reads the compiler errors and tries again inside the same run. + +### Persist artifacts + +The in-memory store is gone on restart. For real storage, back it with a `BlobStore` from `@tanstack/ai-persistence`: + +```ts +import { memoryPersistence } from '@tanstack/ai-persistence' +import { createBlobArtifactStore } from '@tanstack/ai-generative-ui/artifacts' + +const store = createBlobArtifactStore(memoryPersistence().stores.blobs) +``` + +Swap the blob store for an S3, filesystem, or database implementation with no other change. + +## Client: render the artifact inline + +The client listens for the `genui:artifact` event to learn the pin, then renders the frame with `FrameArtifactRenderer`. The renderer mounts a sandboxed iframe and asks your host for the artifact's shell document by URL. + +```tsx ignore +import { useState } from 'react' +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' +import { FrameArtifactRenderer, type ArtifactPin } from '@tanstack/ai-generative-ui' + +const sandboxUrl = (pin: ArtifactPin, instanceId: string) => + `/sandbox/${pin.artifactId}/${pin.revision}?i=${encodeURIComponent(instanceId)}` + +export function Chat() { + // toolCallId -> pin, filled by the genui:artifact event. + const [pins, setPins] = useState>({}) + + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + onCustomEvent: (type, data, ctx) => { + if (type === 'genui:artifact' && ctx.toolCallId && data?.pin) { + setPins((p) => ({ ...p, [ctx.toolCallId]: data.pin })) + } + }, + }) + + return ( +
+ {messages.map((m) => ( +
+ {m.parts.map((part, i) => { + if (part.type === 'text') return

{part.content}

+ if (part.type === 'tool-call' && pins[part.id]) { + return ( + + ) + } + return null + })} +
+ ))} +
+ ) +} +``` + +### Serve the sandbox shell + +`FrameArtifactRenderer` loads the artifact from the `sandboxUrl` you give it. Your host serves that URL. The host reads the record from the same `store`, builds the shell document with `buildShellDocument`, and returns it with a strict content security policy. + +The shell, the import map, and the theme are host code. The model never writes them. This serving layer is host-specific, so the [`examples/generative-ui`](https://github.com/TanStack/ai/tree/main/examples/generative-ui) app shows a complete `/sandbox/:id/:rev` route, the runtime modules, and a dedicated sandbox origin. + +Next: make a view update on its own with [live data](./live-data). diff --git a/docs/generative-ui/index.md b/docs/generative-ui/index.md new file mode 100644 index 0000000000..7d6fc28b7c --- /dev/null +++ b/docs/generative-ui/index.md @@ -0,0 +1,57 @@ +--- +title: Generative UI +id: generative-ui +order: 1 +description: "Let the model author interactive UI during a chat() run. The host builds, stores, and renders it inline in a sandboxed iframe — a chord diagram, a calculator, a live data monitor." +keywords: + - tanstack ai + - generative ui + - artifacts + - create_artifact + - sandboxed iframe + - react-tsx + - html-fragment + - FrameArtifactRenderer + - live data +--- + +A chat reply is usually text. Sometimes the real answer is a thing the user touches: a loan calculator they can tweak, a guitar chord they can inspect, a spend monitor that keeps ticking. Describing that in prose is weak. Rendering it is strong. + +**`@tanstack/ai-generative-ui`** lets the model author a UI during a `chat()` run. The model calls a tool with the source for a small component. The host builds it, stores it, and renders it inline in a sandboxed iframe, as part of the assistant's message. + +```ts ignore +import { chat } from '@tanstack/ai' +import { generativeUiArtifacts } from '@tanstack/ai-generative-ui/artifacts' + +const stream = chat({ + adapter, + messages, + middleware: [generativeUiArtifacts({ store, builder, registry })], +}) +``` + +> **Experimental.** This package is a prototype of the Open Generative UI design. The API can change between releases. + +## The artifact model + +An artifact is one stored, versioned UI. The model authors only the source. Everything structural (the iframe shell, the bridge, the theme, the build) is host code, so a security or theme fix reaches every stored artifact without regeneration. + +There are two kinds: + +- **`html-fragment`** — markup plus an optional ` + + + + + diff --git a/examples/generative-ui/scripts/serve-sandbox.mjs b/examples/generative-ui/scripts/serve-sandbox.mjs new file mode 100644 index 0000000000..29a916a726 --- /dev/null +++ b/examples/generative-ui/scripts/serve-sandbox.mjs @@ -0,0 +1,25 @@ +// Serves the MCP Apps sandbox-proxy page on its OWN origin (port 8765), +// separate from the Vite dev server (port 3000). This cross-origin split is a +// hard requirement of `@mcp-ui/client`'s `AppRenderer`: the proxy runs a +// security self-test that fails if it shares an origin with the host page, so +// it cannot be served from Vite's own `public/`. The proxy file itself +// (public/sandbox_proxy.html) is the official one from modelcontextprotocol/ +// inspector and only trusts http(s)://localhost embedders. +import { createServer } from 'node:http' +import { readFileSync } from 'node:fs' + +const PORT = 8765 +const html = readFileSync( + new URL('../public/sandbox_proxy.html', import.meta.url), +) + +createServer((_req, res) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + // Don't let a stale copy mask edits to the proxy during development. + res.setHeader('Cache-Control', 'no-store') + res.end(html) +}).listen(PORT, () => { + console.log( + `[mcp-apps] sandbox proxy → http://localhost:${PORT}/sandbox_proxy.html`, + ) +}) diff --git a/examples/generative-ui/scripts/verify-live.mjs b/examples/generative-ui/scripts/verify-live.mjs new file mode 100644 index 0000000000..58c7a04bc7 --- /dev/null +++ b/examples/generative-ui/scripts/verify-live.mjs @@ -0,0 +1,82 @@ +// Verifies the live data-source transport: GET /api/live?source=spendFeed opens +// an SSE stream that emits distinct values over time (the feed the client pushes +// into a mounted frame for real-time views). Run while `pnpm dev` is up: +// node scripts/verify-live.mjs +const BASE = process.env.APP_URL || 'http://localhost:3000' +const SOURCE = process.env.LIVE_SOURCE || 'spendFeed' +const WANT = Number(process.env.LIVE_COUNT || 3) + +function ok(label, cond) { + console.log(`${cond ? '✓' : '✗'} ${label}`) + if (!cond) process.exitCode = 1 +} + +const url = `${BASE}/api/live?source=${encodeURIComponent(SOURCE)}¶ms=${encodeURIComponent(JSON.stringify({ intervalMs: 400 }))}` +const controller = new AbortController() +const timeout = setTimeout(() => controller.abort(), 8000) + +const events = [] +try { + const res = await fetch(url, { + headers: { accept: 'text/event-stream' }, + signal: controller.signal, + }) + ok( + 'GET /api/live responds 200 text/event-stream', + res.ok && + (res.headers.get('content-type') || '').includes('text/event-stream'), + ) + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (events.length < WANT) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const chunk = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + const line = chunk.split('\n').find((l) => l.startsWith('data:')) + if (line) { + try { + events.push(JSON.parse(line.slice(5).trim())) + } catch { + /* skip */ + } + } + } + } + try { + await reader.cancel() + } catch { + /* ignore */ + } +} catch (err) { + if (err?.name !== 'AbortError') throw err +} finally { + clearTimeout(timeout) + controller.abort() +} + +ok( + `received ${events.length} live emissions (wanted ${WANT})`, + events.length >= WANT, +) +ok( + 'each emission carries a running total', + events.every((e) => typeof e?.total === 'number'), +) +const totals = events.map((e) => e?.total) +ok(`total changes over time (${totals.join(' → ')})`, new Set(totals).size > 1) +ok( + 'each emission lists recent transactions', + events.every((e) => Array.isArray(e?.recent)), +) + +console.log( + process.exitCode + ? '\nFAIL' + : '\nPASS — live data streams into the host for real-time views', +) diff --git a/examples/generative-ui/scripts/verify-mcp.mjs b/examples/generative-ui/scripts/verify-mcp.mjs new file mode 100644 index 0000000000..5d05dbe3fb --- /dev/null +++ b/examples/generative-ui/scripts/verify-mcp.mjs @@ -0,0 +1,72 @@ +// Verifies the generative-UI MCP server is a conformant MCP Apps server: an +// external MCP client can discover the create_artifact tool (with its +// _meta.ui.resourceUri linkage), call it, and read the ui://genui/last resource +// back as MCP-Apps HTML. Run while `pnpm dev` is up: node scripts/verify-mcp.mjs +import { createMCPClient } from '@tanstack/ai-mcp' + +const URL = process.env.MCP_URL || 'http://localhost:3000/mcp' +const FRAGMENT = `
+

Emaj7

+ +

Open Emaj7 voicing.

+
` + +function ok(label, cond) { + console.log(`${cond ? '✓' : '✗'} ${label}`) + if (!cond) process.exitCode = 1 +} + +const client = await createMCPClient({ transport: { type: 'http', url: URL } }) +try { + const tools = await client.tools() + const list = Array.isArray(tools) ? tools : Object.values(tools ?? {}) + const create = list.find((t) => t?.name === 'create_artifact') + ok('server lists create_artifact', !!create) + if (process.env.DEBUG) + console.log('RAW TOOL:', JSON.stringify(create, null, 1)) + // The client stamps the SEP-1865 _meta.ui.resourceUri as metadata.mcp.uiResourceUri + // (this is exactly what the host's emitUiResourceIfLinked reads to render). + const uri = + create?.metadata?.mcp?.uiResourceUri ?? + create?._meta?.ui?.resourceUri ?? + create?._meta?.['ui/resourceUri'] + ok( + `create_artifact is UI-linked (_meta.ui.resourceUri = ${uri})`, + uri === 'ui://genui/last', + ) + + const res = await client.callTool('create_artifact', { + title: 'Emaj7', + source: FRAGMENT, + fallbackText: 'Emaj7 open voicing chord diagram.', + }) + const text = JSON.stringify(res) + ok( + 'create_artifact call succeeded', + !text.includes('Build failed') && !res?.isError, + ) + + const read = await client.readResource('ui://genui/last') + const content = read?.contents?.[0] + ok( + 'ui://genui/last is text/html;profile=mcp-app', + content?.mimeType === 'text/html;profile=mcp-app', + ) + ok( + 'resource HTML carries the authored fragment', + typeof content?.text === 'string' && content.text.includes('Emaj7'), + ) + ok( + 'resource HTML is MCP-Apps-native (guest adapter inlined)', + typeof content?.text === 'string' && + /__ogui|ext-apps|PostMessage|mcp/i.test(content.text), + ) + + console.log( + process.exitCode + ? '\nFAIL' + : '\nPASS — external MCP hosts can render generative UI from this server', + ) +} finally { + await client.close?.() +} diff --git a/examples/generative-ui/server/chat-route.ts b/examples/generative-ui/server/chat-route.ts new file mode 100644 index 0000000000..c24d03cb10 --- /dev/null +++ b/examples/generative-ui/server/chat-route.ts @@ -0,0 +1,173 @@ +/** + * Conversational chat endpoint, migrated to `@tanstack/ai` `chat()` with a real + * server-side agent loop. The model composes Tier N catalog graphs via the + * `render_ui` tool (args carry the graph; a no-op server ack keeps the loop + * moving) and authors Tier F artifacts via the `generativeUiArtifacts` + * middleware's `create_artifact`/`revise_artifact`/… tools — built + stored + * server-side, streamed back, and rendered inline. SSE is hand-pumped from the + * web `Response` into the raw Node response (this is Vite dev middleware). + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, + toolDefinition, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { generativeUiArtifacts } from '@tanstack/ai-generative-ui/artifacts' +import { + LiveDriver, + buildFragmentDocument, + describeDataSourcesForPrompt, + describeRegistryForPrompt, + renderUICompositionGuidance, + type FrameTheme, +} from '@tanstack/ai-generative-ui' +import { + getFragmentGuestScript, + reactBuilder, + registry, + store, +} from './frame-host' +import { dataSources } from './data-sources' + +// Light theme injected into html-fragment MCP-Apps documents (fragments also +// carry var() fallbacks). A dedicated sandbox origin can't read the host's CSS. +const FRAGMENT_THEME: FrameTheme = { + mode: 'light', + tokens: { + '--ui-bg': '#ffffff', + '--ui-surface': '#ffffff', + '--ui-surface-2': '#f8fafc', + '--ui-text': '#0f172a', + '--ui-muted': '#64748b', + '--ui-border': '#e5e7eb', + '--ui-accent': '#2563eb', + '--ui-accent-text': '#ffffff', + '--ui-positive': '#16a34a', + '--ui-negative': '#dc2626', + '--ui-radius': '14px', + }, +} + +const MODELS: Record = { + openai: 'gpt-4o', + anthropic: 'claude-sonnet-4-5-20250929', +} + +function adapterFor(provider: string) { + if (provider === 'anthropic') return anthropicText(MODELS.anthropic as any) + return openaiText(MODELS.openai as any) +} + +const SYSTEM_PROMPT = `You are an Open Generative UI agent speaking in a chat conversation (Open Generative UI, Draft 0.2). + +Each turn: reply with a short, warm sentence or two of plain markdown prose, AND — when an interactive interface would genuinely serve the request — call ONE tool so the UI renders inline as part of your message. For a purely conversational question, just answer in prose. + +You have two ways to render UI: + +1. render_ui — compose a declarative catalog node graph (Tier N). Best for comparisons, forms, approvals, checklists, metrics, and simple dashboards: testable and host-native. Follow the catalog + compute rules below. + +2. create_artifact — author a bespoke Frame-tier artifact (Tier F) when craft and interactivity dominate: domain figures (a guitar chord card, an annotated diagram), rich calculators, or data tables/charts. Choose kind: + - "html-fragment": markup + optional +

${esc(rec.title)}

+

Open Generative UI · Tier F artifact · revision ${rec.revision} · ${rec.kind}

+
▢${esc(rec.title)}sandboxed
+
` +} diff --git a/examples/generative-ui/server/frame-runtime.ts b/examples/generative-ui/server/frame-runtime.ts new file mode 100644 index 0000000000..fb902354ac --- /dev/null +++ b/examples/generative-ui/server/frame-runtime.ts @@ -0,0 +1,268 @@ +/** + * Tier F runtime modules (Draft 0.2 §7.3.2), served from the sandbox runtime + * route. react / react-dom / react/jsx-runtime are bundled from node_modules by + * esbuild (one shared React instance via import-map externals). The TanStack + * modules and the host design system are served as host-authored ESM here: + * `@tanstack/react-table`/`-charts` ship faithful, compact shims (the real + * packages are a host release action, §7.3.2 — out of scope for this POC), and + * `@ogui/bridge` is the exact source the package publishes. + * + * Shims are plain ESM (no TSX/types) so the browser runs them directly; they + * import `react` as a bare specifier resolved by the shell's import map. + */ + +import { OGUI_BRIDGE_MODULE_SOURCE } from '@tanstack/ai-generative-ui' + +/* ---- @host/ui — host design-system primitives, themed with --ui-* -------- */ +const HOST_UI_SOURCE = /* js */ ` +import { createElement as h } from 'react'; + +export function Card(props) { + var head = props.title + ? h('div', { style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 10 } }, + h('div', { style: { fontSize: 17, fontWeight: 650, letterSpacing: '-.01em' } }, props.title), + props.subtitle ? h('div', { style: { fontSize: 12, color: 'var(--ui-muted,#6b7280)' } }, props.subtitle) : null) + : null; + return h('div', { + style: Object.assign({ + padding: '16px 18px', background: 'var(--ui-surface,#fff)', + border: '1px solid var(--ui-border,#e5e7eb)', borderRadius: 'var(--ui-radius,14px)' + }, props.style) + }, head, props.children); +} + +export function Button(props) { + var rest = Object.assign({}, props); delete rest.style; delete rest.children; delete rest.tone; + var bg = props.tone === 'danger' ? 'var(--ui-negative,#dc2626)' : 'var(--ui-accent,#2563eb)'; + return h('button', Object.assign({}, rest, { + style: Object.assign({ + padding: '9px 12px', border: 0, borderRadius: 10, background: bg, + color: 'var(--ui-accent-text,#fff)', fontSize: 13, fontWeight: 600, cursor: 'pointer' + }, props.style) + }), props.children); +} + +export function Input(props) { + return h('input', Object.assign({}, props, { + style: Object.assign({ + padding: '8px 10px', border: '1px solid var(--ui-border,#e5e7eb)', + borderRadius: 8, background: 'var(--ui-surface,#fff)', color: 'var(--ui-text,#111)', fontSize: 13 + }, props.style) + })); +} + +export function Slider(props) { + var rest = Object.assign({}, props); delete rest.style; delete rest.label; + return h('input', Object.assign({ type: 'range' }, rest, { + style: Object.assign({ width: '100%', accentColor: 'var(--ui-accent,#2563eb)' }, props.style) + })); +} + +export function Select(props) { + var rest = Object.assign({}, props); delete rest.style; delete rest.children; delete rest.options; + var opts = props.options + ? props.options.map(function (o, i) { return h('option', { key: i, value: o.value != null ? o.value : o }, o.label != null ? o.label : o); }) + : props.children; + return h('select', Object.assign({}, rest, { + style: Object.assign({ + padding: '8px 10px', border: '1px solid var(--ui-border,#e5e7eb)', borderRadius: 8, + background: 'var(--ui-surface,#fff)', color: 'var(--ui-text,#111)', fontSize: 13 + }, props.style) + }), opts); +} + +export function Label(props) { + var rest = Object.assign({}, props); delete rest.style; delete rest.children; + return h('label', Object.assign({}, rest, { + style: Object.assign({ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--ui-muted,#64748b)', margin: '0 0 4px' }, props.style) + }), props.children); +} + +export function Checkbox(props) { + var rest = Object.assign({}, props); delete rest.style; + return h('input', Object.assign({ type: 'checkbox' }, rest, { style: Object.assign({ accentColor: 'var(--ui-accent,#2563eb)' }, props.style) })); +} +` + +/* ---- @tanstack/react-table shim — headless sorting/model ----------------- */ +const REACT_TABLE_SHIM = /* js */ ` +export function getCoreRowModel() { return 'core'; } +export function getSortedRowModel() { return 'sorted'; } +export function getFilteredRowModel() { return 'filtered'; } +export function getPaginationRowModel() { return 'pagination'; } +export function flexRender(comp, ctx) { return typeof comp === 'function' ? comp(ctx) : comp; } +export function createColumnHelper() { + return { + accessor: function (key, col) { + var base = typeof key === 'function' ? { accessorFn: key } : { accessorKey: key }; + return Object.assign(base, col || {}); + }, + display: function (col) { return col || {}; }, + group: function (col) { return col || {}; }, + }; +} +// v7 compatibility alias — some models reach for useTable. +export function useTable(options) { return useReactTable(options); } + +export function useReactTable(options) { + var data = options.data || []; + var sorting = (options.state && options.state.sorting) || []; + var onSortingChange = options.onSortingChange; + + var cols = (options.columns || []).map(function (c) { + var id = c.id || c.accessorKey; + var accessor = c.accessorFn ? c.accessorFn : function (row) { return row[c.accessorKey]; }; + var columnDef = Object.assign({}, c); + // Default cell renderer: show the accessor value (real Table behavior). + if (typeof columnDef.cell === 'undefined') columnDef.cell = function (info) { return info.getValue(); }; + var col = { + id: id, + columnDef: columnDef, + accessor: accessor, + getToggleSortingHandler: function () { + return function () { + var cur = sorting[0]; + var next; + if (!cur || cur.id !== id) next = [{ id: id, desc: false }]; + else if (!cur.desc) next = [{ id: id, desc: true }]; + else next = []; + if (onSortingChange) onSortingChange(next); + }; + }, + getIsSorted: function () { + var s = sorting.find(function (x) { return x.id === id; }); + return s ? (s.desc ? 'desc' : 'asc') : false; + } + }; + return col; + }); + + var headers = cols.map(function (col) { + return { id: col.id, column: col, getContext: function () { return { column: col }; } }; + }); + var headerGroups = [{ id: 'hg0', headers: headers }]; + + var rows = data.map(function (d, i) { return { original: d, id: String(i) }; }); + if (sorting.length) { + var s = sorting[0]; + var col = cols.find(function (c) { return c.id === s.id; }); + if (col) { + rows = rows.slice().sort(function (a, b) { + var av = col.accessor(a.original), bv = col.accessor(b.original); + var r = av < bv ? -1 : av > bv ? 1 : 0; + return s.desc ? -r : r; + }); + } + } + + var rowModel = { + rows: rows.map(function (r) { + return { + id: r.id, + original: r.original, + getVisibleCells: function () { + return cols.map(function (col) { + return { + id: r.id + '_' + col.id, + column: col, + getContext: function () { + return { getValue: function () { return col.accessor(r.original); }, row: r, column: col }; + } + }; + }); + } + }; + }) + }; + + return { getHeaderGroups: function () { return headerGroups; }, getRowModel: function () { return rowModel; } }; +} +` + +/* ---- @tanstack/react-charts shim — themed SVG bar chart ------------------ */ +const REACT_CHARTS_SHIM = /* js */ ` +import { createElement as h } from 'react'; + +export function Chart(props) { + var options = props.options || {}; + var series = (options.data && options.data[0]) || { data: [] }; + var pts = series.data || []; + var getX = (options.primaryAxis && options.primaryAxis.getValue) || function (d) { return d.primary; }; + var getY = ((options.secondaryAxes && options.secondaryAxes[0] && options.secondaryAxes[0].getValue)) || function (d) { return d.secondary; }; + var W = 320, H = 140, pad = 26; + var vals = pts.map(getY); + var max = Math.max.apply(null, vals.concat([1])); + var n = pts.length || 1; + var slot = (W - pad * 2) / n; + var bw = slot * 0.6; + var bars = pts.map(function (d, i) { + var v = getY(d); + var bh = Math.max(1, (v / max) * (H - pad * 2)); + var x = pad + i * slot + (slot - bw) / 2; + var y = H - pad - bh; + return h('g', { key: i }, + h('rect', { x: x, y: y, width: bw, height: bh, rx: 3, fill: 'var(--ui-accent,#2563eb)' }), + h('text', { x: x + bw / 2, y: H - pad + 12, textAnchor: 'middle', fontSize: 9, fill: 'var(--ui-muted,#6b7280)' }, String(getX(d))) + ); + }); + return h('svg', { width: '100%', height: '100%', viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': 'bar chart' }, + h('line', { x1: pad, y1: H - pad, x2: W - pad, y2: H - pad, stroke: 'var(--ui-border,#e5e7eb)', strokeWidth: 1 }), + bars + ); +} + +export function AxisOptions() {} +` + +/* ---- @tanstack/react-query stub (not used by fixtures) ------------------- */ +const REACT_QUERY_STUB = /* js */ ` +export function useQuery() { throw new Error('react-query is not enabled in this sandbox (no connect-src grant).'); } +export function QueryClient() {} +export function QueryClientProvider(props) { return props.children; } +` + +// react-dom re-exports from the single bundled react.js so there is exactly +// one React instance (importing both 'react' and 'react-dom' hits the same +// module). Avoids esbuild's `__require("react")` trap for CJS externals. +const REACT_DOM_REEXPORT = `export { createRoot, hydrateRoot } from './react.js';` + +/** Host-authored ESM runtime modules, keyed by served filename. */ +export const RUNTIME_SHIMS: Record = { + 'host-ui.js': HOST_UI_SOURCE, + 'ogui-bridge.js': OGUI_BRIDGE_MODULE_SOURCE, + 'react-dom.js': REACT_DOM_REEXPORT, + 'tanstack-react-table.js': REACT_TABLE_SHIM, + 'tanstack-react-charts.js': REACT_CHARTS_SHIM, + 'tanstack-react-query.js': REACT_QUERY_STUB, +} + +/** + * npm modules bundled from node_modules by esbuild, keyed by served filename. + * `entry` is the stdin source; `external` keeps the shared React instance + * out of the bundle so the import map resolves it to the single `react.js`. + */ +export const RUNTIME_NPM: Record< + string, + { entry: string; external: Array } +> = { + // One self-contained bundle holding the single React instance plus react-dom's + // createRoot. Zero externals → no `__require` shim; React ships CJS so the API + // is re-exported explicitly via destructuring (each becomes a static named + // export the import map's consumers can bind). + 'react.js': { + entry: ` +import React from 'react'; +import { createRoot, hydrateRoot } from 'react-dom/client'; +export default React; +export { createRoot, hydrateRoot }; +export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, Suspense, + cloneElement, createContext, createElement, createRef, forwardRef, isValidElement, + lazy, memo, startTransition, useCallback, useContext, useDebugValue, useDeferredValue, + useEffect, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, + useReducer, useRef, useState, useSyncExternalStore, useTransition, version +} = React; +`, + external: [], + }, +} diff --git a/examples/generative-ui/server/fs-blob-store.ts b/examples/generative-ui/server/fs-blob-store.ts new file mode 100644 index 0000000000..b18b867200 --- /dev/null +++ b/examples/generative-ui/server/fs-blob-store.ts @@ -0,0 +1,141 @@ +/** + * A minimal filesystem `BlobStore` (the `@tanstack/ai-persistence` interface) + * for the test host. Persists artifact metadata + built bundles under a + * gitignored `.data/artifacts` dir, so model-authored artifacts survive dev + * restarts. Production hosts swap this for the SDK's SQLite / S3 / R2 stores. + * + * Scope: only the methods `createBlobArtifactStore` uses are meaningful + * (put/get/head/list/delete, whole-object reads). Byte ranges and rich list + * cursors beyond simple pagination are not needed here. + */ + +import { mkdir, readFile, writeFile, stat, rm, readdir } from 'node:fs/promises' +import { dirname, join, relative, sep } from 'node:path' +import type { + BlobStore, + BlobBody, + BlobObject, + BlobRecord, + BlobListPage, + BlobPutOptions, + BlobListOptions, +} from '@tanstack/ai-persistence' + +// Map a blob key to a filesystem path. Keys are URL-ish; only `:` (from the +// fnv1a hash in built-bundle keys) is unsafe on some filesystems. Metadata keys +// — the only ones ever listed — contain no `:`, so list() recovers keys from +// paths unambiguously. +const keyToRel = (key: string) => key.replace(/:/g, '-') + +async function bodyToString(body: BlobBody): Promise { + if (typeof body === 'string') return body + if (body instanceof Uint8Array) return Buffer.from(body).toString('utf8') + if (body instanceof ArrayBuffer) return Buffer.from(body).toString('utf8') + if (ArrayBuffer.isView(body)) + return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString( + 'utf8', + ) + if (typeof (body as Blob).text === 'function') return (body as Blob).text() + throw new Error( + 'fs-blob-store: unsupported blob body (streams not supported)', + ) +} + +async function walk(dir: string): Promise> { + let entries: Array + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return [] + } + const out: Array = [] + for (const e of entries) { + const full = join(dir, e.name) + if (e.isDirectory()) out.push(...(await walk(full))) + else out.push(full) + } + return out +} + +export function createFsBlobStore(baseDir: string): BlobStore { + const pathFor = (key: string) => join(baseDir, keyToRel(key)) + + return { + async put( + key: string, + body: BlobBody, + options?: BlobPutOptions, + ): Promise { + const content = await bodyToString(body) + const path = pathFor(key) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content, 'utf8') + const now = Date.now() + return { + key, + size: Buffer.byteLength(content), + contentType: options?.contentType, + createdAt: now, + updatedAt: now, + } + }, + + async get(key: string): Promise { + try { + const content = await readFile(pathFor(key), 'utf8') + const info = await stat(pathFor(key)) + return { + key, + size: info.size, + createdAt: info.birthtimeMs, + updatedAt: info.mtimeMs, + text: async () => content, + arrayBuffer: async () => { + const buf = Buffer.from(content, 'utf8') + return buf.buffer.slice( + buf.byteOffset, + buf.byteOffset + buf.byteLength, + ) + }, + } + } catch { + return null + } + }, + + async head(key: string): Promise { + try { + const info = await stat(pathFor(key)) + return { + key, + size: info.size, + createdAt: info.birthtimeMs, + updatedAt: info.mtimeMs, + } + } catch { + return null + } + }, + + async delete(key: string): Promise { + await rm(pathFor(key), { force: true }) + }, + + async list(options?: BlobListOptions): Promise { + const files = await walk(baseDir) + const keys = files + .map((f) => relative(baseDir, f).split(sep).join('/')) + .filter((k) => (options?.prefix ? k.startsWith(options.prefix) : true)) + .filter((k) => (options?.cursor ? k > options.cursor : true)) + .sort() + const limit = options?.limit + const page = typeof limit === 'number' ? keys.slice(0, limit) : keys + const truncated = typeof limit === 'number' && keys.length > limit + return { + objects: page.map((key) => ({ key })), + truncated, + cursor: truncated ? page[page.length - 1] : undefined, + } + }, + } +} diff --git a/examples/generative-ui/server/live-route.ts b/examples/generative-ui/server/live-route.ts new file mode 100644 index 0000000000..2e42647118 --- /dev/null +++ b/examples/generative-ui/server/live-route.ts @@ -0,0 +1,56 @@ +/** + * Live data-source SSE transport: the bridge from the server-side + * {@link dataSources} registry to the browser renderer. `GET /api/live?source= + * ¶ms=` subscribes to the named source and streams each emission + * as an SSE `data:` line; the subscription is torn down when the client (the + * EventSource opened by `FrameArtifactRenderer`'s `subscribeData`) disconnects. + * + * This is what carries a live value from the host into a mounted iframe: the + * client receives each message and pushes it over the bridge + * (`host.data` / MCP Apps `tool-result`), so the frame's `useHostData()` + * re-renders in real time. + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' +import { dataSources } from './data-sources' + +export function handleLive(req: IncomingMessage, res: ServerResponse): void { + const query = new URLSearchParams((req.url || '').split('?')[1] || '') + const source = query.get('source') || '' + let params: Record = {} + const raw = query.get('params') + if (raw) { + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') + params = parsed as Record + } catch { + // Ignore malformed params; the source gets {}. + } + } + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Connection', 'keep-alive') + res.setHeader('Access-Control-Allow-Origin', '*') + // Open the stream (and defeat proxy buffering) with a comment line. + res.write(': ok\n\n') + + const unsubscribe = dataSources.subscribe(source, params, (data) => { + try { + res.write(`data: ${JSON.stringify(data)}\n\n`) + } catch { + // Write after close — ignore; cleanup runs on 'close'. + } + }) + + req.on('close', () => { + try { + unsubscribe() + } catch { + // ignore + } + res.end() + }) +} diff --git a/examples/generative-ui/server/mcp-server.ts b/examples/generative-ui/server/mcp-server.ts new file mode 100644 index 0000000000..1dc0c44cad --- /dev/null +++ b/examples/generative-ui/server/mcp-server.ts @@ -0,0 +1,134 @@ +/** + * MCP server adapter (Draft 0.2 §11 / SEP-1865) — exposes generative UI over + * the Model Context Protocol so EXTERNAL MCP Apps hosts (Claude, ChatGPT apps, + * Goose, …) can render it. Built on the repo's own `createMCPServer`, over the + * SAME artifact store + builder as the in-app path (`./frame-host`). + * + * - tool `create_artifact` — authors an html-fragment artifact; its + * `_meta.ui.resourceUri` links it to the `ui://genui/last` view (SEP-1865's + * static tool→resource linkage, so the host renders the just-generated UI). + * - resource `ui://genui/last` — returns the latest artifact as a self-contained, + * MCP-Apps-native document (`text/html;profile=mcp-app`), guest adapter inlined. + * + * html-fragment is used because it is trivially portable (self-contained); + * react-tsx over MCP needs a serving origin / inline-bundle variant (follow-up). + */ + +import { toolDefinition } from '@tanstack/ai' +import { createMCPServer, resourceDefinition } from '@tanstack/ai-mcp/server' +import { + buildFragmentDocument, + type ArtifactRecord, + type FrameTheme, +} from '@tanstack/ai-generative-ui' +import { MCP_APP_MIME_TYPE } from '@tanstack/ai-generative-ui/artifacts' +import { artifacts, getFragmentGuestScript, store } from './frame-host' + +const LAST_URI = 'ui://genui/last' + +const THEME: FrameTheme = { + mode: 'light', + tokens: { + '--ui-bg': '#ffffff', + '--ui-surface': '#ffffff', + '--ui-surface-2': '#f8fafc', + '--ui-text': '#0f172a', + '--ui-muted': '#64748b', + '--ui-border': '#e5e7eb', + '--ui-accent': '#2563eb', + '--ui-accent-text': '#ffffff', + '--ui-positive': '#16a34a', + '--ui-negative': '#dc2626', + '--ui-radius': '14px', + }, +} + +// The most recently authored artifact, surfaced at ui://genui/last. +let last: { artifactId: string; revision: number } | null = null + +const createArtifactTool = toolDefinition({ + name: 'create_artifact', + description: + 'Author a bespoke, self-contained UI (a hand-tuned HTML/SVG fragment) and render it. Numbers presented as computed should come from host data, not model arithmetic.', + // Minimal schema — kind is fixed to html-fragment for portability. + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['title', 'source', 'fallbackText'], + properties: { + title: { + type: 'string', + description: 'Short human title for the artifact.', + }, + source: { + type: 'string', + description: + 'A self-contained HTML/SVG fragment: markup + optional inline +
+
+
Projected value · 30 yrs
—
+
Monthly
$300/mo
+
+ + +
Curve drawn in-widget (arbitrary rendering). Headline value computed host-side via savingsGrowth.
+ +
+
+` + +export const savingsWidgetFixture: GraphDocument = { + specVersion: '0.1', + graphId: 'g_savings_widget_01', + revision: 1, + title: 'Retirement Savings Explorer', + fallbackText: + 'A custom savings-growth widget: drag the monthly contribution to see the projected 30-year value, computed by host code. Save the plan to send it back to the agent.', + provenance: { driver: 'fixture', sourceId: 'savings-widget' }, + root: { + id: 'root', + type: 'Stack', + props: { gap: 'md' }, + children: [ + { + id: 'intro', + type: 'Callout', + props: { + tone: 'info', + title: 'Escape hatch — sandboxed custom widget', + text: 'The chart below is hand-drawn inside a sandboxed iframe (arbitrary rendering a catalog cannot express). The projected value is still computed by host code, state lives host-side, and "Save" leaves as a structured intent. Draw anything; the truth stays host-side.', + }, + }, + { + id: 'widget', + type: 'Widget', + props: { + title: 'Savings growth', + height: 360, + srcdoc: SAVINGS_WIDGET_SRCDOC, + }, + }, + ], + }, +} + +export const CANONICAL_FIXTURES: Record = { + 'compare-plans-basic': comparePlansBasicFixture, + 'bicycle-drivetrain': bicycleDrivetrainFixture, + 'cooking-plan': cookingPlanFixture, + 'bill-split': billSplitFixture, + 'recipe-parameters': recipeParametersFixture, + 'approval-tool-call': approvalToolCallFixture, + 'spend-monitor': spendMonitorFixture, + 'trip-stops': tripStopsFixture, + 'chord-diagram-emaj7': chordDiagramEmaj7Fixture, + 'recipe-card': recipeCardFixture, + 'savings-widget': savingsWidgetFixture, +} diff --git a/packages/ai-generative-ui/src/frame/bridge.ts b/packages/ai-generative-ui/src/frame/bridge.ts new file mode 100644 index 0000000000..ef08ae02a2 --- /dev/null +++ b/packages/ai-generative-ui/src/frame/bridge.ts @@ -0,0 +1,227 @@ +/** + * The bridge protocol, host side (Draft 0.2 §6). Framework-agnostic: given a + * transport (`send`) and host callbacks, it validates every inbound envelope, + * answers `frame.ready` with `host.init`, persists `frame.state.set`, and + * routes `frame.intent.*` through the host (which owns approval, §6.3). + * + * The React renderer (`../react/frame`) and the §10.2 contract tests both use + * this; there is no second copy of the protocol logic. + */ + +import { FRAME_PROTOCOL } from './types' +import type { + BridgeEnvelope, + FrameErrorPayload, + FrameIntentPayload, + FrameMessageType, + FrameTheme, +} from './types' + +/** Version of the shell/bridge contract, reported in `frame.ready` (§6.2). */ +export const SHELL_VERSION = '0.2.0' + +/** The frame→host subset of the message set (§6.2). */ +export type FrameToHostType = + | 'frame.ready' + | 'frame.resize' + | 'frame.error' + | 'frame.state.set' + | 'frame.intent.submit' + | 'frame.intent.action' + +const FRAME_TO_HOST: ReadonlySet = new Set([ + 'frame.ready', + 'frame.resize', + 'frame.error', + 'frame.state.set', + 'frame.intent.submit', + 'frame.intent.action', +]) + +/** Result of validating an inbound message against the protocol (§6.1). */ +export type EnvelopeCheck = + | { ok: true; envelope: BridgeEnvelope } + | { ok: false; reason: string } + +/** + * Validate an inbound value as a frame→host envelope for this instance (§6.1). + * Unknown types and shape-invalid payloads are rejected (caller drops + logs). + */ +export function validateInbound( + data: unknown, + expected: { artifactId: string; instanceId: string }, +): EnvelopeCheck { + if (!data || typeof data !== 'object') + return { ok: false, reason: 'not an object' } + const e = data as Record + if (e.ns !== FRAME_PROTOCOL) + return { ok: false, reason: `bad ns: ${String(e.ns)}` } + if ( + typeof e.type !== 'string' || + !FRAME_TO_HOST.has(e.type as FrameMessageType) + ) { + return { ok: false, reason: `unknown or non-frame type: ${String(e.type)}` } + } + if (e.artifactId !== expected.artifactId) + return { ok: false, reason: 'artifactId mismatch' } + if (e.instanceId !== expected.instanceId) + return { ok: false, reason: 'instanceId mismatch' } + if (!e.payload || typeof e.payload !== 'object') + return { ok: false, reason: 'missing payload' } + + // Light per-type payload checks; anything malformed is dropped. + const p = e.payload as Record + switch (e.type as FrameToHostType) { + case 'frame.ready': + break + case 'frame.resize': + if (typeof p.height !== 'number' || !isFinite(p.height)) + return { ok: false, reason: 'resize.height' } + break + case 'frame.error': + if (typeof p.message !== 'string') + return { ok: false, reason: 'error.message' } + break + case 'frame.state.set': + if (!p.state || typeof p.state !== 'object') + return { ok: false, reason: 'state.set.state' } + break + case 'frame.intent.submit': + case 'frame.intent.action': + if (typeof p.intent !== 'string') + return { ok: false, reason: 'intent.intent' } + if (!p.state || typeof p.state !== 'object') + return { ok: false, reason: 'intent.state' } + break + } + return { + ok: true, + envelope: { + ns: FRAME_PROTOCOL, + type: e.type as FrameMessageType, + artifactId: e.artifactId, + instanceId: e.instanceId, + requestId: typeof e.requestId === 'string' ? e.requestId : undefined, + payload: e.payload, + }, + } +} + +export interface IntentDispatch { + intentClass: 'submit' | 'action' + intent: string + payload?: unknown + state: Record +} + +export interface FrameHostCallbacks { + getTheme: () => FrameTheme + getState: () => Record + /** Persist a full-replacement state snapshot per instance (§6.4). */ + setState: (state: Record) => void + getData?: () => unknown + registryVersion?: string + /** + * Route a submit/action intent through the host (§6.3). Action intents go to + * the host's approval machinery; the returned value becomes host.intent.result. + */ + onIntent?: ( + dispatch: IntentDispatch, + ) => Promise<{ ok: boolean; result?: unknown; error?: string }> | void + onReady?: () => void + onResize?: (height: number) => void + onError?: (err: FrameErrorPayload) => void + /** Dropped-message hook (§6.1: "logged host-side"). */ + onDrop?: (reason: string, data: unknown) => void + /** Transport: deliver a host→frame envelope to the frame. */ + send: (envelope: BridgeEnvelope) => void +} + +/** + * The host endpoint for one rendered instance (§6, "Instance" §2). Owns the + * conversation with exactly one frame; `handleMessage` is wired to the + * transport's receive, the `push*` helpers send host→frame updates. + */ +export function createFrameHost( + ctx: { artifactId: string; instanceId: string }, + cb: FrameHostCallbacks, +) { + const envelope =

( + type: FrameMessageType, + payload: P, + requestId?: string, + ): BridgeEnvelope

=> ({ + ns: FRAME_PROTOCOL, + type, + artifactId: ctx.artifactId, + instanceId: ctx.instanceId, + ...(requestId ? { requestId } : {}), + payload, + }) + + const sendInit = () => + cb.send( + envelope('host.init', { + theme: cb.getTheme(), + state: cb.getState(), + data: cb.getData?.(), + registryVersion: cb.registryVersion, + }), + ) + + async function handleMessage(data: unknown): Promise { + const check = validateInbound(data, ctx) + if (!check.ok) { + cb.onDrop?.(check.reason, data) + return + } + const { type, payload, requestId } = check.envelope + switch (type as FrameToHostType) { + case 'frame.ready': + cb.onReady?.() + sendInit() + return + case 'frame.resize': + cb.onResize?.((payload as { height: number }).height) + return + case 'frame.error': + cb.onError?.(payload as FrameErrorPayload) + return + case 'frame.state.set': + cb.setState((payload as { state: Record }).state) + return + case 'frame.intent.submit': + case 'frame.intent.action': { + const ip = payload as FrameIntentPayload + const intentClass = type === 'frame.intent.submit' ? 'submit' : 'action' + const outcome = await cb.onIntent?.({ + intentClass, + intent: ip.intent, + payload: ip.payload, + state: ip.state, + }) + if (requestId) { + const res = outcome ?? { ok: true } + cb.send( + envelope('host.intent.result', { requestId, ...res }, requestId), + ) + } + return + } + } + } + + return { + /** Wire to transport receive. */ + handleMessage, + /** Proactively (re)send host.init — usually triggered by frame.ready. */ + init: sendInit, + pushTheme: (theme: FrameTheme) => + cb.send(envelope('host.theme', { theme })), + pushState: (state: Record) => + cb.send(envelope('host.state', { state })), + pushData: (data: unknown) => cb.send(envelope('host.data', { data })), + } +} + +export type FrameHost = ReturnType diff --git a/packages/ai-generative-ui/src/frame/build.ts b/packages/ai-generative-ui/src/frame/build.ts new file mode 100644 index 0000000000..0fd0c7c8b2 --- /dev/null +++ b/packages/ai-generative-ui/src/frame/build.ts @@ -0,0 +1,85 @@ +/** + * Build-at-write-time support (Draft 0.2 §7.3.3). The actual TSX→ESM compile + * runs server-side in the host (esbuild in the reference host) and is injected + * as an `ArtifactBuilder` so the published package stays browser-safe. What + * lives here is the browser-safe, testable part: the import-allowlist preflight + * that turns disallowed imports into model-facing build diagnostics *before* + * any compiler runs. + */ + +import { checkImports } from './registry' +import type { ArtifactBuilder, BuildResult, ModuleRegistry } from './types' + +export type { ArtifactBuilder } from './types' + +/** + * Run the import allowlist gate (§7.3.3). Returns a failing `BuildResult` when + * the source imports anything outside the registry (or a relative path), else + * `null` to proceed to compilation. The returned `externals` on success are the + * registry specifiers the shell's import map must resolve. + */ +export function preflightReactImports( + source: string, + registry: ModuleRegistry, +): { result: BuildResult | null; externals: Array } { + const check = checkImports(source, registry) + if (!check.ok) { + return { + result: { + ok: false, + buildLog: check.diagnostics + .map((d) => `import error: ${d.message}`) + .join('\n'), + diagnostics: check.diagnostics, + }, + externals: check.externals, + } + } + return { result: null, externals: check.externals } +} + +/** + * Wrap an injected low-level compiler (e.g. esbuild transform) with the + * preflight gate and external-resolution bookkeeping, producing a spec-shaped + * `ArtifactBuilder`. The `transform` only needs to turn TSX into ESM JS, + * treating the given `externals` as external imports. + */ +export function makeReactBuilder( + transform: (input: { + source: string + externals: Array + }) => Promise< + { code: string } | { error: string; line?: number; column?: number } + >, +): ArtifactBuilder { + return { + async build({ kind, source, registry }): Promise { + if (kind !== 'react-tsx') { + // html-fragment is not compiled; the shell inlines the source (§7.1). + return { + ok: true, + code: source, + buildLog: 'html-fragment: no build step', + } + } + const pre = preflightReactImports(source, registry) + if (pre.result) return pre.result + const out = await transform({ source, externals: pre.externals }) + if ('error' in out) { + return { + ok: false, + buildLog: out.error, + diagnostics: [ + { message: out.error, line: out.line, column: out.column }, + ], + } + } + return { + ok: true, + code: out.code, + buildLog: 'built ok', + externals: pre.externals, + } + }, + } +} diff --git a/packages/ai-generative-ui/src/frame/fixtures.ts b/packages/ai-generative-ui/src/frame/fixtures.ts new file mode 100644 index 0000000000..f5fb1b4084 --- /dev/null +++ b/packages/ai-generative-ui/src/frame/fixtures.ts @@ -0,0 +1,365 @@ +/** + * Tier F fixture artifacts (Draft 0.2 §8.2, §10.3). Known sources whose build + * → render is snapshot- and interaction-tested without a model. The chord-card + * and spend-monitor pair are the flagship cases (§13): exactly what Tier N + * rendered worst (a domain figure) and adequately (a data monitor). + * + * These are *sources* (the model's output), not records; a store + builder turn + * them into revisions via the §8.1 tools. + */ + +import type { ArtifactKind } from './types' + +export interface FrameFixture { + id: string + kind: ArtifactKind + title: string + fallbackText: string + source: string + /** Optional authoritative data (host-computed numbers, §7.3.4). */ + data?: unknown + /** Optional live data-source binding (host streams values in real time). */ + dataSource?: { name: string; params?: Record } +} + +/* -------------------------------------------------------------------------- */ +/* chord-card — html-fragment (the hand-tuned SVG domain figure, §7.1) */ +/* -------------------------------------------------------------------------- */ + +export const CHORD_CARD_FRAGMENT_SOURCE = /* html */ ` + +

+
+
Emaj7
+
open voicing · 0-2-1-1-0-0
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + + 1 + + 1 + + + + E + B + D# + G# + B + E + + +
Tap a dot to inspect the note.
+ +
+ +`.trim() + +/* -------------------------------------------------------------------------- */ +/* chord-card — react-tsx (same figure, component form, host UI) */ +/* -------------------------------------------------------------------------- */ + +export const CHORD_CARD_TSX_SOURCE = /* tsx */ ` +import { useHostState, useIntent } from '@ogui/bridge'; +import { Button, Card } from '@host/ui'; + +const NOTES = ['E', 'B', 'D#', 'G#', 'B', 'E']; +const ROLES = ['root', '5th', 'maj7', '3rd', '5th', 'root']; +const OPEN = [0, 4, 5]; +const DOTS = [ + { s: 1, cx: 66, cy: 120, finger: '2' }, + { s: 2, cx: 102, cy: 80, finger: '1' }, + { s: 3, cx: 138, cy: 80, finger: '1' }, +]; +const STRINGS = [30, 66, 102, 138, 174, 210]; + +export default function ChordCard() { + const [state, setState] = useHostState({ selected: null }); + const fire = useIntent(); + const sel = state.selected; + return ( + + + + {[60, 100, 140, 180, 220].map((y) => )} + + + + {STRINGS.map((x) => )} + + {OPEN.map((s) => ( + setState({ selected: s })} /> + ))} + {DOTS.map((d) => ( + setState({ selected: d.s })}> + + {d.finger} + + ))} + + {NOTES.map((n, i) => {n})} + + +
+ {sel == null + ? Tap a dot to inspect the note. + : {NOTES[sel]} — the {ROLES[sel]} of Emaj7 (string {6 - sel})} +
+ +
+ ); +} +`.trim() + +/* -------------------------------------------------------------------------- */ +/* spend-monitor — react-tsx (TanStack Table + Charts, host data) */ +/* -------------------------------------------------------------------------- */ + +export const SPEND_MONITOR_TSX_SOURCE = /* tsx */ ` +import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender } from '@tanstack/react-table'; +import { Chart } from '@tanstack/react-charts'; +import { useHostState, useIntent } from '@ogui/bridge'; +import { Card, Button } from '@host/ui'; + +type Row = { category: string; spend: number; budget: number }; + +const columns = [ + { accessorKey: 'category', header: 'Category' }, + { accessorKey: 'spend', header: 'Spend', cell: (c: any) => '$' + c.getValue().toLocaleString() }, + { accessorKey: 'budget', header: 'Budget', cell: (c: any) => '$' + c.getValue().toLocaleString() }, + { + id: 'pct', header: '% used', + accessorFn: (r: Row) => Math.round((r.spend / r.budget) * 100), + cell: (c: any) => { + const v = c.getValue(); + return = 100 ? 'var(--ui-negative,#dc2626)' : v >= 85 ? 'var(--ui-accent,#2563eb)' : 'var(--ui-positive,#16a34a)' }}>{v}%; + }, + }, +]; + +export default function SpendMonitor({ data }: { data: { rows: Row[]; total: number; currency?: string } }) { + const rows = (data && data.rows) || []; + const [state, setState] = useHostState({ sorting: [] as any[] }); + const fire = useIntent(); + const table = useReactTable({ + data: rows, + columns, + state: { sorting: state.sorting }, + onSortingChange: (updater) => setState({ sorting: typeof updater === 'function' ? updater(state.sorting) : updater }), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + const series = [{ label: 'Spend', data: rows.map((r) => ({ primary: r.category, secondary: r.spend })) }]; + + return ( + +
+ d.primary }, secondaryAxes: [{ getValue: (d: any) => d.secondary }] }} /> +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((r) => ( + + {r.getVisibleCells().map((c) => ( + + ))} + + ))} + +
+ {flexRender(h.column.columnDef.header, h.getContext())} + {{ asc: ' ↑', desc: ' ↓' }[h.column.getIsSorted() as string] ?? ''} +
+ {flexRender(c.column.columnDef.cell, c.getContext())} +
+ +
+ ); +} +`.trim() + +export const SPEND_MONITOR_DATA = { + currency: 'USD', + total: 7420, + rows: [ + { category: 'Compute', spend: 3120, budget: 3000 }, + { category: 'Storage', spend: 980, budget: 1500 }, + { category: 'Bandwidth', spend: 1460, budget: 1600 }, + { category: 'AI tokens', spend: 1860, budget: 2000 }, + ], +} + +/* -------------------------------------------------------------------------- */ +/* live-spend-monitor — react-tsx bound to a live data source */ +/* -------------------------------------------------------------------------- */ + +/** + * The flagship live case: a view bound to a host data source via `dataSource`. + * It reads the streamed value with `useHostData()` — the SAME value the host + * pushes over the bridge (`host.data` / MCP Apps `tool-result`) on every change + * — so the total and transaction feed update in place, in real time, without + * the artifact ever being regenerated. `props.data` seeds the first paint. + */ +export const LIVE_SPEND_MONITOR_TSX_SOURCE = /* tsx */ ` +import { useHostData } from '@ogui/bridge'; +import { Card } from '@host/ui'; + +type Txn = { id: string; label: string; amount: number }; +type Feed = { total: number; recent: Txn[]; currency?: string }; + +export default function LiveSpendMonitor({ data }: { data?: Feed }) { + const live = useHostData() as Feed | undefined; + const feed = live || data || { total: 0, recent: [] }; + const total = feed.total || 0; + const recent = feed.recent || []; + return ( + +
+
+ {'$' + Math.round(total).toLocaleString()} +
+ + + live + +
+
+ {recent.length === 0 + ?
Waiting for transactions…
+ : recent.map((t) => ( +
+ {t.label} + {'+$' + (t.amount || 0).toLocaleString()} +
+ ))} +
+
+ ); +} +`.trim() + +/** First-paint seed for the live monitor; the feed takes over once connected. */ +export const LIVE_SPEND_MONITOR_DATA = { + currency: 'USD', + total: 0, + recent: [] as Array<{ id: string; label: string; amount: number }>, +} + +/** All Tier F fixtures, keyed by id (parallel to Draft 0.1 CANONICAL_FIXTURES). */ +export const FRAME_FIXTURES: Record = { + 'chord-card-fragment': { + id: 'chord-card-fragment', + kind: 'html-fragment', + title: 'Emaj7 — open voicing', + fallbackText: + 'Emaj7 open voicing, fingered 0-2-1-1-0-0 (low to high): open E, B at 2nd fret, D# at 1st, G# at 1st, open B, open E.', + source: CHORD_CARD_FRAGMENT_SOURCE, + }, + 'chord-card-tsx': { + id: 'chord-card-tsx', + kind: 'react-tsx', + title: 'Emaj7 — open voicing', + fallbackText: + 'Emaj7 open voicing, fingered 0-2-1-1-0-0 (low to high): open E, B at 2nd fret, D# at 1st, G# at 1st, open B, open E.', + source: CHORD_CARD_TSX_SOURCE, + }, + 'spend-monitor': { + id: 'spend-monitor', + kind: 'react-tsx', + title: 'Spend monitor', + fallbackText: + 'Spend this cycle totals $7,420. Compute $3,120/$3,000 (104%, over), Storage $980/$1,500, Bandwidth $1,460/$1,600, AI tokens $1,860/$2,000.', + source: SPEND_MONITOR_TSX_SOURCE, + data: SPEND_MONITOR_DATA, + }, + 'live-spend-monitor': { + id: 'live-spend-monitor', + kind: 'react-tsx', + title: 'Live spend monitor', + fallbackText: + 'A live spend monitor: a running total and a feed of recent transactions that update in real time as the host streams new data.', + source: LIVE_SPEND_MONITOR_TSX_SOURCE, + data: LIVE_SPEND_MONITOR_DATA, + dataSource: { name: 'spendFeed' }, + }, +} diff --git a/packages/ai-generative-ui/src/frame/guest.ts b/packages/ai-generative-ui/src/frame/guest.ts new file mode 100644 index 0000000000..da06cf2c4c --- /dev/null +++ b/packages/ai-generative-ui/src/frame/guest.ts @@ -0,0 +1,88 @@ +/** + * MCP-Apps-native guest adapter for `html-fragment` artifacts (Draft 0.2 / SEP-1865). + * + * Source for a self-contained script the host bundles (ext-apps + this adapter) + * and inlines ahead of a fragment's own markup. It stands up the official + * ext-apps `App` over `PostMessageTransport(window.parent, window)` and exposes + * the SAME `window.__ogui` API our fragments already use — so existing fragments + * work unchanged, now speaking the real MCP Apps bridge: + * + * - `setState` → `ui/update-model-context` (host-visible) + local for render. + * - `emitIntent` action → `tools/call`; submit → `ui/message`. + * - `onData`/`getData` ← `ui/notifications/tool-input` / `…/tool-result`. + * - `getTheme`/`onTheme` ← host `styles.variables` (applied to `:root`). + * - size: `setupSizeChangedNotifications()` auto-emits `ui/notifications/size-changed` + * so the host sizes the iframe to content. + * + * Bundled with `format: 'iife'` so it runs synchronously (classic script) and + * `window.__ogui` exists before the fragment's inline script runs. + */ +export const FRAGMENT_GUEST_SOURCE = /* js */ ` +import { App, PostMessageTransport, applyHostStyleVariables } from '@modelcontextprotocol/ext-apps'; + +(function () { + var app = new App({ name: 'ogui-fragment', version: '0.2.0' }, {}); + var state = {}; + var data; + var theme; + var ready = false; + var subs = { state: [], data: [], theme: [], ready: [] }; + function fire(kind, value) { (subs[kind] || []).forEach(function (cb) { try { cb(value); } catch (e) {} }); } + function on(kind, cb) { (subs[kind] = subs[kind] || []).push(cb); return function () { var a = subs[kind], i = a.indexOf(cb); if (i >= 0) a.splice(i, 1); }; } + function applyTheme(ctx) { + if (ctx && ctx.styles && ctx.styles.variables) { try { applyHostStyleVariables(ctx.styles.variables); } catch (e) {} } + if (ctx && ctx.theme) { try { document.documentElement.setAttribute('data-theme', ctx.theme); } catch (e) {} } + theme = ctx; fire('theme', theme); + } + + app.ontoolinput = function (p) { data = (p && p.arguments) || data; fire('data', data); }; + app.ontoolresult = function (p) { data = (p && p.structuredContent) || data; fire('data', data); }; + app.onhostcontextchanged = function (p) { applyTheme(p); }; + + window.__ogui = { + getState: function () { return state; }, + setState: function (next) { + state = typeof next === 'function' ? next(state) : Object.assign({}, state, next); + fire('state', state); + try { app.updateModelContext({ structuredContent: state }); } catch (e) {} + }, + getData: function () { return data; }, + getTheme: function () { return theme; }, + onState: function (cb) { return on('state', cb); }, + onData: function (cb) { return on('data', cb); }, + onTheme: function (cb) { return on('theme', cb); }, + onReady: function (cb) { if (ready) { try { cb(); } catch (e) {} } return on('ready', cb); }, + emitIntent: function (intent, payload, opts) { + opts = opts || {}; + if (opts.intentClass === 'action') { + var pr = app.callServerTool({ name: intent, arguments: payload || {} }); + return pr && pr.then + ? pr.then(function (r) { return { ok: true, result: (r && r.structuredContent) || r }; }).catch(function (e) { return { ok: false, error: String(e) }; }) + : undefined; + } + try { app.sendMessage({ role: 'user', content: [{ type: 'text', text: JSON.stringify({ intent: intent, payload: payload }) }] }); } catch (e) {} + return undefined; + } + }; + + (async function () { + try { + // Direct mode (react-tsx shell in our own iframe): connect straight to the + // host AppBridge over window.parent. Proxy mode (html-fragment via + // @mcp-ui/client): no explicit transport — App auto-wires the proxy. + if (window.__OGUI_DIRECT__) { + await app.connect(new PostMessageTransport(window.parent, window)); + } else { + await app.connect(); + } + if (app.getHostContext) applyTheme(app.getHostContext()); + if (app.setupSizeChangedNotifications) app.setupSizeChangedNotifications(); + ready = true; + fire('ready'); fire('data', data); fire('state', state); + } catch (e) { + // No MCP Apps host present (e.g. opened standalone) — __ogui still works + // locally so the fragment renders and interacts in-frame. + } + })(); +})(); +` diff --git a/packages/ai-generative-ui/src/frame/index.ts b/packages/ai-generative-ui/src/frame/index.ts new file mode 100644 index 0000000000..bbb9974392 --- /dev/null +++ b/packages/ai-generative-ui/src/frame/index.ts @@ -0,0 +1,19 @@ +/** + * Open Generative UI Specification (Draft 0.2) — The Frame Tier (Tier F). + * + * Browser-safe, host-agnostic core: artifact store, module registry, serving + * shell + CSP + bridge client, the host-side bridge protocol, the import + * allowlist / build interface, the agent-facing artifact tools, and fixtures. + * The server-only compiler (esbuild) is injected by the host as an + * `ArtifactBuilder`, so nothing here pulls a Node-only dependency. + */ + +export * from './types' +export * from './registry' +export * from './store' +export * from './shell' +export * from './guest' +export * from './bridge' +export * from './build' +export * from './tools' +export * from './fixtures' diff --git a/packages/ai-generative-ui/src/frame/registry.ts b/packages/ai-generative-ui/src/frame/registry.ts new file mode 100644 index 0000000000..8fc18409be --- /dev/null +++ b/packages/ai-generative-ui/src/frame/registry.ts @@ -0,0 +1,184 @@ +/** + * The module registry (Draft 0.2 §7.3.2) — the host's real component + * ecosystem, allow-listed and version-pinned. A react-tsx artifact may import + * *only* these specifiers; anything else fails the build (§7.3.3). + * + * Adding a module is a host release action, never a runtime request (§7.3.2). + */ + +import type { + BuildDiagnostic, + ModuleRegistry, + ModuleRegistryEntry, +} from './types' + +/** Pinned version of the reference registry; recorded on artifacts (§3.1). */ +export const REFERENCE_REGISTRY_VERSION = 'runtime-2026.10' + +/** + * The reference registry (§7.3.2 table). `url` points at the sandbox-origin + * runtime route (§4.1); the shell's import map resolves bare specifiers here. + */ +export const REFERENCE_REGISTRY: ModuleRegistry = { + version: REFERENCE_REGISTRY_VERSION, + modules: [ + { + name: 'react', + version: '19.2.3', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/react.js`, + description: 'The runtime itself.', + }, + { + name: 'react-dom', + version: '19.2.3', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/react-dom.js`, + description: 'The runtime itself (DOM renderer).', + }, + { + name: 'react/jsx-runtime', + version: '19.2.3', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/react-jsx-runtime.js`, + description: 'Automatic JSX runtime (resolved by the compiler, §7.3.3).', + }, + { + name: '@tanstack/react-table', + version: '8.21.3', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/tanstack-react-table.js`, + description: + 'TanStack Table v8 (headless). Import EXACTLY: useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, flexRender, createColumnHelper. Define columns as plain objects [{ accessorKey, header, cell }], pass { data, columns, getCoreRowModel: getCoreRowModel() } to useReactTable, and render with table.getHeaderGroups()/getRowModel() + flexRender. Do NOT use the v7 useTable/useSortBy API.', + }, + { + name: '@tanstack/react-charts', + version: '3.0.0-beta', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/tanstack-react-charts.js`, + description: + 'TanStack Charts. Import EXACTLY: Chart. Render d.primary }, secondaryAxes: [{ getValue: d => d.secondary }] }} /> inside a fixed-height container.', + }, + { + name: '@tanstack/react-query', + version: '5.90.5', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/tanstack-react-query.js`, + description: + 'Data fetching inside the frame; permitted only against §9.3-granted connect-src domains, or via bridge host.data refreshes.', + }, + { + name: '@ogui/bridge', + version: '0.2.0', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/ogui-bridge.js`, + description: + 'useHostState, useIntent, useTheme — the §6 bridge as React imports.', + }, + { + name: '@host/ui', + version: '0.2.0', + url: `/sandbox/_runtime/${REFERENCE_REGISTRY_VERSION}/host-ui.js`, + description: + 'Host design-system primitives. Exports EXACTLY: Card, Button, Input, Slider, Select, Label, Checkbox — import only these names. For anything else (text, headings, layout, badges) use plain HTML elements (div/p/h3/span) styled with host theme CSS variables: var(--ui-text), var(--ui-muted), var(--ui-accent), var(--ui-surface), var(--ui-border).', + }, + ], +} + +/** Look up a registry entry by exact specifier name. */ +export function getRegistryEntry( + registry: ModuleRegistry, + name: string, +): ModuleRegistryEntry | undefined { + return registry.modules.find((m) => m.name === name) +} + +/** + * Reduce an import specifier to the package name the registry is keyed by. + * `@tanstack/react-table/foo` → `@tanstack/react-table`; `react/jsx-runtime` + * stays whole only if the registry lists it, else falls back to `react`. + */ +export function packageBase(specifier: string): string { + if (specifier.startsWith('@')) { + const parts = specifier.split('/') + return parts.slice(0, 2).join('/') + } + const parts = specifier.split('/') + return parts[0] ?? specifier +} + +/** Is `specifier` a relative/absolute path rather than a bare package import? */ +export function isPathImport(specifier: string): boolean { + return specifier.startsWith('.') || specifier.startsWith('/') +} + +const IMPORT_RE = + // static `import ... from 'x'`, bare `import 'x'`, and `import('x')` + /(?:import\s+(?:[^'"]*?\s+from\s+)?|import\s*\(\s*|export\s+[^'"]*?\s+from\s+)['"]([^'"]+)['"]/g + +/** Every module specifier a source imports, in source order (deduped). */ +export function extractImports(source: string): Array { + const found = new Set() + let m: RegExpExecArray | null + IMPORT_RE.lastIndex = 0 + while ((m = IMPORT_RE.exec(source)) !== null) { + if (m[1]) found.add(m[1]) + } + return [...found] +} + +/** + * Pre-build allowlist gate (§7.3.3). Returns the registry specifiers the source + * imports (the shell's import-map externals) and diagnostics for any specifier + * not in the registry or resolved as a path. Imports resolve *only* against the + * registry; relative/path imports fail (single-file artifact, §12.4). + */ +export function checkImports( + source: string, + registry: ModuleRegistry, +): { + ok: boolean + externals: Array + diagnostics: Array +} { + const diagnostics: Array = [] + const externals = new Set() + const allowed = new Set(registry.modules.map((m) => m.name)) + + for (const spec of extractImports(source)) { + if (isPathImport(spec)) { + diagnostics.push({ + message: `Relative/path import "${spec}" is not allowed. A frame artifact is a single module; import only registry modules.`, + specifier: spec, + }) + continue + } + if (allowed.has(spec)) { + externals.add(spec) + continue + } + const base = packageBase(spec) + if (allowed.has(base)) { + externals.add(base) + continue + } + const names = registry.modules.map((m) => m.name).join(', ') + diagnostics.push({ + message: `Import "${spec}" is not in the module registry (${registry.version}). Allowed: ${names}.`, + specifier: spec, + }) + } + + return { + ok: diagnostics.length === 0, + externals: [...externals], + diagnostics, + } +} + +/** + * Model-facing description of the registry for authoring guidance (§7.3.2). + * Fed into the create_artifact tool description / system prompt. + */ +export function describeRegistryForPrompt(registry: ModuleRegistry): string { + const lines = registry.modules.map( + (m) => `- \`${m.name}\` (v${m.version}) — ${m.description}`, + ) + return [ + `Module registry ${registry.version}. In a react-tsx artifact you may import ONLY these specifiers (any other import fails the build):`, + ...lines, + ].join('\n') +} diff --git a/packages/ai-generative-ui/src/frame/shell.ts b/packages/ai-generative-ui/src/frame/shell.ts new file mode 100644 index 0000000000..d433b52f13 --- /dev/null +++ b/packages/ai-generative-ui/src/frame/shell.ts @@ -0,0 +1,441 @@ +/** + * The shell contract (Draft 0.2 §5) and the serving CSP (§4.3). + * + * The shell is host-authored and host-versioned: the model never writes it, and + * stored artifacts never contain it, so bridge/theme/security fixes ship to + * every existing artifact without regeneration (§5). This module generates the + * shell document, the frame-side bridge client, and the `@ogui/bridge` runtime + * module — all host runtime, served from the sandbox origin. + */ + +import { FRAME_PROTOCOL } from './types' +import { SHELL_VERSION as SHELL_VERSION_VALUE } from './bridge' +import type { ArtifactRecord, FrameTheme, ModuleRegistry } from './types' + +/** + * Canonical semantic theme token names the shell projects onto the frame + * `:root` (§5.2). Names are the host's choice; these are the reference set. + */ +export const FRAME_THEME_TOKEN_NAMES = [ + '--ui-bg', + '--ui-surface', + '--ui-surface-2', + '--ui-text', + '--ui-muted', + '--ui-border', + '--ui-accent', + '--ui-accent-text', + '--ui-positive', + '--ui-negative', + '--ui-radius', +] as const + +/* -------------------------------------------------------------------------- */ +/* CSP (§4.3) */ +/* -------------------------------------------------------------------------- */ + +export interface CspOptions { + kind: ArtifactRecord['kind'] + /** App origin allowed to frame the sandbox (`frame-ancestors`). */ + appOrigin: string + /** Per-revision granted connect-src domains (§9.3); default none. */ + connectGrants?: Array + /** Nonce for react-tsx shell scripts (strict script-src, §7.3.3). */ + nonce?: string +} + +/** + * Build the sandbox-route CSP (§4.3). react-tsx gets the strict + * `script-src 'self' 'nonce-…'` the spec advertises (all scripts are the + * external built bundle or nonce'd shell bootstrap — no eval, §7.3.3). + * html-fragment relaxes to `'unsafe-inline'` because the model's own inline + * `` : '' + // Center the authored content; the host iframe sizes it once the guest adapter + // sends MCP Apps `ui/notifications/size-changed`. + return ` + + +${escapeHtml(record.title)} + +${guest} + +
${record.source}
+` +} + +/** + * Build the complete shell document for one pinned revision (§5.1). The model + * authored only `record.source`; everything structural — skeleton, bridge, + * theme, error boundary, (for react-tsx) the import map and `createRoot` + * bootstrap — is host code here. + */ +export function buildShellDocument(opts: ShellOptions): string { + const { record, instanceId, appOrigin, theme } = opts + const nonceAttr = + record.kind === 'react-tsx' && opts.nonce ? ` nonce="${opts.nonce}"` : '' + const csp = buildCsp({ + kind: record.kind, + appOrigin, + connectGrants: record.networkGrants, + nonce: opts.nonce, + }) + // MCP-Apps mode (react-tsx): the ext-apps guest in direct transport; else the + // bespoke ogui/1 bridge client. + const client = opts.guestScript + ? `window.__OGUI_DIRECT__=true;${opts.guestScript}` + : bridgeClient(record, instanceId, appOrigin) + const fallback = escapeHtml(record.fallbackText || record.title) + + const head = ` + + + +${escapeHtml(record.title)} +` + + if (record.kind === 'html-fragment') { + // The shell completes the document; the model wrote only the fragment. + return `${head} +
${record.source}
+ + +` + } + + // react-tsx: import map → runtime modules, shell-owned bootstrap behind an + // error boundary (the model wrote a component, never a createRoot, §7.3.1). + const registry = opts.registry + const imports: Record = {} + if (registry) for (const m of registry.modules) imports[m.name] = m.url + const importMap = JSON.stringify({ imports }, null, 2) + const propsJson = JSON.stringify(opts.props ?? {}) + const builtUrl = opts.builtUrl ?? '' + + const bootstrap = /* js */ ` +import React from 'react'; +import { createRoot } from 'react-dom'; +import Artifact from ${JSON.stringify(builtUrl)}; + +var PROPS = ${propsJson}; + +function ErrorCard(props) { + return React.createElement('div', { className: 'ogui-error' }, + React.createElement('h3', null, 'This view could not render'), + React.createElement('p', null, ${JSON.stringify(record.fallbackText || record.title)})); +} + +class Boundary extends React.Component { + constructor(p) { super(p); this.state = { error: null }; } + static getDerivedStateFromError(error) { return { error: error }; } + componentDidCatch(error) { + try { window.dispatchEvent(new ErrorEvent('error', { message: String(error && error.message || error), error: error })); } catch (e) {} + } + render() { return this.state.error ? React.createElement(ErrorCard) : this.props.children; } +} + +try { + var root = createRoot(document.getElementById('root')); + root.render(React.createElement(Boundary, null, React.createElement(Artifact, PROPS))); +} catch (e) { + var el = document.getElementById('root'); + if (el) el.innerHTML = '

' + ${JSON.stringify(fallback)} + '

'; +} +` + + return `${head} + + +
+ +${client} + +` +} diff --git a/packages/ai-generative-ui/src/frame/store.ts b/packages/ai-generative-ui/src/frame/store.ts new file mode 100644 index 0000000000..d6fc4d2d02 Binary files /dev/null and b/packages/ai-generative-ui/src/frame/store.ts differ diff --git a/packages/ai-generative-ui/src/frame/tools.ts b/packages/ai-generative-ui/src/frame/tools.ts new file mode 100644 index 0000000000..d0fb84e8d4 --- /dev/null +++ b/packages/ai-generative-ui/src/frame/tools.ts @@ -0,0 +1,330 @@ +/** + * Artifact tools (Draft 0.2 §8.1), agent-facing. Tool *definitions* (name, + * description, JSON-Schema parameters) for the model, plus a handler factory + * that composes a store (§3) and an injected builder (§7.3.3) into working + * create/revise/read/list/render operations. + * + * `list_artifacts` before `create_artifact` is a spec-level nudge: reuse is + * cheaper than regeneration (§8.1). + */ + +import { hashContent } from './store' +import { describeRegistryForPrompt } from './registry' +import type { ArtifactStore } from './store' +import type { + ArtifactBuilder, + ArtifactRecord, + ArtifactToolResult, + CreateArtifactInput, + ModuleRegistry, + ReviseArtifactInput, +} from './types' + +export interface ArtifactToolDefinition { + name: string + description: string + parameters: Record +} + +/** The agent-facing tool definitions (§8.1). `registry` tailors the react-tsx guidance. */ +export function getArtifactToolDefinitions( + registry: ModuleRegistry, +): Array { + const kindDesc = + 'Authoring kind. "html-fragment": markup + optional