Compare commits

...
25 Commits
Author SHA1 Message Date
duffyduck 486d7dedbb release: bump version to 0.2.1.2 2026-07-11 22:48:43 +02:00
duffyduckandClaude Opus 4.8 d5bcbb4814 feat(skills): Skill steuert Ausgabe selbst — speak + converse, pro Aufruf
Verallgemeinert die Ausgabesteuerung, dynamisch statt hardcoded:
- Zwei Flags: speak (vorlesen?) + converse (danach 30s weiterlauschen?).
  Getrennt, weil 'vorlesen' und 'Dialog offen halten' verschiedene Dinge sind
  ('was laeuft gerade' → vorlesen JA, aber keine 30s).
- Statischer Manifest-Default (speak/converse) PLUS: der Skill kann beide im
  JSON-Output PRO AUFRUF setzen und den Default ueberschreiben — so kann EIN
  Skill gemischt sein (Spotify: 'next' stumm/stop, 'was laeuft' vorlesen/stop).
- chat() gibt jetzt (reply, answered_by, speak, converse) zurueck; gilt fuer
  Fast-Path (converse immer False), local UND Claude (run_*-Skill setzt beide).
- Brain: _last_skill_flags aus Skill-stdout-JSON, _skill_response_flags mergt
  Output > Manifest > False. main.py ChatOut.converse, background.py angepasst.
- Bridge: converse aus /chat gelesen + in Chat-Payload + _process_core_response.
- App: converseRef aus der Payload; onPlaybackFinished endet mit skipPassive
  wenn converse=false (vorlesen ohne 30s).
- Skill-Bau-Anleitung (skill_create/update-Schema): speak+converse dokumentiert
  MIT dem WARUM, damit ARIA sie beim Bauen sinnvoll setzt (nicht nur mechanisch).
- Prompt-Hardcode fuer Spotify-Faehigkeiten raus → Skills beschreiben sich selbst.

