Zum Inhalt springen
UI Kit
Accent

Modellauswahl

Fünf Wege, das Modell hinter einer KI-Funktion zu wählen. Ein kompaktes Menü, Karten mit Tempo, Denktiefe, Kontext und Kosten, eine Vergleichstabelle, die automatische Wahl als Weg erklärt und Standards je Aufgabe mit Kostengrenze.

Eine von 3 Components in KI-Einrichtung

5 Variants

Variants

Menü

Ein kompakter Schalter für die Eingabe, mit automatischer Wahl, Tempo und Kosten je Modell und einem gesperrten Modell, das seinen Tarif nennt. Pfeiltasten und Escape funktionieren.

Im Studio öffnen

Modellkarten

Jedes Modell als Karte mit Tempo und Denktiefe als Skalen, Kontext, Kosten und wofür es gut ist. Die Karten sind eine Auswahlgruppe, die gewählte trägt einen Ring.

Im Studio öffnen

Vergleichstabelle

Modelle als Spalten mit Skalen, Kontext, Kosten und Fähigkeiten, der beste Wert jeder Zeile markiert. Telefone zeigen ein Modell nach dem anderen mit gleitendem Umschalter.

Im Studio öffnen

Automatische Wahl

Beispielanfragen links, Modelle rechts und eine Linie dazwischen, die zeigt, welches Modell die automatische Wahl nimmt und warum.

Im Studio öffnen

Standards je Aufgabe

Ein Standardmodell je Aufgabe und eine Grenze für die Kosten einer Anfrage, zusammen gespeichert. Änderungen schalten Speichern und Zurücksetzen frei.

Im Studio öffnen

Details

Was Modellauswahl mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.1 · seit 1.1.0
Datei
components/ui/model-picker.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 37 eigene Texte
In Sammlungen
KI-Produkt

Props

Die Schnittstelle ModelPickerProps aus components/ui/model-picker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ModelPickerVariant

    dropdowncardscompare-tableauto-routingtask-defaults

  • titleoptional
    string
  • descriptionoptional
    string
  • modelsPflicht
    AiModelOption[]
  • defaultValueoptional
    string

    Selected model id

  • defaultOpenoptional
    boolean

    dropdown: start open

  • autooptional
    boolean

    dropdown: offer an automatic choice

  • routesoptional
    ModelRoute[]

    auto-routing

  • tasksoptional
    ModelTask[]

    task-defaults

  • maxCostoptional
    { value: number; min: number; max: number; unit: string }

    task-defaults: credit limit per request, with its range

  • onChangeoptional
    (id: string) => void

Eingebaute Texte

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

Alle 37 Texte anzeigen
modelEnglisch ModelDeutsch Modell
chooseEnglisch Choose a modelDeutsch Modell wählen
autoEnglisch AutoDeutsch Automatisch
autoNoteEnglisch Picks a model for each requestDeutsch Wählt für jede Anfrage ein Modell
speedEnglisch SpeedDeutsch Tempo
depthEnglisch ReasoningDeutsch Denktiefe
contextEnglisch ContextDeutsch Kontext
costEnglisch Cost per messageDeutsch Kosten je Nachricht
costShortEnglisch CostDeutsch Kosten
useEnglisch Use this modelDeutsch Dieses Modell nutzen
usingEnglisch In useDeutsch In Nutzung
compareEnglisch Compare modelsDeutsch Modelle vergleichen
lockedEnglisch Needs {plan}Deutsch Braucht {plan}
filesEnglisch FilesDeutsch Dateien
imagesEnglisch ImagesDeutsch Bilder
webEnglisch Web searchDeutsch Websuche
codeEnglisch CodeDeutsch Code
voiceEnglisch VoiceDeutsch Sprache
euEnglisch Processed in the EUDeutsch Verarbeitung in der EU
yesEnglisch YesDeutsch Ja
noEnglisch NoDeutsch Nein
bestEnglisch Best in this rowDeutsch Bester Wert der Zeile
taskEnglisch RequestDeutsch Anfrage
routedToEnglisch Goes toDeutsch Geht an
overrideEnglisch Auto chooses by the kind of request. You can pick a model by hand at any time.Deutsch Automatisch wählt nach der Art der Anfrage. Du kannst jederzeit selbst ein Modell wählen.
defaultsEnglisch Default model per taskDeutsch Standardmodell je Aufgabe
limitEnglisch Most a single request may costDeutsch Höchstens je Anfrage
limitNoteEnglisch Requests above the limit ask before they run.Deutsch Anfragen über der Grenze fragen nach, bevor sie laufen.
savedEnglisch SavedDeutsch Gespeichert
saveEnglisch Save defaultsDeutsch Standards speichern
resetEnglisch ResetDeutsch Zurücksetzen
levelEnglisch {n} of 5Deutsch {n} von 5
costLevelEnglisch Cost {n} of 3Deutsch Kosten {n} von 3
estimateEnglisch Speeds and costs are typical values and vary with the request.Deutsch Tempo und Kosten sind typische Werte und hängen von der Anfrage ab.
typicalEnglisch Typical valuesDeutsch Typische Werte
startWithEnglisch New chat with {name}Deutsch Neuer Chat mit {name}
modelsEnglisch ModelsDeutsch Modelle

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Modellauswahl reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab und Leertaste.
  • Setzt aria-activedescendant, aria-checked, aria-controls, aria-disabled, aria-expanded, aria-haspopup, aria-label, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, listbox, option, radio, radiogroup, 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. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 969 Zeilen
components/ui/model-picker.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type ModelPickerVariant = 'dropdown' | 'cards' | 'compare-table' | 'auto-routing' | 'task-defaults';

