Compare commits

..
29 Commits
Author SHA1 Message Date
duffyduck 629a3d82ac release: bump version to 0.2.1.3 2026-07-11 23:18:27 +02:00
duffyduckandClaude Opus 4.8 9bb90a777a fix(app): Sprachnachricht-Bubble verschwindet nach manuellem Stop nicht mehr
Ohne Ohr (manuelle Aufnahme) + Stop-Button: das echte STT-Endpoint (mit Text)
fuellt die Bubble, aber danach klappt ein zweites, LEERES stream_end-Endpoint
nach (Audio schon transkribiert). Der Empty-Handler loeschte die Bubble
bedingungslos per audioRequestId — auch die schon mit Text gefuellte.
Ergebnis: Bubble weg, obwohl ARIA an der Antwort arbeitet; erst nach
App-Neustart (aus chat_backup) wieder da.

Fix: leeres Endpoint entfernt nur noch den NOCH UNAUFGELOESTEN Platzhalter
(Text enthaelt 'Spracheingabe wird verarbeitet'), nicht eine bereits
aufgeloeste Bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:17:04 +02:00
duffyduckandClaude Opus 4.8 c3bb7985a2 fix(skills): skill_get-Tool nachgereicht — kein Blind-Rewrite mehr
Skandal-Ursache: die skill_update-Anleitung sagt "erst skill_get lesen, dann
updaten" — aber skill_get existierte GAR NICHT als Tool. ARIA/Claude konnte
einen Skill also nie LESEN vor dem Aendern → Blind-Rewrite aus Beschreibung +
Gedaechtnis. (Claude sagte korrekt "skill_get steht mir nicht zur Verfuegung"
und hat den Spotify-Skill trotzdem komplett neu geschrieben.)

