KI-Suche
KI-Suche in vier Formen. Ergebnisse mit KI-Übersicht oben, deren Belege zu ihrem Ergebnis springen, eine Recherche, die zum Bericht wird, eine Antwort als Vergleichstabelle mit Empfehlung und Hinweisen und ein Verlauf mit Nachfragen.
Eine von 4 Components in KI-Antworten
Variants
Details
Was KI-Suche mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Antworten
- Bereich
- App
- Variants
- 4
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/ai-search.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 29 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AiSearchProps aus components/ui/ai-search.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAiSearchVariantoverviewresearch-reportstructured-answerfollow-up-thread
titleoptionalstringqueryoptionalstringsummaryoptionalstringoverview and follow-up-thread: the AI summary, [n] cites a result
resultsoptionalSearchResult[]confidenceoptional'high' | 'medium' | 'low'overview, research-report and structured-answer: how sure the answer is
queriesoptionalResearchQuery[]research-report
sectionsoptionalReportSection[]readoptionalnumberresearch-report: sources read in total
tableoptionalAnswerTablestructured-answer
caveatsoptionalstring[]structured-answer: things to keep in mind, shown under the table
turnsoptionalThreadTurn[]follow-up-thread
relatedoptionalstring[]onSearchoptional(query: string) => voidoverview and structured-answer: called with the query when the search is submitted
Eingebaute Texte
Diese Texte schreibt KI-Suche selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 29 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| search | Englisch Search | Deutsch Suchen |
| overview | Englisch AI overview | Deutsch KI-Übersicht |
| more | Englisch Show more | Deutsch Mehr zeigen |
| less | Englisch Show less | Deutsch Weniger zeigen |
| results | Englisch Results | Deutsch Ergebnisse |
| source | Englisch Source {n} | Deutsch Quelle {n} |
| confidence_high | Englisch Sources agree | Deutsch Quellen sind sich einig |
| confidence_medium | Englisch Sources partly disagree | Deutsch Quellen sind sich teils uneinig |
| confidence_low | Englisch Few sources, check carefully | Deutsch Wenige Quellen, genau prüfen |
| aiNote | Englisch Generated from the results below. It can be wrong. | Deutsch Aus den Ergebnissen unten erstellt. Kann falsch sein. |
| helpful | Englisch Helpful | Deutsch Hilfreich |
| notHelpful | Englisch Not helpful | Deutsch Nicht hilfreich |
| planning | Englisch Planning searches | Deutsch Plant die Suchen |
| reading | Englisch Reading sources | Deutsch Liest Quellen |
| writing | Englisch Writing the report | Deutsch Schreibt den Bericht |
| readLabel | Englisch sources read | Deutsch Quellen gelesen |
| contents | Englisch Contents | Deutsch Inhalt |
| report | Englisch Report | Deutsch Bericht |
| recommended | Englisch Fits best | Deutsch Passt am besten |
| caveats | Englisch Keep in mind | Deutsch Gut zu wissen |
| sources | Englisch Sources | Deutsch Quellen |
| sourceCount | Englisch {n} sources | Deutsch {n} Quellen |
| follow | Englisch Ask a follow-up | Deutsch Nachfrage stellen |
| ask | Englisch Ask | Deutsch Fragen |
| related | Englisch People also ask | Deutsch Andere fragen auch |
| thinking | Englisch Searching | Deutsch Sucht |
| collapse | Englisch Collapse answer | Deutsch Antwort einklappen |
| expand | Englisch Expand answer | Deutsch Antwort aufklappen |
| fallback | Englisch I looked through the same sources again. The short answer is that it depends on your tariff and how much hot water you use. Open the sources for the details. | Deutsch Ich habe dieselben Quellen noch einmal durchgesehen. Kurz gesagt hängt es von deinem Tarif und deinem Warmwasser ab. Die Details stehen in den Quellen. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- KI-Suche reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-busy, aria-expanded, aria-label, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen search, status, 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.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- 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, 750 Zeilen
'use client';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, 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 AiSearchVariant = 'overview' | 'research-report' | 'structured-answer' | 'follow-up-thread';
export interface SearchResult {
id: number;
title: string;
site: string;
/** Shown as the visible address, e.g. "stadtwerke-nordheim.de › tarife" */
path?: string;
snippet: string;
href?: string;
date?: string;
}
export interface ResearchQuery {
id: string;
query: string;
/** Sources found for it */
found: number;
}
export interface ReportSection {
id: string;
heading: string;
/** Text with [n] citations */
text: string;
}
export interface AnswerTable {
columns: string[];
/** Each row: label and one cell per column, cells may end with [n] */
rows: { label: string; cells: string[] }[];
/** Index of the column that is the recommendation */
pick?: number;
}
export interface ThreadTurn {
id: string;
question: string;
/** Text with [n] citations, **bold** works */
answer: string;
sources?: number[];
}
export interface AiSearchProps {
variant?: AiSearchVariant;
title?: string;
query?: string;
/** overview and follow-up-thread: the AI summary, [n] cites a result */
summary?: string;
results?: SearchResult[];
/** overview, research-report and structured-answer: how sure the answer is */
confidence?: 'high' | 'medium' | 'low';
/** research-report */
queries?: ResearchQuery[];
sections?: ReportSection[];
/** research-report: sources read in total */
read?: number;
/** structured-answer */
table?: AnswerTable;
/** structured-answer: things to keep in mind, shown under the table */
caveats?: string[];
/** follow-up-thread */
turns?: ThreadTurn[];
related?: string[];
/** overview and structured-answer: called with the query when the search is submitted */
onSearch?: (query: string) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search',
overview: 'AI overview',
more: 'Show more',
less: 'Show less',
results: 'Results',
source: 'Source {n}',
confidence_high: 'Sources agree',
confidence_medium: 'Sources partly disagree',
confidence_low: 'Few sources, check carefully',
aiNote: 'Generated from the results below. It can be wrong.',
helpful: 'Helpful',
notHelpful: 'Not helpful',
planning: 'Planning searches',
reading: 'Reading sources',
writing: 'Writing the report',
readLabel: 'sources read',
contents: 'Contents',
report: 'Report',
recommended: 'Fits best',
caveats: 'Keep in mind',
sources: 'Sources',
sourceCount: '{n} sources',
follow: 'Ask a follow-up',
ask: 'Ask',
related: 'People also ask',
thinking: 'Searching',
collapse: 'Collapse answer',
expand: 'Expand answer',
fallback: 'I looked through the same sources again. The short answer is that it depends on your tariff and how much hot water you use. Open the sources for the details.',
},
de: {
search: 'Suchen',
overview: 'KI-Übersicht',
more: 'Mehr zeigen',
less: 'Weniger zeigen',
results: 'Ergebnisse',
source: 'Quelle {n}',
confidence_high: 'Quellen sind sich einig',
confidence_medium: 'Quellen sind sich teils uneinig',
confidence_low: 'Wenige Quellen, genau prüfen',
aiNote: 'Aus den Ergebnissen unten erstellt. Kann falsch sein.',
helpful: 'Hilfreich',
notHelpful: 'Nicht hilfreich',
planning: 'Plant die Suchen',
reading: 'Liest Quellen',
writing: 'Schreibt den Bericht',
readLabel: 'Quellen gelesen',
contents: 'Inhalt',
report: 'Bericht',
recommended: 'Passt am besten',
caveats: 'Gut zu wissen',
sources: 'Quellen',
sourceCount: '{n} Quellen',
follow: 'Nachfrage stellen',
ask: 'Fragen',
related: 'Andere fragen auch',
thinking: 'Sucht',
collapse: 'Antwort einklappen',
expand: 'Antwort aufklappen',
fallback: 'Ich habe dieselben Quellen noch einmal durchgesehen. Kurz gesagt hängt es von deinem Tarif und deinem Warmwasser ab. Die Details stehen in den Quellen.',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiSearchProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const CSS =
'@keyframes asr-up{from{opacity:0;transform:translateY(8px)}}@keyframes asr-pop{from{opacity:0;transform:scale(.9)}}@keyframes asr-dot{0%,80%,100%{opacity:.25}40%{opacity:1}}' +
'@media (prefers-reduced-motion:no-preference){.asr-up{animation:asr-up .45s var(--ease-kit) var(--asr-d,0ms) both}.asr-pop{animation:asr-pop .35s var(--ease-kit) var(--asr-d,0ms) both}.asr-dot{animation:asr-dot 1.2s ease-in-out infinite}}';
function Style() {
return (
<style href="kit-ai-search" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
function Stage({ title, children, className, wide }: { title?: 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-[46rem]')}>
{title && (
<Reveal className="mb-5 sm:mb-6">
<h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
function SearchBar({ value, t, onSubmit }: { value?: string; t: Texts; onSubmit?: (q: string) => void }) {
const [q, setQ] = useState(value ?? '');
return (
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
onSubmit?.(q);
}}
className="flex items-center gap-2 rounded-2xl border border-border bg-card p-1.5 pl-4 shadow-[var(--highlight),var(--shadow-md)] focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
>
<Icon name="search" size={18} className="shrink-0 text-muted-foreground" />
<label className="min-w-0 flex-1">
<span className="sr-only">{t.search}</span>
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} className="h-11 w-full bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
</label>
<button type="submit" aria-label={t.search} className="grid size-11 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
<Icon name="arrow-right" size={18} />
</button>
</form>
);
}
const CONF: Record<'high' | 'medium' | 'low', { icon: IconName; cls: string }> = {
high: { icon: 'check-circle', cls: 'text-success-foreground' },
medium: { icon: 'alert-circle', cls: 'text-warning-foreground' },
low: { icon: 'help-circle', cls: 'text-destructive' },
};
function Confidence({ level, t }: { level: 'high' | 'medium' | 'low'; t: Texts }) {
const c = CONF[level];
return (
<span className={cn('inline-flex items-center gap-1.5 text-[12.5px] font-medium', c.cls)}>
<Icon name={c.icon} size={14} />
{t[`confidence_${level}`]}
</span>
);
}
const cite = 'inline-flex h-[1.3em] min-w-[1.3em] items-center justify-center rounded-[5px] px-1 align-[0.1em] text-[11px] font-semibold tabular-nums leading-none transition-colors';
/** Text with **bold** and [n] markers, the marker stays with the word before it */
function Cited({ text, onCite, active, t }: { text: string; onCite?: (n: number) => void; active?: number | null; t: Texts }) {
const out: React.ReactNode[] = [];
const re = /(\S*)\s?\[(\d+(?:\s*,\s*\d+)*)\]([.,;:!?]?)/g;
let last = 0;
let k = 0;
const bold = (s: string) => s.split(/(\*\*[^*]+\*\*)/g).map((x, i) => (x.startsWith('**') ? <strong key={i} className="font-semibold text-foreground">{x.slice(2, -2)}</strong> : <Fragment key={i}>{x}</Fragment>));
for (const m of text.matchAll(re)) {
out.push(<Fragment key={k++}>{bold(text.slice(last, m.index))}</Fragment>);
out.push(
<span key={k++} className="whitespace-nowrap">
{bold(m[1])}
{m[3]}
{m[2].split(',').map((n) => {
const id = Number(n.trim());
if (!onCite)
return (
<span key={id} aria-label={fill(t.source, { n: id })} className={cn(cite, 'ml-0.5 bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]')}>
{id}
</span>
);
return (
<button key={id} type="button" aria-label={fill(t.source, { n: id })} onClick={() => onCite?.(id)} className={cn(cite, "relative ml-0.5 after:absolute after:-inset-x-1 after:-inset-y-4 after:content-['']", active === id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_24%,var(--card))]')}>
{id}
</button>
);
})}
</span>,
);
last = m.index! + m[0].length;
}
out.push(<Fragment key={k++}>{bold(text.slice(last))}</Fragment>);
return <>{out}</>;
}
function Favicon({ site, id }: { site: string; id: number }) {
const tone = ['--primary', '--info', '--success', '--warning', '--destructive'][(id - 1) % 5];
return (
<span aria-hidden className="grid size-6 shrink-0 place-items-center rounded-full text-[11px] font-semibold uppercase" style={{ background: `color-mix(in oklab, var(${tone}) 16%, var(--card))`, color: `color-mix(in oklab, var(${tone}) 72%, var(--foreground))` }}>
{site.charAt(0)}
</span>
);
}
/* ------------------------------------------------------------------ */
/* overview */
/* ------------------------------------------------------------------ */
function Overview({ t, title, query, summary = '', results = [], confidence = 'high', onSearch, className }: P) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState<number | null>(null);
const [vote, setVote] = useState<'up' | 'down' | null>(null);
// A search plays the overview in again, the results come from onSearch
const [run, setRun] = useState(0);
const go = (n: number) => {
setActive(n);
document.getElementById(`asr-r-${n}`)?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'center' });
};
const paras = summary.split(/\n{2,}/);
return (
<Stage title={title} className={className}>
<Reveal>
<SearchBar value={query} t={t} onSubmit={(q) => (onSearch?.(q), setRun((r) => r + 1), setVote(null))} />
</Reveal>
<Reveal delay={100}>
<section key={run} aria-label={t.overview} className="asr-up relative mt-5 overflow-hidden rounded-2xl border border-[color-mix(in_oklab,var(--primary)_30%,var(--border))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_7%,var(--card)),var(--card)_40%)] shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 pt-4 sm:px-5">
<p className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
<Icon name="sparkle" size={15} className="text-primary" />
{t.overview}
</p>
<Confidence level={confidence} t={t} />
</div>
<div className={cn('relative px-4 pt-3 sm:px-5', !open && 'max-h-[9.5rem] overflow-hidden')}>
<div className="space-y-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">
{paras.map((p, i) => (
<p key={i}>
<Cited text={p} t={t} onCite={go} active={active} />
</p>
))}
</div>
{!open && <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-[linear-gradient(to_top,var(--card),transparent)]" />}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 px-4 pb-3 pt-2 sm:px-5">
<button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} className="inline-flex h-11 items-center gap-1 rounded-lg px-2 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8">
{open ? t.less : t.more}
<Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', open && 'rotate-180')} />
</button>
<span className="flex items-center gap-1">
<span className="mr-1 text-[12px] text-subtle-foreground max-sm:hidden">{t.aiNote}</span>
{(['up', 'down'] as const).map((v) => (
<button key={v} type="button" aria-pressed={vote === v} aria-label={v === 'up' ? t.helpful : t.notHelpful} onClick={() => setVote(vote === v ? null : v)} className={cn('grid size-11 place-items-center rounded-lg transition-colors sm:size-8', vote === v ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
<Icon name={v === 'up' ? 'thumbs-up' : 'thumbs-down'} size={15} />
</button>
))}
</span>
</div>
<p className="border-t border-border px-4 py-2 text-[12px] text-subtle-foreground sm:hidden">{t.aiNote}</p>
</section>
</Reveal>
<h3 className="mb-2 mt-7 px-1 text-[12.5px] font-medium text-subtle-foreground">{t.results}</h3>
<Stagger as="ol" step={60} className="space-y-1">
{results.map((r) => (
<li key={r.id} id={`asr-r-${r.id}`} className={cn('scroll-mt-24 rounded-xl px-3 py-3 transition-colors duration-500 sm:px-4', active === r.id ? 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_35%,transparent)]' : 'hover:bg-card/70')}>
<div className="flex items-center gap-2">
<Favicon site={r.site} id={r.id} />
<span className="min-w-0">
<span className="block truncate text-[13px] text-foreground">{r.site}</span>
{r.path && <span className="block truncate text-[12px] text-muted-foreground">{r.path}</span>}
</span>
<span className={cn(cite, 'ml-auto shrink-0', active === r.id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>{r.id}</span>
</div>
<a href={r.href ?? '#'} className="-mb-3 -mt-1.5 block py-3 text-[16.5px] font-medium leading-snug text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] underline-offset-4 hover:underline">
{r.title}
</a>
<p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">
{r.date && <span className="text-subtle-foreground">{r.date} · </span>}
{r.snippet}
</p>
</li>
))}
</Stagger>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* research-report */
/* ------------------------------------------------------------------ */
function ResearchReport({ t, title, query, queries = [], sections = [], results = [], read = 0, confidence = 'medium', className }: P) {
// phase 0 planning, 1 reading, 2 writing, 3 done. The server renders the finished report.
const [phase, setPhase] = useState(3);
const [count, setCount] = useState(read);
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
const started = useRef(false);
const timers = useRef<number[]>([]);
// Once started the run plays to the end, even when it scrolls out of view, and is only cleared on unmount
useEffect(() => () => timers.current.forEach((x) => (window.clearTimeout(x), window.clearInterval(x))), []);
useEffect(() => {
if (!inView || started.current || prefersReducedMotion()) return;
// The lock is set when the timer fires: strict mode clears the first timer and starts a second one
const go = window.setTimeout(() => {
started.current = true;
setPhase(0);
setCount(0);
const id = window.setInterval(() => setCount((c) => (c >= read ? (window.clearInterval(id), c) : c + Math.max(1, Math.round(read / 12)))), 100);
timers.current.push(window.setTimeout(() => setPhase(1), 500), window.setTimeout(() => setPhase(2), 1600), window.setTimeout(() => setPhase(3), 2200), id);
}, 0);
return () => window.clearTimeout(go);
}, [inView, read]);
const steps = [t.planning, t.reading, t.writing];
const [src, setSrc] = useState<number | null>(null);
const uid = useId();
const toSource = (n: number) => {
setSrc(n);
document.getElementById(`${uid}-src-${n}`)?.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'center' });
};
return (
<Stage title={title} className={className} wide>
<div ref={ref}>
<Reveal>
<p className="mb-4 flex items-start gap-2 rounded-xl border border-border bg-card px-4 py-3 text-[15px] text-foreground shadow-[var(--shadow-sm)]">
<Icon name="search" size={17} className="mt-0.5 shrink-0 text-muted-foreground" />
{query}
</p>
</Reveal>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[19rem_minmax(0,1fr)] lg:items-start lg:gap-6">
{/* The process: steps, sources counted and the searches it ran */}
<Reveal delay={80} className="lg:sticky lg:top-6">
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
<ol className="space-y-2.5" aria-live="polite">
{steps.map((s, i) => {
const st = phase > i ? 'done' : phase === i ? 'now' : 'next';
return (
<li key={s} className="flex items-center gap-2.5 text-[13.5px]">
<span className={cn('grid size-6 shrink-0 place-items-center rounded-full', st === 'done' ? 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' : st === 'now' ? 'bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-primary' : 'border border-border text-subtle-foreground')}>
<Icon name={st === 'done' ? 'check' : st === 'now' ? 'loader' : 'circle'} size={13} className={cn(st === 'now' && 'animate-spin', st === 'next' && 'scale-50')} />
</span>
<span className={st === 'next' ? 'text-muted-foreground' : 'text-foreground'}>{s}</span>
</li>
);
})}
</ol>
<p className="mt-4 border-t border-border pt-3 text-[13px] tabular-nums text-muted-foreground">
<span className="text-[20px] font-semibold text-foreground">{Math.min(count, read)}</span> {t.readLabel}
</p>
<ul className="mt-3 space-y-1.5">
{queries.map((q, i) => (
<li key={q.id} className="flex items-center gap-2 text-[12.5px]">
<Icon name="search" size={12} className="shrink-0 text-subtle-foreground" />
<span className="min-w-0 flex-1 truncate text-muted-foreground">{q.query}</span>
<span className={cn('shrink-0 tabular-nums text-subtle-foreground transition-opacity duration-300', phase < 1 && 'opacity-0')} style={{ transitionDelay: `${i * 250}ms` }}>
{q.found}
</span>
</li>
))}
</ul>
</div>
</Reveal>
<Reveal delay={160}>
<article className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-7" aria-busy={phase < 3}>
<div className="mb-5 flex flex-wrap items-center justify-between gap-2 border-b border-border pb-4">
<p className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
<Icon name="file-check" size={15} className="text-primary" />
{t.report}
</p>
{phase >= 3 ? (
<span className="asr-up">
<Confidence level={confidence} t={t} />
</span>
) : (
<span className="text-[12.5px] text-muted-foreground">{steps[Math.min(phase, 2)]}</span>
)}
</div>
<nav aria-label={t.contents} className="mb-6 flex flex-wrap gap-1.5">
{sections.map((s) => (
<a key={s.id} href={`#asr-s-${s.id}`} className="inline-flex min-h-9 items-center rounded-lg border border-border px-2.5 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:min-h-11">
{s.heading}
</a>
))}
</nav>
<div className="space-y-7">
{sections.map((s, i) => (
<section key={s.id} id={`asr-s-${s.id}`} className={cn('scroll-mt-24 transition-[opacity,filter] duration-700 ease-[var(--ease-kit)]', phase < 3 && 'opacity-25 blur-[2px]')} style={{ transitionDelay: phase >= 3 ? `${i * 140}ms` : '0ms' }}>
<h3 className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">{s.heading}</h3>
<p className="mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground">
<Cited text={s.text} t={t} onCite={toSource} active={src} />
</p>
</section>
))}
</div>
<div className="mt-7 border-t border-border pt-4">
<p className="mb-2 text-[12.5px] font-medium text-subtle-foreground">{t.sources}</p>
<ol className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{results.map((r) => (
<li key={r.id} id={`${uid}-src-${r.id}`} className="scroll-mt-24">
<a href={r.href ?? '#'} className={cn('flex min-h-11 items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] transition-colors duration-500 hover:bg-accent', src === r.id && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]')}>
<span className={cn(cite, src === r.id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground')}>{r.id}</span>
<span className="min-w-0 flex-1 text-pretty leading-snug text-foreground line-clamp-2">{r.title}</span>
</a>
</li>
))}
</ol>
</div>
</article>
</Reveal>
</div>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
function StructuredAnswer({ t, title, query, summary, table, caveats = [], results = [], confidence = 'medium', onSearch, className }: P) {
const [active, setActive] = useState<number | null>(null);
const [phone, setPhone] = useState(table?.pick ?? 0);
const [run, setRun] = useState(0);
const cols = table?.columns ?? [];
const src = results.find((r) => r.id === active);
return (
<Stage title={title} className={className} wide>
<Reveal>
<SearchBar value={query} t={t} onSubmit={(q) => (onSearch?.(q), setRun((r) => r + 1))} />
</Reveal>
<Reveal delay={100}>
<div key={run} className="asr-up mt-5 rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 sm:px-5">
<p className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
<Icon name="sparkle" size={15} className="text-primary" />
{t.overview}
</p>
<Confidence level={confidence} t={t} />
</div>
{summary && (
<p className="px-4 pt-4 text-pretty text-[15px] leading-relaxed text-muted-foreground sm:px-5">
<Cited text={summary} t={t} onCite={setActive} active={active} />
</p>
)}
{/* Phones: one option at a time */}
<div className="p-4 md:hidden">
<div role="tablist" aria-label={t.overview} className="flex gap-1 rounded-xl bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
{cols.map((c, i) => (
<button
key={c}
type="button"
role="tab"
aria-selected={phone === i}
tabIndex={phone === i ? 0 : -1}
onClick={() => setPhone(i)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + cols.length) % cols.length;
setPhone(n);
(e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
}} className={cn('min-h-11 flex-1 truncate rounded-lg px-1.5 text-[12.5px] font-medium', phone === i ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground')}>
{c}
</button>
))}
</div>
<dl key={phone} role="tabpanel" className="asr-up mt-3 divide-y divide-border rounded-xl border border-border">
{table?.pick === phone && (
<div className="flex items-center gap-1.5 bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] px-3.5 py-2 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
<Icon name="star" size={13} />
{t.recommended}
</div>
)}
{table?.rows.map((r) => (
<div key={r.label} className="flex items-start justify-between gap-3 px-3.5 py-2.5 text-[13.5px]">
<dt className="shrink-0 text-muted-foreground">{r.label}</dt>
<dd className="text-right text-foreground">
<Cited text={r.cells[phone] ?? ''} t={t} onCite={setActive} active={active} />
</dd>
</div>
))}
</dl>
</div>
<div className="hidden p-5 md:block">
<table className="w-full table-fixed border-separate border-spacing-0 text-left text-[14px]">
<thead>
<tr>
<th className="w-[24%] pb-2" />
{cols.map((c, i) => (
<th key={c} scope="col" className={cn('rounded-t-xl px-4 pb-2 pt-3 align-bottom font-semibold text-foreground', table?.pick === i && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]')}>
{table?.pick === i && (
<span className="mb-1 flex items-center gap-1 text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
<Icon name="star" size={12} />
{t.recommended}
</span>
)}
{c}
</th>
))}
</tr>
</thead>
<tbody>
{table?.rows.map((r, ri) => (
<tr key={r.label} className="asr-up" style={{ '--asr-d': `${200 + ri * 70}ms` } as React.CSSProperties}>
<th scope="row" className="border-t border-border py-3 pr-3 align-top text-[13px] font-normal text-muted-foreground">
{r.label}
</th>
{r.cells.map((cell, ci) => (
<td key={ci} className={cn('border-t border-border px-4 py-3 align-top text-foreground', table?.pick === ci && 'bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))]', table?.pick === ci && ri === (table?.rows.length ?? 0) - 1 && 'rounded-b-xl')}>
<Cited text={cell} t={t} onCite={setActive} active={active} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{src && (
<div key={src.id} className="asr-up mx-4 mb-4 flex items-start gap-3 rounded-xl border border-border bg-background/60 p-3.5 sm:mx-5" aria-live="polite">
<Favicon site={src.site} id={src.id} />
<div className="min-w-0">
<a href={src.href ?? '#'} className="text-[13.5px] font-medium text-foreground underline-offset-4 hover:underline">
{src.title}
</a>
<p className="mt-0.5 text-[12.5px] leading-relaxed text-muted-foreground">
{src.site} · {src.snippet}
</p>
</div>
</div>
)}
{caveats.length > 0 && (
<div className="border-t border-border px-4 py-4 sm:px-5">
<p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="info" size={14} className="text-warning-foreground" />
{t.caveats}
</p>
<ul className="space-y-1.5">
{caveats.map((c) => (
<li key={c} className="relative pl-4 text-pretty text-[13.5px] leading-relaxed text-muted-foreground before:absolute before:left-1 before:top-[0.6em] before:size-1.5 before:rounded-full before:bg-warning">
{c}
</li>
))}
</ul>
</div>
)}
</div>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* follow-up-thread */
/* ------------------------------------------------------------------ */
function FollowUpThread({ t, title, turns: initial = [], results = [], related = [], className }: P) {
const [turns, setTurns] = useState(initial);
const [collapsed, setCollapsed] = useState<string[]>(initial.slice(0, -1).map((x) => x.id));
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const seq = useRef(0);
const ask = (question: string) => {
if (!question.trim() || busy) return;
setBusy(true);
setQ('');
const id = `f${++seq.current}`;
setCollapsed(turns.map((x) => x.id));
window.setTimeout(
() => {
setTurns((s) => [...s, { id, question, answer: t.fallback, sources: results.slice(0, 2).map((r) => r.id) }]);
setBusy(false);
},
prefersReducedMotion() ? 0 : 1200,
);
};
return (
<Stage title={title} className={className}>
<ol className="space-y-3">
{turns.map((x, i) => {
const shut = collapsed.includes(x.id);
return (
<li key={x.id} className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', i === turns.length - 1 && 'asr-up')}>
<button type="button" aria-expanded={!shut} aria-label={shut ? t.expand : t.collapse} onClick={() => setCollapsed((c) => (shut ? c.filter((y) => y !== x.id) : [...c, x.id]))} className="flex w-full items-start gap-3 px-4 py-3.5 text-left sm:px-5">
<h3 className="min-w-0 flex-1 text-pretty text-[16px] font-semibold leading-snug text-foreground">{x.question}</h3>
{shut && !!x.sources?.length && <span className="mt-0.5 shrink-0 text-[12px] tabular-nums text-muted-foreground">{fill(t.sourceCount, { n: x.sources.length })}</span>}
<Icon name="chevron-down" size={17} className={cn('mt-0.5 shrink-0 text-muted-foreground transition-transform duration-300', !shut && 'rotate-180')} />
</button>
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', shut ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]')}>
<div className="overflow-hidden" inert={shut || undefined}>
<div className="px-4 pb-4 sm:px-5">
<p className="text-pretty text-[15px] leading-relaxed text-muted-foreground">
<Cited text={x.answer} t={t} />
</p>
{!!x.sources?.length && (
<div className="mt-3 flex flex-wrap gap-1.5">
{x.sources.map((n) => {
const r = results.find((y) => y.id === n);
return r ? (
<a key={n} href={r.href ?? '#'} className="inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border border-border bg-background pl-0.5 pr-2.5 text-[12px] text-muted-foreground hover:text-foreground max-sm:h-11">
<Favicon site={r.site} id={r.id} />
<span className="truncate">{r.site}</span>
</a>
) : null;
})}
</div>
)}
</div>
</div>
</div>
</li>
);
})}
{busy && (
<li className="asr-up flex items-center gap-2 rounded-2xl border border-dashed border-border-strong px-5 py-4 text-[13.5px] text-muted-foreground" role="status">
<Icon name="loader" size={15} className="animate-spin" />
{t.thinking}
</li>
)}
</ol>
{related.length > 0 && (
<Reveal delay={100}>
<p className="mb-2 mt-6 px-1 text-[12.5px] font-medium text-subtle-foreground">{t.related}</p>
<ul className="divide-y divide-border rounded-2xl border border-border bg-card">
{related.map((r) => (
<li key={r}>
<button type="button" disabled={busy} onClick={() => ask(r)} className="flex min-h-12 w-full items-center gap-3 px-4 py-2.5 text-left text-[14px] text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50 sm:px-5">
<Icon name="corner-down-left" size={15} className="shrink-0 rotate-180 text-muted-foreground" />
<span className="flex-1">{r}</span>
<Icon name="plus" size={15} className="shrink-0 text-muted-foreground" />
</button>
</li>
))}
</ul>
</Reveal>
)}
<form
onSubmit={(e) => {
e.preventDefault();
ask(q);
}}
className="sticky bottom-4 mt-5 flex items-center gap-2 rounded-2xl border border-border bg-card/95 p-1.5 pl-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-lg),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
>
<label className="min-w-0 flex-1">
<span className="sr-only">{t.follow}</span>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.follow} className="h-11 w-full bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
</label>
<button type="submit" disabled={!q.trim() || busy} className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-xl bg-primary px-4 text-[13.5px] font-medium text-primary-foreground disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground">
{t.ask}
</button>
</form>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* AI search: results with an AI overview on top and citations that jump to
* their result, a research run that turns into a report, an answer as a
* comparison table with a recommendation and caveats, and a thread of
* follow-up questions with related ones to tap.
*/
export function AiSearch({ variant = 'overview', labels, ...props }: AiSearchProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const body = (() => {
switch (variant) {
case 'research-report':
return <ResearchReport {...p} />;
case 'structured-answer':
return <StructuredAnswer {...p} />;
case 'follow-up-thread':
return <FollowUpThread key={JSON.stringify(props.turns)} {...p} />;
default:
return <Overview {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Häufige Fragen
Wie installiere ich KI-Suche?
Mit aiw add @ai-website/ai-search. Der Befehl legt die Datei components/ui/ai-search.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 KI-Suche?
4 Varianten, Ergebnisse mit KI-Übersicht, Recherchebericht, Antwort als Tabelle und Verlauf mit Nachfragen. Du wählst sie mit der Prop variant, zum Beispiel variant="research-report".
Kann ich die Texte von KI-Suche übersetzen?
Ja. KI-Suche bringt 29 eigene Texte auf Deutsch und Englisch mit, etwa „Suchen“, „KI-Übersicht“ und „Mehr zeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent KI-Suche einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Suche mit aiw_components und installieren sie mit aiw_add @ai-website/ai-search. Nenn im Prompt die Variante, etwa variant="overview", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich KI-Suche ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden AiSearch-Code zum Kopieren.