Feat: Chat-Verbesserungen — echte Umlaute, TTS-Filter, Lautstärke-Regler, Sprechblasen

- Umlaute: System-Prompt erzwingt echte ä/ö/ü/ß (Hermes spiegelte bei Technik den ASCII-Hint).
- TTS-Filter: cleanForTTS entfernt URLs/Markdown/Code/Emojis vor der Sprachausgabe (Anzeige bleibt).
- Lautstärke: GainNode in AudioQueue + Regler (Default 60%, live); Probe-hören respektiert ihn.
  (Bugfix: Number(null)===0 → wäre sonst stumm gewesen.)
- Chat: Sprechblasen (User rechts / Hermes links), Zeilenumbrüche, Auto-Scroll, Tipp-Indikator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 02:44:20 +02:00
parent 49c9e27502
commit d07fe1de66
9 changed files with 396 additions and 306 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BiSwZCwF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
<script type="module" crossorigin src="/assets/index-Sr8SESP7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CH4ZNiiA.css">
</head>
<body>
<div id="root"></div>
@@ -17,8 +17,20 @@ export function VoiceControls() {
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "elevenlabs")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const [volume, setVolume] = useState(() => {
const raw = localStorage.getItem("mc_voice_volume")
if (raw === null || raw === "") return 0.6 // Number(null)===0 → sonst aus Versehen stumm
const v = Number(raw)
return Number.isNaN(v) ? 0.6 : v
})
const previewAudio = useRef<HTMLAudioElement | null>(null)
const onVolume = (v: number) => {
setVolume(v)
localStorage.setItem("mc_voice_volume", String(v))
window.dispatchEvent(new CustomEvent("mc-voice-volume", { detail: v }))
}
const saveVoice = (eng: string, v: string) => {
setEngine(eng); setVoice(v)
localStorage.setItem("mc_voice_engine", eng)
@@ -55,6 +67,7 @@ export function VoiceControls() {
const url = URL.createObjectURL(await r.blob())
previewAudio.current?.pause()
const a = new Audio(url)
a.volume = Math.min(1, volume) // Regler auch für die Probe respektieren
previewAudio.current = a
a.onended = () => URL.revokeObjectURL(url)
await a.play()
@@ -114,6 +127,17 @@ export function VoiceControls() {
{previewing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
{previewing ? "Spielt …" : "Probe hören"}
</button>
{/* Lautstärke */}
<div className="flex items-center gap-2 pt-0.5">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="range" min={0} max={1.2} step={0.05} value={volume}
onChange={(e) => onVolume(Number(e.target.value))}
className="h-1 flex-1 cursor-pointer accent-primary"
title="Lautstärke"
/>
<span className="w-9 text-right text-[11px] tabular-nums text-muted-foreground">{Math.round(volume * 100)}%</span>
</div>
{elKeyMissing && (
<p className="text-[11px] text-amber-300">
Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder keine eigene Stimme angelegt.
+19 -1
View File
@@ -8,6 +8,7 @@
export class AudioQueue {
private ctx: AudioContext
private analyser: AnalyserNode
private gain: GainNode
private queue: ArrayBuffer[] = []
private playing = false
private raf = 0
@@ -16,14 +17,31 @@ export class AudioQueue {
readonly level = { current: 0 }
onSpeaking?: (speaking: boolean) => void
/** Lautstärke 0..1.5 aus localStorage (Default 0.6 — die Stimmen waren zu laut). */
static readVolume(): number {
const raw = localStorage.getItem("mc_voice_volume")
if (raw === null || raw === "") return 0.6 // Number(null)===0 → sonst aus Versehen stumm
const v = Number(raw)
return Number.isNaN(v) ? 0.6 : Math.max(0, Math.min(1.5, v))
}
constructor() {
const Ctor = window.AudioContext || (window as any).webkitAudioContext
this.ctx = new Ctor()
this.analyser = this.ctx.createAnalyser()
this.analyser.fftSize = 256
this.analyser.smoothingTimeConstant = 0.6
this.analyser.connect(this.ctx.destination)
// Kette: Quelle → Analyser (Lippensync liest vollen Pegel) → Gain (Lautstärke) → Ausgang.
this.gain = this.ctx.createGain()
this.gain.gain.value = AudioQueue.readVolume()
this.analyser.connect(this.gain)
this.gain.connect(this.ctx.destination)
this.freq = new Uint8Array(new ArrayBuffer(this.analyser.frequencyBinCount))
// Live-Lautstärke vom Slider.
window.addEventListener("mc-voice-volume", (e: Event) => {
const v = Number((e as CustomEvent).detail)
if (!Number.isNaN(v)) this.gain.gain.value = Math.max(0, Math.min(1.5, v))
})
}
async enqueue(buf: ArrayBuffer) {
+22 -3
View File
@@ -18,7 +18,25 @@ export interface ChatMsg { role: "user" | "assistant"; text: string }
const SYSTEM_PROMPT =
"Du sprichst per Sprache mit dem Nutzer. Antworte natürlich, freundlich und KNAPP in ganzen, " +
"gut vorlesbaren Sätzen. Kein Markdown, keine Codeblöcke, keine Aufzählungszeichen, keine Emojis — " +
"reiner Fließtext, den man laut vorlesen kann."
"reiner Fließtext, den man laut vorlesen kann. Verwende IMMER echte deutsche Umlaute (ä, ö, ü, ß) " +
"und NIEMALS Umschreibungen wie ae, oe, ue oder ss. Nenne möglichst keine langen URLs oder Codebefehle."
// Entfernt, was nicht vorgelesen werden soll (Links, Markdown, Code, Emojis), bevor der Satz ans TTS geht.
// Die Anzeige im Chat bleibt unangetastet — nur die gesprochene Fassung wird gesäubert.
function cleanForTTS(s: string): string {
return s
.replace(/```[\s\S]*?```/g, " ") // Codeblöcke
.replace(/`([^`]*)`/g, "$1") // Inline-Code
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [Text](url) → Text
.replace(/https?:\/\/\S+/gi, " ") // URLs
.replace(/www\.\S+/gi, " ")
.replace(/\b\S+@\S+\.\S+\b/g, " ") // E-Mails
.replace(/[*_#>~|`]+/g, " ") // Markdown-Zeichen
.replace(/^\s*[-•·]\s+/gm, " ") // Listen-Bullets
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "") // Emojis/Symbole
.replace(/\s+/g, " ")
.trim()
}
function getSessionId(): string {
let id = localStorage.getItem("mc_voice_session")
@@ -105,13 +123,14 @@ export function useVoiceAgent() {
let spokeAny = false
let ttsFailed = false
const speak = (sentence: string) => {
if (!sentence.trim()) return
const spoken = cleanForTTS(sentence)
if (!spoken) return // z.B. ein Satz, der nur aus einer URL bestand
ttsChain = ttsChain.then(async () => {
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: sentence, engine, voice }),
body: JSON.stringify({ text: spoken, engine, voice }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
await queue.enqueue(await r.arrayBuffer())
+33 -4
View File
@@ -8,6 +8,21 @@ 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"
// 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 …",
@@ -22,6 +37,12 @@ export function VoiceView() {
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(() => {
@@ -95,7 +116,7 @@ export function VoiceView() {
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 space-y-2.5 overflow-y-auto p-4 scrollbar-thin">
<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
@@ -103,13 +124,21 @@ export function VoiceView() {
</p>
)}
{messages.map((m, i) => (
<div key={i} className={cn("text-sm", m.role === "user" ? "text-foreground" : "text-primary/90")}>
<span className="mr-1.5 text-[10px] font-semibold uppercase text-muted-foreground">
<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>
{m.text || <span className="text-muted-foreground"></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 || <Dots />}
</div>
</div>
))}
<div ref={convEndRef} />
</div>
</div>
</div>