feat(fillers): Beruhigungssätze auch im Geräte-TTS-Modus sprechen

Bisher wurde der Filler im "Im Gerät"-Modus (Web Speech API) nur angezeigt,
aber nicht gesprochen (Server-Audio ist dort aus). Jetzt eigener Kanal:

- Orchestrator: chat_stream bekommt on_filler; _emit_filler bevorzugt den
  dedizierten Filler-Kanal (sonst Fallback on_token). Ephemer wie bisher.
- ws.py: on_filler -> {type:"filler", text}.
- Frontend (app.js): {type:"filler"} -> transient in der STATUS-Zeile (nicht
  in der Antwort-Bubble) und im Geräte-Modus sofort speechSynthesis.speak()
  OHNE cancel (mehrere Filler -> Queue; die Antwort cancelt spaeter und
  uebernimmt). Abgesichert ueber deviceVoicesReady()/deviceVoiceReady(lang) —
  keine Verschaerfung der bekannten deviceVoiceReady-Regression.

Server-TTS-Modus unveraendert (Filler zusaetzlich als "Satz null" gesprochen),
zeigt den Filler nun ebenfalls in der Status-Zeile statt in der Bubble.

Tests: 312 gruen; JS-Syntax geprueft. Doc §5.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-29 23:08:48 +02:00
commit fdf65e8623
4 changed files with 36 additions and 7 deletions

View file

@ -102,10 +102,17 @@ Fallback Englisch.
`FILLER_PATIENCE_INTERVAL` Sekunden einen zufälligen PATIENCE-Satz nachschiebt; er
wird beim ersten Antwort-Delta (Antwort beginnt) und im `finally` abgeräumt.
**Ephemeralität (Invariante):** Filler laufen über den Callback, nicht über den
Delta-Stream — sie landen **nicht** in `trace.semantic_response` und **nicht** im
gespeicherten History-Turn. Server-TTS ist verdrahtet; **„Im Gerät" ist Fast-follow**
(eigener Event-Typ → Browser-`speak()`, Gesten-/Voices-Absicherung).
**Ephemeralität (Invariante):** Filler laufen über den `on_filler`-Callback, nicht
über den Delta-Stream — sie landen **nicht** in `trace.semantic_response` und
**nicht** im gespeicherten History-Turn.
**Kanal:** Der Orchestrator emittiert Filler über `on_filler`; die WS-Schicht sendet
`{type:"filler", text}`. Das Frontend zeigt ihn **transient in der Status-Zeile**
(nicht in der Antwort-Bubble) und spricht ihn im **Geräte-TTS-Modus** sofort per
`speechSynthesis.speak()` **ohne `cancel`** (mehrere Filler laufen in die Queue; die
Antwort `cancelt` später und übernimmt) — abgesichert über `deviceVoicesReady()`/
`deviceVoiceReady(lang)` (keine Verschärfung der bekannten Regression). Im Server-TTS-
Modus wird der Filler zusätzlich als „Satz null" über die Chunk-Queue gesprochen.
### 5.5 Konfiguration & Opt-out
`web_search_enabled: bool = True`**global an per Default**, pro Nutzer/Profil

View file

@ -136,6 +136,12 @@ async def _run_turn(
audio_seq += 1
await websocket.send_bytes(chunk)
# Filler (Beruhigungs-/Geduldssätze) als eigener Event: Status-Zeile + Geräte-TTS.
on_filler = None
if stream or audio_stream:
async def on_filler(filler_text):
await websocket.send_json({"type": "filler", "text": filler_text})
try:
if stream or audio_stream:
trace, audio = await orchestrator.chat_stream(
@ -146,6 +152,7 @@ async def _run_turn(
history=llm_context,
on_token=on_token,
on_audio=on_audio,
on_filler=on_filler,
text_only=text_only,
)
else:

View file

@ -187,6 +187,7 @@ class Orchestrator:
history: list[dict] | None = None,
on_token=None,
on_audio=None,
on_filler=None,
text_only: bool = False,
):
"""Wie chat_text, aber gestreamt.
@ -249,12 +250,16 @@ class Orchestrator:
patience_task: asyncio.Task | None = None
async def _emit_filler(phrase: str) -> None:
"""Ephemer: über on_token (Anzeige) + _dispatch (Server-TTS, "Satz null").
"""Ephemer: dedizierter Filler-Kanal (Anzeige/Status + Geräte-TTS) +
_dispatch (Server-TTS, "Satz null").
Läuft bewusst NICHT über den Token-Stream -> nie in parts/
semantic_response/History.
semantic_response/History. on_filler bevorzugt (eigener Event, damit
Geräte-TTS ihn sprechen kann); sonst Fallback auf on_token (Anzeige).
"""
if on_token:
if on_filler is not None:
await on_filler(phrase)
elif on_token is not None:
await on_token(phrase + " ")
if chunker is not None:
await _dispatch(phrase)

View file

@ -679,6 +679,16 @@ function runTurn(path, onopen) {
case "ack":
routeLang = (msg.route && msg.route.language) || null;
break;
case "filler":
// Beruhigungs-/Geduldssatz: transient in der Status-Zeile (nicht in der
// Antwort-Bubble) und im Geräte-Modus sofort sprechen — OHNE cancel, damit
// mehrere Filler in die SpeechSynthesis-Queue laufen; die Antwort (semantic)
// canceled später und übernimmt.
if (msg.text) statusEl.textContent = msg.text;
if (msg.text && isDeviceMode() && deviceVoicesReady() && deviceVoiceReady(turnLang())) {
try { speechSynthesis.speak(_makeUtterance(msg.text, turnLang())); } catch (e) {}
}
break;
case "audio":
// Ankuendigung: der naechste Binaer-Frame ist ein satzweiser TTS-Chunk.
expectChunk = true;