Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Ergebnisse mit KI-Übersicht

Eine kurze KI-Zusammenfassung über klassischen Ergebnissen, auf wenige Zeilen gefaltet, mit klarem Hinweis, wie einig sich die Quellen sind. Ein Beleg scrollt zu seinem Ergebnis und markiert es.

Im Studio öffnen

Recherchebericht

Beim Hereinscrollen läuft die Recherche ab. Suchen werden geplant, Quellen gezählt und aufgelistet, dann erscheint der Bericht mit Inhalt, Abschnitten und Quellen.

Im Studio öffnen

Antwort als Tabelle

Eine Vergleichsfrage als Tabelle beantwortet, mit empfohlener Spalte, Belegen in den Zellen, die ihre Quelle darunter öffnen, und Hinweisen. Telefone wechseln zwischen den Optionen.

Im Studio öffnen

Verlauf mit Nachfragen

Fragen und Antworten als Verlauf, frühere Antworten falten sich auf ihre Frage, verwandte Fragen fügen eine neue Runde an, und das Fragefeld bleibt griffbereit.

Im Studio öffnen

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

Props

Die Schnittstelle AiSearchProps aus components/ui/ai-search.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AiSearchVariant

    overviewresearch-reportstructured-answerfollow-up-thread

  • titleoptional
    string
  • queryoptional
    string
  • summaryoptional
    string

    overview and follow-up-thread: the AI summary, [n] cites a result

  • resultsoptional
    SearchResult[]
  • confidenceoptional
    'high' | 'medium' | 'low'

    overview, research-report and structured-answer: how sure the answer is

  • queriesoptional
    ResearchQuery[]

    research-report

  • sectionsoptional
    ReportSection[]
  • readoptional
    number

    research-report: sources read in total

  • tableoptional
    AnswerTable

    structured-answer

  • caveatsoptional
    string[]

    structured-answer: things to keep in mind, shown under the table

  • turnsoptional
    ThreadTurn[]

    follow-up-thread

  • relatedoptional
    string[]
  • onSearchoptional
    (query: string) => void

    overview 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
searchEnglisch SearchDeutsch Suchen
overviewEnglisch AI overviewDeutsch KI-Übersicht
moreEnglisch Show moreDeutsch Mehr zeigen
lessEnglisch Show lessDeutsch Weniger zeigen
resultsEnglisch ResultsDeutsch Ergebnisse
sourceEnglisch Source {n}Deutsch Quelle {n}
confidence_highEnglisch Sources agreeDeutsch Quellen sind sich einig
confidence_mediumEnglisch Sources partly disagreeDeutsch Quellen sind sich teils uneinig
confidence_lowEnglisch Few sources, check carefullyDeutsch Wenige Quellen, genau prüfen
aiNoteEnglisch Generated from the results below. It can be wrong.Deutsch Aus den Ergebnissen unten erstellt. Kann falsch sein.
helpfulEnglisch HelpfulDeutsch Hilfreich
notHelpfulEnglisch Not helpfulDeutsch Nicht hilfreich
planningEnglisch Planning searchesDeutsch Plant die Suchen
readingEnglisch Reading sourcesDeutsch Liest Quellen
writingEnglisch Writing the reportDeutsch Schreibt den Bericht
readLabelEnglisch sources readDeutsch Quellen gelesen
contentsEnglisch ContentsDeutsch Inhalt
reportEnglisch ReportDeutsch Bericht
recommendedEnglisch Fits bestDeutsch Passt am besten
caveatsEnglisch Keep in mindDeutsch Gut zu wissen
sourcesEnglisch SourcesDeutsch Quellen
sourceCountEnglisch {n} sourcesDeutsch {n} Quellen
followEnglisch Ask a follow-upDeutsch Nachfrage stellen
askEnglisch AskDeutsch Fragen
relatedEnglisch People also askDeutsch Andere fragen auch
thinkingEnglisch SearchingDeutsch Sucht
collapseEnglisch Collapse answerDeutsch Antwort einklappen
expandEnglisch Expand answerDeutsch Antwort aufklappen
fallbackEnglisch 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
components/ui/ai-search.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert