9332971384
Stimme las '&' u.a. wörtlich/komisch vor → cleanForTTS wandelt Sonderzeichen aussprechbar um. Anzeige rendert **fett** jetzt als echte Fettschrift statt roher Sternchen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
6.8 KiB
TypeScript
156 lines
6.8 KiB
TypeScript
import { useEffect, useRef } from "react"
|
|
import { Mic, RotateCcw, Loader2, Volume2 } from "lucide-react"
|
|
import { Avatar3D } from "@/components/voice/Avatar3D"
|
|
import { VoiceControls } from "@/components/voice/AvatarPicker"
|
|
import { useVoiceAgent } from "@/lib/voice/useVoiceAgent"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// Fester Avatar (das eine, gewählte VRM). Liegt unter frontend/public/avatar.vrm → /avatar.vrm.
|
|
const FIXED_AVATAR = "/avatar.vrm"
|
|
|
|
// **fett** als echte Fettschrift rendern (Rest als Text; Zeilenumbrüche via whitespace-pre-wrap).
|
|
function renderRich(text: string) {
|
|
return text.split(/(\*\*[^*\n]+\*\*)/g).map((p, i) => {
|
|
const m = p.match(/^\*\*([^*]+)\*\*$/)
|
|
return m ? <strong key={i} className="font-semibold text-foreground">{m[1]}</strong> : <span key={i}>{p}</span>
|
|
})
|
|
}
|
|
|
|
// Kleiner Tipp-Indikator („Hermes schreibt …").
|
|
function Dots() {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 align-middle">
|
|
{[0, 1, 2].map((i) => (
|
|
<span
|
|
key={i}
|
|
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground"
|
|
style={{ animationDelay: `${i * 0.15}s` }}
|
|
/>
|
|
))}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
idle: "Bereit — halte zum Sprechen",
|
|
listening: "Höre zu …",
|
|
transcribing: "Verstehe …",
|
|
thinking: "Hermes denkt …",
|
|
speaking: "Hermes spricht …",
|
|
error: "Fehler",
|
|
}
|
|
|
|
export function VoiceView() {
|
|
const { status, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset } =
|
|
useVoiceAgent()
|
|
|
|
const holding = useRef(false)
|
|
const convEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Gespräch automatisch nach unten scrollen, wenn neue Tokens/Nachrichten kommen.
|
|
useEffect(() => {
|
|
convEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" })
|
|
}, [messages])
|
|
|
|
// Push-to-talk per Leertaste (solange der Sprechen-Tab fokussiert ist und kein Eingabefeld aktiv).
|
|
useEffect(() => {
|
|
const isField = (el: EventTarget | null) =>
|
|
el instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
|
|
const down = (e: KeyboardEvent) => {
|
|
if (e.code !== "Space" || e.repeat || holding.current || isField(e.target)) return
|
|
e.preventDefault(); holding.current = true; pressStart()
|
|
}
|
|
const up = (e: KeyboardEvent) => {
|
|
if (e.code !== "Space" || !holding.current) return
|
|
e.preventDefault(); holding.current = false; pressEnd()
|
|
}
|
|
window.addEventListener("keydown", down)
|
|
window.addEventListener("keyup", up)
|
|
return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up) }
|
|
}, [pressStart, pressEnd])
|
|
|
|
const speaking = status === "speaking"
|
|
const busy = status === "transcribing" || status === "thinking"
|
|
|
|
return (
|
|
<div className="flex h-full gap-5">
|
|
{/* Avatar-Bühne */}
|
|
<div className="relative flex min-w-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/30 overflow-hidden">
|
|
<div className="flex-1 min-h-0">
|
|
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} />
|
|
</div>
|
|
|
|
{/* Status + Push-to-talk */}
|
|
<div className="shrink-0 flex flex-col items-center gap-3 border-t border-border/40 bg-background/30 py-5 backdrop-blur-sm">
|
|
<div className={cn(
|
|
"flex items-center gap-2 text-sm",
|
|
status === "error" ? "text-red-400" : speaking ? "text-primary" : "text-muted-foreground",
|
|
)}>
|
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{speaking && <Volume2 className="h-4 w-4 animate-pulse" />}
|
|
<span>{error || STATUS_LABEL[status]}</span>
|
|
</div>
|
|
|
|
<button
|
|
onPointerDown={(e) => { e.preventDefault(); holding.current = true; pressStart() }}
|
|
onPointerUp={() => { if (holding.current) { holding.current = false; pressEnd() } }}
|
|
onPointerLeave={() => { if (holding.current) { holding.current = false; pressEnd() } }}
|
|
className={cn(
|
|
"flex h-20 w-20 items-center justify-center rounded-full border-2 transition-all select-none touch-none",
|
|
recording
|
|
? "border-red-500 bg-red-500/20 scale-110 shadow-lg shadow-red-500/30"
|
|
: "border-primary/60 bg-primary/15 hover:bg-primary/25 hover:scale-105",
|
|
)}
|
|
title="Gedrückt halten zum Sprechen (oder Leertaste halten)"
|
|
>
|
|
<Mic className={cn("h-8 w-8", recording ? "text-red-400" : "text-primary")} />
|
|
</button>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Halten zum Sprechen · <kbd className="rounded bg-muted px-1 py-0.5 font-mono">Leertaste</kbd> geht auch
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Seitenspalte: Einstellungen + Transcript */}
|
|
<div className="flex w-80 shrink-0 flex-col gap-4">
|
|
<div className="rounded-xl border border-border/40 bg-card/40 p-4">
|
|
<VoiceControls />
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/40">
|
|
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Gespräch</span>
|
|
<button onClick={reset} className="text-muted-foreground hover:text-foreground" title="Neues Gespräch">
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 space-y-3 overflow-y-auto p-4 scrollbar-thin">
|
|
{messages.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Halte den Knopf (oder die Leertaste) und sprich. Hermes hört zu, denkt mit seinem
|
|
vollen Gedächtnis und seinen Werkzeugen — und antwortet hörbar.
|
|
</p>
|
|
)}
|
|
{messages.map((m, i) => (
|
|
<div key={i} className={cn("flex flex-col gap-1", m.role === "user" ? "items-end" : "items-start")}>
|
|
<span className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{m.role === "user" ? "Du" : "Hermes"}
|
|
</span>
|
|
<div className={cn(
|
|
"max-w-[88%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm leading-relaxed",
|
|
m.role === "user"
|
|
? "rounded-br-sm bg-primary/15 text-foreground"
|
|
: "rounded-bl-sm border border-border/40 bg-background/40 text-foreground/90",
|
|
)}>
|
|
{m.text ? renderRich(m.text) : <Dots />}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div ref={convEndRef} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|