@kitn.ai/chat
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
187 lines (146 loc) • 9.29 kB
text/mdx
{/* src/stories/docs/generative-ui-overview.mdx */}
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Generative UI/Overview" />
# Generative UI
Agents don't just reply with text — they ask the chat to **render typed, interactive
cards**: a form to fill, an action to approve, a plan to pick from, a link to preview.
`@kitn.ai/chat` does this with a small, typed **Card Contract** and a turnkey dispatcher.
## The loop
```
agent/server ──CardEnvelope(s)──▶ host sets <kc-cards>.cards
▲ │
│ dispatcher renders the right kc-* by `type`
│ │
└── result / next envelopes ◀─ CardPolicy ◀─ kc-card event ◀─ user interacts
```
## Envelopes vs. cards
Two things are easy to conflate — keeping them straight makes the rest click:
- An **envelope** is what the agent *sends*: an addressed data wrapper, not UI. Like a
postal envelope, it *carries* a card spec but isn't the card itself.
- A **card** is what the user *sees*: the rendered, interactive UI the dispatcher mounts
for that envelope (a `kc-form`, `kc-confirm`, … inside the shared card chrome).
The dispatcher's whole job is **envelope in → card out** — one envelope renders one card.
Everything an agent sends is a `CardEnvelope`:
```ts
interface CardEnvelope {
type: string; // selects the card: form | confirm | choice | tasks | link | embed
id: string; // correlates every event back to this card
data: unknown; // follows that type's JSON Schema (shipped in dist/schemas)
title?: string; // optional chrome heading
resolution?: CardResolution; // set once the user acts → renders the read-only view
}
```
`data` follows the `type`'s **JSON Schema**, so an agent/server can validate before
sending; `id` correlates every event back to this card. (For **remote** cards, a transport
`WireFrame` wraps this *same* envelope over `postMessage` — the envelope inside is identical;
see Remote cards.)
## Turnkey: drop `<kc-cards>`
```html
<kc-cards></kc-cards>
<script type="module">
import '@kitn.ai/chat/elements';
const cards = document.querySelector('kc-cards');
cards.cards = [{ type: 'confirm', id: 'deploy', title: 'Deploy?',
data: { body: 'Ship it?', actions: [{ id: 'go', label: 'Deploy', default: true }] } }];
cards.policy = { onAction: (id, action) => console.log(id, action) };
</script>
```
`<kc-cards>` renders one card per envelope and routes every interaction through `policy`
(or listen for the raw bubbling `kc-card` event). In SolidJS, use `renderCard(envelope)` /
`<CardRenderer>` inside a `<CardProvider>`.
## Streaming & follow-ups
Swap the `cards` array to add, replace, or remove cards — e.g. replace a `confirm` with a
result card once the user acts. For **live streaming into a single card**, the SolidJS
`<CardRenderer>` re-renders reactively as its envelope's `data` changes, so an agent can
stream into it as work progresses. (Keyed-by-`id` in-place updates within a `<kc-cards>`
list are a planned refinement.)
## Resolved cards
Interactive cards (`kc-confirm`, `kc-choice`, `kc-tasks`, `kc-form`) flip to a **chromed
read-only** view the moment the user acts — optimistically, before any server round-trip.
To make that survive a **reload or history re-hydration**, the card reads an optional
`resolution` field on its `CardEnvelope`. Persist it by feeding the card's terminal event
back into the array with the `applyResolution` helper:
```ts
import { applyResolution } from '@kitn.ai/chat';
// <kc-cards> is controlled — persist the resolution so it survives reload:
el.addEventListener('kc-card', (e) => {
el.cards = applyResolution(el.cards, e.detail); // resolved cards re-hydrate read-only
// …then save el.cards to your store/server.
});
```
`applyResolution` is pure and safe to call on every `kc-card` event — non-terminal events
(`ready` / `error` / …) and unknown card ids return the same array unchanged. The terminal
verbs are `action` (confirm / choice) and `submit` (tasks / form).
## Remote cards
Everything above renders cards you **bundle yourself** — the dispatcher mounts a native
`kc-*` element for each envelope. That native `types` seam stays the easy path: register a
card type, ship its element, done.
**Remote cards** are the power feature for **provider-owned, cross-origin UI** — a card
whose code lives on a *different* origin (a partner, a plugin, your own card service) and
that you don't want to bundle or trust in your DOM. `<kc-remote>` renders it inside a
**sandboxed cross-origin `<iframe>`** and bridges it to the host over `postMessage`, carrying
the **exact same** `CardEnvelope` / `CardContext` / `CardEvent` shapes and routing every
event through the **same `CardPolicy`** as a native card. The envelope inside is identical —
a transport `WireFrame` just wraps it on the wire.
```html
<kc-remote
provider-origin="https://cards.provider.example"
src="https://cards.provider.example/card"
></kc-remote>
<script type="module">
import '@kitn.ai/chat/elements'; // registers <kc-remote>
const el = document.querySelector('kc-remote');
// The CardEnvelope is set as a JS property — it travels down the wire unchanged.
el.envelope = { type: 'form', id: 'signup', title: 'Join the beta',
data: { type: 'object', required: ['email'],
properties: { email: { type: 'string', title: 'Email', format: 'email' } } } };
// Every routed event is re-emitted as a bubbling kc-card CustomEvent — persist the
// resolution exactly like a native card:
el.addEventListener('kc-card', (e) => {
if (e.detail.kind === 'submit') save(e.detail.data);
});
// …or pass a CardPolicy directly: el.policy = { onSubmit: (id, data) => save(data) };
</script>
```
Prefer the **imperative** SDK when you manage the lifecycle yourself (theme refresh, swapping
cards in one warm frame):
```ts
import { mountRemoteCard } from '@kitn.ai/chat';
const handle = mountRemoteCard({
container: document.querySelector('#slot'),
providerOrigin: 'https://cards.provider.example',
src: 'https://cards.provider.example/card',
envelope,
context: { theme: { mode: 'dark' }, locale: 'en', authToken },
policy: { onSubmit: (cardId, data) => { /* persist via applyResolution, advance the chat */ } },
});
handle.updateContext({ theme: { mode: 'light' } }); // re-themes the framed card live
handle.update(nextEnvelope); // swap cards in the same iframe
handle.destroy(); // teardown + remove the frame
```
**Inputs.** `providerOrigin` is the exact origin (`https://…`, or `http://localhost` for
dev) the iframe is pinned to. `src` is the provider page URL (must be on `providerOrigin`).
`envelope` is the `CardEnvelope` to render. `policy` is the optional `CardPolicy`. `context`
carries theme/locale/`a11y` and a short-lived `authToken`.
**Theme changes remount; token/locale refreshes are silent (v1).** A host **theme change
re-renders (remounts) the remote card**, so any in-progress remote-card state (e.g. a
half-filled form) **resets on a theme toggle** — renderers apply theme imperatively at mount,
and v1 has no in-place renderer theme hook yet. **Token / locale / `a11y` refreshes are
silent** (no remount — renderers read `host.context()` on demand). A future refinement is a
renderer context subscription for live re-theme without remount.
**Security model.** The host mints a per-instance **nonce** and pins every inbound frame on
**origin + source window + nonce + negotiated protocol version** — a frame failing any check
is dropped, so a wrong-origin or forged `postMessage` can't drive your policy. The iframe is
sandboxed (`allow-scripts allow-forms allow-same-origin`, **no** `allow-popups`) and gets a
`no-referrer` policy. Any `authToken` you pass should be **short-lived** (the provider sees
it). The **host page must set `Content-Security-Policy: frame-ancestors 'self'`** (or
`'none'`) so it can't itself be reframed; the **provider** locks who may embed it with its
own `frame-ancestors` listing your exact host origin (no wildcards).
**Reference runtime.** `examples/remote-provider/` is the runnable provider iframe a real
provider copies — it calls `createCardBridge` (from `@kitn.ai/chat/provider`, a SolidJS-free
bundle) and registers one renderer per card `type`. `examples/remote-host/` is the matching
host page. The real cross-origin behavior is verified by the standalone Playwright suite
(`tests/e2e/`).
## Card elements are `kc-*` components too
Each card type (`kc-confirm`, `kc-choice`, `kc-tasks`, `kc-form`, `kc-link`, `kc-embed`, `kc-remote`) is a registered `kc-*` custom element with its own prop/event API — the same model as everything else under **Components**. They live here under **Generative UI / Cards** because they only make sense inside the dispatcher/envelope system, but they follow the same component contract: attributes, JS properties, a `kc-card` event, CSS parts. Browse **Generative UI → Cards** for the per-element API references and live demos.
See **Cards** for each card type, and **SDK** for the dispatcher in action.