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:
+1
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+294
-294
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user