Files
mission-control-v2/frontend/src/views/VoiceView.tsx
T
Hitonabi 9332971384 Fix: TTS-Sonderzeichen (& → und, % → Prozent, °/=/Schrägstrich) + **fett** im Chat gerendert
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>
2026-06-28 10:16:33 +02:00

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>
)
}