Sprachassistent
Sprachoberflächen in vier Formen. Ein Gespräch um eine Kugel, die zuhört, nachdenkt und spricht, mit Live-Untertiteln, die Mitschrift eines Anrufs mit Wiedergabe und Zusammenfassung, eine Stimme mit Hörproben wählen und Halten zum Sprechen mit Sprachnachrichten.
Eine von 2 Components in KI-Assistenten
Variants
Details
Was Sprachassistent mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Assistenten
- Bereich
- App
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/voice-assistant.tsx
- Bringt mit
- utilskiticonrevealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 40 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle VoiceAssistantProps aus components/ui/voice-assistant.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalVoiceAssistantVariantorbcall-transcriptvoice-pickerpush-to-talk
titleoptionalstringnameoptionalstringName of the assistant
linesoptionalVoiceLine[]orb: what the assistant hears and says, played in turns
calloptional{ caller: string; number?: string; duration: number; date: string; outcome?: string }call-transcript: caller and call facts
summaryoptional{ label: string; value: string }[]call-transcript: what the assistant took from the call
voicesoptionalVoiceOption[]voice-picker
defaultVoiceoptionalstringexchangeoptional{ heard: string; reply: string }push-to-talk: what the demo hears and answers when you let go. Connect real speech here.
noteoptionalstringShort line on what happens with audio
Eingebaute Texte
Diese Texte schreibt Sprachassistent selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 40 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| listening | Englisch Listening | Deutsch Hört zu |
| thinking | Englisch Thinking | Deutsch Denkt nach |
| speaking | Englisch Speaking | Deutsch Spricht |
| idle | Englisch Tap to talk | Deutsch Tippen und sprechen |
| paused | Englisch Microphone off | Deutsch Mikrofon aus |
| mute | Englisch Turn microphone off | Deutsch Mikrofon ausschalten |
| unmute | Englisch Turn microphone on | Deutsch Mikrofon einschalten |
| end | Englisch End conversation | Deutsch Gespräch beenden |
| captions | Englisch Captions | Deutsch Untertitel |
| you | Englisch You | Deutsch Du |
| restart | Englisch Start again | Deutsch Neu starten |
| ended | Englisch Conversation ended. The transcript stays in your history. | Deutsch Gespräch beendet. Die Mitschrift bleibt in deinem Verlauf. |
| play | Englisch Play recording | Deutsch Aufnahme abspielen |
| pause | Englisch Pause | Deutsch Pausieren |
| seek | Englisch Position in the recording | Deutsch Stelle in der Aufnahme |
| caller | Englisch Caller | Deutsch Anrufer |
| assistant | Englisch Assistant | Deutsch Assistent |
| summary | Englisch What was agreed | Deutsch Was vereinbart wurde |
| jump | Englisch Play from {time} | Deutsch Ab {time} abspielen |
| voice | Englisch Voice | Deutsch Stimme |
| quoteOpen | Englisch “ | Deutsch „ |
| quoteClose | Englisch ” | Deutsch “ |
| preview | Englisch Play sample of {name} | Deutsch Hörprobe von {name} abspielen |
| stopPreview | Englisch Stop sample | Deutsch Hörprobe stoppen |
| speed | Englisch Speed | Deutsch Tempo |
| pitch | Englisch Pitch | Deutsch Tonhöhe |
| slower | Englisch slower | Deutsch langsamer |
| faster | Englisch faster | Deutsch schneller |
| lower | Englisch lower | Deutsch tiefer |
| higher | Englisch higher | Deutsch höher |
| use | Englisch Use this voice | Deutsch Diese Stimme nutzen |
| inUse | Englisch In use | Deutsch In Nutzung |
| hold | Englisch Hold to talk | Deutsch Halten und sprechen |
| release | Englisch Release to send | Deutsch Loslassen zum Senden |
| recording | Englisch Recording | Deutsch Nimmt auf |
| showText | Englisch Show text | Deutsch Text zeigen |
| hideText | Englisch Hide text | Deutsch Text verbergen |
| spaceHint | Englisch Or hold the space bar | Deutsch Oder Leertaste halten |
| heard | Englisch Is the delivery for Kantstraße 12 still on for Thursday? | Deutsch Bleibt die Lieferung für die Kantstraße 12 bei Donnerstag? |
| reply | Englisch Yes, Thursday between 8 and 10 am. The driver calls 30 minutes before. | Deutsch Ja, Donnerstag zwischen 8 und 10 Uhr. Der Fahrer ruft 30 Minuten vorher an. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Sprachassistent reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-label, aria-live, aria-pressed und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- 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.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 683 Zeilen
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
export type VoiceAssistantVariant = 'orb' | 'call-transcript' | 'voice-picker' | 'push-to-talk';
export interface VoiceLine {
id: string;
speaker: 'assistant' | 'caller' | 'user';
text: string;
/** Seconds from the start */
at?: number;
/** Seconds of audio, for voice messages */
duration?: number;
}
export interface VoiceOption {
id: string;
name: string;
/** e.g. "Warm, calm" */
character: string;
languages: string[];
/** A sentence the sample says */
sample: string;
tag?: string;
}
export interface VoiceAssistantProps {
variant?: VoiceAssistantVariant;
title?: string;
/** Name of the assistant */
name?: string;
/** orb: what the assistant hears and says, played in turns */
lines?: VoiceLine[];
/** call-transcript: caller and call facts */
call?: { caller: string; number?: string; duration: number; date: string; outcome?: string };
/** call-transcript: what the assistant took from the call */
summary?: { label: string; value: string }[];
/** voice-picker */
voices?: VoiceOption[];
defaultVoice?: string;
/** push-to-talk: what the demo hears and answers when you let go. Connect real speech here. */
exchange?: { heard: string; reply: string };
/** Short line on what happens with audio */
note?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
listening: 'Listening',
thinking: 'Thinking',
speaking: 'Speaking',
idle: 'Tap to talk',
paused: 'Microphone off',
mute: 'Turn microphone off',
unmute: 'Turn microphone on',
end: 'End conversation',
captions: 'Captions',
you: 'You',
restart: 'Start again',
ended: 'Conversation ended. The transcript stays in your history.',
play: 'Play recording',
pause: 'Pause',
seek: 'Position in the recording',
caller: 'Caller',
assistant: 'Assistant',
summary: 'What was agreed',
jump: 'Play from {time}',
voice: 'Voice',
quoteOpen: '“',
quoteClose: '”',
preview: 'Play sample of {name}',
stopPreview: 'Stop sample',
speed: 'Speed',
pitch: 'Pitch',
slower: 'slower',
faster: 'faster',
lower: 'lower',
higher: 'higher',
use: 'Use this voice',
inUse: 'In use',
hold: 'Hold to talk',
release: 'Release to send',
recording: 'Recording',
showText: 'Show text',
hideText: 'Hide text',
spaceHint: 'Or hold the space bar',
heard: 'Is the delivery for Kantstraße 12 still on for Thursday?',
reply: 'Yes, Thursday between 8 and 10 am. The driver calls 30 minutes before.',
},
de: {
listening: 'Hört zu',
thinking: 'Denkt nach',
speaking: 'Spricht',
idle: 'Tippen und sprechen',
paused: 'Mikrofon aus',
mute: 'Mikrofon ausschalten',
unmute: 'Mikrofon einschalten',
end: 'Gespräch beenden',
captions: 'Untertitel',
you: 'Du',
restart: 'Neu starten',
ended: 'Gespräch beendet. Die Mitschrift bleibt in deinem Verlauf.',
play: 'Aufnahme abspielen',
pause: 'Pausieren',
seek: 'Stelle in der Aufnahme',
caller: 'Anrufer',
assistant: 'Assistent',
summary: 'Was vereinbart wurde',
jump: 'Ab {time} abspielen',
voice: 'Stimme',
quoteOpen: '„',
quoteClose: '“',
preview: 'Hörprobe von {name} abspielen',
stopPreview: 'Hörprobe stoppen',
speed: 'Tempo',
pitch: 'Tonhöhe',
slower: 'langsamer',
faster: 'schneller',
lower: 'tiefer',
higher: 'höher',
use: 'Diese Stimme nutzen',
inUse: 'In Nutzung',
hold: 'Halten und sprechen',
release: 'Loslassen zum Senden',
recording: 'Nimmt auf',
showText: 'Text zeigen',
hideText: 'Text verbergen',
spaceHint: 'Oder Leertaste halten',
heard: 'Bleibt die Lieferung für die Kantstraße 12 bei Donnerstag?',
reply: 'Ja, Donnerstag zwischen 8 und 10 Uhr. Der Fahrer ruft 30 Minuten vorher an.',
},
};
type Texts = (typeof TEXTS)['en'];
type P = VoiceAssistantProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const CSS =
'@keyframes vca-up{from{opacity:0;transform:translateY(8px)}}@keyframes vca-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}@keyframes vca-ring{from{transform:scale(.9);opacity:.6}to{transform:scale(1.5);opacity:0}}@keyframes vca-spin{to{transform:rotate(360deg)}}@keyframes vca-bar{0%,100%{transform:scaleY(.25)}50%{transform:scaleY(1)}}' +
'@media (prefers-reduced-motion:no-preference){.vca-up{animation:vca-up .45s var(--ease-kit) var(--vca-d,0ms) both}.vca-breathe{animation:vca-breathe 3.2s ease-in-out infinite}.vca-ring{animation:vca-ring 2s ease-out infinite}.vca-spin{animation:vca-spin 6s linear infinite}.vca-bar{animation:vca-bar var(--vca-t,.9s) ease-in-out var(--vca-d,0ms) infinite}}.vca-bar{transform-origin:50% 50%}';
function Style() {
return (
<style href="kit-voice-assistant" precedence="default">
{CSS}
</style>
);
}
/** Bar heights, fixed so server and browser agree */
const wave = (n: number, seed = 1) => Array.from({ length: n }, (_, i) => round(0.2 + 0.8 * Math.abs(Math.sin(i * 0.9 * seed + seed) * Math.cos(i * 0.31 + seed * 2)), 2));
function Stage({ title, children, className, wide, dark }: { title?: string; children: React.ReactNode; className?: string; wide?: boolean; dark?: boolean }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', dark && 'dark bg-background', className)}>
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[64rem]' : '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>
);
}
const round_ = 'grid size-14 place-items-center rounded-full transition-[background-color,transform] duration-200 active:scale-95';
/* ------------------------------------------------------------------ */
/* orb */
/* ------------------------------------------------------------------ */
type Phase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'ended';
function Orb({ phase, level }: { phase: Phase; level: number }) {
const live = phase !== 'idle' && phase !== 'ended';
return (
<div aria-hidden className="relative mx-auto grid size-56 place-items-center sm:size-64">
{phase === 'listening' && (
<>
<span className="vca-ring absolute inset-6 rounded-full border border-[color-mix(in_oklab,var(--primary)_50%,transparent)]" />
<span className="vca-ring absolute inset-6 rounded-full border border-[color-mix(in_oklab,var(--primary)_40%,transparent)] [animation-delay:1s]" />
</>
)}
<span
className={cn('absolute inset-8 rounded-full transition-[transform,opacity] duration-500 ease-[var(--ease-kit)]', live ? 'vca-breathe' : 'opacity-70')}
style={{
background: 'radial-gradient(circle at 35% 30%, color-mix(in oklab, var(--primary) 30%, white) 0%, var(--primary) 38%, color-mix(in oklab, var(--primary) 55%, black) 100%)',
boxShadow: '0 30px 80px -20px color-mix(in oklab, var(--primary) 70%, transparent), inset 0 -12px 40px color-mix(in oklab, black 30%, transparent)',
transform: `scale(${round(0.92 + level * 0.12, 3)})`,
}}
/>
<span className={cn('absolute inset-14 rounded-full border border-[color-mix(in_oklab,var(--primary-foreground)_28%,transparent)]', phase === 'thinking' && 'vca-spin border-dashed')} />
{phase === 'speaking' && (
<span className="relative flex h-10 items-center gap-1">
{wave(9, 2).map((h, i) => (
<span key={i} className="vca-bar w-1.5 rounded-full bg-[color-mix(in_oklab,var(--primary-foreground)_90%,transparent)]" style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${i * 80}ms`, '--vca-t': `${700 + (i % 3) * 160}ms` } as React.CSSProperties} />
))}
</span>
)}
</div>
);
}
function OrbView({ t, title, name, lines = [], note, className }: P) {
const [phase, setPhase] = useState<Phase>('idle');
const [i, setI] = useState(0);
const [shown, setShown] = useState('');
const [muted, setMuted] = useState(false);
const [level, setLevel] = useState(0);
const [captions, setCaptions] = useState(true);
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
const started = useRef(false);
// Plays the lines in turns: the user is heard, the assistant thinks, then speaks
useEffect(() => {
if (phase === 'idle' || phase === 'ended' || muted) return;
const line = lines[i];
if (!line) return setPhase('ended');
if (prefersReducedMotion()) {
setShown(line.text);
const id = window.setTimeout(() => (setI((n) => n + 1), setPhase(lines[i + 1]?.speaker === 'assistant' ? 'speaking' : 'listening')), 1200);
return () => window.clearTimeout(id);
}
if (phase === 'thinking') {
const id = window.setTimeout(() => setPhase('speaking'), 900);
return () => window.clearTimeout(id);
}
const words = line.text.split(' ');
let n = 0;
let next = 0;
setShown('');
const id = window.setInterval(() => {
n++;
setShown(words.slice(0, n).join(' '));
setLevel(round(0.3 + 0.7 * Math.abs(Math.sin(n * 1.7)), 2));
if (n >= words.length) {
window.clearInterval(id);
next = window.setTimeout(() => {
setLevel(0);
setI((k) => k + 1);
const next = lines[i + 1];
setPhase(!next ? 'ended' : next.speaker === 'assistant' ? 'thinking' : 'listening');
}, 900);
}
}, 190);
// Muting or ending in the pause after a line must not start the next one
return () => (window.clearInterval(id), window.clearTimeout(next));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, i, muted]);
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, and scrolling away and back never restarts the talk
const id = window.setTimeout(() => {
started.current = true;
setPhase(lines[0]?.speaker === 'assistant' ? 'speaking' : 'listening');
}, 800);
return () => window.clearTimeout(id);
}, [inView, lines]);
const start = () => (setI(0), setShown(''), setMuted(false), setPhase(lines[0]?.speaker === 'assistant' ? 'speaking' : 'listening'));
const line = lines[Math.min(i, lines.length - 1)];
const status = muted ? t.paused : phase === 'idle' ? t.idle : phase === 'ended' ? t.ended : t[phase];
const who = phase === 'listening' ? t.you : name;
return (
<Stage title={title} className={className} dark>
<div ref={ref} className="relative overflow-hidden rounded-[28px] border border-border bg-[radial-gradient(120%_80%_at_50%_0%,color-mix(in_oklab,var(--primary)_18%,var(--background)),var(--background)_60%)] px-5 pb-6 pt-8 text-center shadow-[var(--shadow-lg)] sm:px-10 sm:pt-10">
<p className="text-[13px] font-medium text-muted-foreground">{name}</p>
<p className="mt-1 text-[15px] font-medium text-foreground" aria-live="polite">
{status}
</p>
<button type="button" onClick={phase === 'idle' || phase === 'ended' ? start : () => setMuted((m) => !m)} aria-label={phase === 'idle' || phase === 'ended' ? t.idle : muted ? t.unmute : t.mute} className="mx-auto mt-2 block rounded-full focus-visible:outline-offset-[-12px]">
<Orb phase={muted ? 'idle' : phase} level={level} />
</button>
<div className="mx-auto mt-2 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
{captions && phase !== 'idle' && phase !== 'ended' && line && (
<>
<p className="text-[12px] font-medium text-subtle-foreground">{who}</p>
<p className={cn('mt-1 text-balance text-[17px] leading-relaxed sm:text-[19px]', phase === 'listening' ? 'text-muted-foreground' : 'text-foreground')}>
{phase === 'thinking' ? '' : shown}
{phase !== 'thinking' && <span aria-hidden className="ml-0.5 inline-block h-[1em] w-0.5 translate-y-[0.15em] animate-kit-pulse bg-primary" />}
</p>
</>
)}
{phase === 'ended' && <p className="vca-up text-[14px] text-muted-foreground">{t.ended}</p>}
</div>
<div className="mt-4 flex items-center justify-center gap-4">
<button type="button" aria-label={t.captions} aria-pressed={captions} onClick={() => setCaptions((c) => !c)} className={cn(round_, 'size-12 border', captions ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground' : 'border-border bg-card text-muted-foreground hover:bg-accent')}>
<Icon name="captions" size={20} />
</button>
{phase === 'ended' || phase === 'idle' ? (
<button type="button" onClick={start} aria-label={t.restart} className={cn(round_, 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}>
<Icon name="mic" size={24} />
</button>
) : (
<button type="button" aria-pressed={muted} aria-label={muted ? t.unmute : t.mute} onClick={() => setMuted((m) => !m)} className={cn(round_, muted ? 'bg-foreground text-background' : 'border border-border bg-card text-foreground hover:bg-accent')}>
<Icon name={muted ? 'volume-x' : 'mic'} size={24} />
</button>
)}
<button type="button" aria-label={t.end} onClick={() => setPhase('ended')} disabled={phase === 'ended' || phase === 'idle'} className={cn(round_, 'size-12 bg-destructive text-destructive-foreground disabled:opacity-40')}>
<Icon name="x" size={20} />
</button>
</div>
{note && (
<p className="mx-auto mt-6 flex max-w-[28rem] items-start justify-center gap-1.5 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="lock" size={13} className="mt-px shrink-0" />
{note}
</p>
)}
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* call-transcript */
/* ------------------------------------------------------------------ */
const WAVE = wave(96, 1.3);
function CallTranscript({ t, title, name, lines = [], call, summary = [], className }: P) {
const total = call?.duration ?? 60;
const [pos, setPos] = useState(0);
const [playing, setPlaying] = useState(false);
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => setPos((p) => (p >= total ? (setPlaying(false), total) : p + 1)), prefersReducedMotion() ? 1000 : 250);
return () => window.clearInterval(id);
}, [playing, total]);
const current = [...lines].reverse().find((l) => (l.at ?? 0) <= pos)?.id;
useEffect(() => {
if (!playing || !current) return;
const el = list.current?.querySelector<HTMLElement>(`[data-line="${current}"]`);
if (el && list.current) list.current.scrollTo({ top: el.offsetTop - list.current.offsetTop - 12, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [current, playing]);
const share = pos / total;
return (
<Stage title={title} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
<Reveal delay={60}>
<div className="rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<div className="flex items-center gap-3 border-b border-border p-4 sm:px-5">
<button type="button" aria-label={playing ? t.pause : t.play} onClick={() => (pos >= total && setPos(0), setPlaying((p) => !p))} className="grid size-11 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]">
<Icon name={playing ? 'pause' : 'play'} size={18} />
</button>
<div className="min-w-0 flex-1">
<div className="relative flex h-10 items-center gap-[2px]" aria-hidden>
{WAVE.map((h, i) => (
<span key={i} className={cn('w-full rounded-full transition-colors duration-200', i / WAVE.length <= share ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]', i % 2 && 'max-sm:hidden')} style={{ height: `${Math.round(h * 100)}%` }} />
))}
</div>
<input type="range" min={0} max={total} value={pos} onChange={(e) => setPos(Number(e.target.value))} aria-label={t.seek} aria-valuetext={`${clock(pos)} / ${clock(total)}`} className="mt-1 h-5 w-full cursor-pointer accent-[var(--primary)]" />
</div>
<span className="w-20 shrink-0 text-right text-[12.5px] tabular-nums text-muted-foreground">
{clock(pos)} / {clock(total)}
</span>
</div>
<ol ref={list} className="kit-scrollbar relative max-h-[26rem] space-y-1 overflow-y-auto p-2 pb-6 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.5rem),transparent)] sm:p-3 sm:pb-6">
{lines.map((l) => {
const on = l.id === current && pos > 0;
const assistant = l.speaker === 'assistant';
return (
<li key={l.id} data-line={l.id}>
<button type="button" aria-label={fill(t.jump, { time: clock(l.at ?? 0) })} onClick={() => setPos(l.at ?? 0)} className={cn('grid w-full grid-cols-[3rem_minmax(0,1fr)] gap-3 rounded-xl px-2 py-2.5 text-left transition-colors duration-300 sm:px-3', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'hover:bg-accent/60')}>
<span className="pt-0.5 text-[12px] tabular-nums text-subtle-foreground">{clock(l.at ?? 0)}</span>
<span className="min-w-0">
<span className={cn('block text-[12px] font-medium', assistant ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-muted-foreground')}>{assistant ? name ?? t.assistant : call?.caller ?? t.caller}</span>
<span className={cn('mt-0.5 block text-pretty text-[14.5px] leading-relaxed', on ? 'text-foreground' : 'text-muted-foreground')}>{l.text}</span>
</span>
</button>
</li>
);
})}
</ol>
</div>
</Reveal>
<Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
{call && (
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-center gap-3">
<span aria-hidden className="grid size-10 place-items-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground">
<Icon name="phone-call" size={18} />
</span>
<div className="min-w-0">
<p className="truncate text-[14.5px] font-medium text-foreground">{call.caller}</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{[call.number, call.date].filter(Boolean).join(' · ')}
</p>
</div>
</div>
{call.outcome && (
<p className="mt-3 flex items-center gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-3 py-2 text-[13px] font-medium text-success-foreground">
<Icon name="check-circle" size={15} />
{call.outcome}
</p>
)}
</div>
)}
{summary.length > 0 && (
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
<p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="sparkle" size={14} className="text-primary" />
{t.summary}
</p>
<Stagger as="ul" step={70} className="divide-y divide-border">
{summary.map((s) => (
<li key={s.label} className="flex justify-between gap-3 py-2 text-[13px]">
<span className="text-muted-foreground">{s.label}</span>
<span className="text-right text-foreground">{s.value}</span>
</li>
))}
</Stagger>
</div>
)}
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* voice-picker */
/* ------------------------------------------------------------------ */
function VoicePicker({ t, title, voices = [], defaultVoice, note, className }: P) {
const locale = useLocale();
const [voice, setVoice] = useState(defaultVoice ?? voices[0]?.id);
const [used, setUsed] = useState(defaultVoice ?? voices[0]?.id);
const [playing, setPlaying] = useState<string | null>(null);
const [speed, setSpeed] = useState(100);
const [pitch, setPitch] = useState(0);
const refs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
if (!playing) return;
const id = window.setTimeout(() => setPlaying(null), 3200);
return () => window.clearTimeout(id);
}, [playing]);
const cur = voices.find((v) => v.id === voice);
const nf = (n: number) => (n / 100).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
const slider = (label: string, value: number, set: (n: number) => void, min: number, max: number, lo: string, hi: string, show: string) => (
<label className="block">
<span className="flex items-baseline justify-between text-[13px]">
<span className="font-medium text-foreground">{label}</span>
<span className="tabular-nums text-muted-foreground">{show}</span>
</span>
<input type="range" min={min} max={max} value={value} onChange={(e) => set(Number(e.target.value))} className="mt-2 h-6 w-full cursor-pointer accent-[var(--primary)]" />
<span className="flex justify-between text-[11.5px] text-subtle-foreground">
<span>{lo}</span>
<span>{hi}</span>
</span>
</label>
);
return (
<Stage title={title} className={className} wide>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start lg:gap-6">
<Stagger role="radiogroup" aria-label={t.voice} step={80} className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{voices.map((v, i) => {
const on = v.id === voice;
const live = playing === v.id;
return (
<div
key={v.id}
ref={(el) => {
refs.current[i] = el;
}}
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setVoice(v.id)}
onKeyDown={(e) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (e.key === ' ' || e.key === 'Enter') (e.preventDefault(), setVoice(v.id));
if (!d) return;
e.preventDefault();
const n = (i + d + voices.length) % voices.length;
setVoice(voices[n].id);
refs.current[n]?.focus();
}}
className={cn('cursor-pointer rounded-2xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]' : 'border-border hover:border-border-strong')}
>
<div className="flex items-start gap-3">
<button
type="button"
aria-label={live ? t.stopPreview : fill(t.preview, { name: v.name })}
onClick={(e) => (e.stopPropagation(), setPlaying(live ? null : v.id))}
className={cn('grid size-11 shrink-0 place-items-center rounded-full transition-colors', live ? 'bg-primary text-primary-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] hover:bg-[color-mix(in_oklab,var(--primary)_20%,var(--card))]')}
>
<Icon name={live ? 'pause' : 'play'} size={17} />
</button>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
{v.name}
{v.tag && <span className="rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] px-1.5 py-px text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_72%,var(--foreground))]">{v.tag}</span>}
</p>
<p className="text-[12.5px] text-muted-foreground">{v.character}</p>
</div>
<span className={cn('grid size-6 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-transparent')}>
<Icon name="check" size={13} strokeWidth={2.4} />
</span>
</div>
<div className="mt-3 flex h-8 items-center gap-[3px]" aria-hidden>
{wave(40, i + 1.4).map((h, k) => (
<span key={k} className={cn('w-full rounded-full', live ? 'vca-bar bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_16%,transparent)]')} style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${(k % 8) * 70}ms` } as React.CSSProperties} />
))}
</div>
<p className={cn('mt-2 text-pretty text-[13px] italic leading-relaxed transition-colors', live ? 'text-foreground' : 'text-muted-foreground')}>{t.quoteOpen}{v.sample}{t.quoteClose}</p>
<p className="mt-2 text-[12px] text-subtle-foreground">{v.languages.join(' · ')}</p>
</div>
);
})}
</Stagger>
<Reveal delay={160} className="lg:sticky lg:top-6">
<div className="space-y-5 rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
<p className="text-[13px] font-medium text-foreground">{cur?.name}</p>
{slider(t.speed, speed, setSpeed, 75, 150, t.slower, t.faster, `${nf(speed)}×`)}
{slider(t.pitch, pitch, setPitch, -4, 4, t.lower, t.higher, pitch > 0 ? `+${pitch}` : String(pitch))}
<button type="button" onClick={() => setUsed(voice)} disabled={used === voice} aria-live="polite" className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-primary text-[13.5px] font-medium text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] disabled:text-success-foreground sm:h-9">
<Icon name={used === voice ? 'check-circle' : 'check'} size={15} />
{used === voice ? t.inUse : t.use}
</button>
</div>
{note && <p className="mt-3 px-1 text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* push-to-talk */
/* ------------------------------------------------------------------ */
function PushToTalk({ t, title, name, lines: initial = [], exchange, note, className }: P) {
const [lines, setLines] = useState(initial);
const [rec, setRec] = useState(false);
const [secs, setSecs] = useState(0);
const [open, setOpen] = useState<string[]>(initial.slice(-1).map((l) => l.id));
const [playing, setPlaying] = useState<string | null>(null);
const seq = useRef(0);
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
if (!rec) return;
setSecs(0);
const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
return () => window.clearInterval(id);
}, [rec]);
useEffect(() => {
if (!playing) return;
const id = window.setTimeout(() => setPlaying(null), 2400);
return () => window.clearTimeout(id);
}, [playing]);
useEffect(() => {
const el = list.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, [lines.length]);
const begin = () => setRec(true);
const finish = () => {
if (!rec) return;
setRec(false);
const a = `u${++seq.current}`;
const b = `a${seq.current}`;
setLines((s) => [...s, { id: a, speaker: 'user', text: exchange?.heard ?? t.heard, duration: Math.max(2, secs) }]);
setOpen((o) => [...o, a]);
window.setTimeout(() => (setLines((s) => [...s, { id: b, speaker: 'assistant', text: exchange?.reply ?? t.reply, duration: 4 }]), setOpen((o) => [...o, b])), prefersReducedMotion() ? 0 : 1100);
};
return (
<Stage title={title} className={className}>
<Reveal effect="scale" delay={60}>
<div className="mx-auto max-w-[26rem] overflow-hidden rounded-[28px] border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<span aria-hidden className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground">
<Icon name="headset" size={17} />
</span>
<p className="text-[14.5px] font-semibold text-foreground">{name}</p>
</div>
<ol ref={list} className="kit-scrollbar relative h-[22rem] space-y-3 overflow-y-auto px-3 py-4">
{lines.map((l) => {
const mine = l.speaker === 'user';
const shown = open.includes(l.id);
const live = playing === l.id;
return (
<li key={l.id} className={cn('vca-up flex', mine ? 'justify-end' : 'justify-start')}>
<div className={cn('w-[82%] rounded-2xl px-3 py-2.5', mine ? 'rounded-br-md bg-primary text-primary-foreground' : 'rounded-bl-md bg-[color-mix(in_oklab,var(--foreground)_6%,var(--card))] text-foreground')}>
<div className="flex items-center gap-2">
<button type="button" aria-label={live ? t.pause : t.play} onClick={() => setPlaying(live ? null : l.id)} className={cn('grid size-11 shrink-0 place-items-center rounded-full sm:size-9', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_20%,transparent)]' : 'bg-card text-foreground shadow-[var(--shadow-sm)]')}>
<Icon name={live ? 'pause' : 'play'} size={15} />
</button>
<span className="flex h-7 min-w-0 flex-1 items-center gap-[2px]" aria-hidden>
{wave(28, l.text.length % 7 + 1).map((h, k) => (
<span key={k} className={cn('w-full rounded-full', mine ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_80%,transparent)]' : 'bg-[color-mix(in_oklab,var(--foreground)_40%,transparent)]', live && 'vca-bar')} style={{ height: `${Math.round(h * 100)}%`, '--vca-d': `${(k % 6) * 80}ms` } as React.CSSProperties} />
))}
</span>
<span className="text-[11.5px] tabular-nums opacity-80">{clock(l.duration ?? 3)}</span>
</div>
{shown && <p className="mt-2 text-pretty text-[13.5px] leading-snug">{l.text}</p>}
<button type="button" onClick={() => setOpen((o) => (shown ? o.filter((x) => x !== l.id) : [...o, l.id]))} className={cn('-mb-1.5 mt-0.5 inline-flex min-h-11 items-center text-[11.5px] font-medium underline-offset-2 hover:underline sm:mb-0 sm:mt-1 sm:min-h-8', mine ? 'text-[color-mix(in_oklab,var(--primary-foreground)_85%,transparent)]' : 'text-muted-foreground')}>
{shown ? t.hideText : t.showText}
</button>
</div>
</li>
);
})}
</ol>
<div className="border-t border-border px-4 pb-5 pt-4 text-center">
<p className={cn('mb-3 text-[13px] font-medium tabular-nums', rec ? 'text-destructive' : 'text-muted-foreground')} aria-live="polite">
{rec ? `${t.recording} ${clock(secs)}` : t.hold}
</p>
<button
type="button"
aria-pressed={rec}
aria-label={rec ? t.release : t.hold}
onPointerDown={(e) => (e.preventDefault(), begin())}
onPointerUp={finish}
onPointerLeave={finish}
onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && !e.repeat && (e.preventDefault(), begin())}
onKeyUp={(e) => (e.key === ' ' || e.key === 'Enter') && finish()}
className={cn('relative mx-auto grid size-20 touch-none select-none place-items-center rounded-full text-primary-foreground shadow-[var(--shadow-lg)] transition-[transform,background-color] duration-200', rec ? 'scale-110 bg-destructive' : 'bg-primary hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]')}
>
{rec && <span aria-hidden className="vca-ring absolute inset-0 rounded-full border-2 border-destructive" />}
<Icon name="mic" size={30} />
</button>
<p className="mt-3 text-[12px] text-subtle-foreground max-sm:hidden">{t.spaceHint}</p>
</div>
</div>
{note && <p className="mx-auto mt-3 max-w-[26rem] px-1 text-center text-[12.5px] text-muted-foreground">{note}</p>}
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* Voice interfaces: a full conversation around an orb that listens, thinks
* and speaks with live captions, the transcript of a call the assistant took
* with playback and summary, choosing a voice with samples, and push to talk
* with voice messages and their text.
*/
export function VoiceAssistant({ variant = 'orb', labels, ...props }: VoiceAssistantProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const key = JSON.stringify(props.lines ?? props.voices ?? null);
const body = (() => {
switch (variant) {
case 'call-transcript':
return <CallTranscript {...p} />;
case 'voice-picker':
return <VoicePicker key={key} {...p} />;
case 'push-to-talk':
return <PushToTalk key={key} {...p} />;
default:
return <OrbView key={key} {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Häufige Fragen
Wie installiere ich Sprachassistent?
Mit aiw add @ai-website/voice-assistant. Der Befehl legt die Datei components/ui/voice-assistant.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 Sprachassistent?
4 Varianten, Sprachgespräch, Anrufmitschrift, Stimme wählen und Halten zum Sprechen. Du wählst sie mit der Prop variant, zum Beispiel variant="call-transcript".
Kann ich die Texte von Sprachassistent übersetzen?
Ja. Sprachassistent bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Hört zu“, „Denkt nach“ und „Spricht“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Sprachassistent einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sprachassistent mit aiw_components und installieren sie mit aiw_add @ai-website/voice-assistant. Nenn im Prompt die Variante, etwa variant="orb", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Sprachassistent ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden VoiceAssistant-Code zum Kopieren.