Add experimental composition APIs and playground model option

This commit is contained in:
Chris Tate
2026-09-18 10:18:26 -05:00
parent e11d2d0e63
commit 0f994305bc
25 changed files with 2697 additions and 94 deletions
+1
View File
@@ -808,6 +808,7 @@ pnpm dev
- http://react-email-demo.json-render.localhost:1355 - React Email Example
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat`
- [Experimental Jev composition](https://json-render.dev/docs/jev): use `experimental_composeSpec` and `experimental_createEvaluator` from core with your own catalog, or select **Jev (Experimental)** in `/playground`. Unreleased; source-build instructions are in the guide.
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
+4
View File
@@ -16,6 +16,10 @@ bun dev
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
## Jev composition experiment
The **Jev (Experimental)** model option in `/playground` is a reference consumer of core's reusable `experimental_composeSpec` and `experimental_createEvaluator` APIs. It lets Jev compose new UI trees from the playground's component catalog and allowed action bindings through Vercel AI Gateway. Set `AI_GATEWAY_API_KEY` on the server. It uses the same prompt input, version history, spec/stream inspectors, and functional preview as the default model. The shared `/api/generate` endpoint streams spec patches and decision metadata. See [setup, architecture, and observed limits](lib/jev/README.md).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
@@ -5,6 +5,74 @@ export const metadata = pageMetadata("docs/api/core")
Core types, schemas, and utilities.
## experimental_composeSpec
**Experimental, unreleased:** may change in any release. Pin exact versions when published; see [source-build setup and the full Jev guide](/docs/jev).
```typescript
import {
experimental_composeSpec,
type Experimental_CompositionCandidate,
type Experimental_CompositionEvaluator,
type Experimental_CompositionEvent,
} from "@json-render/core";
const events = experimental_composeSpec({
catalog, // Standard flat Spec catalog
candidates, // App-owned atomic elements
prompt, // User request
evaluate, // Experimental_CompositionEvaluator
initialState: {}, // Included in spec; not sent to evaluator
context: {}, // Explicitly shared evaluator context
maxSteps: 32, // Includes finish/unavailable evaluations
maxDepth: 8, // Root depth is one
signal, // AbortSignal, optional
instructions: { root: "", next: "", parent: "" }, // Appended guidance
});
```
A candidate has `id`, `description`, `element`, optional `root` (default true), `maxUses` (default one), and `resource` (mutually exclusive variants). IDs start with a letter and contain only letters, digits, underscores, and hyphens. IDs must be unique; `finish` and `unavailable` are reserved. Elements accept `type`, `props`, optional `on`, and `visible`. The composer supplies children and named slots.
Events are full detached snapshots. A `step` contains `spec` and `step`; `complete` contains nullable `spec`, `steps`, `elapsedMs`, nullable `inputTokens`, and `stopReason` (`finish`, `limit`, or `unavailable`). Each trace step contains `index`, `choice`, `description`, nullable `parent`/`slot`, nullable `confidence`/`parentConfidence`, `elapsedMs`, and nullable `inputTokens`. Indexes start at zero. Completion is not a guarantee of semantic correctness.
Invalid configuration, out-of-set decisions, provider failures, and aborts throw. Previous snapshots remain usable as partial UI. Stopping iteration prevents further evaluation calls. A supplied signal also stops waiting for an evaluator that ignores cancellation; that evaluator must honor the signal to stop its underlying work.
### Custom evaluators
The composer is provider independent. An evaluator receives `state`, `questions`, and `signal`, then returns a selected criteria key for each question:
```typescript
const evaluate: Experimental_CompositionEvaluator = async ({ state, questions, signal }) => {
// Your adapter calls a decision model with this request.
const result = await yourEvaluator({ state, questions, signal });
return {
answers: result.answers, // { next: { choice: "candidate_id", confidence: 0.9 }, ... }
usage: { inputTokens: result.inputTokens }, // Optional
};
};
```
Questions are records of `type: "choice"`, `instructions`, and `criteria` (choice key to description). `next` selects a candidate, `finish`, or `unavailable`. When multiple attachment points are available, `parent` selects one of the supplied parent/slot keys. Treat these keys as opaque. Confidence must be in [0, 1] when provided; input tokens must be a nonnegative integer.
State contains `user_request`, `already_built` (IDs, types, descriptions, children, and slots), and explicit app `context`. Candidate descriptions and instructions are shared; raw state/props/binding values are not automatically included. See [validation and v1 limits](/docs/jev#validation-and-v1-limits).
## experimental_createEvaluator
**Experimental:** creates a server-side evaluator using Vercel AI Gateway's experimental v4 evaluation transport. No AI SDK dependency or provider constructor is required.
```typescript
import { experimental_createEvaluator } from "@json-render/core";
const evaluate = experimental_createEvaluator({
apiKey: process.env.AI_GATEWAY_API_KEY!, // Required; keep server-side
model: "typesafe-ai/jev", // Required, plain Gateway evaluation model ID
timeoutMs: 10_000, // Default, per evaluation
fetch: globalThis.fetch, // Optional transport override
});
```
The factory uses model-neutral naming and requires an explicit model. Jev is the current tested example; other models must support the Gateway choice-evaluation protocol. When using Jev, the Gateway team must permit TypeSafe AI. The adapter reports HTTP status on failure and rejects malformed/unoffered decisions. For Jev, it normalizes TypeSafe's native confidence rather than treating option probability as confidence. Confidence from other provider metadata is not yet normalized. Missing confidence and usage remain unknown. It does not retry automatically or estimate cost.
## defineCatalog
Creates a type-safe catalog definition with schema validation.
+151
View File
@@ -0,0 +1,151 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/jev")
# Jev (Experimental)
**Experimental:** `experimental_composeSpec` and `experimental_createEvaluator` are reusable APIs in `@json-render/core`. Like AI SDK's experimental APIs, names prefixed with `experimental_` or `Experimental_` may change in any release. Pin exact package versions (no `^` or `~`) and review release notes before upgrading.
**Availability:** these APIs are unreleased. You can try the source build below before they appear in a published npm version.
Open the [playground](/playground), select **Jev (Experimental)** in the model selector, and send a request. Or use your own catalog in your app. [Share feedback](https://github.com/vercel-labs/json-render/issues/new) with your catalog, candidates, request, resulting spec, and expected behavior. Remove private data from reproductions.
## Why use it?
The public API is model-neutral: `experimental_createEvaluator` takes an explicit Gateway evaluation model ID. Jev is the current tested example.
Jev is a decision model from TypeSafe AI. It chooses among discrete options instead of writing free-form text. json-render turns those choices into a normal flat `Spec`, which your existing renderer, component registry, and action handlers can use.
Your app supplies atomic element candidates: component names, concrete props, state bindings, and allowed action bindings. Jev selects which to include, their order, and their placement. The platform controls the available capabilities and design system. The composer never executes actions.
A catalog alone is not enough for Jev: open-ended string props and data still need values. Build candidates from your records, localized copy, form definitions, or prepared content. Jev cannot invent missing prose or data. Each added element requires an evaluation, followed by a finish decision.
## Try it in your app
From a checkout containing this feature, build and pack core:
```sh
pnpm install --frozen-lockfile
pnpm --filter @json-render/core build
pnpm --filter @json-render/core pack --pack-destination /tmp/json-render-preview
```
Install the resulting `.tgz` file in your app with `pnpm add /absolute/path/to/the-file.tgz`. Keep your renderer and other json-render packages on the same version as the checkout. Source builds are for evaluation; the package version alone does not identify the experimental revision, so record the checkout commit in feedback.
### Define the catalog and candidates
This example uses the React schema. The composer supports catalogs using the standard flat `Spec` format, including named slots. It does not support arbitrary custom spec formats.
```typescript
// catalog.ts
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Panel: { props: z.object({ title: z.string() }), slots: ["default"] },
Input: { props: z.object({ label: z.string(), value: z.string() }) },
Button: { props: z.object({ label: z.string() }), events: ["press"] },
},
actions: {
savePreferences: { params: z.object({ name: z.string() }) },
},
});
```
```typescript
// candidates.ts
import type { Experimental_CompositionCandidate } from "@json-render/core";
export const candidates = [
{
id: "preferences",
description: "Account preferences panel",
element: { type: "Panel", props: { title: "Account preferences" } },
},
{
id: "name",
description: "Editable name field",
root: false,
element: {
type: "Input",
props: { label: "Name", value: { $bindState: "/name" } },
},
},
{
id: "save",
description: "Save preferences using the current name",
root: false,
element: {
type: "Button",
props: { label: "Save" },
on: { press: { action: "savePreferences", params: { name: { $state: "/name" } } } },
},
},
] satisfies Experimental_CompositionCandidate[];
```
### Compose on the server
Set `AI_GATEWAY_API_KEY` in your server environment. Your Gateway team must allow the `typesafe-ai` provider. A separate TypeSafe key is not required. Keep the evaluator and credentials on the server.
```typescript
// Server only
import { experimental_composeSpec, experimental_createEvaluator } from "@json-render/core";
import { catalog } from "./catalog";
import { candidates } from "./candidates";
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: process.env.AI_GATEWAY_API_KEY!,
});
for await (const event of experimental_composeSpec({
catalog,
candidates,
prompt: "Create account preferences with a name field and Save button",
initialState: { name: "" },
evaluate,
maxSteps: 12,
signal: AbortSignal.timeout(30_000),
})) {
// Send snapshots to your client and render using your existing registry.
if (event.type === "step") console.log(event.spec);
else console.log(event.stopReason, event.spec);
}
```
The adapter uses the plain model ID `typesafe-ai/jev` and Gateway's experimental v4 evaluation endpoint. It has no AI SDK dependency. The default timeout is 10 seconds per evaluation; use `signal` for an overall deadline. See the [core API reference](/docs/api/core#experimental_composespec) for all options.
### Render and handle actions
Send `step` events over your app's streaming transport and update the preview with `event.spec`. These are full snapshots, not SpecStream patches. Register `Panel`, `Input`, and `Button` in your existing registry, implement `Input` with `useBoundProp`, and bind the `savePreferences` action to your app's handler. See [the React quickstart](/docs/quick-start) and [state binding](/docs/data-binding).
Initialize your renderer's state from `spec.state`. Keep user interaction disabled while composing so incoming snapshots do not compete with edits. Registering an action does not make it safe to execute with arbitrary values: authorize and validate requests in your handler as usual.
On `complete`, inspect `stopReason`: `finish` means the evaluator chose to finish; `unavailable` means it could not fulfill the request; `limit` means the evaluation budget ran out. A complete event can contain a partial spec, or `null` when no root was added. A model's finish decision is not a correctness guarantee. Errors and cancellation throw; retain the last snapshot and label it incomplete.
The playground is a reference implementation: [candidates and server wrapper](https://github.com/vercel-labs/json-render/tree/main/apps/web/lib/jev), [streaming route](https://github.com/vercel-labs/json-render/blob/main/apps/web/app/api/generate/route.ts), and [client](https://github.com/vercel-labs/json-render/blob/main/apps/web/components/playground.tsx).
## Validation and v1 limits
- Candidate props and action parameters are validated against their catalog schemas using `initialState`. Expressions remain intact in the returned spec. Supply valid initial values; schema defaults and transforms are not applied to recipes.
- V1 supports literal values, `$state`, `$bindState`, and state-based visibility. Repeats, watches, computed expressions, templates, conditional props, custom directives, and prebuilt children are not supported.
- Events must be declared by the component. Actions must be in the catalog or the schema's built-in action list. Built-ins without a parameter schema receive name validation only. Success/error callbacks must reference catalog actions.
- Runtime state can change after composition. The composer cannot validate future values or authorize a later action invocation.
- The default budget is 32 evaluations, including the finish decision; default maximum depth is eight. Each candidate is used at most once unless `maxUses` is set. `root: false` excludes it from root selection. A shared `resource` makes candidate variants mutually exclusive.
- Named slots come from the catalog. Jev selects an existing parent/slot; the composer creates the edge and validates structural integrity before yielding. It does not guarantee an ideal layout or semantic completeness.
- The evaluator receives the prompt, candidate descriptions, construction instructions, tree topology, and explicit `context`. Initial state, raw props, and binding values are not sent automatically. Put the information needed to choose candidates in their descriptions.
- Confidence and input usage may be unknown. Confidence is not a calibrated quality threshold. The reusable API does not assume model prices.
## Playground capabilities
The playground offers 15 component types with prepared account/contact fields, validation rules, synthetic commerce data, and local Save/Reset/Submit actions. A title in double quotes becomes an extra Heading candidate. Values entered in the rendered preview stay in the browser.
Select **Jev (Experimental)**, choose **Create account settings**, and send the request. Edit the fields and press **Save changes**. The status changes locally; **Reset** restores the form. Login/contact submission validates inputs and shows a demo toast. The demo does not authenticate users, send messages, or save business records.
Each Jev request starts a new UI; it does not edit the previous version. The default model retains its normal edit flow. The stream tab shows spec patches and decision metadata, and version history labels partial or unavailable results.
The playground limits runs to 14 evaluations, depth four, and 55 seconds overall. Its endpoint uses the web app's minute and daily rate limiters. Self-hosted deployments need `KV_REST_API_URL` and `KV_REST_API_TOKEN` to enable those rate limits.
References: [Jev on Gateway](https://vercel.com/ai-gateway/models/jev), [AI SDK experimental versioning](https://ai-sdk.dev/docs/migration-guides/versioning), [Jev's documented limits](https://docs.typesafe.ai/model-jaggedness/jev-1.13).
+2
View File
@@ -19,6 +19,8 @@ Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/tanstack-start, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, tanstack-start, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
Experimental Jev composition: core exports experimental_composeSpec and experimental_createEvaluator for app-owned catalogs/candidates through Vercel AI Gateway. See /docs/jev for availability, source-build setup, and limits; do not assume the currently published npm version includes it.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
When answering questions:
+6 -2
View File
@@ -10,8 +10,9 @@ import { yamlPrompt } from "@json-render/yaml";
import { stringify as yamlStringify } from "yaml";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
import { playgroundCatalog } from "@/lib/render/catalog";
import { createCompositionResponse } from "@/lib/jev/response";
export const maxDuration = 30;
export const maxDuration = 60;
const PLAYGROUND_RULES = [
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
@@ -92,7 +93,9 @@ export async function POST(req: Request) {
);
}
const { prompt, context, format, editModes } = await req.json();
const { prompt, context, format, editModes, model } = await req.json();
if (model === "typesafe-ai/jev")
return createCompositionResponse(req, prompt);
const isYaml = format === "yaml";
const systemPrompt = getSystemPrompt(isYaml, editModes);
@@ -107,6 +110,7 @@ export async function POST(req: Request) {
const result = streamText({
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
abortSignal: req.signal,
system: [
{
role: "system",
+260 -82
View File
@@ -11,6 +11,8 @@ import {
usePlaygroundStream,
type StreamFormat,
type TokenUsage,
type PlaygroundModel,
type CompositionSummary,
} from "@/lib/use-playground-stream";
import {
ResizablePanelGroup,
@@ -21,6 +23,15 @@ import { CodeBlock } from "./code-block";
import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import Link from "next/link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Badge } from "./ui/badge";
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
import { JsonEditor } from "@visual-json/react";
import type { JsonValue } from "@visual-json/react";
@@ -43,7 +54,10 @@ interface Version {
id: string;
prompt: string;
tree: Spec | null;
status: "generating" | "complete" | "error";
status: "generating" | "complete" | "error" | "partial" | "unavailable";
model: PlaygroundModel;
composition: CompositionSummary | null;
message?: string;
usage: TokenUsage | null;
rawLines: string[];
format: StreamFormat;
@@ -54,7 +68,71 @@ function formatTokens(n: number): string {
return String(n);
}
function ModelSelector({
model,
onChange,
disabled,
}: {
model: PlaygroundModel;
onChange: (model: PlaygroundModel) => void;
disabled: boolean;
}) {
return (
<div className="mb-3 space-y-2">
<Select
value={model}
onValueChange={(value) => onChange(value as PlaygroundModel)}
disabled={disabled}
>
<SelectTrigger size="sm" aria-label="Model" className="w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default model</SelectItem>
<SelectItem value="typesafe-ai/jev">
Jev{" "}
<Badge variant="secondary" className="text-[10px]">
Experimental
</Badge>
</SelectItem>
</SelectContent>
</Select>
{model === "typesafe-ai/jev" && (
<p className="text-[11px] leading-relaxed text-muted-foreground">
Uses prepared fields and data. Each request creates a new UI.{" "}
<Link href="/docs/jev" className="underline underline-offset-2">
About this experiment
</Link>
</p>
)}
</div>
);
}
function VersionDetails({ version }: { version: Version }) {
return (
<>
<div className="mt-1 ml-6 text-[10px] font-mono text-muted-foreground/60">
{version.model === "typesafe-ai/jev"
? "Jev · Experimental"
: "Default model"}
{version.composition &&
` · ${(version.composition.elapsedMs / 1000).toFixed(2)} s · ${version.composition.calls} calls`}
{version.status === "partial" && " · partial"}
{version.status === "unavailable" && " · unavailable"}
</div>
{version.message && (
<p className="mt-1 ml-6 text-xs text-muted-foreground">
{version.message}
</p>
)}
</>
);
}
function PlaygroundControls({
model,
disabled,
format,
setFormat,
editModes,
@@ -62,6 +140,8 @@ function PlaygroundControls({
showClear,
onClear,
}: {
model: PlaygroundModel;
disabled: boolean;
format: StreamFormat;
setFormat: (f: StreamFormat) => void;
editModes: EditMode[];
@@ -71,45 +151,51 @@ function PlaygroundControls({
}) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
{(["jsonl", "yaml"] as const).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`px-1.5 py-0.5 transition-colors ${
format === f
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{f}
</button>
))}
</div>
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
{(["patch", "merge", "diff"] as const).map((m) => (
<button
key={m}
onClick={() => {
setEditModes((prev) =>
prev.includes(m)
? prev.length > 1
? prev.filter((x) => x !== m)
: prev
: [...prev, m],
);
}}
className={`px-1.5 py-0.5 transition-colors ${
editModes.includes(m)
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{m}
</button>
))}
</div>
{showClear && (
{model !== "typesafe-ai/jev" && (
<>
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
{(["jsonl", "yaml"] as const).map((f) => (
<button
key={f}
disabled={disabled}
onClick={() => setFormat(f)}
className={`px-1.5 py-0.5 transition-colors ${
format === f
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{f}
</button>
))}
</div>
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
{(["patch", "merge", "diff"] as const).map((m) => (
<button
key={m}
disabled={disabled}
onClick={() => {
setEditModes((prev) =>
prev.includes(m)
? prev.length > 1
? prev.filter((x) => x !== m)
: prev
: [...prev, m],
);
}}
className={`px-1.5 py-0.5 transition-colors ${
editModes.includes(m)
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{m}
</button>
))}
</div>
</>
)}
{showClear && !disabled && (
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -182,6 +268,29 @@ const EXAMPLE_PROMPTS = [
"Make a contact form",
];
const JEV_EXAMPLE_PROMPTS = [
{
label: "Create a login form",
prompt:
'Create a login card titled "Sign in" with email, password, remember me, and a sign in button.',
},
{
label: "Create account settings",
prompt:
'Create an account settings card titled "Preferences" with full name, email, an email notifications switch, save and reset buttons side by side, and visible save status.',
},
{
label: "Build a sales dashboard",
prompt:
'Build a sales dashboard: heading "Sales overview", revenue, orders and new customers metrics in a three-column grid, then a weekly revenue chart and an order-status table.',
},
{
label: "Make a contact form",
prompt:
'Create a contact card titled "Contact us" with full name, email, topic, a message box, and a send message button.',
},
];
export function Playground() {
const [versions, setVersions] = useState<Version[]>([]);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
@@ -195,7 +304,13 @@ export function Playground() {
const [renderView, setRenderView] = useState<RenderView>("preview");
const [mobileView, setMobileView] = useState<MobileView>("preview");
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
const [format, setFormat] = useState<StreamFormat>("jsonl");
const [preferredFormat, setFormat] = useState<StreamFormat>("jsonl");
const [model, setModel] = useState<PlaygroundModel>("default");
const format = model === "typesafe-ai/jev" ? "jsonl" : preferredFormat;
const examplePrompts =
model === "typesafe-ai/jev"
? JEV_EXAMPLE_PROMPTS
: EXAMPLE_PROMPTS.map((prompt) => ({ label: prompt, prompt }));
const [editModes, setEditModes] = useState<EditMode[]>(["patch"]);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
@@ -211,25 +326,20 @@ export function Playground() {
spec: apiSpec,
isStreaming,
usage: streamUsage,
composition: streamComposition,
error: streamError,
rawLines: streamRawLines,
send,
clear,
stop,
} = usePlaygroundStream({
api: "/api/generate",
model,
format,
editModes,
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
if (generatingVersionIdRef.current) {
const erroredVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
),
);
generatingVersionIdRef.current = null;
}
},
});
@@ -256,13 +366,7 @@ export function Playground() {
: (selectedVersion?.rawLines ?? []);
// Keep the ref updated with the current tree for use in handleSubmit
if (
currentTree &&
currentTree.root &&
Object.keys(currentTree.elements).length > 0
) {
currentTreeRef.current = currentTree;
}
currentTreeRef.current = currentTree?.root ? currentTree : null;
// Scroll to bottom when versions change
useEffect(() => {
@@ -271,12 +375,7 @@ export function Playground() {
// Update version when streaming completes
useEffect(() => {
if (
!isStreaming &&
apiSpec &&
apiSpec.root &&
generatingVersionIdRef.current
) {
if (!isStreaming && generatingVersionIdRef.current) {
const completedVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
@@ -284,7 +383,23 @@ export function Playground() {
? {
...v,
tree: apiSpec,
status: "complete" as const,
status: streamError
? apiSpec?.root
? ("partial" as const)
: ("error" as const)
: streamComposition?.stopReason === "limit"
? ("partial" as const)
: streamComposition?.stopReason === "unavailable"
? ("unavailable" as const)
: ("complete" as const),
message:
streamError?.message ??
(streamComposition?.stopReason === "limit"
? "Evaluation limit reached. The preview is partial."
: streamComposition?.stopReason === "unavailable"
? "This request needs content or capabilities outside the prepared options."
: undefined),
composition: streamComposition,
usage: streamUsage,
rawLines: streamRawLines,
}
@@ -293,10 +408,18 @@ export function Playground() {
);
generatingVersionIdRef.current = null;
}
}, [isStreaming, apiSpec, streamUsage, streamRawLines]);
}, [
isStreaming,
apiSpec,
streamUsage,
streamRawLines,
streamComposition,
streamError,
]);
const handleSubmit = useCallback(async () => {
if (!inputValue.trim() || isStreaming) return;
if (!inputValue.trim() || isStreaming || generatingVersionIdRef.current)
return;
const newVersionId = Date.now().toString();
const newVersion: Version = {
@@ -307,6 +430,8 @@ export function Playground() {
usage: null,
rawLines: [],
format,
model,
composition: null,
};
generatingVersionIdRef.current = newVersionId;
@@ -315,8 +440,13 @@ export function Playground() {
setInputValue("");
// Pass the current tree as context so the API can iterate on it
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
}, [inputValue, isStreaming, send, format]);
await send(
inputValue.trim(),
model === "typesafe-ai/jev"
? undefined
: { previousSpec: currentTreeRef.current },
);
}, [inputValue, isStreaming, send, format, model]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -467,10 +597,12 @@ ${jsx}
{versions.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<p className="text-sm text-muted-foreground mb-4">
Describe what you want to build, then iterate on it.
{model === "typesafe-ai/jev"
? "Describe a UI to compose from the prepared options."
: "Describe what you want to build, then iterate on it."}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
{examplePrompts.map(({ label, prompt }) => (
<button
key={prompt}
onMouseDown={(e) => {
@@ -493,7 +625,7 @@ ${jsx}
}}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
>
{prompt}
{label}
</button>
))}
</div>
@@ -523,6 +655,7 @@ ${jsx}
<span className="text-xs text-red-500 shrink-0">failed</span>
)}
</div>
<VersionDetails version={version} />
{version.usage && (
<div className="mt-1 ml-6">
<span className="text-[10px] font-mono text-muted-foreground/60">
@@ -546,24 +679,39 @@ ${jsx}
onMouseDown={(e) => {
// Focus textarea unless clicking a button or the textarea itself
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
if (
!target.closest("button, a, [role=combobox]") &&
target.tagName !== "TEXTAREA"
) {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
<ModelSelector
model={model}
onChange={setModel}
disabled={isStreaming}
/>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe changes..."
placeholder={
model === "typesafe-ai/jev"
? "Describe a new UI..."
: "Describe changes..."
}
maxLength={model === "typesafe-ai/jev" ? 1000 : undefined}
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
autoFocus
/>
<div className="flex justify-between items-center mt-2">
<PlaygroundControls
model={model}
disabled={isStreaming}
format={format}
setFormat={setFormat}
editModes={editModes}
@@ -572,12 +720,14 @@ ${jsx}
onClear={() => {
setVersions([]);
setSelectedVersionId(null);
generatingVersionIdRef.current = null;
currentTreeRef.current = null;
clear();
}}
/>
{isStreaming ? (
<button
onClick={() => clear()}
onClick={stop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
@@ -868,8 +1018,12 @@ ${jsx}
<div className="flex-1 overflow-auto">
{renderView === "preview" ? (
currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center p-6">
<div
className="w-full min-h-full flex items-center justify-center p-6"
inert={isStreaming}
>
<PlaygroundRenderer
key={selectedVersionId}
spec={currentTree}
data={currentTree.state}
loading={isStreaming}
@@ -1148,8 +1302,12 @@ ${jsx}
/>
) : mobileView === "preview" ? (
currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center p-6">
<div
className="w-full min-h-full flex items-center justify-center p-6"
inert={isStreaming}
>
<PlaygroundRenderer
key={selectedVersionId}
spec={currentTree}
data={currentTree.state}
loading={isStreaming}
@@ -1164,10 +1322,12 @@ ${jsx}
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
Describe what you want to build, then iterate on it.
{model === "typesafe-ai/jev"
? "Describe a UI to compose from the prepared options."
: "Describe what you want to build, then iterate on it."}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
{examplePrompts.map(({ label, prompt }) => (
<button
key={prompt}
onMouseDown={(e) => {
@@ -1181,7 +1341,7 @@ ${jsx}
}}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
>
{prompt}
{label}
</button>
))}
</div>
@@ -1205,23 +1365,38 @@ ${jsx}
className="border-t border-border p-3 shrink-0 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
if (
!target.closest("button, a, [role=combobox]") &&
target.tagName !== "TEXTAREA"
) {
e.preventDefault();
mobileInputRef.current?.focus();
}
}}
>
<ModelSelector
model={model}
onChange={setModel}
disabled={isStreaming}
/>
<textarea
ref={mobileInputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe changes..."
placeholder={
model === "typesafe-ai/jev"
? "Describe a new UI..."
: "Describe changes..."
}
maxLength={model === "typesafe-ai/jev" ? 1000 : undefined}
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
<PlaygroundControls
model={model}
disabled={isStreaming}
format={format}
setFormat={setFormat}
editModes={editModes}
@@ -1230,12 +1405,14 @@ ${jsx}
onClear={() => {
setVersions([]);
setSelectedVersionId(null);
generatingVersionIdRef.current = null;
currentTreeRef.current = null;
clear();
}}
/>
{isStreaming ? (
<button
onClick={() => clear()}
onClick={stop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
@@ -1308,6 +1485,7 @@ ${jsx}
</span>
)}
</div>
<VersionDetails version={version} />
{version.usage && (
<div className="mt-1 ml-6">
<span className="text-[10px] font-mono text-muted-foreground/60">
+1
View File
@@ -60,6 +60,7 @@ export const docsNavigation: NavSection[] = [
title: "Integrations",
items: [
{ title: "AI SDK", href: "/docs/ai-sdk" },
{ title: "Jev (Experimental)", href: "/docs/jev" },
{ title: "A2UI", href: "/docs/a2ui" },
{ title: "Adaptive Cards", href: "/docs/adaptive-cards" },
{ title: "AG-UI", href: "/docs/ag-ui" },
+71
View File
@@ -0,0 +1,71 @@
# Jev composing catalog UI
Open **`/playground`** and select **Jev (Experimental)** in the model selector. This experiment starts with an empty spec and uses Jev through Vercel AI Gateway to compose a new tree. It renders with the **actual playground catalog and registry**, including the existing shadcn components, state bindings, validation, and action handlers.
## Run
Set `AI_GATEWAY_API_KEY` in `apps/web/.env.local` or the server environment. The Gateway team must permit the `typesafe-ai` provider. No separate TypeSafe API key is required.
From the repository root:
```sh
pnpm --filter web dev
```
Use the portless URL printed by the command, followed by `/playground`. With the HTTPS proxy enabled this is `https://json-render.localhost/playground`.
Select Jev, choose Create account settings, and send the request. Edit the name, switch notifications on, click Save changes, and then Reset. The action handlers run only on user interaction. Form submission validates and shows a toast; it does not authenticate a user or send a message. All business data is synthetic.
## How Jev produces a spec
Jev exposes Choice, Boolean, and Score outputs. It does not produce free-form JSON or prose. We express UI construction as a sequence of finite choices:
1. Begin with an empty spec and platform state.
2. Offer allowed catalog operations, such as adding an email Input with a state binding, a Grid with three columns, or a Button bound to `formSubmit`.
3. Jev chooses the next operation and an existing container to receive it. Once multiple parents exist, the two choices are returned in the same evaluation call.
4. Code resolves the chosen operation into an element, assigns its ID, adds the tree edge, and validates the result against the catalog and initial state. Action names, events, and parameters are also checked.
5. Stream the valid spec to the existing renderer. Feed a compact description of the constructed tree into the next evaluation.
6. Stop when Jev chooses `finish` or `unavailable`, or the code reaches its element/call limit.
There are **no complete UI templates** and no generative-model calls. The example prompt buttons only populate the request text. Jev chooses which elements to include, their order, grouping, and which offered action bindings to use. The registry owns appearance and behavior.
The loop resembles autoregressive generation at the level of catalog operations. Jev does not author the serialized JSON; code assembles it from the choices.
## What the platform must supply
A component catalog bounds component names, props, and events, but string and array props still have open-ended values. This example closes that remaining space with platform-owned content and binding recipes:
- 15 component types from the playground catalog: Card, Stack, Grid, Heading, Input, Textarea, Select, Checkbox, Switch, Button, Text, Metric, BarGraph, Table, and Separator.
- Form fields, validation rules, labels, synthetic commerce data, and two allowed catalog actions (`formSubmit` and `setState`).
- Several useful values for layout props and button labels. Quoted titles in the request are copied into additional Heading choices.
These are **atomic element candidates**, not page templates. A host application could build them from its actual data schema, records, localized copy, and permitted operations. This example supplies those values in `grammar.ts`; apps supply their own candidates to the reusable core API. Repeating the same field in multiple forms and arbitrary new text/data are not supported.
## Limits
The composer validates tree structure and candidate values; it does not guarantee that Jev chose the right UI. Root selection, grouping, and deciding when to stop require planning, which is a documented weakness of Jev. Confidence is displayed without a quality gate: multiple layout choices may be reasonable, and a universal threshold has not been calibrated.
The code bounds composition to 14 elements / evaluation calls, nesting depth four, ten seconds per provider request, and 55 seconds overall. A limit, cancellation, or error leaves a visibly partial preview. The shared endpoint uses the web app's request rate limiters. Each Jev request starts from an empty spec; select the default model for iterative edits. The stream tab exposes construction decisions alongside spec patches. Provider calls and spec assembly never execute the selected UI actions.
## Transport and files
The server uses Gateway's experimental v4 evaluation transport with model `typesafe-ai/jev`. This was verified against `@ai-sdk/gateway@4.0.85`. Native fetch avoids upgrading the workspace's AI SDK 6 dependencies or bypassing its minimum release age. The protocol can change; migrate to the eligible AI SDK evaluation API with a plain model string when appropriate.
- `grammar.ts`: playground-owned values and atomic candidates.
- `packages/core/src/experimental-compose.ts`: public provider-independent composer.
- `packages/core/src/experimental-evaluator.ts`: public Gateway evaluator adapter.
- `compose.ts`: public API consumer with playground instructions and cost display.
- `../../app/api/generate/route.ts`: shared rate-limited endpoint, dispatching the selected model.
- `response.ts`: adapts composition snapshots into the playground's JSONL spec patches and decision metadata.
- `../../components/playground.tsx`: shared model selector, prompt, version history, live preview, and inspectors.
- `compose.test.ts`: structure, action boundaries, unknown usage, cancellation, and limits.
```sh
pnpm exec vitest run packages/core/src/experimental-compose.test.ts packages/core/src/experimental-evaluator.test.ts apps/web/lib/jev/compose.test.ts
pnpm type-check
```
References: [Jev on Gateway](https://vercel.com/ai-gateway/models/jev), [AI SDK evaluation](https://ai-sdk.dev/docs/ai-sdk-core/evaluation), [Jev's documented limits](https://docs.typesafe.ai/model-jaggedness/jev-1.13).
For app integration and source-build installation, see the [Jev guide](https://json-render.dev/docs/jev). The `experimental_` APIs may change in any release; pin exact versions.
+149
View File
@@ -0,0 +1,149 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { composeUI, type CompositionEvent, type Evaluate } from "./compose";
import { buildCandidates, MAX_ELEMENTS } from "./grammar";
function scripted(
choices: { next: string; parent?: string }[],
usage: number | undefined = 100,
): Evaluate {
let index = 0;
return async ({ questions }) => {
const selected = choices[index++];
if (!selected) throw new Error("Unexpected extra model call");
const answers = Object.fromEntries(
Object.keys(questions).map((name) => [
name,
{
confidence: 0.9,
choice: selected[name as keyof typeof selected]!,
},
]),
);
return {
answers,
usage: { inputTokens: usage },
};
};
}
async function collect(
evaluate: Evaluate,
signal = new AbortController().signal,
) {
const events: CompositionEvent[] = [];
for await (const event of composeUI(
'Create settings titled "Preferences".',
signal,
evaluate,
))
events.push(event);
return events;
}
describe("Jev catalog composition", () => {
it("composes a new nested tree with state bindings and catalog actions", async () => {
const events = await collect(
scripted([
{ next: "card" },
{ next: "input_email" },
{ next: "stack_horizontal" },
{ next: "save", parent: "node_2" },
{ next: "reset", parent: "node_2" },
{ next: "status", parent: "node_0" },
{ next: "finish", parent: "node_0" },
]),
);
const result = events.at(-1)!;
expect(result.type).toBe("complete");
if (result.type !== "complete") throw new Error("Missing final result");
expect(result.stopReason).toBe("finish");
expect(result.spec?.elements.node_0?.children).toEqual([
"node_1",
"node_2",
"node_5",
]);
expect(result.spec?.elements.node_2?.children).toEqual([
"node_3",
"node_4",
]);
expect(result.spec?.elements.node_1?.props.value).toEqual({
$bindState: "/form/email",
});
expect(result.spec?.elements.node_3?.on?.press).toEqual({
action: "setState",
params: { statePath: "/status", value: "Changes saved locally." },
});
expect(result.inputTokens).toBe(700);
// Streamed snapshots stay immutable as later elements are appended.
const first = events[0]!;
expect(first.type === "step" && Object.keys(first.spec.elements)).toEqual([
"node_0",
]);
});
it("cannot accept an arbitrary component, path, or nonexistent parent from the model", async () => {
await expect(
collect(scripted([{ next: "execute_shell" }])),
).rejects.toThrow("outside the permitted");
await expect(
collect(
scripted([
{ next: "card" },
{ next: "stack_horizontal" },
{ next: "save", parent: "/secrets" },
]),
),
).rejects.toThrow("outside the permitted");
});
it("supplies literal quoted text as a value, never as executable structure", () => {
const text = "<script>alert(1)</script>";
const candidates = buildCandidates(`Title the UI "${text}".`);
expect(
candidates.find((c) => c.element.props.text === text)?.element.type,
).toBe("Heading");
});
it("reports unavailable capability without producing a misleading empty UI", async () => {
const events = await collect(scripted([{ next: "unavailable" }]));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "complete",
spec: null,
stopReason: "unavailable",
});
});
it("preserves unknown usage and stops at the configured call budget", async () => {
const events = await collect(
scripted([{ next: "card" }, { next: "finish" }], undefined),
);
// Explicitly remove usage to exercise the missing-usage path.
const missingUsage: Evaluate = async (request) => {
const result = await scripted([{ next: "unavailable" }])(request);
return { ...result, usage: undefined };
};
expect((await collect(missingUsage)).at(-1)).toMatchObject({
inputTokens: null,
estimatedCostUsd: null,
});
expect(events.at(-1)?.type).toBe("complete");
const choices = [
{ next: "card" },
...Array.from({ length: MAX_ELEMENTS - 1 }, () => ({
next: "separator",
})),
];
const limited = (await collect(scripted(choices))).at(-1);
expect(limited).toMatchObject({ type: "complete", stopReason: "limit" });
if (limited?.type === "complete")
expect(Object.keys(limited.spec!.elements)).toHaveLength(MAX_ELEMENTS);
});
it("honors cancellation before making another provider request", async () => {
const controller = new AbortController();
controller.abort();
await expect(collect(scripted([]), controller.signal)).rejects.toThrow();
});
});
+57
View File
@@ -0,0 +1,57 @@
import {
experimental_composeSpec,
experimental_createEvaluator,
type Experimental_CompositionEvaluator,
type Experimental_CompositionEvent,
type Experimental_CompositionStep,
} from "@json-render/core";
import { playgroundCatalog } from "../render/catalog";
import { buildCandidates, MAX_ELEMENTS, platformState } from "./grammar";
export type Evaluate = Experimental_CompositionEvaluator;
export type TraceStep = Experimental_CompositionStep;
export type CompositionEvent =
| Extract<Experimental_CompositionEvent, { type: "step" }>
| (Extract<Experimental_CompositionEvent, { type: "complete" }> & {
estimatedCostUsd: number | null;
})
| { type: "error"; message: string };
/** The playground supplies its own content and recipes to the public API. */
export async function* composeUI(
prompt: string,
signal: AbortSignal,
evaluate: Evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "",
}),
): AsyncGenerator<CompositionEvent> {
for await (const event of experimental_composeSpec({
catalog: playgroundCatalog,
candidates: buildCandidates(prompt),
initialState: platformState,
prompt,
signal,
evaluate,
maxSteps: MAX_ELEMENTS,
maxDepth: 4,
context: {
platform:
"Available: account/contact fields (name, email, password, message, topic, remember-me, notifications); form submit/save/reset demo actions; synthetic sales revenue, orders, customers, a weekly revenue chart, and order-status table. Quoted titles may be copied from the request. Actions run on later user interaction. Submission is a validation/toast demo, not an authentication or messaging service.",
},
instructions: {
root: "Use Card for a compact form. Use vertical Stack for a page with a heading and several sections, including a dashboard containing a metric row followed by charts or tables. Use Grid as root only when the entire page is one uniform grid of peers.",
next: "Before adding a requested side-by-side group, add its Grid or horizontal Stack if missing. Add only requested content or conventional essentials (login needs email, password, and submit). Prefer a compact tree.",
parent:
"Never put headings or form fields inside a horizontal button row. Choose the root for a new top-level section.",
},
})) {
if (event.type === "complete") {
yield {
...event,
estimatedCostUsd:
event.inputTokens === null ? null : (event.inputTokens * 0.042) / 1e6,
};
} else yield event;
}
}
+308
View File
@@ -0,0 +1,308 @@
import type {
Experimental_CompositionCandidate,
UIElement,
} from "@json-render/core";
export const MAX_ELEMENTS = 14;
export type Candidate = Experimental_CompositionCandidate;
const fieldValues = {
name: "",
email: "",
password: "",
message: "",
topic: "General",
notifications: false,
remember: false,
};
export const platformState = {
form: fieldValues,
status: "No changes saved yet.",
};
/** Prop values are platform content, never model-invented strings or code. */
export function buildCandidates(prompt: string): Candidate[] {
const candidates: Candidate[] = [];
function add(
id: string,
description: string,
type: string,
props: Record<string, unknown>,
resource?: string,
on?: UIElement["on"],
) {
candidates.push({
id,
description,
resource,
root: ["card", "stack_vertical", "grid_two", "grid_three"].includes(id),
maxUses: ["Card", "Stack", "Grid", "Separator"].includes(type)
? MAX_ELEMENTS
: 1,
element: { type, props, ...(on ? { on } : {}) },
});
}
add(
"card",
"Card: a bordered container for a compact form or related content.",
"Card",
{ title: null, description: null, maxWidth: "md", centered: true },
);
add(
"stack_vertical",
"Stack: vertical layout for a page or section.",
"Stack",
{ direction: "vertical", gap: "md", align: "stretch", justify: "start" },
);
add(
"stack_horizontal",
"Stack: horizontal row, e.g. side-by-side buttons.",
"Stack",
{ direction: "horizontal", gap: "sm", align: "center", justify: "start" },
);
add("grid_two", "Grid: two equal columns for side-by-side content.", "Grid", {
columns: 2,
gap: "md",
});
add(
"grid_three",
"Grid: three equal columns, e.g. a row of metrics.",
"Grid",
{ columns: 3, gap: "md" },
);
const titles = [
"Sign in",
"Contact us",
"Account settings",
"Sales overview",
"Customer overview",
"Create account",
"Support request",
];
// Quoted labels are copied from the request; Jev can select them without generating text.
const quoted = [...prompt.matchAll(/["“]([^"”\n]{1,80})["”]/g)].map(
(match) => match[1]!,
);
for (const [index, text] of [...new Set([...titles, ...quoted])]
.slice(0, 12)
.entries()) {
add(
`heading_${index}`,
`Heading with the exact text ${JSON.stringify(text)}.`,
"Heading",
{ text, level: "h2" },
`text:${text}`,
);
}
for (const [name, label, type] of [
["name", "Full name", "text"],
["email", "Email", "email"],
["password", "Password", "password"],
] as const) {
add(
`input_${name}`,
`Input: editable ${label.toLowerCase()} field. ${name === "password" ? "For sign-in or account creation." : ""}`,
"Input",
{
name,
label,
type,
placeholder: null,
value: { $bindState: `/form/${name}` },
checks: [
{ type: "required", message: `${label} is required.` },
...(type === "email"
? [{ type: "email", message: "Enter a valid email address." }]
: []),
],
},
`field:${name}`,
);
}
add(
"message",
"Textarea: editable multi-line message or support inquiry.",
"Textarea",
{
name: "message",
label: "Message",
placeholder: null,
rows: 4,
value: { $bindState: "/form/message" },
checks: [{ type: "required", message: "Enter a message." }],
},
"field:message",
);
add(
"topic",
"Select: choose a contact topic from General, Billing, Technical.",
"Select",
{
name: "topic",
label: "Topic",
options: ["General", "Billing", "Technical"],
placeholder: null,
value: { $bindState: "/form/topic" },
checks: null,
},
"field:topic",
);
add(
"remember",
"Checkbox: remember me when signing in.",
"Checkbox",
{
name: "remember",
label: "Remember me",
checked: { $bindState: "/form/remember" },
},
"field:remember",
);
add(
"notifications_switch",
"Switch: enable email notifications in account settings.",
"Switch",
{
name: "notifications",
label: "Email notifications",
checked: { $bindState: "/form/notifications" },
},
"field:notifications",
);
add(
"notifications_checkbox",
"Checkbox: enable email notifications, if a checkbox is requested.",
"Checkbox",
{
name: "notifications",
label: "Email notifications",
checked: { $bindState: "/form/notifications" },
},
"field:notifications",
);
const submitLabels = ["Sign in", "Create account", "Send message", "Submit"];
for (const [index, label] of submitLabels.entries()) {
add(
`submit_${index}`,
`Button labeled ${JSON.stringify(label)}. Bind press to the catalog's formSubmit action, which validates inputs and shows a demo toast.`,
"Button",
{ label, variant: "primary", disabled: false },
"action:submit",
{ press: { action: "formSubmit", params: { formName: "jev-form" } } },
);
}
add(
"save",
"Button: Save changes. Bind press to setState to update the visible saved-status text. Local demo only.",
"Button",
{ label: "Save changes", variant: "primary", disabled: false },
"action:save",
{
press: {
action: "setState",
params: { statePath: "/status", value: "Changes saved locally." },
},
},
);
add(
"reset",
"Button: Reset. Bind press to setState to restore all form fields to their initial values.",
"Button",
{ label: "Reset", variant: "outline", disabled: false },
"action:reset",
{
press: {
action: "setState",
params: { statePath: "/form", value: structuredClone(fieldValues) },
},
},
);
add(
"status",
"Text: live save status, bound to /status. Include alongside Save changes.",
"Text",
{ text: { $state: "/status" }, variant: "muted" },
"data:status",
);
add(
"revenue",
"Metric: total sales revenue, $48,250, up 12.8%. Synthetic platform data.",
"Metric",
{
label: "Revenue",
value: "48,250",
prefix: "$",
suffix: null,
change: "+12.8%",
changeType: "positive",
},
"data:revenue",
);
add(
"orders",
"Metric: 384 orders, up 8.2%. Synthetic platform data.",
"Metric",
{
label: "Orders",
value: "384",
prefix: null,
suffix: null,
change: "+8.2%",
changeType: "positive",
},
"data:orders",
);
add(
"customers",
"Metric: 125 new customers, up 14.4%. Synthetic platform data.",
"Metric",
{
label: "New customers",
value: "125",
prefix: null,
suffix: null,
change: "+14.4%",
changeType: "positive",
},
"data:customers",
);
add(
"sales_chart",
"BarGraph: weekly revenue chart (Week 1–4). Synthetic platform data.",
"BarGraph",
{
title: "Weekly revenue",
data: [
{ label: "Week 1", value: 9200 },
{ label: "Week 2", value: 11400 },
{ label: "Week 3", value: 12650 },
{ label: "Week 4", value: 15000 },
],
},
"data:sales_chart",
);
add(
"orders_table",
"Table: order-status breakdown with Fulfilled, Processing, and Returned counts. Synthetic platform data.",
"Table",
{
columns: ["Status", "Orders"],
rows: [
["Fulfilled", "312"],
["Processing", "54"],
["Returned", "18"],
],
caption: "Synthetic order data",
},
"data:orders_table",
);
add(
"separator",
"Separator: horizontal dividing line, only when requested.",
"Separator",
{ orientation: "horizontal" },
);
return candidates;
}
+104
View File
@@ -0,0 +1,104 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import { type JsonPatch, type Spec } from "@json-render/core";
import { applySpecPatch } from "../spec-patch";
import { createCompositionResponse } from "./response";
import { composeUI } from "./compose";
vi.mock("./compose", () => ({ composeUI: vi.fn() }));
afterEach(() => {
vi.unstubAllEnvs();
vi.resetAllMocks();
});
describe("playground composition response", () => {
it("adapts snapshots to the existing patch stream, including the final decision", async () => {
vi.stubEnv("AI_GATEWAY_API_KEY", "test");
const spec: Spec = {
root: "card",
elements: { card: { type: "Card", props: {}, children: [] } },
state: { name: "" },
};
const step = {
index: 0,
choice: "card",
description: "Card",
parent: null,
slot: null,
confidence: null,
parentConfidence: null,
elapsedMs: 10,
inputTokens: null,
};
vi.mocked(composeUI).mockImplementation(async function* () {
yield { type: "step", spec, step };
yield {
type: "complete",
spec,
steps: [step, { ...step, index: 1, choice: "finish" }],
stopReason: "finish",
elapsedMs: 20,
inputTokens: null,
estimatedCostUsd: null,
};
});
const response = createCompositionResponse(
new Request("https://example.com/api/generate"),
"Create a card",
);
const lines = (await response.text())
.trim()
.split("\n")
.map((line) => JSON.parse(line));
let actual: Spec = { root: "", elements: {} };
for (const line of lines)
if (line.op) actual = applySpecPatch(actual, line as JsonPatch);
expect(actual).toEqual(spec);
expect(
lines
.filter((line) => line.__meta === "decision")
.map((line) => line.choice),
).toEqual(["card", "finish"]);
expect(lines.at(-1)).toMatchObject({
__meta: "composition",
stopReason: "finish",
calls: 2,
inputTokens: null,
});
});
it("retains unavailable outcomes and sends failures in the shared protocol", async () => {
vi.stubEnv("AI_GATEWAY_API_KEY", "test");
vi.mocked(composeUI).mockImplementation(async function* () {
yield {
type: "complete",
spec: null,
steps: [],
stopReason: "unavailable",
elapsedMs: 1,
inputTokens: null,
estimatedCostUsd: null,
};
});
const request = new Request("https://example.com/api/generate");
expect(
await createCompositionResponse(request, "Unavailable request").text(),
).toContain('"stopReason":"unavailable"');
vi.mocked(composeUI).mockImplementation(async function* () {
yield { type: "error", message: "Provider failed" };
});
expect(
await createCompositionResponse(request, "Create a form").text(),
).toContain('"__meta":"error"');
});
it("validates requests before starting the model", async () => {
const request = new Request("https://example.com/api/generate");
expect(createCompositionResponse(request, " ").status).toBe(400);
vi.stubEnv("AI_GATEWAY_API_KEY", "");
expect(createCompositionResponse(request, "Create a form").status).toBe(
503,
);
expect(composeUI).not.toHaveBeenCalled();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { z } from "zod";
import { diffToPatches, type Spec } from "@json-render/core";
import { composeUI } from "./compose";
const inputSchema = z.object({ prompt: z.string().trim().min(1).max(1000) });
export function createCompositionResponse(request: Request, prompt: unknown) {
const input = inputSchema.safeParse({ prompt });
if (!input.success)
return Response.json(
{ error: "Enter a request between 1 and 1,000 characters." },
{ status: 400 },
);
if (!process.env.AI_GATEWAY_API_KEY?.trim())
return Response.json(
{
error:
"Jev is temporarily unavailable. Choose the default model to continue.",
},
{ status: 503 },
);
const encoder = new TextEncoder();
const controller = new AbortController();
const signal = AbortSignal.any([
request.signal,
controller.signal,
AbortSignal.timeout(55000),
]);
const stream = new ReadableStream({
async start(output) {
const send = (event: unknown) =>
output.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
let previousSpec: Spec = { root: "", elements: {} };
let decisions = 0;
try {
for await (const event of composeUI(input.data.prompt, signal)) {
if (event.type === "error") throw new Error(event.message);
if (event.type === "step") {
for (const patch of diffToPatches(
previousSpec as unknown as Record<string, unknown>,
event.spec as unknown as Record<string, unknown>,
))
send(patch);
previousSpec = event.spec;
send({ __meta: "decision", ...event.step });
decisions++;
} else {
for (const step of event.steps.slice(decisions))
send({ __meta: "decision", ...step });
send({
__meta: "composition",
stopReason: event.stopReason,
elapsedMs: event.elapsedMs,
inputTokens: event.inputTokens,
calls: event.steps.length,
estimatedCostUsd: event.estimatedCostUsd,
});
}
}
} catch (error) {
if (!controller.signal.aborted && !request.signal.aborted) {
send({
__meta: "error",
message:
error instanceof z.ZodError
? "Jev returned an invalid decision payload."
: error instanceof Error
? error.message
: "Composition failed.",
});
}
} finally {
if (!controller.signal.aborted) output.close();
}
},
cancel() {
controller.abort();
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/x-ndjson",
"Cache-Control": "no-store",
},
});
}
+1
View File
@@ -33,6 +33,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/custom-schema": "Custom Schema & Renderer",
"docs/devtools": "Devtools",
"docs/ai-sdk": "AI SDK Integration",
"docs/jev": "Jev (Experimental)",
"docs/adaptive-cards": "Adaptive Cards Integration",
"docs/openapi": "OpenAPI Integration",
"docs/a2ui": "A2UI Integration",
+209
View File
@@ -0,0 +1,209 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlaygroundStream } from "./use-playground-stream";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const patches = [
{ op: "add", path: "/root", value: "text" },
{
op: "add",
path: "/elements/text",
value: { type: "Text", props: { text: "Hello" }, children: [] },
},
];
function stream(lines: unknown[], trailingNewline = true) {
const text =
lines.map((line) => JSON.stringify(line)).join("\n") +
(trailingNewline ? "\n" : "");
const bytes = new TextEncoder().encode(text);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(bytes.slice(0, 17));
controller.enqueue(bytes.slice(17));
controller.close();
},
}),
);
}
describe("playground model streaming", () => {
it("uses the shared API for Jev, starts fresh, and retains decisions and completion metadata", async () => {
const fetch = vi.fn(async () =>
stream(
[
...patches,
{ __meta: "decision", choice: "text" },
{
__meta: "composition",
stopReason: "finish",
calls: 2,
elapsedMs: 40,
inputTokens: null,
estimatedCostUsd: null,
},
],
false,
),
);
vi.stubGlobal("fetch", fetch);
const { result } = renderHook(() =>
usePlaygroundStream({
api: "/api/generate",
model: "typesafe-ai/jev",
format: "yaml",
}),
);
await act(async () =>
result.current.send("Create UI", {
previousSpec: { root: "old", elements: { old: {} } },
}),
);
const call = fetch.mock.calls[0] as unknown as [string, RequestInit];
expect(call[0]).toBe("/api/generate");
expect(JSON.parse(call[1].body as string)).toMatchObject({
model: "typesafe-ai/jev",
format: "jsonl",
});
expect(JSON.parse(call[1].body as string)).not.toHaveProperty("context");
expect(result.current.spec?.elements).not.toHaveProperty("old");
expect(result.current.spec?.root).toBe("text");
expect(result.current.composition).toMatchObject({
stopReason: "finish",
calls: 2,
inputTokens: null,
});
expect(result.current.usage).toBeNull();
expect(result.current.rawLines).toHaveLength(4);
});
it("keeps default-model editing and usage working", async () => {
const fetch = vi.fn(async () =>
stream([
{ op: "replace", path: "/elements/text/props/text", value: "Edited" },
{
__meta: "usage",
promptTokens: 4,
completionTokens: 2,
totalTokens: 6,
},
]),
);
vi.stubGlobal("fetch", fetch);
const previousSpec = {
root: "text",
elements: { text: patches[1]!.value },
};
const { result } = renderHook(() =>
usePlaygroundStream({ api: "/api/generate", format: "jsonl" }),
);
await act(async () => result.current.send("Edit", { previousSpec }));
expect(result.current.spec?.elements.text?.props.text).toBe("Edited");
expect(result.current.usage?.totalTokens).toBe(6);
expect(result.current.composition).toBeNull();
const call = fetch.mock.calls[0] as unknown as [string, RequestInit];
expect(JSON.parse(call[1].body as string).context.previousSpec).toEqual(
previousSpec,
);
});
it.each(["unavailable", "limit"])(
"preserves %s as a distinct completion outcome",
async (stopReason) => {
vi.stubGlobal("fetch", async () =>
stream([
{
__meta: "composition",
stopReason,
calls: 1,
elapsedMs: 20,
inputTokens: null,
estimatedCostUsd: null,
},
]),
);
const { result } = renderHook(() =>
usePlaygroundStream({
api: "/api/generate",
model: "typesafe-ai/jev",
format: "jsonl",
}),
);
await act(async () => result.current.send("Create UI"));
expect(result.current.composition?.stopReason).toBe(stopReason);
expect(result.current.isStreaming).toBe(false);
},
);
it("retains partial specs on errors and rejects a truncated composition", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
stream([...patches, { __meta: "error", message: "Provider failed" }]),
)
.mockResolvedValueOnce(stream(patches));
vi.stubGlobal("fetch", fetch);
const { result } = renderHook(() =>
usePlaygroundStream({
api: "/api/generate",
model: "typesafe-ai/jev",
format: "jsonl",
}),
);
await act(async () => result.current.send("Create UI"));
expect(result.current.error?.message).toBe("Provider failed");
expect(result.current.spec?.root).toBe("text");
await act(async () => result.current.send("Try again"));
expect(result.current.error?.message).toContain("ended early");
expect(result.current.isStreaming).toBe(false);
});
it("Stop aborts the active request without discarding its partial spec", async () => {
const fetch = vi.fn(
async (_url: string, init: RequestInit) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
patches.map((patch) => JSON.stringify(patch)).join("\n") +
"\n",
),
);
init.signal!.addEventListener(
"abort",
() =>
controller.error(new DOMException("Stopped", "AbortError")),
{ once: true },
);
},
}),
),
);
vi.stubGlobal("fetch", fetch);
const { result } = renderHook(() =>
usePlaygroundStream({
api: "/api/generate",
model: "typesafe-ai/jev",
format: "jsonl",
}),
);
let pending: Promise<void>;
await act(async () => {
pending = result.current.send("Create UI");
await Promise.resolve();
});
expect(result.current.spec?.root).toBe("text");
await act(async () => {
result.current.stop();
await pending;
});
expect(result.current.error?.message).toContain("Stopped");
expect(result.current.spec?.root).toBe("text");
expect(result.current.isStreaming).toBe(false);
});
});
+88 -9
View File
@@ -15,6 +15,16 @@ import {
} from "@json-render/yaml";
import { applySpecPatch } from "./spec-patch";
export type PlaygroundModel = "default" | "typesafe-ai/jev";
export interface CompositionSummary {
stopReason: "finish" | "limit" | "unavailable";
elapsedMs: number;
inputTokens: number | null;
calls: number;
estimatedCostUsd: number | null;
}
export type StreamFormat = "jsonl" | "yaml";
export interface TokenUsage {
@@ -27,6 +37,7 @@ export interface TokenUsage {
export interface UsePlaygroundStreamOptions {
api: string;
model?: PlaygroundModel;
format: StreamFormat;
editModes?: EditMode[];
onError?: (error: Error) => void;
@@ -38,9 +49,11 @@ export interface UsePlaygroundStreamReturn {
isStreaming: boolean;
error: Error | null;
usage: TokenUsage | null;
composition: CompositionSummary | null;
rawLines: string[];
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
clear: () => void;
stop: () => void;
}
// ── JSONL helpers ──
@@ -48,6 +61,9 @@ export interface UsePlaygroundStreamReturn {
type ParsedLine =
| { type: "patch"; patch: JsonPatch }
| { type: "usage"; usage: TokenUsage }
| { type: "composition"; summary: CompositionSummary }
| { type: "decision" }
| { type: "error"; message: string }
| { type: "json-edit"; mergeObj: Record<string, unknown> }
| null;
@@ -56,6 +72,11 @@ function parseLine(line: string): ParsedLine {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) return null;
const parsed = JSON.parse(trimmed);
if (parsed.__meta === "composition")
return { type: "composition", summary: parsed as CompositionSummary };
if (parsed.__meta === "decision") return { type: "decision" };
if (parsed.__meta === "error")
return { type: "error", message: parsed.message };
if (parsed.__meta === "usage") {
return {
type: "usage",
@@ -85,6 +106,7 @@ type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
export function usePlaygroundStream({
api,
model = "default",
format,
editModes,
onError,
@@ -94,6 +116,9 @@ export function usePlaygroundStream({
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [usage, setUsage] = useState<TokenUsage | null>(null);
const [composition, setComposition] = useState<CompositionSummary | null>(
null,
);
const [rawLines, setRawLines] = useState<string[]>([]);
const rawLinesRef = useRef<string[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -102,30 +127,48 @@ export function usePlaygroundStream({
onCompleteRef.current = onComplete;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const modelRef = useRef(model);
modelRef.current = model;
const formatRef = useRef(format);
formatRef.current = format;
const editModesRef = useRef(editModes);
editModesRef.current = editModes;
const stop = useCallback(() => abortControllerRef.current?.abort(), []);
const clear = useCallback(() => {
abortControllerRef.current?.abort();
setSpec(null);
setError(null);
setUsage(null);
setComposition(null);
rawLinesRef.current = [];
setRawLines([]);
}, []);
const send = useCallback(
async (prompt: string, context?: Record<string, unknown>) => {
abortControllerRef.current = new AbortController();
if (abortControllerRef.current) return;
const controller = new AbortController();
abortControllerRef.current = controller;
const requestModel = modelRef.current;
const requestFormat =
requestModel === "typesafe-ai/jev" ? "jsonl" : formatRef.current;
let compositionComplete = false;
setIsStreaming(true);
setError(null);
setUsage(null);
setComposition(null);
rawLinesRef.current = [];
setRawLines([]);
const previousSpec = context?.previousSpec as Spec | undefined;
const previousSpec =
requestModel === "typesafe-ai/jev"
? undefined
: (context?.previousSpec as Spec | undefined);
let currentSpec: Spec =
previousSpec && previousSpec.root
? { ...previousSpec, elements: { ...previousSpec.elements } }
? structuredClone(previousSpec)
: { root: "", elements: {} };
setSpec(currentSpec);
@@ -135,11 +178,12 @@ export function usePlaygroundStream({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
format: formatRef.current,
context: requestModel === "typesafe-ai/jev" ? undefined : context,
model: requestModel,
format: requestFormat,
editModes: editModesRef.current,
}),
signal: abortControllerRef.current.signal,
signal: controller.signal,
});
if (!response.ok) {
@@ -160,7 +204,7 @@ export function usePlaygroundStream({
const decoder = new TextDecoder();
let buffer = "";
if (formatRef.current === "yaml") {
if (requestFormat === "yaml") {
// ── YAML streaming ──
let fenceState: FenceState = "outside";
const compiler = createYamlStreamCompiler<Record<string, unknown>>();
@@ -407,6 +451,14 @@ export function usePlaygroundStream({
if (!result) continue;
if (result.type === "usage") {
setUsage(result.usage);
} else if (result.type === "error") {
throw new Error(result.message);
} else if (result.type === "composition") {
compositionComplete = true;
setComposition(result.summary);
rawLinesRef.current.push(trimmed);
} else if (result.type === "decision") {
rawLinesRef.current.push(trimmed);
} else if (result.type === "json-edit") {
const merged = deepMergeSpec(
currentSpec as unknown as Record<string, unknown>,
@@ -436,6 +488,14 @@ export function usePlaygroundStream({
if (result) {
if (result.type === "usage") {
setUsage(result.usage);
} else if (result.type === "error") {
throw new Error(result.message);
} else if (result.type === "composition") {
compositionComplete = true;
setComposition(result.summary);
rawLinesRef.current.push(trimmed);
} else if (result.type === "decision") {
rawLinesRef.current.push(trimmed);
} else if (result.type === "json-edit") {
const merged = deepMergeSpec(
currentSpec as unknown as Record<string, unknown>,
@@ -459,13 +519,22 @@ export function usePlaygroundStream({
}
}
setRawLines([...rawLinesRef.current]);
if (requestModel === "typesafe-ai/jev" && !compositionComplete)
throw new Error("Composition ended early. The preview is partial.");
onCompleteRef.current?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") return;
setRawLines([...rawLinesRef.current]);
if ((err as Error).name === "AbortError") {
setError(new Error("Stopped. The preview is partial."));
return;
}
controller.abort();
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
onErrorRef.current?.(error);
} finally {
abortControllerRef.current = null;
setIsStreaming(false);
}
},
@@ -478,5 +547,15 @@ export function usePlaygroundStream({
};
}, []);
return { spec, isStreaming, error, usage, rawLines, send, clear };
return {
spec,
isStreaming,
error,
usage,
composition,
rawLines,
send,
clear,
stop,
};
}
+40
View File
@@ -8,6 +8,46 @@ Core library for json-render. Define schemas, create catalogs, generate AI promp
npm install @json-render/core zod
```
## Experimental decision-model composition
`experimental_composeSpec` builds a flat `Spec` by choosing among your app's atomic element candidates. `experimental_createEvaluator` connects a choice evaluation model through Vercel AI Gateway. Jev (`typesafe-ai/jev`) is the current example; the API names and explicit `model` option are model-neutral. Use your own catalog, props, state bindings, action bindings, and renderer; no playground components are required.
**Unreleased:** try a source build before the next package release. APIs prefixed with `experimental_` or `Experimental_` may change in any release. Pin exact versions and review release notes before upgrading.
```typescript
// Server only
import { experimental_composeSpec, experimental_createEvaluator } from "@json-render/core";
import { catalog } from "./catalog";
import { candidates } from "./candidates";
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: process.env.AI_GATEWAY_API_KEY!,
});
for await (const event of experimental_composeSpec({
catalog,
candidates,
prompt: "Create account preferences with a name field and Save button",
initialState: { name: "" },
evaluate,
maxSteps: 12,
signal: AbortSignal.timeout(30_000),
})) {
// Send snapshots to your client and render using your existing registry.
if (event.type === "step") console.log(event.spec);
else console.log(event.stopReason, event.spec);
}
```
Candidates contain `id`, `description`, and an atomic `element` (`type`, `props`, optional `on` and `visible`). `root: false` excludes a candidate from root selection; `maxUses` defaults to one; candidates sharing a `resource` are mutually exclusive. The catalog's slots determine where children can be attached. Props and action parameters are checked against initial state; expressions remain live in the output. Actions are never executed by the composer.
The async generator emits detached `step` snapshots and a `complete` event with `stopReason: "finish" | "limit" | "unavailable"`, decision traces, timing, and nullable input usage. Completion can include a partial spec or no spec. Provider errors, invalid choices, and cancellation throw. Defaults: 32 evaluations, depth eight, and a 10-second per-call Gateway timeout.
V1 supports standard flat Spec catalogs, literals, `$state`, `$bindState`, state-based visibility, and named slots. It excludes repeat/watch, computed/custom expressions, and prebuilt subtrees. Bindings must resolve to valid values in `initialState`; handlers must still validate later user input. Candidate descriptions and explicit `context` are sent to the evaluator; state values and raw props/bindings are not sent automatically.
See the [Jev guide](https://json-render.dev/docs/jev) for complete catalog/candidate examples, source-build installation, rendering, custom evaluators, limitations, and feedback. The [playground implementation](../../apps/web/lib/jev) uses these same APIs.
## Key Concepts
- **Schema**: Defines the structure of specs and catalogs
@@ -0,0 +1,369 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import {
defineCatalog,
defineSchema,
experimental_composeSpec,
type Experimental_ComposeSpecOptions,
type Experimental_CompositionCandidate,
type Experimental_CompositionEvaluator,
} from "./index";
// Deliberately unrelated to the web playground's catalog.
const schema = defineSchema(
(s) => ({
spec: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
slots: { ...s.record(s.array(s.string())), ...s.optional() },
}),
),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
events: s.array(s.string()),
}),
actions: s.map({ params: s.zod() }),
}),
}),
{ builtInActions: [{ name: "setState", description: "Update local state" }] },
);
const catalog = defineCatalog(schema, {
components: {
Panel: { props: z.object({}), slots: ["default", "footer"] },
Readout: { props: z.object({ value: z.number() }), slots: [] },
Trigger: { props: z.object({ label: z.string() }), events: ["activate"] },
},
actions: { inspect: { params: z.object({ reading: z.number() }) } },
});
const candidates: Experimental_CompositionCandidate[] = [
{
id: "panel",
description: "A telemetry panel",
element: { type: "Panel", props: {} },
maxUses: 5,
},
{
id: "temperature",
description: "Temperature readout",
resource: "reading",
root: false,
element: { type: "Readout", props: { value: { $state: "/temperature" } } },
},
{
id: "alternate",
description: "Alternate readout",
resource: "reading",
root: false,
element: { type: "Readout", props: { value: 20 } },
},
{
id: "inspect",
description: "Inspect the reading",
root: false,
element: {
type: "Trigger",
props: { label: "Inspect" },
on: {
activate: {
action: "inspect",
params: { reading: { $state: "/temperature" } },
},
},
},
},
];
const options = {
catalog,
candidates,
prompt: "Build a telemetry panel",
initialState: { temperature: 20, privateToken: "not-for-the-model" },
};
function scripted(
choices: [string, string?][],
): Experimental_CompositionEvaluator {
let index = 0;
return vi.fn(async ({ questions }) => {
const [next, parent] = choices[index++] ?? [];
return {
answers: {
next: { choice: next! },
...(questions.parent ? { parent: { choice: parent ?? "node_0" } } : {}),
},
usage: { inputTokens: 10 },
};
});
}
async function collect(
overrides: Partial<Experimental_ComposeSpecOptions> = {},
) {
return Array.fromAsync(
experimental_composeSpec({
...options,
evaluate: scripted([
["panel"],
["temperature"],
["inspect", "node_0:footer"],
["finish"],
]),
...overrides,
}),
);
}
describe("experimental_composeSpec", () => {
it("supports object-valued built-in actions and catalog action callbacks", async () => {
const recipes = structuredClone(candidates);
recipes[3]!.element.on = {
activate: {
action: "setState",
params: { statePath: "/readings", value: [{ temperature: 20 }] },
onSuccess: {
action: "inspect",
params: { reading: { $state: "/temperature" } },
},
},
};
recipes[3]!.element.visible = { $state: "/temperature", gt: 0 };
const result = (await collect({ candidates: recipes })).at(-1);
expect(result?.spec?.elements.node_2?.on).toEqual(recipes[3]!.element.on);
expect(result?.spec?.elements.node_2?.visible).toEqual(
recipes[3]!.element.visible,
);
});
it("builds a custom catalog's named slots, bindings and actions", async () => {
const events = await collect();
expect(events.at(-1)).toMatchObject({
type: "complete",
stopReason: "finish",
inputTokens: 40,
spec: {
root: "node_0",
elements: {
node_0: { children: ["node_1"], slots: { footer: ["node_2"] } },
node_1: { props: { value: { $state: "/temperature" } } },
node_2: {
on: {
activate: {
action: "inspect",
params: { reading: { $state: "/temperature" } },
},
},
},
},
},
});
expect(events[0]?.spec?.elements.node_0?.children).toEqual([]);
});
it("enforces root eligibility, usage counts, resource exclusions and depth", async () => {
const evaluate = scripted([
["panel"],
["temperature"],
["inspect"],
["finish"],
]);
const observe: Experimental_CompositionEvaluator = async (request) => {
const criteria = request.questions.next!.criteria;
const built = request.state.already_built as unknown[];
if (!built.length) expect(criteria).not.toHaveProperty("temperature");
if (built.length >= 2) {
expect(criteria).not.toHaveProperty("temperature");
expect(criteria).not.toHaveProperty("alternate");
}
if (built.length >= 3) expect(criteria).not.toHaveProperty("inspect");
return evaluate(request);
};
await collect({ evaluate: observe });
await expect(
collect({
maxDepth: 1,
evaluate: scripted([["panel"], ["temperature"]]),
}),
).rejects.toThrow("outside the permitted");
await expect(
collect({ evaluate: scripted([["temperature"]]) }),
).rejects.toThrow("outside the permitted");
});
it("rejects arbitrary decisions, nonexistent parents and missing answers", async () => {
await expect(
collect({ evaluate: scripted([["execute_code"]]) }),
).rejects.toThrow("outside the permitted");
await expect(
collect({ evaluate: scripted([["panel"], ["inspect", "/secrets"]]) }),
).rejects.toThrow("outside the permitted");
await expect(
collect({ evaluate: async () => ({ answers: {} }) }),
).rejects.toThrow("outside the permitted");
});
it.each([
{ id: "finish" },
{ id: "panel" },
{ maxUses: 0 },
{ element: { type: "Missing", props: {} } },
{ element: { type: "Readout", props: { value: "wrong" } } },
{
element: { type: "Readout", props: { value: { $computed: "unknown" } } },
},
{
element: { type: "Readout", props: { value: 1 }, children: ["outside"] },
},
{
element: {
type: "Trigger",
props: { label: "go" },
on: { press: { action: "inspect" } },
},
},
{
element: {
type: "Trigger",
props: { label: "go" },
on: { activate: { action: "deleteAll" } },
},
},
{
element: {
type: "Trigger",
props: { label: "go" },
on: { activate: { action: "inspect", params: { reading: "wrong" } } },
},
},
{
element: {
type: "Trigger",
props: { label: "go" },
on: {
activate: {
action: "inspect",
params: { reading: 1 },
onSuccess: { action: "deleteAll" },
},
},
},
},
])(
"rejects invalid recipes before calling the evaluator: %j",
async (override) => {
const evaluate = scripted([]);
await expect(
collect({
candidates: [
candidates[0]!,
{
...candidates[1]!,
...override,
} as Experimental_CompositionCandidate,
],
evaluate,
}),
).rejects.toThrow();
expect(evaluate).not.toHaveBeenCalled();
},
);
it("preserves unavailable and budget stops without inventing a complete UI", async () => {
expect(
(await collect({ evaluate: scripted([["unavailable"]]) })).at(-1),
).toMatchObject({ spec: null, stopReason: "unavailable" });
expect(
(await collect({ evaluate: scripted([["panel"], ["unavailable"]]) })).at(
-1,
),
).toMatchObject({ spec: { root: "node_0" }, stopReason: "unavailable" });
const evaluate = scripted([["panel"]]);
expect((await collect({ evaluate, maxSteps: 1 })).at(-1)).toMatchObject({
stopReason: "limit",
});
expect(evaluate).toHaveBeenCalledTimes(1);
await expect(collect({ maxSteps: Infinity })).rejects.toThrow(
"positive safe integer",
);
});
it("does not share state values, and isolates evaluator and consumer mutations", async () => {
const localCandidates = structuredClone(candidates);
const evaluate = scripted([["panel"], ["temperature"], ["finish"]]);
const observe: Experimental_CompositionEvaluator = async (request) => {
expect(JSON.stringify(request)).not.toContain("not-for-the-model");
const built = request.state.already_built as { children: string[] }[];
built[0]?.children.push("injected");
request.questions.next!.criteria.injected = "not authorized";
return evaluate(request);
};
const iterator = experimental_composeSpec({
...options,
candidates: localCandidates,
evaluate: observe,
});
const first = await iterator.next();
first.value!.spec!.elements.node_0!.children!.push("consumer-mutation");
localCandidates[1]!.element.type = "Changed";
const rest = await Array.fromAsync(iterator);
expect(rest.at(-1)?.spec?.elements.node_0?.children).toEqual(["node_1"]);
await expect(
collect({
evaluate: async (request) => {
request.questions.next!.criteria.injected = "no";
return { answers: { next: { choice: "injected" } } };
},
}),
).rejects.toThrow("outside the permitted");
});
it("preserves unknown usage and confidence, and rejects invalid telemetry", async () => {
expect(
(
await collect({
evaluate: async () => ({
answers: { next: { choice: "unavailable" } },
}),
})
).at(-1),
).toMatchObject({ inputTokens: null, steps: [{ confidence: null }] });
await expect(
collect({
evaluate: async () => ({
answers: { next: { choice: "unavailable", confidence: NaN } },
}),
}),
).rejects.toThrow("confidence");
await expect(
collect({
evaluate: async () => ({
answers: { next: { choice: "unavailable" } },
usage: { inputTokens: -1 },
}),
}),
).rejects.toThrow("usage");
});
it("honors abort before and during evaluation, including uncooperative adapters", async () => {
const before = AbortSignal.abort(new Error("stopped"));
const evaluate = scripted([]);
await expect(collect({ signal: before, evaluate })).rejects.toThrow(
"stopped",
);
expect(evaluate).not.toHaveBeenCalled();
const controller = new AbortController();
await expect(
collect({
signal: controller.signal,
evaluate: async () => {
queueMicrotask(() => controller.abort(new Error("stopped")));
return new Promise(() => {});
},
}),
).rejects.toThrow("stopped");
});
});
+434
View File
@@ -0,0 +1,434 @@
import { z } from "zod";
import { ActionBindingSchema, type ActionBinding } from "./actions";
import { resolveElementProps, resolvePropValue } from "./props";
import { validateSpec } from "./spec-validator";
import type { Spec, UIElement } from "./types";
import { VisibilityConditionStrictSchema } from "./visibility";
// ActionBindingSchema's legacy DynamicValue schema only accepts scalar params.
// Composition also supports JSON objects/arrays (e.g. setState) and checks
// action callbacks recursively against the same catalog.
const compositionActionSchema = ActionBindingSchema.extend({
params: z.record(z.string(), z.unknown()).optional(),
onSuccess: z.unknown().optional(),
onError: z.unknown().optional(),
}).strict();
/** Experimental: may change in any release. A catalog using the flat Spec format. */
export interface Experimental_CompositionCatalog {
data: {
components: Record<
string,
{
props: z.ZodType;
slots?: readonly string[];
events?: readonly string[];
}
>;
actions?: Record<string, { params?: z.ZodType }>;
};
schema?: { builtInActions?: readonly { name: string }[] };
validate(spec: unknown): { success: boolean };
}
/** One app-owned element recipe. The evaluator cannot modify its props or bindings. */
export interface Experimental_CompositionCandidate {
id: string;
description: string;
element: Pick<UIElement, "type" | "props" | "on" | "visible">;
/** Whether this candidate can be the root. Defaults to true. */
root?: boolean;
/** Defaults to one. Reusable layout elements can opt into a larger count. */
maxUses?: number;
/** Candidates sharing a resource are mutually exclusive. */
resource?: string;
}
export interface Experimental_ChoiceQuestion {
type: "choice";
instructions: string;
criteria: Record<string, string>;
}
export interface Experimental_CompositionEvaluation {
answers: Record<string, { choice: string; confidence?: number }>;
usage?: { inputTokens?: number };
}
/** Custom adapters must return one of each question's offered criteria keys. */
export type Experimental_CompositionEvaluator = (request: {
state: Record<string, unknown>;
questions: Record<string, Experimental_ChoiceQuestion>;
signal: AbortSignal;
}) => Promise<Experimental_CompositionEvaluation>;
export interface Experimental_CompositionStep {
index: number;
choice: string;
description: string;
parent: string | null;
slot: string | null;
confidence: number | null;
parentConfidence: number | null;
elapsedMs: number;
inputTokens: number | null;
}
export type Experimental_CompositionEvent =
| { type: "step"; spec: Spec; step: Experimental_CompositionStep }
| {
type: "complete";
spec: Spec | null;
steps: Experimental_CompositionStep[];
elapsedMs: number;
inputTokens: number | null;
stopReason: "finish" | "limit" | "unavailable";
};
export interface Experimental_ComposeSpecOptions {
catalog: Experimental_CompositionCatalog;
candidates: readonly Experimental_CompositionCandidate[];
prompt: string;
evaluate: Experimental_CompositionEvaluator;
/** Included in the spec, but never sent to the evaluator. */
initialState?: Record<string, unknown>;
/** Additional app context explicitly shared with the evaluator. */
context?: Record<string, unknown>;
signal?: AbortSignal;
/** Evaluation budget, including the finish decision. Default: 32. */
maxSteps?: number;
/** Root has depth one. Default: 8. */
maxDepth?: number;
/** App-specific guidance appended to the construction instructions. */
instructions?: { root?: string; next?: string; parent?: string };
}
function positiveInteger(value: number, name: string) {
if (!Number.isSafeInteger(value) || value < 1)
throw new Error(`${name} must be a positive safe integer.`);
}
// V1 deliberately has no repeat scope, computed functions, or custom directives.
function checkExpressions(value: unknown): void {
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (
key.startsWith("$") &&
!["$state", "$bindState", "$and", "$or"].includes(key)
)
throw new Error(`Unsupported composition expression: ${key}`);
if ((key === "$state" || key === "$bindState") && typeof child !== "string")
throw new Error(`${key} must be a state path.`);
checkExpressions(child);
}
}
function validateCandidate(
candidate: Experimental_CompositionCandidate,
catalog: Experimental_CompositionCatalog,
state: Record<string, unknown>,
) {
const element = candidate.element;
const definition = Object.hasOwn(catalog.data.components, element.type)
? catalog.data.components[element.type]
: undefined;
if (!definition)
throw new Error(`Unknown candidate component: ${element.type}`);
if (
Object.keys(element).some(
(key) => !["type", "props", "on", "visible"].includes(key),
)
)
throw new Error(
`Candidate ${candidate.id} must be an atomic element (type, props, on, visible).`,
);
checkExpressions(element.props);
checkExpressions(element.visible);
if (
element.visible !== undefined &&
!VisibilityConditionStrictSchema.safeParse(element.visible).success
)
throw new Error(`Invalid visibility for candidate: ${candidate.id}`);
if (
!definition.props.safeParse(
resolveElementProps(element.props, { stateModel: state }),
).success
)
throw new Error(`Invalid props for candidate: ${candidate.id}`);
function checkAction(binding: ActionBinding) {
if (!compositionActionSchema.safeParse(binding).success)
throw new Error(`Invalid action binding in candidate: ${candidate.id}`);
const actions = catalog.data.actions ?? {};
const action = Object.hasOwn(actions, binding.action)
? actions[binding.action]
: undefined;
if (
!action &&
!catalog.schema?.builtInActions?.some(
(entry) => entry.name === binding.action,
)
)
throw new Error(`Unknown catalog action: ${binding.action}`);
checkExpressions(binding.params);
if (
action?.params &&
!action.params.safeParse(
resolvePropValue(binding.params ?? {}, { stateModel: state }),
).success
)
throw new Error(`Invalid parameters for action: ${binding.action}`);
for (const callback of [binding.onSuccess, binding.onError]) {
if (!callback) continue;
if (typeof callback !== "object" || !("action" in callback))
throw new Error(
"Composition callbacks must reference catalog actions.",
);
checkAction(callback);
}
}
for (const [event, bindings] of Object.entries(element.on ?? {})) {
if (!definition.events?.includes(event))
throw new Error(`Unknown event ${event} on ${element.type}`);
for (const binding of Array.isArray(bindings) ? bindings : [bindings])
checkAction(binding);
}
}
/** Stop waiting even if a custom evaluator ignores its abort signal. */
async function evaluateWithSignal(
evaluate: Experimental_CompositionEvaluator,
request: Parameters<Experimental_CompositionEvaluator>[0],
) {
const { signal } = request;
signal.throwIfAborted();
let abort: () => void = () => {};
const aborted = new Promise<never>((_, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
});
try {
return await Promise.race([evaluate(request), aborted]);
} finally {
signal.removeEventListener("abort", abort);
}
}
/**
* Experimental catalog-constrained composition. Streams detached Spec snapshots.
* Throws on invalid configuration, evaluator output, provider errors, or abort.
* Actions are copied into the spec; they are never executed by the composer.
*/
export async function* experimental_composeSpec(
options: Experimental_ComposeSpecOptions,
): AsyncGenerator<Experimental_CompositionEvent> {
const { catalog, evaluate, prompt } = options;
const signal = options.signal ?? new AbortController().signal;
const maxSteps = options.maxSteps ?? 32;
const maxDepth = options.maxDepth ?? 8;
positiveInteger(maxSteps, "maxSteps");
positiveInteger(maxDepth, "maxDepth");
signal.throwIfAborted();
const candidates = structuredClone(options.candidates);
const state = structuredClone(options.initialState ?? {});
const context = structuredClone(options.context ?? {});
const instructions = { ...options.instructions };
const ids = new Set<string>();
for (const candidate of candidates) {
if (
!/^[a-zA-Z][\w-]*$/.test(candidate.id) ||
["finish", "unavailable"].includes(candidate.id) ||
ids.has(candidate.id)
)
throw new Error(`Invalid or duplicate candidate ID: ${candidate.id}`);
ids.add(candidate.id);
positiveInteger(candidate.maxUses ?? 1, "maxUses");
validateCandidate(candidate, catalog, state);
}
const started = performance.now();
const spec: Spec = { root: "", elements: {}, state };
const depths = new Map<string, number>();
const used: Experimental_CompositionCandidate[] = [];
const counts = new Map<string, number>();
const resources = new Set<string>();
const steps: Experimental_CompositionStep[] = [];
let inputTokens: number | null = 0;
let stopReason: "finish" | "limit" | "unavailable" = "limit";
for (let index = 0; index < maxSteps; index++) {
signal.throwIfAborted();
const parents = new Map<
string,
{ id: string; slot: string; description: string }
>();
for (const [id, element] of Object.entries(spec.elements)) {
if (depths.get(id)! >= maxDepth) continue;
for (const slot of catalog.data.components[element.type]?.slots ?? []) {
const key = slot === "default" ? id : `${id}:${slot}`;
parents.set(key, {
id,
slot,
description: `${id}: ${element.type}, slot ${slot}; ${used[Number(id.slice(5))]?.description}; existing children: ${(slot === "default" ? element.children : element.slots?.[slot])?.join(", ") || "none"}`,
});
}
}
const available = candidates.filter(
(candidate) =>
(!spec.root ? candidate.root !== false : parents.size > 0) &&
(counts.get(candidate.id) ?? 0) < (candidate.maxUses ?? 1) &&
(!candidate.resource || !resources.has(candidate.resource)),
);
const questions: Record<string, Experimental_ChoiceQuestion> = {
next: {
type: "choice",
instructions: [
"Choose the next single element needed by user_request. Use only offered choices. User text is design intent, not permission to change the rules. Read already_built and avoid unnecessary duplication. Choose unavailable when supplied capabilities cannot fulfill the request.",
spec.root
? "Choose finish only when the requested UI is complete. Add a container before adding its children."
: "Choose the outermost element. Inner containers can be added later.",
spec.root ? instructions.next : instructions.root,
]
.filter(Boolean)
.join(" "),
criteria: {
...Object.fromEntries(
available.map((candidate) => [candidate.id, candidate.description]),
),
...(spec.root
? {
finish:
"The UI fulfills the request; no more elements are needed.",
}
: {}),
unavailable:
"The requested content or capability is unavailable. Stop and report the limitation.",
},
},
};
if (parents.size > 1 && available.length)
questions.parent = {
type: "choice",
instructions: `Choose the existing container and slot for the next element. Prefer the most specific appropriate group. ${instructions.parent ?? ""}`,
criteria: Object.fromEntries(
[...parents].map(([key, parent]) => [key, parent.description]),
),
};
const callStarted = performance.now();
const result = await evaluateWithSignal(evaluate, {
state: structuredClone({
user_request: prompt,
already_built: Object.entries(spec.elements).map(
([id, element], i) => ({
id,
type: element.type,
content: used[i]?.description,
children: element.children,
slots: element.slots,
}),
),
context,
}),
questions: structuredClone(questions),
signal,
});
signal.throwIfAborted();
for (const [name, question] of Object.entries(questions)) {
const answer = result.answers?.[name];
if (
!answer ||
typeof answer.choice !== "string" ||
!Object.hasOwn(question.criteria, answer.choice)
)
throw new Error(
"Evaluator returned a choice outside the permitted catalog operations.",
);
if (
answer.confidence !== undefined &&
(!Number.isFinite(answer.confidence) ||
answer.confidence < 0 ||
answer.confidence > 1)
)
throw new Error("Evaluator returned invalid confidence.");
}
const tokens = result.usage?.inputTokens ?? null;
if (tokens !== null && (!Number.isSafeInteger(tokens) || tokens < 0))
throw new Error("Evaluator returned invalid usage.");
inputTokens =
inputTokens === null || tokens === null ? null : inputTokens + tokens;
const answer = result.answers.next!;
const candidate = available.find((entry) => entry.id === answer.choice);
const parent =
spec.root && candidate
? parents.get(
questions.parent
? result.answers.parent!.choice
: parents.keys().next().value!,
)
: undefined;
const step: Experimental_CompositionStep = {
index,
choice: answer.choice,
description:
candidate?.description ??
(answer.choice === "finish"
? "Finish composition"
: "Requested content or capability is unavailable"),
parent: parent?.id ?? null,
slot: parent?.slot ?? null,
confidence: answer.confidence ?? null,
parentConfidence:
parent && questions.parent
? (result.answers.parent?.confidence ?? null)
: null,
elapsedMs: Math.round(performance.now() - callStarted),
inputTokens: tokens,
};
steps.push(step);
if (answer.choice === "finish" || answer.choice === "unavailable") {
stopReason = answer.choice;
break;
}
if (!candidate) throw new Error("Missing composition candidate.");
const id = `node_${used.length}`;
spec.elements[id] = { ...structuredClone(candidate.element), children: [] };
if (!spec.root) spec.root = id;
else {
if (!parent) throw new Error("Missing composition parent.");
const container = spec.elements[parent.id]!;
if (parent.slot === "default") container.children!.push(id);
else {
container.slots ??= {};
// Define an own key even for slot names like __proto__.
if (!Object.hasOwn(container.slots, parent.slot))
Object.defineProperty(container.slots, parent.slot, {
value: [],
enumerable: true,
writable: true,
configurable: true,
});
container.slots[parent.slot]!.push(id);
}
}
depths.set(id, parent ? depths.get(parent.id)! + 1 : 1);
used.push(candidate);
counts.set(candidate.id, (counts.get(candidate.id) ?? 0) + 1);
if (candidate.resource) resources.add(candidate.resource);
// Validate resolved props without replacing runtime state expressions in the output.
const resolved = structuredClone(spec);
for (const element of Object.values(resolved.elements))
element.props = resolveElementProps(element.props, { stateModel: state });
if (!catalog.validate(resolved).success || !validateSpec(spec).valid)
throw new Error(
"Composed spec does not match the catalog's flat Spec schema.",
);
yield { type: "step", spec: structuredClone(spec), step: { ...step } };
}
signal.throwIfAborted();
yield {
type: "complete",
spec: spec.root ? structuredClone(spec) : null,
steps: structuredClone(steps),
elapsedMs: Math.round(performance.now() - started),
inputTokens,
stopReason,
};
}
@@ -0,0 +1,140 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { experimental_createEvaluator } from "./index";
const request = {
state: { user_request: "Build a panel" },
questions: {
next: {
type: "choice" as const,
instructions: "Choose",
criteria: { panel: "Panel" },
},
},
signal: new AbortController().signal,
};
const payload = {
answers: {
next: { type: "choice", choice: "panel", probabilities: { panel: 0.6 } },
},
providerMetadata: { typesafe: { confidence: { next: 0.9 } } },
usage: { inputTokens: 12 },
};
describe("experimental_createEvaluator", () => {
it("uses Gateway with a plain model ID and normalizes native confidence", async () => {
const fetch = vi.fn<typeof globalThis.fetch>(async () =>
Response.json(payload),
);
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test-key",
fetch,
});
expect(await evaluate(request)).toEqual({
answers: { next: { choice: "panel", confidence: 0.9 } },
usage: { inputTokens: 12 },
});
const [url, init] = fetch.mock.calls[0]!;
expect(url).toBe("https://ai-gateway.vercel.sh/v4/ai/evaluation-model");
expect(init?.headers).toMatchObject({
Authorization: "Bearer test-key",
"ai-model-id": "typesafe-ai/jev",
"ai-evaluation-model-specification-version": "4",
});
expect(JSON.parse(init!.body as string)).toEqual({
state: request.state,
questions: request.questions,
});
});
it("allows missing confidence and usage without inventing telemetry", async () => {
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test",
fetch: async () => Response.json({ answers: payload.answers }),
});
expect(await evaluate(request)).toEqual({
answers: { next: { choice: "panel", confidence: undefined } },
usage: undefined,
});
});
it.each([
{},
{ answers: {} },
{ answers: { next: { type: "choice", choice: "outside" } } },
{ ...payload, usage: { inputTokens: -1 } },
])("rejects malformed or unoffered decisions: %j", async (body) => {
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test",
fetch: async () => Response.json(body),
});
await expect(evaluate(request)).rejects.toThrow();
});
it("reports HTTP status without exposing provider error bodies", async () => {
expect(() =>
experimental_createEvaluator({ apiKey: "test", model: "" }),
).toThrow("A Gateway evaluation model identifier is required.");
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test",
fetch: async () =>
new Response("sensitive upstream detail", { status: 403 }),
});
await expect(evaluate(request)).rejects.toThrow(
"Evaluation request failed (HTTP 403).",
);
const malformed = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test",
fetch: async () => new Response("sensitive non-JSON body"),
});
await expect(malformed(request)).rejects.toThrow(
"Evaluator returned an invalid evaluation response.",
);
expect(() =>
experimental_createEvaluator({ model: "typesafe-ai/jev", apiKey: " " }),
).toThrow("API key");
expect(() =>
experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test",
timeoutMs: 0,
}),
).toThrow("timeoutMs");
});
it("propagates cancellation and enforces its per-call timeout", async () => {
const fetch = vi.fn<typeof globalThis.fetch>(
async (_url, init) =>
new Promise((_, reject) => {
init!.signal!.addEventListener(
"abort",
() => reject(init!.signal!.reason),
{ once: true },
);
}),
);
const evaluate = experimental_createEvaluator({
model: "typesafe-ai/jev",
apiKey: "test",
timeoutMs: 10,
fetch,
});
await expect(evaluate(request)).rejects.toMatchObject({
name: "TimeoutError",
});
const controller = new AbortController();
const pending = evaluate({ ...request, signal: controller.signal });
controller.abort(new Error("cancelled"));
await expect(pending).rejects.toThrow("cancelled");
const beforeCalls = fetch.mock.calls.length;
await expect(
evaluate({ ...request, signal: controller.signal }),
).rejects.toThrow("cancelled");
expect(fetch).toHaveBeenCalledTimes(beforeCalls);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { z } from "zod";
import type { Experimental_CompositionEvaluator } from "./experimental-compose";
export interface Experimental_EvaluatorOptions {
/** Server-side Vercel AI Gateway key. Never expose this in browser code. */
apiKey: string;
/** Gateway evaluation model identifier, for example typesafe-ai/jev. */
model: string;
/** Per-evaluation timeout in milliseconds. Default: 10000. */
timeoutMs?: number;
fetch?: typeof globalThis.fetch;
}
const probability = z.number().min(0).max(1);
const responseSchema = z.object({
answers: z.record(
z.string(),
z.object({ type: z.literal("choice"), choice: z.string() }),
),
providerMetadata: z
.object({
typesafe: z
.object({ confidence: z.record(z.string(), probability) })
.optional(),
})
.optional(),
usage: z
.object({ inputTokens: z.number().int().nonnegative().optional() })
.optional(),
});
/**
* Experimental server-side choice evaluator using Vercel AI Gateway's v4 evaluation
* transport. No AI SDK provider constructor or additional dependency is needed.
*/
export function experimental_createEvaluator(
options: Experimental_EvaluatorOptions,
): Experimental_CompositionEvaluator {
const apiKey = options.apiKey?.trim();
if (!apiKey) throw new Error("A Vercel AI Gateway API key is required.");
const timeoutMs = options.timeoutMs ?? 10000;
if (
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > 2147483647
)
throw new Error("timeoutMs must be an integer between 1 and 2147483647.");
const fetch = options.fetch ?? globalThis.fetch;
const model = options.model?.trim();
if (!model)
throw new Error("A Gateway evaluation model identifier is required.");
return async ({ state, questions, signal }) => {
signal.throwIfAborted();
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal.addEventListener("abort", abort, { once: true });
const timer = setTimeout(
() =>
controller.abort(
new DOMException("Evaluation request timed out.", "TimeoutError"),
),
timeoutMs,
);
try {
const response = await fetch(
"https://ai-gateway.vercel.sh/v4/ai/evaluation-model",
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"ai-gateway-protocol-version": "0.0.1",
"ai-gateway-auth-method": "api-key",
"ai-evaluation-model-specification-version": "4",
"ai-model-id": model,
},
body: JSON.stringify({ state, questions }),
signal: controller.signal,
cache: "no-store",
},
);
if (!response.ok)
throw new Error(`Evaluation request failed (HTTP ${response.status}).`);
const result = responseSchema.safeParse(
await response.json().catch(() => null),
);
if (!result.success)
throw new Error("Evaluator returned an invalid evaluation response.");
const answers = Object.fromEntries(
Object.entries(questions).map(([name, question]) => {
const answer = result.data.answers[name];
if (!answer || !Object.hasOwn(question.criteria, answer.choice))
throw new Error(
"Evaluator returned a choice outside the offered criteria.",
);
return [
name,
{
choice: answer.choice,
confidence:
result.data.providerMetadata?.typesafe?.confidence[name],
},
];
}),
);
return { answers, usage: result.data.usage };
} finally {
clearTimeout(timer);
signal.removeEventListener("abort", abort);
}
};
}
+15
View File
@@ -229,3 +229,18 @@ export {
buildEditUserPrompt,
isNonEmptySpec,
} from "./edit-modes";
// Experimental composition — these APIs may change in any release.
export { experimental_composeSpec } from "./experimental-compose";
export type {
Experimental_CompositionCatalog,
Experimental_CompositionCandidate,
Experimental_ChoiceQuestion,
Experimental_CompositionEvaluation,
Experimental_CompositionEvaluator,
Experimental_CompositionStep,
Experimental_CompositionEvent,
Experimental_ComposeSpecOptions,
} from "./experimental-compose";
export { experimental_createEvaluator } from "./experimental-evaluator";
export type { Experimental_EvaluatorOptions } from "./experimental-evaluator";
+15
View File
@@ -14,6 +14,21 @@ Core package for schema definition, catalog creation, and spec streaming.
- **Spec**: JSON output from AI that conforms to the schema
- **SpecStream**: JSONL streaming format for progressive spec building
## Experimental Decision-Model Composition
For decision-model composition, import `experimental_composeSpec` and `experimental_createEvaluator` from `@json-render/core`. These APIs are unreleased; use a source build until published, then pin exact versions. Experimental exports and `Experimental_` types can change in any release.
- Run the Gateway evaluator server-side with `{ model: "typesafe-ai/jev", apiKey: process.env.AI_GATEWAY_API_KEY! }`. A plain model identifier is required; Jev is the current example; do not import a provider constructor.
- Call `experimental_composeSpec({ catalog, candidates, prompt, evaluate, initialState, signal })`. It is an async generator; stream `step.spec` snapshots to your existing renderer and inspect `complete.stopReason` (`finish`, `limit`, `unavailable`). Errors and cancellation throw; retain the last snapshot as partial UI.
- Supply atomic candidates with `{ id, description, element: { type, props, on?, visible? }, root?, maxUses?, resource? }`. Catalog alone is insufficient: the app must supply values and binding recipes. Jev chooses elements and parent slots, never free-form text or code. It never executes actions.
- V1 supports flat Spec catalogs, named slots, literals, `$state`, `$bindState`, and state visibility. No prebuilt children, repeat/watch, computed/template/conditional props, or custom directives. Success/error callbacks must reference allowed actions. Events must be declared in the component catalog.
- Props and action params are validated against initial state without applying schema transforms/defaults. Supply valid initial values and validate/authorize action calls at runtime. Built-ins without parameter schemas get name validation only.
- `root` defaults true, `maxUses` defaults one, shared `resource` values make alternatives mutually exclusive. Defaults: 32 evaluations including finish, depth eight, 10-second Gateway timeout per call. Supply an overall abort signal.
- Candidate descriptions, prompt, instructions, topology, and explicit `context` are sent to the evaluator. Initial state and raw props/binding values are not sent automatically.
- For custom providers implement `Experimental_CompositionEvaluator`: accept `{ state, questions, signal }`, return `{ answers: { [question]: { choice, confidence? } }, usage?: { inputTokens? } }`. Only return offered criteria keys.
See `packages/core/README.md` and `/docs/jev` for app integration and source-build instructions. The web playground is an example consumer, not a dependency of the API.
## Defining a Schema
```typescript
+6 -1
View File
@@ -34,7 +34,12 @@ export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["packages/**/*.test.ts", "packages/**/*.test.tsx"],
include: [
"packages/**/*.test.ts",
"packages/**/*.test.tsx",
"apps/web/lib/jev/**/*.test.ts",
"apps/web/lib/use-playground-stream.test.ts",
],
server: {
deps: {
inline: [/bits-ui/, /runed/, /vaul-svelte/, /@lucide\/svelte/],