Neu: skill_get(name) liefert Manifest (args/fast_patterns/speak/converse) +
kompletten entry_code (echter Python) + README. skills.read_skill_source().
So sieht ARIA den Ist-Code und aendert gezielt statt blind zu ueberschreiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:04:37 +02:00
duffyduckandClaude Opus 4.8 debd96b16f fix(fast-path): converse folgt dem Skill (Manifest/Output), nicht hart False
Fast-Path hatte converse hart auf False — ein Fast-Path-Skill konnte damit nie
ein Dialog-Skill sein. Jetzt konsistent zu local/Claude: speak UND converse
kommen aus dem Skill-Manifest-Default und werden vom Skill-JSON-Output pro
Aufruf ueberschrieben. Default (kein Flag) bleibt false/false = stumm/stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:57:52 +02:00
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 923 additions and 48 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20007
versionName "0.2.0.7"
versionCode 20103
versionName "0.2.1.3"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.0.7",
"version": "0.2.1.3",
"private": true,
"scripts": {
"android": "react-native run-android",
+63 -14
View File
@@ -49,7 +49,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
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';
// --- Typen ---
@@ -338,6 +338,10 @@ const ChatScreen: React.FC = () => {
// bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
// oder AsyncStorage-Load nicht beruecksichtigt).
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 messageIdCounter = useRef(0);
@@ -562,6 +566,14 @@ const ChatScreen: React.FC = () => {
// Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
useEffect(() => {
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) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
@@ -1195,6 +1207,26 @@ const ChatScreen: React.FC = () => {
clearAckTimer(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
@@ -1423,12 +1455,13 @@ const ChatScreen: React.FC = () => {
useEffect(() => {
const unsubPlayback = audioService.onPlaybackFinished(() => {
if (!wakeWordService.isActive()) return;
if (AppState.currentState === 'active') {
wakeWordService.resume();
} else {
console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)');
wakeWordService.endConversation().catch(() => {});
}
// Gespraech (gesprochene Antwort): KEIN neuer Gong / kein Aufnahme-Fenster,
// sondern direkt passives Lauschen (30s weiterreden wie mit einem Menschen).
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {});
});
return () => unsubPlayback();
}, []);
@@ -1465,7 +1498,7 @@ const ChatScreen: React.FC = () => {
interrupted: wasInterrupted,
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
@@ -1504,9 +1537,15 @@ const ChatScreen: React.FC = () => {
} else {
// Kein Speech im Window → Konversation beenden
console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
// Placeholder-Bubble wieder weg
// NUR den noch UNAUFGELOESTEN Platzhalter entfernen. Nach manuellem Stop
// klappt oft ein zweites, LEERES stream_end-Endpoint nach (das Audio war
// schon vom echten Endpoint transkribiert) — das darf die bereits mit
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
setMessages(prev => prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
// lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
@@ -1561,7 +1600,7 @@ const ChatScreen: React.FC = () => {
interrupted: true, // Barge-In → Brain weiss "User hat unterbrochen"
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
@@ -1618,7 +1657,7 @@ const ChatScreen: React.FC = () => {
interrupted: false,
location: location || null,
noSpeechTimeoutMs: Math.min(passiveMs, 30000),
endpointMs: 1500,
endpointMs: await loadSttEndpointMs(),
hardCapMs: Math.max(passiveMs + 5000, 35000),
projectId: focusedProjectIdRef.current,
});
@@ -2028,7 +2067,7 @@ const ChatScreen: React.FC = () => {
// Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
// die Session auch app-seitig haben wir +2s Toleranz.
noSpeechTimeoutMs: 0,
endpointMs: 1500,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 300000,
projectId: focusedProjectIdRef.current,
});
@@ -2046,10 +2085,20 @@ const ChatScreen: React.FC = () => {
// forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
// endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
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');
if (wakeWordService.isConversing()) {
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,
DEFAULT_KEYWORD,
WAKE_KEYWORD_STORAGE,
WAKE_THRESHOLD_DEFAULT,
WAKE_THRESHOLD_MIN,
WAKE_THRESHOLD_MAX,
loadWakeThreshold,
saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS,
loadPassiveListenMs,
savePassiveListenMs,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
@@ -200,6 +208,8 @@ const SettingsScreen: React.FC = () => {
const [wakeKeyword, setWakeKeyword] = useState<string>(DEFAULT_KEYWORD);
const [wakeStatus, setWakeStatus] = useState<string>('');
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 [xttsVoice, setXttsVoice] = useState('');
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);
});
isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1862,6 +1874,40 @@ const SettingsScreen: React.FC = () => {
))}
</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'}}>
<TouchableOpacity
style={[styles.connectButton, {flex: 1}]}
@@ -1907,6 +1953,39 @@ const SettingsScreen: React.FC = () => {
thumbColor={wakeReadySound ? '#FFFFFF' : '#666680'}
/>
</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>
</>)}
+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_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
// Bridge mitgegeben (speed-Param im F5-TTS infer()). 1.0 = normal.
export const TTS_SPEED_DEFAULT = 1.0;
@@ -1711,11 +1731,39 @@ class AudioService {
this._stoppedMessageId = 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; }
/** 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 */
stopPlayback(): void {
// 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';
// 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
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -76,8 +100,9 @@ export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
hey_rhasspy: 'Hey Rhasspy',
};
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
const DEFAULT_THRESHOLD = 0.5;
// Detection-Tuning. Threshold ist ueber die Settings konfigurierbar
// (loadWakeThreshold) — der Wert hier ist nur der Fallback.
const DEFAULT_THRESHOLD = WAKE_THRESHOLD_DEFAULT;
const DEFAULT_PATIENCE = 2;
const DEFAULT_DEBOUNCE_MS = 1500;
@@ -132,6 +157,12 @@ class WakeWordService {
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
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 nativeReady: boolean = false;
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. */
async configure(keyword: string): Promise<boolean> {
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
@@ -178,7 +222,9 @@ class WakeWordService {
if (this.initInProgress) return this.initInProgress;
this.initInProgress = (async () => {
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
if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
@@ -422,7 +468,10 @@ class WakeWordService {
* wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
* 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') {
import('./logger').then(m => m.reportAppDebug('wake.end',
`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
// Anrede weitersprechen.
const passiveMs = await loadPassiveListenMs();
if (passiveMs > 0 && this.nativeReady) {
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
this.enterPassiveListening(passiveMs);
return;
}
@@ -454,6 +503,7 @@ class WakeWordService {
// als state extra zu fragen, garantiert dass nach diesem Pfad
// Native auch wirklich an ist falls es out-of-band gestoppt wurde.
try {
await this._freeMic(); // Streaming-Aufnahme canceln → Mikro frei
await OpenWakeWord.start();
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
import('./logger').then(m => m.reportAppDebug('wake.end',
@@ -520,6 +570,7 @@ class WakeWordService {
// timeout oder manual → Wake-Word reaktivieren, armed-State.
if (this.nativeReady && OpenWakeWord) {
try {
await this._freeMic(); // passive Streaming-Aufnahme canceln → Mikro frei
await OpenWakeWord.start();
console.log('[WakeWord] zurueck zu armed nach passive-listen');
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
@@ -564,6 +615,7 @@ class WakeWordService {
this.lastTriggerAt = 0;
if (this.nativeReady && OpenWakeWord) {
try {
await this._freeMic(); // ggf. laufende Aufnahme canceln → Mikro frei
await OpenWakeWord.start();
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
this.setState('armed');
+273 -8
View File
@@ -234,6 +234,43 @@ META_TOOLS = [
"\"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"],
},
@@ -247,6 +284,27 @@ META_TOOLS = [
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "skill_get",
"description": (
"Liest einen EXISTIERENDEN Skill VOLLSTAENDIG: Manifest (inkl. "
"args, fast_patterns, speak/converse) + kompletter entry_code "
"(der echte Python-Code) + README. IMMER vor `skill_update` "
"aufrufen, damit du den bestehenden Code SIEHST und ihn gezielt "
"aenderst statt blind zu ueberschreiben (Blind-Rewrite killt "
"leicht funktionierende Teile!)."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Skill-Name."},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
@@ -310,6 +368,23 @@ META_TOOLS = [
"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"],
},
@@ -1018,10 +1093,53 @@ META_TOOLS = [
# Diese Logik ist generisch — ARIA deklariert die Patterns selbst beim
# 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:
norm = (text or "").strip().lower()
norm = re.sub(r"[.!?]+$", "", norm)
norm = re.sub(r"\s+", " ", norm)
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(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
@@ -1117,6 +1235,9 @@ class Agent:
rx = pat.get("match") or ""
if not rx:
continue
# Pattern GLEICH falten wie den Text — egal ob der Skill-Autor
# 'naechstes' oder 'nächstes' geschrieben hat.
rx = _fold_umlauts(rx)
try:
if not re.match(rx, norm, re.IGNORECASE):
continue
@@ -1125,6 +1246,12 @@ class Agent:
continue
args = pat.get("args") or {}
reply = pat.get("reply") or f"{skill_name}: ok"
# Vorlesen/Weiterlauschen folgen dem Skill — GENAU wie bei local/
# Claude: Manifest-Default, vom Skill-Output pro Aufruf ueber-
# schreibbar. Also NICHT generell converse=False: ein Fast-Path-
# Skill darf ein Dialog-Skill sein.
self._fast_path_speak = bool(skill.get("speak", False))
self._fast_path_converse = bool(skill.get("converse", False))
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
skill_name, rx, user_message[:60])
try:
@@ -1137,6 +1264,16 @@ class Agent:
tail = (res.get("stderr") or res.get("stdout") or "").strip().splitlines()
hint = (tail[-1] if tail else "")[:120]
return f"{skill_name}: {reply} — Fehler: {hint or 'siehe Brain-Log'}"
# Per-Aufruf-Override aus dem Skill-JSON-Output (falls vorhanden).
try:
_j = json.loads((res.get("stdout") or "").strip())
if isinstance(_j, dict):
if isinstance(_j.get("speak"), bool):
self._fast_path_speak = _j["speak"]
if isinstance(_j.get("converse"), bool):
self._fast_path_converse = _j["converse"]
except Exception:
pass
return reply
return None
@@ -1192,11 +1329,60 @@ class Agent:
break
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,
active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config()
if not router_mod.should_try_local(user_message, cfg):
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_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
tools = self._build_local_tools() # B1b: kuratierte Tools
@@ -1244,6 +1430,13 @@ class Agent:
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys()))
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):
had_error = True
messages.append({"role": "tool",
@@ -1289,6 +1482,14 @@ class Agent:
logger.info("[router] lokal eskaliert → Claude")
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))
self.conversation.add("assistant", final, project_id=active_project_id)
return final
@@ -1338,9 +1539,12 @@ class Agent:
metrics.log_fast_path(fast_reply)
except Exception:
pass
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
return fast_reply, "fast-path", False
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
return fast_reply, "fast-path", speak, converse
# 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source,
@@ -1354,7 +1558,11 @@ class Agent:
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
local_reply = self._try_local_fast_lane(user_message, active_project_id)
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)
hot = self.store.list_pinned()
@@ -1470,6 +1678,13 @@ class Agent:
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
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:
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools,
@@ -1487,6 +1702,12 @@ class Agent:
# Tools ausfuehren + Ergebnis als role=tool zurueck
for tc in result.tool_calls:
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
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
# viel zu wenig: Spotify _all=true mit 90 Playlists
@@ -1555,7 +1776,10 @@ class Agent:
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
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 ───────────────────────────────────────
@@ -1576,6 +1800,8 @@ class Agent:
pip_packages=arguments.get("pip_packages", []),
config_schema=arguments.get("config_schema") or None,
fast_patterns=arguments.get("fast_patterns") or None,
speak=bool(arguments.get("speak", False)),
converse=bool(arguments.get("converse", False)),
author="aria",
)
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
@@ -1627,6 +1853,29 @@ class Agent:
f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}"
for s in items
)
if name == "skill_get":
sk_name = (arguments.get("name") or "").strip()
if not sk_name:
return "FEHLER: name ist Pflicht."
src = skills_mod.read_skill_source(sk_name)
if src is None:
return f"FEHLER: Skill '{sk_name}' nicht gefunden."
m = src["manifest"]
# Manifest kompakt + kompletter Code, damit ARIA gezielt aendern kann.
meta = {
"name": m.get("name"), "description": m.get("description"),
"execution": m.get("execution"), "entry": m.get("entry"),
"active": m.get("active"), "args": m.get("args"),
"requires": m.get("requires"),
"config_schema": m.get("config_schema"),
"fast_patterns": m.get("fast_patterns"),
"speak": m.get("speak"), "converse": m.get("converse"),
}
return (
f"MANIFEST:\n{json.dumps(meta, ensure_ascii=False, indent=2)}\n\n"
f"ENTRY-CODE ({src['entry']}):\n{src['entry_code']}\n\n"
f"README:\n{src.get('readme') or '(leer)'}"
)
if name == "skill_update":
skill_name = (arguments.get("name") or "").strip()
if not skill_name:
@@ -1635,6 +1884,10 @@ class Agent:
for k in ("entry_code", "readme", "description", "args", "active"):
if k in arguments and arguments[k] is not None:
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):
patch["pip_packages"] = arguments["pip_packages"]
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
@@ -1736,6 +1989,18 @@ class Agent:
skill_name = cand_name
break
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,
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA
# 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:
agent = agent_factory()
reply, _, _ = agent.chat(prompt, source="trigger")
reply, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
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;
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
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
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -719,7 +722,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
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,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -743,6 +746,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
project_id=pid,
answered_by=answered_by,
speak=speak,
converse=converse,
)
finally:
_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
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:
"""True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll.
localOnly überschreibt die Heuristik (dann IMMER lokal)."""
@@ -94,7 +105,7 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
return False
if cfg.get("localOnly"):
return True
msg = (user_message or "").strip()
msg = _strip_leading_hint_blocks(user_message)
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False
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.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `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) "
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
"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 += [
"",
+36 -1
View File
@@ -139,6 +139,26 @@ def read_manifest(name: str) -> Optional[dict]:
return None
def read_skill_source(name: str) -> Optional[dict]:
"""Manifest + kompletter entry_code + README eines Skills. Damit ARIA einen
Skill LESEN kann bevor sie ihn per skill_update aendert (sonst Blind-Rewrite,
der bestehende Funktionen killt)."""
m = read_manifest(name)
if m is None:
return None
d = _skill_dir(name)
entry = m.get("entry", "run.sh")
try:
code = (d / entry).read_text(encoding="utf-8")
except Exception as exc:
code = f"(entry-Datei '{entry}' nicht lesbar: {exc})"
try:
readme = (d / "README.md").read_text(encoding="utf-8")
except Exception:
readme = ""
return {"manifest": m, "entry": entry, "entry_code": code, "readme": readme}
def write_manifest(name: str, manifest: dict) -> None:
d = _skill_dir(name)
d.mkdir(parents=True, exist_ok=True)
@@ -165,6 +185,8 @@ def create_skill(
author: str = "aria",
config_schema: Optional[list] = None,
fast_patterns: Optional[list] = None,
speak: bool = False,
converse: bool = False,
) -> dict:
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
@@ -215,6 +237,14 @@ def create_skill(
"author": author,
"config_schema": _normalize_config_schema(config_schema),
"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": [],
}
write_manifest(name, manifest)
@@ -335,10 +365,15 @@ def update_skill(name: str, patch: dict) -> dict:
# nach archive_current_version manifest neu laden (version_history geupdatet)
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():
if k in allowed:
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:
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
if "fast_patterns" in patch:
+259 -7
View File
@@ -16,6 +16,7 @@ import asyncio
import base64
import json
import logging
import math
import mimetypes
import os
import re
@@ -32,6 +33,7 @@ from pathlib import Path
from typing import Optional
import subprocess
import urllib.parse
import urllib.request
import numpy as np
import sounddevice as sd
@@ -370,6 +372,37 @@ def clean_text_for_tts(text: str) -> str:
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 ───────────────────────────────────────────────
@@ -1175,8 +1208,28 @@ class ARIABridge:
"lat": float(lat),
"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:
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
# oder langsam ist, blockt das nicht den GPS-Pfad.
try:
@@ -1184,6 +1237,150 @@ class ARIABridge:
except Exception:
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:
"""Brain-Endpoint POST /triggers/check-now anstossen."""
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 "",
# Quell-Backend (local/claude/fast-path) fuer den Diagnostic-Badge.
"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),
})
@@ -1552,14 +1757,33 @@ class ARIABridge:
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
)
if location and isinstance(location, dict):
lat = location.get("lat")
lon = location.get("lon") or location.get("lng")
# Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
# (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
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:
# 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(
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
f"nicht von dir aus, ausser er fragt explizit danach.]"
)
if parts:
return " ".join(parts) + " " + text
@@ -1707,6 +1931,9 @@ class ARIABridge:
# Soll vorgelesen werden? Fast-Path (Steuerbefehl) = False. System-Flag
# vom Brain — robust, unabhaengig von <voice>-Tags im Reply-Text.
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)
# damit sie in der UI vor der Reply auftauchen
@@ -1775,7 +2002,8 @@ class ARIABridge:
try:
await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by,
"speak": speak})
"speak": speak,
"converse": converse})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
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'",
str(payload.get("projectId") or "") or "(main)", text[:80])
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)
logger.info("[rvs] App-Chat%s%s: '%s'",
" [BARGE-IN]" if interrupted else "",
@@ -3006,6 +3237,24 @@ class ARIABridge:
if self._is_duplicate_client_msg(client_msg_id):
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-Version die projectId noch nicht mitschickt: leer = Hauptchat).
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)
# 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)
await self.send_to_core(core_text,
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 diagQueueStatus = {};
let diagProjectsCache = [];
let diagShowHiddenStrip = false; // versteckte Projekte im Streifen zeigen?
function updateChatVisibilityByFocus() {
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}` };
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 = [];
// Hauptchat
const mainDot = dotFor('__main__');
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) {
if (p.status === 'archived') continue;
if (p.hidden) {
hiddenCount++;
if (!diagShowHiddenStrip) continue;
}
const d = dotFor(p.id);
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('');
}
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() {
try {
const r = await fetch('/api/brain/projects/queue-status');
@@ -1775,8 +1835,10 @@
}
if (msg.type === 'project_changed') {
// ARIA hat in einem Tool-Call ein Projekt erstellt/betreten/verlassen/beendet.
// Liste neu laden falls sichtbar.
// Projekt geaendert (ARIA-Tool, App/Diagnostic-Verstecken, …) →
// BEIDE Projekt-UIs live aktualisieren: den Kontext-Streifen (Main)
// und die vertikale Liste (Einstellungen).
refreshDiagProjectsCache();
loadProjects();
return;
}
+10 -1
View File
@@ -1602,7 +1602,16 @@ const htmlPath = path.join(__dirname, "index.html");
const server = http.createServer((req, res) => {
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"));
} else if (req.url === "/api/state") {
res.writeHead(200, { "Content-Type": "application/json" });