Generative UI

How the model produces charts, tables, and metric cards.

A reply can carry a rendered widget when a chart, table or card says it better than prose. The model emits the data; Breeze does the rendering.

The model emits data, never JSX

Every spec is zod-validated against a closed, compile-time whitelist in lib/genui/schema.ts. Component dispatch is an exhaustive switch, not a lookup keyed on a model-supplied string, and an unknown type renders as collapsed JSON. Nothing from the model is ever evaluated. See Security.

The mechanism

An assistant reply embeds a fenced code block:

```breeze-ui
{ "type": "chart", "title": "Revenue", "variant": "bar",
  "data": [{ "name": "Q1", "value": 84 }, { "name": "Q2", "value": 96 }] }
```

The fence rides inside the reply's normal text, which is the whole trick: it needs no new stream event, no API change and no database field. It persists with the message and re-renders on reload for free.

The widget types

The grammar is a closed whitelist. tabs is the only one that nests -- it holds leaf widgets, not other tabs.

Prop

Type

Chart colours are not free-form

They come from a validated fixed palette, documented in tasks/chart-design.md and re-checkable with scripts/validate_palette.js. A model-supplied colour is not a thing the grammar can express.

When a widget appears

The request carries a genui field.

The default. The backend asks a router model whether this turn warrants UI. It answers yes only for quantitative data -- charts, tables, metric cards and comparisons. Prose, factual questions and code get a plain reply from the normal local path.

Generative UI composes with web search: "chart the last 10 days of weather in Hyderabad" searches and renders a chart in one turn. Search results reach the answer as evidence in the user turn rather than as tool messages, so the widget model never needs to call a tool. See Web search.

Both compete for the same context window, so on a combined turn history is trimmed harder and the evidence block is capped tighter.

Rules the model follows

backend/genui_prompt.py teaches the same rules lib/genui/schema.ts enforces.

  • Always write prose too; the widget complements it, never replaces it.
  • Use only data the user gave, or that already appears in the conversation.
  • One headline number is a metrics tile, never a one-bar chart.
  • Tones are semantic, not decorative.
  • Strict JSON: double quotes, no trailing commas, no comments.

Keep the prompt and the schema in lockstep

backend/genui_prompt.py and lib/genui/schema.ts are two statements of one grammar. Changing the zod schema without changing the prompt means the model keeps emitting specs the validator now rejects.

The context-window trap

Read this before editing backend/genui_prompt.py

Ollama's default context window is 4096 tokens, and its OpenAI-compatible endpoint silently ignores options.num_ctx -- verified, not assumed.

When the prompt overflows, Ollama truncates from the front, which evicts the system prompt. The model loses both the widget grammar and its Breeze identity, and answers "I'm unable to display charts". Three consequences, all of which are budget decisions rather than preferences:

The grammar is budgeted

Held to roughly 650 tokens, enforced by genui_prompt.test_prompt_budget(). If you add a widget type, something else has to come out.

max_tokens is capped at 1536

Prompt and completion share the one window. Asking for the full 4096 back guarantees the front of the prompt is evicted to make room.

History is trimmed on genui turns

_trim_history runs so that a long conversation cannot push the grammar out of the window.

Raising OLLAMA_CONTEXT_LENGTH on the Ollama server relaxes all three, but the defaults have to work unconfigured.

Working on the widgets

/dev/genui is a fixture harness that renders every widget in components/genui/ against sample specs, with no model in the loop. It is the fastest way to check a rendering change.