export type ModelAbility = 'files' | 'images' | 'web' | 'code' | 'voice' | 'eu';

export interface AiModelOption {
  id: string;
  name: string;
  description: string;
  /** 1 to 5 */
  speed: number;
  /** 1 to 5, how far it reasons */
  depth: number;
  /** As shown, e.g. "200k tokens" */
  context: string;
  /** As shown, e.g. "1 credit" */
  cost: string;
  /** 1 to 3, drawn as coins */
  costLevel: number;
  /** Small labels, e.g. "New" */
  tags?: string[];
  abilities?: ModelAbility[];
  /** cards: what it is good at */
  bestFor?: string[];
  /** Plan needed to use it, shown instead of the select */
  locked?: string;
}

export interface ModelRoute {
  id: string;
  /** An example request */
  task: string;
  /** Short name of the kind of task */
  kind: string;
  icon?: IconName;
  /** Model id */
  model: string;
  reason: string;
}

export interface ModelTask {
  id: string;
  label: string;
  icon?: IconName;
  description?: string;
  /** Model id */
  model: string;
}

export interface ModelPickerProps {
  variant?: ModelPickerVariant;
  title?: string;
  description?: string;
  models: AiModelOption[];
  /** Selected model id */
  defaultValue?: string;
  /** dropdown: start open */
  defaultOpen?: boolean;
  /** dropdown: offer an automatic choice */
  auto?: boolean;
  /** auto-routing */
  routes?: ModelRoute[];
  /** task-defaults */
  tasks?: ModelTask[];
  /** task-defaults: credit limit per request, with its range */
  maxCost?: { value: number; min: number; max: number; unit: string };
  onChange?: (id: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    model: 'Model',
    choose: 'Choose a model',
    auto: 'Auto',
    autoNote: 'Picks a model for each request',
    speed: 'Speed',
    depth: 'Reasoning',
    context: 'Context',
    cost: 'Cost per message',
    costShort: 'Cost',
    
    
    
    use: 'Use this model',
    using: 'In use',
    compare: 'Compare models',
    locked: 'Needs {plan}',
    files: 'Files',
    images: 'Images',
    web: 'Web search',
    code: 'Code',
    voice: 'Voice',
    eu: 'Processed in the EU',
    yes: 'Yes',
    no: 'No',
    best: 'Best in this row',
    
    task: 'Request',
    routedTo: 'Goes to',
    
    override: 'Auto chooses by the kind of request. You can pick a model by hand at any time.',
    defaults: 'Default model per task',
    limit: 'Most a single request may cost',
    limitNote: 'Requests above the limit ask before they run.',
    saved: 'Saved',
    save: 'Save defaults',
    reset: 'Reset',
    level: '{n} of 5',
    costLevel: 'Cost {n} of 3',
    estimate: 'Speeds and costs are typical values and vary with the request.',
    typical: 'Typical values',
    startWith: 'New chat with {name}',
    models: 'Models',
  },
  de: {
    model: 'Modell',
    choose: 'Modell wählen',
    auto: 'Automatisch',
    autoNote: 'Wählt für jede Anfrage ein Modell',
    speed: 'Tempo',
    depth: 'Denktiefe',
    context: 'Kontext',
    cost: 'Kosten je Nachricht',
    costShort: 'Kosten',
    
    
    
    use: 'Dieses Modell nutzen',
    using: 'In Nutzung',
    compare: 'Modelle vergleichen',
    locked: 'Braucht {plan}',
    files: 'Dateien',
    images: 'Bilder',
    web: 'Websuche',
    code: 'Code',
    voice: 'Sprache',
    eu: 'Verarbeitung in der EU',
    yes: 'Ja',
    no: 'Nein',
    best: 'Bester Wert der Zeile',
    
    task: 'Anfrage',
    routedTo: 'Geht an',
    
    override: 'Automatisch wählt nach der Art der Anfrage. Du kannst jederzeit selbst ein Modell wählen.',
    defaults: 'Standardmodell je Aufgabe',
    limit: 'Höchstens je Anfrage',
    limitNote: 'Anfragen über der Grenze fragen nach, bevor sie laufen.',
    saved: 'Gespeichert',
    save: 'Standards speichern',
    reset: 'Zurücksetzen',
    level: '{n} von 5',
    costLevel: 'Kosten {n} von 3',
    estimate: 'Tempo und Kosten sind typische Werte und hängen von der Anfrage ab.',
    typical: 'Typische Werte',
    startWith: 'Neuer Chat mit {name}',
    models: 'Modelle',
  },
};

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

const CSS =
  '@keyframes mpk-up{from{opacity:0;transform:translateY(8px)}}@keyframes mpk-menu{from{opacity:0;transform:translateY(-6px) scale(.98)}}@keyframes mpk-bar{from{transform:scaleX(0)}}@keyframes mpk-flow{to{stroke-dashoffset:-24}}' +
  '@media (prefers-reduced-motion:no-preference){.mpk-up{animation:mpk-up .45s var(--ease-kit) var(--mpk-d,0ms) both}.mpk-menu{transform-origin:0 0;animation:mpk-menu .22s var(--ease-kit) both}.mpk-bar{transform-origin:left;animation:mpk-bar .9s var(--ease-kit) var(--mpk-d,0ms) both}.mpk-flow{stroke-dasharray:6 6;animation:mpk-flow .9s linear infinite}}';

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

