Compare commits

...
18 Commits
Author SHA1 Message Date
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
duffyduck bbfa1f73f6 release: bump version to 0.2.0.7 2026-07-11 17:14:32 +02:00
duffyduckandClaude Opus 4.8 fed3cb9f18 fix(projects): Live-Sync verstecken zwischen App und Diagnostic (ohne Refresh)
Kern-Bug: der Diagnostic-Server reichte ein von RVS empfangenes
project_changed NIE an die Browser weiter — der Handler in index.html war
toter Code, der Diagnostic aktualisierte die Projektliste also nie live.

- server.js: RVS-Handler forwardet project_changed jetzt an die Browser-Tabs;
  der /api/brain-Proxy broadcastet project_changed (RVS + lokal) nach jeder
  erfolgreichen Projekt-Mutation (create/switch/end/archive/PATCH inkl. hidden).
- App ProjectsBrowser: sendet project_changed nach dem Verstecken/Sichtbar-
  machen und laedt bei eingehendem project_changed selbst neu.

Damit: verstecken in der App -> sofort im Diagnostic weg (und umgekehrt),
ohne Seiten-Refresh. Diagnostic-Hide funktionierte schon (Daten/Proxy/Logik
korrekt) — versteckte sind per "Versteckte anzeigen (N)"-Toggle sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:11:51 +02:00
duffyduckandClaude Opus 4.8 9ec9119bd9 docs(changelog): 0.2.0.6 abgebunden (Unreleased → [0.2.0.6])
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:51:10 +02:00
duffyduck 06d0064c8c release: bump version to 0.2.0.6 2026-07-11 16:50:22 +02:00
duffyduckandClaude Opus 4.8 a7cde52ee6 docs(changelog): Plan-B-Epos (0.2.0.4/5) + heutige Fixes nachgetragen
Changelog endete bei 0.2.0.3 (07-10). Nachgetragen:
- 0.2.0.4/5 (07-11): Plan B Lokales LLM (Router, Fast-Lane, SearXNG-Tools,
  llama-swap/B0.5, Quell-Badge, speak-Flag, --system-prompt Identity-Fix,
  Diskretions-Regel).
- Unreleased (07-11): Spotify-Resume via MEDIA_PLAY, TTS-Zahlen ausschreiben,
  "ARIA denkt"-Sync, weiche Tool-Fehler-Eskalation, Token-Ersparnis-Metrik,
  Projekte verstecken (Diagnostic + App), von ARIA geschaerfter Spotify-Skill.
Alt-Label "Unreleased 07-10" korrekt auf [0.2.0.3] gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:49:02 +02:00
duffyduckandClaude Opus 4.8 a353b62b37 feat(app): Projekte verstecken auch in der App (Auge + Toggle)
Spiegelt das Diagnostic-Feature auf App-Seite:
- Project-Interface + updateProject um hidden erweitert; setProjectHidden().
- ProjectsBrowser: versteckte Projekte standardmaessig ausgeblendet (mama
  sieht sie nicht). Auge pro Zeile (🙈 verstecken / 👁 sichtbar), Toggle
  "👁 Versteckte anzeigen (N)" blendet sie temporaer gedimmt + Badge ein
  zum Ansehen/Auswaehlen/Wieder-Sichtbarmachen. Eigener Touch am Auge, damit
  der Tap nicht das Projekt wechselt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:45:15 +02:00
duffyduckandClaude Opus 4.8 bc47c2053b feat(projects): Projekte verstecken (Auge-Toggle im Diagnostic)
Neues hidden-Flag pro Projekt (default false, bleibt voll nutzbar — nur
optisch aus der Liste ausgeblendet, unabhaengig von status/archived).
- projects.py: hidden in create_project + update_project-Patchkeys.
- main.py: ProjectUpdateBody.hidden → PATCH /projects/{id} setzt es.
- Diagnostic: pro Projekt-Bubble ein Auge (🙈 verstecken / 👁 sichtbar
  machen); Header-Toggle "Versteckte anzeigen (N)" blendet sie temporaer
  ein (gedimmt + Badge) zum Ansehen/Auswaehlen, ohne sie permanent
  sichtbar zu machen. Auge im eingeblendeten Zustand macht sie dauerhaft
  wieder sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:41:56 +02:00
duffyduckandClaude Opus 4.8 dc043ceb4d feat(metrics): lokaler LLM-Verbrauch + Claude-Ersparnis in Diagnostic
metrics.jsonl-Eintraege tragen jetzt 'source' (claude|local|fast-path).
- log_local_call(): echte usage-Tokens vom Adapter (prompt/completion),
  sonst chars/4-Schaetzung. Geloggt pro Tool-Runde im lokalen Fast-Lane.
- log_fast_path(): reiner Skill, 0 Prompt-Tokens — gesparter Claude-Call.
- aggregate() liefert zusaetzlich by_source (calls/tokens_in/tokens_out).
  Alt-Eintraege ohne source zaehlen als claude (rueckwaerts-kompatibel).

