KI-Zusammenfassung
Von KI geschriebene Zusammenfassungen, die zeigen, woher sie kommen. Eine kompakte Karte mit Kernpunkten und Quellen, ein Meeting mit Entscheidungen und Aufgaben, ein Dokument mit der Fundstelle hinter jedem Punkt und mehrere Dokumente im Vergleich. Sicherheit und offene Fragen bleiben sichtbar, Kopieren und Feedback gibt es auf jeder Karte.
Zusammenfassungen, die sich prüfen lassen
Eine KI-Zusammenfassung spart nur dann Lesezeit, wenn Menschen ihr vertrauen können. Diese Karten zeigen, woher jeder Punkt stammt, halten Sicherheit und offene Fragen sichtbar und geben jeder Karte Kopieren und Feedback, aus einer Zusammenfassung wird so etwas zum Prüfen statt zum Glauben.
„Kompakt“ ist eine Karte für einen Verlauf mit dem Wichtigsten, Kernpunkten mit nummerierten Quellen, die die zitierte Stelle öffnen, und mehr hinter einer Falte, die auch ohne JavaScript geht. „Meeting“ zeigt Datum, Länge und Teilnehmende, Entscheidungen mit der Stelle zum Anhören und Aufgaben mit Zuständigen und Termin zum Abhaken, daneben Sicherheit und was unklar blieb. „Dokument mit Fundstellen“ nummeriert die Kernpunkte mit Sicherheit und Seite, ein gewählter Punkt zeigt die Stelle, auf der er beruht, am Desktop neben der Liste, am Telefon direkt darunter. „Dokumente vergleichen“ geht Thema für Thema durch mehrere Dokumente und markiert jede Zeile als gleich, abweichend, fehlend oder widersprüchlich.
Setz die Quellen direkt neben die Aussage, nicht in eine Liste am Ende. Sag, was das Dokument nicht abdeckt, das verhindert die teuersten Missverständnisse. Halte das Wichtigste bei zwei oder drei Sätzen und lass die Punkte die Details tragen.
Variants
Details
Was KI-Zusammenfassung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Antworten
- Bereich
- App
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/ai-summary-card.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 44 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AiSummaryCardProps aus components/ui/ai-summary-card.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAiSummaryCardVariantcompactmeetingdocumentcompare
titleoptionalstringcontextoptionalstringWhat was summarized, e.g. "Thread · 23 messages"
tldroptionalstringThe summary in one or two sentences
pointsoptionalSummaryPoint[]detailsoptionalstring[]compact: more paragraphs behind "Show more"
sourcesoptionalSummarySource[]confidenceoptionalnumberOverall confidence in percent
unclearoptionalstring[]Things the summary could not settle
meetingoptional{ date: string; duration: string; participants: string[] }meeting
decisionsoptional{ text: string; source?: number }[]actionsoptionalMeetingAction[]missingoptionalstring[]document, compare: facts the sources do not contain
docsoptionalCompareDoc[]compare
rowsoptionalCompareRow[]generatedoptionalstringe.g. "Nova 2 · 2 min ago"
onFeedbackoptional(helpful: boolean, reason?: string) => voidonCopyoptional(text: string) => void
Eingebaute Texte
Diese Texte schreibt KI-Zusammenfassung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 44 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| summary | Englisch AI summary | Deutsch KI-Zusammenfassung |
| keyPoints | Englisch Key points | Deutsch Kernpunkte |
| more | Englisch Show more | Deutsch Mehr anzeigen |
| less | Englisch Show less | Deutsch Weniger anzeigen |
| high | Englisch High confidence | Deutsch Hohe Sicherheit |
| medium | Englisch Medium confidence | Deutsch Mittlere Sicherheit |
| low | Englisch Low confidence | Deutsch Geringe Sicherheit |
| confidence | Englisch Confidence | Deutsch Sicherheit |
| confidenceOf | Englisch Confidence {v} | Deutsch Sicherheit {v} |
| unclear | Englisch Not settled | Deutsch Nicht geklärt |
| sources | Englisch Sources | Deutsch Quellen |
| source | Englisch Source {n} | Deutsch Quelle {n} |
| openSource | Englisch Show passage from source {n} | Deutsch Stelle aus Quelle {n} zeigen |
| copy | Englisch Copy summary | Deutsch Zusammenfassung kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| copyShort | Englisch Copy | Deutsch Kopieren |
| helpful | Englisch Helpful | Deutsch Hilfreich |
| notHelpful | Englisch Not helpful | Deutsch Nicht hilfreich |
| why | Englisch What was off? | Deutsch Was hat nicht gepasst? |
| reasons | Englisch Inaccurate|Something is missing|Too long|Wrong tone | Deutsch Ungenau|Es fehlt etwas|Zu lang|Falscher Ton |
| thanks | Englisch Thanks, this helps the next summary. | Deutsch Danke, das hilft der nächsten Zusammenfassung. |
| decisions | Englisch Decisions | Deutsch Entscheidungen |
| actions | Englisch Action items | Deutsch Aufgaben |
| owner | Englisch Owner | Deutsch Zuständig |
| due | Englisch Due {v} | Deutsch bis {v} |
| openItems | Englisch {n} of {total} open | Deutsch {n} von {total} offen |
| participants | Englisch Participants | Deutsch Teilnehmende |
| jump | Englisch Hear it at {v} | Deutsch Bei {v} anhören |
| passage | Englisch Passage | Deutsch Fundstelle |
| passageOf | Englisch Passage for “{name}” | Deutsch Fundstelle zu „{name}“ |
| pick | Englisch Choose a point to see where it comes from. | Deutsch Wähle einen Punkt, um zu sehen, woher er kommt. |
| inSource | Englisch In the source | Deutsch In der Quelle |
| noSource | Englisch No source backs this point. | Deutsch Keine Quelle stützt diesen Punkt. |
| missing | Englisch Not in the documents | Deutsch Nicht in den Dokumenten |
| compare | Englisch Comparison | Deutsch Vergleich |
| topic | Englisch Topic | Deutsch Thema |
| onlyDiff | Englisch Only differences | Deutsch Nur Unterschiede |
| same | Englisch Same | Deutsch Gleich |
| differs | Englisch Differs | Deutsch Abweichend |
| conflict | Englisch Contradicts | Deutsch Widerspruch |
| missingStatus | Englisch Missing | Deutsch Fehlt |
| tally | Englisch {same} same, {differs} different, {conflict} contradicting | Deutsch {same} gleich, {differs} abweichend, {conflict} im Widerspruch |
| empty | Englisch All documents agree. | Deutsch Alle Dokumente stimmen überein. |
| honest | Englisch Written by AI from the sources listed. It can leave things out or get them wrong, check the passages before you rely on it. | Deutsch Von KI aus den genannten Quellen geschrieben. Sie kann etwas auslassen oder falsch verstehen, prüfe die Fundstellen, bevor du dich darauf verlässt. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- KI-Zusammenfassung reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter und Leertaste.
- Setzt aria-controls, aria-describedby, aria-expanded, aria-label, aria-live, aria-pressed, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen cell, columnheader, meter, region, row, rowgroup, rowheader, status und table.
- 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. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 931 Zeilen
'use client';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, 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 AiSummaryCardVariant = 'compact' | 'meeting' | 'document' | 'compare';
export type SummaryConfidence = 'high' | 'medium' | 'low';
export interface SummarySource {
id: number;
title: string;
kind?: 'doc' | 'mail' | 'chat' | 'web' | 'meeting';
/** Where in the source, e.g. "p. 4", "12:40" or "Message 7" */
locator?: string;
/** The passage the point relies on. ==Marked words== are highlighted. */
quote?: string;
/** e.g. the author or speaker */
author?: string;
}
export interface SummaryPoint {
id: string;
text: string;
confidence?: SummaryConfidence;
/** Source ids */
sources?: number[];
/** Why the point is uncertain */
note?: string;
}
export interface MeetingAction {
id: string;
text: string;
owner: string;
due?: string;
done?: boolean;
}
export interface CompareDoc {
id: string;
title: string;
/** e.g. the date or version */
meta?: string;
}
export interface CompareRow {
id: string;
topic: string;
/** Value per document id */
values: Record<string, string>;
status: 'same' | 'differs' | 'conflict' | 'missing';
note?: string;
}
export interface AiSummaryCardProps {
variant?: AiSummaryCardVariant;
title?: string;
/** What was summarized, e.g. "Thread · 23 messages" */
context?: string;
/** The summary in one or two sentences */
tldr?: string;
points?: SummaryPoint[];
/** compact: more paragraphs behind "Show more" */
details?: string[];
sources?: SummarySource[];
/** Overall confidence in percent */
confidence?: number;
/** Things the summary could not settle */
unclear?: string[];
/** meeting */
meeting?: { date: string; duration: string; participants: string[] };
decisions?: { text: string; source?: number }[];
actions?: MeetingAction[];
/** document, compare: facts the sources do not contain */
missing?: string[];
/** compare */
docs?: CompareDoc[];
rows?: CompareRow[];
/** e.g. "Nova 2 · 2 min ago" */
generated?: string;
onFeedback?: (helpful: boolean, reason?: string) => void;
onCopy?: (text: string) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
summary: 'AI summary',
keyPoints: 'Key points',
more: 'Show more',
less: 'Show less',
high: 'High confidence',
medium: 'Medium confidence',
low: 'Low confidence',
confidence: 'Confidence',
confidenceOf: 'Confidence {v}',
unclear: 'Not settled',
sources: 'Sources',
source: 'Source {n}',
openSource: 'Show passage from source {n}',
copy: 'Copy summary',
copied: 'Copied',
copyShort: 'Copy',
helpful: 'Helpful',
notHelpful: 'Not helpful',
why: 'What was off?',
reasons: 'Inaccurate|Something is missing|Too long|Wrong tone',
thanks: 'Thanks, this helps the next summary.',
decisions: 'Decisions',
actions: 'Action items',
owner: 'Owner',
due: 'Due {v}',
openItems: '{n} of {total} open',
participants: 'Participants',
jump: 'Hear it at {v}',
passage: 'Passage',
passageOf: 'Passage for “{name}”',
pick: 'Choose a point to see where it comes from.',
inSource: 'In the source',
noSource: 'No source backs this point.',
missing: 'Not in the documents',
compare: 'Comparison',
topic: 'Topic',
onlyDiff: 'Only differences',
same: 'Same',
differs: 'Differs',
conflict: 'Contradicts',
missingStatus: 'Missing',
tally: '{same} same, {differs} different, {conflict} contradicting',
empty: 'All documents agree.',
honest: 'Written by AI from the sources listed. It can leave things out or get them wrong, check the passages before you rely on it.',
},
de: {
summary: 'KI-Zusammenfassung',
keyPoints: 'Kernpunkte',
more: 'Mehr anzeigen',
less: 'Weniger anzeigen',
high: 'Hohe Sicherheit',
medium: 'Mittlere Sicherheit',
low: 'Geringe Sicherheit',
confidence: 'Sicherheit',
confidenceOf: 'Sicherheit {v}',
unclear: 'Nicht geklärt',
sources: 'Quellen',
source: 'Quelle {n}',
openSource: 'Stelle aus Quelle {n} zeigen',
copy: 'Zusammenfassung kopieren',
copied: 'Kopiert',
copyShort: 'Kopieren',
helpful: 'Hilfreich',
notHelpful: 'Nicht hilfreich',
why: 'Was hat nicht gepasst?',
reasons: 'Ungenau|Es fehlt etwas|Zu lang|Falscher Ton',
thanks: 'Danke, das hilft der nächsten Zusammenfassung.',
decisions: 'Entscheidungen',
actions: 'Aufgaben',
owner: 'Zuständig',
due: 'bis {v}',
openItems: '{n} von {total} offen',
participants: 'Teilnehmende',
jump: 'Bei {v} anhören',
passage: 'Fundstelle',
passageOf: 'Fundstelle zu „{name}“',
pick: 'Wähle einen Punkt, um zu sehen, woher er kommt.',
inSource: 'In der Quelle',
noSource: 'Keine Quelle stützt diesen Punkt.',
missing: 'Nicht in den Dokumenten',
compare: 'Vergleich',
topic: 'Thema',
onlyDiff: 'Nur Unterschiede',
same: 'Gleich',
differs: 'Abweichend',
conflict: 'Widerspruch',
missingStatus: 'Fehlt',
tally: '{same} gleich, {differs} abweichend, {conflict} im Widerspruch',
empty: 'Alle Dokumente stimmen überein.',
honest: 'Von KI aus den genannten Quellen geschrieben. Sie kann etwas auslassen oder falsch verstehen, prüfe die Fundstellen, bevor du dich darauf verlässt.',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiSummaryCardProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value);
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes asc-up{from{opacity:0;transform:translateY(6px)}}@keyframes asc-flash{from{background-color:color-mix(in oklab,var(--primary) 22%,transparent)}}' +
'@media (prefers-reduced-motion:no-preference){.asc-up{animation:asc-up .4s var(--ease-kit) var(--asc-d,0ms) both}.asc-flash{animation:asc-flash 1.2s ease-out both}}' +
'.asc-details>summary{list-style:none}.asc-details>summary::-webkit-details-marker{display:none}';
function Style() {
return (
<style href="kit-ai-summary-card" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const iconBtn = 'inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-9 sm:min-w-9';
const KIND_ICON: Record<NonNullable<SummarySource['kind']>, IconName> = { doc: 'file', mail: 'mail', chat: 'chat', web: 'globe', meeting: 'video' };
const level = (n: number): SummaryConfidence => (n >= 80 ? 'high' : n >= 55 ? 'medium' : 'low');
const LEVEL_TONE: Record<SummaryConfidence, { text: string; bar: string; bg: string }> = {
high: { text: 'text-success-foreground', bar: 'bg-success', bg: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)]' },
medium: { text: 'text-warning-foreground', bar: 'bg-warning', bg: 'bg-[color-mix(in_oklab,var(--warning)_15%,transparent)]' },
low: { text: 'text-destructive', bar: 'bg-destructive', bg: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)]' },
};
/** Five bars and a word, with the exact value for screen readers */
function ConfidenceMeter({ value, t, className }: { value: number; t: Texts; className?: string }) {
const locale = useLocale();
const lv = level(value);
const on = Math.max(1, Math.round(value / 20));
return (
<span role="meter" aria-label={t.confidence} aria-valuemin={0} aria-valuemax={100} aria-valuenow={value} aria-valuetext={`${t[lv]}, ${formatPercent(value, locale)}`} className={cn('inline-flex items-center gap-2', className)}>
<span aria-hidden className="flex items-end gap-[3px]">
{[0, 1, 2, 3, 4].map((i) => (
<span key={i} className={cn('w-[5px] rounded-[2px]', i < on ? LEVEL_TONE[lv].bar : 'bg-track')} style={{ height: 6 + i * 2.5 }} />
))}
</span>
<span aria-hidden className={cn('text-[12px] font-medium', LEVEL_TONE[lv].text)}>
{t[lv]} <span className="tabular-nums text-muted-foreground">· {formatPercent(value, locale)}</span>
</span>
</span>
);
}
function LevelTag({ level: lv, t }: { level: SummaryConfidence; t: Texts }) {
return <span className={cn('inline-flex h-5 shrink-0 items-center whitespace-nowrap rounded-[5px] px-1.5 text-[11px] font-medium', LEVEL_TONE[lv].bg, LEVEL_TONE[lv].text)}>{t[lv]}</span>;
}
/** Text with ==marked== words */
function Marked({ text }: { text: string }) {
return (
<>
{text.split(/(==[^=]+==)/g).map((part, i) =>
part.startsWith('==') ? (
<mark key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--warning)_30%,transparent)] px-0.5 text-foreground">
{part.slice(2, -2)}
</mark>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
/** A small numbered marker that opens the source passage */
function SourceChip({ source, t, active, onClick }: { source: SummarySource; t: Texts; active?: boolean; onClick?: () => void }) {
const cls = cn('relative inline-flex h-[1.4em] min-w-[1.4em] items-center justify-center rounded-[5px] px-1 align-[0.1em] text-[11px] font-semibold tabular-nums leading-none transition-colors after:absolute after:-inset-1', active ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground hover:bg-[color-mix(in_oklab,var(--primary)_18%,transparent)] hover:text-foreground');
if (!onClick) return <span className={cls}>{source.id}</span>;
return (
<button type="button" aria-label={fill(t.openSource, { n: source.id })} aria-pressed={active} onClick={onClick} className={cls}>
{source.id}
</button>
);
}
function Header({ t, title, context, confidence, generated, className }: { t: Texts; title?: string; context?: string; confidence?: number; generated?: string; className?: string }) {
return (
<div className={cn('flex flex-wrap items-start justify-between gap-x-4 gap-y-2', className)}>
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">
<Icon name="sparkle" size={14} />
{t.summary}
{context && <span className="font-normal text-muted-foreground">· {context}</span>}
</p>
{title && <h3 className="mt-1.5 text-balance text-[18px] font-semibold leading-snug tracking-[-0.015em] text-foreground sm:text-[20px]">{title}</h3>}
{generated && <p className="mt-1 text-[12px] text-subtle-foreground">{generated}</p>}
</div>
{confidence !== undefined && <ConfidenceMeter value={confidence} t={t} className="pt-0.5" />}
</div>
);
}
/** Copy, helpful and not helpful with a reason */
function Feedback({ t, text, onFeedback, onCopy, className }: { t: Texts; text: string; onFeedback?: AiSummaryCardProps['onFeedback']; onCopy?: AiSummaryCardProps['onCopy']; className?: string }) {
const [vote, setVote] = useState<boolean | null>(null);
const [reason, setReason] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const id = window.setTimeout(() => setCopied(false), 1800);
return () => window.clearTimeout(id);
}, [copied]);
const reasons = t.reasons.split('|');
return (
<div className={className}>
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(text).catch(() => undefined);
onCopy?.(text);
setCopied(true);
}}
className={iconBtn}
aria-live="polite"
>
<Icon name={copied ? 'check' : 'copy'} size={15} className={copied ? 'text-success-foreground' : undefined} />
<span className={copied ? 'text-success-foreground' : undefined}>
{copied ? t.copied : (
<>
<span className="sm:hidden">{t.copyShort}</span>
<span className="max-sm:hidden">{t.copy}</span>
</>
)}
</span>
</button>
<span className="flex-1" />
{[true, false].map((v) => (
<button key={String(v)} type="button" aria-pressed={vote === v} aria-label={v ? t.helpful : t.notHelpful} title={v ? t.helpful : t.notHelpful} onClick={() => (setVote(v), setReason(null), v && onFeedback?.(true))} className={cn(iconBtn, 'px-0', vote === v && 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
<Icon name={v ? 'thumbs-up' : 'thumbs-down'} size={15} />
</button>
))}
</div>
{vote === false && !reason && (
<div className="asc-up mt-2 rounded-xl border border-border bg-surface p-3">
<p className="text-[12.5px] font-medium text-foreground">{t.why}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{reasons.map((r) => (
<button key={r} type="button" onClick={() => (setReason(r), onFeedback?.(false, r))} className="inline-flex min-h-11 items-center rounded-lg border border-border bg-card px-3 text-[12.5px] font-medium text-foreground transition-colors hover:bg-accent sm:min-h-8">
{r}
</button>
))}
</div>
</div>
)}
<p className="text-[12.5px] text-muted-foreground empty:hidden" role="status">
{(vote === true || reason) && <span className="asc-up mt-2 inline-flex items-center gap-1.5"><Icon name="check-circle" size={14} className="text-success-foreground" />{t.thanks}</span>}
</p>
</div>
);
}
function Honest({ t }: { t: Texts }) {
return (
<p className="flex items-start gap-2 text-[12px] leading-snug text-muted-foreground">
<Icon name="info" size={14} className="mt-px shrink-0" />
<span>{t.honest}</span>
</p>
);
}
function Stage({ children, className, wide }: { 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)}>
<Style />
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[42rem]')}>{children}</div>
</div>
</section>
);
}
function SectionLabel({ icon, children, aside }: { icon: IconName; children: React.ReactNode; aside?: React.ReactNode }) {
return (
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
<Icon name={icon} size={14} />
{children}
{aside && <span className="ml-auto font-normal">{aside}</span>}
</p>
);
}
function summaryText(p: AiSummaryCardProps) {
return [p.title, p.tldr, ...(p.points ?? []).map((x) => `• ${x.text}`), ...(p.decisions ?? []).map((x) => `• ${x.text}`), ...(p.actions ?? []).map((a) => `☐ ${a.text} (${a.owner}${a.due ? `, ${a.due}` : ''})`)].filter(Boolean).join('\n');
}
/* ------------------------------------------------------------------ */
/* compact */
/* ------------------------------------------------------------------ */
function Compact(p: P) {
const { t, title, context, tldr, points = [], details = [], sources = [], confidence, unclear = [], generated, onFeedback, onCopy, className } = p;
const [open, setOpen] = useState<number | null>(null);
const src = sources.find((s) => s.id === open);
return (
<Stage className={className}>
<Reveal effect="scale" className={cn(card, 'relative overflow-hidden p-5 sm:p-6')}>
<span aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--primary)_70%,transparent),transparent)]" />
<Header t={t} title={title} context={context} confidence={confidence} generated={generated} />
{tldr && <p className="mt-4 text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
{points.length > 0 && (
<Stagger as="ul" step={60} className="mt-4 space-y-2.5">
{points.map((x) => (
<li key={x.id} className="flex gap-2.5 text-[14px] leading-relaxed text-foreground">
<span aria-hidden className="mt-[9px] size-1.5 shrink-0 rounded-full bg-primary" />
<span className="min-w-0">
{x.text}
{x.sources?.map((id) => {
const s = sources.find((y) => y.id === id);
return s ? (
<Fragment key={id}>
{' '}
<SourceChip source={s} t={t} active={open === id} onClick={() => setOpen(open === id ? null : id)} />
</Fragment>
) : null;
})}
{x.confidence && x.confidence !== 'high' && (
<span className="ml-1.5 inline-block align-[0.05em]">
<LevelTag level={x.confidence} t={t} />
</span>
)}
</span>
</li>
))}
</Stagger>
)}
{src && (
<figure key={src.id} className="asc-up mt-4 rounded-xl border border-border bg-surface p-3.5" aria-live="polite">
<figcaption className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
<Icon name={KIND_ICON[src.kind ?? 'doc']} size={13} />
<span className="font-medium text-foreground">{src.title}</span>
{src.locator && <span>· {src.locator}</span>}
</figcaption>
{src.quote && (
<blockquote className="mt-1.5 text-[13.5px] leading-relaxed text-foreground">
<Marked text={src.quote} />
</blockquote>
)}
</figure>
)}
{(details.length > 0 || unclear.length > 0) && (
<details className="asc-details group mt-4">
<summary className="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9 -ml-2.5">
<Icon name="chevron-down" size={15} className="transition-transform duration-300 group-open:rotate-180 motion-reduce:transition-none" />
<span className="group-open:hidden">{t.more}</span>
<span className="hidden group-open:inline">{t.less}</span>
</summary>
<div className="asc-up mt-2 space-y-3">
{details.map((x, i) => (
<p key={i} className="text-pretty text-[14px] leading-relaxed text-muted-foreground">
{x}
</p>
))}
{unclear.length > 0 && (
<div className="rounded-xl bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] p-3.5">
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-warning-foreground">
<Icon name="help-circle" size={14} />
{t.unclear}
</p>
<ul className="mt-1.5 space-y-1">
{unclear.map((u) => (
<li key={u} className="text-[13.5px] leading-relaxed text-foreground">
{u}
</li>
))}
</ul>
</div>
)}
</div>
</details>
)}
{sources.length > 0 && (
<div className="mt-4 border-t border-border pt-4">
<SectionLabel icon="book-open">{t.sources}</SectionLabel>
<ul className="mt-2 flex flex-wrap gap-1.5">
{sources.map((s) => (
<li key={s.id}>
<button type="button" aria-pressed={open === s.id} onClick={() => setOpen(open === s.id ? null : s.id)} className={cn('inline-flex min-h-11 max-w-full items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] transition-colors sm:min-h-8', open === s.id ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}>
<span className="tabular-nums text-subtle-foreground">{s.id}</span>
<Icon name={KIND_ICON[s.kind ?? 'doc']} size={13} />
<span className="truncate">{s.title}</span>
</button>
</li>
))}
</ul>
</div>
)}
<Feedback t={t} text={summaryText(p)} onFeedback={onFeedback} onCopy={onCopy} className="mt-4 border-t border-border pt-3" />
</Reveal>
<Reveal delay={120} className="mt-4 px-1">
<Honest t={t} />
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* meeting */
/* ------------------------------------------------------------------ */
const initials = (n: string) =>
n
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
function Meeting(p: P) {
const { t, title, context, tldr, decisions = [], actions: initial = [], sources = [], unclear = [], confidence, meeting, generated, onFeedback, onCopy, className } = p;
const [actions, setActions] = useSynced(initial);
const [open, setOpen] = useState<number | null>(null);
const uid = useId();
const openCount = actions.filter((a) => !a.done).length;
const src = sources.find((s) => s.id === open);
const passage = src && (
<figure key={src.id} className="asc-up mt-2 rounded-xl border border-border bg-surface p-3" aria-live="polite">
<figcaption className="flex flex-wrap items-center gap-x-1.5 text-[12px] text-muted-foreground">
<Icon name="play-circle" size={13} />
<span className="font-medium text-foreground">{src.author ?? src.title}</span>
{src.locator && <span className="tabular-nums">· {src.locator}</span>}
</figcaption>
{src.quote && (
<blockquote className="mt-1 text-[13.5px] leading-relaxed text-foreground">
„<Marked text={src.quote} />“
</blockquote>
)}
</figure>
);
return (
<Stage className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-5">
<Reveal className={cn(card, 'p-5 sm:p-6')}>
<Header t={t} title={title} context={context} generated={generated} />
{meeting && (
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-[12.5px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Icon name="calendar" size={14} />
{meeting.date}
</span>
<span className="inline-flex items-center gap-1.5">
<Icon name="clock" size={14} />
{meeting.duration}
</span>
<span className="inline-flex items-center gap-2">
<span className="sr-only">{t.participants}</span>
<span aria-hidden className="flex -space-x-1.5">
{meeting.participants.slice(0, 5).map((n, i) => (
<span key={n} title={n} className="grid size-6 place-items-center rounded-full border-2 border-card text-[9.5px] font-semibold text-foreground" style={{ background: `color-mix(in oklab, var(--primary) ${12 + (i % 4) * 9}%, var(--surface-2))` }}>
{initials(n)}
</span>
))}
</span>
<span>{meeting.participants.join(', ')}</span>
</span>
</div>
)}
{tldr && <p className="mt-4 text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
{decisions.length > 0 && (
<div className="mt-6">
<SectionLabel icon="flag">{t.decisions}</SectionLabel>
<ul className="mt-2.5 space-y-2">
{decisions.map((x, i) => {
const s = sources.find((y) => y.id === x.source);
return (
<li key={i} className="asc-up flex gap-2.5 text-[14px] leading-relaxed text-foreground" style={{ '--asc-d': `${i * 60}ms` } as React.CSSProperties}>
<Icon name="check-circle" size={16} className="mt-[3px] shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1">
{x.text}
{s?.locator && (
<button type="button" aria-pressed={open === s.id} aria-label={fill(t.jump, { v: s.locator })} onClick={() => setOpen(open === s.id ? null : s.id)} className={cn('relative ml-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 align-[0.05em] text-[11.5px] font-medium tabular-nums transition-colors after:absolute after:-inset-3 sm:after:-inset-1', open === s.id ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground hover:text-foreground')}>
<Icon name="play" size={10} />
{s.locator}
</button>
)}
{open === s?.id && passage}
</span>
</li>
);
})}
</ul>
</div>
)}
{actions.length > 0 && (
<div className="mt-6">
<SectionLabel icon="list-checks" aside={fill(t.openItems, { n: openCount, total: actions.length })}>
{t.actions}
</SectionLabel>
<ul className="mt-2.5 divide-y divide-border rounded-xl border border-border">
{actions.map((a) => (
<li key={a.id}>
<label className="flex min-h-12 cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)]">
<input type="checkbox" checked={!!a.done} onChange={(e) => setActions((s) => s.map((x) => (x.id === a.id ? { ...x, done: e.target.checked } : x)))} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" aria-describedby={`${uid}-${a.id}`} />
<span className="min-w-0 flex-1">
<span className={cn('block text-[14px] leading-snug transition-colors', a.done ? 'text-muted-foreground line-through' : 'text-foreground')}>{a.text}</span>
<span id={`${uid}-${a.id}`} className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="grid size-5 place-items-center rounded-full bg-surface-2 text-[9px] font-semibold text-foreground">{initials(a.owner)}</span>
<span className="sr-only">{t.owner} </span>
{a.owner}
</span>
{a.due && <span className="tabular-nums">· {fill(t.due, { v: a.due })}</span>}
</span>
</span>
</label>
</li>
))}
</ul>
</div>
)}
<Feedback t={t} text={summaryText({ ...p, actions })} onFeedback={onFeedback} onCopy={onCopy} className="mt-6 border-t border-border pt-3" />
</Reveal>
<Reveal delay={120} className="space-y-4 lg:sticky lg:top-6">
{confidence !== undefined && (
<div className={cn(card, 'p-4')}>
<p className="text-[12.5px] font-medium text-muted-foreground">{t.confidence}</p>
<ConfidenceMeter value={confidence} t={t} className="mt-2" />
{unclear.length > 0 && (
<div className="mt-4 border-t border-border pt-3.5">
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-warning-foreground">
<Icon name="help-circle" size={14} />
{t.unclear}
</p>
<ul className="mt-2 space-y-2">
{unclear.map((u) => (
<li key={u} className="text-[13px] leading-snug text-foreground">
{u}
</li>
))}
</ul>
</div>
)}
</div>
)}
<Honest t={t} />
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* document */
/* ------------------------------------------------------------------ */
function DocumentView(p: P) {
const { t, title, context, tldr, points = [], sources = [], missing = [], confidence, generated, onFeedback, onCopy, className } = p;
const [sel, setSel] = useState<string | null>(points[0]?.id ?? null);
const uid = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const point = points.find((x) => x.id === sel);
const src = point?.sources?.map((id) => sources.find((s) => s.id === id)).filter((s): s is SummarySource => !!s) ?? [];
const passage = (
<div key={sel ?? 'none'} className="asc-up">
{!point ? (
<p className="text-[13px] text-muted-foreground">{t.pick}</p>
) : src.length ? (
<div className="space-y-3">
{src.map((s) => (
<figure key={s.id}>
<figcaption className="flex flex-wrap items-center gap-x-1.5 text-[12px] text-muted-foreground">
<Icon name={KIND_ICON[s.kind ?? 'doc']} size={13} />
<span className="font-medium text-foreground">{s.title}</span>
{s.locator && <span className="tabular-nums">· {s.locator}</span>}
</figcaption>
{s.quote && (
<blockquote className="asc-flash mt-1.5 rounded-lg border-l-2 border-[color-mix(in_oklab,var(--warning)_70%,var(--border))] py-1 pl-3 text-[13.5px] leading-relaxed text-foreground">
<Marked text={s.quote} />
</blockquote>
)}
</figure>
))}
{point.note && (
<p className="flex items-start gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--warning)_10%,var(--card))] p-2.5 text-[12.5px] leading-snug text-foreground">
<Icon name="alert" size={14} className="mt-px shrink-0 text-warning-foreground" />
{point.note}
</p>
)}
</div>
) : (
<p className="flex items-start gap-1.5 text-[13px] leading-snug text-destructive">
<Icon name="alert-circle" size={14} className="mt-px shrink-0" />
{point.note ?? t.noSource}
</p>
)}
</div>
);
return (
<Stage className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-5">
<Reveal className={cn(card, 'p-5 sm:p-6')}>
<Header t={t} title={title} context={context} confidence={confidence} generated={generated} />
{tldr && <p className="mt-4 text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
<div className="mt-6">
<SectionLabel icon="list">{t.keyPoints}</SectionLabel>
<ol className="mt-2.5 space-y-1.5">
{points.map((x, i) => {
const on = sel === x.id;
return (
<li key={x.id}>
<button
ref={(el) => {
refs.current[i] = el;
}}
type="button"
aria-expanded={on}
aria-controls={`${uid}-passage`}
onClick={() => setSel(on ? null : x.id)}
onKeyDown={(e) => {
const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
refs.current[(i + d + points.length) % points.length]?.focus();
}}
className={cn('flex w-full items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-colors', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-transparent hover:bg-accent')}
>
<span aria-hidden className={cn('mt-px grid size-6 shrink-0 place-items-center rounded-full text-[11.5px] font-semibold tabular-nums', on ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted-foreground')}>
{i + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[14px] leading-relaxed text-foreground">{x.text}</span>
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
{x.confidence && <LevelTag level={x.confidence} t={t} />}
{x.sources?.map((id) => {
const s = sources.find((y) => y.id === id);
return s?.locator ? (
<span key={id} className="inline-flex h-5 items-center gap-1 rounded-[5px] bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 text-[11px] tabular-nums text-muted-foreground">
<Icon name={KIND_ICON[s.kind ?? 'doc']} size={11} />
{s.locator}
</span>
) : null;
})}
</span>
</span>
<Icon name="chevron-right" size={16} className={cn('mt-1 shrink-0 text-subtle-foreground transition-transform duration-300 max-lg:rotate-90 motion-reduce:transition-none', on && 'text-foreground max-lg:-rotate-90')} />
</button>
{/* On phones and tablets the passage opens under its point */}
{on && <div className="mx-3.5 mb-2 mt-1 border-l border-border pl-3 lg:hidden">{passage}</div>}
</li>
);
})}
</ol>
</div>
{missing.length > 0 && (
<div className="mt-6 rounded-xl border border-dashed border-border-strong p-3.5">
<SectionLabel icon="search">{t.missing}</SectionLabel>
<ul className="mt-2 space-y-1">
{missing.map((m) => (
<li key={m} className="text-[13.5px] leading-relaxed text-foreground">
{m}
</li>
))}
</ul>
</div>
)}
<Feedback t={t} text={summaryText(p)} onFeedback={onFeedback} onCopy={onCopy} className="mt-6 border-t border-border pt-3" />
</Reveal>
<Reveal delay={120} className="space-y-4 max-lg:hidden lg:sticky lg:top-6">
<div id={`${uid}-passage`} role="region" aria-label={point ? fill(t.passageOf, { name: point.text.slice(0, 40) }) : t.passage} className={cn(card, 'p-4')}>
<p className="mb-3 flex items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
<Icon name="quote" size={14} />
{t.passage}
</p>
{passage}
</div>
<Honest t={t} />
</Reveal>
</div>
<div className="mt-4 px-1 lg:hidden">
<Honest t={t} />
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* compare */
/* ------------------------------------------------------------------ */
const STATUS: Record<CompareRow['status'], { key: 'same' | 'differs' | 'conflict' | 'missingStatus'; icon: IconName; cls: string }> = {
same: { key: 'same', icon: 'check', cls: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)] text-success-foreground' },
differs: { key: 'differs', icon: 'arrows-left-right', cls: 'bg-[color-mix(in_oklab,var(--warning)_15%,transparent)] text-warning-foreground' },
conflict: { key: 'conflict', icon: 'alert-circle', cls: 'bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive' },
missing: { key: 'missingStatus', icon: 'minus', cls: 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground' },
};
function StatusTag({ status, t }: { status: CompareRow['status']; t: Texts }) {
const s = STATUS[status];
return (
<span className={cn('inline-flex h-6 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 text-[11.5px] font-medium', s.cls)}>
<Icon name={s.icon} size={12} />
{t[s.key]}
</span>
);
}
function Compare(p: P) {
const { t, title, context, tldr, docs = [], rows = [], confidence, missing = [], generated, onFeedback, onCopy, className } = p;
const [only, setOnly] = useState(false);
const shown = rows.filter((r) => !only || r.status !== 'same');
const n = (s: CompareRow['status']) => rows.filter((r) => r.status === s).length;
const cols = `minmax(9rem,0.8fr) repeat(${docs.length}, minmax(0,1fr)) 7.5rem`;
return (
<Stage className={className} wide>
<Reveal className={cn(card, 'p-5 sm:p-6')}>
<Header t={t} title={title} context={context} confidence={confidence} generated={generated} />
{tldr && <p className="mt-4 max-w-[48rem] text-pretty text-[15.5px] leading-relaxed text-foreground">{tldr}</p>}
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] text-muted-foreground">{fill(t.tally, { same: n('same'), differs: n('differs') + n('missing'), conflict: n('conflict') })}</p>
<label className="inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-1 text-[13px] font-medium text-foreground sm:min-h-9">
<input type="checkbox" checked={only} onChange={(e) => setOnly(e.target.checked)} className="size-4 accent-[var(--primary)]" />
{t.onlyDiff}
</label>
</div>
{/* A grid table from tablets on */}
<div role="table" aria-label={t.compare} className="mt-3 hidden overflow-hidden rounded-xl border border-border md:block">
<div role="rowgroup">
<div role="row" className="grid border-b border-border bg-surface" style={{ gridTemplateColumns: cols }}>
<span role="columnheader" className="px-3.5 py-2.5 text-[12px] font-medium text-muted-foreground">
{t.topic}
</span>
{docs.map((d) => (
<span key={d.id} role="columnheader" className="min-w-0 border-l border-border px-3.5 py-2.5">
<span className="flex items-center gap-1.5 text-[12.5px] font-medium text-foreground">
<Icon name="file" size={13} className="shrink-0 text-muted-foreground" />
<span className="truncate">{d.title}</span>
</span>
{d.meta && <span className="block truncate text-[11.5px] text-muted-foreground">{d.meta}</span>}
</span>
))}
<span role="columnheader" className="border-l border-border px-3.5 py-2.5 text-[12px] font-medium text-muted-foreground">
{t.compare}
</span>
</div>
</div>
<div role="rowgroup">
{shown.map((r, i) => (
<div key={r.id} role="row" className={cn('asc-up grid border-b border-border last:border-b-0', r.status === 'conflict' && 'bg-[color-mix(in_oklab,var(--destructive)_4%,transparent)]')} style={{ gridTemplateColumns: cols, '--asc-d': `${i * 45}ms` } as React.CSSProperties}>
<span role="rowheader" className="px-3.5 py-3 text-[13px] font-medium text-foreground">
{r.topic}
{r.note && <span className="mt-1 block text-[12px] font-normal leading-snug text-muted-foreground">{r.note}</span>}
</span>
{docs.map((d) => (
<span key={d.id} role="cell" className={cn('border-l border-border px-3.5 py-3 text-[13px] leading-snug', r.values[d.id] ? 'text-foreground' : 'text-subtle-foreground')}>
{r.values[d.id] || '·'}
</span>
))}
<span role="cell" className="border-l border-border px-3.5 py-3">
<StatusTag status={r.status} t={t} />
</span>
</div>
))}
{!shown.length && <p className="px-4 py-8 text-center text-[13px] text-muted-foreground">{t.empty}</p>}
</div>
</div>
{/* Cards on phones */}
<ul className="mt-3 space-y-2 md:hidden">
{shown.map((r) => (
<li key={r.id} className={cn('rounded-xl border border-border p-3.5', r.status === 'conflict' && 'border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))]')}>
<div className="flex items-start justify-between gap-3">
<p className="text-[13.5px] font-medium text-foreground">{r.topic}</p>
<StatusTag status={r.status} t={t} />
</div>
<dl className="mt-2.5 space-y-1.5">
{docs.map((d) => (
<div key={d.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] gap-3 text-[13px]">
<dt className="truncate text-muted-foreground">{d.title}</dt>
<dd className={r.values[d.id] ? 'text-foreground' : 'text-subtle-foreground'}>{r.values[d.id] || '·'}</dd>
</div>
))}
</dl>
{r.note && <p className="mt-2 text-[12.5px] leading-snug text-muted-foreground">{r.note}</p>}
</li>
))}
{!shown.length && <li className="py-6 text-center text-[13px] text-muted-foreground">{t.empty}</li>}
</ul>
{missing.length > 0 && (
<div className="mt-5 rounded-xl border border-dashed border-border-strong p-3.5">
<SectionLabel icon="search">{t.missing}</SectionLabel>
<ul className="mt-2 space-y-1">
{missing.map((m) => (
<li key={m} className="text-[13.5px] leading-relaxed text-foreground">
{m}
</li>
))}
</ul>
</div>
)}
<Feedback t={t} text={[title, tldr, ...rows.map((r) => `${r.topic}: ${docs.map((d) => `${d.title} ${r.values[d.id] ?? ''}`).join(' | ')}`)].filter(Boolean).join('\n')} onFeedback={onFeedback} onCopy={onCopy} className="mt-5 border-t border-border pt-3" />
</Reveal>
<Reveal delay={120} className="mt-4 px-1">
<Honest t={t} />
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* An AI summary as a card: a compact one with key points, sources and more
* behind a fold, a meeting with decisions, action items and the moment to
* listen to, a document with the passage behind every point, and several
* documents compared topic by topic. Confidence and open questions are shown,
* never hidden, with copy and feedback on every card.
*/
export function AiSummaryCard({ variant = 'compact', labels, ...props }: AiSummaryCardProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
switch (variant) {
case 'meeting':
return <Meeting {...p} />;
case 'document':
return <DocumentView {...p} />;
case 'compare':
return <Compare {...p} />;
default:
return <Compact {...p} />;
}
}Häufige Fragen
Wie installiere ich KI-Zusammenfassung?
Mit aiw add @ai-website/ai-summary-card. Der Befehl legt die Datei components/ui/ai-summary-card.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-Zusammenfassung?
4 Varianten, Kompakt, Meeting, Dokument mit Fundstellen und Dokumente vergleichen. Du wählst sie mit der Prop variant, zum Beispiel variant="meeting".
Kann ich die Texte von KI-Zusammenfassung übersetzen?
Ja. KI-Zusammenfassung bringt 44 eigene Texte auf Deutsch und Englisch mit, etwa „KI-Zusammenfassung“, „Kernpunkte“ und „Mehr anzeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent KI-Zusammenfassung einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Zusammenfassung mit aiw_components und installieren sie mit aiw_add @ai-website/ai-summary-card. Nenn im Prompt die Variante, etwa variant="compact", dann schreibt der Assistent deine Inhalte direkt in die Props.