function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<DOMRect | null>(null);
  if (typeof window !== 'undefined' && ref.current) before.current = ref.current.querySelector('[data-glide]')?.getBoundingClientRect() ?? null;
  useIso(() => {
    const m = ref.current?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!m || !a || prefersReducedMotion()) return;
    const b = m.getBoundingClientRect();
    if (!b.width || (Math.abs(a.left - b.left) < 1 && Math.abs(a.top - b.top) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.left - b.left}px, ${a.top - b.top}px) scale(${a.width / b.width}, ${a.height / b.height})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)' });
  }, [key]);
  return ref;
}

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

const ABILITY: Record<ModelAbility, IconName> = { files: 'paperclip', images: 'image', web: 'globe', code: 'code', voice: 'mic', eu: 'shield' };

function Stage({ title, description, children, className, wide }: { title?: string; description?: string; 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 || description) && (
            <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>}
              {description && <p className="mt-2 max-w-[38rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

/** Five short segments, filled to the level */
function Meter({ value, label, t, delay = 0 }: { value: number; label: string; t: Texts; delay?: number }) {
  return (
    <span role="img" aria-label={`${label}, ${fill(t.level, { n: value })}`} className="flex gap-0.5">
      {[1, 2, 3, 4, 5].map((n) => (
        <span key={n} className="h-1.5 w-3.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
          {n <= value && <span className="mpk-bar block h-full rounded-full bg-primary" style={{ '--mpk-d': `${delay + n * 60}ms` } as React.CSSProperties} />}
        </span>
      ))}
    </span>
  );
}

function Coins({ level, t }: { level: number; t: Texts }) {
  return (
    <span role="img" aria-label={fill(t.costLevel, { n: level })} className="inline-flex items-center gap-px">
      {[1, 2, 3].map((n) => (
        <Icon key={n} name="coins" size={13} className={n <= level ? 'text-warning-foreground' : 'text-[color-mix(in_oklab,var(--foreground)_18%,transparent)]'} />
      ))}
    </span>
  );
}

function Tag({ children, tone = 'primary' }: { children: React.ReactNode; tone?: 'primary' | 'muted' }) {
  return (
    <span className={cn('inline-flex h-5 items-center rounded-md px-1.5 text-[11px] font-medium', tone === 'primary' ? 'bg-[color-mix(in_oklab,var(--primary)_15%,var(--card))] text-[color-mix(in_oklab,var(--primary)_72%,var(--foreground))]' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground')}>
      {children}
    </span>
  );
}

function ModelMark({ model, size = 36 }: { model: AiModelOption; size?: number }) {
  // A small drawn mark: rings grow with the reasoning depth, the dot with speed
  const r = round(size / 2 - 2);
  return (
    <svg aria-hidden viewBox={`0 0 ${size} ${size}`} width={size} height={size} className="shrink-0">
      <rect width={size} height={size} rx={round(size * 0.28)} style={{ fill: 'color-mix(in oklab, var(--primary) 12%, var(--card))' }} />
      {Array.from({ length: Math.max(1, Math.min(4, Math.round(model.depth / 1.4))) }, (_, i) => (
        <circle key={i} cx={size / 2} cy={size / 2} r={round(r * (0.32 + i * 0.2))} fill="none" stroke="var(--primary)" strokeOpacity={round(0.85 - i * 0.18)} strokeWidth="1.3" />
      ))}
      <circle cx={round(size / 2 + r * 0.32 * Math.cos(model.speed))} cy={round(size / 2 - r * 0.32 * Math.sin(model.speed))} r="2.2" fill="var(--primary)" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* dropdown                                                            */
/* ------------------------------------------------------------------ */

function Dropdown({ t, title, description, models, defaultValue, defaultOpen, auto = true, onChange, className }: P) {
  const [value, setValue] = useState(defaultValue ?? (auto ? 'auto' : models[0]?.id));
  const [open, setOpen] = useState(!!defaultOpen);
  const [active, setActive] = useState(0);
  const id = useId();
  const btn = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLDivElement>(null);
  const options = [...(auto ? [{ id: 'auto' } as const] : []), ...models];
  const glide = useGlide<HTMLDivElement>(`${active}-${open}`);
  const current = models.find((m) => m.id === value);

  useEffect(() => {
    if (!open) return;
    setActive(Math.max(0, options.findIndex((o) => o.id === value)));
    const away = (e: PointerEvent) => !btn.current?.parentElement?.contains(e.target as Node) && setOpen(false);
    const esc = (e: KeyboardEvent) => e.key === 'Escape' && (setOpen(false), btn.current?.focus());
    document.addEventListener('pointerdown', away);
    document.addEventListener('keydown', esc);
    return () => {
      document.removeEventListener('pointerdown', away);
      document.removeEventListener('keydown', esc);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  const pick = (i: number) => {
    const o = options[i];
    if (!o || ('locked' in o && o.locked)) return;
    setValue(o.id);
    onChange?.(o.id);
    setOpen(false);
    btn.current?.focus();
  };
  const keys = (e: React.KeyboardEvent) => {
    if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) {
      e.preventDefault();
      setOpen(true);
      requestAnimationFrame(() => list.current?.focus());
      return;
    }
    if (!open) return;
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length);
    } else if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      pick(active);
    } else if (e.key === 'Escape' || e.key === 'Tab') {
      setOpen(false);
      if (e.key === 'Escape') btn.current?.focus();
    }
  };

  return (
    <Stage title={title} description={description} className={className}>
      <Reveal effect="scale" delay={80}>
        <div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
          <div className="relative flex items-center gap-2 border-b border-border p-2.5 sm:p-3">
            <button
              ref={btn}
              type="button"
              aria-haspopup="listbox"
              aria-expanded={open}
              aria-controls={id}
              onClick={() => (setOpen((o) => !o), requestAnimationFrame(() => list.current?.focus()))}
              onKeyDown={keys}
              className="inline-flex h-11 items-center gap-2 rounded-lg border border-border bg-background pl-2 pr-2.5 text-[14px] font-medium text-foreground transition-colors hover:bg-accent sm:h-10"
            >
              {current ? <ModelMark model={current} size={26} /> : <span className="grid size-[26px] place-items-center rounded-[7px] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary"><Icon name="sparkle" size={15} /></span>}
              <span className="sr-only">{`${t.model}, `}</span>
              {current?.name ?? t.auto}
              <Icon name="chevrons-up-down" size={15} className="text-muted-foreground" />
            </button>
            <span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground max-sm:hidden">{current ? current.description : t.autoNote}</span>
            {open && (
              <div
                ref={(el) => {
                  (list as React.MutableRefObject<HTMLDivElement | null>).current = el;
                  (glide as React.MutableRefObject<HTMLDivElement | null>).current = el;
                }}
                id={id}
                role="listbox"
                tabIndex={-1}
                aria-label={t.choose}
                aria-activedescendant={`${id}-${options[active]?.id}`}
                onKeyDown={keys}
                className="mpk-menu absolute left-0 top-[calc(100%+0.5rem)] z-20 w-[min(26rem,calc(100vw-3rem))] overflow-hidden rounded-2xl border border-border bg-card p-1.5 shadow-[var(--highlight),var(--shadow-lg)] outline-none"
              >
                {options.map((o, i) => {
                  const on = o.id === value;
                  const m = o.id === 'auto' ? null : (o as AiModelOption);
                  return (
                    <div
                      key={o.id}
                      id={`${id}-${o.id}`}
                      role="option"
                      aria-selected={on}
                      aria-disabled={m?.locked ? true : undefined}
                      onPointerMove={() => setActive(i)}
                      onClick={() => pick(i)}
                      className={cn('relative flex min-h-14 cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2.5', m?.locked && 'cursor-not-allowed')}
                    >
                      {i === active && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-accent" />}
                      <span className={cn('relative', m?.locked && 'opacity-50')}>
                        {m ? <ModelMark model={m} size={34} /> : <span className="grid size-[34px] place-items-center rounded-[10px] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary"><Icon name="sparkle" size={18} /></span>}
                      </span>
                      <span className={cn('relative min-w-0 flex-1', m?.locked && 'opacity-60')}>
                        <span className="flex items-center gap-1.5">
                          <span className="text-[14px] font-medium text-foreground">{m?.name ?? t.auto}</span>
                          {m?.tags?.map((tg) => <Tag key={tg}>{tg}</Tag>)}
                        </span>
                        <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{m ? (m.locked ? fill(t.locked, { plan: m.locked }) : m.description) : t.autoNote}</span>
                      </span>
                      {m && !m.locked && (
                        <span className="relative hidden shrink-0 flex-col items-end gap-1 sm:flex">
                          <Meter value={m.speed} label={t.speed} t={t} />
                          <Coins level={m.costLevel} t={t} />
                        </span>
                      )}
                      {m?.locked && <Icon name="lock" size={15} className="relative shrink-0 text-muted-foreground" />}
                      <Icon name="check" size={16} className={cn('relative shrink-0 text-primary', !on && 'invisible')} />
                    </div>
                  );
                })}
                <div className="mt-1 flex items-center justify-between gap-3 border-t border-border px-2.5 pb-1 pt-2.5 text-[12px] text-muted-foreground">
                  <span className="flex items-center gap-3">
                    <span className="flex items-center gap-1.5">
                      <span className="flex gap-0.5">
                        <span className="h-1.5 w-2 rounded-full bg-primary" />
                        <span className="h-1.5 w-2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                      </span>
                      {t.speed}
                    </span>
                    <span className="flex items-center gap-1">
                      <Icon name="coins" size={13} className="text-warning-foreground" />
                      {t.costShort}
                    </span>
                  </span>
                  <span>{t.typical}</span>
                </div>
              </div>
            )}
          </div>
          <div className="grid min-h-[27rem] place-items-center px-6 py-10 text-center">
            <div key={value} className="mpk-up">
              <span className="mx-auto grid w-fit place-items-center">{current ? <ModelMark model={current} size={48} /> : <span className="grid size-12 place-items-center rounded-[14px] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary"><Icon name="sparkle" size={24} /></span>}</span>
              <p className="mt-4 text-[17px] font-semibold text-foreground">{fill(t.startWith, { name: current?.name ?? t.auto })}</p>
              <p className="mx-auto mt-1 max-w-[22rem] text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{current ? current.description : t.autoNote}</p>
            </div>
          </div>
        </div>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* cards                                                               */
/* ------------------------------------------------------------------ */

function Cards({ t, title, description, models, defaultValue, onChange, className }: P) {
  const [value, setValue] = useState(defaultValue ?? models[0]?.id);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const choose = (id: string) => (setValue(id), onChange?.(id));
  return (
    <Stage title={title} description={description} className={className} wide>
      <Stagger role="radiogroup" aria-label={t.choose} step={90} className={cn('grid grid-cols-1 gap-3 sm:grid-cols-2', models.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
        {models.map((m, i) => {
          const on = m.id === value;
          return (
            <button
              key={m.id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              aria-disabled={m.locked ? true : undefined}
              tabIndex={on ? 0 : -1}
              onClick={() => !m.locked && choose(m.id)}
              onKeyDown={(e) => {
                const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                if (!d) return;
                e.preventDefault();
                let n = i;
                do n = (n + d + models.length) % models.length;
                while (models[n].locked && n !== i);
                choose(models[n].id);
                refs.current[n]?.focus();
              }}
              className={cn(
                'group relative flex h-full flex-col rounded-2xl border bg-card p-4 text-left shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] sm:p-5',
                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 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0',
                m.locked && 'cursor-not-allowed',
              )}
            >
              <span className="flex items-start justify-between gap-2">
                <ModelMark model={m} size={40} />
                <span className={cn('grid size-6 place-items-center rounded-full border transition-colors duration-300', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-transparent')}>
                  <Icon name={m.locked ? 'lock' : 'check'} size={13} strokeWidth={2.2} className={m.locked ? 'text-muted-foreground' : undefined} />
                </span>
              </span>
              <span className="mt-4 flex flex-wrap items-center gap-1.5">
                <span className="text-[16px] font-semibold tracking-[-0.01em] text-foreground">{m.name}</span>
                {m.tags?.map((tg) => <Tag key={tg}>{tg}</Tag>)}
              </span>
              <span className="mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground sm:min-h-[2.75rem]">{m.description}</span>
              <span className="mt-4 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2.5 border-t border-border pt-4 text-[12.5px]">
                <span className="text-muted-foreground">{t.speed}</span>
                <span className="justify-self-end">
                  <Meter value={m.speed} label={t.speed} t={t} delay={i * 90} />
                </span>
                <span className="text-muted-foreground">{t.depth}</span>
                <span className="justify-self-end">
                  <Meter value={m.depth} label={t.depth} t={t} delay={i * 90 + 120} />
                </span>
                <span className="text-muted-foreground">{t.context}</span>
                <span className="justify-self-end tabular-nums text-foreground">{m.context}</span>
                <span className="text-muted-foreground">{t.costShort}</span>
                <span className="flex items-center gap-2 justify-self-end tabular-nums text-foreground">
                  {m.cost}
                  <Coins level={m.costLevel} t={t} />
                </span>
              </span>
              {!!m.bestFor?.length && (
                <span className="mt-4 flex flex-wrap gap-1.5">
                  {m.bestFor.map((b) => (
                    <Tag key={b} tone="muted">
                      {b}
                    </Tag>
                  ))}
                </span>
              )}
              <span className="mt-auto flex items-center gap-1.5 pt-4 text-[12.5px] font-medium">
                {m.locked ? (
                  <span className="text-muted-foreground">{fill(t.locked, { plan: m.locked })}</span>
                ) : on ? (
                  <span className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.using}</span>
                ) : (
                  <span className="text-muted-foreground group-hover:text-foreground">{t.use}</span>
                )}
              </span>
            </button>
          );
        })}
      </Stagger>
      <p className="mt-4 text-[12.5px] text-muted-foreground">{t.estimate}</p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* compare-table                                                       */
/* ------------------------------------------------------------------ */

function CompareTable({ t, title, description, models, defaultValue, onChange, className }: P) {
  const [value, setValue] = useState(defaultValue ?? models[0]?.id);
  const [phone, setPhone] = useState(defaultValue ?? models[0]?.id);
  const glide = useGlide<HTMLDivElement>(phone);
  const all: ModelAbility[] = ['files', 'images', 'web', 'code', 'voice', 'eu'];
  const abilities = all.filter((a) => models.some((m) => m.abilities?.includes(a)));
  const max = (k: 'speed' | 'depth') => Math.max(...models.map((m) => m[k]));
  const minCost = Math.min(...models.map((m) => m.costLevel));
  const rows: { label: string; cell: (m: AiModelOption, i: number) => React.ReactNode; best?: (m: AiModelOption) => boolean }[] = [
    { label: t.speed, cell: (m, i) => <Meter value={m.speed} label={t.speed} t={t} delay={i * 80} />, best: (m) => m.speed === max('speed') },
    { label: t.depth, cell: (m, i) => <Meter value={m.depth} label={t.depth} t={t} delay={i * 80 + 120} />, best: (m) => m.depth === max('depth') },
    { label: t.context, cell: (m) => <span className="tabular-nums">{m.context}</span> },
    { label: t.cost, cell: (m) => (
        <span className="inline-flex items-center gap-2 tabular-nums">
          {m.cost}
          <Coins level={m.costLevel} t={t} />
        </span>
      ), best: (m) => m.costLevel === minCost },
    ...abilities.map((a) => ({
      label: t[a],
      cell: (m: AiModelOption) =>
        m.abilities?.includes(a) ? (
          <span className="inline-flex items-center gap-1.5 text-success-foreground">
            <Icon name="check" size={15} strokeWidth={2.2} />
            <span className="sr-only">{t.yes}</span>
          </span>
        ) : (
          <span className="text-subtle-foreground">
            <Icon name="minus" size={15} />
            <span className="sr-only">{t.no}</span>
          </span>
        ),
    })),
  ];
  const pm = models.find((m) => m.id === phone) ?? models[0];

  return (
    <Stage title={title} description={description} className={className} wide>
      <Reveal effect="scale" delay={80}>
        {/* Phones: one model at a time, picked with a segmented control */}
        <div className="md:hidden">
          <div ref={glide} role="tablist" aria-label={t.choose} className="grid gap-1 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1" style={{ gridTemplateColumns: `repeat(${models.length}, minmax(0,1fr))` }}>
            {models.map((m, i) => (
              <button
                key={m.id}
                type="button"
                role="tab"
                aria-selected={m.id === phone}
                tabIndex={m.id === phone ? 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);
                  (e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
                }}
                className={cn('relative min-h-11 truncate rounded-lg px-1.5 text-[12.5px] font-medium', m.id === phone ? 'text-foreground' : 'text-muted-foreground')}>
                {m.id === phone && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)]" />}
                <span className="relative">{m.name.split(' ').slice(-2).join(' ')}</span>
              </button>
            ))}
          </div>
          {pm && (
            <div key={pm.id} role="tabpanel" className="mpk-up mt-3 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
              <div className="flex items-center gap-3 border-b border-border p-4">
                <ModelMark model={pm} size={36} />
                <div className="min-w-0 flex-1">
                  <p className="text-[15px] font-semibold text-foreground">{pm.name}</p>
                  <p className="text-[12.5px] text-muted-foreground">{pm.description}</p>
                </div>
              </div>
              <dl className="divide-y divide-border">
                {rows.map((r) => (
                  <div key={r.label} className="flex min-h-11 items-center justify-between gap-3 px-4 py-2 text-[13px]">
                    <dt className="text-muted-foreground">{r.label}</dt>
                    <dd className="flex items-center gap-2 text-foreground">
                      {r.best?.(pm) && <span className="size-1.5 rounded-full bg-primary" title={t.best} />}
                      {r.cell(pm, 0)}
                    </dd>
                  </div>
                ))}
              </dl>
              <div className="border-t border-border p-3">
                <button type="button" onClick={() => (setValue(pm.id), onChange?.(pm.id))} disabled={!!pm.locked} className={cn('inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg text-[13.5px] font-medium', value === pm.id ? 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'bg-primary text-primary-foreground disabled:opacity-50')}>
                  {value === pm.id && <Icon name="check" size={15} />}
                  {pm.locked ? fill(t.locked, { plan: pm.locked }) : value === pm.id ? t.using : t.use}
                </button>
              </div>
            </div>
          )}
        </div>
        {/* Tablet and desktop: the full table */}
        <div className="hidden overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] md:block">
          <table className="w-full table-fixed border-collapse text-left text-[13.5px]">
            <caption className="sr-only">{t.compare}</caption>
            <colgroup>
              <col className="w-[22%] lg:w-[20%]" />
              {models.map((m) => (
                <col key={m.id} />
              ))}
            </colgroup>
            <thead>
              <tr className="border-b border-border">
                <th scope="col" className="p-4 align-bottom text-[12px] font-medium text-muted-foreground">
                  {models.length} {t.models}
                </th>
                {models.map((m) => (
                  <th key={m.id} scope="col" className={cn('relative p-4 align-top font-normal transition-colors duration-300', value === m.id && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
                    {value === m.id && <span aria-hidden className="absolute inset-x-0 top-0 h-0.5 bg-primary" />}
                    <ModelMark model={m} size={32} />
                    <span className="mt-2.5 flex flex-wrap items-center gap-1.5">
                      <span className="text-[14.5px] font-semibold text-foreground">{m.name}</span>
                      {m.tags?.map((tg) => <Tag key={tg}>{tg}</Tag>)}
                    </span>
                    <span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{m.description}</span>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {rows.map((r, ri) => (
                <tr key={r.label} className="mpk-up border-b border-border last:border-0" style={{ '--mpk-d': `${200 + ri * 50}ms` } as React.CSSProperties}>
                  <th scope="row" className="px-4 py-3 text-[13px] font-normal text-muted-foreground">
                    {r.label}
                  </th>
                  {models.map((m, i) => (
                    <td key={m.id} className={cn('px-4 py-3 text-foreground transition-colors duration-300', value === m.id && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
                      <span className="flex items-center gap-2">
                        {r.cell(m, i)}
                        {r.best?.(m) && (
                          <span className="size-1.5 shrink-0 rounded-full bg-primary" title={t.best}>
                            <span className="sr-only">{t.best}</span>
                          </span>
                        )}
                      </span>
                    </td>
                  ))}
                </tr>
              ))}
              <tr>
                <td className="p-4" />
                {models.map((m) => (
                  <td key={m.id} className={cn('p-4 pt-2 transition-colors duration-300', value === m.id && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
                    <button
                      type="button"
                      disabled={!!m.locked}
                      aria-pressed={value === m.id}
                      onClick={() => (setValue(m.id), onChange?.(m.id))}
                      className={cn('inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg text-[13px] font-medium transition-colors lg:h-9', value === m.id ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'border border-border bg-background text-foreground hover:bg-accent disabled:opacity-50')}
                    >
                      {value === m.id && <Icon name="check" size={14} />}
                      {m.locked ? fill(t.locked, { plan: m.locked }) : value === m.id ? t.using : t.use}
                    </button>
                  </td>
                ))}
              </tr>
            </tbody>
          </table>
        </div>
      </Reveal>
      <p className="mt-4 flex items-center gap-2 text-[12.5px] text-muted-foreground">
        <span className="size-1.5 rounded-full bg-primary" aria-hidden />
        {t.best} · {t.estimate}
      </p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* auto-routing                                                        */
/* ------------------------------------------------------------------ */

function AutoRouting({ t, title, description, models, routes = [], className }: P) {
  const [sel, setSel] = useState(routes[0]?.id);
  const route = routes.find((r) => r.id === sel) ?? routes[0];
  const target = models.findIndex((m) => m.id === route?.model);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const glide = useGlide<HTMLDivElement>(sel);
  // Rows of both columns share one height, so the line can be drawn from row to row
  const ROW = 64;
  const GAP = 10;
  const yOf = (i: number) => i * (ROW + GAP) + ROW / 2;
  const H = Math.max(routes.length, models.length) * (ROW + GAP) - GAP;

  return (
    <Stage title={title} description={description} className={className} wide>
      <Reveal effect="scale" delay={80}>
        <div className="rounded-2xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-md)] sm:p-5">
          <div className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,1fr)_5rem_minmax(0,0.8fr)] md:gap-0 lg:grid-cols-[minmax(0,1fr)_8rem_minmax(0,0.8fr)]">
            <div>
              <p className="mb-2 px-1 text-[12px] font-medium text-subtle-foreground">{t.task}</p>
              <div ref={glide} role="radiogroup" aria-label={t.task} className="space-y-2.5 md:space-y-[10px]">
                {routes.map((r, i) => {
                  const on = r.id === route?.id;
                  return (
                    <button
                      key={r.id}
                      ref={(el) => {
                        tabs.current[i] = el;
                      }}
                      type="button"
                      role="radio"
                      aria-checked={on}
                      tabIndex={on ? 0 : -1}
                      onClick={() => setSel(r.id)}
                      onKeyDown={(e) => {
                        const d = ['ArrowDown', 'ArrowRight'].includes(e.key) ? 1 : ['ArrowUp', 'ArrowLeft'].includes(e.key) ? -1 : 0;
                        if (!d) return;
                        e.preventDefault();
                        const n = (i + d + routes.length) % routes.length;
                        setSel(routes[n].id);
                        tabs.current[n]?.focus();
                      }}
                      className={cn('relative flex w-full items-center gap-3 rounded-xl border px-3 text-left transition-colors duration-300 md:h-16', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))]' : 'border-border hover:border-border-strong')}
                      style={{ minHeight: ROW }}
                    >
                      {on && <span data-glide aria-hidden className="absolute inset-0 rounded-xl bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]" />}
                      <span className={cn('relative grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-background', on ? 'text-primary' : 'text-muted-foreground')}>
                        <Icon name={r.icon ?? 'chat'} size={17} />
                      </span>
                      <span className="relative min-w-0 flex-1 py-2">
                        <span className="block text-[12px] font-medium text-muted-foreground">{r.kind}</span>
                        <span className="block text-[13.5px] leading-snug text-foreground md:truncate">{r.task}</span>
                      </span>
                    </button>
                  );
                })}
              </div>
            </div>
            <svg aria-hidden viewBox={`0 0 100 ${H}`} preserveAspectRatio="none" className="hidden self-end md:block" style={{ height: H }}>
              {routes.map((r, i) =>
                models.map((m, j) => {
                  if (r.model !== m.id) return null;
                  const on = r.id === route?.id;
                  const y1 = yOf(i);
                  const y2 = yOf(j);
                  return <path key={`${r.id}-${m.id}`} d={`M0 ${y1} C50 ${y1} 50 ${y2} 100 ${y2}`} fill="none" vectorEffect="non-scaling-stroke" stroke={on ? 'var(--primary)' : 'var(--border-strong)'} strokeWidth={on ? 2 : 1} className={on ? 'mpk-flow' : undefined} style={{ transition: 'stroke .3s' }} />;
                }),
              )}
            </svg>
            <div>
              <p className="mb-2 px-1 text-[12px] font-medium text-subtle-foreground max-md:hidden">{t.model}</p>
              <ul className="hidden space-y-[10px] md:block">
                {models.map((m, j) => {
                  const on = j === target;
                  return (
                    <li key={m.id} className={cn('flex items-center gap-3 rounded-xl border px-3 transition-[border-color,background-color,opacity] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]' : 'border-border opacity-70')} style={{ height: ROW }}>
                      <ModelMark model={m} size={32} />
                      <span className="min-w-0">
                        <span className="block truncate text-[13.5px] font-medium text-foreground">{m.name}</span>
                        <span className="flex items-center gap-2">
                          <Meter value={m.speed} label={t.speed} t={t} />
                          <Coins level={m.costLevel} t={t} />
                        </span>
                      </span>
                    </li>
                  );
                })}
              </ul>
            </div>
          </div>
          {route && models[target] && (
            <div key={route.id} className="mpk-up mt-4 flex items-start gap-3 rounded-xl border border-border bg-background/60 p-3.5 sm:p-4" aria-live="polite">
              <span className="max-md:hidden">
                <Icon name="route" size={18} className="mt-0.5 text-primary" />
              </span>
              <span className="md:hidden">
                <ModelMark model={models[target]} size={34} />
              </span>
              <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">
                <span className="font-medium text-foreground">
                  {t.routedTo} {models[target].name}.
                </span>{' '}
                {route.reason}
              </p>
            </div>
          )}
        </div>
      </Reveal>
      <p className="mt-4 flex items-start gap-2 text-[12.5px] text-muted-foreground">
        <Icon name="info" size={14} className="mt-px shrink-0" />
        {t.override}
      </p>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* task-defaults                                                       */
/* ------------------------------------------------------------------ */

function TaskDefaults({ t, title, description, models, tasks = [], maxCost, className }: P) {
  const locale = useLocale();
  const [chosen, setChosen] = useState<Record<string, string>>(() => Object.fromEntries(tasks.map((k) => [k.id, k.model])));
  const [limit, setLimit] = useState(maxCost?.value ?? 0);
  const [saved, setSaved] = useState(false);
  const uid = useId();
  const dirty = tasks.some((k) => chosen[k.id] !== k.model) || limit !== (maxCost?.value ?? 0);
  useEffect(() => setSaved(false), [chosen, limit]);
  const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
  const share = maxCost ? (limit - maxCost.min) / (maxCost.max - maxCost.min) : 0;

  return (
    <Stage title={title} description={description} className={className}>
      <Reveal effect="scale" delay={80}>
        <form
          onSubmit={(e) => {
            e.preventDefault();
            setSaved(true);
          }}
          className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]"
        >
          <p className="border-b border-border px-4 py-3 text-[13px] font-medium text-foreground sm:px-5">{t.defaults}</p>
          <Stagger as="ul" step={60} className="divide-y divide-border">
            {tasks.map((k) => {
              const m = models.find((x) => x.id === chosen[k.id]);
              const fid = `${uid}-task-${k.id}`;
              return (
                <li key={k.id} className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2.5 px-4 py-3.5 sm:grid-cols-[auto_minmax(0,1fr)_15rem] sm:px-5">
                  <span aria-hidden className="grid size-10 place-items-center rounded-xl border border-border bg-background text-muted-foreground">
                    <Icon name={k.icon ?? 'sparkle'} size={18} />
                  </span>
                  <span className="min-w-0">
                    <label htmlFor={fid} className="block text-[14px] font-medium text-foreground">
                      {k.label}
                    </label>
                    {k.description && <span className="block text-[12.5px] text-muted-foreground">{k.description}</span>}
                  </span>
                  <span className="relative col-span-2 sm:col-span-1">
                    <span aria-hidden className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2">{m && <ModelMark model={m} size={26} />}</span>
                    <select
                      id={fid}
                      value={chosen[k.id]}
                      onChange={(e) => setChosen((c) => ({ ...c, [k.id]: e.target.value }))}
                      className="h-11 w-full appearance-none rounded-lg border border-border bg-background pl-10 pr-9 text-[13.5px] text-foreground transition-colors hover:border-border-strong sm:h-10"
                    >
                      {models.map((o) => (
                        <option key={o.id} value={o.id} disabled={!!o.locked}>
                          {o.locked ? `${o.name} · ${fill(t.locked, { plan: o.locked })}` : `${o.name} · ${o.cost}`}
                        </option>
                      ))}
                    </select>
                    <Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
                  </span>
                </li>
              );
            })}
          </Stagger>
          {maxCost && (
            <div className="border-t border-border px-4 py-4 sm:px-5">
              <div className="flex items-baseline justify-between gap-3">
                <label htmlFor={`${uid}-max-cost`} className="text-[14px] font-medium text-foreground">
                  {t.limit}
                </label>
                <span className="text-[15px] font-semibold tabular-nums text-foreground">
                  {nf(limit)} <span className="text-[13px] font-normal text-muted-foreground">{maxCost.unit}</span>
                </span>
              </div>
              <div className="relative mt-3 h-11">
                <div aria-hidden className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
                  <div className="h-full rounded-full bg-primary" style={{ width: `calc(${round(share, 3)} * (100% - 20px) + 10px)` }} />
                </div>
                <input
                  id={`${uid}-max-cost`}
                  type="range"
                  min={maxCost.min}
                  max={maxCost.max}
                  value={limit}
                  onChange={(e) => setLimit(Number(e.target.value))}
                  className="relative h-11 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-card [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:shadow-[var(--shadow-sm)]"
                />
              </div>
              <div className="flex justify-between text-[11.5px] tabular-nums text-subtle-foreground">
                <span>{nf(maxCost.min)}</span>
                <span>{nf(maxCost.max)}</span>
              </div>
              <p className="mt-2 text-[12.5px] text-muted-foreground">{t.limitNote}</p>
            </div>
          )}
          <div className="flex items-center justify-end gap-2 border-t border-border bg-surface/60 px-4 py-3 sm:px-5">
            <span className="mr-auto text-[12.5px] text-muted-foreground" aria-live="polite">
              {saved && (
                <span className="mpk-up inline-flex items-center gap-1.5 text-success-foreground">
                  <Icon name="check-circle" size={15} />
                  {t.saved}
                </span>
              )}
            </span>
            <button
              type="button"
              disabled={!dirty}
              onClick={() => (setChosen(Object.fromEntries(tasks.map((k) => [k.id, k.model]))), setLimit(maxCost?.value ?? 0))}
              className="inline-flex h-11 items-center rounded-lg px-3.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:h-9"
            >
              {t.reset}
            </button>
            <button type="submit" disabled={!dirty} className="inline-flex h-11 items-center rounded-lg bg-primary px-4 text-[13px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9">
              {t.save}
            </button>
          </div>
        </form>
      </Reveal>
    </Stage>
  );
}

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

/**
 * Choosing the model behind an AI feature: a compact dropdown, cards with
 * speed, depth, context and cost, a comparison table, the automatic choice
 * explained with a route from request to model, and defaults per task.
 */
export function ModelPicker({ variant = 'dropdown', labels, ...props }: ModelPickerProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'cards':
        return <Cards {...p} />;
      case 'compare-table':
        return <CompareTable {...p} />;
      case 'auto-routing':
        return <AutoRouting {...p} />;
      case 'task-defaults':
        return <TaskDefaults {...p} />;
      default:
        return <Dropdown {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Modellauswahl?

Mit aiw add @ai-website/model-picker. Der Befehl legt die Datei components/ui/model-picker.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 Modellauswahl?

5 Varianten, Menü, Modellkarten, Vergleichstabelle, Automatische Wahl und Standards je Aufgabe. Du wählst sie mit der Prop variant, zum Beispiel variant="cards".

Kann ich die Texte von Modellauswahl übersetzen?

Ja. Modellauswahl bringt 37 eigene Texte auf Deutsch und Englisch mit, etwa „Modell“, „Modell wählen“ und „Automatisch“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Modellauswahl einbauen?

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

Kann ich Modellauswahl ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert