Feedback zu Antworten
Feedback zu KI-Antworten in fünf Formen. Daumen mit Gründen und Kommentar, eine Korrektur mit Vorschau und Quelle, Bewertungen auf mehreren Skalen, ein Meldedialog mit Wahl zum Datenschutz und eine Auswertung für das Team.
Eine von 4 Components in KI-Antworten
Variants
Details
Was Feedback zu Antworten mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Antworten
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/feedback-on-answer.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 42 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FeedbackOnAnswerProps aus components/ui/feedback-on-answer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFeedbackOnAnswerVariantthumbscorrectionrating-scalereport-dialoginsights
titleoptionalstringansweroptional{ question: string; text: string; assistant?: string }The answer the feedback is about
defaultRatingoptional'up' | 'down'thumbs: a rating that is already given, the reasons open
reasonsoptionalFeedbackReason[]thumbs: reasons for a bad answer and for a good one
praiseoptionalFeedbackReason[]wrongoptionalstringcorrection: the sentence to correct
correctionoptionalstringcorrection: the corrected sentence already typed in
defaultValuesoptionalRecord<string, number>rating-scale: values already chosen, by dimension id
dimensionsoptionalFeedbackDimension[]rating-scale
defaultOpenoptionalbooleanreport-dialog: start open
periodoptionalstringinsights
positiveoptionalnumbernegativeoptionalnumbertrendoptionalnumber[]insights: share of good ratings per week, 0 to 1
topReasonsoptional{ label: string; count: number }[]commentsoptionalFeedbackComment[]onSubmitoptional(value: Record<string, unknown>) => void
Eingebaute Texte
Diese Texte schreibt Feedback zu Antworten selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 42 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| helpful | Englisch Was this answer helpful? | Deutsch War diese Antwort hilfreich? |
| good | Englisch Good answer | Deutsch Gute Antwort |
| bad | Englisch Bad answer | Deutsch Schlechte Antwort |
| whatWrong | Englisch What was the problem? | Deutsch Was war das Problem? |
| whatGood | Englisch What was good? | Deutsch Was war gut? |
| comment | Englisch Add details (optional) | Deutsch Details ergänzen (optional) |
| commentPlaceholder | Englisch What should the answer have said? | Deutsch Was hätte die Antwort sagen sollen? |
| send | Englisch Send feedback | Deutsch Feedback senden |
| thanks | Englisch Thanks. Your feedback helps improve the answers. | Deutsch Danke. Dein Feedback hilft, die Antworten zu verbessern. |
| change | Englisch Change | Deutsch Ändern |
| privacy | Englisch Your rating and this conversation are reviewed by the product team. They are never shared outside it. | Deutsch Deine Bewertung und dieses Gespräch sieht nur das Produktteam. Nichts davon geht nach außen. |
| suggest | Englisch Suggest a correction | Deutsch Korrektur vorschlagen |
| yourVersion | Englisch Your correction | Deutsch Deine Korrektur |
| source | Englisch Source that shows it (optional) | Deutsch Quelle, die es belegt (optional) |
| sourcePlaceholder | Englisch https:// | Deutsch https:// |
| preview | Englisch Preview | Deutsch Vorschau |
| submit | Englisch Send correction | Deutsch Korrektur senden |
| corrected | Englisch Correction sent. The team checks it before anything changes. | Deutsch Korrektur gesendet. Das Team prüft sie, bevor sich etwas ändert. |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| rate | Englisch Rate this answer | Deutsch Diese Antwort bewerten |
| overall | Englisch Overall | Deutsch Gesamt |
| sendRating | Englisch Send rating | Deutsch Bewertung senden |
| rated | Englisch Rating saved. | Deutsch Bewertung gespeichert. |
| of | Englisch {n} of 5 | Deutsch {n} von 5 |
| report | Englisch Report a problem | Deutsch Problem melden |
| reportTitle | Englisch Report this answer | Deutsch Diese Antwort melden |
| category | Englisch What kind of problem? | Deutsch Welche Art von Problem? |
| details | Englisch Details | Deutsch Details |
| detailsPlaceholder | Englisch Describe what happened | Deutsch Beschreib, was passiert ist |
| include | Englisch Include this conversation | Deutsch Dieses Gespräch mitsenden |
| includeNote | Englisch The team can read the messages to understand the problem. | Deutsch Das Team kann die Nachrichten lesen, um das Problem zu verstehen. |
| reported | Englisch Report received. We answer within two working days. | Deutsch Meldung erhalten. Wir antworten innerhalb von zwei Werktagen. |
| close | Englisch Close | Deutsch Schließen |
| required | Englisch Choose a kind of problem first. | Deutsch Wähl zuerst eine Art von Problem. |
| positive | Englisch Helpful | Deutsch Hilfreich |
| negative | Englisch Not helpful | Deutsch Nicht hilfreich |
| ratio | Englisch Helpful share | Deutsch Anteil hilfreich |
| topReasons | Englisch Most named problems | Deutsch Häufigste Probleme |
| recent | Englisch Latest comments | Deutsch Neueste Kommentare |
| all | Englisch All | Deutsch Alle |
| weeks | Englisch Per week | Deutsch Je Woche |
| question | Englisch Question | Deutsch Frage |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Feedback zu Antworten reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab und Leertaste.
- Setzt aria-checked, aria-expanded, aria-haspopup, aria-label, aria-labelledby, aria-live, aria-modal und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, dialog, group, radio, radiogroup und status.
- 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, 797 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
export type FeedbackOnAnswerVariant = 'thumbs' | 'correction' | 'rating-scale' | 'report-dialog' | 'insights';
export interface FeedbackReason {
id: string;
label: string;
}
export interface FeedbackDimension {
id: string;
label: string;
/** Words at both ends of the scale */
low: string;
high: string;
}
export interface FeedbackComment {
id: string;
rating: 'up' | 'down';
reason?: string;
text: string;
question: string;
time: string;
}
export interface FeedbackOnAnswerProps {
variant?: FeedbackOnAnswerVariant;
title?: string;
/** The answer the feedback is about */
answer?: { question: string; text: string; assistant?: string };
/** thumbs: a rating that is already given, the reasons open */
defaultRating?: 'up' | 'down';
/** thumbs: reasons for a bad answer and for a good one */
reasons?: FeedbackReason[];
praise?: FeedbackReason[];
/** correction: the sentence to correct */
wrong?: string;
/** correction: the corrected sentence already typed in */
correction?: string;
/** rating-scale: values already chosen, by dimension id */
defaultValues?: Record<string, number>;
/** rating-scale */
dimensions?: FeedbackDimension[];
/** report-dialog: start open */
defaultOpen?: boolean;
/** insights */
period?: string;
positive?: number;
negative?: number;
/** insights: share of good ratings per week, 0 to 1 */
trend?: number[];
topReasons?: { label: string; count: number }[];
comments?: FeedbackComment[];
onSubmit?: (value: Record<string, unknown>) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
helpful: 'Was this answer helpful?',
good: 'Good answer',
bad: 'Bad answer',
whatWrong: 'What was the problem?',
whatGood: 'What was good?',
comment: 'Add details (optional)',
commentPlaceholder: 'What should the answer have said?',
send: 'Send feedback',
thanks: 'Thanks. Your feedback helps improve the answers.',
change: 'Change',
privacy: 'Your rating and this conversation are reviewed by the product team. They are never shared outside it.',
suggest: 'Suggest a correction',
yourVersion: 'Your correction',
source: 'Source that shows it (optional)',
sourcePlaceholder: 'https://',
preview: 'Preview',
submit: 'Send correction',
corrected: 'Correction sent. The team checks it before anything changes.',
cancel: 'Cancel',
rate: 'Rate this answer',
overall: 'Overall',
sendRating: 'Send rating',
rated: 'Rating saved.',
of: '{n} of 5',
report: 'Report a problem',
reportTitle: 'Report this answer',
category: 'What kind of problem?',
details: 'Details',
detailsPlaceholder: 'Describe what happened',
include: 'Include this conversation',
includeNote: 'The team can read the messages to understand the problem.',
reported: 'Report received. We answer within two working days.',
close: 'Close',
required: 'Choose a kind of problem first.',
positive: 'Helpful',
negative: 'Not helpful',
ratio: 'Helpful share',
topReasons: 'Most named problems',
recent: 'Latest comments',
all: 'All',
weeks: 'Per week',
question: 'Question',
},
de: {
helpful: 'War diese Antwort hilfreich?',
good: 'Gute Antwort',
bad: 'Schlechte Antwort',
whatWrong: 'Was war das Problem?',
whatGood: 'Was war gut?',
comment: 'Details ergänzen (optional)',
commentPlaceholder: 'Was hätte die Antwort sagen sollen?',
send: 'Feedback senden',
thanks: 'Danke. Dein Feedback hilft, die Antworten zu verbessern.',
change: 'Ändern',
privacy: 'Deine Bewertung und dieses Gespräch sieht nur das Produktteam. Nichts davon geht nach außen.',
suggest: 'Korrektur vorschlagen',
yourVersion: 'Deine Korrektur',
source: 'Quelle, die es belegt (optional)',
sourcePlaceholder: 'https://',
preview: 'Vorschau',
submit: 'Korrektur senden',
corrected: 'Korrektur gesendet. Das Team prüft sie, bevor sich etwas ändert.',
cancel: 'Abbrechen',
rate: 'Diese Antwort bewerten',
overall: 'Gesamt',
sendRating: 'Bewertung senden',
rated: 'Bewertung gespeichert.',
of: '{n} von 5',
report: 'Problem melden',
reportTitle: 'Diese Antwort melden',
category: 'Welche Art von Problem?',
details: 'Details',
detailsPlaceholder: 'Beschreib, was passiert ist',
include: 'Dieses Gespräch mitsenden',
includeNote: 'Das Team kann die Nachrichten lesen, um das Problem zu verstehen.',
reported: 'Meldung erhalten. Wir antworten innerhalb von zwei Werktagen.',
close: 'Schließen',
required: 'Wähl zuerst eine Art von Problem.',
positive: 'Hilfreich',
negative: 'Nicht hilfreich',
ratio: 'Anteil hilfreich',
topReasons: 'Häufigste Probleme',
recent: 'Neueste Kommentare',
all: 'Alle',
weeks: 'Je Woche',
question: 'Frage',
},
};
type Texts = (typeof TEXTS)['en'];
type P = FeedbackOnAnswerProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const CSS =
'@keyframes fba-up{from{opacity:0;transform:translateY(8px)}}@keyframes fba-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}@keyframes fba-bar{from{transform:scaleX(0)}}@keyframes fba-in{from{opacity:0;transform:translateY(10px) scale(.98)}}@keyframes fba-draw{from{stroke-dashoffset:1}}' +
'@media (prefers-reduced-motion:no-preference){.fba-up{animation:fba-up .45s var(--ease-kit) var(--fba-d,0ms) both}.fba-pop{animation:fba-pop .5s var(--ease-kit)}.fba-bar{transform-origin:left;animation:fba-bar 1s var(--ease-kit) var(--fba-d,0ms) both}.fba-in{animation:fba-in .35s var(--ease-kit) both}}';
function Style() {
return (
<style href="kit-feedback-on-answer" 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-[44rem]')}>
{title && (
<Reveal className="mb-6 sm:mb-8">
<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>
);
}
/** The answer card the feedback hangs on */
function AnswerCard({ answer, children, highlight }: { answer?: FeedbackOnAnswerProps['answer']; children?: React.ReactNode; highlight?: string }) {
if (!answer) return <>{children}</>;
const parts = highlight && answer.text.includes(highlight) ? answer.text.split(highlight) : null;
return (
<div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div className="space-y-4 p-5 sm:p-6">
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-md bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] px-4 py-2.5 text-[14.5px] text-foreground">{answer.question}</p>
<div className="flex gap-3">
<span aria-hidden className="grid size-8 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary">
<Icon name="sparkle" size={16} />
</span>
<div className="min-w-0 flex-1">
{answer.assistant && <p className="mb-1 text-[12.5px] font-medium text-muted-foreground">{answer.assistant}</p>}
<p className="text-pretty text-[15px] leading-relaxed text-foreground">
{parts ? (
<>
{parts[0]}
<mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--destructive)_16%,transparent)] text-foreground underline decoration-[color-mix(in_oklab,var(--destructive)_70%,transparent)] decoration-wavy underline-offset-4 [box-decoration-break:clone]">{highlight}</mark>
{parts[1]}
</>
) : (
answer.text
)}
</p>
</div>
</div>
</div>
{children}
</div>
);
}
const chip = 'focus-visible:-outline-offset-2 inline-flex min-h-11 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-[background-color,border-color,color] duration-200 sm:min-h-9';
const chipOn = 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground';
const chipOff = 'border-border bg-background text-muted-foreground hover:text-foreground hover:border-border-strong';
const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-[13.5px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9';
const quietBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9';
const field = 'w-full rounded-xl border border-border bg-background px-3.5 py-2.5 text-[14.5px] text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)] focus-visible:outline-none';
function Done({ children }: { children: React.ReactNode }) {
return (
<p role="status" className="fba-up flex items-start gap-2 text-[13.5px] leading-snug text-success-foreground">
<Icon name="check-circle" size={16} className="mt-px shrink-0" />
{children}
</p>
);
}
/* ------------------------------------------------------------------ */
/* thumbs */
/* ------------------------------------------------------------------ */
function Thumbs({ t, title, answer, reasons = [], praise = [], defaultRating, onSubmit, className }: P) {
const [rating, setRating] = useState<'up' | 'down' | null>(defaultRating ?? null);
const [picked, setPicked] = useState<string[]>([]);
const [note, setNote] = useState('');
const [sent, setSent] = useState(false);
const [pop, setPop] = useState(0);
const list = rating === 'up' ? praise : reasons;
const rate = (r: 'up' | 'down') => {
setRating(rating === r ? null : r);
setPicked([]);
setSent(false);
setPop((n) => n + 1);
};
return (
<Stage title={title} className={className}>
<Reveal effect="scale" delay={60}>
<AnswerCard answer={answer}>
<div className="border-t border-border px-5 py-3.5 sm:px-6">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<p className="mr-auto text-[13.5px] text-muted-foreground">{t.helpful}</p>
<div className="flex gap-1.5">
{(['up', 'down'] as const).map((r) => (
<button
key={r}
type="button"
aria-pressed={rating === r}
aria-label={r === 'up' ? t.good : t.bad}
onClick={() => rate(r)}
className={cn('grid size-11 place-items-center rounded-lg border transition-colors duration-200 sm:size-9', rating === r ? (r === 'up' ? 'border-[color-mix(in_oklab,var(--success)_50%,var(--border))] bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] text-success-foreground' : 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive') : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground')}
>
<Icon key={rating === r ? pop : 0} name={r === 'up' ? 'thumbs-up' : 'thumbs-down'} size={17} className={rating === r ? 'fba-pop' : undefined} />
</button>
))}
</div>
</div>
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', rating ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden" inert={!rating || undefined}>
<div className="pt-4">
{sent ? (
<div className="flex items-center justify-between gap-3">
<Done>{t.thanks}</Done>
<button type="button" onClick={() => setSent(false)} className="inline-flex h-11 shrink-0 items-center rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8">
{t.change}
</button>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit?.({ rating, reasons: picked, note });
setSent(true);
}}
>
<fieldset>
<legend className="mb-2 text-[13px] font-medium text-foreground">{rating === 'up' ? t.whatGood : t.whatWrong}</legend>
<div className="flex flex-wrap gap-2">
{list.map((r, i) => {
const on = picked.includes(r.id);
return (
<button key={`${rating}-${r.id}`} type="button" aria-pressed={on} onClick={() => setPicked((p) => (on ? p.filter((x) => x !== r.id) : [...p, r.id]))} className={cn(chip, on ? chipOn : chipOff, 'fba-up')} style={{ '--fba-d': `${i * 40}ms` } as React.CSSProperties}>
{on && <Icon name="check" size={14} />}
{r.label}
</button>
);
})}
</div>
</fieldset>
<label className="mt-3 block">
<span className="mb-1.5 block text-[12.5px] text-muted-foreground">{t.comment}</span>
<textarea rows={2} value={note} onChange={(e) => setNote(e.target.value)} placeholder={t.commentPlaceholder} className={cn(field, 'resize-none [field-sizing:content] min-h-[4.5rem]')} />
</label>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="flex items-start gap-1.5 text-[12px] leading-snug text-muted-foreground">
<Icon name="lock" size={13} className="mt-px shrink-0" />
{t.privacy}
</p>
<button type="submit" disabled={!picked.length && !note.trim()} className={cn(primaryBtn, 'shrink-0')}>
{t.send}
</button>
</div>
</form>
)}
</div>
</div>
</div>
</div>
</AnswerCard>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* correction */
/* ------------------------------------------------------------------ */
function Correction({ t, title, answer, wrong = '', correction, onSubmit, className }: P) {
const [open, setOpen] = useState(true);
const [text, setText] = useState(correction ?? wrong);
const [src, setSrc] = useState('');
const [sent, setSent] = useState(false);
const area = useRef<HTMLTextAreaElement>(null);
const changed = text.trim() !== wrong.trim() && text.trim().length > 0;
return (
<Stage title={title} className={className}>
<Reveal effect="scale" delay={60}>
<AnswerCard answer={answer} highlight={sent ? undefined : wrong}>
<div className="border-t border-border px-5 py-4 sm:px-6">
{!open && !sent && (
<button type="button" onClick={() => (setOpen(true), requestAnimationFrame(() => area.current?.focus()))} className={quietBtn}>
<Icon name="edit" size={15} />
{t.suggest}
</button>
)}
{sent && <Done>{t.corrected}</Done>}
{open && !sent && (
<form
className="fba-up"
onSubmit={(e) => {
e.preventDefault();
if (!changed) return;
onSubmit?.({ before: wrong, after: text, source: src });
setSent(true);
}}
>
<label className="block">
<span className="mb-1.5 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="edit" size={14} className="text-primary" />
{t.yourVersion}
</span>
<textarea ref={area} rows={3} value={text} onChange={(e) => setText(e.target.value)} className={cn(field, 'resize-none [field-sizing:content] min-h-[5.5rem]')} />
</label>
{changed && (
<div className="fba-up mt-3 rounded-xl border border-border bg-background/60 p-3.5">
<p className="mb-1.5 text-[12px] font-medium text-subtle-foreground">{t.preview}</p>
<p className="text-[13.5px] leading-relaxed">
<del className="text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)]">{wrong}</del>
</p>
<p className="mt-1 text-[13.5px] leading-relaxed">
<ins className="rounded-[3px] bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-foreground no-underline [box-decoration-break:clone]">{text}</ins>
</p>
</div>
)}
<label className="mt-3 block">
<span className="mb-1.5 block text-[12.5px] text-muted-foreground">{t.source}</span>
<input type="url" inputMode="url" value={src} onChange={(e) => setSrc(e.target.value)} placeholder={t.sourcePlaceholder} className={cn(field, 'h-11 py-0')} />
</label>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<button type="button" onClick={() => (setOpen(false), setText(wrong))} className={quietBtn}>
{t.cancel}
</button>
<button type="submit" disabled={!changed} className={primaryBtn}>
{t.submit}
</button>
</div>
</form>
)}
</div>
</AnswerCard>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* rating-scale */
/* ------------------------------------------------------------------ */
function RatingScale({ t, title, answer, dimensions = [], defaultValues, onSubmit, className }: P) {
const locale = useLocale();
const [values, setValues] = useState<Record<string, number>>(defaultValues ?? {});
const [sent, setSent] = useState(false);
const rated = dimensions.filter((d) => values[d.id]);
const avg = rated.length ? rated.reduce((s, d) => s + values[d.id], 0) / rated.length : 0;
const refs = useRef<Record<string, (HTMLButtonElement | null)[]>>({});
const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
return (
<Stage title={title} className={className}>
<Reveal effect="scale" delay={60}>
<AnswerCard answer={answer}>
<form
className="border-t border-border px-5 py-5 sm:px-6"
onSubmit={(e) => {
e.preventDefault();
onSubmit?.(values);
setSent(true);
}}
>
<div className="mb-4 flex items-baseline justify-between gap-3">
<p className="text-[14px] font-medium text-foreground">{t.rate}</p>
<p className="min-h-6 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
{avg > 0 && (
<span className="fba-up">
{t.overall} <span className="text-[17px] font-semibold text-foreground">{nf(avg)}</span> / 5
</span>
)}
</p>
</div>
<Stagger step={70} className="space-y-4">
{dimensions.map((d) => {
const v = values[d.id] ?? 0;
return (
<div key={d.id} role="radiogroup" aria-label={d.label}>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-[13.5px] font-medium text-foreground">{d.label}</span>
<span className="text-[12px] tabular-nums text-subtle-foreground">{v ? fill(t.of, { n: v }) : ''}</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{[1, 2, 3, 4, 5].map((n) => {
const on = n <= v;
return (
<button
key={n}
ref={(el) => {
(refs.current[d.id] ??= [])[n - 1] = el;
}}
type="button"
role="radio"
aria-checked={v === n}
aria-label={`${d.label}, ${fill(t.of, { n })}`}
tabIndex={v === n || (!v && n === 1) ? 0 : -1}
disabled={sent}
onClick={() => setValues((s) => ({ ...s, [d.id]: n }))}
onKeyDown={(e) => {
const dir = ['ArrowRight', 'ArrowUp'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowDown'].includes(e.key) ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = Math.min(5, Math.max(1, (v || n) + dir));
setValues((s) => ({ ...s, [d.id]: next }));
refs.current[d.id]?.[next - 1]?.focus();
}}
className={cn('h-11 rounded-lg border transition-[background-color,border-color,transform] duration-300 ease-[var(--ease-kit)] active:scale-95 sm:h-9', on ? 'border-transparent bg-primary' : 'border-border bg-background hover:border-border-strong')}
style={{ transitionDelay: on ? `${(n - 1) * 35}ms` : '0ms' }}
>
<span className={cn('text-[12.5px] font-semibold tabular-nums', on ? 'text-primary-foreground' : 'text-muted-foreground')}>{n}</span>
</button>
);
})}
</div>
<div className="mt-1.5 flex justify-between text-[11.5px] text-subtle-foreground">
<span>{d.low}</span>
<span>{d.high}</span>
</div>
</div>
);
})}
</Stagger>
<div className="mt-5 flex items-center justify-between gap-3">
{sent ? <Done>{t.rated}</Done> : <span />}
{!sent && (
<button type="submit" disabled={rated.length < dimensions.length} className={primaryBtn}>
{t.sendRating}
</button>
)}
</div>
</form>
</AnswerCard>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* report-dialog */
/* ------------------------------------------------------------------ */
function ReportDialog({ t, title, answer, reasons = [], defaultOpen = true, onSubmit, className }: P) {
const [open, setOpen] = useState(defaultOpen);
const [kind, setKind] = useState('');
const [details, setDetails] = useState('');
const [include, setInclude] = useState(true);
const [sent, setSent] = useState(false);
const [error, setError] = useState(false);
const id = useId();
const dialog = useRef<HTMLDivElement>(null);
const opener = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const el = dialog.current;
const keys = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
opener.current?.focus();
}
if (e.key !== 'Tab' || !el) return;
const f = Array.from(el.querySelectorAll<HTMLElement>('button, input, textarea, [tabindex="0"]')).filter((x) => !x.hasAttribute('disabled'));
if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) (e.preventDefault(), f[f.length - 1].focus());
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) (e.preventDefault(), f[0].focus());
};
document.addEventListener('keydown', keys);
return () => document.removeEventListener('keydown', keys);
}, [open]);
return (
<Stage title={title} className={className}>
{/* Card and dialog share one grid cell, so the cell grows with the taller one and nothing is cut */}
<div className="relative grid">
<Reveal effect="scale" delay={60} className="[grid-area:1/1]">
<AnswerCard answer={answer}>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-3 sm:px-6">
<span className="text-[12.5px] text-muted-foreground">{answer?.assistant}</span>
<button ref={opener} type="button" aria-haspopup="dialog" aria-expanded={open} onClick={() => (setOpen(true), setSent(false), requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>('input, button:not([aria-label])')?.focus()))} className="inline-flex h-11 items-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-8">
<Icon name="flag" size={15} />
{t.report}
</button>
</div>
</AnswerCard>
</Reveal>
{open && (
<>
<div aria-hidden className="fba-up -m-3 rounded-[1.5rem] bg-scrim backdrop-blur-[2px] [grid-area:1/1] sm:-m-4" onClick={() => setOpen(false)} />
<div
ref={dialog}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-t`}
className="fba-in relative z-10 mx-auto mb-2 mt-6 h-fit w-full max-w-[30rem] self-start rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)] [grid-area:1/1] sm:mt-10"
>
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5">
<h3 id={`${id}-t`} className="text-[15.5px] font-semibold text-foreground">
{t.reportTitle}
</h3>
<button type="button" aria-label={t.close} onClick={() => (setOpen(false), opener.current?.focus())} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
<Icon name="x" size={17} />
</button>
</div>
{sent ? (
<div className="px-5 py-8 text-center">
<span className="mx-auto grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground">
<Icon name="check" size={22} />
</span>
<p className="mx-auto mt-4 max-w-[20rem] text-pretty text-[14px] leading-relaxed text-foreground" role="status">
{t.reported}
</p>
<button type="button" onClick={() => (setOpen(false), opener.current?.focus())} className={cn(quietBtn, 'mt-5')}>
{t.close}
</button>
</div>
) : (
<form
noValidate
className="px-5 py-4"
onSubmit={(e) => {
e.preventDefault();
if (!kind) return setError(true);
onSubmit?.({ kind, details, include });
setSent(true);
}}
>
<fieldset>
<legend className="mb-2 text-[13px] font-medium text-foreground">{t.category}</legend>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{reasons.map((r) => (
<label key={r.id} className={cn('flex min-h-11 cursor-pointer items-center gap-2.5 rounded-lg border px-3 text-[13.5px] transition-colors', kind === r.id ? chipOn : 'border-border text-foreground hover:bg-accent/60')}>
<input type="radio" name={`${id}-kind`} value={r.id} checked={kind === r.id} onChange={() => (setKind(r.id), setError(false))} className="size-4 accent-[var(--primary)]" />
{r.label}
</label>
))}
</div>
{error && (
<p role="alert" className="fba-up mt-2 flex items-center gap-1.5 text-[12.5px] text-destructive">
<Icon name="alert-circle" size={14} />
{t.required}
</p>
)}
</fieldset>
<label className="mt-3 block">
<span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.details}</span>
<textarea rows={3} value={details} onChange={(e) => setDetails(e.target.value)} placeholder={t.detailsPlaceholder} className={cn(field, 'resize-none')} />
</label>
<label className="mt-3 flex cursor-pointer items-start gap-2.5 rounded-xl bg-background/60 p-3">
<input type="checkbox" checked={include} onChange={(e) => setInclude(e.target.checked)} className="mt-0.5 size-4 shrink-0 accent-[var(--primary)]" />
<span>
<span className="block text-[13.5px] font-medium text-foreground">{t.include}</span>
<span className="block text-[12.5px] leading-snug text-muted-foreground">{t.includeNote}</span>
</span>
</label>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => (setOpen(false), opener.current?.focus())} className={quietBtn}>
{t.cancel}
</button>
<button type="submit" className={primaryBtn}>
{t.report}
</button>
</div>
</form>
)}
</div>
</>
)}
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* insights */
/* ------------------------------------------------------------------ */
function Insights({ t, title, period, positive = 0, negative = 0, trend = [], topReasons = [], comments = [], className }: P) {
const locale = useLocale();
const [filter, setFilter] = useState<'all' | 'up' | 'down'>('all');
const total = positive + negative;
const share = total ? positive / total : 0;
const pct = (n: number) => (locale === 'de' ? `${Math.round(n * 100)} %` : `${Math.round(n * 100)}%`);
const maxReason = Math.max(1, ...topReasons.map((r) => r.count));
const shown = comments.filter((c) => filter === 'all' || c.rating === filter);
const w = 160;
const h = 44;
const pts = trend.map((v, i) => [round((i / Math.max(1, trend.length - 1)) * w), round(h - 4 - v * (h - 8))]);
const path = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join(' ');
return (
<Stage title={title} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)] lg:gap-6">
<div className="space-y-4">
<Reveal delay={60}>
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[12.5px] text-muted-foreground">{t.ratio}</p>
<p className="mt-1 text-[32px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{pct(share)}</p>
<p className="mt-1.5 text-[12px] text-subtle-foreground">{period}</p>
</div>
{pts.length > 1 && (
<svg aria-hidden viewBox={`0 0 ${w} ${h}`} className="h-11 w-40 overflow-visible">
<path d={`${path} L${w} ${h} L0 ${h} Z`} style={{ fill: 'color-mix(in oklab, var(--success) 14%, transparent)' }} />
<path d={path} fill="none" stroke="var(--success)" strokeWidth="1.6" strokeLinejoin="round" pathLength={1} className="[stroke-dasharray:1] motion-safe:animate-[fba-draw_1.2s_var(--ease-kit)_both]" />
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r="2.6" fill="var(--success)" />
<title>{t.weeks}</title>
</svg>
)}
</div>
<div aria-hidden className="mt-4 flex h-2.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--destructive)_55%,var(--card))]">
<span className="fba-bar h-full rounded-r-full bg-success" style={{ width: `${round(share * 100, 1)}%` }} />
</div>
<div className="mt-2.5 flex justify-between text-[12.5px] tabular-nums">
<span className="flex items-center gap-1.5 text-success-foreground">
<Icon name="thumbs-up" size={14} />
{t.positive} {positive.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
<span className="flex items-center gap-1.5 text-destructive">
{t.negative} {negative.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}
<Icon name="thumbs-down" size={14} />
</span>
</div>
</div>
</Reveal>
<Reveal delay={140}>
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)]">
<p className="mb-3 text-[13px] font-medium text-foreground">{t.topReasons}</p>
<ul className="space-y-3">
{topReasons.map((r, i) => (
<li key={r.label}>
<div className="mb-1 flex justify-between gap-3 text-[13px]">
<span className="text-foreground">{r.label}</span>
<span className="tabular-nums text-muted-foreground">{r.count}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]">
<span className="fba-bar block h-full rounded-full bg-[color-mix(in_oklab,var(--destructive)_70%,var(--card))]" style={{ width: `${round((r.count / maxReason) * 100, 1)}%`, '--fba-d': `${200 + i * 90}ms` } as React.CSSProperties} />
</div>
</li>
))}
</ul>
</div>
</Reveal>
</div>
<Reveal delay={200}>
<div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
<p className="text-[13px] font-medium text-foreground">{t.recent}</p>
<div role="group" aria-label={t.recent} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
{(
[
['all', t.all, null],
['up', t.positive, 'thumbs-up'],
['down', t.negative, 'thumbs-down'],
] as [typeof filter, string, IconName | null][]
).map(([k, label, icon]) => (
<button key={k} type="button" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn('inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium transition-colors sm:h-8 sm:min-w-0', filter === k ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{icon && <Icon name={icon} size={13} />}
<span className={icon ? 'max-sm:sr-only' : undefined}>{label}</span>
</button>
))}
</div>
</div>
<ul className="divide-y divide-border">
{shown.map((c, i) => (
<li key={`${filter}-${c.id}`} className="fba-up flex gap-3 px-5 py-4" style={{ '--fba-d': `${i * 50}ms` } as React.CSSProperties}>
<span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-full', c.rating === 'up' ? 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--destructive)_13%,var(--card))] text-destructive')}>
<Icon name={c.rating === 'up' ? 'thumbs-up' : 'thumbs-down'} size={13} />
</span>
<div className="min-w-0 flex-1">
<p className="text-pretty text-[14px] leading-relaxed text-foreground">{c.text}</p>
<p className="mt-1 truncate text-[12.5px] text-muted-foreground">
{t.question} · {c.question}
</p>
<p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-subtle-foreground">
{c.reason && <span className="rounded-md bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 py-0.5 text-muted-foreground">{c.reason}</span>}
{c.time}
</p>
</div>
</li>
))}
</ul>
</div>
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* Feedback on AI answers: thumbs with reasons and a comment, a correction
* with preview and source, a rating on several scales, a report dialog with
* privacy choice and an insights view for the team.
*/
export function FeedbackOnAnswer({ variant = 'thumbs', labels, ...props }: FeedbackOnAnswerProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const body = (() => {
switch (variant) {
case 'correction':
return <Correction {...p} />;
case 'rating-scale':
return <RatingScale {...p} />;
case 'report-dialog':
return <ReportDialog {...p} />;
case 'insights':
return <Insights {...p} />;
default:
return <Thumbs {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Häufige Fragen
Wie installiere ich Feedback zu Antworten?
Mit aiw add @ai-website/feedback-on-answer. Der Befehl legt die Datei components/ui/feedback-on-answer.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 Feedback zu Antworten?
5 Varianten, Daumen mit Gründen, Korrektur vorschlagen, Bewertungsskalen, Meldedialog und Auswertung. Du wählst sie mit der Prop variant, zum Beispiel variant="correction".
Kann ich die Texte von Feedback zu Antworten übersetzen?
Ja. Feedback zu Antworten bringt 42 eigene Texte auf Deutsch und Englisch mit, etwa „War diese Antwort hilfreich?“, „Gute Antwort“ und „Schlechte Antwort“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Feedback zu Antworten einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Feedback zu Antworten mit aiw_components und installieren sie mit aiw_add @ai-website/feedback-on-answer. Nenn im Prompt die Variante, etwa variant="thumbs", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Feedback zu Antworten ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden FeedbackOnAnswer-Code zum Kopieren.