Diagnostic Gehirn-Tab: neue Card "Lokales LLM & Claude-Ersparnis" — pro
Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) und
lokale Token-Last (eigene HW, kein Quota) + Info-Block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:39:02 +02:00
duffyduckandClaude Opus 4.8 8bac7c56bc fix(local-llm): weiche Tool-Fehler eskalieren (Exit 0 + Fehlertext)
Die Escalate-on-Tool-Error-Logik griff nur bei hartem FEHLER-Prefix (Exit
!= 0). Action-Skills melden Fehlschlaege aber oft im stdout-Text bei Exit 0
(Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Das lokale
LLM las den Fehler dann brav vor statt zu eskalieren — und wiederholte beim
Nachhaken dieselbe leere Absichtserklaerung.

Generisch (nicht Spotify-spezifisch): fuer run_*-Skills werten weiche
Fehler-Marker im Ausgabetext ebenfalls als Fehlschlag → Claude uebernimmt
das mehrstufige Mitdenken. Info-Tools (web_search/memory_search) ausgenommen,
damit deren Inhalt das Wort "Fehler" tragen darf. Eskalieren ist immer sicher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:25:52 +02:00
duffyduckandClaude Opus 4.8 3b6d36f2de fix(sync): "ARIA denkt" bleibt haengen obwohl Turn fertig
Zwei Ursachen, beide adressiert:
- App raeumte den (kontext-scoped) Thinking-Indikator nur ueber das
  agent_activity 'idle' der Bridge. Kam das nicht an (dedup/mismatch),
  blieb "ARIA denkt" + Abbrechen stehen. Jetzt raeumt die App den
  Indikator beim Eintreffen der Antwort selbst (definitiver Turn-Ende-Beweis).
- Bridge sendete 'thinking' mit der REQUEST-projectId, 'idle' aber mit der
  TURN-projectId (Brain kann umrouten, z.B. Voice-Sticky). Bei Abweichung
  bekam der Request-Kontext nie sein idle → zusaetzliches idle fuer die
  Request-projectId am Turn-Ende.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:23:41 +02:00
duffyduckandClaude Opus 4.8 7f6e266d15 fix(tts): freistehende Ganzzahlen tag-unabhaengig ausschreiben
Regression seit das lokale LLM leichte Turns (Wetter, Spotify) beantwortet:
es nutzt bewusst KEINE <voice>-Tags, an denen frueher die Zahl-Aussprache hing.
clean_text_for_tts schrieb nur Uhrzeiten/Dezimalzahlen/Zahl+Einheit aus, nie
freistehende Ganzzahlen ('23°C', '100%', '7 Nachrichten').

Neuer vollstaendiger Konverter _int_to_words_de (0..999999) + generischer
Durchlauf am Ende von clean_text_for_tts, der alle uebrigen Ganzzahlen zu
Woertern macht. Tag-unabhaengig -> gilt fuer local UND claude. Lange
Ziffernfolgen (IDs/Codes, >6 Stellen) bleiben Ziffern; an .,:/ klebende
Zahlen (IPs, Versionen) werden uebersprungen. Nebenbei: fehlendes Leerzeichen
bei '23°C' -> 'dreiundzwanzig Grad Celsius' gefixt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:18:58 +02:00
duffyduckandClaude Opus 4.8 4d1664a328 fix(app): zuverlaessiger Spotify-Resume via MEDIA_PLAY-KeyEvent statt Focus-Nudge
Native AudioFocus: dispatchMediaPlay() (echter KEYCODE_MEDIA_PLAY an die aktive
MediaSession, wie die Kopfhoerer-Play-Taste) + isMusicActive() zum Gaten.
audio.ts merkt vor dem Focus-Grab ob Musik lief und resumt am Dialog-Ende nur
dann — deterministisch statt des auf OnePlus flakigen nudgeMediaResume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:13:18 +02:00
17 changed files with 739 additions and 51 deletions
+54 -1
View File
@@ -10,7 +10,60 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
--- ---
## [Unreleased] — 2026-07-10 ## [0.2.0.6] — 2026-07-11
### Hinzugefügt
**Diagnostic + App — Projekte verstecken**
- Neues `hidden`-Flag pro Projekt (bleibt voll nutzbar, nur aus Listen ausgeblendet — unabhängig von `status`/`archived`); `PATCH /projects/{id} {hidden}`
- Diagnostic: 👁-Auge pro Projekt-Bubble (🙈 verstecken / 👁 dauerhaft sichtbar), Header-Toggle „Versteckte anzeigen (N)" blendet sie temporär gedimmt + „versteckt"-Badge ein — zum Ansehen/Auswählen ohne permanentes Enttarnen
- App (`ProjectsBrowser`): versteckte standardmäßig ausgeblendet (Mama sieht sie nicht), Auge pro Zeile + Toggle spiegeln das Diagnostic-Verhalten; geteilter `hidden`-Status übers Brain
**Diagnostic — Token-Ersparnis durch lokales LLM**
- `metrics.jsonl` trägt jetzt `source` (claude | local | fast-path); lokale Calls nutzen echte `usage`-Tokens vom Adapter, Fast-Path = 0 Prompt-Tokens (`by_source`-Aggregation, rückwärts-kompatibel)
- Neue Card „Lokales LLM & Claude-Ersparnis": pro Fenster (1h/5h/24h/30d) gesparte Claude-Calls (local + fast-path) + lokale Token-Last (eigene HW, kein Quota)
**Spotify-Skill — von ARIA selbst geschärft**
- Geräte-Transfer startet die Wiedergabe direkt mit (`play=true`) statt nur zu übertragen, inkl. Verifikation (`is_playing`-Check + expliziter Play-Fallback), Fuzzy-Gerätenamen und sauberen Exit-Codes; neue semantische Actions `play_on_device`/`search_and_play`/`playlist_play`/`queue_add`
### Behoben
- **Spotify-Resume (App):** nach einem Voice-Befehl blieb Spotify auf dem Handy pausiert. Statt des auf manchen Geräten (OnePlus) flakigen Audio-Focus-Nudge jetzt ein echter `KEYCODE_MEDIA_PLAY`-KeyEvent an die aktive MediaSession — gegated: nur wenn vor dem Dialog Musik lief (`isMusicActive`). Deterministisch, geräteunabhängig
- **TTS-Zahlen:** freistehende Ganzzahlen werden jetzt tag-unabhängig ausgeschrieben („23°C" → „dreiundzwanzig Grad Celsius", „100%" → „einhundert Prozent"). Regression, seit das lokale LLM (bewusst ohne `<voice>`-Tag) leichte Turns übernahm; neuer vollständiger Zahl→Wort-Konverter (0…999999) am Ende von `clean_text_for_tts`, lange Ziffernfolgen (IDs) bleiben Ziffern
- **„ARIA denkt" hängt:** Indikator + Abbrechen blieben stehen, obwohl der Turn laut Diagnostic fertig war. Die App räumt den kontext-scoped Indikator jetzt beim Eintreffen der Antwort selbst; die Bridge sendet zusätzlich ein `idle` für die Request-`projectId`, falls der Turn umgeroutet wurde (thinking ging mit Request-, idle mit Turn-`projectId`)
- **Lokale Tool-Fehler:** Action-Skills, die bei Exit 0 einen Fehlschlag nur im stdout-Text melden (Spotify: „Fehler beim Übertragen", „Gerät nicht gefunden"), eskalieren jetzt generisch an Claude statt vom lokalen LLM vorgelesen zu werden (Info-Tools wie web_search ausgenommen)
---
## [0.2.0.4 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling")
Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der Gamebox-GPU) übernimmt einfache Turns in **<1 s**; alles Schwere/Technische/Werkzeug-artige reicht ein Router automatisch an **Claude** weiter. Ziel: schnelle Antworten ohne die Claude-Max-Subscription aufzugeben.
### Hinzugefügt
**Lokales LLM (Brain + Bridge + Adapter)**
- Router (B1a): Heuristik + `<<ESCALATE>>`-Selbstabbruch entscheidet pro Turn lokal vs. Claude; schlanker System-Prompt mit demselben `IDENTITY_ANCHOR` wie Claude (Rolle hält), nur letzte 8 Turns (Speed)
- Lokale Tool-Fast-Lane (B1b): kuratierte Tools — `web_search` (self-hosted **SearXNG**, local-only), `memory_search`, `trigger_timer`, Spotify; Eskalation bei Tool-Fehler statt Raten
- Consumer-Kette gespiegelt zu FLUX: Brain → Bridge `/internal/local-llm` → RVS → `llm-adapter` → llama.cpp; `enable_thinking:false` (Qwen wickelte sonst die ganze Antwort in `<think>`)
- **B0.5:** llama-swap (Hot-Swap der Modelle on-demand) + Modellauswahl-Dropdown + Live-Lade-Status (loading/ready + Download-Hinweis) in Diagnostic
- **SearXNG** als 6. Container auf der ARIA-VM (keyless Meta-Suche, JSON-API)
**Quell-Badge (local / claude / fast-path)**
- Diagnostic: immer an den ARIA-Bubbles
- App: optionaler Schalter in den Einstellungen, pro Gerät gemerkt, default aus („ich will's, meine Mama nicht")
**TTS — System-Flag `speak` (ja/nein) pro Antwort**
- Die Quelle entscheidet übers Vorlesen (Fast-Path/Steuerbefehl = stumm, ARIA-Antwort = vorlesen), robust statt des fragilen leeren `<voice></voice>`-Hacks der beim Skill-Rebuild verloren ging
### Behoben / Geändert
- **Identität (Hauptchat):** Proxy nutzt jetzt `--system-prompt` (voller Replace) statt `--append-system-prompt` — die Claude-Code-Basis-Identität leakt nicht mehr in den Hauptchat (ARIA antwortete dort als „Claude Code" bzw. deutete die Persona als Injection). Dazu `IDENTITY_SEED` (synthetischer Grounding-Turn) + Gift-Wächter (Identity-Breaks werden nie in die History persistiert, Retry+Fallback) + Cleanup-Script gegen bereits vergiftete Turns
- **Datenschutz (kritisch):** harte Diskretions-Regel im `IDENTITY_ANCHOR` — ARIA kennt intime/private Details, gibt sie aber **NIE ungefragt** preis (nicht in Vorstellungen, „was weißt du über mich", Zusammenfassungen, Triggern); nur auf konkrete Nachfrage, knapp. Bereits ausgeplauderte Turns bereinigt. Lokales Tier eskaliert Personen-/Beziehungs-/Gedächtnisfragen an Claude (kennt das Gedächtnis + antwortet diskret)
- **Lokale Antwort nicht in `<voice>`** wickeln (Qwen imitierte den Tag aus dem Kontext → Anzeige war leer); **generische** Tool-Fehler-Eskalation statt per-Skill-Router-Hardcode (Router muss nicht wissen, welche Skills „schwer" sind)
---
## [0.2.0.3] — 2026-07-10
### Hinzugefügt ### Hinzugefügt
+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 20005 versionCode 20008
versionName "0.2.0.5" versionName "0.2.0.8"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -5,7 +5,9 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.SystemClock
import android.util.Log import android.util.Log
import android.view.KeyEvent
import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
@@ -183,6 +185,50 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
promise.resolve(true) promise.resolve(true)
} }
/** Zuverlaessiger Spotify-Resume: einen echten MEDIA_PLAY-Tastendruck an die
* aktive MediaSession schicken — exakt das Signal der Play-Taste am
* Bluetooth-Kopfhoerer. Anders als nudgeMediaResume (Focus-Stack-Trick,
* auf manchen OEMs/Spotify-Versionen unzuverlaessig) spricht das Spotifys
* MediaSession DIREKT an und startet die Wiedergabe deterministisch wieder.
*
* Wir senden bewusst KEYCODE_MEDIA_PLAY (nicht PLAY_PAUSE) — das kann nur
* starten, nie pausieren. Aufrufer muss also selbst gaten (nur senden wenn
* vor dem Gespraech wirklich Musik lief, siehe isMusicActive()).
*/
@ReactMethod
fun dispatchMediaPlay(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
try {
val now = SystemClock.uptimeMillis()
val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
am.dispatchMediaKeyEvent(down)
am.dispatchMediaKeyEvent(up)
Log.i(TAG, "dispatchMediaPlay: KEYCODE_MEDIA_PLAY an aktive MediaSession gesendet")
promise.resolve(true)
} catch (e: Exception) {
Log.w(TAG, "dispatchMediaPlay failed: ${e.message}")
promise.resolve(false)
}
}
/** Ob gerade Musik/Media aktiv abgespielt wird (AudioManager.isMusicActive).
* Der Aufrufer merkt sich das VOR dem Focus-Grab, um am Dialog-Ende zu
* entscheiden ob ein dispatchMediaPlay-Resume ueberhaupt gewuenscht ist. */
@ReactMethod
fun isMusicActive(promise: Promise) {
val am = audioManager()
if (am == null) {
promise.resolve(false)
return
}
promise.resolve(am.isMusicActive)
}
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube /** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus * resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA * nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.0.5", "version": "0.2.0.8",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+79 -6
View File
@@ -75,6 +75,9 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const [editing, setEditing] = useState<Project | null>(null); const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState(''); const [editDesc, setEditDesc] = useState('');
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
const [showHidden, setShowHidden] = useState(false);
// Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt // Refs damit useCallback NICHT bei jeder Re-Render des Parents neu erzeugt
// wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes // wird (parent uebergibt oft inline-arrow-Callbacks, neue Identity jedes
@@ -109,6 +112,17 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
return () => unsub(); return () => unsub();
}, [visible, load]); }, [visible, load]);
// Live-Sync: ein anderer Client (Diagnostic / andere App) hat ein Projekt
// geaendert (verstecken/anlegen/beenden/…) → project_changed ueber RVS →
// Liste neu laden, ohne dass Stefan manuell refreshen muss.
useEffect(() => {
if (!visible) return;
const unsub = rvs.onMessage((msg: any) => {
if (msg?.type === 'project_changed') load();
});
return () => unsub();
}, [visible, load]);
const switchTo = useCallback((id: string) => { const switchTo = useCallback((id: string) => {
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung. // Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
// Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr). // Brain wird nicht mehr benachrichtigt (kein globaler active_project mehr).
@@ -158,6 +172,19 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
]); ]);
}, [load]); }, [load]);
// Nach einer Projekt-Mutation die anderen Clients (Diagnostic, weitere
// App-Instanzen) live aktualisieren — via RVS project_changed. RVS echot
// NICHT an den Sender zurueck, darum laden wir lokal zusaetzlich selbst.
const broadcastProjectsChanged = useCallback(() => {
try { rvs.send('project_changed' as any, { reason: 'app' }); } catch {}
}, []);
const toggleHidden = useCallback((p: Project) => {
brainApi.setProjectHidden(p.id, !p.hidden)
.then(() => { broadcastProjectsChanged(); load(); })
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [load, broadcastProjectsChanged]);
const archiveProject = useCallback((p: Project) => { const archiveProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" archivieren?`, Alert.alert(`"${p.name}" archivieren?`,
'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.', 'Verschwindet aus der Standardliste. Über "archivierte zeigen" erreichbar.',
@@ -176,11 +203,12 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const renderItem = ({ item }: { item: Project }) => { const renderItem = ({ item }: { item: Project }) => {
const isActive = item.id === activeId; const isActive = item.id === activeId;
const dot = _statusDot(item.id); const dot = _statusDot(item.id);
const hidden = !!item.hidden;
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => switchTo(item.id)} onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)} onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive]} style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
> >
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
@@ -188,6 +216,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} /> <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
)} )}
<Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text> <Text style={[s.rowName, isActive && { color: '#34C759' }]}>{item.name}</Text>
{hidden && <Text style={s.hiddenBadge}>versteckt</Text>}
{item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>} {item.status === 'ended' && <Text style={s.statusBadge}>beendet</Text>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>} {isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View> </View>
@@ -199,10 +228,22 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
{dot.label ? ` · ${dot.label}` : ''} {dot.label ? ` · ${dot.label}` : ''}
</Text> </Text>
</View> </View>
{/* Auge: verstecken (🙈) / wieder sichtbar (👁). Eigener Touch, damit
der Tap NICHT das Projekt wechselt. */}
<TouchableOpacity
onPress={() => toggleHidden(item)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={s.eyeBtn}
>
<Text style={s.eyeIcon}>{hidden ? '👁' : '🙈'}</Text>
</TouchableOpacity>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
const body = ( const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}> <View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* Header */} {/* Header */}
@@ -243,6 +284,17 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
); );
})()} })()}
{/* Versteckte-Toggle — nur wenn es welche gibt (oder gerade eingeblendet) */}
{(hiddenCount > 0 || showHidden) && (
<TouchableOpacity onPress={() => setShowHidden(v => !v)} style={s.hiddenToggle}>
<Text style={s.hiddenToggleText}>
{showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ` (${hiddenCount})` : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ` (${hiddenCount})` : ''}`}
</Text>
</TouchableOpacity>
)}
{loading ? ( {loading ? (
<View style={{ padding: 24, alignItems: 'center' }}> <View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" /> <ActivityIndicator color="#0096FF" />
@@ -251,14 +303,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<Text style={s.errorText}> {err}</Text> <Text style={s.errorText}> {err}</Text>
) : ( ) : (
<FlatList <FlatList
data={projects} data={visibleProjects}
keyExtractor={p => p.id} keyExtractor={p => p.id}
renderItem={renderItem} renderItem={renderItem}
ListEmptyComponent={ ListEmptyComponent={
<Text style={s.emptyText}> projects.length > 0 ? (
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'} <Text style={s.emptyText}>
Lass uns ein Projekt 'XY' anlegen". Alle {hiddenCount} Projekte sind versteckt.{'\n'}
</Text> Tipp 👁 Versteckte anzeigen".
</Text>
) : (
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
„Lass uns ein Projekt 'XY' anlegen".
</Text>
)
} }
/> />
)} )}
@@ -365,6 +424,8 @@ const s = StyleSheet.create({
headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' }, headerBtnText: { color: '#0096FF', fontSize: 18, fontWeight: '600' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' }, headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: { row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
@@ -375,6 +436,18 @@ const s = StyleSheet.create({
borderLeftWidth: 3, borderLeftWidth: 3,
borderLeftColor: '#34C759', borderLeftColor: '#34C759',
}, },
rowHidden: { opacity: 0.55 },
eyeBtn: { paddingHorizontal: 8, paddingVertical: 6, marginLeft: 6 },
eyeIcon: { fontSize: 18 },
hiddenBadge: { color: '#B392F0', fontSize: 10, fontWeight: '700',
backgroundColor: 'rgba(179,146,240,0.15)', paddingHorizontal: 6,
paddingVertical: 2, borderRadius: 4 },
hiddenToggle: {
paddingHorizontal: 16, paddingVertical: 10,
borderBottomWidth: 1, borderColor: '#1E1E2E',
backgroundColor: '#0D0D18',
},
hiddenToggleText: { color: '#B392F0', fontSize: 12, fontWeight: '600' },
rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' }, rowName: { color: '#E0E0F0', fontSize: 16, fontWeight: '600' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 }, rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 }, rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 },
+28 -5
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 ---
@@ -562,6 +562,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');
@@ -1172,6 +1180,21 @@ const ChatScreen: React.FC = () => {
}); });
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert // ARIA hat geantwortet → Watchdog clearen, falls noch armiert
clearStuckWatchdog(); clearStuckWatchdog();
// Thinking-Indikator fuer DIESEN Kontext raeumen. Die Antwort ist der
// definitive Beweis, dass der Turn fertig ist — unabhaengig davon, ob
// das agent_activity 'idle' der Bridge ankam (es kann mit abweichender
// projectId gesendet oder wegdedupt worden sein → "ARIA denkt" blieb
// sonst haengen). Zusaetzlich global raeumen, falls der sichtbare
// Kontext ueber den Legacy-Indikator lief.
{
const ansPid = ((message.payload as any).projectId as string) || '';
setAgentActivityByCtx(prev =>
prev[ansPid] && prev[ansPid].activity !== 'idle'
? { ...prev, [ansPid]: { activity: 'idle', tool: '' } }
: prev);
setAgentActivity(cur =>
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' });
}
// ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht // ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
@@ -1450,7 +1473,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,
}); });
@@ -1546,7 +1569,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,
}); });
@@ -1603,7 +1626,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,
}); });
@@ -2013,7 +2036,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,
}); });
+81 -9
View File
@@ -44,6 +44,11 @@ const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
release: () => Promise<boolean>; release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>; kickReleaseMedia: () => Promise<boolean>;
getMode?: () => Promise<number>; getMode?: () => Promise<number>;
// Zuverlaessiger Spotify-Resume via echtem MEDIA_PLAY-KeyEvent (statt
// Focus-Stack-Nudge). isMusicActive() zum Gaten: nur resumen wenn vor
// dem Gespraech wirklich Musik lief.
dispatchMediaPlay?: () => Promise<boolean>;
isMusicActive?: () => Promise<boolean>;
}; };
PcmStreamPlayer?: { PcmStreamPlayer?: {
start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>; start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise<boolean>;
@@ -146,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;
@@ -275,6 +300,13 @@ class AudioService {
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt. // damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
private _conversationFocusActive: boolean = false; private _conversationFocusActive: boolean = false;
// Lief unmittelbar VOR dem Focus-Grab (Wake-Word/Aufnahme) Musik? Wird beim
// Betreten des Dialogs gemerkt (latch: nur auf true), damit wir am Dialog-Ende
// NUR dann Spotify per MEDIA_PLAY-KeyEvent zuverlaessig resumen, wenn vorher
// wirklich etwas lief. Verhindert, dass wir bei Stille versehentlich Musik
// starten. Wird nach dem Resume-Dispatch wieder auf false gesetzt.
private _mediaWasActiveAtAcquire: boolean = false;
// VAD State // VAD State
private vadEnabled: boolean = false; private vadEnabled: boolean = false;
private lastSpeechTime: number = 0; private lastSpeechTime: number = 0;
@@ -450,15 +482,29 @@ class AudioService {
import('./logger').then(m => m.reportAppDebug('audio.focus', import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{}); 'AudioFocus.release() now')).catch(()=>{});
AudioFocus?.release().catch(() => {}); AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack // Spotify-Resume: NUR wenn vor dem Gespraech wirklich Musik lief. Dann
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei // einen echten MEDIA_PLAY-KeyEvent an die aktive MediaSession schicken
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss. // (wie die Play-Taste am Kopfhoerer) — das resumt Spotify zuverlaessig,
// 50ms Delay damit das Abandon erst durch ist. // im Gegensatz zum flakigen Focus-Stack-Nudge, der auf manchen Geraeten
setTimeout(() => { // (OnePlus) nach Auto-Loss nicht griff. 120ms Delay, damit das Abandon
// sicher durch ist, bevor der Play-Key kommt.
const shouldResume = this._mediaWasActiveAtAcquire;
this._mediaWasActiveAtAcquire = false;
if (shouldResume) {
setTimeout(() => {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'dispatchMediaPlay() now (Musik lief vor Dialog → Resume)')).catch(()=>{});
if (AudioFocus?.dispatchMediaPlay) {
AudioFocus.dispatchMediaPlay().catch(() => {});
} else {
// Fallback fuer alte Native-Builds ohne dispatchMediaPlay
AudioFocus?.nudgeMediaResume().catch(() => {});
}
}, 120);
} else {
import('./logger').then(m => m.reportAppDebug('audio.focus', import('./logger').then(m => m.reportAppDebug('audio.focus',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{}); 'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {}); }
}, 50);
}, this.FOCUS_RELEASE_DELAY_MS); }, this.FOCUS_RELEASE_DELAY_MS);
} }
@@ -469,6 +515,20 @@ class AudioService {
} }
} }
/** Merkt sich (latch: nur auf true), ob GERADE Musik laeuft — VOR einem
* Focus-Grab aufrufen. Am Dialog-Ende entscheidet die Flag, ob wir Spotify
* aktiv per MEDIA_PLAY resumen. Awaitet bewusst isMusicActive bevor der
* Focus-Request die Wiedergabe pausiert (sonst laese man schon 'false'). */
private async _captureMediaActive(): Promise<void> {
try {
const active = await AudioFocus?.isMusicActive?.();
if (active) {
this._mediaWasActiveAtAcquire = true;
console.log('[Audio] Musik lief vor Focus-Grab → Resume am Dialog-Ende gemerkt');
}
} catch {}
}
/** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt /** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */ * pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void { acquireConversationFocus(): void {
@@ -476,7 +536,11 @@ class AudioService {
this._conversationFocusActive = true; this._conversationFocusActive = true;
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)'); console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
AudioFocus?.requestDuck().catch(() => {}); // Erst pruefen ob Musik laeuft, DANN ducken (requestDuck wuerde sie sonst
// schon pausieren bevor wir es messen koennen).
this._captureMediaActive().finally(() => {
AudioFocus?.requestDuck().catch(() => {});
});
} }
/** Conversation-Mode endet → Focus darf wieder freigegeben werden /** Conversation-Mode endet → Focus darf wieder freigegeben werden
@@ -493,6 +557,8 @@ class AudioService {
haltAllPlayback(reason: string = ''): void { haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)'); console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false; this._conversationFocusActive = false;
// Barge-In → User spricht gleich weiter, Spotify NICHT resumen.
this._mediaWasActiveAtAcquire = false;
this.stopPlayback(); this.stopPlayback();
} }
@@ -503,6 +569,8 @@ class AudioService {
pauseForCall(reason: string = ''): void { pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)'); console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false; this._conversationFocusActive = false;
// Anruf → Spotify bleibt aus, kein Auto-Resume beim spaeteren Release.
this._mediaWasActiveAtAcquire = false;
this._pausedForCall = true; this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button // Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf). // (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
@@ -796,6 +864,8 @@ class AudioService {
this.setState('recording'); this.setState('recording');
// Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.)
// Vorher merken ob Musik lief, damit wir sie danach resumen koennen.
await this._captureMediaActive();
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
@@ -1043,6 +1113,8 @@ class AudioService {
} }
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad. // AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease(); this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {}); AudioFocus?.requestExclusive().catch(() => {});
+11 -2
View File
@@ -157,6 +157,7 @@ export interface Project {
name: string; name: string;
description: string; description: string;
status: 'active' | 'ended' | 'archived'; status: 'active' | 'ended' | 'archived';
hidden?: boolean; // aus Listen ausgeblendet (bleibt nutzbar)
created_at: number; created_at: number;
updated_at: number; updated_at: number;
last_activity_at: number; last_activity_at: number;
@@ -593,14 +594,22 @@ export const brainApi = {
}); });
}, },
/** Projekt-Metadaten patchen (name / description). */ /** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description'>>): Promise<Project> { updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, { return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH', method: 'PATCH',
body: patch, body: patch,
}); });
}, },
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { hidden },
});
},
/** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat) /** Queue-Status: pro Kontext (project_id oder __main__ fuer Hauptchat)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten. * ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */ * Wird fuer Status-Dots im Drawer periodisch gepollt. */
+22
View File
@@ -132,6 +132,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 +155,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)
@@ -454,6 +473,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 +540,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 +585,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');
+72 -4
View File
@@ -30,6 +30,7 @@ from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod import router as router_mod
import metrics
from local_llm import local_llm_chat from local_llm import local_llm_chat
import skills as skills_mod import skills as skills_mod
import triggers as triggers_mod import triggers as triggers_mod
@@ -1017,10 +1018,36 @@ 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 _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
@@ -1116,6 +1143,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
@@ -1150,6 +1180,32 @@ class Agent:
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed) _LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude _LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
# menschenlesbaren Fehler auf stdout aus und beenden sich TROTZDEM mit Exit 0
# (z.B. Spotify: "Fehler beim Uebertragen", "Geraet nicht gefunden"). Der
# harte FEHLER-Prefix (Exit != 0) faengt das nicht. Fuer run_*-Skills werten
# wir daher auch solche Marker als Fehlschlag → eskalieren an Claude, der
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
_SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar",
"exception", "traceback",
)
def _local_tool_failed(self, tool_name: str, tresult: str) -> bool:
"""True wenn ein lokaler Tool-Call als gescheitert gilt (→ Claude)."""
s = (tresult or "").strip()
if not s:
return False
if s.startswith("FEHLER"): # harter Exit-Code-Fehler
return True
if tool_name.startswith("run_"): # Action-Skill: auch weiche Fehler
low = s.lower()
return any(mk in low for mk in self._SOFT_FAIL_MARKERS)
return False
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only, # Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill. # SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude. # Bewusst klein (Speed + Sicherheit); alles andere → Claude.
@@ -1194,6 +1250,13 @@ class Agent:
if local_only: if local_only:
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]" return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None return None
# Metric: dieser lokale Call (echte usage-Tokens wenn der Adapter sie
# liefert). Erfasst pro Tool-Runde — mehrere Runden = mehrere Calls.
try:
metrics.log_local_call(res.get("model") or local_model, messages,
res.get("content") or "", res.get("usage"))
except Exception:
pass
tcs = res.get("tool_calls") tcs = res.get("tool_calls")
if tcs: if tcs:
messages.append({"role": "assistant", messages.append({"role": "assistant",
@@ -1210,7 +1273,7 @@ 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)
if (tresult or "").strip().startswith("FEHLER"): if self._local_tool_failed(tname, tresult):
had_error = True had_error = True
messages.append({"role": "tool", messages.append({"role": "tool",
"tool_call_id": tc.get("id") or "", "tool_call_id": tc.get("id") or "",
@@ -1299,6 +1362,11 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id) self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id: if active_project_id:
projects_mod.touch_project(active_project_id) projects_mod.touch_project(active_project_id)
# Metric: Fast-Path spart einen ganzen Claude-Call zum Nulltarif.
try:
metrics.log_fast_path(fast_reply)
except Exception:
pass
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False). # Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt. # System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
return fast_reply, "fast-path", False return fast_reply, "fast-path", False
+1
View File
@@ -817,6 +817,7 @@ def projects_archive(project_id: str):
class ProjectUpdateBody(BaseModel): class ProjectUpdateBody(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
hidden: Optional[bool] = None
@app.patch("/projects/{project_id}") @app.patch("/projects/{project_id}")
+54 -10
View File
@@ -52,25 +52,55 @@ def _messages_tokens(messages: list) -> int:
return total return total
def log_call(model: str, messages_in: list, reply_text: str = "") -> None: def _append(model: str, tokens_in: int, tokens_out: int, source: str) -> None:
"""Eine Call-Metric anhaengen. Robust gegen Fehler (silent fail).""" """Ein Metric-Entry auf Disk anhaengen. Robust (silent fail)."""
try: try:
tokens_in = _messages_tokens(messages_in)
tokens_out = _estimate_tokens(reply_text)
line = json.dumps({ line = json.dumps({
"ts": int(time.time() * 1000), "ts": int(time.time() * 1000),
"model": model, "model": model,
"in": tokens_in, "in": int(tokens_in),
"out": tokens_out, "out": int(tokens_out),
"source": source, # "claude" | "local" | "fast-path"
}) })
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True) METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with METRICS_FILE.open("a", encoding="utf-8") as f: with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n") f.write(line + "\n")
# Sanftes Rotate ohne hohe IO-Kosten — nur alle 1000 Calls checken
if (tokens_in + tokens_out) % 1000 < 4: if (tokens_in + tokens_out) % 1000 < 4:
_maybe_rotate() _maybe_rotate()
except Exception as exc: except Exception as exc:
logger.warning("metrics.log_call: %s", exc) logger.warning("metrics._append: %s", exc)
def log_call(model: str, messages_in: list, reply_text: str = "",
source: str = "claude") -> None:
"""Claude-Call-Metric anhaengen (Tokens per chars/4-Schaetzung)."""
_append(model, _messages_tokens(messages_in), _estimate_tokens(reply_text), source)
def log_local_call(model: str, messages_in: list, reply_text: str = "",
usage: dict | None = None) -> None:
"""Lokaler-LLM-Call-Metric. Nutzt echte usage-Tokens (prompt/completion)
wenn der Adapter sie liefert, sonst chars/4-Schaetzung wie bei Claude.
Quelle = 'local' damit die Ersparnis-Rechnung local von claude trennt."""
tokens_in = tokens_out = None
if isinstance(usage, dict):
pt = usage.get("prompt_tokens")
ct = usage.get("completion_tokens")
if isinstance(pt, (int, float)):
tokens_in = int(pt)
if isinstance(ct, (int, float)):
tokens_out = int(ct)
if tokens_in is None:
tokens_in = _messages_tokens(messages_in)
if tokens_out is None:
tokens_out = _estimate_tokens(reply_text)
_append(model or "local", tokens_in, tokens_out, "local")
def log_fast_path(reply_text: str = "") -> None:
"""Fast-Path (reiner Skill, KEIN LLM) — spart einen ganzen Claude-Call zum
Nulltarif. tokens_in=0 (kein Prompt ans LLM), out = winzige Quittung."""
_append("fast-path", 0, _estimate_tokens(reply_text), "fast-path")
def _maybe_rotate() -> None: def _maybe_rotate() -> None:
@@ -95,6 +125,11 @@ def aggregate(window_seconds: int) -> dict:
tokens_in = 0 tokens_in = 0
tokens_out = 0 tokens_out = 0
by_model: dict[str, int] = {} by_model: dict[str, int] = {}
# Aufschluesselung nach Quelle (claude / local / fast-path) fuer die
# Ersparnis-Anzeige im Diagnostic.
def _src_bucket() -> dict:
return {"calls": 0, "tokens_in": 0, "tokens_out": 0}
by_source: dict[str, dict] = {}
if METRICS_FILE.exists(): if METRICS_FILE.exists():
try: try:
for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines(): for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines():
@@ -107,11 +142,19 @@ def aggregate(window_seconds: int) -> dict:
continue continue
if obj.get("ts", 0) < cutoff_ms: if obj.get("ts", 0) < cutoff_ms:
continue continue
ti = int(obj.get("in") or 0)
to = int(obj.get("out") or 0)
calls += 1 calls += 1
tokens_in += int(obj.get("in") or 0) tokens_in += ti
tokens_out += int(obj.get("out") or 0) tokens_out += to
m = obj.get("model", "?") m = obj.get("model", "?")
by_model[m] = by_model.get(m, 0) + 1 by_model[m] = by_model.get(m, 0) + 1
# Alt-Eintraege ohne 'source' zaehlen als claude (Rueckwaerts-Kompat).
src = obj.get("source") or "claude"
b = by_source.setdefault(src, _src_bucket())
b["calls"] += 1
b["tokens_in"] += ti
b["tokens_out"] += to
except Exception as exc: except Exception as exc:
logger.warning("metrics aggregate: %s", exc) logger.warning("metrics aggregate: %s", exc)
return { return {
@@ -120,6 +163,7 @@ def aggregate(window_seconds: int) -> dict:
"tokens_in": tokens_in, "tokens_in": tokens_in,
"tokens_out": tokens_out, "tokens_out": tokens_out,
"by_model": by_model, "by_model": by_model,
"by_source": by_source,
} }
+2 -1
View File
@@ -132,6 +132,7 @@ def create_project(name: str, description: str = "") -> dict:
"name": name, "name": name,
"description": description.strip(), "description": description.strip(),
"status": "active", # active | ended | archived "status": "active", # active | ended | archived
"hidden": False, # optisch aus Listen ausblenden (bleibt nutzbar)
"created_at": now, "created_at": now,
"updated_at": now, "updated_at": now,
"last_activity_at": now, "last_activity_at": now,
@@ -148,7 +149,7 @@ def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all() projects = _load_all()
for p in projects: for p in projects:
if p["id"] == project_id: if p["id"] == project_id:
for k in ("name", "description", "status"): for k in ("name", "description", "status", "hidden"):
if k in patch and patch[k] is not None: if k in patch and patch[k] is not None:
p[k] = patch[k] p[k] = patch[k]
p["updated_at"] = _now() p["updated_at"] = _now()
+12 -1
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):
+85 -2
View File
@@ -149,6 +149,54 @@ def _num_to_words_de(n: int) -> str:
return str(n) return str(n)
# Vollstaendige Zehner fuer den generischen Konverter (inkl. 60-90).
_TENS_FULL_DE = {20: "zwanzig", 30: "dreissig", 40: "vierzig", 50: "fuenfzig",
60: "sechzig", 70: "siebzig", 80: "achtzig", 90: "neunzig"}
def _below_100_de(n: int) -> str:
if n in _NUM_WORDS_DE: # 0..20
return _NUM_WORDS_DE[n]
tens = (n // 10) * 10
ones = n % 10
if ones == 0:
return _TENS_FULL_DE[tens]
ones_word = "ein" if ones == 1 else _NUM_WORDS_DE[ones]
return f"{ones_word}und{_TENS_FULL_DE[tens]}"
def _below_1000_de(n: int) -> str:
if n < 100:
return _below_100_de(n)
h, rest = divmod(n, 100)
h_word = ("ein" if h == 1 else _NUM_WORDS_DE[h]) + "hundert"
return h_word if rest == 0 else h_word + _below_100_de(rest)
def _int_to_words_de(n: int) -> str:
"""Ganzzahl 0..999999 als zusammenhaengendes deutsches Wort
('dreiundzwanzig', 'einhundert', 'zweitausendsechsundzwanzig').
Groesser (IDs/Codes) als Ziffern lassen (der Aufrufer gated zusaetzlich)."""
if n < 0:
return "minus " + _int_to_words_de(-n)
if n < 1000:
return _below_1000_de(n)
if n < 1_000_000:
th, rest = divmod(n, 1000)
th_word = ("ein" if th == 1 else _below_1000_de(th)) + "tausend"
return th_word if rest == 0 else th_word + _below_1000_de(rest)
return str(n)
def _spell_standalone_int(m) -> str:
"""Regex-Callback: freistehende Ganzzahl ausschreiben. Sehr lange
Ziffernfolgen (IDs, Codes, Telefonnummern) bleiben Ziffern."""
s = m.group(0)
if len(s) > 6:
return s
return _int_to_words_de(int(s))
def _time_range_to_words(m): def _time_range_to_words(m):
"""'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'.""" """'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'."""
h1 = int(m.group(1)) h1 = int(m.group(1))
@@ -168,7 +216,7 @@ def _decimal_to_words(m):
"""'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'.""" """'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'."""
int_part = int(m.group(1)) int_part = int(m.group(1))
dec_part = m.group(2) dec_part = m.group(2)
int_word = _num_to_words_de(int_part) if 0 <= int_part <= 59 else str(int_part) int_word = _int_to_words_de(int_part) if int_part <= 999999 else str(int_part)
dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part) dec_words = " ".join(_num_to_words_de(int(d)) for d in dec_part)
return f"{int_word} komma {dec_words}" return f"{int_word} komma {dec_words}"
@@ -184,7 +232,7 @@ _UNIT_WORDS = [
(r'\bkm\b', 'Kilometer'), (r'\bkm\b', 'Kilometer'),
(r'\bm/s\b', 'Meter pro Sekunde'), (r'\bm/s\b', 'Meter pro Sekunde'),
(r'\bkg\b', 'Kilogramm'), (r'\bkg\b', 'Kilogramm'),
(r'\b°C\b', 'Grad Celsius'), (r'\b°C\b', ' Grad Celsius'),
(r'°C', ' Grad Celsius'), (r'°C', ' Grad Celsius'),
(r'\bMbps\b', 'Megabit pro Sekunde'), (r'\bMbps\b', 'Megabit pro Sekunde'),
(r'\bGbps\b', 'Gigabit pro Sekunde'), (r'\bGbps\b', 'Gigabit pro Sekunde'),
@@ -301,6 +349,15 @@ def clean_text_for_tts(text: str) -> str:
# werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden. # werden, koennen bei Bedarf explizit in _UNIT_WORDS uebersteuert werden.
t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t) t = _re_tts.sub(r'\b([A-Z]{2,5})\b', lambda m: " ".join(m.group(1)), t)
# Generisches Ausschreiben ALLER verbleibenden freistehenden Ganzzahlen
# ('23' → 'dreiundzwanzig', '100' → 'einhundert', '2026' → 'zwei-
# tausendsechsundzwanzig'). Laeuft NACH Uhrzeiten/Dezimalzahlen/Einheiten,
# die ihre Ziffern schon zu Woertern gemacht haben. Tag-unabhaengig — so
# klingen auch Antworten des lokalen LLM (das keine <voice>-Tags nutzt)
# sauber. Ziffernfolgen die an ., :, / kleben (IPs, Versionen, Uhrzeit-
# Reste) ueberspringen wir, um sie nicht zu zerreissen.
t = _re_tts.sub(r'(?<![\d.,:/])\d{1,6}(?![\d.,:/])', _spell_standalone_int, t)
# Anfuehrungszeichen # Anfuehrungszeichen
t = _re_tts.sub(r'["""„`]', '', t) t = _re_tts.sub(r'["""„`]', '', t)
@@ -1729,6 +1786,14 @@ class ARIABridge:
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
# Das 'thinking' oben ging mit der REQUEST-projectId raus, das 'idle' in
# _process_core_response aber mit der TURN-projectId (Brain kann den Turn
# umgeroutet haben, z.B. Voice-Sticky). Weichen sie ab, bekaeme der
# Request-Kontext nie sein idle → "ARIA denkt" bliebe dort haengen.
# Darum hier zusaetzlich fuer die Request-projectId ein idle.
if (project_id or "") != (turn_project_id or ""):
await self._emit_activity("idle", "", project_id=project_id)
if data.get("distilling"): if data.get("distilling"):
logger.info("[brain] Destillat laeuft im Hintergrund") logger.info("[brain] Destillat laeuft im Hintergrund")
@@ -2941,6 +3006,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 ""
+156 -6
View File
@@ -1028,6 +1028,18 @@
</div> </div>
</div> </div>
<div class="settings-section">
<h2>Lokales LLM & Claude-Ersparnis <button class="info-btn" onclick="showInfo('local-savings')" title="Wie wird die Ersparnis gerechnet?"></button></h2>
<div class="card">
<div style="font-size:11px;color:#8888AA;margin-bottom:10px;">
Turns, die das <strong style="color:#B392F0;">lokale LLM</strong> (Qwen) oder ein reiner <strong style="color:#F0B85E;">Skill-Fast-Path</strong> uebernommen hat — jeder davon ist ein Claude-Call, der NICHT passiert ist. Die lokalen Tokens laufen auf deiner eigenen Hardware (kein Subscription-Quota).
</div>
<div id="savings-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;font-size:12px;">
<div class="metric-cell"><div class="metric-label"></div><div class="metric-value"></div></div>
</div>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h2>Bootstrap & Migration <button class="info-btn" onclick="showInfo('bootstrap')" title="Was sind die drei Wege?"></button></h2> <h2>Bootstrap & Migration <button class="info-btn" onclick="showInfo('bootstrap')" title="Was sind die drei Wege?"></button></h2>
<div class="card" style="line-height:1.6;"> <div class="card" style="line-height:1.6;">
@@ -1083,6 +1095,7 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2> <h2 style="margin:0;">📁 Projekte</h2>
<div> <div>
<button class="btn secondary" id="toggle-hidden-btn" onclick="toggleShowHidden()" style="padding:4px 10px;font-size:11px;" title="Versteckte Projekte ein-/ausblenden">👁 Versteckte anzeigen</button>
<button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button> <button class="btn secondary" onclick="loadProjects()" style="padding:4px 10px;font-size:11px;">🔄 Aktualisieren</button>
<button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button> <button class="btn" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div> </div>
@@ -1411,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')]) {
@@ -1451,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');
@@ -1762,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;
} }
@@ -2990,16 +3065,28 @@
<div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div> <div style="color:${!activeId ? '#34C759' : '#E0E0F0'};font-weight:600;">💬 Hauptchat ${!activeId ? '<span style="font-size:10px;font-weight:800;">✓ AKTIV</span>' : ''}</div>
<div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div> <div style="color:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`); </div>`);
const showHidden = !!window.__showHidden;
let hiddenCount = 0, shownCount = 0;
for (const p of projects) { for (const p of projects) {
const hidden = !!p.hidden;
if (hidden) hiddenCount++;
// Versteckte nur zeigen wenn der Toggle an ist.
if (hidden && !showHidden) continue;
shownCount++;
const isActive = p.id === activeId; const isActive = p.id === activeId;
const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?'; const since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
const ended = p.status === 'ended'; const ended = p.status === 'ended';
// Auge: versteckt → 👁 (wieder sichtbar machen), sichtbar → 🙈 (verstecken).
const eyeIcon = hidden ? '👁' : '🙈';
const eyeTitle = hidden ? 'Wieder dauerhaft sichtbar machen' : 'Verstecken (aus Listen ausblenden)';
const eyeColor = hidden ? '#B392F0' : '#8888AA';
rows.push(` rows.push(`
<div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}"> <div style="padding:12px 14px;border-bottom:1px solid #1E1E2E;${isActive ? 'background:rgba(52,199,89,0.08);border-left:3px solid #34C759;' : ''}${hidden ? 'opacity:0.55;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;"> <div onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;"> <div style="color:${isActive ? '#34C759' : '#E0E0F0'};font-weight:600;">
📁 ${escapeHtml(p.name)} ${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''} ${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''} ${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div> </div>
@@ -3007,6 +3094,7 @@
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div> <div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div> </div>
<div style="display:flex;gap:4px;"> <div style="display:flex;gap:4px;">
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''} ${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button> <button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div> </div>
@@ -3015,13 +3103,43 @@
} }
if (projects.length === 0) { if (projects.length === 0) {
rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>'); rows.push('<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Noch keine Projekte. „+ Neues Projekt" oder sag ARIA „lass uns ein Projekt anlegen".</div>');
} else if (shownCount === 0) {
rows.push(`<div style="padding:18px;color:#555570;font-size:12px;text-align:center;">Alle ${hiddenCount} Projekte sind versteckt. Klick „👁 Versteckte anzeigen".</div>`);
} }
listEl.innerHTML = rows.join(''); listEl.innerHTML = rows.join('');
// Toggle-Button beschriften (mit Anzahl) + Zustand spiegeln.
const thBtn = document.getElementById('toggle-hidden-btn');
if (thBtn) {
thBtn.textContent = showHidden
? `🙈 Versteckte ausblenden${hiddenCount ? ' ('+hiddenCount+')' : ''}`
: `👁 Versteckte anzeigen${hiddenCount ? ' ('+hiddenCount+')' : ''}`;
thBtn.style.opacity = hiddenCount ? '1' : '0.5';
}
} catch (e) { } catch (e) {
listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`; listEl.innerHTML = `<div style="padding:14px;color:#FF6E6E;font-size:12px;">Fehler: ${e.message}</div>`;
} }
} }
function toggleShowHidden() {
window.__showHidden = !window.__showHidden;
loadProjects();
}
async function setProjectHidden(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);
// Beim Verstecken bleibt der „anzeigen"-Modus wie er ist; beim
// Wieder-Sichtbarmachen sieht man es sofort ohne Umschalten.
loadProjects();
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
}
async function switchProject(projectId) { async function switchProject(projectId) {
try { try {
await fetch('/api/brain/projects/switch', { await fetch('/api/brain/projects/switch', {
@@ -5897,6 +6015,29 @@
setCell('metrics-h24', d.h24); setCell('metrics-h24', d.h24);
setCell('metrics-d30', d.d30); setCell('metrics-d30', d.d30);
// Lokales LLM & Claude-Ersparnis — pro Fenster: wie viele Claude-Calls
// durch local/fast-path vermieden wurden + lokale Token-Last.
const savingsGrid = document.getElementById('savings-grid');
if (savingsGrid) {
const wins = [['letzte 1h', d.h1], ['letzte 5h', d.h5],
['letzte 24h', d.h24], ['letzte 30 Tage', d.d30]];
const z = { calls: 0, tokens_in: 0, tokens_out: 0 };
savingsGrid.innerHTML = wins.map(([label, w]) => {
const bs = (w && w.by_source) || {};
const loc = bs.local || z;
const fp = bs['fast-path'] || z;
const saved = (loc.calls || 0) + (fp.calls || 0);
const locTok = (loc.tokens_in || 0) + (loc.tokens_out || 0);
const color = saved > 0 ? '#3FB950' : '#555570';
return `<div class="metric-cell">
<div class="metric-label">${label}</div>
<div class="metric-value" style="color:${color};">${saved} Claude-Calls gespart</div>
<div class="metric-sub">lokal ${loc.calls || 0} · fast-path ${fp.calls || 0}</div>
<div class="metric-sub">${fmtTokens(locTok)} lokale Tokens (eigene HW)</div>
</div>`;
}).join('');
}
// 5h-Fenster gegen Plan-Limit: Warn-Klassen // 5h-Fenster gegen Plan-Limit: Warn-Klassen
const plan = getActivePlanLimit(); const plan = getActivePlanLimit();
const limit = plan.h5; const limit = plan.h5;
@@ -5939,6 +6080,15 @@
// Vor-definierte Info-Blocks // Vor-definierte Info-Blocks
const INFO_TEXTS = { const INFO_TEXTS = {
'local-savings': {
title: 'Lokales LLM & Claude-Ersparnis',
html: `
<p>Jeder Turn, den das <strong>lokale LLM</strong> oder ein <strong>Fast-Path</strong> (reiner Skill, ganz ohne LLM) beantwortet, ist ein Claude-Call, der <strong>nicht</strong> gegen dein Subscription-Quota laeuft.</p>
<p><strong>„Claude-Calls gespart"</strong> = Anzahl der local- + fast-path-Antworten im Zeitfenster. Konservativ gezaehlt: 1 Antwort = mindestens 1 gesparter Claude-Call (bei Tool-Use waeren es real oft mehr).</p>
<p><strong>„lokale Tokens"</strong> = Prompt+Antwort-Tokens, die auf deiner eigenen Gamebox-GPU verarbeitet wurden (echte <code>usage</code>-Zahlen vom Modell, sonst chars/4-Schaetzung). Die kosten dich nichts ausser Strom.</p>
<p>Fast-Path-Antworten (z.B. „nächstes Lied") haben ~0 Tokens — reiner Skill-Aufruf, kein Modell.</p>
`,
},
'local-llm': { 'local-llm': {
title: 'Lokales LLM — schnelle Antworten', title: 'Lokales LLM — schnelle Antworten',
html: ` html: `
+33 -1
View File
@@ -898,6 +898,11 @@ function connectRVS(forcePlain) {
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen // Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`); log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
broadcast({ type: "mode", payload: msg.payload }); broadcast({ type: "mode", payload: msg.payload });
} else if (msg.type === "project_changed") {
// Ein Projekt wurde geaendert (ARIA-Tool, App-Verstecken, …) → an die
// Browser-Tabs weiterreichen, damit die Projektliste live neu laedt
// (bisher wurde das NICHT geforwardet → Diagnostic aktualisierte nie).
broadcast({ type: "project_changed", payload: msg.payload || {} });
} else if (msg.type === "agent_activity") { } else if (msg.type === "agent_activity") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser // Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint. // weiterreichen, damit der Thinking-Indikator im Chat erscheint.
@@ -1597,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" });
@@ -2168,6 +2182,13 @@ const server = http.createServer((req, res) => {
// mehr als eine Minute. // mehr als eine Minute.
const isUpload = /\/attachments(\/upload)?$/.test(targetPath); const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
const timeout = isUpload ? 120000 : 60000; const timeout = isUpload ? 120000 : 60000;
// Projekt-Mutationen (create/switch/end/archive/patch inkl. hidden) sollen
// alle Clients live aktualisieren. Wir broadcasten nach Erfolg ein
// project_changed an RVS — App + andere Diagnostic-Tabs laden dann neu,
// ohne Seiten-Refresh (spiegelt das bestehende ARIA-project_changed-Event).
const isProjectMutation =
/^\/projects\b/.test(targetPath) &&
(req.method === "POST" || req.method === "PATCH" || req.method === "DELETE");
const proxyReq = http.request({ const proxyReq = http.request({
host: "aria-brain", host: "aria-brain",
port: 8080, port: 8080,
@@ -2178,6 +2199,17 @@ const server = http.createServer((req, res) => {
}, (proxyRes) => { }, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers); res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res); proxyRes.pipe(res);
if (isProjectMutation && proxyRes.statusCode >= 200 && proxyRes.statusCode < 300) {
try {
// An App + Bridge (RVS echot NICHT an den Sender) …
sendToRVS_raw({ type: "project_changed",
payload: { reason: "diagnostic" },
timestamp: Date.now() });
// … und an die eigenen Browser-Tabs (die haengen am Diag-Server, nicht
// direkt am RVS, kriegen den RVS-Broadcast also nicht).
broadcast({ type: "project_changed", payload: { reason: "diagnostic" } });
} catch (_) {}
}
}); });
proxyReq.on("error", (err) => { proxyReq.on("error", (err) => {
res.writeHead(503, { "Content-Type": "application/json" }); res.writeHead(503, { "Content-Type": "application/json" });