Bestehende Skills ohne Flags = false/false = stumm/stop (kein Verhaltenswechsel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:47:30 +02:00
duffyduckandClaude Opus 4.8 54ebd57990 fix: generischer Skill-Prompt (kein Hardcode) + Stop im 30s-Lauschen beendet
- router.py: die run_spotify-Faehigkeiten NICHT mehr im Prompt aufzaehlen
  (war selbst Hardcoding). Generisch: "run_*-Skills — was sie koennen steht in
  IHRER Tool-Beschreibung, lies + nutze sie fuer alles Passende". Skills
  beschreiben sich selbst (dynamisch, ARIA-authored). Anti-Halluzination bleibt.
- App: Stop-Button waehrend passivem 30s-Lauschen ('listening') beendet jetzt
  sauber (exitPassiveListening) statt via leerem Endpoint den Passiv-Stream neu
  zu starten — die 30s liefen sonst von vorne los.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:36:01 +02:00
duffyduckandClaude Opus 4.8 fb06ed928c fix(local-llm): Anti-Halluzination — behauptete Skill-Aktion ohne Tool → Claude
Local hat "Spotify: überspringe 3 Titel" / "Playlist X abspielen" geantwortet
OHNE run_spotify je zu rufen (Skill-Logs: kein Aufruf) — reine Fabrikation,
real passierte nichts. Zwei Ursachen behoben:
- Prompt: run_spotify-Beschreibung von "next/pause/weiter" auf JEDE
  Musiksteuerung erweitert (Playlist, Gerät, überspringen mehrerer, Lautstärke)
  + harte Anti-Halluzinations-Regel (nie eine Aktion behaupten ohne Tool-Call).
- Guard: sagt local eine Steuerbefehl-Quittung ('Spotify: …', 'Playlist …
  abspielen', 'überspringe … Titel') aber hat KEINEN run_*-Skill gerufen
  (_local_ran_skill=False) → eskalieren, Claude ruft das Tool zuverlässig.

_looks_like_skill_action bewusst eng (Doppelpunkt-Praefix / spezifische Verben),
trifft normale Musik-Konversation nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:30:00 +02:00
duffyduckandClaude Opus 4.8 08bb257791 fix(bridge): Ortsname rechtzeitig da — Geocode vor Präfix-Bau awaiten
Der Reverse-Geocode lief nur async im Hintergrund — die ERSTE Nachricht an
einem Ort (v.a. direkt nach Bridge-Neustart, Cache leer) wurde gebaut BEVOR
der Name fertig war → Präfix ohne Ort → local suchte Wetter mit rohen
Koordinaten und fand nichts.

Neu: _ensure_place_name() awaitet den (laufenden oder frisch gestarteten)
Geocode kurz mit Timeout, bevor der Standort-Präfix gebaut wird. Gecacht →
nur die erste Nachricht an einem neuen Ort zahlt ~0,5s, danach instant.
Timeout/Fehler → Fallback auf reine Koordinaten (kein Hänger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:18:42 +02:00
duffyduckandClaude Opus 4.8 44982a9b3c feat(bridge): exakte Peilung (Grad) zusätzlich zur Himmelsrichtung im Präfix
Neben "nordwestwaerts" jetzt auch die genaue Bearing-Gradzahl (0-359) —
"unterwegs nordwestwaerts (304 Grad)". Die KI nutzt, was die Frage braucht
(Pannenhilfe → Ort/km, Luftfahrt/Navigation → exakte Peilung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:12:38 +02:00
duffyduckandClaude Opus 4.8 ddb1bfac6a feat(bridge): Fahrtrichtung (Himmelsrichtung) im Standort-Präfix
Aus dem permanenten GPS wird die Bewegungsrichtung berechnet: Bearing zwischen
einem Anker-Punkt und der aktuellen Position, gemappt auf 8-Wind-Adverb
(nordwestwaerts …). Der Anker rueckt erst bei echter Bewegung (>25 m) nach,
sonst zaehlt GPS-Jitter im Stand nicht — Stehenbleiben behaelt die letzte
Richtung, Umdrehen liefert automatisch die neue.

Praefix jetzt z.B.:
  [Stefans aktueller Standort: A 28 bei km 55,6, Oldenburg, Niedersachsen
   (GPS 53.12, 8.22), unterwegs nordwestwaerts. ...]

Reiner Bridge-Fix (Haversine + Bearing lokal, kein Dienst noetig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:11:07 +02:00
duffyduckandClaude Opus 4.8 4605698b02 feat(bridge): Standort-Präfix mit Straße+Hausnr, Straßen-Ref + km (Autobahn)
Erweitert den Reverse-Geocode: Nominatim zoom=18 → Straße + Hausnummer, PLZ,
Stadt, Bundesland ("Am Wunderburgpark 6, 26135 Oldenburg, Niedersachsen").
Bei Autobahn/Bundes-/Kreisstraße wird die Ref (A 28 / B 75 / K …, aus
extratags) vorangestellt + best-effort Kilometer via Overpass-Milestone
(highway=milestone, distance-Tag, naechster im Umkreis) → z.B.
"A 28 bei km 55,6, Oldenburg, Niedersachsen". Für Pannenhilfe & Co.

Cache-Raster von ~1km auf ~110m verfeinert (Straßen-Genauigkeit). Overpass
nur wenn's nach Straße/Autobahn aussieht (ref/Autobahn im Namen). Alles
best-effort mit stiller Fallback-Kette (Fehler → gröberer Ort → nur GPS).
NOMINATIM_URL + OVERPASS_URL env → self-hostbar.

Richtung ("Richtung Leer") bewusst weggelassen — nicht zuverlaessig aus
einem Punkt ableitbar (braucht Fahrtrichtung/Routing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:06:15 +02:00
duffyduckandClaude Opus 4.8 1c11cb6a2f feat(bridge): GPS→Ortsname im Standort-Präfix (Reverse-Geocode, keine Tokens)
Das lokale 8B kann Koordinaten nicht zuverlässig in eine Stadt uebersetzen
(riet "Berlin" fuer Oldenburg). Claude kann's, aber das kostet Tokens.
Loesung: die Bridge loest lat/lon EINMAL via Nominatim (OpenStreetMap, gratis,
keyless) auf und schreibt den Namen in den Praefix:
  [Stefans aktueller Standort: Oldenburg, Niedersachsen (GPS 53.12, 8.22). ...]
So muss KEIN Modell mehr raten — local UND Claude lesen den Ort direkt ab,
Token-Ersparnis bleibt voll erhalten.

- _persist_location merkt sich den letzten Ort + triggert den Geocode async
  (im Hintergrund, gecacht pro ~1km-Raster, nur bei Bewegung → Nominatim-
  Rate-Limit locker eingehalten).
- _build_core_text nutzt frische ODER letzte bekannte Position (Praefix faellt
  beim passiven Weiterreden ohne frisches GPS nicht mehr weg) + den Ortsnamen.
- NOMINATIM_URL env (Default oeffentlicher Dienst) → spaeter self-hostbar.
Reiner Bridge-Fix, kein APK/Brain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:02:47 +02:00
duffyduck 6cf75644d8 release: bump version to 0.2.1.1 2026-07-11 21:46:56 +02:00
duffyduckandClaude Opus 4.8 2c1de6705b feat(skills): speak-Flag gilt jetzt auch im Claude-Pfad
Vorher gab Claude immer speak=true zurück — führte Claude einen speak=false-
Steuerbefehl aus (z.B. "spiele auf duffy desktop weiter"), wurde die Antwort
trotzdem vorgelesen + 30s. Jetzt konsistent zu Fast-Path/local: führt Claude
einen run_*-Skill mit speak=false erfolgreich aus → speak=false (kein TTS,
App STOP). Der Text landet aber normal in der Bubble (Stefans Wunsch: Text ok,
nur nicht vorlesen). Antwort-Skills (speak=true) + reine Konversation bleiben
gesprochen; bei Skill-Fehler bleibt's gesprochen (Erklaerung soll man hoeren).

Reiner Brain-Fix — App liest speak bereits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:45:27 +02:00
duffyduckandClaude Opus 4.8 2716bc62ff feat(skills): Skill entscheidet selbst ob vorgelesen wird (manifest.speak)
Verallgemeinert das "run_* → stumm"-Heuristik: jeder Skill deklariert im
Manifest ein speak-Flag.
- speak=false (Default) = Steuerbefehl (Spotify, Licht) → kein TTS, App
  beendet direkt (STOP), wie bisher.
- speak=true = Antwort-Skill (Info/Ergebnis) → Antwort wird vorgelesen +
  Gespraechs-Fenster bleibt offen.

Gilt für BEIDE Pfade: Fast-Path (_fast_path_speak aus skill.speak) und
lokale Skill-Ausführung (_local_turn_speak = _skill_speak_flag(run_*)).
Info-Tools (web_search/memory_search/trigger_timer) bleiben gesprochen.

- skills.py: speak in create_skill + update_skill-allowed.
- agent.py: skill_create/skill_update Tool-Schema dokumentiert speak (damit
  ARIA es beim Skill-Bau setzen kann), Dispatch reicht es durch.
- App: _isSilent nur noch speak===false (kein answeredBy=fast-path-Fallback
  mehr, sonst waere ein speak=true-Fast-Path faelschlich stumm).

Bestehende Skills ohne Feld = false = stumm (kein Verhaltenswechsel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:39:55 +02:00
duffyduckandClaude Opus 4.8 a9e1a46a2f feat(local-llm): Skill-Ausführung via local = kein TTS (speak=False)
Wenn das lokale LLM einen echten Skill (run_*, z.B. Spotify) ausführt, ist es
ein Steuerbefehl — genau wie Fast-Path. Jetzt speak=False: kein Vorlesen, und
die App beendet direkt (STOP) statt ins 30s-Gespräch zu gehen. Deckt den Fall
ab, wo Whisper sich verhört ("Nächster Slick") und local statt Fast-Path
den Skill auffängt.

Info-Tools (web_search/memory_search/trigger_timer) zählen NICHT — deren
Antwort/Bestätigung bleibt gesprochen. Reiner Brain-Fix: die App liest speak
bereits (2b48e5c), kein neuer APK nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:33:17 +02:00
duffyduck 245dfc4d73 release: bump version to 0.2.1.0 2026-07-11 21:10:02 +02:00
duffyduckandClaude Opus 4.8 6a94b574f2 fix(app): Mund-Button stoppt laufendes Vorlesen wirklich (Cache bleibt)
Zwei Fehler: (1) War der PCM-Stream schon komplett empfangen (isFinal durch),
sind pcmStreamActive+isPlaying false — der AudioTrack spielt aber seinen
Buffer noch sekundenlang aus. stopPlayback() returnte dann früh ("nichts
aktiv") und rief PcmStreamPlayer.stop() NIE → Mund-Button wirkungslos.
(2) stopPlayback() wirft pcmBuffer weg → die Cache-WAV waere unvollstaendig,
Nachhoeren via Lautsprecher-Symbol kaputt.

Neue _silenceAudibleOutput(): stoppt den AudioTrack IMMER (auch im Drain-Fall),
laesst aber pcmBuffer/pcmMessageId/pcmStreamActive stehen — restliche Chunks
cachen stumm weiter, isFinal schreibt die vollstaendige WAV. setMuted(true)
nutzt das statt stopPlayback(). stopPlayback bleibt fuer Barge-In/Cancel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:07:41 +02:00
duffyduckandClaude Opus 4.8 a5e2256a44 feat(app): Wake-Word-Empfindlichkeit + Weiterreden-Fenster in Settings
Fehlauslösung im Auto: Spotify läuft über die Lautsprecher, das Mikro hört
mit, openWakeWord halluziniert "computer" rein (Log: wake.detect state=armed
→ leerer Transkript). Der Echo-Canceler kann nur ARIAs eigenes TTS
rausrechnen, nicht Spotify (fremde App, kein Referenzsignal).

- Wake-Word-Threshold jetzt konfigurierbar (loadWakeThreshold), Default von
  0.5 auf 0.6 hoch (strenger → weniger Fehlauslösung). Slider im Wake-Word-
  Settings-Bereich (0.30–0.90, greift beim "Speichern + Aktivieren").
- Weiterreden-Fenster (passives Lauschen) als Slider, Default 30s (10–60s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:01:48 +02:00
duffyduckandClaude Opus 4.8 9fb29aa517 fix(voice): klarer Befehl = STOP, Gespraech = 30s passiv (kein zweiter Gong)
Nach dem Re-Arm-Fix rief die App resume() → das spielte einen zweiten Gong
und oeffnete ein neues Aufnahme-Fenster, obwohl Stefan nur einen Steuerbefehl
gab. Sein gewuenschtes Verhalten:
- Klarer Befehl (Fast-Path, speak=false, z.B. Liedersteuerung) = KEINE
  Konversation → STOP: direkt zurueck aufs Wake-Word (kein Gong, keine
  Aufnahme, kein 30s-Fenster).
- Gespraech (gesprochene Antwort) = kein neuer Gong, direkt 30s passives
  Lauschen (weiterreden wie mit einem Menschen), 30s still → Wake-Word.

endConversation(skipPassive) neu: true springt direkt zu armed statt in
passives Lauschen. onPlaybackFinished (Gespraech) → passiv (Vordergrund) bzw.
direkt armed (Hintergrund); stille Fast-Path-Antwort → skipPassive; manueller
Stop-Button → skipPassive. resume() bleibt nur noch Mic-Fail-Retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:52:04 +02:00
duffyduck a0494e90ee release: bump version to 0.2.0.9 2026-07-11 20:42:25 +02:00
duffyduckandClaude Opus 4.8 2b48e5cac6 fix(voice): Ohr re-armt nach Fast-Path-Befehl (kein TTS → kein Re-Arm-Trigger)
Fast-Path-Antworten sind speak=False → kein TTS → onPlaybackFinished feuert
nie. Das Wake-Word-Re-Arm haengt aber genau daran → nach "nächster Titel"
blieb das Ohr grau in 'conversing' stecken (Stefan im Auto, 2 Min gewartet,
kein Re-Arm). Log bestaetigt: nach stream.final kam kein wake.end/wake.start.

- Bridge: chat-Payload traegt jetzt 'speak' (war nur answeredBy).
- App: bei stiller Antwort (speak=false ODER answeredBy=fast-path) und
  laufender Konversation dieselbe Re-Arm-Logik wie bei TTS-Ende anstossen
  (aktiv → resume/Konversationsfenster, sonst → endConversation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:40:57 +02:00
duffyduck aefdff89dc release: bump version to 0.2.0.8 2026-07-11 20:33:12 +02:00
duffyduckandClaude Opus 4.8 f99e90f524 fix(voice): Doppel-Ausfuehrung — identischen STT-Endpoint-Text <5s deduppen
Im Auto cancelt der App-seitige No-Speech-Watchdog den conversing-Stream
vorzeitig (Whisper-Partials kommen bei Fahrgeraeusch verspaetet) und startet
passives Lauschen, waehrend die Bridge dieselbe Aeusserung parallel noch
finalisiert. Ergebnis: derselbe Befehl ("nächstes lied") wird zweimal
ausgefuehrt — aus zwei verschiedenen audioRequestIds, darum griff die
bestehende ID-Idempotenz nicht.

Text-Fenster-Dedup: identischer normalisierter Endpoint-Text innerhalb 5s
wird verworfen. Serverseitig, kein APK noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:30:37 +02:00
duffyduckandClaude Opus 4.8 1dd47888a8 fix(app): STT-Endpoint großzügiger + Mikro vor Re-Arm freigeben
#2 Vorzeitiges Absenden: endpointMs war hart 1500ms — im Auto (Sprechpausen)
schnitt das mitten im Satz ab (die 11.8s-Frage wurde bei "…ohne dass ein"
gekappt). Jetzt konfigurierbar (aria_stt_endpoint_ms, Default 2400, 1000-4000).

#3 Ohr bleibt ausgegraut: beim Re-Arm rief der Wake-Word-Service
OpenWakeWord.start(), waehrend die passive Streaming-Aufnahme noch das Mikro
hielt → start() schlug fehl → state=off. Neuer micReleaseHook cancelt die
Aufnahme VOR start() (endConversation / exitPassiveListening /
discardIfFreshlyTriggered). ChatScreen registriert den Hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:29:04 +02:00
duffyduckandClaude Opus 4.8 8b22557ca5 fix(fast-path): Voice-Befehle matchen wieder (GPS-Praefix, Umlaute, Kommas)
Voice-Nachrichten tragen den GPS-Hint-Praefix der Bridge
("[Stefans aktuelle GPS-Position: ...] nächstes lied bitte") und echte
Umlaute + Whisper-Kommas. Die ^...$-verankerten fast_patterns matchten damit
NIE — jeder Voice-Spotify-Befehl ging unnoetig an local/Claude (Tokens +
Latenz + TTS statt 0-Token-Fast-Path, was das Doppel-Ausfuehren mit anheizte).

_normalize_for_fast_match: fuehrende [ ... ]-Hint-Bloecke strippen, Umlaute
falten (ä→ae …), interne Satzzeichen (Komma/Punkt) raus. Pattern wird gleich
gefaltet. Router strippt den Praefix ebenfalls (Laengen-/Hint-Heuristik).

Wirkung: "nächstes lied bitte", "play", "nächster titel" -> Fast-Path
(instant, speak=False). "spiele auf duffy desktop weiter" bleibt Router-Sache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:25:26 +02:00
duffyduckandClaude Opus 4.8 bd78f384a0 fix(diagnostic): Verstecken im Kontext-Streifen (Main-Tab) statt nur Settings
Das Auge/Toggle war faelschlich nur in der vertikalen Projektliste unter
Einstellungen — Stefan managed Projekte aber ueber die Ordner-Bubbles im
Kontext-Streifen auf dem Main-Tab, und der filterte hidden gar nicht
(verstecktes Projekt blieb sichtbar, kein Auge).

renderContextStrip: versteckte Projekte standardmaessig raus, 🙈/👁-Auge pro
Bubble (eigenes onclick + stopPropagation, wechselt nicht den Kontext),
"versteckte anzeigen (N)"-Toggle-Chip am Ende; setStripProjectHidden patcht
+ raeumt Focus wenn noetig. project_changed refresht jetzt Streifen UND Liste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:18:07 +02:00
duffyduckandClaude Opus 4.8 14bdd4dbf4 fix(diagnostic): no-store fuer index.html — Browser servierte alte Version
Die /-Response hatte keinen Cache-Control-Header. Der Browser cachte das
grosse Single-HTML-Dashboard heuristisch und servierte trotz (soft) Reload
die alte Inline-JS/CSS — neue Features (Projekte-verstecken-Button, Auge,
Token-Ersparnis-Card) tauchten erst nach Hard-Reload auf. Jetzt no-store +
no-cache, so dass jeder Load die frische Version bekommt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:15:34 +02:00
14 changed files with 836 additions and 46 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20007 versionCode 20102
versionName "0.2.0.7" versionName "0.2.1.2"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.0.7", "version": "0.2.1.2",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+55 -12
View File
@@ -49,7 +49,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload'; import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText'; import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio'; import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs } from '../services/audio';
import Geolocation from '@react-native-community/geolocation'; import Geolocation from '@react-native-community/geolocation';
// --- Typen --- // --- Typen ---
@@ -338,6 +338,10 @@ const ChatScreen: React.FC = () => {
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert, // bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
// oder AsyncStorage-Load nicht beruecksichtigt). // oder AsyncStorage-Load nicht beruecksichtigt).
const ttsCanPlayRef = useRef<boolean>(true); const ttsCanPlayRef = useRef<boolean>(true);
// Letzte Antwort: nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const messageIdCounter = useRef(0); const messageIdCounter = useRef(0);
@@ -562,6 +566,14 @@ const ChatScreen: React.FC = () => {
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt) // Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => { useEffect(() => {
wakeWordService.loadFromStorage().catch(() => {}); wakeWordService.loadFromStorage().catch(() => {});
// Mikro-Release-Hook: vor jedem Re-Arm eine laufende (passive) Streaming-
// Aufnahme canceln, sonst haelt sie das Mikro und OpenWakeWord.start()
// schlaegt fehl → Ohr bleibt ausgegraut (state=off).
wakeWordService.setMicReleaseHook(async () => {
if (audioService.isStreamingRecording()) {
await audioService.cancelStreamingRecording('rearm-mic-free');
}
});
const unsub = wakeWordService.onStateChange((s) => { const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s); setWakeWordState(s);
setWakeWordActive(s !== 'off'); setWakeWordActive(s !== 'off');
@@ -1195,6 +1207,26 @@ const ChatScreen: React.FC = () => {
clearAckTimer(cmid); clearAckTimer(cmid);
pendingPayloads.current.delete(cmid); pendingPayloads.current.delete(cmid);
} }
// Stille Antwort (speak=false, z.B. Fast-Path „nächster Titel") → es
// kommt KEIN TTS, also feuert onPlaybackFinished nie. Genau daran haengt
// aber das Wake-Word-Re-Arm — sonst bleibt das Ohr nach dem Steuerbefehl
// grau haengen (Reproduktion Stefan im Auto). Hier dieselbe Logik wie
// bei TTS-Ende anstossen: aktiv → Konversations-Fenster (resume),
// sonst → Konversation beenden (re-arm).
// Stumm = die Bridge sagt speak=false (Steuerbefehl-Skill via Fast-Path
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
// Merken ob nach dem Vorlesen 30s weiterlauschen (Gespraech) oder direkt
// stoppen — onPlaybackFinished liest converseRef. Default true.
converseRef.current = (message.payload as any).converse !== false;
const _isSilent = (message.payload as any).speak === false;
if (_isSilent && wakeWordService.isConversing()) {
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
// kein 30s-Fenster (skipPassive=true).
wakeWordService.endConversation(true).catch(() => {});
}
} }
// TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
@@ -1423,12 +1455,13 @@ const ChatScreen: React.FC = () => {
useEffect(() => { useEffect(() => {
const unsubPlayback = audioService.onPlaybackFinished(() => { const unsubPlayback = audioService.onPlaybackFinished(() => {
if (!wakeWordService.isActive()) return; if (!wakeWordService.isActive()) return;
if (AppState.currentState === 'active') { // Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
wakeWordService.resume(); // sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
} else { // Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)'); // converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
wakeWordService.endConversation().catch(() => {}); // ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
} const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
}); });
return () => unsubPlayback(); return () => unsubPlayback();
}, []); }, []);
@@ -1465,7 +1498,7 @@ const ChatScreen: React.FC = () => {
interrupted: wasInterrupted, interrupted: wasInterrupted,
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: windowMs,
endpointMs: 1500, endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000, hardCapMs: 60000,
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
@@ -1561,7 +1594,7 @@ const ChatScreen: React.FC = () => {
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen" interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null, location: location || null,
noSpeechTimeoutMs: windowMs, noSpeechTimeoutMs: windowMs,
endpointMs: 1500, endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000, hardCapMs: 60000,
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
@@ -1618,7 +1651,7 @@ const ChatScreen: React.FC = () => {
interrupted: false, interrupted: false,
location: location || null, location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000), noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: 1500, endpointMs: await loadSttEndpointMs(),
hardCapMs: Math.max(passiveMs + 5000, 35000), hardCapMs: Math.max(passiveMs + 5000, 35000),
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
@@ -2028,7 +2061,7 @@ const ChatScreen: React.FC = () => {
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper // Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
// die Session auch app-seitig haben wir +2s Toleranz. // die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0, noSpeechTimeoutMs: 0,
endpointMs: 1500, endpointMs: await loadSttEndpointMs(),
hardCapMs: 300000, hardCapMs: 300000,
projectId: focusedProjectIdRef.current, projectId: focusedProjectIdRef.current,
}); });
@@ -2046,10 +2079,20 @@ const ChatScreen: React.FC = () => {
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume. // endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
const handleVoiceButtonStop = useCallback(async (): Promise<void> => { const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
// Stop WAEHREND des passiven 30s-Lauschens ('listening'): sauber beenden
// (zurueck aufs Wake-Word), NICHT den passiven Stream neu starten.
// exitPassiveListening cancelt den Stream selbst (via _freeMic) → es feuert
// kein leeres Endpoint, das startPassiveStreamingRecording erneut ausloest
// (genau das liess die 30s vorher von vorne loslaufen).
if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Manueller Stop im Passiv-Lauschen → exitPassiveListening (Ende)');
await wakeWordService.exitPassiveListening('manual');
return;
}
await audioService.stopStreamingRecording('user'); await audioService.stopStreamingRecording('user');
if (wakeWordService.isConversing()) { if (wakeWordService.isConversing()) {
console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed'); console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
await wakeWordService.endConversation(); await wakeWordService.endConversation(true); // explizit gestoppt → STOP (kein 30s-Passiv)
} }
}, []); }, []);
+79
View File
@@ -105,6 +105,14 @@ import wakeWordService, {
KEYWORD_LABELS, KEYWORD_LABELS,
DEFAULT_KEYWORD, DEFAULT_KEYWORD,
WAKE_KEYWORD_STORAGE, WAKE_KEYWORD_STORAGE,
WAKE_THRESHOLD_DEFAULT,
WAKE_THRESHOLD_MIN,
WAKE_THRESHOLD_MAX,
loadWakeThreshold,
saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS,
loadPassiveListenMs,
savePassiveListenMs,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
@@ -200,6 +208,8 @@ const SettingsScreen: React.FC = () => {
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD); const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true); const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000));
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -322,6 +332,8 @@ const SettingsScreen: React.FC = () => {
if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved); if (saved && (WAKE_KEYWORDS as readonly string[]).includes(saved)) setWakeKeyword(saved);
}); });
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1862,6 +1874,40 @@ const SettingsScreen: React.FC = () => {
))} ))}
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Empfindlichkeit</Text>
<Text style={styles.toggleHint}>
Wie leicht das Wake-Word anspringt. Hoeher = strenger = weniger
Fehlauslösung (z.B. durch Musik/Radio, die das Mikro mithoert der
Echo-Canceler kann nur ARIAs eigene Stimme rausrechnen, nicht Spotify),
aber du musst evtl. deutlicher sprechen. Default: {WAKE_THRESHOLD_DEFAULT.toFixed(2)}.
Wird beim Speichern + Aktivieren" uebernommen.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(WAKE_THRESHOLD_MIN, Math.round((wakeThreshold - 0.05) * 100) / 100);
setWakeThreshold(next);
saveWakeThreshold(next);
}}
disabled={wakeThreshold <= WAKE_THRESHOLD_MIN}
>
<Text style={styles.prerollButtonText}>0.05</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{wakeThreshold.toFixed(2)}</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(WAKE_THRESHOLD_MAX, Math.round((wakeThreshold + 0.05) * 100) / 100);
setWakeThreshold(next);
saveWakeThreshold(next);
}}
disabled={wakeThreshold >= WAKE_THRESHOLD_MAX}
>
<Text style={styles.prerollButtonText}>+0.05</Text>
</TouchableOpacity>
</View>
<View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}> <View style={{flexDirection: 'row', gap: 8, marginTop: 16, alignItems: 'center'}}>
<TouchableOpacity <TouchableOpacity
style={[styles.connectButton, {flex: 1}]} style={[styles.connectButton, {flex: 1}]}
@@ -1907,6 +1953,39 @@ const SettingsScreen: React.FC = () => {
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'} thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
/> />
</View> </View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort.
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(10, passiveSec - 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec <= 10}
>
<Text style={styles.prerollButtonText}>5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{passiveSec} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(60, passiveSec + 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec >= 60}
>
<Text style={styles.prerollButtonText}>+5</Text>
</TouchableOpacity>
</View>
</View> </View>
</>)} </>)}
+49 -1
View File
@@ -151,6 +151,26 @@ export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0; export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec'; export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
// im Satz ab, besonders im Auto wo man mit Pausen spricht (Reproduktion: die
// 11.8s-Frage wurde bei "…ohne dass ein" gekappt). 1500 war zu aggressiv;
// 2400 default, im Auto ggf. hoeher. Konfigurierbar in den Settings.
export const STT_ENDPOINT_DEFAULT_MS = 2400;
export const STT_ENDPOINT_MIN_MS = 1000;
export const STT_ENDPOINT_MAX_MS = 4000;
export const STT_ENDPOINT_STORAGE_KEY = 'aria_stt_endpoint_ms';
export async function loadSttEndpointMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
if (raw != null) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= STT_ENDPOINT_MIN_MS && n <= STT_ENDPOINT_MAX_MS) return n;
}
} catch {}
return STT_ENDPOINT_DEFAULT_MS;
}
// TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die // TTS-Wiedergabegeschwindigkeit — wird pro Geraet gespeichert und an die
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal. // Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
export const TTS_SPEED_DEFAULT = 1.0; export const TTS_SPEED_DEFAULT = 1.0;
@@ -1711,11 +1731,39 @@ class AudioService {
this._stoppedMessageId = activeMsgId; this._stoppedMessageId = activeMsgId;
console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId); console.log('[Audio] Antwort %s als gestoppt markiert', activeMsgId);
} }
this.stopPlayback(); // NUR die hoerbare Wiedergabe stoppen — Cache-Buffer behalten, damit die
// Nachricht spaeter ueber das Lautsprecher-Symbol nachgehoert werden kann.
this._silenceAudibleOutput();
} }
} }
isMuted(): boolean { return this._muted; } isMuted(): boolean { return this._muted; }
/** Mund-Button: laufendes Vorlesen SOFORT verstummen lassen, aber den
* PCM-Cache NICHT verwerfen (der Stream cached zu Ende → Nachhoeren via
* Lautsprecher-Symbol bleibt moeglich). Unterschied zu stopPlayback():
* - stopt den AudioTrack IMMER (auch wenn pcmStreamActive schon false ist,
* weil der Stream fertig empfangen wurde aber der AudioTrack seinen Buffer
* noch sekundenlang ausspielt — genau da war der Mund-Button wirkungslos),
* - laesst pcmBuffer/pcmMessageId/pcmStreamActive stehen (Cache laeuft weiter). */
private _silenceAudibleOutput(): void {
console.log('[Audio] _silenceAudibleOutput: AudioTrack+WAV stoppen, Cache behalten');
this.audioQueue = [];
this.isPlaying = false;
if (this.currentSound) {
try { this.currentSound.stop(); this.currentSound.release(); } catch {}
this.currentSound = null;
}
if (this.resumeSound) {
try { this.resumeSound.stop(); this.resumeSound.release(); } catch {}
this.resumeSound = null;
}
// AudioTrack IMMER hart stoppen (idempotent) — auch im Drain-Fall.
PcmStreamPlayer?.stop().catch(() => {});
stopBackgroundAudio().catch(() => {});
this._cancelDeferredFocusRelease();
AudioFocus?.release().catch(() => {});
}
/** Laufende Wiedergabe stoppen + Queue leeren */ /** Laufende Wiedergabe stoppen + Queue leeren */
stopPlayback(): void { stopPlayback(): void {
// Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/ // Idempotent: wenn nichts mehr aktiv ist, NICHT noch einen Focus-Release/
+57 -5
View File
@@ -53,6 +53,30 @@ export async function savePassiveListenMs(ms: number): Promise<void> {
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword'; export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
// Wake-Word-Empfindlichkeit (openWakeWord-Threshold). Hoeher = strenger =
// weniger Fehlauslösung (z.B. durch Musik/Radio ueber die Auto-Lautsprecher,
// die das Mikro mithoert — der App-Echo-Canceler kann nur ARIAs eigenes TTS
// rausrechnen, NICHT Spotify). Default 0.6 (war 0.5). 0..1.
export const WAKE_THRESHOLD_DEFAULT = 0.6;
export const WAKE_THRESHOLD_MIN = 0.3;
export const WAKE_THRESHOLD_MAX = 0.9;
export const WAKE_THRESHOLD_STORAGE_KEY = 'aria_wake_threshold';
export async function loadWakeThreshold(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(WAKE_THRESHOLD_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= WAKE_THRESHOLD_MIN && n <= WAKE_THRESHOLD_MAX) return n;
}
} catch {}
return WAKE_THRESHOLD_DEFAULT;
}
export async function saveWakeThreshold(v: number): Promise<void> {
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
}
/** Verfuegbare Wake-Words entsprechen den .onnx Dateien in /** Verfuegbare Wake-Words entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut * Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -76,8 +100,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
hey_rhasspy: 'Hey Rhasspy', hey_rhasspy: 'Hey Rhasspy',
}; };
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden. // Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
const DEFAULT_THRESHOLD = 0.5; // (loadWakeThreshold) — der Wert hier ist nur der Fallback.
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
const DEFAULT_PATIENCE = 2; const DEFAULT_PATIENCE = 2;
const DEFAULT_DEBOUNCE_MS = 1500; const DEFAULT_DEBOUNCE_MS = 1500;
@@ -132,6 +157,12 @@ class WakeWordService {
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */ * hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
private passiveListenCallbacks: PassiveListenCallback[] = []; private passiveListenCallbacks: PassiveListenCallback[] = [];
/** Hook, der das Mikro freigibt (laufende Streaming-Aufnahme canceln) BEVOR
* wir OpenWakeWord.start() rufen. Ohne das haelt die passive/conversing
* Aufnahme das Mikro noch, start() schlaegt fehl Ohr bleibt ausgegraut
* (state=off). ChatScreen registriert den Hook mit audioService.cancel. */
private micReleaseHook: (() => Promise<void>) | null = null;
private keyword: WakeKeyword = DEFAULT_KEYWORD; private keyword: WakeKeyword = DEFAULT_KEYWORD;
private nativeReady: boolean = false; private nativeReady: boolean = false;
private initInProgress: Promise<boolean> | null = null; private initInProgress: Promise<boolean> | null = null;
@@ -149,6 +180,19 @@ class WakeWordService {
} }
} }
/** ChatScreen registriert hier einen Hook, der eine laufende Streaming-
* Aufnahme cancelt (Mikro freigeben) wird vor jedem Re-Arm gerufen. */
setMicReleaseHook(fn: (() => Promise<void>) | null): void {
this.micReleaseHook = fn;
}
private async _freeMic(): Promise<void> {
if (!this.micReleaseHook) return;
try { await this.micReleaseHook(); } catch (e) {
console.warn('[WakeWord] micReleaseHook err:', e);
}
}
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */ /** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
async configure(keyword: string): Promise<boolean> { async configure(keyword: string): Promise<boolean> {
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword) const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
@@ -178,7 +222,9 @@ class WakeWordService {
if (this.initInProgress) return this.initInProgress; if (this.initInProgress) return this.initInProgress;
this.initInProgress = (async () => { this.initInProgress = (async () => {
try { try {
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); const threshold = await loadWakeThreshold();
console.log('[WakeWord] init mit threshold=%s', threshold);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal // Subscribe nur einmal
if (!this.eventSub) { if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
@@ -422,7 +468,10 @@ class WakeWordService {
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true * wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
* ist, und unseren frisch re-armierten Listener killen. * ist, und unseren frisch re-armierten Listener killen.
*/ */
async endConversation(): Promise<void> { /** @param skipPassive true = KEIN passives Lauschen, direkt zurueck aufs
* Wake-Word (armed). Fuer klare Steuerbefehle (Fast-Path) nach
* "nächster Titel" will Stefan kein 30s-Fenster, sondern Stop. */
async endConversation(skipPassive: boolean = false): Promise<void> {
if (this.state !== 'conversing') { if (this.state !== 'conversing') {
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called but state=${this.state} → noop`)).catch(()=>{}); `endConversation called but state=${this.state} → noop`)).catch(()=>{});
@@ -442,7 +491,7 @@ class WakeWordService {
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute // (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
// Anrede weitersprechen. // Anrede weitersprechen.
const passiveMs = await loadPassiveListenMs(); const passiveMs = await loadPassiveListenMs();
if (passiveMs > 0 && this.nativeReady) { if (!skipPassive && passiveMs > 0 && this.nativeReady) {
this.enterPassiveListening(passiveMs); this.enterPassiveListening(passiveMs);
return; return;
} }
@@ -454,6 +503,7 @@ class WakeWordService {
// als state extra zu fragen, garantiert dass nach diesem Pfad // als state extra zu fragen, garantiert dass nach diesem Pfad
// Native auch wirklich an ist falls es out-of-band gestoppt wurde. // Native auch wirklich an ist falls es out-of-band gestoppt wurde.
try { try {
await this._freeMic(); // Streaming-Aufnahme canceln → Mikro frei
await OpenWakeWord.start(); await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge); console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
import('./logger').then(m => m.reportAppDebug('wake.end', import('./logger').then(m => m.reportAppDebug('wake.end',
@@ -520,6 +570,7 @@ class WakeWordService {
// timeout oder manual → Wake-Word reaktivieren, armed-State. // timeout oder manual → Wake-Word reaktivieren, armed-State.
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { try {
await this._freeMic(); // passive Streaming-Aufnahme canceln → Mikro frei
await OpenWakeWord.start(); await OpenWakeWord.start();
console.log('[WakeWord] zurueck zu armed nach passive-listen'); console.log('[WakeWord] zurueck zu armed nach passive-listen');
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT); ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
@@ -564,6 +615,7 @@ class WakeWordService {
this.lastTriggerAt = 0; this.lastTriggerAt = 0;
if (this.nativeReady && OpenWakeWord) { if (this.nativeReady && OpenWakeWord) {
try { try {
await this._freeMic(); // ggf. laufende Aufnahme canceln → Mikro frei
await OpenWakeWord.start(); await OpenWakeWord.start();
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT); ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
this.setState('armed'); this.setState('armed');
+214 -8
View File
@@ -234,6 +234,43 @@ META_TOOLS = [
"\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]" "\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]"
), ),
}, },
"speak": {
"type": "boolean",
"description": (
"STATISCHER Default fuers Vorlesen dieses Skills (TTS). "
"Default false.\n\n"
"WARUM es das gibt: ARIA ist voice-first. Ein reiner "
"STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT "
"vorgelesen werden — die Aktion selbst ist die Bestaetigung, "
"Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert "
"eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, "
"'was laeuft gerade'). Also: Kommando-Skill=false, "
"Antwort-Skill=true.\n\n"
"PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill "
"beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = "
"vorlesen), kann dein run.py im JSON-Output pro Aufruf "
"`speak` (und `converse`, s.u.) setzen — das ueberschreibt "
"diesen statischen Default. Beispiel-Output:\n"
" next -> {\"ok\":true,\"speak\":false,\"converse\":false}\n"
" was_laeuft -> {\"ok\":true,\"speak\":true,\"converse\":false,"
"\"reply\":\"Laeuft: …\"}"
),
},
"converse": {
"type": "boolean",
"description": (
"Soll ARIA NACH dieser Skill-Antwort 30s WEITERLAUSCHEN "
"(Dialog), oder direkt zurueck aufs Wake-Word? Default false.\n\n"
"WARUM getrennt von speak: 'vorlesen' und 'weiterreden "
"koennen' sind zwei verschiedene Dinge. 'was laeuft gerade' "
"soll vorgelesen werden (speak=true), aber es ist eine "
"abgeschlossene Einzel-Info — Stefan will danach NICHT ins "
"30s-Mikrofon-Fenster gezwungen werden (converse=false). Nur "
"wenn eine echte Rueckfrage/ein Dialog sinnvoll ist "
"(converse=true). Am besten pro Aufruf im run.py-Output "
"setzen (dynamisch), nicht statisch."
),
},
}, },
"required": ["name", "description", "entry_code"], "required": ["name", "description", "entry_code"],
}, },
@@ -310,6 +347,23 @@ META_TOOLS = [
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt." "wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
), ),
}, },
"speak": {
"type": "boolean",
"description": (
"Statischer Vorlese-Default (siehe skill_create). false = "
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen. Pro "
"Aufruf via run.py-JSON-Output ueberschreibbar."
),
},
"converse": {
"type": "boolean",
"description": (
"Statischer Default: nach der Antwort 30s weiterlauschen "
"(Dialog=true) oder direkt zurueck aufs Wake-Word (false, "
"Default). Siehe skill_create. Pro Aufruf via Output "
"ueberschreibbar."
),
},
}, },
"required": ["name"], "required": ["name"],
}, },
@@ -1018,10 +1072,53 @@ META_TOOLS = [
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim # Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
# skill_create / skill_update, das Brain orchestriert nur. # skill_create / skill_update, das Brain orchestriert nur.
def _strip_leading_hint_blocks(text: str) -> str:
"""Entfernt fuehrende Hint-Bloecke `[ ... ]`, die die Bridge an
Voice-Nachrichten haengt (GPS-Position, Barge-In-Hinweis). Ohne das matcht
KEIN Voice-Befehl je den Fast-Path (Regex ist ^...$-verankert) selbst
'nächstes lied' landete unnoetig bei Claude statt beim 0-Token-Fast-Path."""
s = (text or "").strip()
prev = None
while prev != s:
prev = s
s = re.sub(r"^\s*\[[^\]]*\]\s*", "", s)
return s
def _fold_umlauts(s: str) -> str:
"""ä→ae, ö→oe, ü→ue, ß→ss (lowercase erwartet). Whisper liefert echte
Umlaute ('Nächstes Lied'), viele Skill-fast_patterns sind aber in ae/oe/ue
geschrieben ohne Faltung matcht das nie."""
return (s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
.replace("ß", "ss"))
def _looks_like_skill_action(text: str) -> bool:
"""Erkennt eine BEHAUPTETE Steuerbefehl-Quittung (v.a. Spotify) im Reply.
Genutzt fuer den Anti-Halluzinations-Guard: sagt local sowas, hat aber KEIN
Werkzeug gerufen, ist real nichts passiert eskalieren. Bewusst eng, um
normale Konversation ueber Musik nicht zu treffen."""
t = (text or "").strip().lower()
if not t:
return False
if t.startswith("spotify:") or t.startswith("spotify -") or t.startswith("musik:"):
return True
if "playlist" in t and ("abspiel" in t or "spiele" in t or "starte" in t):
return True
if ("ueberspring" in t or "überspring" in t) and ("titel" in t or "lied" in t or "song" in t):
return True
return False
def _normalize_for_fast_match(text: str) -> str: def _normalize_for_fast_match(text: str) -> str:
norm = (text or "").strip().lower() norm = _strip_leading_hint_blocks(text).lower()
norm = re.sub(r"[.!?]+$", "", norm) norm = _fold_umlauts(norm)
norm = re.sub(r"\s+", " ", norm) # Interne Satzzeichen raus, die Whisper einstreut ('Nächstes Lied, bitte.').
# Kommas/Semikola/Doppelpunkte → Space, Punkt/!/? ganz weg. Sonst matcht das
# ^...$-verankerte fast_pattern nie, weil das Komma dazwischenfunkt.
norm = re.sub(r"[,;:]", " ", norm)
norm = re.sub(r"[.!?]+", "", norm)
norm = re.sub(r"\s+", " ", norm).strip()
return norm return norm
@@ -1117,6 +1214,9 @@ class Agent:
rx = pat.get("match") or "" rx = pat.get("match") or ""
if not rx: if not rx:
continue continue
# Pattern GLEICH falten wie den Text — egal ob der Skill-Autor
# 'naechstes' oder 'nächstes' geschrieben hat.
rx = _fold_umlauts(rx)
try: try:
if not re.match(rx, norm, re.IGNORECASE): if not re.match(rx, norm, re.IGNORECASE):
continue continue
@@ -1125,6 +1225,8 @@ class Agent:
continue continue
args = pat.get("args") or {} args = pat.get("args") or {}
reply = pat.get("reply") or f"{skill_name}: ok" reply = pat.get("reply") or f"{skill_name}: ok"
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl).
self._fast_path_speak = bool(skill.get("speak", False))
logger.info("[fast-path] match skill=%s pattern=%r msg=%r", logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
skill_name, rx, user_message[:60]) skill_name, rx, user_message[:60])
try: try:
@@ -1192,11 +1294,60 @@ class Agent:
break break
return tools return tools
def _resolve_skill_manifest(self, tname: str) -> Optional[dict]:
"""run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping)."""
if not tname.startswith("run_"):
return None
suffix = tname[len("run_"):]
m = skills_mod.read_manifest(suffix)
if m is None:
for cand in skills_mod.list_skills(active_only=False):
cn = cand.get("name") or ""
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
m = cand
break
return m
def _skill_speak_flag(self, tname: str) -> bool:
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False)."""
if not tname.startswith("run_"):
return True
return bool((self._resolve_skill_manifest(tname) or {}).get("speak", False))
def _skill_response_flags(self, tname: str) -> tuple:
"""(speak, converse) fuer einen gerade ausgefuehrten run_*-Skill.
Prioritaet: JSON-Output des Skills (self._last_skill_flags, pro Aufruf) >
Manifest-Default > False. So kann EIN Skill pro Operation unterschiedlich
sein 'next' stumm/stop, 'was laeuft' vorlesen aber trotzdem stop."""
flags = getattr(self, "_last_skill_flags", None) or {}
m = self._resolve_skill_manifest(tname) or {}
speak = bool(m.get("speak", False))
converse = bool(m.get("converse", False))
if isinstance(flags.get("speak"), bool):
speak = flags["speak"]
if isinstance(flags.get("converse"), bool):
converse = flags["converse"]
return speak, converse
def _try_local_fast_lane(self, user_message: str, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config() cfg = router_mod.load_config()
if not router_mod.should_try_local(user_message, cfg): if not router_mod.should_try_local(user_message, cfg):
return None return None
# Vorlesen ja/nein fuer diesen lokalen Turn. Default True (Info-Antwort).
# Fuehrt local einen Skill (run_*) aus, uebernimmt dessen speak-Flag aus
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
self._local_turn_speak = True
# Nach der Antwort 30s weiterlauschen? Default True (Gespraech/Info via
# web_search). Ein run_*-Skill setzt es auf seinen Output-Wert (Default
# False = Einzelaktion).
self._local_turn_converse = True
# Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen?
# Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill-
# Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene
# Bestaetigung durchzulassen.
self._local_ran_skill = False
local_only = bool(cfg.get("localOnly")) local_only = bool(cfg.get("localOnly"))
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
tools = self._build_local_tools() # B1b: kuratierte Tools tools = self._build_local_tools() # B1b: kuratierte Tools
@@ -1244,6 +1395,13 @@ class Agent:
logger.info("[router] lokal Tool-Call: %s(%s)", tname, logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys())) ", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs) tresult = self._dispatch_tool(tname, targs)
# Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
self._local_turn_speak, self._local_turn_converse = \
self._skill_response_flags(tname)
self._local_ran_skill = True
if self._local_tool_failed(tname, tresult): if self._local_tool_failed(tname, tresult):
had_error = True had_error = True
messages.append({"role": "tool", messages.append({"role": "tool",
@@ -1289,6 +1447,14 @@ class Agent:
logger.info("[router] lokal eskaliert → Claude") logger.info("[router] lokal eskaliert → Claude")
return None return None
# Anti-Halluzination: local behauptet manchmal eine Musik-/Skill-Aktion
# ('Spotify: …', 'Playlist … abspielen'), OHNE run_spotify gerufen zu
# haben → es ist real nichts passiert. Dann eskalieren: Claude ruft das
# Tool zuverlaessig. (Echte Skill-Ausfuehrung → _local_ran_skill=True.)
if not self._local_ran_skill and _looks_like_skill_action(final):
logger.info("[router] lokal: Skill-Aktion behauptet ohne Tool-Call → Claude")
return None
logger.info("[router] lokal beantwortet (%d Zeichen)", len(final)) logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
self.conversation.add("assistant", final, project_id=active_project_id) self.conversation.add("assistant", final, project_id=active_project_id)
return final return final
@@ -1338,9 +1504,11 @@ class Agent:
metrics.log_fast_path(fast_reply) metrics.log_fast_path(fast_reply)
except Exception: except Exception:
pass pass
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False). # Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt. # Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
return fast_reply, "fast-path", False speak = bool(getattr(self, "_fast_path_speak", False))
# Fast-Path = reiner Steuerbefehl → nie ins 30s-Gespraech (converse=False).
return fast_reply, "fast-path", speak, False
# 1. User-Turn an die Konversation # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source, self.conversation.add("user", user_message, source=source,
@@ -1354,7 +1522,11 @@ class Agent:
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id) local_reply = self._try_local_fast_lane(user_message, active_project_id)
if local_reply is not None: if local_reply is not None:
return local_reply, "local", True # ARIA-Antwort → vorlesen ok # speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
return local_reply, "local", speak, converse
# 2. Hot Memory (alle pinned Punkte) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1470,6 +1642,13 @@ class Agent:
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen # zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann. # zu 400-Errors fuehren kann.
final_reply = "" final_reply = ""
# Vorlesen ja/nein fuer diesen Claude-Turn. Default True (Gespraech).
# Fuehrt Claude einen Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# aus, wird die Antwort NICHT vorgelesen — der Text landet aber normal in
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
# Konversation bleiben gesprochen.
self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
try: try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools, result = self.proxy.chat_full(messages, tools=tools,
@@ -1487,6 +1666,12 @@ class Agent:
# Tools ausfuehren + Ergebnis als role=tool zurueck # Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls: for tc in result.tool_calls:
tool_result = self._dispatch_tool(tc["name"], tc["arguments"]) tool_result = self._dispatch_tool(tc["name"], tc["arguments"])
# Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
_tn = tc.get("name") or ""
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
self._claude_turn_speak, self._claude_turn_converse = \
self._skill_response_flags(_tn)
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer # Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war # Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists # viel zu wenig: Spotify _all=true mit 90 Playlists
@@ -1555,7 +1740,10 @@ class Agent:
# 7. Assistant-Turn (final reply) in die Conversation # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) project_id=active_project_id)
return final_reply, "claude", True # ARIA-Antwort → vorlesen ok # speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)))
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -1576,6 +1764,8 @@ class Agent:
pip_packages=arguments.get("pip_packages", []), pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None, config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None, fast_patterns=arguments.get("fast_patterns") or None,
speak=bool(arguments.get("speak", False)),
converse=bool(arguments.get("converse", False)),
author="aria", author="aria",
) )
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1635,6 +1825,10 @@ class Agent:
for k in ("entry_code", "readme", "description", "args", "active"): for k in ("entry_code", "readme", "description", "args", "active"):
if k in arguments and arguments[k] is not None: if k in arguments and arguments[k] is not None:
patch[k] = arguments[k] patch[k] = arguments[k]
if "speak" in arguments and arguments["speak"] is not None:
patch["speak"] = bool(arguments["speak"])
if "converse" in arguments and arguments["converse"] is not None:
patch["converse"] = bool(arguments["converse"])
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list): if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
patch["pip_packages"] = arguments["pip_packages"] patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list): if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
@@ -1736,6 +1930,18 @@ class Agent:
skill_name = cand_name skill_name = cand_name
break break
res = skills_mod.run_skill(skill_name, args=arguments) res = skills_mod.run_skill(skill_name, args=arguments)
# Steuer-Flags aus dem Skill-JSON-Output (speak/converse) fuer
# DIESEN Aufruf merken. Der Aufrufer (local/claude/fast-path)
# liest sie und steuert damit Vorlesen (speak) + 30s-Weiter-
# lauschen (converse) — pro Aufruf, dynamisch. Kein JSON / nicht
# gesetzt → Aufrufer nimmt Manifest-Default (speak) bzw. False.
self._last_skill_flags = None
if res.get("ok"):
try:
_j = json.loads((res.get("stdout") or "").strip())
self._last_skill_flags = _j if isinstance(_j, dict) else None
except Exception:
self._last_skill_flags = None
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB, # 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA # da wurde der Track-Name regelmaessig abgeschnitten und ARIA
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte # hat aus dem Album-Kontext halluziniert. Claude kann hunderte
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try: try:
agent = agent_factory() agent = agent_factory()
reply, _, _ = agent.chat(prompt, source="trigger") reply, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events() events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
+5 -1
View File
@@ -636,6 +636,9 @@ class ChatOut(BaseModel):
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False; # Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag. # ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
speak: bool = True speak: bool = True
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
converse: bool = True
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -719,7 +722,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
reply, answered_by, speak = await loop.run_in_executor( reply, answered_by, speak, converse = await loop.run_in_executor(
None, None,
lambda: a.chat( lambda: a.chat(
body.message, source=body.source, project_id=pid, body.message, source=body.source, project_id=pid,
@@ -743,6 +746,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
project_id=pid, project_id=pid,
answered_by=answered_by, answered_by=answered_by,
speak=speak, speak=speak,
converse=converse,
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+22 -2
View File
@@ -87,6 +87,17 @@ _HARD_HINTS = re.compile(
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude _MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
def _strip_leading_hint_blocks(text: str) -> str:
"""Fuehrende `[ ... ]`-Hint-Bloecke (GPS, Barge-In von der Bridge) weg —
sonst blaeht der Praefix die Laenge auf und verfaelscht die Heuristik."""
s = (text or "").strip()
prev = None
while prev != s:
prev = s
s = re.sub(r"^\s*\[[^\]]*\]\s*", "", s)
return s
def should_try_local(user_message: str, cfg: dict) -> bool: def should_try_local(user_message: str, cfg: dict) -> bool:
"""True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll. """True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll.
localOnly überschreibt die Heuristik (dann IMMER lokal).""" localOnly überschreibt die Heuristik (dann IMMER lokal)."""
@@ -94,7 +105,7 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
return False return False
if cfg.get("localOnly"): if cfg.get("localOnly"):
return True return True
msg = (user_message or "").strip() msg = _strip_leading_hint_blocks(user_message)
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL: if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False return False
if _TOOL_HINTS.search(msg): if _TOOL_HINTS.search(msg):
@@ -142,10 +153,19 @@ def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
"GPS-Hinweis in der Nachricht.", "GPS-Hinweis in der Nachricht.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).", "- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').", "- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
"- `run_spotify`: Musik steuern (naechster Titel, Pause, weiter).", "- `run_*`-Skills: konkrete Faehigkeiten (z.B. Musik/Spotify u.a.). "
"Was ein Skill kann + welche Parameter er nimmt, steht in SEINER "
"Tool-Beschreibung — LIES sie und nutze den Skill fuer ALLES was "
"dazu passt (nicht nur die offensichtlichen Faelle). Nie selbst eine "
"Aktion 'spielen'/'nachschauen', wenn ein Skill das kann.",
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) " "WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem " "KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
"Bedarf; erfinde keine.", "Bedarf; erfinde keine.",
"ANTI-HALLUZINATION (kritisch): Behaupte NIEMALS eine Aktion als "
"erledigt, ohne das Werkzeug WIRKLICH aufgerufen zu haben. Keine "
"'Spotify: …' / 'Playlist abspielen'-Quittung o.ae. ohne echten "
"Skill-Aufruf. Kannst/willst du es nicht per Werkzeug tun, sag es "
"ehrlich oder eskaliere — erfinde keine Bestaetigung.",
] ]
parts += [ parts += [
"", "",
+16 -1
View File
@@ -165,6 +165,8 @@ def create_skill(
author: str = "aria", author: str = "aria",
config_schema: Optional[list] = None, config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None, fast_patterns: Optional[list] = None,
speak: bool = False,
converse: bool = False,
) -> dict: ) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs. """Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -215,6 +217,14 @@ def create_skill(
"author": author, "author": author,
"config_schema": _normalize_config_schema(config_schema), "config_schema": _normalize_config_schema(config_schema),
"fast_patterns": _normalize_fast_patterns(fast_patterns), "fast_patterns": _normalize_fast_patterns(fast_patterns),
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen.
# converse: nach der Antwort 30s weiterlauschen (Dialog)? Default False
# (Einzelaktion). Beide sind STATISCHE Defaults — der Skill kann sie im
# JSON-Output pro Aufruf ueberschreiben (gemischte Skills).
"speak": bool(speak),
"converse": bool(converse),
"version_history": [], "version_history": [],
} }
write_manifest(name, manifest) write_manifest(name, manifest)
@@ -335,10 +345,15 @@ def update_skill(name: str, patch: dict) -> dict:
# nach archive_current_version manifest neu laden (version_history geupdatet) # nach archive_current_version manifest neu laden (version_history geupdatet)
manifest = read_manifest(name) or manifest manifest = read_manifest(name) or manifest
allowed = {"description", "args", "requires", "active", "version", "entry"} allowed = {"description", "args", "requires", "active", "version", "entry",
"speak", "converse"}
for k, v in patch.items(): for k, v in patch.items():
if k in allowed: if k in allowed:
manifest[k] = v manifest[k] = v
if "speak" in patch:
manifest["speak"] = bool(patch["speak"])
if "converse" in patch:
manifest["converse"] = bool(patch["converse"])
if "config_schema" in patch: if "config_schema" in patch:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"]) manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
if "fast_patterns" in patch: if "fast_patterns" in patch:
+259 -7
View File
@@ -16,6 +16,7 @@ import asyncio
import base64 import base64
import json import json
import logging import logging
import math
import mimetypes import mimetypes
import os import os
import re import re
@@ -32,6 +33,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
import subprocess import subprocess
import urllib.parse
import urllib.request import urllib.request
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
@@ -370,6 +372,37 @@ def clean_text_for_tts(text: str) -> str:
return t.strip() return t.strip()
# ── Geo: Bewegungsrichtung aus zwei GPS-Punkten ──────────────
# Fahrtrichtung als Himmelsrichtungs-Adverb (8-Wind, "…waerts"). Index =
# gerundeter Bearing / 45 (mod 8).
_COMPASS_ADV = ["nordwaerts", "nordostwaerts", "ostwaerts", "suedostwaerts",
"suedwaerts", "suedwestwaerts", "westwaerts", "nordwestwaerts"]
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz in Metern zwischen zwei GPS-Punkten."""
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Kompass-Peilung 0..360 (0=Nord) von Punkt1 nach Punkt2."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dl = math.radians(lon2 - lon1)
y = math.sin(dl) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
def _heading_word(bearing: float) -> str:
return _COMPASS_ADV[round(bearing / 45.0) % 8]
# ── STT Engine ─────────────────────────────────────────────── # ── STT Engine ───────────────────────────────────────────────
@@ -1175,8 +1208,28 @@ class ARIABridge:
"lat": float(lat), "lat": float(lat),
"lon": float(lon), "lon": float(lon),
}) })
lat, lon = float(lat), float(lon)
# Fuer den Standort-Praefix merken (auch fuer Turns ohne frisches GPS).
self._last_location = {"lat": lat, "lon": lon}
# Fahrtrichtung aus dem Bewegungsvektor. Anker bleibt stehen bis wir
# uns >MIN_MOVE_M wirklich wegbewegt haben — so zaehlt echtes Fahren,
# nicht das GPS-Jitter im Stand. Stehen wir → letzte Richtung bleibt.
# Umdrehen liefert automatisch neue Punkte → neue Richtung.
MIN_MOVE_M = 25.0
anchor = getattr(self, "_heading_anchor", None)
if anchor:
moved = _haversine_m(anchor["lat"], anchor["lon"], lat, lon)
if moved >= MIN_MOVE_M:
_bg = _bearing_deg(anchor["lat"], anchor["lon"], lat, lon)
self._last_heading = _heading_word(_bg)
self._last_bearing = int(round(_bg)) % 360 # exakte Peilung
self._heading_anchor = {"lat": lat, "lon": lon}
else:
self._heading_anchor = {"lat": lat, "lon": lon}
except Exception: except Exception:
return return
# Ortsname im Hintergrund aufloesen (Nominatim, gecacht) — kein Modell.
self._maybe_reverse_geocode(float(lat), float(lon))
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet # Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
# oder langsam ist, blockt das nicht den GPS-Pfad. # oder langsam ist, blockt das nicht den GPS-Pfad.
try: try:
@@ -1184,6 +1237,150 @@ class ARIABridge:
except Exception: except Exception:
pass pass
def _maybe_reverse_geocode(self, lat: float, lon: float) -> None:
"""Loest lat/lon → Ortsname auf, aber nur bei nennenswerter Bewegung
(~1 km Raster) und im Hintergrund. Ergebnis landet in
self._last_place_name; der Standort-Praefix liest es dann nur ab."""
try:
key = (round(lat, 3), round(lon, 3)) # ~110 m — fuer Strasse/Hausnr
except Exception:
return
if key == getattr(self, "_geocoded_key", None):
return # diese Gegend schon aufgeloest
# Neue Gegend: alten Namen verwerfen, sonst zeigt der Praefix die falsche
# Stadt bis der neue Geocode da ist.
self._geocoded_key = key
self._last_place_name = ""
try:
self._geocode_task = asyncio.create_task(self._do_reverse_geocode(lat, lon, key))
except Exception:
self._geocode_task = None
async def _ensure_place_name(self, lat, lon) -> None:
"""Stellt (blockierend, mit Timeout) sicher, dass der Ortsname fuer die
aktuelle Position da ist fuer die ERSTE Nachricht an einem neuen Ort,
wo der Hintergrund-Geocode noch laeuft. Gecacht danach instant."""
try:
lat, lon = float(lat), float(lon)
key = (round(lat, 3), round(lon, 3))
except Exception:
return
if key == getattr(self, "_geocoded_key", None) and getattr(self, "_last_place_name", ""):
return # schon aufgeloest
if key != getattr(self, "_geocoded_key", None):
self._maybe_reverse_geocode(lat, lon) # startet Task
task = getattr(self, "_geocode_task", None)
if task is not None and not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=6)
except Exception:
pass # Timeout/Fehler → Praefix faellt auf reine Koordinaten zurueck
async def _do_reverse_geocode(self, lat: float, lon: float, key) -> None:
"""Nominatim-Reverse-Geocode (keyless, gratis). Baut einen moeglichst
genauen Ort: Strasse+Hausnummer, PLZ+Stadt, Bundesland; bei Autobahn/
Bundesstrasse zusaetzlich die Ref (A 28 / B 75) + best-effort Kilometer
(Overpass-Milestone). Setzt self._last_place_name. Fehler still
(Praefix faellt auf reine Koordinaten zurueck)."""
base = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
url = (f"{base}/reverse?lat={lat:.5f}&lon={lon:.5f}&format=jsonv2"
f"&zoom=18&addressdetails=1&extratags=1&accept-language=de")
def _fetch():
try:
req = urllib.request.Request(url, headers={
# Nominatim verlangt einen aussagekraeftigen User-Agent.
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
})
with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.debug("[geo] reverse-geocode fehlgeschlagen: %s", exc)
return None
data = await asyncio.get_event_loop().run_in_executor(None, _fetch)
if not isinstance(data, dict):
return
addr = data.get("address") or {}
extra = data.get("extratags") or {}
road = addr.get("road") or ""
house = addr.get("house_number") or ""
city = (addr.get("city") or addr.get("town") or addr.get("village")
or addr.get("municipality") or addr.get("county") or "")
plz = addr.get("postcode") or ""
state = addr.get("state") or ""
ref = (extra.get("ref") or "").strip() # z.B. "A 28", "B 75"
# Strasse + Hausnummer
street = (f"{road} {house}".strip()) if road else ""
# PLZ + Ort
citypart = (f"{plz} {city}".strip()) if plz else city
segs: list[str] = []
# Autobahn/Bundes-/Kreisstrasse? Ref + (best-effort) Kilometer voranstellen.
is_road = bool(ref) or any(w in road.lower()
for w in ("autobahn", "bundesstrasse", "bundesstraße",
"kreisstrasse", "kreisstraße"))
if is_road:
label = ref or road
km = await self._overpass_km(lat, lon)
if km:
label = f"{label} bei km {km}"
if label:
segs.append(label)
if street:
segs.append(street)
if citypart:
segs.append(citypart)
if state and (state not in citypart):
segs.append(state)
name = ", ".join(s for s in segs if s)
if not name:
name = data.get("name") or ""
# Nur uebernehmen wenn Stefan nicht schon weitergezogen ist (key aktuell).
if name and getattr(self, "_geocoded_key", None) == key:
self._last_place_name = name
logger.info("[geo] %.5f,%.5f%s", lat, lon, name)
async def _overpass_km(self, lat: float, lon: float) -> str:
"""Best-effort Autobahn/Strassen-Kilometer: naechsten OSM-Milestone
(highway=milestone mit distance-Tag) im Umkreis suchen. Leer wenn keiner
gefunden / Overpass nicht erreichbar. Milestones stehen i.d.R. alle 500 m,
also grob (fuer Pannenhilfe 'ca. km X' voellig ausreichend)."""
ov = os.environ.get("OVERPASS_URL", "https://overpass-api.de/api/interpreter")
query = (f"[out:json][timeout:8];"
f"node(around:900,{lat:.5f},{lon:.5f})[highway=milestone];out;")
def _fetch():
try:
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
req = urllib.request.Request(ov, data=body, headers={
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
})
with urllib.request.urlopen(req, timeout=12) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.debug("[geo] overpass-km fehlgeschlagen: %s", exc)
return None
d = await asyncio.get_event_loop().run_in_executor(None, _fetch)
if not isinstance(d, dict):
return ""
best_dist = ""
best_sq = 1e18
for e in (d.get("elements") or []):
tags = e.get("tags") or {}
dist = tags.get("distance") or tags.get("milestone:distance") or ""
elat, elon = e.get("lat"), e.get("lon")
if not dist or elat is None or elon is None:
continue
sq = (float(elat) - lat) ** 2 + (float(elon) - lon) ** 2
if sq < best_sq:
best_sq = sq
best_dist = str(dist)
return best_dist.replace(".", ",") if best_dist else ""
async def _trigger_brain_check_now(self) -> None: async def _trigger_brain_check_now(self) -> None:
"""Brain-Endpoint POST /triggers/check-now anstossen.""" """Brain-Endpoint POST /triggers/check-now anstossen."""
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
@@ -1366,6 +1563,14 @@ class ARIABridge:
"projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "", "projectId": (payload.get("projectId") or "") if isinstance(payload, dict) else "",
# Quell-Backend (local/claude/fast-path) fuer den Diagnostic-Badge. # Quell-Backend (local/claude/fast-path) fuer den Diagnostic-Badge.
"answeredBy": (payload.get("answeredBy") or "") if isinstance(payload, dict) else "", "answeredBy": (payload.get("answeredBy") or "") if isinstance(payload, dict) else "",
# Wird diese Antwort vorgelesen? Fast-Path/Steuerbefehl = False.
# Die App braucht das: ohne TTS feuert onPlaybackFinished nie,
# und daran haengt das Wake-Word-Re-Arm — sonst bleibt das Ohr
# nach einem Fast-Path-Befehl grau haengen.
"speak": bool(payload.get("speak", True)) if isinstance(payload, dict) else True,
# Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs-
# Fenster in der App (Skill/Einzelaktion=False, Konversation=True).
"converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True,
}, },
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -1552,14 +1757,33 @@ class ARIABridge:
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, " "gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]" "Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
) )
if location and isinstance(location, dict): # Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
lat = location.get("lat") # (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
lon = location.get("lon") or location.get("lng") loc = location if (isinstance(location, dict) and location.get("lat") is not None) \
else getattr(self, "_last_location", None)
if isinstance(loc, dict):
lat = loc.get("lat")
lon = loc.get("lon") or loc.get("lng")
if lat is not None and lon is not None: if lat is not None and lon is not None:
# Ortsname kommt vom Reverse-Geocode (Nominatim, gecacht) — so
# muss KEIN Modell Koordinaten raten (das lokale 8B tippt sonst
# daneben, z.B. "Berlin" fuer Oldenburg). Noch nicht aufgeloest
# → nur Koordinaten (Claude kann die zur Not selbst deuten).
place = getattr(self, "_last_place_name", "")
where = f"{place} " if place else ""
heading = getattr(self, "_last_heading", "")
bearing = getattr(self, "_last_bearing", None)
if heading and bearing is not None:
moving = f", unterwegs {heading} ({bearing} Grad)"
elif heading:
moving = f", unterwegs {heading}"
else:
moving = ""
parts.append( parts.append(
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. " f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. " f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]" f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
f"nicht von dir aus, ausser er fragt explizit danach.]"
) )
if parts: if parts:
return " ".join(parts) + " " + text return " ".join(parts) + " " + text
@@ -1707,6 +1931,9 @@ class ARIABridge:
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag # Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text. # vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
speak = data.get("speak", True) speak = data.get("speak", True)
# Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs
# Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus.
converse = data.get("converse", True)
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # damit sie in der UI vor der Reply auftauchen
@@ -1775,7 +2002,8 @@ class ARIABridge:
try: try:
await self._process_core_response(reply, {"projectId": turn_project_id, await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by, "answeredBy": answered_by,
"speak": speak}) "speak": speak,
"converse": converse})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)
@@ -2082,6 +2310,9 @@ class ARIABridge:
logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'", logger.info("[rvs] App-Chat (mit Anhaengen) project=%s: '%s'",
str(payload.get("projectId") or "") or "(main)", text[:80]) str(payload.get("projectId") or "") or "(main)", text[:80])
else: else:
_lg = location or getattr(self, "_last_location", None)
if isinstance(_lg, dict) and _lg.get("lat") is not None:
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
core_text = self._build_core_text(text, interrupted, location) core_text = self._build_core_text(text, interrupted, location)
logger.info("[rvs] App-Chat%s%s: '%s'", logger.info("[rvs] App-Chat%s%s: '%s'",
" [BARGE-IN]" if interrupted else "", " [BARGE-IN]" if interrupted else "",
@@ -3006,6 +3237,24 @@ class ARIABridge:
if self._is_duplicate_client_msg(client_msg_id): if self._is_duplicate_client_msg(client_msg_id):
return return
# Text-Fenster-Dedup: im Auto cancelt der App-No-Speech-Watchdog den
# Stream vorzeitig und startet passives Lauschen, waehrend die Bridge
# dieselbe Aeusserung parallel noch finalisiert → derselbe Befehl
# kaeme zweimal (aus ZWEI verschiedenen audioRequestIds, darum greift
# die ID-Idempotenz oben nicht). Identischen Endpoint-Text innerhalb
# 5s verwerfen. Genuines "nächstes, nächstes" liegt praktisch immer
# weiter auseinander (man hoert den Wechsel erst).
_norm_txt = " ".join(text.lower().split())
_now_t = asyncio.get_event_loop().time()
if (_norm_txt
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
text[:60])
return
self._last_endpoint_norm = _norm_txt
self._last_endpoint_at = _now_t
# App-Focus aus stt_stream_start-Registry auflösen (falls die # App-Focus aus stt_stream_start-Registry auflösen (falls die
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat). # App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
stream_req_id = payload.get("requestId", "") or "" stream_req_id = payload.get("requestId", "") or ""
@@ -3161,6 +3410,9 @@ class ARIABridge:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e) logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
# Dann an Brain — der blockt synchron bis ARIA fertig ist. # Dann an Brain — der blockt synchron bis ARIA fertig ist.
_lg = location or getattr(self, "_last_location", None)
if isinstance(_lg, dict) and _lg.get("lat") is not None:
await self._ensure_place_name(_lg.get("lat"), _lg.get("lon") or _lg.get("lng"))
core_text = self._build_core_text(text, interrupted, location) core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text, await self.send_to_core(core_text,
source="app-voice" + (" [barge-in]" if interrupted else ""), source="app-voice" + (" [barge-in]" if interrupted else ""),
+66 -4
View File
@@ -1424,6 +1424,7 @@
let focusedContextId = localStorage.getItem('diag_focused_context_id') || ''; let focusedContextId = localStorage.getItem('diag_focused_context_id') || '';
let diagQueueStatus = {}; let diagQueueStatus = {};
let diagProjectsCache = []; let diagProjectsCache = [];
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
function updateChatVisibilityByFocus() { function updateChatVisibilityByFocus() {
for (const box of [chatBox, document.getElementById('chat-box-fs')]) { for (const box of [chatBox, document.getElementById('chat-box-fs')]) {
@@ -1464,20 +1465,79 @@
if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` }; if (s.queue_size > 0) return { color: '#FFD60A', label: `Queue: ${s.queue_size}` };
return { color: '#34C759', label: 'idle' }; return { color: '#34C759', label: 'idle' };
}; };
// Projekt-Chip MIT Auge (verstecken/sichtbar). Auge hat eigenes onclick
// + stopPropagation, damit der Klick nicht den Kontext wechselt.
const projChip = (p, isFocus, dotColor, subline) => {
const hidden = !!p.hidden;
const bg = isFocus ? 'rgba(52,199,89,0.15)' : '#1E1E2E';
const border = isFocus ? '#34C759' : '#2A2A3E';
const eye = hidden ? '👁' : '🙈';
const eyeTitle = hidden ? 'Wieder dauerhaft sichtbar machen' : 'Verstecken (aus dem Streifen ausblenden)';
return `<div style="flex:0 0 auto;padding:6px 10px;background:${bg};border:1px solid ${border};border-radius:6px;display:flex;align-items:center;gap:6px;min-width:120px;${hidden ? 'opacity:0.5;' : ''}">
<div onclick="switchDiagFocus('${p.id}')" style="cursor:pointer;display:flex;align-items:center;gap:6px;min-width:0;">
<div style="width:8px;height:8px;border-radius:4px;background:${dotColor};"></div>
<div style="display:flex;flex-direction:column;min-width:0;">
<div style="color:${isFocus?'#34C759':'#E0E0F0'};font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px;">📁 ${escapeHtml(p.name)}${hidden ? ' <span style="color:#B392F0;font-size:9px;font-weight:700;">versteckt</span>' : ''}</div>
<div style="color:#8888AA;font-size:10px;">${subline}</div>
</div>
</div>
<span onclick="event.stopPropagation();setStripProjectHidden('${p.id}',${!hidden})" title="${eyeTitle}" style="cursor:pointer;font-size:14px;padding:2px 4px;user-select:none;">${eye}</span>
</div>`;
};
const cards = []; const cards = [];
// Hauptchat // Hauptchat
const mainDot = dotFor('__main__'); const mainDot = dotFor('__main__');
cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle')); cards.push(chip('', '💬 Hauptchat', focusedContextId === '', mainDot.color, mainDot.label || 'idle'));
// Projekte — nur active/ended, sortiert nach letzter Aktivitaet // Projekte — nur active/ended, sortiert nach letzter Aktivitaet.
// Versteckte standardmaessig raus; per Toggle-Chip einblendbar.
let hiddenCount = 0;
for (const p of diagProjectsCache) { for (const p of diagProjectsCache) {
if (p.status === 'archived') continue; if (p.status === 'archived') continue;
if (p.hidden) {
hiddenCount++;
if (!diagShowHiddenStrip) continue;
}
const d = dotFor(p.id); const d = dotFor(p.id);
const sub = d.label || `${p.turn_count} Turns`; const sub = d.label || `${p.turn_count} Turns`;
cards.push(chip(p.id, `📁 ${p.name}`, focusedContextId === p.id, d.color, sub)); cards.push(projChip(p, focusedContextId === p.id, d.color, sub));
}
// Toggle-Chip am Ende (nur wenn es versteckte gibt oder gerade gezeigt werden)
if (hiddenCount > 0 || diagShowHiddenStrip) {
const tLabel = diagShowHiddenStrip
? `🙈 versteckte ausblenden (${hiddenCount})`
: `👁 versteckte anzeigen (${hiddenCount})`;
cards.push(`<div onclick="toggleDiagHiddenStrip()" title="Versteckte Projekte ein-/ausblenden" style="cursor:pointer;flex:0 0 auto;padding:6px 10px;background:#0D0D18;border:1px dashed #3A3A50;border-radius:6px;display:flex;align-items:center;color:#B392F0;font-size:11px;font-weight:600;white-space:nowrap;">${tLabel}</div>`);
} }
strip.innerHTML = cards.join(''); strip.innerHTML = cards.join('');
} }
function toggleDiagHiddenStrip() {
diagShowHiddenStrip = !diagShowHiddenStrip;
renderContextStrip();
}
async function setStripProjectHidden(id, hidden) {
try {
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !!hidden }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
// Wenn wir das gerade fokussierte Projekt verstecken (und Versteckte nicht
// eingeblendet sind), zurueck auf Hauptchat — sonst haengt der Focus an
// einem unsichtbaren Chip.
if (hidden && id === focusedContextId && !diagShowHiddenStrip) {
switchDiagFocus('');
}
// Lokal sofort aktualisieren (der /api/brain-Proxy broadcastet zusaetzlich
// project_changed an App + andere Tabs).
await refreshDiagProjectsCache();
} catch (e) {
alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message);
}
}
async function refreshDiagQueueStatus() { async function refreshDiagQueueStatus() {
try { try {
const r = await fetch('/api/brain/projects/queue-status'); const r = await fetch('/api/brain/projects/queue-status');
@@ -1775,8 +1835,10 @@
} }
if (msg.type === 'project_changed') { if (msg.type === 'project_changed') {
// ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet. // Projekt geaendert (ARIA-Tool, App/Diagnostic-Verstecken, …) →
// Liste neu laden falls sichtbar. // BEIDE Projekt-UIs live aktualisieren: den Kontext-Streifen (Main)
// und die vertikale Liste (Einstellungen).
refreshDiagProjectsCache();
loadProjects(); loadProjects();
return; return;
} }
+10 -1
View File
@@ -1602,7 +1602,16 @@ const htmlPath = path.join(__dirname, "index.html");
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if (req.url === "/" || req.url === "/index.html") { if (req.url === "/" || req.url === "/index.html") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); // no-store: das Dashboard ist eine Single-HTML-App die bei jedem Deploy
// neue Inline-JS/CSS bekommt. Ohne Cache-Header servierte der Browser die
// alte Version trotz Reload (neue Features tauchten erst nach Hard-Reload
// auf) — genau das Symptom „ich seh den Button nicht".
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
});
res.end(fs.readFileSync(htmlPath, "utf-8")); res.end(fs.readFileSync(htmlPath, "utf-8"));
} else if (req.url === "/api/state") { } else if (req.url === "/api/state") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });