From fdf65e8623673324a4d340ec72749a795542fc96 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Mon, 29 Jun 2026 23:08:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(fillers):=20Beruhigungss=C3=A4tze=20auch?= =?UTF-8?q?=20im=20Ger=C3=A4te-TTS-Modus=20sprechen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Docs/weg2-tool-calling.md | 15 +++++++++++---- app/api/ws.py | 7 +++++++ app/core/orchestrator.py | 11 ++++++++--- app/web/app.js | 10 ++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Docs/weg2-tool-calling.md b/Docs/weg2-tool-calling.md index 67730e9..bb04e81 100644 --- a/Docs/weg2-tool-calling.md +++ b/Docs/weg2-tool-calling.md @@ -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 diff --git a/app/api/ws.py b/app/api/ws.py index ebdb075..8201c8d 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -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: diff --git a/app/core/orchestrator.py b/app/core/orchestrator.py index a315f51..871a156 100644 --- a/app/core/orchestrator.py +++ b/app/core/orchestrator.py @@ -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) diff --git a/app/web/app.js b/app/web/app.js index 45ae652..2b26ae0 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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;