Zum Inhalt springen
UI Kit
Accent

Antworten vergleichen

KI-Antworten in vier Formen vergleichen. Eine blinde Abstimmung zwischen zwei Antworten, die danach die Modelle zeigt, ein Wortvergleich zweier Fassungen, mehrere Modelle nebeneinander mit Dauer, Länge und Kosten und eine Auswertungstabelle.

Eine von 4 Components in KI-Antworten

4 Variants

Variants

Blinde Abstimmung

Zwei Antworten als A und B, vier Möglichkeiten zum Abstimmen, danach klappen die Modellnamen herein und die gewählte Karte trägt einen Ring.

Im Studio öffnen

Wortvergleich

Zwei Fassungen Wort für Wort verglichen, neue Wörter füllen sich grün, entfernte sind gestrichen. Im Text oder ab Tablet nebeneinander, auf Wunsch nur die Änderungen.

Im Studio öffnen

Modelle nebeneinander

Derselbe Auftrag von mehreren Modellen beantwortet, jeweils mit Dauer, Länge und Kosten. Die beste markieren, jede kopieren. Telefone wischen mit Punkten.

Im Studio öffnen

Auswertungstabelle

Testfälle gegen Modelle mit Bestanden oder nicht je Zelle, ein Schnitt je Modell mit Balken und ein Filter für nicht bestandene Fälle. Telefone zeigen ein Modell nach dem anderen.

Im Studio öffnen

Details

Was Antworten vergleichen mitbringt, aus Registry und Quellcode gelesen.

Kategorie
KI-Antworten
Bereich
App
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/compare-outputs.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 31 eigene Texte

Props

Die Schnittstelle CompareOutputsProps aus components/ui/compare-outputs.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CompareOutputsVariant

    blind-votetext-diffmodel-grideval-table

  • titleoptional
    string
  • promptoptional
    string

    The prompt both outputs answer

  • outputsoptional
    CompareOutput[]
  • beforeoptional
    { label: string; text: string }

    text-diff: the earlier and the later version

  • afteroptional
    { label: string; text: string }
  • modelsoptional
    { id: string; name: string }[]

    eval-table

  • casesoptional
    EvalCase[]
  • passAtoptional
    number

    eval-table: score from which a case passes, 0 to 1

  • onVoteoptional
    (choice: string) => void

Eingebaute Texte

Diese Texte schreibt Antworten vergleichen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 31 Texte anzeigen
promptEnglisch PromptDeutsch Auftrag
answerEnglisch Answer {n}Deutsch Antwort {n}
whichEnglisch Which answer is better?Deutsch Welche Antwort ist besser?
betterEnglisch {n} is betterDeutsch {n} ist besser
tieEnglisch Both are goodDeutsch Beide gut
badEnglisch Both are poorDeutsch Beide schwach
revealedEnglisch Thanks. Here is which model wrote what.Deutsch Danke. So verteilen sich die Modelle.
againEnglisch Vote againDeutsch Neu abstimmen
blindNoteEnglisch Models stay hidden until you vote, so the name does not sway you.Deutsch Die Modelle bleiben verborgen, bis du abstimmst, damit der Name nicht beeinflusst.
youPickedEnglisch Your pickDeutsch Deine Wahl
changesEnglisch ChangesDeutsch Änderungen
addedEnglisch {n} addedDeutsch {n} neu
removedEnglisch {n} removedDeutsch {n} entfernt
onlyChangesEnglisch Changes onlyDeutsch Nur Änderungen
inlineEnglisch InlineDeutsch Im Text
sideBySideEnglisch Side by sideDeutsch Nebeneinander
timeEnglisch TimeDeutsch Dauer
tokensEnglisch LengthDeutsch Länge
costEnglisch CostDeutsch Kosten
pinEnglisch Mark as bestDeutsch Als beste markieren
pinnedEnglisch Best answerDeutsch Beste Antwort
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
caseEnglisch CaseDeutsch Fall
casesEnglisch {n} casesDeutsch {n} Fälle
passedEnglisch {n} of {total} passedDeutsch {n} von {total} bestanden
failingEnglisch Show failing onlyDeutsch Nur nicht bestandene
passEnglisch PassDeutsch Bestanden
failEnglisch FailDeutsch Nicht bestanden
scoreNoteEnglisch Scores come from an automatic check. Read the failing answers before you decide.Deutsch Die Werte kommen aus einer automatischen Prüfung. Lies die schwachen Antworten, bevor du entscheidest.
modelEnglisch ModelDeutsch Modell

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Antworten vergleichen reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-current, aria-label, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 650 Zeilen
components/ui/compare-outputs.tsx
'use client';

