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
Variants
Details
Was Modellauswahl mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Einrichtung
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ModelPickerProps aus components/ui/model-picker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalModelPickerVariantdropdowncardscompare-tableauto-routingtask-defaults
titleoptionalstringdescriptionoptionalstringmodelsPflichtAiModelOption[]defaultValueoptionalstringSelected model id
defaultOpenoptionalbooleandropdown: start open
autooptionalbooleandropdown: offer an automatic choice
routesoptionalModelRoute[]auto-routing
tasksoptionalModelTask[]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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| model | Englisch Model | Deutsch Modell |
| choose | Englisch Choose a model | Deutsch Modell wählen |
| auto | Englisch Auto | Deutsch Automatisch |
| autoNote | Englisch Picks a model for each request | Deutsch Wählt für jede Anfrage ein Modell |
| speed | Englisch Speed | Deutsch Tempo |
| depth | Englisch Reasoning | Deutsch Denktiefe |
| context | Englisch Context | Deutsch Kontext |
| cost | Englisch Cost per message | Deutsch Kosten je Nachricht |
| costShort | Englisch Cost | Deutsch Kosten |
| use | Englisch Use this model | Deutsch Dieses Modell nutzen |
| using | Englisch In use | Deutsch In Nutzung |
| compare | Englisch Compare models | Deutsch Modelle vergleichen |
| locked | Englisch Needs {plan} | Deutsch Braucht {plan} |
| files | Englisch Files | Deutsch Dateien |
| images | Englisch Images | Deutsch Bilder |
| web | Englisch Web search | Deutsch Websuche |
| code | Englisch Code | Deutsch Code |
| voice | Englisch Voice | Deutsch Sprache |
| eu | Englisch Processed in the EU | Deutsch Verarbeitung in der EU |
| yes | Englisch Yes | Deutsch Ja |
| no | Englisch No | Deutsch Nein |
| best | Englisch Best in this row | Deutsch Bester Wert der Zeile |
| task | Englisch Request | Deutsch Anfrage |
| routedTo | Englisch Goes to | Deutsch Geht an |
| override | Englisch 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. |
| defaults | Englisch Default model per task | Deutsch Standardmodell je Aufgabe |
| limit | Englisch Most a single request may cost | Deutsch Höchstens je Anfrage |
| limitNote | Englisch Requests above the limit ask before they run. | Deutsch Anfragen über der Grenze fragen nach, bevor sie laufen. |
| saved | Englisch Saved | Deutsch Gespeichert |
| save | Englisch Save defaults | Deutsch Standards speichern |
| reset | Englisch Reset | Deutsch Zurücksetzen |
| level | Englisch {n} of 5 | Deutsch {n} von 5 |
| costLevel | Englisch Cost {n} of 3 | Deutsch Kosten {n} von 3 |
| estimate | Englisch 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. |
| typical | Englisch Typical values | Deutsch Typische Werte |
| startWith | Englisch New chat with {name} | Deutsch Neuer Chat mit {name} |
| models | Englisch Models | Deutsch 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
'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.