@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
128 lines (117 loc) • 6.02 kB
text/typescript
// Types + pure helpers for the dual-response comparison card (`ResponseCompare` /
// `<kai-compare>`). A compare presents exactly TWO assistant candidates for the
// same prompt side-by-side; the user picks the better one (a COMMIT, not a
// Submit), which the consumer turns into a `(prompt, chosen, rejected)`
// preference pair. Kept framework-agnostic and DOM-free so it unit-tests in
// isolation.
import type { ToolPart } from './tool';
import type { AttachmentData } from './attachments';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
/** One assistant response in a comparison. Rendered identically to an assistant
* message (reasoning + tools + attachments + markdown content) via `MessageBody`. */
export interface CompareCandidate {
/** Stable id, unique within the pair; echoed back in the selection. */
id: string;
/** The response text/markdown. */
content: string;
/** Optional collapsible reasoning block. */
reasoning?: { text: string; label?: string };
/** Tool-call parts rendered above the content. */
tools?: ToolPart[];
/** Inline attachment previews rendered above the content. */
attachments?: AttachmentData[];
/** Optional short column label (e.g. "A" / "B" / "Concise"). */
label?: string;
/** Optional model name shown as a sub-label. */
model?: string;
/** When true this candidate is still streaming — its pick control is disabled
* and a per-column shimmer shows until it (and its sibling) settle. */
streaming?: boolean;
}
/** The two-candidate tuple. Enforced at the type level; validated at runtime by
* `normalizeCandidates`. */
export type ComparePair = [CompareCandidate, CompareCandidate];
/** How the card collapses once the user picks. `'winner'` (default) shows just the
* chosen candidate's body; `'none'` keeps both columns but marks the choice. */
export type CompareCollapse = 'winner' | 'none';
/** The compare definition (set as the `data` JS property on `<kai-compare>`). */
export interface ResponseCompareData {
/** Optional prompt/question shown above the two columns. */
prompt?: string;
/** Exactly two candidates: [A, B]. */
candidates: ComparePair;
/** Collapse behaviour after a pick. Default `'winner'`. */
collapse?: CompareCollapse;
}
/** Emitted (and re-hydratable) when the user picks. The consumer pairs
* `(prompt, chosenId, rejectedIds)` for preference capture / RLHF. */
export interface CompareSelection {
/** The id of the chosen candidate. */
chosenId: string;
/** The id(s) of the rejected candidate(s) — for a pair, the other one. */
rejectedIds: string[];
/** Epoch ms when the pick was made. */
at?: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Pure helpers (unit-tested in isolation).
// ─────────────────────────────────────────────────────────────────────────────
/**
* Validate + normalize the candidate list. A compare requires EXACTLY two
* candidates with unique, non-empty string ids. Returns the usable pair, or an
* error message when the definition is unusable (rendered as an inline error /
* emitted as a `kai-error`). Modeled on `normalizeOptions`.
*/
export function normalizeCandidates(candidates: unknown): {
candidates: ComparePair | null;
error?: string;
} {
if (!Array.isArray(candidates)) {
return { candidates: null, error: "This comparison couldn't be displayed." };
}
if (candidates.length !== 2) {
return {
candidates: null,
error: `A comparison needs exactly two candidates (got ${candidates.length}).`,
};
}
const out: CompareCandidate[] = [];
const seen = new Set<string>();
for (const c of candidates) {
if (!c || typeof c !== 'object') {
return { candidates: null, error: "This comparison couldn't be displayed." };
}
const cand = c as Partial<CompareCandidate>;
if (typeof cand.id !== 'string' || cand.id.length === 0) {
return { candidates: null, error: 'Each candidate needs a non-empty id.' };
}
if (seen.has(cand.id)) {
return { candidates: null, error: `Duplicate candidate id "${cand.id}".` };
}
seen.add(cand.id);
out.push({
id: cand.id,
content: typeof cand.content === 'string' ? cand.content : '',
reasoning: cand.reasoning,
tools: Array.isArray(cand.tools) ? cand.tools : undefined,
attachments: Array.isArray(cand.attachments) ? cand.attachments : undefined,
label: typeof cand.label === 'string' ? cand.label : undefined,
model: typeof cand.model === 'string' ? cand.model : undefined,
streaming: cand.streaming === true,
});
}
return { candidates: [out[0], out[1]] };
}
/** Build the selection payload for choosing `chosenId` out of the pair (the other
* candidate is the rejected one). Stamps `at` with the current time. */
export function buildSelection(pair: ComparePair, chosenId: string): CompareSelection {
const rejectedIds = pair.filter((c) => c.id !== chosenId).map((c) => c.id);
return { chosenId, rejectedIds, at: Date.now() };
}
/** Whether any candidate in the pair is still streaming (pick stays disabled). */
export function isAnyStreaming(pair: ComparePair | null): boolean {
if (!pair) return false;
return pair.some((c) => c.streaming === true);
}