kai-compare
”Which response do you prefer?” — render two assistant candidates for the same prompt side by side, let the user pick one, and turn that pick into a (prompt, chosen, rejected) preference pair.
- Shadow DOM
- Preference capture
- Both candidates stream
- Keyboard radiogroup
- Collapses to the winner
Try it
Section titled “Try it”Set data in JavaScript — it’s an object with exactly two candidates (arrays and objects can’t be HTML attributes). Listen for kai-compare-select:
<kai-compare id="cmp"></kai-compare>
<script type="module"> import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-compare');
const cmp = document.getElementById('cmp');
cmp.data = { prompt: 'Explain a closure in one paragraph.', candidates: [ { id: 'a', label: 'A', model: 'gpt-4o', content: 'A closure is a function that…' }, { id: 'b', label: 'B', model: 'claude', content: 'Closures let a function remember…' }, ], };
cmp.addEventListener('kai-compare-select', (e) => { const { chosenId, rejectedIds } = e.detail; // Send the preference pair to your backend. recordPreference({ prompt: cmp.data.prompt, chosenId, rejectedIds }); });</script>data—{ prompt?, candidates: [A, B], collapse? }. Each candidate is an assistant response; it renders exactly like a message body (markdown, reasoning, tool calls, attachments).kai-compare-select— fires once when the user picks.detailis{ chosenId, rejectedIds, at }.- The pick commits and collapses to the chosen candidate. It’s single-shot —
<kai-compare>won’t emit again for the same definition.
The candidate shape
Section titled “The candidate shape”Each entry in candidates describes one assistant response:
| Field | Type | Notes |
|---|---|---|
id | string | Unique within the pair; echoed back in the selection. |
content | string | The response text (markdown). |
reasoning | { text, label? } | Optional collapsible reasoning block. |
tools | ToolPart[] | Tool-call parts rendered above the content. |
attachments | AttachmentData[] | Inline attachment previews. |
label | string | Short column label (e.g. 'A', 'Concise'). |
model | string | Model name shown as a sub-label. |
streaming | boolean | true while this candidate is still generating. |
A comparison needs exactly two candidates with unique, non-empty ids. Anything else renders an inline error and fires kai-error.
Capturing the preference
Section titled “Capturing the preference”The selection is the whole point. kai-compare-select hands you chosenId and rejectedIds (for a pair, the other candidate’s id). Pair that with the prompt and send it to wherever you collect preference data:
cmp.addEventListener('kai-compare-select', (e) => { const { chosenId, rejectedIds, at } = e.detail; fetch('/api/preferences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: cmp.data.prompt, chosen: chosenId, rejected: rejectedIds, at, }), });});Streaming both candidates
Section titled “Streaming both candidates”Both responses can stream at once. Mark a candidate streaming: true and its column shows a shimmer; the pick controls stay disabled until both settle, then kai-ready fires.
Push a fresh data reference per chunk — assign a new object, never mutate the existing one in place, or the element won’t re-render:
cmp.data = { prompt, candidates: [ { id: 'a', label: 'A', content: '', streaming: true }, { id: 'b', label: 'B', content: '', streaming: true }, ],};
for await (const { id, delta, done } of streamBoth()) { cmp.data = { ...cmp.data, candidates: cmp.data.candidates.map((c) => c.id === id ? { ...c, content: c.content + delta, streaming: !done } : c, ), };}Re-hydrating a past pick
Section titled “Re-hydrating a past pick”To show a comparison the user already answered (e.g. loading a conversation), set selection. The card renders collapsed to the chosen candidate and emits nothing:
cmp.data = { prompt, candidates: [a, b] };cmp.selection = { chosenId: 'a', rejectedIds: ['b'] };Layout
Section titled “Layout”layout controls the columns:
| Value | Behavior |
|---|---|
'auto' (default) | Container query — tabs (pills) on narrow, side-by-side columns when wide. |
'columns' | Always two columns. |
'tabs' | Always tabbed — one candidate at a time with pills to switch. |
The two columns are a WAI-ARIA radiogroup with roving focus: Arrow keys move between A and B, Enter or Space picks the focused one.
| Property | Type | Default | Notes |
|---|---|---|---|
| data | Record<string, unknown> | — | The compare definition (prompt + the two candidates). Set as a JS PROPERTY: `el.data = { prompt, candidates: [A, B], collapse? }`. Import `ResponseCompareData` from `@kitn.ai/ui` for the full shape. |
| compareId | string | — | Stable id correlating every emitted event. Attribute: `compare-id`. |
| selection | Record<string, unknown> | — | Re-hydrate / control the user's pick. Set as a JS PROPERTY: `el.selection = { chosenId, rejectedIds }`. Renders the collapsed winner. |
| layout | "auto" | "columns" | "tabs" | 'auto' | Layout: `'auto'` (default — columns when wide, tabs when narrow, by CONTAINER width) | `'columns'` (side-by-side) | `'tabs'` (pills to switch). Attribute: `layout`. |
| proseSize | "sm" | "lg" | "xs" | "base" | — | Prose/text size for the rendered candidates. Attribute: `prose-size`. |
| codeTheme | string | — | Shiki theme for code blocks in the candidates. Attribute: `code-theme`. |
| codeHighlight | boolean | — | Whether code blocks are syntax-highlighted. Attribute: `code-highlight`. |
Events
Section titled “Events”| Event | Detail | Notes |
|---|---|---|
| kai-compare-select | | The user committed a pick. `detail` = `{ chosenId, rejectedIds, at }`. |
| kai-error | | The definition was unusable. |
| kai-ready | | Both candidates have settled and the pick is live. |
Composed from
Section titled “Composed from”This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.