import { Fragment, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';

export type CompareOutputsVariant = 'blind-vote' | 'text-diff' | 'model-grid' | 'eval-table';

export interface CompareOutput {
  id: string;
  /** Model or version name, hidden until the vote in blind-vote */
  model: string;
  text: string;
  /** e.g. "1.8 s" */
  time?: string;
  /** e.g. "412 tokens" */
  tokens?: string;
  /** e.g. "0.4 credits" */
  cost?: string;
}

export interface EvalCase {
  id: string;
  prompt: string;
  /** Score per model id, 0 to 1 */
  scores: Record<string, number>;
  /** What the case checks, e.g. "Cites a source" */
  check?: string;
}

export interface CompareOutputsProps {
  variant?: CompareOutputsVariant;
  title?: string;
  /** The prompt both outputs answer */
  prompt?: string;
  outputs?: CompareOutput[];
  /** text-diff: the earlier and the later version */
  before?: { label: string; text: string };
  after?: { label: string; text: string };
  /** eval-table */
  models?: { id: string; name: string }[];
  cases?: EvalCase[];
  /** eval-table: score from which a case passes, 0 to 1 */
  passAt?: number;
  onVote?: (choice: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    prompt: 'Prompt',
    answer: 'Answer {n}',
    which: 'Which answer is better?',
    better: '{n} is better',
    tie: 'Both are good',
    bad: 'Both are poor',
    revealed: 'Thanks. Here is which model wrote what.',
    again: 'Vote again',
    blindNote: 'Models stay hidden until you vote, so the name does not sway you.',
    youPicked: 'Your pick',
    changes: 'Changes',
    added: '{n} added',
    removed: '{n} removed',
    
    onlyChanges: 'Changes only',
    inline: 'Inline',
    sideBySide: 'Side by side',
    
    time: 'Time',
    tokens: 'Length',
    cost: 'Cost',
    pin: 'Mark as best',
    pinned: 'Best answer',
    copy: 'Copy',
    copied: 'Copied',
    case: 'Case',
    cases: '{n} cases',
    
    passed: '{n} of {total} passed',
    failing: 'Show failing only',
    pass: 'Pass',
    fail: 'Fail',
    scoreNote: 'Scores come from an automatic check. Read the failing answers before you decide.',
    model: 'Model',
    
    
  },
  de: {
    prompt: 'Auftrag',
    answer: 'Antwort {n}',
    which: 'Welche Antwort ist besser?',
    better: '{n} ist besser',
    tie: 'Beide gut',
    bad: 'Beide schwach',
    revealed: 'Danke. So verteilen sich die Modelle.',
    again: 'Neu abstimmen',
    blindNote: 'Die Modelle bleiben verborgen, bis du abstimmst, damit der Name nicht beeinflusst.',
    youPicked: 'Deine Wahl',
    changes: 'Änderungen',
    added: '{n} neu',
    removed: '{n} entfernt',
    
    onlyChanges: 'Nur Änderungen',
    inline: 'Im Text',
    sideBySide: 'Nebeneinander',
    
    time: 'Dauer',
    tokens: 'Länge',
    cost: 'Kosten',
    pin: 'Als beste markieren',
    pinned: 'Beste Antwort',
    copy: 'Kopieren',
    copied: 'Kopiert',
    case: 'Fall',
    cases: '{n} Fälle',
    
    passed: '{n} von {total} bestanden',
    failing: 'Nur nicht bestandene',
    pass: 'Bestanden',
    fail: 'Nicht bestanden',
    scoreNote: 'Die Werte kommen aus einer automatischen Prüfung. Lies die schwachen Antworten, bevor du entscheidest.',
    model: 'Modell',
    
    
  },
};

type Texts = (typeof TEXTS)['en'];
type P = CompareOutputsProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

const CSS =
  '@keyframes cmp-up{from{opacity:0;transform:translateY(8px)}}@keyframes cmp-flip{0%{opacity:0;transform:rotateX(70deg)}100%{opacity:1;transform:none}}@keyframes cmp-bar{from{transform:scaleX(0)}}@keyframes cmp-hl{from{background-size:0 100%}}' +
  '@media (prefers-reduced-motion:no-preference){.cmp-up{animation:cmp-up .45s var(--ease-kit) var(--cmp-d,0ms) both}.cmp-flip{transform-origin:50% 0;animation:cmp-flip .55s var(--ease-kit) var(--cmp-d,0ms) both}.cmp-bar{transform-origin:left;animation:cmp-bar .9s var(--ease-kit) var(--cmp-d,0ms) both}.cmp-hl{animation:cmp-hl .7s var(--ease-kit) var(--cmp-d,0ms) both}}';

function Style() {
  return (
    <style href="kit-compare-outputs" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function Stage({ title, prompt, t, children, className, wide = true }: { title?: string; prompt?: string; t: Texts; children: React.ReactNode; className?: string; wide?: boolean }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[48rem]')}>
          {(title || prompt) && (
            <Reveal className="mb-6 sm:mb-8">
              {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
              {prompt && (
                <p className="mt-3 flex max-w-[44rem] items-start gap-2.5 rounded-xl border border-border bg-card px-4 py-3 text-[14.5px] leading-relaxed text-foreground shadow-[var(--shadow-sm)]">
                  <span className="mt-0.5 shrink-0 text-[12px] font-medium text-muted-foreground">{t.prompt}</span>
                  {prompt}
                </p>
              )}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

function Paragraphs({ text, className }: { text: string; className?: string }) {
  return (
    <div className={cn('space-y-3 text-pretty text-[14.5px] leading-relaxed text-foreground', className)}>
      {text.split(/\n{2,}/).map((p, i) => (
        <p key={i} className="whitespace-pre-line">
          {p.split(/(\*\*[^*]+\*\*)/g).map((x, j) => (x.startsWith('**') ? <strong key={j} className="font-semibold">{x.slice(2, -2)}</strong> : <Fragment key={j}>{x}</Fragment>))}
        </p>
      ))}
    </div>
  );
}

const Letter = ({ i }: { i: number }) => String.fromCharCode(65 + i);

/* ------------------------------------------------------------------ */
/* blind-vote                                                          */
/* ------------------------------------------------------------------ */

function BlindVote({ t, title, prompt, outputs = [], onVote, className }: P) {
  const pair = outputs.slice(0, 2);
  const [vote, setVote] = useState<string | null>(null);
  const choices = [...pair.map((o, i) => ({ id: o.id, label: fill(t.better, { n: Letter({ i }) }) })), { id: 'tie', label: t.tie }, { id: 'bad', label: t.bad }];
  const pick = (id: string) => {
    setVote(id);
    onVote?.(id);
  };
  return (
    <Stage title={title} prompt={prompt} t={t} className={className}>
      <Stagger step={120} className="grid grid-cols-1 gap-3 md:grid-cols-2 md:gap-4">
        {pair.map((o, i) => {
          const won = vote === o.id;
          const lost = vote && vote !== o.id && vote !== 'tie' && vote !== 'bad';
          return (
            <article key={o.id} className={cn('flex h-full flex-col rounded-2xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,opacity,box-shadow] duration-500 ease-[var(--ease-kit)]', won ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-border', lost && 'opacity-70')}>
              <header className="flex h-12 items-center gap-2.5 border-b border-border px-4 [perspective:400px]">
                <span className={cn('grid size-6 place-items-center rounded-md text-[12px] font-semibold', won ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-foreground')}>
                  <Letter i={i} />
                </span>
                {vote ? (
                  <span key="m" className="cmp-flip text-[13.5px] font-medium text-foreground" style={{ '--cmp-d': `${i * 120}ms` } as React.CSSProperties}>
                    {o.model}
                  </span>
                ) : (
                  <span className="text-[13.5px] font-medium text-muted-foreground">{fill(t.answer, { n: Letter({ i }) })}</span>
                )}
                {won && (
                  <span className="cmp-up ml-auto inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] px-2 py-1 text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                    <Icon name="check" size={12} strokeWidth={2.2} />
                    {t.youPicked}
                  </span>
                )}
              </header>
              <div className="flex-1 p-4 sm:p-5">
                <Paragraphs text={o.text} />
              </div>
              {(o.time || o.tokens) && (
                <footer className="flex gap-4 border-t border-border px-4 py-2.5 text-[12px] tabular-nums text-muted-foreground">
                  {o.time && <span>{o.time}</span>}
                  {o.tokens && <span>{o.tokens}</span>}
                </footer>
              )}
            </article>
          );
        })}
      </Stagger>
      <Reveal delay={200}>
        <div className="mt-4 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5" aria-live="polite">
          {vote ? (
            <div className="cmp-up flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <p className="flex items-center gap-2 text-[14px] text-foreground">
                <Icon name="check-circle" size={17} className="text-success-foreground" />
                {t.revealed}
              </p>
              <button type="button" onClick={() => setVote(null)} className="inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground hover:bg-accent sm:h-9">
                <Icon name="refresh" size={15} />
                {t.again}
              </button>
            </div>
          ) : (
            <>
              <p className="text-[14px] font-medium text-foreground">{t.which}</p>
              <div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
                {choices.map((c, i) => (
                  <button key={c.id} type="button" onClick={() => pick(c.id)} className={cn('inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border px-3 text-[13.5px] font-medium transition-colors', i < 2 ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] text-foreground hover:bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}>
                    {i < 2 && <Icon name={i === 0 ? 'arrow-left' : 'arrow-right'} size={15} className="max-md:rotate-90" />}
                    {c.label}
                  </button>
                ))}
              </div>
              <p className="mt-3 flex items-start gap-1.5 text-[12.5px] text-muted-foreground">
                <Icon name="eye-off" size={14} className="mt-px shrink-0" />
                {t.blindNote}
              </p>
            </>
          )}
        </div>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* text-diff                                                           */
/* ------------------------------------------------------------------ */

type Op = { kind: 'same' | 'add' | 'del'; text: string };

/**
 * Word diff by longest common subsequence, enough for answers of a few hundred
 * words. Changed words between two unchanged ones form one region: first what
 * goes, then what comes, so a rewrite reads as one swap and not as confetti.
 */
function diffWords(a: string, b: string): Op[] {
  const A = a.trim().split(/\s+/).filter(Boolean);
  const B = b.trim().split(/\s+/).filter(Boolean);
  const n = A.length;
  const m = B.length;
  const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
  for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = A[i] === B[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
  const out: Op[] = [];
  let dels: string[] = [];
  let adds: string[] = [];
  const flush = () => {
    if (dels.length) out.push({ kind: 'del', text: dels.join(' ') });
    if (adds.length) out.push({ kind: 'add', text: adds.join(' ') });
    dels = [];
    adds = [];
  };
  const same = (w: string) => {
    flush();
    const last = out[out.length - 1];
    if (last?.kind === 'same') last.text += ` ${w}`;
    else out.push({ kind: 'same', text: w });
  };
  let i = 0;
  let j = 0;
  while (i < n && j < m) {
    if (A[i] === B[j]) same(A[i++]), j++;
    else if (L[i + 1][j] >= L[i][j + 1]) dels.push(A[i++]);
    else adds.push(B[j++]);
  }
  while (i < n) dels.push(A[i++]);
  while (j < m) adds.push(B[j++]);
  flush();
  return out;
}

const count = (ops: Op[], k: Op['kind']) => ops.filter((o) => o.kind === k).reduce((s, o) => s + o.text.split(' ').length, 0);

/** Joins the parts with single spaces, the marks never swallow a space */
function Joined({ ops, render }: { ops: Op[]; render: (o: Op, i: number) => React.ReactNode }) {
  return (
    <>
      {ops.map((o, i) => (
        <Fragment key={i}>
          {i > 0 && ' '}
          {render(o, i)}
        </Fragment>
      ))}
    </>
  );
}

function TextDiff({ t, title, prompt, before, after, className }: P) {
  const ops = useMemo(() => diffWords(before?.text ?? '', after?.text ?? ''), [before?.text, after?.text]);
  const [mode, setMode] = useState<'inline' | 'split'>('inline');
  const [only, setOnly] = useState(false);
  const add = count(ops, 'add');
  const del = count(ops, 'del');
  const shown = only ? ops.map((o) => (o.kind === 'same' && o.text.split(' ').length > 8 ? { ...o, text: `${o.text.split(' ').slice(0, 3).join(' ')} … ${o.text.split(' ').slice(-3).join(' ')}` } : o)) : ops;
  const del_ = 'rounded-[3px] bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_65%,transparent)] [box-decoration-break:clone]';
  const add_ = 'cmp-hl rounded-[3px] bg-[linear-gradient(color-mix(in_oklab,var(--success)_20%,transparent),color-mix(in_oklab,var(--success)_20%,transparent))] bg-no-repeat text-foreground no-underline [box-decoration-break:clone] [background-size:100%_100%]';
  const seg = (k: 'inline' | 'split', label: string) => (
    <button type="button" aria-pressed={mode === k} onClick={() => setMode(k)} className={cn('inline-flex h-11 items-center rounded-md px-3 text-[13px] font-medium transition-colors sm:h-8', mode === k ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
      {label}
    </button>
  );
  return (
    <Stage title={title} prompt={prompt} t={t} className={className} wide={mode === 'split'}>
      <Reveal delay={60}>
        <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
          <p className="mr-auto flex items-center gap-3 text-[13px] tabular-nums">
            <span className="font-medium text-foreground">{t.changes}</span>
            <span className="text-success-foreground">+{fill(t.added, { n: add })}</span>
            <span className="text-destructive">−{fill(t.removed, { n: del })}</span>
          </p>
          <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[13px] text-muted-foreground sm:min-h-8">
            <input type="checkbox" checked={only} onChange={(e) => setOnly(e.target.checked)} className="size-4 accent-[var(--primary)]" />
            {t.onlyChanges}
          </label>
          <div role="group" aria-label={t.changes} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1 max-md:hidden">
            {seg('inline', t.inline)}
            {seg('split', t.sideBySide)}
          </div>
        </div>
        {mode === 'inline' ? (
          <article className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
            <header className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3 text-[12.5px] text-muted-foreground sm:px-5">
              <span className="rounded-md bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] px-1.5 py-0.5">{before?.label}</span>
              <Icon name="arrow-right" size={13} />
              <span className="rounded-md bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-1.5 py-0.5 text-foreground">{after?.label}</span>
            </header>
            <p className="p-4 text-pretty text-[15px] leading-[1.85] text-foreground sm:p-6">
              <Joined ops={shown} render={(o, i) => (o.kind === 'same' ? <span className={only ? 'text-muted-foreground' : undefined}>{o.text}</span> : o.kind === 'del' ? <del className={del_}>{o.text}</del> : <ins className={add_} style={{ '--cmp-d': `${200 + i * 40}ms` } as React.CSSProperties}>{o.text}</ins>)} />
            </p>
          </article>
        ) : (
          <div className="grid grid-cols-2 gap-4">
            {[
              { side: before, keep: 'del' as const },
              { side: after, keep: 'add' as const },
            ].map(({ side, keep }) => (
              <article key={keep} className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
                <header className="border-b border-border px-5 py-3 text-[12.5px] font-medium text-muted-foreground">{side?.label}</header>
                <p className="p-5 text-pretty text-[14.5px] leading-[1.8] text-foreground">
                  <Joined ops={shown.filter((o) => o.kind === 'same' || o.kind === keep)} render={(o) => (o.kind === 'same' ? <span className={only ? 'text-muted-foreground' : undefined}>{o.text}</span> : keep === 'del' ? <del className={del_}>{o.text}</del> : <ins className={add_}>{o.text}</ins>)} />
                </p>
              </article>
            ))}
          </div>
        )}
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* model-grid                                                          */
/* ------------------------------------------------------------------ */

function ModelGrid({ t, title, prompt, outputs = [], className }: P) {
  const [best, setBest] = useState<string | null>(null);
  const [copied, setCopied] = useState<string | null>(null);
  const [active, setActive] = useState(0);
  const [row, fade] = useScrollFade<HTMLDivElement>();
  const scroller = row as React.MutableRefObject<HTMLDivElement | null>;
  const go = (i: number) => {
    const el = scroller.current?.children[0]?.children[i] as HTMLElement | undefined;
    el?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', inline: 'start', block: 'nearest' });
  };
  return (
    <Stage title={title} prompt={prompt} t={t} className={className}>
      <div
        ref={row}
        tabIndex={0}
        aria-label={t.model}
        onScroll={(e) => {
          const el = e.currentTarget;
          const w = (el.children[0]?.children[0] as HTMLElement | undefined)?.offsetWidth ?? 1;
          setActive(Math.round(el.scrollLeft / (w + 12)));
        }}
        className={cn('kit-no-scrollbar -mx-6 snap-x snap-mandatory scroll-px-6 overflow-x-auto px-6 lg:mx-0 lg:overflow-visible lg:px-0', fade, 'lg:[mask-image:none]')}
      >
        <Stagger step={90} className={cn('flex w-max gap-3 lg:grid lg:w-auto', outputs.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
          {outputs.map((o) => {
            const on = best === o.id;
            return (
              <article key={o.id} className={cn('flex w-[min(20rem,calc(100vw-4.5rem))] snap-start flex-col rounded-2xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 lg:w-auto', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-border')}>
                <header className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
                  <span className="truncate text-[14px] font-semibold text-foreground">{o.model}</span>
                  {on && (
                    <span className="cmp-up inline-flex shrink-0 items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] px-1.5 py-0.5 text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                      <Icon name="star" size={11} />
                      {t.pinned}
                    </span>
                  )}
                </header>
                <div className="flex-1 p-4">
                  <Paragraphs text={o.text} className="text-[14px]" />
                </div>
                <dl className="grid grid-cols-3 gap-px border-y border-border bg-border text-[12px]">
                  {[
                    [t.time, o.time],
                    [t.tokens, o.tokens],
                    [t.cost, o.cost],
                  ].map(([k, v]) => (
                    <div key={k} className="bg-card px-3 py-2">
                      <dt className="text-subtle-foreground">{k}</dt>
                      <dd className="mt-0.5 truncate tabular-nums text-foreground">{v ?? ''}</dd>
                    </div>
                  ))}
                </dl>
                <footer className="flex gap-1.5 p-2.5">
                  <button type="button" aria-pressed={on} onClick={() => setBest(on ? null : o.id)} className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 rounded-lg text-[13px] font-medium transition-colors sm:h-9', on ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'border border-border text-foreground hover:bg-accent')}>
                    <Icon name="star" size={14} />
                    {on ? t.pinned : t.pin}
                  </button>
                  <button
                    type="button"
                    aria-label={copied === o.id ? t.copied : t.copy}
                    onClick={() => {
                      navigator.clipboard?.writeText(o.text).catch(() => {});
                      setCopied(o.id);
                      window.setTimeout(() => setCopied((c) => (c === o.id ? null : c)), 1600);
                    }}
                    className="grid size-11 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground hover:bg-accent hover:text-foreground sm:size-9"
                  >
                    <Icon name={copied === o.id ? 'check' : 'copy'} size={15} className={copied === o.id ? 'cmp-up text-success-foreground' : undefined} />
                  </button>
                </footer>
              </article>
            );
          })}
        </Stagger>
      </div>
      <div className="mt-4 flex items-center justify-center gap-1 lg:hidden">
        {outputs.map((o, i) => (
          <button key={o.id} type="button" aria-label={o.model} aria-current={i === active} onClick={() => go(i)} className="grid size-11 place-items-center">
            <span className={cn('h-1.5 rounded-full transition-[width,background-color] duration-300', i === active ? 'w-5 bg-primary' : 'w-1.5 bg-[color-mix(in_oklab,var(--foreground)_25%,transparent)]')} />
          </button>
        ))}
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* eval-table                                                          */
/* ------------------------------------------------------------------ */

function EvalTable({ t, title, prompt, models = [], cases = [], passAt = 0.7, className }: P) {
  const locale = useLocale();
  const [failing, setFailing] = useState(false);
  const [phone, setPhone] = useState(models[0]?.id);
  const pct = (v: number) => (locale === 'de' ? `${Math.round(v * 100)} %` : `${Math.round(v * 100)}%`);
  const avg = (id: string) => (cases.length ? cases.reduce((s, c) => s + (c.scores[id] ?? 0), 0) / cases.length : 0);
  const passed = (id: string) => cases.filter((c) => (c.scores[id] ?? 0) >= passAt).length;
  const shown = failing ? cases.filter((c) => models.some((m) => (c.scores[m.id] ?? 0) < passAt)) : cases;
  // Best by the shown percentage, a tie goes to more passed cases
  const rank = (id: string) => Math.round(avg(id) * 100) * 100 + passed(id);
  const topId = models.reduce<string | undefined>((b, m) => (!b || rank(m.id) > rank(b) ? m.id : b), undefined);
  const cell = (v: number) => (
    <span className={cn('inline-flex min-w-[3.25rem] items-center justify-center gap-1 rounded-md px-1.5 py-1 text-[12.5px] font-medium tabular-nums', v >= passAt ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive')}>
      <Icon name={v >= passAt ? 'check' : 'x'} size={12} strokeWidth={2.2} />
      <span className="sr-only">{v >= passAt ? t.pass : t.fail}</span>
      {pct(v)}
    </span>
  );
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);

  return (
    <Stage title={title} prompt={prompt} t={t} className={className}>
      <Stagger step={90} className={cn('mb-4 grid gap-2 sm:gap-3', models.length >= 3 ? 'grid-cols-3' : 'grid-cols-2')}>
        {models.map((m, i) => (
          <div key={m.id} className={cn('min-w-0 rounded-2xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] sm:p-4', m.id === topId ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))]' : 'border-border')}>
            <p className="flex items-center justify-between gap-2 text-[13px] font-medium text-foreground">
              {m.name}
              {m.id === topId && <Icon name="trophy" size={15} className="text-primary" />}
            </p>
            <p className="mt-2 text-[20px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground sm:text-[26px]">{pct(avg(m.id))}</p>
            <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_9%,transparent)]">
              <span className="cmp-bar block h-full rounded-full bg-primary" style={{ width: `${round(avg(m.id) * 100, 1)}%`, '--cmp-d': `${200 + i * 120}ms` } as React.CSSProperties} />
            </div>
            <p className="mt-2 text-[11.5px] leading-snug tabular-nums text-muted-foreground sm:text-[12px]">{fill(t.passed, { n: passed(m.id), total: cases.length })}</p>
          </div>
        ))}
      </Stagger>
      <Reveal delay={160}>
        <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5 sm:px-5">
            <p className="text-[13px] font-medium text-foreground">
              {fill(t.cases, { n: cases.length })}
            </p>
            <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[13px] text-muted-foreground sm:min-h-8">
              <input type="checkbox" checked={failing} onChange={(e) => setFailing(e.target.checked)} className="size-4 accent-[var(--primary)]" />
              {t.failing}
            </label>
          </div>
          {/* Phones: one model at a time */}
          <div className="md:hidden">
            <div role="tablist" aria-label={t.model} className="flex gap-1 border-b border-border p-2">
              {models.map((m, i) => (
                <button
                  key={m.id}
                  ref={(el) => {
                    tabs.current[i] = el;
                  }}
                  type="button"
                  role="tab"
                  aria-selected={phone === m.id}
                  tabIndex={phone === m.id ? 0 : -1}
                  onClick={() => setPhone(m.id)}
                  onKeyDown={(e) => {
                    const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                    if (!d) return;
                    e.preventDefault();
                    const n = (i + d + models.length) % models.length;
                    setPhone(models[n].id);
                    tabs.current[n]?.focus();
                  }}
                  className={cn('min-h-11 flex-1 truncate rounded-lg px-2 text-[12.5px] font-medium', phone === m.id ? 'bg-accent text-foreground' : 'text-muted-foreground')}
                >
                  {m.name}
                </button>
              ))}
            </div>
            <ul role="tabpanel" className="divide-y divide-border">
              {shown.map((c) => (
                <li key={c.id} className="flex items-start justify-between gap-3 px-4 py-3">
                  <span className="min-w-0">
                    <span className="block text-[13.5px] leading-snug text-foreground">{c.prompt}</span>
                    {c.check && <span className="mt-0.5 block text-[12px] text-muted-foreground">{c.check}</span>}
                  </span>
                  {cell(c.scores[phone ?? ''] ?? 0)}
                </li>
              ))}
            </ul>
          </div>
          <table className="hidden w-full border-collapse text-left md:table">
            <caption className="sr-only">{title}</caption>
            <thead>
              <tr className="border-b border-border text-[12px] text-muted-foreground">
                <th scope="col" className="px-5 py-2.5 font-medium">
                  {t.case}
                </th>
                {models.map((m) => (
                  <th key={m.id} scope="col" className="w-32 px-3 py-2.5 text-center font-medium">
                    {m.name}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {shown.map((c, ri) => (
                <tr key={c.id} className="cmp-up border-b border-border last:border-0" style={{ '--cmp-d': `${ri * 40}ms` } as React.CSSProperties}>
                  <th scope="row" className="px-5 py-3 font-normal">
                    <span className="block text-[13.5px] leading-snug text-foreground">{c.prompt}</span>
                    {c.check && <span className="mt-0.5 block text-[12px] text-muted-foreground">{c.check}</span>}
                  </th>
                  {models.map((m) => (
                    <td key={m.id} className="px-3 py-3 text-center">
                      {cell(c.scores[m.id] ?? 0)}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </Reveal>
      <p className="mt-3 flex items-start gap-2 text-[12.5px] text-muted-foreground">
        <Icon name="info" size={14} className="mt-px shrink-0" />
        {t.scoreNote}
      </p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Comparing AI outputs: a blind vote between two answers that reveals the
 * models afterwards, a word diff between two versions, answers of several
 * models side by side with time, length and cost, and an evaluation table.
 */
export function CompareOutputs({ variant = 'blind-vote', labels, ...props }: CompareOutputsProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'text-diff':
        return <TextDiff {...p} />;
      case 'model-grid':
        return <ModelGrid {...p} />;
      case 'eval-table':
        return <EvalTable {...p} />;
      default:
        return <BlindVote {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Antworten vergleichen?

Mit aiw add @ai-website/compare-outputs. Der Befehl legt die Datei components/ui/compare-outputs.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Antworten vergleichen?

4 Varianten, Blinde Abstimmung, Wortvergleich, Modelle nebeneinander und Auswertungstabelle. Du wählst sie mit der Prop variant, zum Beispiel variant="text-diff".

Kann ich die Texte von Antworten vergleichen übersetzen?

Ja. Antworten vergleichen bringt 31 eigene Texte auf Deutsch und Englisch mit, etwa „Auftrag“, „Welche Antwort ist besser?“ und „Beide gut“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Antworten vergleichen einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Antworten vergleichen mit aiw_components und installieren sie mit aiw_add @ai-website/compare-outputs. Nenn im Prompt die Variante, etwa variant="blind-vote", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Antworten vergleichen ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden CompareOutputs-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert