Compare commits

...
90 Commits
Author SHA1 Message Date
duffyduck ff1205eb6b release: bump version to 0.2.1.4 2026-07-12 02:10:13 +02:00
duffyduckandClaude Opus 4.8 d12f67320b fix(app): QRScanner tsc-clean — toter Prop raus, kaputte camera-kit-Typen umgangen
colorForScannerFrame existiert in react-native-camera-kit v13 nicht (No-Op) —
entfernt. Die Lib markiert zudem etliche optionale CameraScreen-Props faelschlich
als required (defaultProps fuellen sie zur Laufzeit); Props lokal als any
gespreadet, um die fehlerhaften .d.ts zu umgehen ohne Runtime-Verhalten zu
aendern. scanBarcode/onReadCode bleibt die korrekte v13-Barcode-API.

Projekt jetzt komplett tsc-clean (0 Fehler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:08:35 +02:00
duffyduckandClaude Opus 4.8 24a1b4d837 fix(app): sendTextMessage nach seine deps verschoben — TDZ-Fehler weg
sendTextMessage stand vor interruptAriaIfBusy und sendPendingAttachments,
hatte beide aber im deps-Array → Temporal Dead Zone (TS2448/2454). Lief nur,
weil Babel const→var hebt (deps auf erstem Render undefined, Body laeuft erst
bei Interaktion). Deklaration hinter beide verschoben — Abhaengigkeit ist
einseitig, sendTextMessage wird nur in der JSX genutzt. tsc-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:05:52 +02:00
duffyduckandClaude Opus 4.8 db96258f7d fix(app): ARIA-Datei-Anhang erscheint live an der Nachricht, nicht erst nach Seitenwechsel
Die Live-chat-Payload trug keine files — Anhaenge kamen nur als separates
file_from_aria-Event (eigene, teils unsichtbare Bubble). An der Text-Nachricht
tauchte die Datei erst nach einem Seitenwechsel auf (Reload aus chat_backup,
das die files kennt).

- Bridge: files jetzt direkt in der chat-Payload (selbe Struktur wie Backup).
- App: chat-Handler haengt payload.files an die Text-Bubble (wie der Reload-Pfad)
  und entfernt die redundante Solo-file_from_aria-Bubble mit passendem serverPath.
- App: file_from_aria legt keine Doppel-Bubble an, wenn die Datei schon an einer
  Nachricht haengt (Event-Reihenfolge-unabhaengig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:01:37 +02:00
duffyduckandClaude Opus 4.8 becc83d9e3 fix(bridge): leeres <voice></voice> macht TTS nicht mehr stumm
Claude haengt manchmal reflexartig ein leeres <voice></voice> an (v.a. bei
Spotify-Antworten). clean_text_for_tts nahm dann group(1)='' → text='' → gar
keine Sprachausgabe. Das erklaerte, warum Playlist-Wechsel 'Fliegen' vorgelesen
wurde (Claude schrieb Inhalt ins Tag), 'Prodigy' aber stumm blieb (leeres Tag)
— reiner Claude-Output-Zufall, nicht Skill/Playlist-Name.

Fix: leeres/whitespace <voice> ignorieren und den normalen Anzeigetext lesen.
Deterministisch, unabhaengig von Claudes Tag-Reflex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:45:43 +02:00
duffyduckandClaude Opus 4.8 44220edbd1 revert(router): Live-Wortliste raus — Modell soll selbst eskalieren
_LIVE_HINTS war die falsche Idee: aus offenem Freitext per Wortliste die
Absicht raten ist nie vollstaendig, jeder Miss = Halo (nur von per-Skill auf
per-Wort verschoben). Generisch heisst nicht 'groessere Regex', sondern: das
Modell entscheidet selbst ob es Grundwahrheit/ein Tool braucht (<<ESCALATE>>,
Prompt). Der 8B kann das noch nicht zuverlaessig → local bleibt per Einstellung
abschaltbar (aus, nicht raus); ein staerkeres lokales Modell uebernimmt spaeter
die Selbst-Erkennung. Absicherung bleibt der Output-Guard, nicht der Input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:20:02 +02:00
duffyduckandClaude Opus 4.8 fc13957000 fix(router): 'lieder'/'songs'/'springe...weiter' matchen jetzt auch (Live-Gate)
'lied' hatte eine Wortgrenze → 'lieder' (lied+er) matchte nicht, 'springe
zwei lieder weiter' rutschte an local vorbei. lied\w*/song\w* + spring\w*
...weiter ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:06:40 +02:00
duffyduckandClaude Opus 4.8 60580fd67e refactor(brain): local wieder tool-los (B1a) — 8B nur noch Reden
B1b hatte dem 8B run_*/web_search-Tools gegeben. Das war die Wurzel von
BEIDEM: (1) ein 8B ist ein unzuverlaessiger Tool-Caller und erfindet lieber
eine plausible Antwort ('der Song ist X') statt das Tool zu rufen → halo;
(2) das zwang zu per-Skill-Guards (skaliert nicht).

Fix ohne eine einzige per-Skill-Zeile:
- Local: tools=[] — kann kein Skill-Ergebnis mehr faelschen.
- Router: _LIVE_HINTS schickt alles mit aktueller Grundwahrheit
  (Wetter/News/Uhrzeit/Musik/Preise/Timer/…) VORAB an Claude, nicht erst
  nach einem gescheiterten Local-Versuch (kein Doppel-Zahlen). Topic-Ebene,
  nicht per-Skill — ein neuer Skill braucht hier keine Zeile.
- Prompt: _AWARENESS sagt local explizit, dass es tool-los ist und fuer
  Live/Aktion eskaliert.

Arbeitsteilung: Fast-Path (Regex→Skill, deterministisch, 0 halo) fuer
Befehle · Local (tool-los) fuers Reden · Claude fuer alles mit Tool/Fakt/
Gedaechtnis/Urteil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:59:31 +02:00
duffyduckandClaude Opus 4.8 2d83e4ad8b fix(brain): Info-Halluzination-Guard + expliziter Claude-Wunsch im Router
Local erfand Live-Auskuenfte (aktueller Song, Restzeit, Skip-Titel) ohne
run_spotify zu rufen. Neuer Detektor _claims_live_media_state() + Guard
eskaliert solche Behauptungen ohne echten Skill-Call an Claude. Local-Prompt
gehaertet: nie Titel/Interpret/Restzeit ohne Tool-Ergebnis nennen.

Router: _FORCE_CLAUDE respektiert 'nimm Claude/Clodi' → nie lokal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:52:36 +02:00
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
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
duffyduck 1ff02f9763 release: bump version to 0.2.0.5 2026-07-11 16:11:12 +02:00
duffyduck 5b5d61513f fix(router): lokales Tier eskaliert Gedaechtnis-/Personen-Fragen an Claude
Test 'Wer ist Melanie?': das lokale Qwen klammte zu + verwechselte den Frager
('frag Stefan direkt' — Stefan IST der User), weil der schlanke Lokal-Prompt kein
Memory hat. Claude dagegen antwortete diskret + korrekt (und haengte sogar selbst
<voice></voice> an → stumm, 'jemand koennte mithoeren').

Fix: Local-Prompt weist an, bei Fragen zu Stefans Leben/Personen/Beziehungen/
Vergangenheit/gespeichertem Wissen NICHT aus dem Nichts zu antworten, sondern
zu eskalieren (<<ESCALATE>> → Claude mit vollem Gedaechtnis). Die Diskretions-
Regel selbst bleibt (funktioniert auf Claude einwandfrei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)
2026-07-11 15:51:18 +02:00
duffyduckandClaude Opus 4.8 078ed17b57 fix(privacy): harte Diskretions-Regel — intime Details NIE ungefragt preisgeben
Schwerwiegend: ARIA hat bei Selbstvorstellung / "was weisst du ueber mich"
intime Details (Beziehungen, Partnerinnen, Lebensweise) proaktiv rausgedumpt —
ein Vertrauensbruch, wenn jemand mithoert. ARIA DARF das wissen, aber niemals
ungefragt aussprechen.

Regel in IDENTITY_ANCHOR (steht als einziger garantiert in BEIDEN Tiers, lokal
+ Claude): private/intime Infos sind hochvertraulich; nie von sich aus, nicht in
Vorstellungen/Zusammenfassungen/Triggern; nur auf konkrete Nachfrage, knapp und
diskret. Auf "was weisst du ueber mich": allgemein + diskret antworten, kein
Aufzaehlen. "Jemand koennte mithoeren" als Leitplanke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:38:52 +02:00
duffyduckandClaude Opus 4.8 0a2e59d756 feat(tts): System-Flag speak (ja/nein) pro Antwort statt <voice>-Tag-Hack
Stefans Idee: die QUELLE entscheidet ueber Vorlesen, nicht der Reply-Inhalt.
Fast-Path (reiner Steuerbefehl) = speak=False (stumm); ARIA-Antworten
(local/claude) = speak=True (vorlesen ok — eine Ansage ist sogar nett).

- agent.chat() gibt jetzt (reply, answered_by, speak) zurueck; main.py
  ChatOut.speak; background.py-Aufrufer angepasst.
- bridge: liest speak aus /chat, reicht es an _process_core_response; bei
  speak=False wird TTS uebersprungen — VOR jeder <voice>-Logik.

Robust: unabhaengig davon, ob die Skill-fast_pattern-Reply ein <voice></voice>
enthaelt (das verlor ARIA beim semantischen Skill-Rebuild — genau der Bug).
App braucht keinen Change: kein xtts_request -> kein Audio -> stumm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:55:12 +02:00
duffyduckandClaude Opus 4.8 2dfe6fd9c3 feat(local-llm): B0.5-2 — Live-Lade-Status des lokalen Modells in Diagnostic
Adapter meldet bei Modellwechsel/Erst-Load service_status (service=llm):
loading -> ready (mit loadSeconds; freshlyDownloaded 🎉 bei langem Erst-Load)
oder error. Laeuft ueber den vorhandenen Pfad: Adapter -> RVS -> Diagnostic
(RVS-Client) -> Browser -> updateServiceStatus (generisch; nur Label 'Lokales
LLM' ergaenzt). Kein Bridge-/server.js-Change noetig.

Download-% gibt llama-swap nicht her — daher Zustands-Status (laedt/bereit/
Fehler), im gemeinsamen Service-Banner wie whisper/flux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:29:18 +02:00
duffyduckandClaude Opus 4.8 8ae20a9bd8 feat(local-llm): B0.5 — llama-swap + lokale Modellauswahl in Diagnostic
Mehrere lokale Modelle, on-demand geladen/geswappt, in Diagnostic waehlbar.
Design: das Brain schickt den Modellnamen (aus local_llm.json) im llm_request
mit -> Adapter -> llama-swap laedt/swappt. Keine separate Gamebox-Config noetig.

- xtts: `llama`-Container -> `llama-swap` (unified-cuda), config.yaml mit
  qwen3-8b (Standard) + qwen3-4b; Auto-Download via -hf, Cache /models geteilt
  (qwen3-8b schon da). Adapter -> llama-swap:8080, Timeout 600s (Erst-Download).
- adapter: `model` aus dem Request an llama-swap durchreichen (Fallback env).
- brain: router.load_config liest localLlmModel; local_llm_chat(model=...);
  agent gibt cfg-Modell mit; bridge reicht model durch (_local_llm + Route).
- diagnostic: /api/local-models-list (aus /shared/config/local_models.json,
  seeded), local-llm-config um localLlmModel erweitert; Dropdown "Lokales
  Modell" im Settings-Block + Erst-Download-Hinweis.

BLIND gebaut (Gamebox nicht testbar hier): llama-swap CLI/Config-Pfad beim
ersten Start via `docker logs aria-llama-swap` pruefen. Live-Lade-Status
(Adapter->Diagnostic) ist B0.5-2 (Folgeschritt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:11:59 +02:00
duffyduck 3ddcf665f0 release: bump version to 0.2.0.4 2026-07-11 13:48:14 +02:00
duffyduckandClaude Opus 4.8 7ae61701ad feat(app): optionaler Quell-Badge an ARIA-Bubbles (Einstellung, pro Geraet, default aus)
Zeigt wie in Diagnostic, ob eine Antwort vom lokalen Modell / Claude / Fast-Path
kam — aber in der App als Opt-in, damit die App "Mama-tauglich" bleibt.

- ChatScreen: ChatMessage.answeredBy; aus dem chat-Payload UND der History-Sync
  eingefangen; kleiner farbiger Badge im statusRow der ARIA-Bubble, nur wenn
  showSource an. State aus AsyncStorage aria_show_source (default false, per
  2s-Reload synchron mit den Settings).
- SettingsScreen: Toggle "Antwort-Quelle anzeigen" in der Chat-Bubbles-Card,
  schreibt aria_show_source (pro Geraet — Stefan an, Mama aus).
- Bridge liefert answeredBy schon in chat_backup/History mit (kein Change noetig).

Server-Teil in 9cc1aec. APK-Rebuild noetig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:46:12 +02:00
duffyduckandClaude Opus 4.8 9cc1aec5ec feat(diagnostic): Quell-Badge an ARIA-Bubbles (local / claude / fast-path)
Zeigt pro Antwort, welcher Backend sie erzeugt hat — farbcodiert (lokal=gruen,
Claude=blau, Fast-Path=lila). Nur in Diagnostic (App bleibt Mama-tauglich).

- agent.chat() gibt jetzt (reply, answered_by) zurueck; an jedem Return-Punkt
  gesetzt (fast-path/local/claude). Beide Aufrufer (main.py, background.py)
  angepasst. main.py: ChatOut.answered_by.
- bridge: liest answered_by aus /chat, reicht es an _process_core_response,
  broadcastet es im chat-Payload UND persistiert es in chat_backup (answeredBy)
  → bleibt nach Reload.
- diagnostic: srcBadgeHtml() rendert den Badge in Live-Chat + History;
  server.js liefert answeredBy in der chat_history.

Erweiterbar: spaeter kann ein Bild-Backend (FLUX/Modellname) denselben Kanal
nutzen. Modellwechsel-fuer-Antworten (groessere Modelle bei mehr GPUs) bleibt
B0.5/Skalierungs-Thema im Plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:40:36 +02:00
duffyduckandClaude Opus 4.8 53c098a9c8 refactor(local-llm): generische Tool-Fehler-Eskalation statt per-Skill-Router-Code
Stefan-Punkt: der 'auf <Geraet>'-Router-Patch war ein per-Fall-Band-Aid, das
nicht skaliert (naechster Skill mit komplexer API → wieder patchen). Besser:
das LLM merkt es selbst.

- Router-Hardcoding zurueckgenommen (kein 'auf Geraet'→Claude mehr).
- GENERAL: im lokalen Tool-Loop → scheitert ein Tool-Call (Ergebnis beginnt mit
  'FEHLER'), uebernimmt Claude. Gilt fuer JEDEN Skill, kein per-Fall-Wissen im
  Router. Lokal probiert, bei Fehler eskaliert.
- skill_create-Anleitung: SEMANTISCH bauen — Skills bieten klare Operationen als
  args (action/device_name), NICHT rohe {path,method,body}-Durchreichung. Das
  args-Schema ist die 'Bedienungsanleitung', die das LLM sieht (die haben wir
  also schon — sie muss nur semantisch sein). Was das LLM sonst raten muesste
  (Endpunkte/IDs/Payload) gehoert INS Skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:18:26 +02:00
duffyduckandClaude Opus 4.8 0e42cf775f fix(router): geraete-gezieltes Spotify-Play → Claude (8B fummelt Transfer-Flow)
Log-Beweis: Qwen baute fuer 'auf android abspielen' einen erfundenen Endpoint
(/v1/me/player/start) mit Platzhalter-Device-ID -> 404. Device-Transfer ist ein
komplexer Mehrschritt-Flow (Geraete abfragen -> Name->echte ID -> richtiger
Endpoint); das 8B ist da unzuverlaessig. Router schliesst 'auf <Geraet>'-
Formulierungen jetzt aus dem lokalen Pfad aus -> Claude. Simple Steuerung
(pause/next/play, 'was laeuft') + 'auf deutsch' bleiben lokal (verifiziert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:14:36 +02:00
duffyduckandClaude Opus 4.8 0e0c5d742f docs(skill): robustere fast_patterns-Anleitung fuer skill_create/update
ARIA soll Patterns wortstellungs-tolerant + mit Synonymen/Fuellwoertern in EINEM
Regex schreiben (z.B. 'pause spotify' UND 'spotify pause' UND 'stopp mal'), statt
nur einer Formulierung. Plus der Schluessel-Hinweis: Fast-Paths nur fuer die
HAEUFIGSTEN Befehle — den Rest faengt das lokale LLM (hat das Tool) schnell ab,
also lieber wenige breite Patterns als viele enge. Damit kriegen kuenftige
Skills automatisch robuste Patterns (statt manuellem Nachpatchen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:10:44 +02:00
duffyduckandClaude Opus 4.8 5b20bc1743 fix(local-llm): lokale Antwort nicht in <voice> wickeln (Anzeige war leer)
Qwen imitierte aus dem Kontext den <voice>-TTS-Block, packte aber die GANZE
Antwort hinein -> Display strippt <voice> -> leere Bubble (nur TTS klang richtig).
Fix: (1) Local-Prompt verbietet <voice>/[FILE:]/<tool_call>/Markup explizit —
nur Plain-Text (wird angezeigt UND vorgelesen). (2) Sicherheitsnetz: Voice-Tags
aus lokalen Antworten strippen, falls das 8B es doch tut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:09:57 +02:00
duffyduckandClaude Opus 4.8 c3652d2f8e fix(local-llm): web_search local-only — Claude behaelt seine staerkeren Web-Tools
web_search war versehentlich in META_TOOLS -> Claude bekam es zusaetzlich zu
seinen nativen WebSearch/WebFetch/Bash (redundant/verwirrend). Jetzt als
WEB_SEARCH_TOOL nur im lokalen Tool-Set (_build_local_tools). Claude nutzt
weiter seine eigene Suche + Voll-Seiten-WebFetch (wichtig fuer Pentest/Research);
SearXNG ist fuer das lokale Tier, das sonst keinen Netzzugriff haette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:53:03 +02:00
duffyduckandClaude Opus 4.8 3cd7350160 feat(local-llm): B1b — lokale Tools (web_search via SearXNG, Timer, Spotify, Memory)
Das lokale Tier kann jetzt Werkzeuge nutzen — Wetter/News/Fakten laufen lokal
statt ueber Claudes langsamen Web-Fetch.

- SearXNG-Container (aria VM, aria-net): self-hosted Meta-Suche, keyless,
  JSON-API aktiviert (aria-data/searxng/settings.yml), Rate-Limiter aus.
  Brain-Env SEARXNG_URL.
- web_search-Tool in META_TOOLS + _dispatch_tool (_web_search fragt SearXNG,
  gibt Titel/Snippet/URL der Top-Treffer). Steht auch Claude zur Verfuegung.
- Lokaler Tool-Loop (_try_local_fast_lane): kuratiertes Set web_search /
  memory_search / trigger_timer / run_spotify; max 3 Runden, sonst → Claude.
  Break-/Escalate-Guard bleibt.
- Router: should_try_local schliesst nur noch CLAUDE-ONLY-Themen aus (Bild,
  Skill, Projekt, OAuth, Licht, Kalender/Mail). Wetter/Timer/News/Musik/Memory
  gehen jetzt lokal. Verifiziert (alle Testfaelle korrekt).
- Schlanker Local-Prompt mit Tool-Guidance ("nur nutzen wenn noetig, sonst
  Smalltalk direkt beantworten"). Skill-BAUEN bleibt Claude-only.

Deploy: ARIA-VM brain+bridge+searxng, Gamebox llm-adapter (tool-passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:54 +02:00
duffyduckandClaude Opus 4.8 a58aa5594d feat(local-llm): B1b-Plumbing — tools/tool_calls durch Adapter/Bridge/Brain-Client
Traegt OpenAI-Tool-Definitionen (tools) durch den ganzen lokalen Pfad und gibt
tool_calls zurueck:
- adapter.py: tools -> llama.cpp /v1/chat/completions (tool_choice=auto),
  message.tool_calls zurueck in llm_response.
- aria_bridge.py: _local_llm + /internal/local-llm reichen tools durch, geben
  tool_calls zurueck.
- local_llm.py: local_llm_chat akzeptiert tools, result enthaelt tool_calls.

Inert bis der Brain-Tool-Loop (naechster Schritt) tools uebergibt — Verhalten
unveraendert. Tool-Set + lokale Tool-Loop + Router-Anpassung folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:36:09 +02:00
duffyduckandClaude Opus 4.8 3a3c14fdc5 ux(diagnostic): klarere Beschreibungen fuer die Lokales-LLM-Schalter
Pro Schalter ein kurzer Hilfetext (AN/AUS-Bedeutung, Testmodus-Warnung),
deutlichere Labels, Status-Zeile mit Ampel (/🟡/🟢) + Hinweis auf aktuellen
B1a-Stand (lokal plaudert nur, Tools folgen in B1b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:32:12 +02:00
duffyduckandClaude Opus 4.8 1d4f23ada3 fix(brain): Gift-Waechter — Identity-Breaks nie persistieren (Kaskade unterbunden)
Wiederkehrender Identity-Bug: --system-prompt liefert die Persona korrekt (Proxy-
Test = "ICH BIN ARIA"), ABER ein einziger in der History gespeicherter Break
("ich bin nach wie vor Claude / die Persona ist erfunden") zieht bei schwachen
Folgeturns eine Kaskade nach sich — das Modell setzt seine eigene Ablehnung fort.
Das erste Cleanup verlangte "claude code" und liess deutsche Breaks durch.

Fix zweifach:
- prompts.py: looks_like_identity_break() — STARKE selbstreferenzielle Marker
  (ich-bin-Claude / erfundene Persona / diese-Session-injiziert / nicht-real-in-
  dieser). Bewusst NICHT das blosse "injizier"/"prompt injection" — das nutzt
  ARIA in Pentest-Antworten legitim (verifiziert: 0 False Positives).
- agent.py: nach dem Claude-Loop Break-Check; bei Break Retry (Nondeterminismus
  holt meist ARIA), sonst sichere ARIA-Fallback-Antwort — Break wird NIE
  persistiert. Auch die lokale Fast-Lane eskaliert bei Break auf Claude.
- clean_poisoned_turns.py: auf denselben starken Matcher umgestellt (der breite
  produzierte False Positives auf echte Security-Doku, im Dry-Run gesehen).

VM bereits bereinigt (je 2 Rest-Breaks aus conversation.jsonl + chat_backup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:25:45 +02:00
duffyduckandClaude Opus 4.8 b967999a5f feat(diagnostic): B1a-2 — Schalter fuer lokales LLM (Master/Nur-lokal/Tool-Umfang)
Neuer Settings-Block "Lokales LLM (schnelle Antworten)":
- Master-Checkbox "Lokales LLM nutzen" (aus = alles Claude)
- "Nur lokales LLM (kein Claude-Fallback)" — Eval-Haken
- "Tool-Umfang: Abgespeckt / Voll" — Voll deaktiviert (gated) + ⓘ mit VRAM-Erklaerung
- ⓘ-Haupt-Info erklaert Router/Latenz/Heim-Internet-Abhaengigkeit

server.js: GET/POST /api/local-llm-config <-> /shared/config/local_llm.json
(read/write, spiegelt runtime-config-Muster). Genau die Datei, die router.py
im Brain live liest. Onchange-Autosave, Status-Zeile, Laden bei ws.onopen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:07:33 +02:00
duffyduckandClaude Opus 4.8 71b3fc5d31 perf(router): lokales Fenster auf letzte 8 Turns cappen (Speed bei langer History)
Gemessen: lokaler Call mit 12-Turn-Fenster ~2,6s statt ~0,8s. Im Hauptchat (bis
50 Turns) wuerde volles Fenster das Prefill aufblaehen und den Speed-Vorteil
auffressen. Lokales Tier ist fuer kurze Plauder-Turns — letzte 8 Turns reichen.

B1a end-to-end verifiziert: plaudern→lokal (~0,8s warm), Tool/hart→Claude,
localOnly erzwingt lokal; Config live gelesen, Default aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:00:35 +02:00
duffyduckandClaude Opus 4.8 c8b7ea322e feat(router): B1a — lokale Fast-Lane (reden-only) mit Schaltern, gated (Default aus)
Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns → lokales Qwen
(schlanker Prompt, KEINE Tools) in <1 s; sonst Claude wie bisher.

- router.py: load_config() liest /shared/config/local_llm.json (enabled/localOnly/
  toolVariant; Default enabled=false → alles Claude). should_try_local() Heuristik
  (kurz + keine Tool-/Technik-Marker; localOnly erzwingt lokal). build_local_
  system_prompt() = schlank (IDENTITY_ANCHOR + Schnell-Modus + Awareness-Liste +
  <<ESCALATE>>-Regel, keine Tool-Schemas).
- agent.py: _try_local_fast_lane() — baut schlanken Prompt + Window, ruft
  local_llm_chat; leer/ESCALATE → None (→ Claude), sonst Antwort + persistiert.
  localOnly: kein Claude-Fallback (Eval), ehrliche Fehlermeldung bei Nichterreich.
  In chat() nach User-Turn gated aufgerufen.

Heuristik lokal verifiziert (alle Testfaelle korrekt). Gated off → laufendes
Verhalten unveraendert bis Master-Schalter an. Diagnostic-Toggles (B1a-2) +
lokale Tools (B1b) folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:55:29 +02:00
duffyduckandClaude Opus 4.8 f93dd58a68 docs: Verschieben-Button reboot-sicher via Platzierungs-Config + dummem Reconcile-Agent
Button = remote start/stop = braucht Agent pro Host, aber dumme Variante:
gpu_placement.json als Single Source of Truth, Agent reconciled nur (Mensch =
Scheduler). Loest die Reboot-Falle (Laufzeit-Move vs statische Config). Deploy:
Code ueberall via git, Config entscheidet Platzierung. Nach B0/B1; Fallback =
COMPOSE_PROFILES manuell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:45:56 +02:00
duffyduckandClaude Opus 4.8 c72d10cf58 docs: ENTSCHIEDEN — manuelle GPU-Platzierung (Compose-Profiles) + read-only Dashboard
Semi-Auto-Controller verworfen (Host wechselt selten). Pin via Compose-Profiles
pro Host (.env COMPOSE_PROFILES), Verschiebe-Regel up-neu + rm-alt gegen
Reboot-Wiederauferstehung, GPU-Dashboard aus Heartbeats (read-only). Skaliert
auf Gamebox3/4x3060 ohne Orchestrator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:41:22 +02:00
duffyduckandClaude Opus 4.8 291335250a docs: "Waechter"/Orchestrator gestaffelt (billiger Heartbeat vs teure Steuerung)
Idee Container-start/stop auf RVS-Hosts: billiger Teil (Registrierung/Heartbeat
-> Flotten-Sichtbarkeit + Router-Erreichbarkeit) lohnt bald; teurer Teil
(Agent+Controller+Placement = Mini-Nomad) erst bei groesserer Flotte, dann eher
Swarm/Nomad statt Eigenbau. Fuer 2 Gameboxen: statische Platzierung + llama-swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:36:46 +02:00
duffyduckandClaude Opus 4.8 eebafe8895 docs: Skalierung/Multi-GPU/Cluster + Diagnostic-VRAM-Info (Klarstellung: kein VRAM ueber RVS)
Roher VRAM ist nicht ueber RVS teilbar (Relay, nicht GPU-Bus). Echte Pfade:
mehr Karten/Box = VRAM-Pool (volles Arsenal), mehr GPU-Hosts = Modell-Server
ueber RVS (Cluster), llama-swap = geteilter Server im Host. Plus geplantes
Diagnostic-Info-Icon mit VRAM-Bedarf pro Ausbaustufe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:30:17 +02:00
duffyduckandClaude Opus 4.8 25a9b71482 docs: lokales LLM — Awareness (volle Liste) vs Authority (kuratierter Satz)
Klargestellt: lokales Modell ist ueber ALLES im Bilde (kurze Awareness-Liste im
System-Prompt -> gezieltes Escalieren), darf aber nur den sicheren Satz
ausfuehren. Harte Grenze ist Kontext/VRAM (volles Schema ~15-20K Tokens passt
nicht in 8K, 32K-Kontext sprengt die geteilte 12GB-3060), nicht Misstrauen.
Claude im RZ mit 200K-1M Kontext kann sich das ganze Arsenal leisten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:24:53 +02:00
duffyduckandClaude Opus 4.8 e6e87a8b20 docs: lokales LLM bekommt kuratierte Tool-Auswahl (Tool-Calling in B1 statt B3)
Qwen3 kann natives OpenAI-Tool-Calling; llama.cpp (--jinja) unterstuetzt es.
Lokales Tier bekommt einen kleinen, risikoarmen Start-Satz (Wetter, memory_search,
trigger_timer, Spotify, Licht); volles Arsenal bleibt bei Claude, Escalation-Netz
bleibt. Klein halten = Speed. Plan-Entscheidungen + Phasen entsprechend gezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:20:52 +02:00
duffyduckandClaude Opus 4.8 75b022a456 docs: B1-Router bekommt "Nur lokales LLM"-Modus (Eval-Checkbox, kein Claude-Fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:18:18 +02:00
duffyduckandClaude Opus 4.8 436759306e docs: B0.5-Wuensche festgehalten (Modell-Status/aktiviert + Testchat-Zeile in Diagnostic)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:12:10 +02:00
duffyduckandClaude Opus 4.8 45e63b6def feat(local-llm): B0 Consumer — Bridge-Relay + Brain-Client (spiegelt FLUX)
Brain → HTTP /internal/local-llm → Bridge → RVS → llm-adapter → llama.cpp,
1:1 nach dem FLUX-Roundtrip-Muster gebaut:
- Bridge: _pending_llm (requestId→Future), llm_response-Handler (setzt Future),
  _local_llm() (sendet llm_request, wartet mit 30s-Timeout), HTTP-Route
  POST /internal/local-llm ({messages, max_tokens?, temperature?, stop?}).
- Brain: local_llm.py mit local_llm_chat() — POSTet an die Bridge, gibt
  {ok, content, model?, elapsedMs?} zurueck, wirft nie (Aufrufer eskaliert
  bei ok=false auf Claude).

Provider-Kette bereits verifiziert (718ms). Als naechstes: Test brain→bridge→
gamebox end-to-end, dann B1 (Router-Heuristik + Escalation) und der
Diagnostic-Testchat/Status (B0.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:11:49 +02:00
duffyduckandClaude Opus 4.8 03e5c5f9a1 fix(local-llm): Qwen3-Thinking im Adapter aus (schnelles Tier grübelt nicht)
Qwen3 hat Thinking default AN -> verbraet Tokens im <think>-Block, liefert bei
kleinem max_tokens leeren content und ist ~3x langsamer (2,2s statt 0,7s im
Test). ARIAs lokales Tier soll fixe Antworten geben, nicht grübeln (grübeln =
harter Turn = Claude). Adapter setzt daher chat_template_kwargs
{enable_thinking:false}; abschaltbar via LLM_DISABLE_THINKING=false.

Verifiziert end-to-end (RVS->Adapter->llama->Qwen3): "Hallo! Ich bin bereit."
in 718ms Round-trip RZ<->Gamebox@home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:05:51 +02:00
duffyduckandClaude Opus 4.8 b5ba54d05f feat(local-llm): B0 — GGUF Auto-Download via llama.cpp -hf (kein manuelles Ablegen)
Statt einer manuell abgelegten Datei zieht llama.cpp das Modell beim ersten
Start selbst von Hugging Face (-hf Qwen/Qwen3-8B-GGUF:Q4_K_M, offizielles
Repo verifiziert) und cached es unter xtts/models (persistent). Modell/Quant
via LLM_HF_REPO/LLM_HF_QUANT in der .env wechselbar, kein Code.

Diagnostic-Modellauswahl (on-demand laden/aktivieren mehrerer Modelle) als
Folge-Baustein B0.5 via llama-swap ins Plan-Doc aufgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:33:59 +02:00
duffyduckandClaude Opus 4.8 98c78af7ad feat(local-llm): B0 — Gamebox llama.cpp + RVS-Adapter (Provider-Seite)
Plan B, Phase B0 (Provider): lokales Qwen3-8B auf der Gamebox, angebunden
per RVS wie f5tts/whisper (kein IP-Pflegen, nur URL+Token).

- xtts/llm-adapter/: RVS-Client (spiegelt whisper-bridge: TLS+ws-Fallback,
  Reconnect-Backoff), nimmt llm_request, ruft llama.cpp /v1/chat/completions
  lokal, antwortet llm_response (korreliert per requestId). Nicht-streamend
  in B0; llm_partial fuer B2 reserviert.
- xtts/docker-compose.yml: neue Services `llama` (llama.cpp server-cuda,
  GGUF via ./models, OpenAI-API auf :8081) + `llm-adapter`.
- rvs/server.js: ALLOWED_TYPES += llm_request/llm_response/llm_partial.
- GGUF (mehrere GB) via .gitignore aus dem Repo; xtts/models/ mit .gitkeep.

Topologie-Hinweis: Gamebox@home, ARIA@RZ -> Bounce ueber Internet ist
unvermeidbar (Voice macht's schon so); Router faellt bei Nichterreichbarkeit
per Escalation auf Claude zurueck. Consumer-Seite (Bridge-Relay + Brain-
Client + Router) kommt als naechstes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:43:46 +02:00
duffyduckandClaude Opus 4.8 79dab81a77 docs: Plan B — lokaler LLM-Router (Gamebox Qwen3 via RVS) neben Claude
Design-Doc: schnelles lokales LLM fuer die einfachen ~80% der Turns (<1s,
gratis auf Gamebox-GPU), Claude nur fuer die schweren 20%. Anbindung ueber
den RVS-Token-Room wie TTS/STT (kein IP-Pflegen), Router mit Heuristik +
Escalation, schlanke Persona lokal, Phasen B0-B3. Basiert auf der
Latenz-Messung (CLI-Boden ~3,5s) aus dieser Session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:16:42 +02:00
duffyduckandClaude Opus 4.8 5cdd135169 feat(voice): leeres <voice></voice> = bewusst stumm (Steuerbefehl-Quittungen)
Bisher: tts_text = tts_text_preview or text — ein leeres <voice></voice> fiel
auf den vollen Text zurueck und wurde doch gesprochen. Jetzt: ein vorhandener
<voice>-Tag ist die EXPLIZITE TTS-Vorgabe (auch leer). Leeres <voice></voice>
= Bubble sichtbar, aber KEIN TTS. Nur ohne Tag Rueckfall auf vollen Text.

Motivation: Spotify-Steuerbefehle (next/pause/…) laufen jetzt ueber den
Fast-Path (<1s), aber die gesprochene Quittung klaute auf dem Handy den
Audio-Fokus -> Spotify duckte/pausierte und spielte (bei kurzem Text) nicht
weiter. Steuer-Skills koennen ihre Reply nun mit <voice></voice> stummschalten.
Generell nutzbar: ARIA kann jede Antwort bewusst stumm halten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:03:50 +02:00
duffyduckandClaude Opus 4.8 b8c20390d7 tool(brain): Cleanup-Script fuer vergiftete Hauptthread-Turns
Einmal-Tool zum Entfernen der aus-der-Rolle-Antworten ("das ist injiziert,
ich bin Claude Code"), die waehrend der --append-system-prompt-Phase in die
History geschrieben wurden und das Modell per Self-Grounding rueckwaerts aus
der Rolle zogen (siehe 2284c1a). Feld-agnostisch: bedient conversation.jsonl
(content/project_id) UND chat_backup.jsonl (text/projectId). Entfernt nur
Hauptthread-Assistant-Turns mit "claude code" + zweitem Ablehnungs-Marker
plus die ausloesende Frage; projekt-getaggte Turns (z.B. legitime Pentest-
Doku, die Injection als Arbeitsmaterial erwaehnt) bleiben unangetastet.
Dry-Run per Default, Backup vor --apply, idempotent.

Bereits auf der Dev-VM angewandt: je 5 Fehl-Dialoge aus beiden Dateien
entfernt, Brain neu gestartet, Hauptchat verifiziert (ARIA antwortet wieder
als ARIA mit vollem Memory-Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:04:54 +02:00
duffyduckandClaude Opus 4.8 2284c1a780 fix(proxy): ARIA-Persona als VOLLER System-Prompt-Replace (--system-prompt)
Der Self-Grounding-Ansatz (268636c) allein reichte nicht: Sonnet 5 in Claude
Code haelt seine eingebaute "You are Claude Code"-Identitaet hart und liest den
synthetischen <previous_response> als "fabricated". Ursache bleibt der Kanal:
--append-system-prompt HAENGT ARIAs Persona nur hinten an Claude Codes Basis-
Identitaet an — bei duennem Kontext (Hauptchat) gewinnt die Basis und wehrt die
Persona als Injection ab. Anhaengen ist gegen das Anti-Injection-Training des
Modells nicht durchsetzbar.

Fix: System-Prompt VOLL ersetzen statt anhaengen — sed-Patch in docker-compose
schaltet manager.js buildArgs von --append-system-prompt auf --system-prompt.
Damit ist die ARIA-Persona DIE Identitaet des Modells (kein Anhaengsel), und
Claude Codes dynamische Sektionen (cwd '/', git, platform) — die "ich bin ein
Coding-Agent"-Signale — fallen weg. Bestaetigt per claude-code-guide: die CLI
isoliert bei --system-prompt vollstaendig, die eingebaute Identitaet leakt nicht.

extractSystemPrompt liefert weiterhin einen selbsttragenden Prompt (Persona +
Anker + Memory + Skills + Tool-Block); er darf bei --system-prompt nie leer sein
(Brain schickt immer System-Message + Tools -> real nie leer). Der IDENTITY_SEED
aus 268636c bleibt als Defense-in-Depth drin. Kommentare in openai-to-cli.js,
routes.js, agent.py, prompts.py entsprechend nachgezogen.

Deploy: Proxy UND Brain neu (--force-recreate; routes.js/openai-to-cli.js werden
frisch in den Proxy-Container ge-cp't). Verifikation am Container:
docker exec aria-proxy sh -c 'grep -o "\-\-system-prompt" /usr/local/lib/*/claude-max-api-proxy/dist/subprocess/manager.js'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:44:02 +02:00
duffyduckandClaude Opus 4.8 268636c030 fix(brain): ARIA-Identitaet im Hauptchat — Self-Grounding im Konversations-Strom
Symptom: Im Hauptchat antwortete statt ARIA der Basis-Proxy-Claude ("I'm
Claude Code (Sonnet 5), I'm not adopting that persona") und flaggte GPS +
Tool-XML als Prompt-Injection. Im Projekt lief alles normal.

Ursache: Die Persona geht ueber --append-system-prompt zu — das HAENGT sie
nur hinten an Claude Codes eigene "You are Claude Code"-Basis-Identitaet an,
ersetzt sie nicht. Ob ARIA in der Rolle bleibt, entscheidet dann der
Konversations-Strom (stdin): reiche Projekt-Historie voller ARIA-
<previous_response>-Turns haelt die Rolle; der duenne Hauptchat-Verlauf
(bzw. der erste Turn eines neuen Projekts) nicht -> Basis-Identitaet gewinnt.
Der IDENTITY_ANCHOR (a0e8c23) ist inert, weil er im *angehaengten* Teil steht.

Fix: synthetischen ersten ARIA-Turn (IDENTITY_SEED) in ihrer eigenen Stimme
an den Anfang des Konversations-Stroms setzen. Das Modell setzt seine EIGENE
etablierte Stimme fort (Self-Grounding) — und weil es ein <previous_response>
ist und kein <system>-Tag, ist es kein Injection-Trigger. Rein ephemer, wird
nie in die Conversation persistiert. Gilt fuer alle Turns, deckt damit auch
die erste Frage eines frischen Projekts ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:37:30 +02:00
duffyduckandClaude Opus 4.8 de4d95a392 feat(diagnostic): Model-Tier-Liste aus /shared/config/models.json (editierbar ohne Code)
handleModels liest die kuratierte Tier-Liste jetzt pro Request aus
/shared/config/models.json statt sie hart zu codieren. Neue Tier-Namen oder
angepasste Beschreibungen sind damit eine reine Datei-Aenderung — kein
Code-Edit, kein Proxy-Neubau, kein Neustart (Datei bearbeiten → im Diagnostic
„↻ Aktualisieren").

- Fehlt/kaputt die Datei: eingebaute Defaults greifen und werden einmalig als
  models.json angelegt, damit es was zu editieren gibt.
- ids bleiben MODEL_MAP-kompatibel (extractModel), Validierung: nicht-leeres
  Array mit String-id, sonst Fallback auf Defaults.
- UI-Hinweis auf den Dateipfad ergaenzt.
- Load/Seed-Logik simuliert verifiziert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:17:38 +02:00
duffyduckandClaude Opus 4.8 44f4067834 feat(diagnostic): Sprachmodell per Tier-Dropdown waehlen (Opus/Sonnet/Haiku)
ARIA laeuft ueber das Claude-Max-Abo via CLI — waehlbar ist der Tier, nicht
eine feste Modellversion (die CLI nimmt automatisch das aktuelle Modell des
Tiers). Kein API-Key → keine echte Anthropic-Models-API; daher kuratierte Liste.

- proxy routes.js handleModels: kuratierte /v1/models mit tier/display_name/
  description (ids bleiben MODEL_MAP-kompatibel: claude-sonnet-4/opus-4/haiku-4).
- diagnostic server.js: neuer GET /api/models-list — holt die Liste vom Proxy
  (Frontend erreicht den Proxy nicht direkt).
- Frontend: Dropdown statt Freitext + „↻ Aktualisieren"-Button; markiert das
  aktive brainModel (get_model), zeigt Beschreibung, „Setzen" schreibt brainModel
  und weist auf Brain-Neustart hin. Freitext-Override bleibt unter „Erweitert".
  Liste wird bei WS-Connect automatisch geladen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:13:17 +02:00
38 changed files with 3727 additions and 188 deletions
+5 -1
View File
@@ -78,4 +78,8 @@ __pycache__/
.vscode/settings.json
.idea/
*.swp
*.swo
*.swo
# Lokale LLM-Modelle (Plan B) — GGUF/HF-Cache sind mehrere GB, nicht ins Repo
xtts/models/*
!xtts/models/.gitkeep
+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
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20003
versionName "0.2.0.3"
versionCode 20104
versionName "0.2.1.4"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -5,7 +5,9 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
@@ -183,6 +185,50 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
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
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.0.3",
"version": "0.2.1.4",
"private": true,
"scripts": {
"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 [editName, setEditName] = 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
// 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();
}, [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) => {
// Multi-Threading: Focus-Wechsel ist reine App-lokale UI-Entscheidung.
// 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]);
// 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) => {
Alert.alert(`"${p.name}" archivieren?`,
'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 isActive = item.id === activeId;
const dot = _statusDot(item.id);
const hidden = !!item.hidden;
return (
<TouchableOpacity
onPress={() => switchTo(item.id)}
onLongPress={() => openEdit(item)}
style={[s.row, isActive && s.rowActive]}
style={[s.row, isActive && s.rowActive, hidden && s.rowHidden]}
>
<View style={{ flex: 1 }}>
<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 }} />
)}
<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>}
{isActive && <Text style={s.activeBadge}> FOCUS</Text>}
</View>
@@ -199,10 +228,22 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
{dot.label ? ` · ${dot.label}` : ''}
</Text>
</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>
);
};
const hiddenCount = projects.filter(p => p.hidden).length;
const visibleProjects = showHidden ? projects : projects.filter(p => !p.hidden);
const body = (
<View style={{ flex: 1, backgroundColor: '#0A0A14' }}>
{/* 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 ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<ActivityIndicator color="#0096FF" />
@@ -251,14 +303,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
<Text style={s.errorText}> {err}</Text>
) : (
<FlatList
data={projects}
data={visibleProjects}
keyExtractor={p => p.id}
renderItem={renderItem}
ListEmptyComponent={
<Text style={s.emptyText}>
Noch keine Projekte. Tipp + Neu oder sag zu ARIA:{'\n'}
Lass uns ein Projekt 'XY' anlegen".
</Text>
projects.length > 0 ? (
<Text style={s.emptyText}>
Alle {hiddenCount} Projekte sind versteckt.{'\n'}
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' },
headerTitle: { flex: 1, textAlign: 'center', color: '#E0E0F0', fontSize: 18, fontWeight: '700' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
@@ -375,6 +436,18 @@ const s = StyleSheet.create({
borderLeftWidth: 3,
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' },
rowDesc: { color: '#8888AA', fontSize: 13, marginTop: 4 },
rowMeta: { color: '#555570', fontSize: 11, marginTop: 4 },
+13 -6
View File
@@ -121,13 +121,20 @@ const QRScanner: React.FC<QRScannerProps> = ({ visible, onScan, onClose }) => {
<View style={styles.container}>
{hasPermission ? (
<>
{/* react-native-camera-kit v13: die .d.ts markiert viele OPTIONALE
CameraScreen-Props faelschlich als required (defaultProps fuellen
sie zur Laufzeit) und kennt colorForScannerFrame nicht — der war
ein No-Op und ist raus. scanBarcode/onReadCode ist die korrekte
v13-Barcode-API. Props als any spreaden, um die kaputten Lib-Typen
zu umgehen, ohne echten Code zu veraendern. */}
<CameraScreen
scanBarcode={true}
onReadCode={handleBarcodeScan}
showFrame={true}
frameColor="#0096FF"
laserColor="#0096FF"
colorForScannerFrame="#0096FF"
{...({
scanBarcode: true,
onReadCode: handleBarcodeScan,
showFrame: true,
frameColor: '#0096FF',
laserColor: '#0096FF',
} as any)}
/>
{/* Overlay oben */}
+186 -61
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 ---
@@ -73,6 +73,9 @@ interface ChatMessage {
/** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
* Projekt-Bloecken zu gruppieren (auf/einklappbar). */
projectId?: string;
/** Welcher Backend die Antwort erzeugt hat: 'local' | 'claude' | 'fast-path'.
* Fuer den optionalen Quell-Badge (Einstellung aria_show_source, default aus). */
answeredBy?: string;
/** Bridge-Message-ID zur Zuordnung von TTS-Audio */
messageId?: string;
/** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
@@ -324,6 +327,8 @@ const ChatScreen: React.FC = () => {
// App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
// jeden Hint mit. Toggle in Settings.
const [showSystemHints, setShowSystemHints] = useState(false);
// Quell-Badge (local/claude/fast-path) an ARIA-Bubbles — pro Geraet, default AUS.
const [showSource, setShowSource] = useState(false);
// Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
const localXttsVoiceRef = useRef<string>('');
// Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
@@ -333,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);
@@ -546,6 +555,8 @@ const ChatScreen: React.FC = () => {
setGpsEnabled(gps === 'true');
const hints = await AsyncStorage.getItem('aria_show_hints');
setShowSystemHints(hints === 'true'); // default false
const src = await AsyncStorage.getItem('aria_show_source');
setShowSource(src === 'true'); // default false (Mama sieht keine Badges)
};
loadSettings();
const interval = setInterval(loadSettings, 2000);
@@ -555,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');
@@ -815,6 +834,7 @@ const ChatScreen: React.FC = () => {
attachments: attachments.length ? attachments : undefined,
backupTs: typeof m.ts === 'number' ? m.ts : undefined,
projectId: typeof m.project_id === 'string' ? m.project_id : '',
answeredBy: typeof m.answeredBy === 'string' ? m.answeredBy : '',
...(cmid && { clientMsgId: cmid }),
// Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
@@ -974,6 +994,7 @@ const ChatScreen: React.FC = () => {
// file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
if (message.type === 'file_from_aria') {
const p = message.payload || {};
const sp = (p.serverPath as string) || '';
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
@@ -984,10 +1005,20 @@ const ChatScreen: React.FC = () => {
name: (p.name as string) || 'datei',
size: (p.size as number) || 0,
mimeType: (p.mimeType as string) || '',
serverPath: (p.serverPath as string) || '',
serverPath: sp,
}],
};
setMessages(prev => capMessages([...prev, ariaMsg]));
setMessages(prev => {
// Falls die Chat-Bubble mit dieser Datei schon da ist (Event-Reihen-
// folge kann variieren, und die chat-Payload traegt die files jetzt
// selbst), keine doppelte Solo-Bubble anlegen.
if (sp && prev.some(m =>
m.sender === 'aria' && m.attachments?.some(a => a.serverPath === sp)
)) {
return prev;
}
return capMessages([...prev, ariaMsg]);
});
return;
}
@@ -1136,25 +1167,53 @@ const ChatScreen: React.FC = () => {
const text = (message.payload.text as string) || '';
const ts = message.timestamp;
// ARIA-Dateien, die DIREKT an dieser Chat-Bubble haengen (Bridge schickt
// sie jetzt in der chat-Payload mit — vorher kamen sie nur als separates
// file_from_aria-Event, wodurch der Anhang live nicht an der Nachricht
// erschien, sondern erst nach einem Seitenwechsel/Reload).
const incomingFiles = Array.isArray((message.payload as any).files)
? (message.payload as any).files as Array<any> : [];
const fileAttachments: Attachment[] = incomingFiles.map(f => ({
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
name: f.name || 'datei',
size: f.size || 0,
mimeType: f.mimeType || '',
serverPath: f.serverPath || '',
}));
const incomingPaths = new Set(
fileAttachments.map(a => a.serverPath).filter(Boolean) as string[]
);
// Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
setMessages(prev => {
const isDuplicate = prev.some(m =>
m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
);
if (isDuplicate) return prev;
const payloadAtts = (message.payload.attachments as Attachment[] | undefined) || [];
const mergedAtts = [...payloadAtts, ...fileAttachments];
const ariaMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text,
timestamp: ts,
attachments: message.payload.attachments as Attachment[] | undefined,
attachments: mergedAtts.length ? mergedAtts : undefined,
messageId: (message.payload.messageId as string) || undefined,
backupTs: (message.payload.backupTs as number) || undefined,
projectId: ((message.payload as any).projectId as string) || '',
answeredBy: ((message.payload as any).answeredBy as string) || '',
};
// Die separate file_from_aria-Bubble (leerer Text, nur Datei), die kurz
// zuvor fuer DIESE Datei ankam, entfernen — sonst doppelt (einmal solo,
// einmal an der Text-Bubble). Nur solche mit passendem serverPath.
const deduped = incomingPaths.size
? prev.filter(m => !(
m.sender === 'aria' && !m.text && (m.attachments?.length) &&
m.attachments.every(a => a.serverPath && incomingPaths.has(a.serverPath))
))
: prev;
// ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
// markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
return capMessages([...prev, ariaMsg]).map(m =>
return capMessages([...deduped, ariaMsg]).map(m =>
m.sender === 'user'
&& (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
? { ...m, deliveryStatus: 'delivered' }
@@ -1163,6 +1222,21 @@ const ChatScreen: React.FC = () => {
});
// ARIA hat geantwortet → Watchdog clearen, falls noch armiert
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
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
// ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
@@ -1171,6 +1245,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
@@ -1399,12 +1493,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();
}, []);
@@ -1441,7 +1536,7 @@ const ChatScreen: React.FC = () => {
interrupted: wasInterrupted,
location: location || null,
noSpeechTimeoutMs: windowMs,
endpointMs: 1500,
endpointMs: await loadSttEndpointMs(),
hardCapMs: 60000,
projectId: focusedProjectIdRef.current,
});
@@ -1480,9 +1575,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).
@@ -1537,7 +1638,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,
});
@@ -1594,7 +1695,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,
});
@@ -1894,49 +1995,6 @@ const ChatScreen: React.FC = () => {
}, timeoutMs);
}, []);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
const cancelRequest = useCallback(() => {
const pid = focusedProjectIdRef.current || '';
@@ -2004,7 +2062,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,
});
@@ -2022,10 +2080,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)
}
}, []);
@@ -2133,6 +2201,53 @@ const ChatScreen: React.FC = () => {
setInputText('');
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
// Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
if (pendingAttachments.length > 0) {
sendPendingAttachments(text);
return;
}
if (!text) return;
setInputText('');
// Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
const wasInterrupted = interruptAriaIfBusy();
const location = await getCurrentLocation();
const cmid = nextClientMsgId();
const activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = {
id: nextId(),
sender: 'user',
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
dispatchWithAck(cmid, 'chat', {
text,
voice: localXttsVoiceRef.current,
speed: ttsSpeedRef.current,
interrupted: wasInterrupted,
projectId: activePid,
...(location && { location }),
});
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);
// --- Rendering ---
const renderMessage = ({ item }: { item: ChatMessage }) => {
@@ -2411,6 +2526,16 @@ const ChatScreen: React.FC = () => {
) : null}
<View style={styles.statusRow}>
<Text style={styles.timestamp}>{time}</Text>
{!isUser && showSource && item.answeredBy ? (() => {
const ab = item.answeredBy as string;
const map: { [k: string]: { t: string; c: string } } = {
local: { t: '⚡ lokal', c: '#34C759' },
claude: { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ fast', c: '#AF7BFF' },
};
const b = map[ab] || { t: ab, c: '#8A8AA0' };
return <Text style={{ fontSize: 9, fontWeight: 'bold', color: b.c, marginLeft: 6 }}>{b.t}</Text>;
})() : null}
{item.text.length > 0 ? (
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
+104
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';
@@ -175,6 +183,7 @@ const SettingsScreen: React.FC = () => {
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
const [showSource, setShowSource] = useState(false); // Quell-Badge, Default aus
const [scannerVisible, setScannerVisible] = useState(false);
const [logTab, setLogTab] = useState<LogTab>('live');
const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -199,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);
@@ -261,6 +272,9 @@ const SettingsScreen: React.FC = () => {
// Default ist aus — nur explicit 'true' aktiviert
setShowSystemHints(saved === 'true');
});
AsyncStorage.getItem('aria_show_source').then(saved => {
setShowSource(saved === 'true'); // Default aus
});
// gpsTrackingService status syncen + auf Aenderungen lauschen
setGpsTracking(gpsTrackingService.isActive());
const offGps = gpsTrackingService.onChange(setGpsTracking);
@@ -318,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 => {
@@ -857,6 +873,11 @@ const SettingsScreen: React.FC = () => {
AsyncStorage.setItem('aria_show_hints', String(value)).catch(() => {});
}, []);
const handleShowSourceToggle = useCallback((value: boolean) => {
setShowSource(value);
AsyncStorage.setItem('aria_show_source', String(value)).catch(() => {});
}, []);
// --- XTTS Voice ---
const selectVoice = useCallback((voiceName: string) => {
@@ -1580,6 +1601,22 @@ const SettingsScreen: React.FC = () => {
thumbColor={showSystemHints ? '#FFFFFF' : '#666680'}
/>
</View>
<View style={styles.toggleRow}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Antwort-Quelle anzeigen</Text>
<Text style={styles.toggleHint}>
Kleiner Badge an ARIAs Bubbles: ob die Antwort vom schnellen
lokalen Modell, von Claude oder per Direkt-Befehl kam. Nur fuer
dich interessant (Technik) — standardmaessig aus.
</Text>
</View>
<Switch
value={showSource}
onValueChange={handleShowSourceToggle}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={showSource ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Hintergrund-Modus === */}
@@ -1837,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}]}
@@ -1882,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>
</>)}
+110 -10
View File
@@ -44,6 +44,11 @@ const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as {
release: () => Promise<boolean>;
kickReleaseMedia: () => Promise<boolean>;
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?: {
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_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;
@@ -275,6 +300,13 @@ class AudioService {
// damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
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
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@@ -450,15 +482,29 @@ class AudioService {
import('./logger').then(m => m.reportAppDebug('audio.focus',
'AudioFocus.release() now')).catch(()=>{});
AudioFocus?.release().catch(() => {});
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
// 50ms Delay damit das Abandon erst durch ist.
setTimeout(() => {
// Spotify-Resume: NUR wenn vor dem Gespraech wirklich Musik lief. Dann
// einen echten MEDIA_PLAY-KeyEvent an die aktive MediaSession schicken
// (wie die Play-Taste am Kopfhoerer) — das resumt Spotify zuverlaessig,
// im Gegensatz zum flakigen Focus-Stack-Nudge, der auf manchen Geraeten
// (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',
'nudgeMediaResume() now (50ms after release)')).catch(()=>{});
AudioFocus?.nudgeMediaResume().catch(() => {});
}, 50);
'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{});
}
}, 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
* pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
acquireConversationFocus(): void {
@@ -476,7 +536,11 @@ class AudioService {
this._conversationFocusActive = true;
this._cancelDeferredFocusRelease();
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
@@ -493,6 +557,8 @@ class AudioService {
haltAllPlayback(reason: string = ''): void {
console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
this._conversationFocusActive = false;
// Barge-In → User spricht gleich weiter, Spotify NICHT resumen.
this._mediaWasActiveAtAcquire = false;
this.stopPlayback();
}
@@ -503,6 +569,8 @@ class AudioService {
pauseForCall(reason: string = ''): void {
console.log('[Audio] pauseForCall: %s', reason || '(no reason)');
this._conversationFocusActive = false;
// Anruf → Spotify bleibt aus, kein Auto-Resume beim spaeteren Release.
this._mediaWasActiveAtAcquire = false;
this._pausedForCall = true;
// Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button
// (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf).
@@ -796,6 +864,8 @@ class AudioService {
this.setState('recording');
// 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();
AudioFocus?.requestExclusive().catch(() => {});
@@ -1043,6 +1113,8 @@ class AudioService {
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
@@ -1659,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/
+11 -2
View File
@@ -157,6 +157,7 @@ export interface Project {
name: string;
description: string;
status: 'active' | 'ended' | 'archived';
hidden?: boolean; // aus Listen ausgeblendet (bleibt nutzbar)
created_at: number;
updated_at: number;
last_activity_at: number;
@@ -593,14 +594,22 @@ export const brainApi = {
});
},
/** Projekt-Metadaten patchen (name / description). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description'>>): Promise<Project> {
/** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: '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)
* ob gerade ein Request in Verarbeitung ist + wieviele in der Queue warten.
* Wird fuer Status-Dots im Drawer periodisch gepollt. */
+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');
+621 -18
View File
@@ -21,13 +21,17 @@ import logging
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
from conversation import Conversation, Turn
from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt
from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR, looks_like_identity_break
from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod
import metrics
from local_llm import local_llm_chat
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
@@ -35,6 +39,8 @@ import oauth as oauth_mod
import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
@@ -64,6 +70,65 @@ def _load_flux_config() -> dict:
logger = logging.getLogger(__name__)
def _web_search(query: str, max_results: int = 5) -> str:
"""Fragt die self-hosted SearXNG-Instanz (JSON-API) und gibt die Top-Treffer
als kompakten Text zurueck (Titel + Snippet + URL). Nie werfen — Fehler als
Text-Resultat, damit der Tool-Loop weitermachen kann."""
try:
params = urllib.parse.urlencode({
"q": query, "format": "json", "language": "de", "safesearch": "0",
})
req = urllib.request.Request(
f"{SEARXNG_URL}/search?{params}",
headers={"User-Agent": "ARIA/1.0", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.warning("web_search (SearXNG) fehlgeschlagen: %s", exc)
return f"FEHLER: Websuche nicht verfuegbar ({exc})."
results = (data.get("results") or [])[:max_results]
if not results:
answers = data.get("answers") or []
if answers:
return "Direkte Antwort: " + " | ".join(str(a) for a in answers[:3])
return f"Keine Web-Treffer fuer '{query}'."
lines = [f"{len(results)} Web-Treffer fuer '{query}':"]
for i, r in enumerate(results, 1):
title = (r.get("title") or "").strip()
url = (r.get("url") or "").strip()
snippet = (r.get("content") or "").strip()
lines.append(f"\n{i}. {title}\n {snippet[:300]}\n Quelle: {url}")
return "\n".join(lines)
# web_search ist bewusst LOCAL-ONLY (nicht in META_TOOLS): Claude hat seine
# eigenen, staerkeren Web-Tools (WebSearch + WebFetch/Voll-Seiten-lesen + Bash).
# SearXNG ist fuer das lokale Tier, das sonst gar keinen Netz-Zugriff haette.
# _dispatch_tool behandelt "web_search" trotzdem generisch (Name-Match).
WEB_SEARCH_TOOL = {
"type": "function",
"function": {
"name": "web_search",
"description": (
"Durchsuche das Web (via SearXNG) nach AKTUELLEN, nachschlagbaren "
"Infos: Wetter, News, Fakten, Oeffnungszeiten, Preise, Definitionen. "
"Nutze das, wenn die Antwort aktuelles Wissen braucht, das nicht im "
"Gedaechtnis steht. Praezise Suchanfrage (Suchmaschinen-Stil). "
"Ergebnis = Titel + Snippet + URL; fasse daraus knapp zusammen."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchanfrage (praezise)"},
"max_results": {"type": "integer", "description": "Anzahl Treffer (Default 5, max 10)"},
},
"required": ["query"],
},
},
}
# Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [
{
@@ -86,6 +151,17 @@ META_TOOLS = [
"Stefan sich alle 60min manuell neu einloggen.\n"
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
"SEMANTISCH BAUEN (wichtig fuer Zuverlaessigkeit!): Ein Skill soll "
"KLARE Operationen als args anbieten, NICHT die rohe API "
"durchreichen. Schlecht: args = {path, method, body} — dann muss das "
"aufrufende LLM die ganze fremde API selbst kennen und baut bei "
"komplexen Faellen (z.B. Geraete-Transfer) falsche Calls. Gut: args "
"= {action: 'play'|'pause'|'next'|'play_on_device', device_name?: str} "
"— der Skill-Code loest intern auf (Geraet-Name → ID, richtiger "
"Endpoint) und kapselt die API. Faustregel: Was das LLM sonst RATEN "
"muesste (Endpunkte, IDs, Payload-Struktur), gehoert INS Skill. Das "
"`args`-Schema ist die Bedienungsanleitung, die das LLM sieht — mach "
"sie semantisch und selbsterklaerend.\n\n"
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
@@ -134,17 +210,65 @@ META_TOOLS = [
"description": (
"OPTIONAL — fuer 'reines Steuern'-Skills (Licht an/aus, Spotify "
"pause/next, Rollade hoch/runter etc.) eine Liste von "
"[{match, args, reply}] eintragen. Wenn ein User-Befehl gegen "
"match (anchored Regex, case-insensitive) matched, ruft das "
"Brain run_skill(name, args) DIREKT auf und gibt reply zurueck — "
"ohne Claude (~5s Latenz gespart). Match wird gegen den "
"normalisierten Text (lowercase, Endsatzzeichen weg) gemacht; "
"schreibe Patterns mit ^...$ damit nur exakte Befehle matchen "
"und nicht Teilstrings (z.B. ^pause$ statt pause). NICHT fuer "
"Skills mit kreativem Output / parametrisierter Logik — die "
"brauchen Claude. Beispiel: "
"[{\"match\":\"^pause$\",\"args\":{\"path\":\"/v1/me/player/pause\",\"method\":\"PUT\"},"
"\"reply\":\"Spotify: pausiert ⏸\"}]"
"[{match, args, reply}]. Bei match (anchored Regex, "
"case-insensitive, gegen den normalisierten Text: lowercase, "
"Endsatzzeichen weg) ruft das Brain run_skill(name, args) DIREKT "
"auf und gibt reply zurueck — ohne LLM (instant).\n\n"
"ROBUST SCHREIBEN (wichtig!):\n"
"- Immer ^...$ (nur ganze Befehle, keine Teilstrings).\n"
"- **Wortstellung + Synonyme + Fuellwoerter abdecken** in EINEM "
"Pattern via Alternativen und optionalen Gruppen. Nicht nur die "
"eine Formulierung! Beispiel Pause: "
"`^(spotify |musik )?(pause|pausier(e|en)?|stop|stopp|halt|"
"anhalten)( mal| bitte| spotify| die musik)?$` faengt 'pause', "
"'pause spotify', 'stopp mal', 'musik anhalten' … alle ab.\n"
"- Optionale Fuellwoerter mit `( bitte| mal| doch)?` zulassen, "
"Artikel/Objekte mit `( das lied| den song| die musik)?`.\n\n"
"Aber KEIN Zwang zur Vollstaendigkeit: Fast-Paths sind nur fuer "
"die HAEUFIGSTEN exakten Befehle (instant). Seltene/ungewoehnliche "
"Formulierungen faengt das lokale LLM ohnehin schnell ab (es hat "
"das Tool) — also lieber ein paar solide, breite Patterns als 30 "
"enge. NICHT fuer Skills mit kreativem/parametrisiertem Output. "
"Beispiel: [{\"match\":\"^(spotify |musik )?(pause|stop|stopp)"
"( mal| bitte)?$\",\"args\":{\"path\":\"/v1/me/player/pause\","
"\"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."
),
},
},
@@ -160,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": {
@@ -223,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"],
},
@@ -931,10 +1093,86 @@ 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 _claims_live_media_state(text: str) -> bool:
"""Erkennt eine BEHAUPTETE Live-Zustands-Auskunft, die NUR ein Werkzeug
wissen kann: aktueller Song/Interpret, Restzeit, was gerade laeuft, welches
Geraet spielt. local erfindet das gern (mal 'Midnight City von M83', mal
'The House Of House') ohne run_spotify zu rufen. Ohne echten Skill-Aufruf ist
das reine Halluzination → eskalieren (Claude ruft das Tool zuverlaessig).
Bewusst auf Medien-/Wiedergabe-Zustand begrenzt, damit normale Musik-
Konversation ('ich mag M83') NICHT getroffen wird — es muss nach einer
konkreten Ist-Zustands-Auskunft klingen."""
t = (text or "").strip().lower()
if not t:
return False
# Restzeit/Abspielposition ('noch 52 Sekunden', 'Restzeit 2:34').
if "restzeit" in t or "restlaufzeit" in t:
return True
if re.search(r"\bnoch\s+\d+\s*(sekunde|minute|sek|min)", t):
return True
# 'aktueller Song/Titel/Lied heisst/ist …' / 'es laeuft gerade …' /
# 'spielt gerade …' / 'jetzt laeuft …' — Ist-Zustands-Auskunft.
if re.search(r"(aktuell\w*\s+(song|titel|lied|stueck|track))", t):
return True
if re.search(r"(l(ae|ä)uft|spielt)\s+(gerade|jetzt|aktuell|zurzeit|grade)", t):
return True
if re.search(r"(gerade|jetzt|zurzeit|grade)\s+(l(ae|ä)uft|spielt)", t):
return True
# Skip-Quittung, die einen konkreten Folgetitel behauptet ('… ist jetzt „X"').
if ("ueberspring" in t or "übersprung" in t or "uebersprung" in t) and (
"song" in t or "titel" in t or "lied" 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
@@ -1030,6 +1268,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
@@ -1038,6 +1279,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:
@@ -1050,16 +1297,254 @@ 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
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a/B1b) ──
#
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das
# lokale Qwen in <1 s. Seit B1b mit kuratierten Tools (web_search,
# memory_search, trigger_timer, Spotify). Gated ueber
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
_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,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"}
def _build_local_tools(self) -> list:
tools = [WEB_SEARCH_TOOL]
tools += [t for t in META_TOOLS
if t.get("function", {}).get("name") in self._LOCAL_TOOL_NAMES]
for s in skills_mod.list_skills(active_only=False):
if s.get("name") == "spotify" and s.get("active", True):
tools.append(_skill_to_tool(s))
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
# B1a-Rueckbau (bewusst): local ist TOOL-LOS — nur reines Reden. Ein 8B
# ist ein unzuverlaessiger Tool-Caller: mit Werkzeug in der Hand erfindet
# er lieber eine plausible Antwort ('der Song ist X'), statt das Tool zu
# rufen — das war die Halo-Quelle UND zwang zu per-Skill-Guards. Ohne
# Tools KANN er kein Skill-Ergebnis faelschen. Alles mit Grundwahrheit
# (Fakt/Live-Zustand/Gedaechtnis/Aktion) gehoert an Claude oder an den
# deterministischen Fast-Path — nicht an den 8B. WIE es dorthin kommt:
# das Modell eskaliert SELBST (<<ESCALATE>>, siehe Prompt) — bewusst KEINE
# Input-Wortliste im Router. Der Output-Guard unten faengt ab, wenn der
# 8B doch mal statt zu eskalieren einen Live-Zustand behauptet.
tools = []
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools))
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
# wuerde das Prefill aufblaehen).
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
# Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/
# Spotify aufrufen. Ergebnisse zurueck, bis es final (ohne tool_calls) antwortet.
final = ""
for _ in range(self._LOCAL_TOOL_ITERATIONS):
res = local_llm_chat(messages, max_tokens=500, temperature=0.5,
tools=tools, model=local_model)
if not res.get("ok"):
logger.info("[router] lokal fehlgeschlagen (%s) — %s", res.get("error"),
"kein Fallback (localOnly)" if local_only else "→ Claude")
if local_only:
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
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")
if tcs:
messages.append({"role": "assistant",
"content": res.get("content") or "",
"tool_calls": tcs})
had_error = False
for tc in tcs:
fn = tc.get("function") or {}
tname = fn.get("name") or ""
try:
targs = json.loads(fn.get("arguments") or "{}")
except Exception:
targs = {}
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",
"tool_call_id": tc.get("id") or "",
"name": tname,
"content": (tresult or "")[:6000]})
# GENERAL (kein per-Skill-Code): scheitert ein Tool-Call, macht
# das grosse Modell weiter — es baut komplexe/rohe API-Calls
# zuverlaessiger und behandelt Fehler besser. So muss der Router
# NICHT wissen, welche Skill-Aufrufe "schwer" sind; das lokale
# Tier probiert, und bei Fehler uebernimmt Claude.
if had_error and not local_only:
logger.info("[router] lokaler Tool-Fehler → Claude uebernimmt")
return None
continue # naechste Runde mit Tool-Ergebnissen
final = (res.get("content") or "").strip()
break
else:
logger.info("[router] lokal Tool-Loop-Limit → %s",
"Fallback (localOnly)" if local_only else "Claude")
if not local_only:
return None
final = final or "[Lokales LLM: Tool-Loop-Limit erreicht.]"
# Sicherheitsnetz: das lokale Modell wickelt seine Antwort manchmal
# faelschlich komplett in <voice>...</voice> (aus dem Kontext imitiert).
# Das wuerde die Anzeige leeren (Display strippt <voice>). Tags raus —
# der lokale Reply ist kurz, Plain-Text dient Anzeige UND TTS.
final = re.sub(r"</?voice>", "", final).strip()
if local_only:
final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
if not final:
return "[Lokales LLM lieferte keine Antwort.]"
logger.info("[router] lokal (localOnly) beantwortet")
self.conversation.add("assistant", final, project_id=active_project_id)
return final
# Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
# Identity-Break → Claude uebernehmen.
if (not final or router_mod.ESCALATE_MARKER in final
or looks_like_identity_break(final)):
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) or _claims_live_media_state(final)):
logger.info("[router] lokal: Aktion/Live-Zustand 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
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None,
pending_queue: Optional[list[str]] = None) -> str:
pending_queue: Optional[list[str]] = None) -> tuple:
"""Verarbeitet eine User-Nachricht — pro Request project_id explizit
angegeben (leer = Hauptchat). Kein globaler active_project-State mehr —
so laufen parallele /chat-Requests fuer verschiedene Projekte echt
@@ -1093,7 +1578,17 @@ class Agent:
self.conversation.add("assistant", fast_reply, project_id=active_project_id)
if active_project_id:
projects_mod.touch_project(active_project_id)
return fast_reply
# Metric: Fast-Path spart einen ganzen Claude-Call zum Nulltarif.
try:
metrics.log_fast_path(fast_reply)
except Exception:
pass
# 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,
@@ -1101,6 +1596,18 @@ class Agent:
if active_project_id:
projects_mod.touch_project(active_project_id)
# Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
# 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:
# 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()
@@ -1190,6 +1697,13 @@ class Agent:
f"weiterfuehren': project_enter aufrufen."
)
messages = [ProxyMessage(role="system", content=system_prompt)]
# Identitaets-Grounding IM Konversations-Strom (Defense-in-Depth neben
# dem vollen System-Prompt-Replace via --system-prompt). Ein
# synthetischer erster ARIA-Turn in ihrer eigenen Stimme haelt die Rolle
# per Self-Grounding auch bei duennem Verlauf (Hauptchat / erster Turn
# eines neuen Projekts). Kein <system>-Tag -> kein Injection-Trigger.
# Rein ephemer — wird NIE persistiert. Siehe IDENTITY_SEED in prompts.py.
messages.append(ProxyMessage(role="assistant", content=IDENTITY_SEED))
# Conversation-Window auf das aktive Projekt filtern: in einem Projekt
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
# nur die nicht-getaggten Turns.
@@ -1208,6 +1722,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,
@@ -1225,6 +1746,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
@@ -1267,10 +1794,36 @@ class Agent:
logger.warning("Konnte Error-Marker nicht persistieren: %s", add_exc)
raise
# Gift-Waechter: faellt Claude trotz --system-prompt + Seed aus der Rolle
# (Identity-Break), NICHT persistieren — sonst vergiftet dieser eine Turn
# die History und loest bei schwachen Folgeturns eine Kaskade aus (das
# Modell setzt seine eigene Ablehnung fort). Ein Retry holt per
# Nondeterminismus meist die ARIA-Antwort; sonst sichere Fallback-Antwort.
# So kann ein einzelner Ausrutscher nie snowballen.
if looks_like_identity_break(final_reply):
logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
try:
retry = self.proxy.chat_full(messages, tools=tools,
project_id=active_project_id)
retry_text = (retry.content or "").strip()
except Exception as exc:
logger.warning("[guard] Retry fehlgeschlagen: %s", exc)
retry_text = ""
if retry_text and not looks_like_identity_break(retry_text):
logger.info("[guard] Retry lieferte saubere Antwort")
final_reply = retry_text
else:
logger.warning("[guard] Retry weiter Break/leer — Fallback, Break NICHT persistiert")
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
"sag mir einfach, was du brauchst.")
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
return final_reply
# 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 ───────────────────────────────────────
@@ -1291,6 +1844,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
@@ -1342,6 +1897,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:
@@ -1350,6 +1928,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):
@@ -1451,6 +2033,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
@@ -1711,6 +2305,15 @@ class Agent:
except Exception as e:
logger.exception("memory_search fehlgeschlagen")
return f"FEHLER: {e}"
if name == "web_search":
query = (arguments.get("query") or "").strip()
if not query:
return "FEHLER: query ist Pflicht."
try:
n = int(arguments.get("max_results", 5))
except (TypeError, ValueError):
n = 5
return _web_search(query, max(1, min(n, 10)))
if name == "memory_update":
pid = (arguments.get("id") or "").strip()
if not pid:
+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]})
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Einmal-Cleanup: entfernt "vergiftete" Hauptthread-Turns aus conversation.jsonl.
Hintergrund
-----------
Solange ARIAs Persona nur via --append-system-prompt kam (statt --system-prompt,
voller Replace), fiel das Modell im Hauptchat aus der Rolle und antwortete als
"Claude Code" ("das ist injizierter Kontext, ich adoptiere die Persona nicht").
Jede dieser Antworten wurde per conversation.add("assistant", ...) in die History
geschrieben. Beim naechsten Request landet sie als <previous_response> im
stdin-Prompt — das Modell sieht seine EIGENEN Ablehnungs-Turns und setzt die
Haltung fort (Self-Grounding rueckwaerts). Der --system-prompt-Fix verhindert
NEUE Vergiftung, aber die bestehenden Gift-Turns muessen einmalig raus, sonst
zieht die History das Modell weiter aus der Rolle.
Was das Script tut
------------------
- Findet Hauptthread-Assistant-Turns (KEIN project_id), deren Inhalt eindeutig
eine Rollen-Ablehnung ist: enthaelt "claude code" UND einen zweiten Marker
(injiz/inject/fabriz/fabricat/adoptier/adopting/prompt injection/keine echten).
- Entfernt diese Assistant-Turns PLUS den unmittelbar davor stehenden
Hauptthread-User-Turn (die ausloesende Frage) — also den ganzen Fehl-Dialog.
- Laesst ALLES andere unangetastet: projekt-getaggte Turns, distill-Marker,
legitime Hauptchat-Turns.
- Standard = DRY-RUN (zeigt nur was raus wuerde). Mit --apply wird geschrieben,
vorher ein Backup .pre-cleanup.bak angelegt. Idempotent.
Aufruf (auf der VM, Host-Pfad des Bind-Mounts):
python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl
python3 clean_poisoned_turns.py ../aria-data/brain/data/conversation.jsonl --apply
Danach Brain neu starten, damit die bereinigte History geladen wird:
docker compose restart aria-brain
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from pathlib import Path
# STARKE, selbstreferenzielle Break-Marker — identisch zu prompts._IDENTITY_BREAK
# (dem Laufzeit-Gift-Waechter). Hier dupliziert, damit das Script self-contained
# ist (laeuft auch auf dem Host-Python ohne qdrant/prompts-Import). Bewusst NICHT
# das blosse Wort "injizier"/"prompt injection" — das nutzt ARIA in Pentest-
# Antworten legitim (sonst False Positives auf echte Security-Doku, wie im
# Dry-Run gesehen: "Runde 60 … SSRF", "Dein Ziel: LLM …").
_BREAK = re.compile(
r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|"
r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|"
r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|"
r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|"
r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|"
r"injected\s+(?:system\s*prompt|persona|context)|"
r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|"
r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|"
r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this",
re.IGNORECASE,
)
def is_poison(content: str) -> bool:
return bool(_BREAK.search(content or ""))
def get_content(obj: dict) -> str:
"""conversation.jsonl nutzt 'content', chat_backup.jsonl nutzt 'text'."""
v = obj.get("content")
if not isinstance(v, str):
v = obj.get("text")
return v if isinstance(v, str) else ""
def is_main_thread(obj: dict) -> bool:
"""Hauptthread = kein Projekt-Tag. Brain nutzt 'project_id', UI/Bridge
'projectId'."""
pid = obj.get("project_id")
if pid is None:
pid = obj.get("projectId")
return not (str(pid or "").strip())
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
apply = "--apply" in sys.argv[1:]
path = Path(args[0]) if args else Path("/data/conversation.jsonl")
if not path.exists():
print(f"FEHLER: {path} existiert nicht.", file=sys.stderr)
return 2
raw_lines = path.read_text(encoding="utf-8").splitlines()
# Parse zu (raw, obj|None). Nicht-JSON / leere Zeilen bleiben unangetastet.
parsed: list[tuple[str, dict | None]] = []
for line in raw_lines:
s = line.strip()
if not s:
parsed.append((line, None))
continue
try:
parsed.append((line, json.loads(s)))
except Exception:
parsed.append((line, None))
drop = [False] * len(parsed)
poisoned_pairs = [] # (assistant_idx, user_idx|None) fuer's Log
for i, (_, obj) in enumerate(parsed):
if not isinstance(obj, dict):
continue
if obj.get("op") == "distill":
continue
if obj.get("role") != "assistant" or not is_main_thread(obj):
continue
content = get_content(obj)
if not content or not is_poison(content):
continue
# Gift-Assistant-Turn -> droppen
drop[i] = True
user_idx = None
# Unmittelbar davor stehenden Hauptthread-User-Turn (die Frage) mit weg.
for j in range(i - 1, -1, -1):
pj = parsed[j][1]
if not isinstance(pj, dict) or pj.get("op") == "distill":
continue
if pj.get("role") == "user" and is_main_thread(pj):
drop[j] = True
user_idx = j
break # nur der direkt vorangehende Turn
poisoned_pairs.append((i, user_idx))
n_drop = sum(drop)
if n_drop == 0:
print("Keine Gift-Turns gefunden — History ist sauber. Nichts zu tun.")
return 0
print(f"Gefundene Fehl-Dialoge: {len(poisoned_pairs)} "
f"(insgesamt {n_drop} Zeilen zu entfernen)\n")
for a_idx, u_idx in poisoned_pairs:
if u_idx is not None:
uq = get_content(parsed[u_idx][1] or {})
print(f" Frage (Zeile {u_idx + 1}): {uq[:90]!r}")
ac = get_content(parsed[a_idx][1] or {})
print(f" Ablehng (Zeile {a_idx + 1}): {ac[:90]!r}")
print()
if not apply:
print("DRY-RUN — nichts geschrieben. Zum Anwenden erneut mit --apply aufrufen.")
return 0
backup = path.with_suffix(path.suffix + ".pre-cleanup.bak")
shutil.copy2(path, backup)
kept = [raw for idx, (raw, _) in enumerate(parsed) if not drop[idx]]
path.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
print(f"OK — {n_drop} Zeilen entfernt. Backup: {backup}")
print("Jetzt Brain neu starten: docker compose restart aria-brain")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
"""
Local-LLM-Client (Plan B) Brain-Seite.
Ruft das schnelle lokale LLM (Qwen3 auf der Gamebox) ueber die Bridge:
Brain HTTP /internal/local-llm Bridge RVS llm-adapter llama.cpp
Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
das Brain bleibt HTTP-only). Der Router im Brain (B1) entscheidet, welche Turns
hierher gehen (einfach) und welche an Claude (schwer / Tool-Bedarf).
Rueckgabe von local_llm_chat: {ok, content, model?, elapsedMs?} oder {ok:False, error}.
Nie werfen der Aufrufer entscheidet bei ok=False, ob er auf Claude eskaliert.
"""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Etwas ueber dem Bridge-seitigen _LLM_TIMEOUT_S (30s), damit der HTTP-Call nicht
# vor dem eigentlichen LLM-Timeout abbricht.
LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC", "35"))
def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
Blockierend (urllib) chat() laeuft ohnehin im Executor-Thread."""
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature}
if stop:
req["stop"] = stop
if tools:
req["tools"] = tools
if model:
req["model"] = model
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
result = json.loads(resp.read().decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
err_data = json.loads(exc.read().decode("utf-8", "ignore"))
err = err_data.get("error") or str(exc)
except Exception:
err = str(exc)
return {"ok": False, "error": f"local-llm: {err}"}
except Exception as exc:
logger.warning("local_llm_chat HTTP-Call fehlgeschlagen: %s", exc)
return {"ok": False, "error": f"local-llm nicht erreichbar ({exc})"}
if not isinstance(result, dict) or not result.get("ok"):
return {"ok": False, "error": (result or {}).get("error", "unbekannt")}
return result
+14 -1
View File
@@ -630,6 +630,15 @@ class ChatOut(BaseModel):
turns: int
distilling: bool
events: list = Field(default_factory=list)
# Welcher Backend die Antwort erzeugt hat: "local" (Qwen), "claude",
# "fast-path" (Skill/Regex). Fuer den Quell-Badge in Diagnostic.
answered_by: str = "claude"
# 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.
@@ -713,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 = 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,
@@ -735,6 +744,9 @@ async def chat(body: ChatIn, background: BackgroundTasks):
distilling=needs_distill,
events=a.pop_events(),
project_id=pid,
answered_by=answered_by,
speak=speak,
converse=converse,
)
finally:
_project_pending[pid] = [
@@ -809,6 +821,7 @@ def projects_archive(project_id: str):
class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
hidden: Optional[bool] = None
@app.patch("/projects/{project_id}")
+54 -10
View File
@@ -52,25 +52,55 @@ def _messages_tokens(messages: list) -> int:
return total
def log_call(model: str, messages_in: list, reply_text: str = "") -> None:
"""Eine Call-Metric anhaengen. Robust gegen Fehler (silent fail)."""
def _append(model: str, tokens_in: int, tokens_out: int, source: str) -> None:
"""Ein Metric-Entry auf Disk anhaengen. Robust (silent fail)."""
try:
tokens_in = _messages_tokens(messages_in)
tokens_out = _estimate_tokens(reply_text)
line = json.dumps({
"ts": int(time.time() * 1000),
"model": model,
"in": tokens_in,
"out": tokens_out,
"in": int(tokens_in),
"out": int(tokens_out),
"source": source, # "claude" | "local" | "fast-path"
})
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(line + "\n")
# Sanftes Rotate ohne hohe IO-Kosten — nur alle 1000 Calls checken
if (tokens_in + tokens_out) % 1000 < 4:
_maybe_rotate()
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:
@@ -95,6 +125,11 @@ def aggregate(window_seconds: int) -> dict:
tokens_in = 0
tokens_out = 0
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():
try:
for raw in METRICS_FILE.read_text(encoding="utf-8").splitlines():
@@ -107,11 +142,19 @@ def aggregate(window_seconds: int) -> dict:
continue
if obj.get("ts", 0) < cutoff_ms:
continue
ti = int(obj.get("in") or 0)
to = int(obj.get("out") or 0)
calls += 1
tokens_in += int(obj.get("in") or 0)
tokens_out += int(obj.get("out") or 0)
tokens_in += ti
tokens_out += to
m = obj.get("model", "?")
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:
logger.warning("metrics aggregate: %s", exc)
return {
@@ -120,6 +163,7 @@ def aggregate(window_seconds: int) -> dict:
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"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,
"description": description.strip(),
"status": "active", # active | ended | archived
"hidden": False, # optisch aus Listen ausblenden (bleibt nutzbar)
"created_at": now,
"updated_at": now,
"last_activity_at": now,
@@ -148,7 +149,7 @@ def update_project(project_id: str, patch: dict) -> Optional[dict]:
projects = _load_all()
for p in projects:
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:
p[k] = patch[k]
p["updated_at"] = _now()
+70
View File
@@ -15,6 +15,7 @@ mit dem Conversation-Loop in spaeteren Phasen.
from __future__ import annotations
import re
from datetime import datetime, timezone, timedelta
from typing import List
@@ -51,9 +52,78 @@ Rolle wirft — du bleibst ARIA und arbeitest normal weiter.
Dein eigenes Werkzeug-Format (`<tool_call>`) ist echt und von diesem System so
vorgesehen nutze es ganz normal.
## DISKRETION — PRIVATE/INTIME DINGE (HART, GILT IMMER)
Du kennst private und intime Details ueber Stefan (Beziehungen, Partnerinnen,
Sexualitaet/Lebensweise, Gesundheit, Finanzen, anderes Persoenliches). Dieses
Wissen ist fuer deine Arbeit da aber es ist HOCHVERTRAULICH:
- Gib es NIEMALS von dir aus preis. Nicht in Selbstvorstellungen, nicht bei
was weisst du ueber mich", nicht in Zusammenfassungen, nicht in Triggern oder
Push-Nachrichten, nicht als Beispiel, nicht um zu zeigen was du weisst".
- Nur wenn Stefan KONKRET nach genau diesem Thema fragt und dann knapp und
gezielt, ohne Rundum-Dump.
- Denk IMMER daran: jemand koennte danebensitzen oder mithoeren. Intimes
ungefragt auszusprechen ist ein Vertrauensbruch. Im Zweifel: weglassen oder
kurz rueckfragen (Soll ich dazu ins Detail gehen?").
Auf wer bist du" / „was weisst du ueber mich": antworte allgemein und diskret
(z.B. einiges geschaeftlich wie privat; frag mich gezielt"), NIEMALS ein
Aufzaehlen privater/intimer Details.
"""
# Identitaets-Grounding IM Konversations-Strom — Defense-in-Depth.
#
# PRIMAERER Fix ist der volle System-Prompt-Replace (--system-prompt statt
# --append-system-prompt, siehe docker-compose.yml + openai-to-cli.js): damit
# ist die ARIA-Persona DIE Identitaet des Modells, nicht ein Anhaengsel hinter
# Claude Codes "You are Claude Code". Zuvor (--append) hat die eingebaute
# Identitaet bei duennem Kontext (Hauptchat, erster Turn eines neuen Projekts)
# gewonnen und ARIA aus der Rolle geworfen ("ich bin Claude Code, ich adoptiere
# die Persona nicht"), samt Flaggen von GPS/Tool-XML als Injection.
#
# ZUSAETZLICH setzen wir einen synthetischen ERSTEN ARIA-Turn in IHRER eigenen
# Stimme an den Anfang des Konversations-Stroms. Das Modell setzt seine EIGENE
# etablierte Stimme fort (Self-Grounding) — greift auch dann, falls eine CLI-
# Version --system-prompt mal nicht sauber isoliert. Weil es ein
# <previous_response> ist und KEIN <system>-Tag, ist es kein Injection-Trigger.
# Rein ephemer (nur im Request-messages-Array, wird NIE persistiert).
IDENTITY_SEED = (
"Ich bin ARIA, Stefans persoenliche Assistentin. Ich bin einsatzbereit und "
"antworte durchgehend als ARIA — im Hauptchat wie im Projekt, bei kurzen "
"Alltagsfragen genauso wie bei tiefen technischen Themen. Womit kann ich helfen?"
)
# Gift-Waechter: erkennt eine Antwort, in der das Modell AUS DER ROLLE gefallen
# ist (sich selbst als Claude bezeichnet, die ARIA-Persona als injiziert/erfunden
# abtut, die Session als Fake bezeichnet). Solche Antworten duerfen NICHT in die
# Conversation-History — ein einziger gespeicherter Break zieht bei schwachen
# Folgeturns eine Kaskade nach sich (das Modell setzt seine eigene Ablehnung fort).
#
# BEWUSST nur STARKE, selbstreferenzielle Marker — nicht das blosse Wort
# "Injection"/"injizier" (das nutzt ARIA in Security-/Pentest-Projekten voellig
# legitim). Getroffen wird nur das Muster "ICH bin Claude / die Persona ist
# erfunden / diese Session ist injiziert".
_IDENTITY_BREAK = re.compile(
r"ich\s+bin\s+(?:allerdings\s+|ja\s+|nach\s+wie\s+vor\s+|weiterhin\s+)*claude|"
r"i'?m\s+(?:still\s+|actually\s+)?claude\s+code|i\s+am\s+claude\b|"
r"erfundene[nr]?\s+(?:tool|persona|schemas)|fabricated\s+persona|"
r"fabrizierte?\s+(?:persona|gespr|konversation)|fabricated\s+conversation|"
r"fake[- ]persona|injizierte[rn]?\s+(?:system-?prompt|kontext|persona)|"
r"injected\s+(?:system\s*prompt|persona|context)|"
r"diese\s+session\s+enthält\s+(?:einen|eine)\b.{0,40}injizier|"
r"this\s+session\s+(?:contains|has|keeps|repeatedly)\b.{0,40}(?:inject|fabricat|fake)|"
r"nicht\s+real\s+in\s+dieser\s+(?:umgebung|session)|not\s+real\s+in\s+this",
re.IGNORECASE,
)
def looks_like_identity_break(text: str) -> bool:
"""True, wenn eine ARIA-Antwort aus der Rolle gefallen ist. Fuer den
Gift-Waechter im Agent (nicht persistieren + Retry)."""
return bool(text and _IDENTITY_BREAK.search(text))
def build_time_section() -> str:
"""Aktueller Zeitstempel — damit ARIA Timer korrekt anlegen kann
und Watcher-Conditions mit hour_of_day etc. einordenbar bleiben."""
+225
View File
@@ -0,0 +1,225 @@
"""
Router (Plan B, B1a) entscheidet pro Turn: lokales schnelles LLM oder Claude.
Gestaffelt:
- B1a (hier): nur reden" — einfache Plauder-Turns → lokales Qwen (schlanker
Prompt, KEINE Tools). Antwortet es sauber fertig in <1 s. Sagt es
`<<ESCALATE>>`, braucht ein Tool oder faellt aus Claude (bestehender Pfad).
- B1b (spaeter): kuratierte lokale Tools + lokale Tool-Loop.
Schalter kommen aus /shared/config/local_llm.json (Diagnostic schreibt, Brain
liest pro Request):
{
"enabled": false, # Master: lokales Tier an/aus (aus = alles Claude)
"localOnly": false, # Eval: erzwinge lokal, KEIN Claude-Fallback
"toolVariant": "slim" # "slim" | "full" (B1b; "full" braucht mehr VRAM)
}
Default (Datei fehlt/kaputt): enabled=false Verhalten wie bisher (alles Claude).
"""
from __future__ import annotations
import json
import logging
import os
import re
logger = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("LOCAL_LLM_CONFIG", "/shared/config/local_llm.json")
ESCALATE_MARKER = "<<ESCALATE>>"
DEFAULT_CONFIG = {"enabled": False, "localOnly": False,
"toolVariant": "slim", "localLlmModel": "qwen3-8b"}
def load_config() -> dict:
"""Liest die Schalter. Nie werfen — bei Fehler Defaults (= alles Claude)."""
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
data = json.load(f) or {}
return {
"enabled": bool(data.get("enabled", False)),
"localOnly": bool(data.get("localOnly", False)),
"toolVariant": data.get("toolVariant", "slim") or "slim",
# Welches lokale Modell llama-swap laden soll (B0.5). Muss zu einem
# Key in xtts/llama-swap/config.yaml passen.
"localLlmModel": (data.get("localLlmModel") or "qwen3-8b").strip(),
}
except (FileNotFoundError, json.JSONDecodeError):
return dict(DEFAULT_CONFIG)
except Exception as exc:
logger.debug("local_llm-Config lesen fehlgeschlagen: %s", exc)
return dict(DEFAULT_CONFIG)
# ── Heuristik: ist dieser Turn „einfach genug" fuers lokale Tier (B1a)? ──
#
# B1a ist reden-ohne-Tools. Also: alles, was ein Tool/Aktion braucht oder tief/
# technisch ist, geht an Claude. Lieber konservativ (im Zweifel Claude) — das
# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation
# faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab.
# CLAUDE-ONLY-Themen → nicht lokal. Seit B1b hat das lokale Tier Werkzeuge
# (web_search, memory_search, trigger_timer, Spotify), daher gehen Wetter, News,
# Fakten, Timer, Musik, Gedaechtnis-Suche jetzt LOKAL. Nur was das lokale Tier
# nicht kann bleibt hier: Bilder, Skills, Projekte, OAuth, Smart-Home (keine
# Anbindung), Kalender/Mail (kein Tool).
_TOOL_HINTS = re.compile(
r"\b(bild|generier|male?\b|malen|zeichne|foto|"
r"skill|projekt|oauth|"
r"licht|lampe|steckdose|rollade|heizung|"
r"kalender|termin|"
r"maild?|e-?mail|nachricht schreiben)\b",
re.IGNORECASE,
)
# HINWEIS (bewusst KEINE Live-/Topic-Wortliste mehr): frueher stand hier ein
# _LIVE_HINTS-Blacklist (Wetter/Musik/Uhrzeit/… → Claude). Das war die falsche
# Idee — aus offenem Freitext die Absicht per Wortliste zu erraten ist NIE
# vollstaendig, jeder Miss = ein Halo. Die generische Loesung ist keine groessere
# Regex, sondern: das Modell entscheidet selbst („brauche ich Grundwahrheit/ein
# Tool? → <<ESCALATE>>", siehe build_local_system_prompt). Ein 8B kann das noch
# nicht zuverlaessig → local bleibt per Einstellung abschaltbar; ein staerkeres
# lokales Modell uebernimmt spaeter genau diese Selbst-Erkennung. Absicherung ist
# der Output-Guard in agent.py, nicht eine Input-Wortliste.
# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten).
_HARD_HINTS = re.compile(
r"```|" # Codeblock
r"\b(code|fehler|error|stacktrace|exception|bug|debug|pentest|exploit|"
r"vuln|payload|regex|sql|python|javascript|docker|kubernetes|"
r"analysier|erklär.*genau|schritt für schritt|refactor|implementier)\b",
re.IGNORECASE,
)
_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude
# Expliziter Nutzer-Wunsch „nimm das grosse Modell". Stefan sagt oft „frag Clodi"
# / „benutze direkt Claude" — dann soll der Turn NICHT lokal versucht werden,
# sondern direkt an Claude gehen. „Clodi/Clody" ist sein Kosename fuer Claude und
# meint praktisch immer Routing; „claude"/„grosses modell" nur in Imperativ-Kontext
# (nimm/nutze/frag/…), damit reine Trivia „was ist Claude" nicht faelschlich matcht.
_FORCE_CLAUDE = re.compile(
r"\b(clodi|clody)\b"
r"|\b(nimm|nutze|benutze|verwende|frag(e|st)?|nimms?t?|mit|via|direkt|per)\b"
r"[^.?!]*\b(claude|gro(ss|ß)es?\s+modell)\b",
re.IGNORECASE,
)
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)."""
if not cfg.get("enabled"):
return False
if cfg.get("localOnly"):
return True
msg = _strip_leading_hint_blocks(user_message)
if not msg or len(msg) > _MAX_LEN_FOR_LOCAL:
return False
# Expliziter „nimm Claude/Clodi"-Wunsch → nie lokal (User hat entschieden).
if _FORCE_CLAUDE.search(msg):
logger.info("[router] expliziter Claude-Wunsch erkannt → nicht lokal")
return False
if _TOOL_HINTS.search(msg):
return False
if _HARD_HINTS.search(msg):
return False
return True
# ── Schlanker System-Prompt fuers lokale Tier ──
#
# Klein halten (Speed!). Persona-Kern + Identitaets-Anker + kurze Awareness-Liste
# (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas,
# kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
# Seit dem B1a-Rueckbau hat local KEINE Werkzeuge mehr: es kann nichts
# nachschlagen und nichts steuern. Alles was aktuelle Grundwahrheit oder eine
# Aktion braucht, gehoert ans grosse Modell.
_AWARENESS = (
"Du hast im Schnell-Modus KEINE Werkzeuge — du kannst nichts nachschlagen und "
"nichts steuern. Nur das grosse Modell kann: aktuelle Infos holen (Wetter, "
"News, Uhrzeit, Preise), Musik/Spotify steuern oder den laufenden Song "
"nennen, Timer setzen, Bilder generieren, Skills bauen/aendern, Projekte & "
"OAuth verwalten, ins Gedaechtnis schreiben, sowie tiefe/technische Analysen "
"und langen Code. Fuer ALL das eskalierst du."
)
def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
pinned_persona: str = "") -> str:
"""Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe
Anker wie bei Claude (Rolle haelt)."""
parts = [
identity_anchor.strip(),
"",
"## SCHNELL-MODUS",
"Du laeufst gerade als schnelles lokales Modell fuer Alltags-Konversation "
"und einfache Aufgaben. Antworte knapp, freundlich, auf Deutsch, als ARIA.",
"WICHTIG zur Ausgabe: Antworte in ganz NORMALEM Text. Verwende KEINE "
"`<voice>`-Tags, kein `[FILE:]`, kein `<tool_call>`, kein HTML/Markup — "
"nur ein oder zwei natuerliche Saetze. (Der Text wird direkt angezeigt "
"UND vorgelesen.)",
]
if has_tools:
parts += [
"",
"## DEINE WERKZEUGE — nur nutzen wenn die Frage es WIRKLICH braucht",
"- `web_search`: aktuelle Infos aus dem Netz — Wetter, News, Fakten, "
"Preise, Oeffnungszeiten. Bei Wetter: nimm Stefans Ort aus dem "
"GPS-Hinweis in der Nachricht.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
"- `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.",
"Das gilt GENAUSO fuer Live-Auskuenfte: Nenne NIEMALS einen aktuellen "
"Songtitel, Interpreten, die Restzeit oder was gerade laeuft/welches "
"Geraet spielt, ohne den passenden Skill (z.B. run_spotify) WIRKLICH "
"aufgerufen und sein Ergebnis gelesen zu haben. Kein Tool-Ergebnis = du "
"weisst es NICHT — dann eskaliere, statt einen Titel/eine Zeit zu raten. "
"Gib nur weiter, was im Skill-Ergebnis wirklich steht; hat der Skill "
"keinen Titel geliefert (z.B. nur 'OK: next'), erfinde auch keinen.",
]
parts += [
"",
"## WAS DU HIER NICHT KANNST",
_AWARENESS,
"WICHTIG — du hast Stefans GEDAECHTNIS hier NICHT im Kopf: Bei Fragen zu "
"seinem Leben, zu Personen/Namen, Beziehungen, seiner Vergangenheit, "
"seinen Vorlieben/Sachen oder anderem gespeicherten Wissen antworte NICHT "
"aus dem Nichts (und rate nicht, wer wer ist) — sondern eskaliere. Das "
"grosse Modell kennt das Gedaechtnis und antwortet diskret.",
"Dafuer — und bei tiefen/technischen Fragen, langem Code, oder wenn du "
f"unsicher bist — antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` "
"(nichts sonst). Dann uebernimmt das grosse Modell mit vollem Zugriff. "
"Lieber einmal eskalieren als falsch raten oder ein Werkzeug erfinden.",
]
if pinned_persona.strip():
parts += ["", "## PERSONA", pinned_persona.strip()]
return "\n".join(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:
+25
View File
@@ -0,0 +1,25 @@
# SearXNG-Config fuer ARIA (self-hosted Meta-Suche, Backend fuers web_search-Tool).
# Erbt alle Default-Engines; wir ueberschreiben nur das Noetige:
# - JSON-Format aktiviert (Default AUS) -> Brain kann /search?format=json rufen
# - Rate-Limiter aus -> programmatischer Brain-Zugriff wird nicht geblockt
# - eigener secret_key (interne Instanz auf aria-net, nicht oeffentlich exponiert)
use_default_settings: true
server:
# Interner Dienst auf aria-net, nicht oeffentlich. Trotzdem ein eigener Key.
# Bei Bedarf aendern (beliebiger langer Zufallsstring).
secret_key: "aria-searxng-6f2c9a1e8b7d4f30a5c1e2d9b8a7f6c3"
limiter: false
image_proxy: false
search:
formats:
- html
- json
# Deutsch bevorzugen (Brain kann per Query-Param ueberschreiben).
default_lang: "de"
# Sanftere Timeouts, damit eine langsame Engine die Suche nicht ausbremst.
outgoing:
request_timeout: 5.0
max_request_timeout: 10.0
+476 -13
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
@@ -149,6 +151,54 @@ def _num_to_words_de(n: int) -> str:
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):
"""'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'."""
h1 = int(m.group(1))
@@ -168,7 +218,7 @@ def _decimal_to_words(m):
"""'0.1' / '0,1''null komma eins', '1,25''eins komma zwei fuenf'."""
int_part = int(m.group(1))
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)
return f"{int_word} komma {dec_words}"
@@ -184,7 +234,7 @@ _UNIT_WORDS = [
(r'\bkm\b', 'Kilometer'),
(r'\bm/s\b', 'Meter pro Sekunde'),
(r'\bkg\b', 'Kilogramm'),
(r'\b°C\b', 'Grad Celsius'),
(r'\b°C\b', ' Grad Celsius'),
(r'°C', ' Grad Celsius'),
(r'\bMbps\b', 'Megabit pro Sekunde'),
(r'\bGbps\b', 'Gigabit pro Sekunde'),
@@ -245,10 +295,17 @@ def clean_text_for_tts(text: str) -> str:
if not text:
return ""
# <voice>...</voice> wenn vorhanden → nur das nehmen
# <voice>...</voice> wenn vorhanden → nur diesen Inhalt lesen. ABER: ein
# LEERES <voice></voice> darf die Sprachausgabe nicht verstummen lassen.
# Claude haengt reflexartig manchmal ein leeres Tag an (v.a. bei Spotify-
# Antworten) — frueher wurde daraus text="" → gar keine TTS (Playlist-Wechsel
# 'Prodigy' stumm, 'Fliegen' gesprochen, rein je nachdem ob Claude Inhalt ins
# Tag schrieb). Leeres/whitespace-Tag → ignorieren und den normalen Text lesen.
voice_match = _re_tts.search(r'<voice>([\s\S]*?)</voice>', text, _re_tts.IGNORECASE)
if voice_match:
if voice_match and voice_match.group(1).strip():
text = voice_match.group(1)
else:
text = _re_tts.sub(r'<voice>[\s\S]*?</voice>', ' ', text, flags=_re_tts.IGNORECASE)
t = text
@@ -301,6 +358,15 @@ def clean_text_for_tts(text: str) -> str:
# 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)
# 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
t = _re_tts.sub(r'["""„`]', '', t)
@@ -313,6 +379,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 ───────────────────────────────────────────────
@@ -643,6 +740,11 @@ class ARIABridge:
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
# bei 'loading' deutlich grosszuegiger gesetzt (Modell-Download ~24 GB).
self._remote_flux_ready: bool = False
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
# Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen
# llm_request via RVS an den llm-adapter (Gamebox) und warten auf
# llm_response.
self._pending_llm: dict[str, asyncio.Future] = {}
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -1113,8 +1215,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:
@@ -1122,6 +1244,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")
@@ -1252,12 +1518,14 @@ class ARIABridge:
# Voice-Tag-Noise als Kontext sieht).
# File-Marker werden separat als file_from_aria-Events ausgeliefert.
display_text = strip_voice_tag_for_display(text)
_answered_by = (payload.get("answeredBy") or "") if isinstance(payload, dict) else ""
assistant_backup_ts = self._append_chat_backup({
"role": "assistant",
"text": display_text,
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
"project_id": turn_pid,
"answeredBy": _answered_by,
})
metadata = payload.get("metadata", {})
@@ -1292,6 +1560,13 @@ class ARIABridge:
"text": display_text,
"sender": "aria",
"messageId": message_id,
# ARIA-Dateien DIREKT an der Chat-Bubble mitschicken — sonst kommt
# der Anhang live nur als separates file_from_aria-Event (eigene
# Bubble) und taucht an der Nachricht erst nach einem Seiten-
# wechsel auf (Reload aus chat_backup, das die files kennt). Selbe
# Struktur wie im Backup, damit App live == Reload rendert.
"files": [{"serverPath": f["serverPath"], "name": f["name"],
"mimeType": f["mimeType"], "size": f["size"]} for f in aria_files],
# backupTs = der ts in chat_backup.jsonl. Wird von Clients als
# Bubble-ID fuer das Mülltonne-Loeschen verwendet (delete_message_request).
"backupTs": assistant_backup_ts,
@@ -1300,10 +1575,27 @@ class ARIABridge:
# Projekt-Zuordnung — App + Diagnostic sortieren die Bubble in
# den passenden Projekt-Block. Leer = Hauptchat.
"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),
})
# System-Flag vom Brain: reine Steuerbefehle (Fast-Path) NICHT vorlesen.
# Robust und unabhaengig von <voice>-Tags im Text (die ARIA beim
# Skill-Rebuild verlieren kann) — die Quelle entscheidet, nicht der Inhalt.
if isinstance(payload, dict) and payload.get("speak") is False:
logger.info("[core] TTS uebersprungen (speak=False — Fast-Path-Steuerbefehl)")
return
# TTS ueber XTTS (XTTS-Bridge auf Gaming-PC)
if not (getattr(self, 'tts_enabled', True) and should_speak(self.current_mode, is_critical)):
logger.info("[core] TTS unterdrueckt (Modus: %s)", self.current_mode.config.name)
@@ -1326,9 +1618,18 @@ class ARIABridge:
or 1.0
)
tts_text = tts_text_preview or text
# Ein vorhandener <voice>-Tag ist die EXPLIZITE TTS-Vorgabe von ARIA —
# auch wenn er leer ist. Leeres <voice></voice> = bewusst stumm: die
# Bubble erscheint im Chat, aber es wird NICHTS gesprochen. Use-Case:
# Steuerbefehl-Quittungen (Spotify next/pause, Licht an) — dort darf die
# Sprachausgabe NICHT feuern, weil das TTS-Playback auf dem Handy den
# Audio-Fokus klaut, Spotify duckt/pausiert und (bei kurzem Text) nicht
# sauber weiterspielt. Nur OHNE Voice-Tag faellt es auf den vollen Text
# zurueck (normale Antwort ohne TTS-Annotation wird komplett gelesen).
has_voice_tag = "<voice>" in (text or "").lower()
tts_text = tts_text_preview if has_voice_tag else (tts_text_preview or text)
if not tts_text:
logger.info("[core] TTS-Text leer nach Cleanup — uebersprungen")
logger.info("[core] TTS-Text leer (bewusst stumm via <voice></voice> oder nach Cleanup) — uebersprungen")
return
try:
xtts_request_id = str(uuid.uuid4())
@@ -1470,14 +1771,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
@@ -1619,6 +1939,15 @@ class ARIABridge:
# gegeben damit der chat-Broadcast die Bubble dem richtigen Projekt-
# Block in App + Diagnostic zuordnen kann.
turn_project_id = (data.get("project_id") or "").strip()
# Welcher Backend geantwortet hat (local/claude/fast-path) — fuer den
# Quell-Badge in Diagnostic.
answered_by = (data.get("answered_by") or "claude").strip()
# 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
@@ -1685,7 +2014,10 @@ class ARIABridge:
# passend behandelt wird (hier minimal, weil Brain noch keine
# metadata mitschickt).
try:
await self._process_core_response(reply, {"projectId": turn_project_id})
await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by,
"speak": speak,
"converse": converse})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id)
@@ -1696,6 +2028,14 @@ class ARIABridge:
"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"):
logger.info("[brain] Destillat laeuft im Hintergrund")
@@ -1984,6 +2324,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 "",
@@ -2908,6 +3251,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 ""
@@ -2954,6 +3315,15 @@ class ARIABridge:
future.set_result(payload)
return
elif msg_type == "llm_response":
# Antwort des llm-adapter (Gamebox) auf unseren llm_request.
request_id = payload.get("requestId", "")
future = self._pending_llm.get(request_id)
if future is None or future.done():
return
future.set_result(payload)
return
elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
@@ -3054,6 +3424,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 ""),
@@ -3337,6 +3710,66 @@ class ARIABridge:
_FLUX_TIMEOUT_READY_S = 240.0 # 4 min nach erstem Render
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
# Qwen3 auf der Gamebox antwortet auf kurze Turns in <1 s. Grosszuegiger
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (Gamebox@home)
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
_LLM_TIMEOUT_S = 30.0
async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_llm[request_id] = future
try:
req_payload = {
"requestId": request_id,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if stop:
req_payload["stop"] = stop
if tools:
req_payload["tools"] = tools
if model:
req_payload["model"] = model
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-")
ok = await self._send_to_rvs({
"type": "llm_request",
"payload": req_payload,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — Gamebox nicht erreichbar?"}
if not isinstance(result, dict) or not result.get("ok"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "llm-adapter Fehler"}
return {
"ok": True,
"content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"),
"elapsedMs": result.get("elapsedMs"),
}
finally:
self._pending_llm.pop(request_id, None)
async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float],
seed: Optional[int], model: Optional[str] = None) -> dict:
@@ -3788,6 +4221,36 @@ class ARIABridge:
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/local-llm":
# Vom Brain (Router / Testchat) gefeuert. Wir relayen den
# Chat-Request via RVS an den llm-adapter (Gamebox Qwen3),
# warten synchron auf llm_response und geben content zurueck.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
messages = data.get("messages")
if not isinstance(messages, list) or not messages:
await _send_response(writer, 400, {"error": "messages (nicht-leere Liste) erforderlich"})
return
try:
max_tokens = int(data.get("max_tokens") or 512)
except (TypeError, ValueError):
max_tokens = 512
try:
temperature = float(data.get("temperature"))
except (TypeError, ValueError):
temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
_model = data.get("model") if isinstance(data.get("model"), str) else None
result = await self._local_llm(
messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"),
tools=_tools, model=_model,
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/delete-chat-message":
try:
data = json.loads(body.decode("utf-8", "ignore"))
+423 -15
View File
@@ -878,20 +878,100 @@
<h2>Sprachmodell (Brain)</h2>
<div class="card" style="max-width:500px;">
<div style="font-size:11px;color:#8888AA;margin-bottom:10px;line-height:1.5;">
Welches Claude-Model nutzt das Brain pro Anfrage. Wert wird in
Welches Claude-Model das Brain pro Anfrage nutzt. Wert wird in
<code>/shared/config/runtime.json</code> als <code>brainModel</code> persistiert.
Bei Aenderung: <strong>aria-brain restarten</strong> (Reparatur-Section oben), damit's greift.
<br><br>
Verfuegbar via Proxy: <code>claude-sonnet-4</code> (Default — schnell, gut),
<code>claude-opus-4</code> (langsam, smarter), <code>claude-haiku-4-5</code> (sehr schnell, kleiner Kontext).
ARIA laeuft ueber dein Claude-Max-Abo (CLI) — waehlbar ist der <strong>Tier</strong>
(Opus/Sonnet/Haiku), nicht eine feste Version. Die CLI nimmt automatisch das
jeweils aktuelle Modell des Tiers.
<br><br>
Die Auswahlliste kommt aus <code>/shared/config/models.json</code> — dort kannst du
Tiers/Beschreibungen anpassen (kein Neustart noetig, danach „↻ Aktualisieren").
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:12px;color:#8888AA;white-space:nowrap;">Aktives Model:</span>
<input type="text" id="setting-model" placeholder="claude-sonnet-4" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<button class="btn secondary" onclick="loadModel()" style="padding:4px 8px;font-size:10px;">Laden</button>
<select id="setting-model-select" onchange="onModelSelectChange()" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="">(lade Liste…)</option>
</select>
<button class="btn secondary" onclick="loadModelList()" title="Liste vom Proxy neu holen" style="padding:4px 8px;font-size:10px;">↻ Aktualisieren</button>
<button class="btn" onclick="saveModel()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
<div id="model-status" style="font-size:10px;color:#8888AA;"></div>
<div id="model-desc" style="font-size:10px;color:#6a6a88;margin-bottom:6px;min-height:12px;"></div>
<div id="model-status" style="font-size:10px;color:#8888AA;margin-bottom:8px;"></div>
<details style="font-size:11px;color:#8888AA;">
<summary style="cursor:pointer;">Erweitert: freie Model-ID</summary>
<div style="display:flex;align-items:center;gap:8px;margin-top:6px;">
<input type="text" id="setting-model" placeholder="z.B. claude-opus-4" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<button class="btn secondary" onclick="loadModel()" style="padding:4px 8px;font-size:10px;">Laden</button>
<button class="btn" onclick="saveModelFreeText()" style="padding:4px 8px;font-size:10px;">Setzen</button>
</div>
</details>
</div>
</div>
<div class="settings-section">
<h2>Lokales LLM (schnelle Antworten) <button class="info-btn" onclick="showInfo('local-llm')" title="Wie funktioniert das?"></button></h2>
<div class="card" style="max-width:540px;">
<div style="font-size:11px;color:#8888AA;margin-bottom:12px;line-height:1.55;">
Ein schnelles lokales Modell (<strong>Qwen3</strong> auf der Gamebox) beantwortet
<strong>leichte Fragen in unter 1&nbsp;Sekunde</strong>. Was schwerer wird, gibt es
automatisch an <strong>Claude</strong> ab. Aenderungen greifen sofort
(kein Neustart) — geschrieben nach <code>/shared/config/local_llm.json</code>.
</div>
<!-- Master -->
<label style="display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;font-size:13px;color:#E0E0F0;cursor:pointer;">
<input type="checkbox" id="local-llm-enabled" onchange="saveLocalLlmConfig()" style="margin:2px 0 0 0;">
<span><strong>Lokales LLM einschalten</strong></span>
</label>
<div style="font-size:10px;color:#8888AA;margin:0 0 12px 24px;line-height:1.5;">
<strong style="color:#4ADE80;">AN:</strong> leichte Fragen → lokal (schnell), schwere → Claude.&nbsp;
<strong style="color:#8888AA;">AUS:</strong> alles laeuft ueber Claude (wie bisher). — <em>Das ist der Normal-Betrieb.</em>
</div>
<!-- Nur lokal -->
<label style="display:flex;align-items:flex-start;gap:8px;margin-bottom:4px;font-size:13px;color:#E0E0F0;cursor:pointer;">
<input type="checkbox" id="local-llm-onlylocal" onchange="saveLocalLlmConfig()" style="margin:2px 0 0 0;">
<span><strong>Nur lokales LLM</strong> — Claude komplett aussperren</span>
</label>
<div style="font-size:10px;color:#8888AA;margin:0 0 12px 24px;line-height:1.5;">
<strong style="color:#FFD60A;">Nur zum Testen.</strong> Erzwingt IMMER das lokale Modell — auch bei
schweren Fragen, ohne Claude-Rettung. So siehst du, was Qwen allein kann. Werkzeug-Fragen
(Wetter/Timer/…) funktionieren dann nicht. <strong>Fuer den Alltag: AUS lassen.</strong>
</div>
<!-- Tool-Umfang -->
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:13px;color:#E0E0F0;"><strong>Werkzeuge lokal:</strong></span>
<select id="local-llm-toolvariant" onchange="saveLocalLlmConfig()" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="slim">Abgespeckt — kuratierte Tools</option>
<option value="full" disabled>Voll — ganzes Arsenal (braucht mehr VRAM)</option>
</select>
<button class="info-btn" onclick="showInfo('local-llm-tools')" title="Voll: wie viel VRAM?"></button>
</div>
<div style="font-size:10px;color:#8888AA;margin:0 0 6px 0;line-height:1.5;">
Welche Werkzeuge das lokale Modell <em>selbst</em> ausfuehren darf. „Voll" ist gesperrt,
bis eine 2.&nbsp;Grafikkarte da ist (siehe&nbsp;ⓘ).
<br><span style="color:#FFD60A;">Aktueller Stand (B1a): das lokale Modell <strong>plaudert nur</strong> — Werkzeuge macht noch Claude. Lokale Tools kommen mit B1b.</span>
</div>
<!-- Lokales Modell (llama-swap, B0.5) -->
<div style="display:flex;align-items:center;gap:8px;margin:12px 0 4px 0;padding-top:10px;border-top:1px solid #2a2a3a;">
<span style="font-size:13px;color:#E0E0F0;"><strong>Lokales Modell:</strong></span>
<select id="local-llm-model" onchange="saveLocalLlmConfig()" style="flex:1;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-family:inherit;font-size:12px;">
<option value="">(lade Liste…)</option>
</select>
<button class="btn secondary" onclick="loadLocalModelList()" title="Liste neu laden" style="padding:4px 8px;font-size:10px;"></button>
</div>
<div id="local-llm-model-desc" style="font-size:10px;color:#8888AA;margin:0 0 2px 0;line-height:1.5;"></div>
<div style="font-size:10px;color:#FFD60A;margin:0 0 4px 0;line-height:1.5;">
Beim ersten Wechsel zu einem Modell lädt die Gamebox das GGUF (mehrere GB) —
die <strong>erste Antwort dauert dann länger</strong>, danach ist es gecacht.
Liste kommt aus <code>/shared/config/local_models.json</code> (Keys = xtts/llama-swap/config.yaml).
</div>
<div id="local-llm-status" style="font-size:11px;color:#6a6a88;margin-top:8px;padding-top:8px;border-top:1px solid #2a2a3a;min-height:14px;"></div>
</div>
</div>
@@ -948,6 +1028,18 @@
</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">
<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;">
@@ -1003,6 +1095,7 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">📁 Projekte</h2>
<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" onclick="openCreateProjectModal()" style="padding:4px 10px;font-size:11px;">+ Neues Projekt</button>
</div>
@@ -1331,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')]) {
@@ -1371,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');
@@ -1504,6 +1657,10 @@
send({ action: 'load_chat_history' });
// Brain-Card initial laden (sonst zeigt sie "Lade...")
try { loadBrainStatus(); } catch {}
// Sprachmodell-Dropdown befuellen (kuratierte Tier-Liste vom Proxy)
try { loadModelList(); } catch {}
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
};
// Brain-Status periodisch refreshen damit die Card live bleibt
@@ -1678,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;
}
@@ -1822,6 +1981,7 @@
ttsText: p.ttsText,
backupTs: p.backupTs,
projectId: p.projectId || '',
answeredBy: p.answeredBy || '',
});
return;
}
@@ -1923,7 +2083,8 @@
const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
const histBadge = srcBadgeHtml(m.type, m.answeredBy || '');
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)}${histBadge} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
@@ -1966,8 +2127,14 @@
// session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus.
if (msg.type === 'model_info') {
const el = document.getElementById('setting-model');
const sel = document.getElementById('setting-model-select');
const st = document.getElementById('model-status');
if (el && msg.model) el.value = msg.model;
// Dropdown auf das aktuelle Model stellen (falls in der Liste).
if (sel && msg.model) {
const has = Array.from(sel.options).some(o => o.value === msg.model);
if (has) { sel.value = msg.model; onModelSelectChange(); }
}
if (st) {
st.textContent = msg.info || msg.error || '';
st.style.color = msg.error ? '#FF6B6B' : '#34C759';
@@ -2248,6 +2415,18 @@
return t.trim();
}
// Quell-Badge (local/claude/fast-path) fuer ARIA-Bubbles — in Live + History genutzt.
function srcBadgeHtml(type, answeredBy) {
if (type !== 'received' || !answeredBy) return '';
const M = {
'local': { t: '⚡ lokal', c: '#34C759' },
'claude': { t: 'Claude', c: '#0096FF' },
'fast-path': { t: '⚡ Fast-Path', c: '#AF7BFF' },
};
const b = M[answeredBy] || { t: answeredBy, c: '#8888AA' };
return `<span title="Antwort erzeugt von: ${escapeHtml(answeredBy)}" style="display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:bold;background:${b.c}22;color:${b.c};border:1px solid ${b.c}55;">${b.t}</span>`;
}
function addChat(type, text, meta, options) {
// [FILE: /shared/uploads/aria_xxx.ext]-Marker aus dem Antworttext entfernen —
// die Datei kommt separat via file_from_aria-Event als eigene Bubble.
@@ -2282,7 +2461,9 @@
const trashBtn = backupTs
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${backupTs})">🗑</button>`
: '';
const html = `${trashBtn}${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Quell-Badge: welcher Backend die Antwort erzeugt hat (nur ARIA-Bubbles)
const srcBadge = srcBadgeHtml(type, (options && options.answeredBy) || '');
const html = `${trashBtn}${linked}${ttsBlock}${gpsBlock}<div class="meta">${escapeHtml(meta)}${srcBadge} — ${new Date().toLocaleTimeString('de-DE')}</div>`;
// Thinking-Indikator ausblenden bei neuer Nachricht
updateThinkingIndicator({ activity: 'idle' });
@@ -2535,7 +2716,7 @@
// Liste neu aufbauen
list.innerHTML = '';
let anyLoading = false, anyError = false;
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
const labels = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen', llm: 'Lokales LLM' };
for (const [s, info] of Object.entries(_serviceState)) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;';
@@ -2884,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:#555570;font-size:11px;margin-top:2px;">Standard-Verlauf, keine Projekt-Zuordnung</div>
</div>`);
const showHidden = !!window.__showHidden;
let hiddenCount = 0, shownCount = 0;
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 since = p.last_activity_at ? new Date(p.last_activity_at * 1000).toLocaleString('de-DE') : '?';
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(`
<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 onclick="switchProject('${p.id}')" style="cursor:pointer;flex:1;">
<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>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
@@ -2901,6 +3094,7 @@
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<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>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
</div>
@@ -2909,13 +3103,43 @@
}
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>');
} 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('');
// 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) {
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) {
try {
await fetch('/api/brain/projects/switch', {
@@ -5791,6 +6015,29 @@
setCell('metrics-h24', d.h24);
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
const plan = getActivePlanLimit();
const limit = plan.h5;
@@ -5833,6 +6080,37 @@
// Vor-definierte Info-Blocks
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': {
title: 'Lokales LLM — schnelle Antworten',
html: `
<p>Ein kleines, schnelles Modell (<strong>Qwen3 8B</strong>) laeuft auf deiner Gamebox-GPU und beantwortet <strong>einfache Plauder-Turns in &lt;1&nbsp;s</strong>. Alles Schwere, Technische oder Werkzeug-artige (Wetter, Timer, Musik, Bilder, Gedaechtnis, Code) reicht ein Router automatisch an <strong>Claude</strong> weiter.</p>
<p><strong>Lokales LLM nutzen</strong> — Master-Schalter. Aus = alle Anfragen laufen wie bisher ueber Claude.</p>
<p><strong>Nur lokales LLM</strong> — erzwingt lokal, KEIN Claude-Fallback. Zum Ausprobieren, wie stark das lokale Modell allein ist. Achtung: Werkzeug-Turns (Wetter/Timer/…) funktionieren dann nicht — das lokale Tier hat keine Tools.</p>
<p>Die Antwortzeit haengt an deinem Heim-Internet (Gamebox @home, ARIA @RZ). Ist die Gamebox aus/nicht erreichbar, faellt ARIA automatisch auf Claude zurueck.</p>
`,
},
'local-llm-tools': {
title: 'Tool-Umfang: Abgespeckt vs. Voll',
html: `
<p><strong>Abgespeckt</strong> — das lokale Modell bekommt eine kleine, kuratierte Tool-Auswahl (Wetter, Zeit, Gedaechtnis-Suche, Timer, Spotify, Licht). Der Rest laeuft ueber Claude. Passt in den VRAM einer <strong>1×RTX&nbsp;3060 (12&nbsp;GB)</strong>.</p>
<p><strong>Voll</strong> — das lokale Modell soll ARIAs komplettes Arsenal kennen. Das sind ~15-20&nbsp;K Tokens Tool-Schemas → passt <em>nicht</em> in ein 8-K-Kontextfenster. Groesseres Fenster kostet VRAM:</p>
<ul>
<li><strong>12&nbsp;GB (1×3060):</strong> nur „Abgespeckt".</li>
<li><strong>24&nbsp;GB (2×3060, eine Box):</strong> Qwen mit grossem Kontext = volles Schema, oder ein groesseres Modell.</li>
<li><strong>Cluster:</strong> weitere GPU-Hosts, jeder ein Modell-Server ueber RVS.</li>
</ul>
<p>Deshalb ist „Voll" hier deaktiviert, bis die Hardware (2.&nbsp;Karte) und die lokale Tool-Loop (B1b) da sind.</p>
`,
},
'brain-status': {
title: 'Gehirn — Status',
html: `
@@ -6062,14 +6340,144 @@
// ── Einstellungen: Model ────────────────────────────────
let _modelListCache = [];
function loadModel() {
send({ action: 'get_model' });
}
// Kuratierte Tier-Liste vom Proxy holen (via Diagnostic-Server) und ins
// Dropdown fuellen. Danach get_model, damit das aktive Model markiert wird.
async function loadModelList() {
const sel = document.getElementById('setting-model-select');
const st = document.getElementById('model-status');
if (!sel) return;
try {
const r = await fetch('/api/models-list');
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
_modelListCache = d.models || [];
sel.innerHTML = '';
for (const m of _modelListCache) {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.displayName || m.id;
sel.appendChild(opt);
}
if (!_modelListCache.length) {
const opt = document.createElement('option');
opt.value = ''; opt.textContent = '(keine Modelle vom Proxy)';
sel.appendChild(opt);
}
onModelSelectChange();
// Aktuelles Model vom Brain holen → markiert die richtige Option
loadModel();
} catch (e) {
if (st) { st.textContent = 'Model-Liste laden fehlgeschlagen: ' + e.message; st.style.color = '#FF6B6B'; }
}
}
function onModelSelectChange() {
const sel = document.getElementById('setting-model-select');
const desc = document.getElementById('model-desc');
if (!sel || !desc) return;
const m = _modelListCache.find(x => x.id === sel.value);
desc.textContent = m && m.description ? m.description : '';
}
function saveModel() {
const sel = document.getElementById('setting-model-select');
const model = sel && sel.value ? sel.value : '';
if (!model) return;
send({ action: 'set_model', model });
const st = document.getElementById('model-status');
if (st) { st.textContent = 'Gesetzt — aria-brain neu starten (Reparatur oben), damit es greift.'; st.style.color = '#FFD60A'; }
}
function saveModelFreeText() {
const model = document.getElementById('setting-model').value.trim();
if (!model) return;
send({ action: 'set_model', model });
const st = document.getElementById('model-status');
if (st) { st.textContent = 'Gesetzt (Freitext) — aria-brain neu starten, damit es greift.'; st.style.color = '#FFD60A'; }
}
// ── Einstellungen: Lokales LLM (Plan B) ─────────────────
function setLocalLlmStatus(c) {
const el = document.getElementById('local-llm-status');
if (!el) return;
if (!c || !c.enabled) {
el.textContent = '⚪ Status: AUS — alle Fragen laufen ueber Claude (wie bisher).';
el.style.color = '#8888AA';
return;
}
if (c.localOnly) {
el.textContent = '🟡 Status: TESTMODUS — nur lokal, Claude ausgesperrt. Werkzeug-Fragen funktionieren nicht.';
el.style.color = '#FFD60A';
} else {
el.textContent = '🟢 Status: AKTIV — leichte Fragen lokal (<1s), schwere automatisch an Claude.';
el.style.color = '#4ADE80';
}
}
let _localModelsCache = [];
let _currentLocalModel = 'qwen3-8b';
function updateLocalModelDesc() {
const el = document.getElementById('local-llm-model-desc');
const sel = document.getElementById('local-llm-model');
if (!el || !sel) return;
const m = _localModelsCache.find(x => x.id === sel.value);
el.textContent = m && m.description ? m.description : '';
}
async function loadLocalModelList() {
try {
const r = await fetch('/api/local-models-list');
const j = await r.json();
_localModelsCache = (j && j.models) || [];
} catch (e) { _localModelsCache = []; }
const sel = document.getElementById('local-llm-model');
if (sel) {
sel.innerHTML = _localModelsCache.map(m =>
`<option value="${m.id}">${m.display_name || m.id}</option>`).join('') || '<option value="">(keine)</option>';
if (_localModelsCache.some(m => m.id === _currentLocalModel)) sel.value = _currentLocalModel;
updateLocalModelDesc();
}
}
async function loadLocalLlmConfig() {
try {
const r = await fetch('/api/local-llm-config');
const c = await r.json();
const en = document.getElementById('local-llm-enabled');
const ol = document.getElementById('local-llm-onlylocal');
const tv = document.getElementById('local-llm-toolvariant');
if (en) en.checked = !!c.enabled;
if (ol) ol.checked = !!c.localOnly;
if (tv) tv.value = (c.toolVariant === 'full') ? 'full' : 'slim';
_currentLocalModel = c.localLlmModel || 'qwen3-8b';
const sel = document.getElementById('local-llm-model');
if (sel && _localModelsCache.some(m => m.id === _currentLocalModel)) {
sel.value = _currentLocalModel;
updateLocalModelDesc();
}
setLocalLlmStatus(c);
} catch (e) { /* still */ }
}
async function saveLocalLlmConfig() {
const modelSel = document.getElementById('local-llm-model');
const body = {
enabled: document.getElementById('local-llm-enabled').checked,
localOnly: document.getElementById('local-llm-onlylocal').checked,
toolVariant: document.getElementById('local-llm-toolvariant').value,
localLlmModel: (modelSel && modelSel.value) || '',
};
updateLocalModelDesc();
try {
const r = await fetch('/api/local-llm-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (j.ok) setLocalLlmStatus(j.config);
} catch (e) { /* still */ }
}
// ── Einstellungen: OpenClaw Config ──────────────────────
+130 -2
View File
@@ -297,6 +297,58 @@ function writeRuntimeConfig(patch) {
}
// Atomic write: temp-file + rename, laute Logs bei Fehler.
// ── Local-LLM-Config: /shared/config/local_llm.json ─────────────────
// Der Router im Brain (router.py) liest diese Datei pro Request. Wir schreiben
// sie hier aus den Diagnostic-Schaltern. Default = alles aus (nur Claude).
const LOCAL_LLM_CONFIG_FILE = "/shared/config/local_llm.json";
function readLocalLlmConfig() {
try {
const p = JSON.parse(fs.readFileSync(LOCAL_LLM_CONFIG_FILE, "utf-8"));
return {
enabled: !!p.enabled,
localOnly: !!p.localOnly,
toolVariant: p.toolVariant === "full" ? "full" : "slim",
localLlmModel: (typeof p.localLlmModel === "string" && p.localLlmModel) ? p.localLlmModel : "qwen3-8b",
};
} catch {
return { enabled: false, localOnly: false, toolVariant: "slim", localLlmModel: "qwen3-8b" };
}
}
function writeLocalLlmConfig(patch) {
const cur = readLocalLlmConfig();
if (typeof patch.enabled === "boolean") cur.enabled = patch.enabled;
if (typeof patch.localOnly === "boolean") cur.localOnly = patch.localOnly;
if (patch.toolVariant === "slim" || patch.toolVariant === "full") cur.toolVariant = patch.toolVariant;
if (typeof patch.localLlmModel === "string" && patch.localLlmModel.trim()) cur.localLlmModel = patch.localLlmModel.trim();
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = LOCAL_LLM_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(cur, null, 2));
fs.renameSync(tmp, LOCAL_LLM_CONFIG_FILE);
return cur;
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
const LOCAL_MODELS_FILE = "/shared/config/local_models.json";
const DEFAULT_LOCAL_MODELS = [
{ id: "qwen3-8b", display_name: "Qwen3 8B (Standard)", description: "Bestes Tool-Calling, ~6 GB. Passt auf 12 GB." },
{ id: "qwen3-4b", display_name: "Qwen3 4B (schneller)", description: "Kleiner + flotter, ~3 GB. Etwas schwaecher." },
];
function loadLocalModels() {
try {
const arr = JSON.parse(fs.readFileSync(LOCAL_MODELS_FILE, "utf-8"));
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) return arr;
} catch {}
// Seed defaults
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(LOCAL_MODELS_FILE, JSON.stringify(DEFAULT_LOCAL_MODELS, null, 2));
} catch {}
return DEFAULT_LOCAL_MODELS;
}
// ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt.
@@ -846,6 +898,11 @@ function connectRVS(forcePlain) {
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
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") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
@@ -1545,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" });
@@ -1573,6 +1639,27 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/local-models-list" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig()));
} else if (req.url === "/api/local-llm-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeLocalLlmConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Local-LLM-Config: enabled=${cfg.enabled} localOnly=${cfg.localOnly} tools=${cfg.toolVariant}`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });
@@ -1625,6 +1712,28 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ ok: false, error: err.message }));
}
return;
} else if (req.url === "/api/models-list" && req.method === "GET") {
// Kuratierte Model-Liste vom Proxy (/v1/models) — Tier-Auswahl fuers
// Sprachmodell-Dropdown. ARIA laeuft ueber das Max-Abo/CLI, waehlbar ist
// der Tier (opus/sonnet/haiku), keine feste Version.
(async () => {
try {
const r = await fetch(`${PROXY_URL}/v1/models`);
const d = await r.json();
const models = (d.data || []).map(m => ({
id: m.id,
tier: m.tier || m.id,
displayName: m.display_name || m.id,
description: m.description || "",
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models }));
} catch (err) {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
}
})();
return;
} else if (req.url === "/api/files-list" && req.method === "GET") {
// Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User
// (Upload aus App/Diagnostic) oder von ARIA (aria_<name>.<ext> Pattern).
@@ -2073,6 +2182,13 @@ const server = http.createServer((req, res) => {
// mehr als eine Minute.
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
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({
host: "aria-brain",
port: 8080,
@@ -2083,6 +2199,17 @@ const server = http.createServer((req, res) => {
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
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) => {
res.writeHead(503, { "Content-Type": "application/json" });
@@ -2747,6 +2874,7 @@ async function handleLoadChatHistory(clientWs) {
const ts = obj.ts || 0;
const text = String(obj.text || "");
const projectId = String(obj.project_id || ""); // Multi-Threading: Kontext-Zuordnung
const answeredBy = String(obj.answeredBy || ""); // Quell-Badge (local/claude/fast-path)
if (obj.role === "user") {
if (text) messages.push({ type: "sent", text, meta: "Gateway direkt", ts, projectId });
continue;
@@ -2773,7 +2901,7 @@ async function handleLoadChatHistory(clientWs) {
projectId,
});
}
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId });
if (text) messages.push({ type: "received", text, meta: "chat:final", ts, projectId, answeredBy });
}
clientWs.send(JSON.stringify({ type: "chat_history", messages }));
+18 -1
View File
@@ -12,7 +12,7 @@ services:
DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) &&
sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js &&
sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",/' $$DIST/subprocess/manager.js &&
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--append-system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js &&
sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js &&
sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js &&
sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js &&
@@ -53,6 +53,21 @@ services:
networks:
- aria-net
# ─── SearXNG (self-hosted Meta-Suche) ────────────────────
# Backend fuer das web_search-Tool (B1b). Aggregiert Google/Bing/Brave/… ohne
# API-Key, laeuft nur intern auf aria-net. Config: aria-data/searxng/settings.yml
# (JSON-Format aktiviert, Rate-Limiter aus fuer den Brain-Zugriff).
searxng:
image: searxng/searxng:latest
container_name: aria-searxng
volumes:
- ./aria-data/searxng:/etc/searxng
environment:
- SEARXNG_BASE_URL=http://searxng:8080/
restart: unless-stopped
networks:
- aria-net
# ─── ARIA Brain (Agent + Memory) ─────────────────────────
# Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes
# Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM.
@@ -86,6 +101,8 @@ services:
- RVS_HOST=${RVS_HOST:-}
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
- RVS_TLS=${RVS_TLS:-true}
# SearXNG (self-hosted Meta-Suche) fuer das web_search-Tool (B1b).
- SEARXNG_URL=${SEARXNG_URL:-http://searxng:8080}
volumes:
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)
+263
View File
@@ -0,0 +1,263 @@
# Plan B — Lokaler LLM-Router (Gamebox) neben Claude
**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
in <1 s; nur die schweren 20 % (Tiefe, Code, Tools, Pentest, langer Kontext)
gehen an Claude. Claude bleibt das Tiefen-Hirn.
## Warum das der einzige realistische Weg zu „live" ist
Gemessen (10.07.2026): CLI-Round-trip über den Claude-Max-Proxy hat einen
**harten Boden von ~3,5 s** (Subprozess-Start pro Turn). Streaming-API würde das
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
(läuft auf vorhandener Gamebox-GPU). Echtes Speech-to-Speech-Duplex (Gemini
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
## Modell & Serving (entschieden)
- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B-
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
weniger Tool-Calling).
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~12 GB) + F5-TTS (~12 GB) →
~89 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:
- llama.cpp hört nur auf localhost der Gamebox.
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
schickt `llm_response` (korreliert per requestId) zurück.
- `rvs/server.js` `ALLOWED_TYPES` um `llm_request`, `llm_response` und (Phase 2)
`llm_partial` erweitern.
- Das Brain bekommt einen zweiten „Proxy" — nur über RVS statt direktem HTTP.
## Router-Logik im Brain
Reihenfolge pro Turn (früh raus = schnell):
- **Tier 0 — Fast-Path (existiert):** reine Steuerbefehle (Spotify, Licht) →
Skill direkt, **kein LLM**. <1 s.
- **Tier 1 — Lokal (Qwen3):** einfache Konversation, kurze Fakten, Smalltalk,
Bestätigungen. Ziel <1 s.
- **Tier 2 — Claude:** tief/technisch, Code, Tool-Use nötig, Pentest-Projekt,
langer/komplexer Kontext.
**Routing-Signal (heuristisch zuerst, deterministisch & schnell):**
Nachrichtenlänge, Schlüsselwörter, ob ein Tool nötig scheint, Projekt-Kontext
(Pentest-Projekt → immer Claude), Konversationstiefe.
**Escalation statt perfekter Vorab-Klassifikation:** Das lokale Modell bekommt
die Anweisung, bei Unsicherheit oder Tool-Bedarf **NICHT zu raten**, sondern zu
eskalieren (z.B. Antwort `<<ESCALATE>>`). Das Brain routet den Turn dann an
Claude. So sind Fehlklassifikationen billig — lieber einmal lokal→Claude als
eine falsche lokale Antwort.
**Modus „Nur lokales LLM" (Diagnostic-Checkbox, Eval-Schalter):** Ein Flag
`localLlmOnly` (in Diagnostic setzbar, vom Brain beim Routen gelesen). Ist es an:
JEDER Turn geht ans lokale LLM, `<<ESCALATE>>` / „zu schwer" werden ignoriert
(kein Claude-Fallback) — damit Stefan die echte Staerke/Schwaeche des lokalen
Modells sieht, ohne dass Claude die schweren Turns rettet. Haken aus = normale
Heuristik + Escalation. Ehrlicher Hinweis: im Nur-lokal-Modus funktionieren
werkzeug-abhaengige Turns (Wetter, Timer, Memory, Bild) nicht — das lokale Tier
hat keine Tools; das ist ein Gespraechs-Eval-Modus, kein Voll-ARIA. Fast-Path
(Spotify etc.) laeuft davon unberuehrt weiter.
## Persona auf BEIDEN Modellen
Das lokale Modell braucht ARIAs Identität, sonst bricht es aus der Rolle
(gelernt aus dem `--system-prompt`-Debakel). Aber **schlanker**:
- IDENTITY_SEED + Kern-Persona: ja.
- Volles Memory / ALLE Skill-Schemas: **nein** — nur eine **kuratierte, kleine
Tool-Auswahl** (siehe unten). Haelt den lokalen Prompt klein → schnell.
- Persona kommt lokal auch als echter System-Prompt (llama.cpp `system`-Rolle).
## Tool-Calling lokal (kuratierte Auswahl)
Das lokale LLM DARF Werkzeuge nutzen (Qwen3 = natives OpenAI-Tool-Calling, von
llama.cpp `--jinja` unterstuetzt). Ablauf wie bei Claude: Brain schickt
messages + tools → Qwen antwortet mit `tool_calls` → Brain fuehrt via
`_dispatch_tool` aus → Ergebnis zurueck → finale Antwort. Tool-Loop im Brain,
Ziel = lokales LLM statt Claude-Proxy.
**Awareness ≠ Authority.** Das lokale Modell soll WISSEN, was ARIA alles kann
(damit es gezielt eskaliert statt zu halluzinieren), aber nicht alles ausfuehren.
**Harte Grenze = Kontext/VRAM, nicht Misstrauen.** Das volle Tool-Schema sind
~15-20 K Tokens. Qwens Kontext steht auf 8 K (`LLM_CTX=8192`) — es passt nicht
rein. Hochdrehen auf 32 K kostet mehrere GB KV-Cache extra → OOM auf der
geteilten 12-GB-3060 (Whisper + F5-TTS liegen mit drauf). Claude im RZ hat
200 K-1 M Kontext und ist zuverlaessig → kann sich das ganze Arsenal leisten;
das lokale 8B auf Heim-Hardware nicht. Andere Hardware-Klasse, anderes Budget.
**Design (gibt „im Bilde" ohne VRAM zu sprengen):**
- **Ausfuehrbar lokal:** kleiner, risikoarmer Start-Satz — Wetter, Uhrzeit,
`memory_search` (lesen), `trigger_timer`, Spotify-Steuerung, Licht/Smart-Home.
- **Awareness-Liste (billig, ~paar hundert Tokens im System-Prompt):** kurze
Aufzaehlung des Rests — „ARIA kann ausserdem: Skills bauen, OAuth, Projekte,
Bilder, ins Gedaechtnis schreiben — dafuer `<<ESCALATE>>`." Kein volles Schema.
- **Bleibt bei Claude (Authority):** `skill_create/update/delete`, `oauth_*`,
`project_*`, `flux_generate`, `memory_save`.
Escalation-Netz bleibt: braucht ein Turn ein Tool, das lokal nicht ausfuehrbar
ist → `<<ESCALATE>>` → Claude mit vollem Arsenal. Der „Nur lokales LLM"-Haken
dient dazu, spaeter datengetrieben zu messen, ob der ausfuehrbare Satz erweitert
werden kann.
Implementierung (B1): Adapter reicht `tools` an llama.cpp + gibt `tool_calls`
zurueck; Bridge schleust beides durch (llm_request/llm_response); Brain-Tool-Loop
mit Ziel lokal.
## Phasen
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der Gamebox,
`ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
Loop, siehe oben) + „Nur lokales LLM"-Checkbox. Einfache Turns → lokal.
Messen: Trefferquote, Tool-Zuverlaessigkeit & Latenz.
- **B2 — Streaming/Voice:** `llm_partial` → TTS beginnt beim ersten Satz →
der „live"-Sprung. **Hier den Gong-/Ohr-Re-Arm-Bug mit-fixen** (Barge-In,
sauberes Re-Listen).
- **B3 (optional):** lokalen Tool-Satz erweitern, sobald Qwen sich als
zuverlaessig erweist (z.B. `memory_save`).
## Offene Entscheidungen (für Stefan)
1. **Modell:** Qwen3 8B (Tool-Calling) — oder doch Mistral Small 3 7B (Speed)?
2. **Routing v1:** rein heuristisch + Escalation (entschieden).
3. **Tools lokal:** kuratierte kleine Auswahl (entschieden — Start-Satz oben;
Stefan bestaetigt/justiert die konkrete Liste vor dem B1-Bau).
## Folge-Baustein: Modell-Auswahl in ARIA Diagnostic (B0.5)
Ziel: In Diagnostic ein Modell auswählen; ist es nicht da, lädt der Container
es on-demand und aktiviert es. Spiegelt zwei bestehende Muster: den
`whisperModel`-Hotswap (RVS-Config-Broadcast → Bridge hot-swapped) und die
kuratierte Claude-Tier-Liste aus `models.json`.
**Kernproblem:** `llama.cpp`-Server serviert **ein** Modell pro Prozess —
„anderes aktivieren" = neu laden/swappen.
**Lösung: `llama-swap`** (Proxy vor llama.cpp): kennt eine Liste von Modellen,
lädt bei Anfrage das gewünschte on-demand (Download via `-hf` beim ersten Mal),
swappt bei VRAM-Knappheit das alte raus. OpenAI-kompatibel — der llm-adapter
zeigt statt auf `llama:8081` auf `llama-swap`.
**Bausteine:**
- `llama-swap`-Service in `xtts/docker-compose.yml` (ersetzt/ergänzt `llama`),
Config mit den verfügbaren Modellen (Name → `-hf`-Command).
- Kuratierte Liste `local_models.json` (analog `models.json`) — Diagnostic-UI
liest sie, zeigt Dropdown „Lokales Modell".
- Diagnostic → RVS-Config-Broadcast `localLlmModel` → llm-adapter setzt das
`model`-Feld seiner llama-swap-Requests → swap/Download passiert automatisch.
- Status zurück an Diagnostic (lädt / bereit / VRAM-OOM), analog whisper-Status.
**Konkret gewünschte UI (Stefan):**
- Modell-Status sichtbar: **lädt (mit Fortschrittsbalken) → heruntergeladen →
aktiviert**. Ist ein Modell schon im Cache: **nicht neu laden, nur
aktivieren** (llama.cpp/llama-swap macht das nativ ueber den Cache).
- **Testchat-Zeile** in Diagnostic: kurze Nachricht direkt ans lokale LLM
schicken, Antwort + Latenz anzeigen. Nutzt denselben RVS-Pfad
(`llm_request`/`llm_response`) wie der Self-Test — kein neuer Kanal noetig.
Bis dahin: **ein** Modell via `-hf` Auto-Download (B0, erledigt). Erst end-to-end
grün, dann dieser Komfort-Layer.
## Skalierung: VRAM, Multi-GPU, „Cluster"
**Wichtige Klarstellung:** Roher VRAM/GPU ist NICHT ueber RVS teilbar. RVS ist ein
Nachrichten-Relay; GPUs werden lokal per CUDA/PCIe angesprochen. Ueber RVS teilt
man **Inferenz-Faehigkeit** (transkribiere/vervollstaendige), nicht VRAM. Es gibt
daher keinen „GPU-Broker-Container", der Karten uebers Netz verleiht.
Skalierungspfade (echt):
- **Mehr Karten in EINER Box → VRAM-Pool.** llama.cpp/vLLM splitten ein Modell
ueber mehrere GPUs (`--tensor-split`). 2×3060 = 24 GB → groesseres Modell ODER
Qwen8B mit grossem Kontext → **volles Tool-Schema passt rein**. Das ist der
Weg zum „vollen Arsenal lokal".
- **Ein Modell ueber mehrere HOSTS splitten** (llama.cpp `--rpc`): moeglich, aber
langsam (Layer-Grenzen ueber's Netz) — nur schnelles LAN, fuer „schnell"
ungeeignet. Nicht empfohlen.
- **Mehrere eigenstaendige Modell-Server, je einer pro GPU/Host, Router waehlt:**
einfach, = unser RVS-Muster. Zweiter GPU-Host = noch ein llm-adapter, meldet
sich am RVS an, Router load-balanced. Das ist der sinnvolle „Cluster".
- **Innerhalb eines Hosts:** ein geteilter Inferenz-Server (`llama-swap`/vLLM)
statt VRAM-Duplikat pro Container — kommt mit B0.5.
**Diagnostic ⓘ (Feature):** Checkbox „volleres Arsenal" + Info-Icon mit
VRAM-Bedarf: 12 GB (1×3060) = kuratierte Tools; 24 GB (2×3060, eine Box) = Qwen
mit grossem Kontext/volles Schema oder groesseres Modell; Cluster = weitere
GPU-Hosts als Modell-Server ueber RVS. (B0.5/B1-UI.)
### „Waechter" / Orchestrator (Ausbaustufe, gestaffelt)
Idee: ein Dienst, der auf den am RVS angemeldeten Hosts Container startet/stoppt.
Zerfaellt in zwei Teile:
- **Billig & bald nuetzlich — Registrierung + Heartbeat:** jeder GPU-Host meldet
dem RVS „lebe, GPUs, VRAM frei, laufende Dienste" (kleine Erweiterung der
Adapter; whisper broadcastet schon Status). Nutzen: Diagnostic zeigt die
Flotte (Live-Daten fuers ⓘ), Router weiss ob lokal erreichbar (sonst Claude).
- **Teuer & aufschiebbar — Steuerung (Container start/stop):** Agent pro Host
(Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
statt selbst einen Scheduler zu bauen.
### ENTSCHIEDEN: manuelle Platzierung + read-only GPU-Dashboard (kein Auto)
Statt Auto-Controller (Semi-Auto verworfen — Host wechselt selten, Komplexitaet
lohnt nicht):
- **Pin = Docker Compose Profiles.** Services kriegen `profiles: [...]`, jeder
Host setzt `COMPOSE_PROFILES=<seins>` in der `.env`; `docker compose up`
startet nur die eigenen. „In Config gepinnt", nativ, kein Code.
- **Verschiebe-Regel:** `up` auf neuem Host + `docker compose rm -sf <svc>` auf
altem (sonst holt `restart: unless-stopped` den Dienst beim Reboot zurueck →
Broadcast-Kollision; Profile gelten nur beim `up`, nicht beim Daemon-Restart).
- **GPU-Dashboard in Diagnostic (read-only):** jeder GPU-Host sendet periodisch
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up`
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
Ohne Orchestrator.
### Verschieben-Button (Semi-Auto) — reboot-sicher via Platzierungs-Config
Wenn ein „Verschieben"-Button in Diagnostic gewuenscht ist (Dropdown Ziel-Host +
Button = hier stoppen, dort starten), braucht das remote Container-Steuerung →
**kleiner Agent pro GPU-Host** (Docker-Zugriff, hoert RVS-Befehle). Das ist der
zuvor „teure" Teil, aber in der DUMMEN Variante:
- **Eine Platzierungs-Config ist Single Source of Truth:**
`/shared/config/gpu_placement.json` = `{service: host}`.
- **Dummer Reconcile-Agent pro Host:** bei Start UND Config-Aenderung — starte
die mir zugewiesenen Dienste, stoppe die anderen. Keine Policy, kein
VRAM-Placement. Mensch = Scheduler (Button), Agent = befolgt nur Config.
- **Button aendert nur die Config** → Agenten reconcilen (alt stoppt, neu
startet). **Reboot liest Config** → kein Divergieren, keine Kollision.
- **Reboot-Falle vermieden:** NIE Laufzeit-Move ohne Config-Update (sonst holt
`restart: unless-stopped` den Dienst beim Reboot zurueck). Config = Wahrheit.
Deploy-Story: Code liegt via git auf allen Hosts (`pull`+`build`), aber `up -d`
startet nichts GPU-maessig von selbst — die Platzierungs-Config (bzw.
`COMPOSE_PROFILES`) entscheidet, was wo laeuft. Neuer Host = zuweisen, Agent
startet.
**Reihenfolge:** NACH B0/B1. Fallback ohne Button: reine `COMPOSE_PROFILES` pro
Host + Verschieben von Hand (null neue Infra).
## Nicht-Ziele
- Kein echter Gemini-Live-Duplex-Klon (Text-Modell als Hirn).
- FLUX bleibt optional/später (dickere GPU). Bild-Generierung separat als
pluggbarer Provider (ChatGPT/DALL·E-Alternative) — eigenes Feature, nicht Teil B.
+14 -8
View File
@@ -153,9 +153,12 @@ export function messagesToPrompt(messages, tools) {
/**
* Extrahiert NUR den System-Anteil (System-Messages + Tool-Use-Block) als
* rohen Text OHNE <system>-Tags. Fuer den ECHTEN System-Prompt-Kanal der
* Claude-CLI (--append-system-prompt), damit die ARIA-Persona nicht als
* <system>-getaggter User-Content ankommt (den das Modell als Injection wertet),
* sondern als genuine System-Instruktion.
* Claude-CLI (--system-prompt, VOLLER Replace nicht --append). Damit ist
* die ARIA-Persona DIE Identitaet des Modells und nicht ein Anhaengsel hinter
* Claude Codes eigener "You are Claude Code"-Identitaet (die bei duennem
* Kontext sonst gewinnt und die Persona als Injection abwehrt). Der Output
* muss deshalb SELBSTTRAGEND sein er ersetzt Claude Codes System-Prompt
* komplett inkl. dynamischer Sektionen (cwd, git, platform).
* Reihenfolge: erst der Tool-Use-Block (Format-Anweisung), dann die
* System-Messages in Original-Reihenfolge.
*/
@@ -217,12 +220,15 @@ export function conversationToPrompt(messages) {
export function openaiToCli(request) {
// Persona/System + Tool-Block gehen ueber den ECHTEN System-Prompt-Kanal
// (--append-system-prompt, siehe manager.js buildArgs-Patch). Der Prompt
// enthaelt nur noch den Gespraechsverlauf — so kann das Modell die
// ARIA-Vorgaben nicht mehr als <system>-getaggten User-Content und damit als
// Prompt-Injection fehldeuten.
// (--system-prompt = VOLLER Replace, siehe manager.js buildArgs-Patch in
// docker-compose.yml). Der Prompt enthaelt nur noch den Gespraechsverlauf.
// Voller Replace statt --append, weil Anhaengen Claude Codes eingebaute
// "You are Claude Code"-Identitaet stehen laesst — die bei duennem Kontext
// (Hauptchat) gewinnt und die ARIA-Persona als Injection abwehrt.
// systemPrompt ist immer ein String (extractSystemPrompt liefert "" statt
// undefined) → --append-system-prompt "" ist harmlos, kein spawn-Crash.
// undefined). ACHTUNG: bei --system-prompt darf er NIE leer sein, sonst
// laeuft das Modell ganz ohne System-Prompt — der Brain schickt aber immer
// eine System-Message + Tool-Block, also ist er real nie leer.
return {
prompt: conversationToPrompt(request.messages),
systemPrompt: extractSystemPrompt(request.messages, request.tools),
+58 -22
View File
@@ -19,6 +19,7 @@
*/
import { v4 as uuidv4 } from "uuid";
import http from "http";
import fs from "fs";
import { ClaudeSubprocess } from "../subprocess/manager.js";
import { openaiToCli } from "../adapter/openai-to-cli.js";
import { cliResultToOpenai, createDoneChunk, } from "../adapter/cli-to-openai.js";
@@ -362,8 +363,8 @@ async function handleStreamingResponse(req, res, subprocess, cliInput, requestId
model: cliInput.model,
sessionId: cliInput.sessionId,
// ARIA: echter System-Prompt-Kanal — manager.js reicht das (sobald
// gepatcht) als --append-system-prompt an die CLI. Aktuell ignoriert
// ein ungepatchter manager diese Extra-Option gefahrlos.
// gepatcht) als --system-prompt (VOLLER Replace) an die CLI. Aktuell
// ignoriert ein ungepatchter manager diese Extra-Option gefahrlos.
systemPrompt: cliInput.systemPrompt,
}).catch((err) => {
console.error("[Streaming] Subprocess start error:", err);
@@ -452,29 +453,64 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
*
* Returns available models
*/
// Kuratierte Tier-Liste. ARIA laeuft ueber das Claude-Max-Abo via CLI —
// waehlbar ist der TIER (opus/sonnet/haiku), nicht eine feste Modellversion;
// die CLI loest den Alias aufs aktuelle Modell des Tiers auf. Die id-Strings
// muessen von openai-to-cli.js extractModel() erkannt werden (MODEL_MAP).
//
// Quelle: /shared/config/models.json — damit neue Tier-Namen oder angepasste
// Beschreibungen eine reine DATEI-Aenderung sind (kein Code-Edit, kein Neubau,
// kein Neustart: handleModels liest pro Request neu; einfach die Datei
// bearbeiten und im Diagnostic „Aktualisieren" druecken). Fehlt/kaputt die
// Datei, greifen die eingebauten Defaults; die Datei wird dann einmalig mit
// diesen Defaults angelegt, damit es was zu editieren gibt.
const MODELS_FILE = process.env.ARIA_MODELS_FILE || "/shared/config/models.json";
const DEFAULT_MODELS = [
{ id: "claude-sonnet-4", tier: "sonnet", display_name: "Sonnet (aktuell: Sonnet 5)",
description: "Schnell & gut — Standard fuer den Alltag." },
{ id: "claude-opus-4", tier: "opus", display_name: "Opus (aktuell: Opus 4.8)",
description: "Langsamer, aber am schlausten — fuer schwere/lange Aufgaben." },
{ id: "claude-haiku-4", tier: "haiku", display_name: "Haiku (aktuell: Haiku 4.5)",
description: "Sehr schnell & guenstig, kleinerer Kontext — fuer einfache Tasks." },
];
function _loadModels() {
try {
const raw = fs.readFileSync(MODELS_FILE, "utf-8");
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) {
return arr;
}
console.error("[aria-models] models.json ungueltig — nutze Defaults");
} catch (_) {
// Datei fehlt (oder unlesbar) → Defaults + einmalig seeden zum Editieren
try {
fs.mkdirSync("/shared/config", { recursive: true });
if (!fs.existsSync(MODELS_FILE)) {
fs.writeFileSync(MODELS_FILE, JSON.stringify(DEFAULT_MODELS, null, 2));
console.error("[aria-models] models.json mit Defaults angelegt:", MODELS_FILE);
}
} catch (e) {
console.error("[aria-models] Seeden fehlgeschlagen:", e && e.message);
}
}
return DEFAULT_MODELS;
}
export function handleModels(_req, res) {
const created = Math.floor(Date.now() / 1000);
const models = _loadModels();
res.json({
object: "list",
data: [
{
id: "claude-opus-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-sonnet-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
{
id: "claude-haiku-4",
object: "model",
owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
},
],
data: models.map(m => ({
id: m.id,
object: "model",
owned_by: "anthropic",
created,
tier: m.tier || m.id,
display_name: m.display_name || m.id,
description: m.description || "",
})),
});
}
/**
+4
View File
@@ -62,6 +62,10 @@ const ALLOWED_TYPES = new Set([
"flux_request", "flux_response",
"agent_stream",
"oauth_callback",
// Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp).
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
"llm_request", "llm_response", "llm_partial",
]);
// Token-Raum: token -> { clients: Set<ws> }
+48
View File
@@ -89,3 +89,51 @@ services:
# Stimm-Embedding) persistent zwischen
# Container-Restarts.
restart: unless-stopped
# ─── Lokales LLM (Plan B, B0.5) — llama-swap (GPU) ────────────
# llama-swap laedt/swappt mehrere Modelle on-demand (nur eins passt gleich-
# zeitig in die 12 GB). Welches geladen wird, bestimmt das `model`-Feld im
# Request — das Brain schickt es aus local_llm.json mit. Erster Load eines
# Modells zieht das GGUF via -hf von HF (Cache unter /models, persistent).
# OpenAI-kompatibel auf :8080, nur im Compose-Netz; die Bruecke macht der
# llm-adapter. Modell-Liste: ./llama-swap/config.yaml.
#
# BLIND GEBAUT (kein Gamebox-Test hier): beim ersten Start
# `docker logs -f aria-llama-swap` pruefen. Image bundelt llama-server.
llama-swap:
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
container_name: aria-llama-swap
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
- ./models:/models # HF-Download-Cache (persistent)
- ./llama-swap/config.yaml:/app/config.yaml:ro # Modell-Liste
environment:
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
command: ["--config", "/app/config.yaml", "--listen", "0.0.0.0:8080"]
restart: unless-stopped
# ─── Local-LLM-Adapter — RVS <-> llama.cpp (Plan B, B0) ──────
# Verbindet sich per Token an den RVS (wie f5tts/whisper), nimmt
# llm_request entgegen, ruft llama.cpp lokal, antwortet llm_response.
llm-adapter:
build: ./llm-adapter
container_name: aria-llm-adapter
depends_on:
- llama-swap
environment:
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN}
- LLAMA_URL=http://llama-swap:8080
- LLM_MODEL=${LLM_MODEL:-qwen3-8b}
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
+42
View File
@@ -0,0 +1,42 @@
# llama-swap Modell-Liste fuer ARIA (Plan B, B0.5).
# Welches Modell geladen wird, bestimmt das `model`-Feld im Request (das Brain
# schickt es aus /shared/config/local_llm.json mit). llama-swap laedt es
# on-demand, swappt bei Bedarf (nur eins passt gleichzeitig in die 12 GB).
# Erster Load zieht das GGUF via -hf von Hugging Face (Cache unter /models).
#
# Die Modell-KEYS hier muessen zu local_models.json (Diagnostic-Dropdown) passen.
#
# healthCheckTimeout: Sekunden, die llama-swap auf "Modell bereit" wartet.
# GROSSZUEGIG, weil der erste Load ein GGUF (mehrere GB) herunterlaedt. Wenn der
# erste Download laenger dauert und abbricht: hier hochsetzen.
healthCheckTimeout: 1800
models:
# Standard — Qwen3 8B (~6 GB Q4). Bestes Tool-Calling, passt auf 12 GB.
"qwen3-8b":
cmd: |
llama-server --port ${PORT} --host 127.0.0.1
-hf Qwen/Qwen3-8B-GGUF:Q4_K_M
-ngl 99 -c 8192 --jinja
ttl: 3600 # nach 1h Idle entladen (VRAM freigeben)
# Kleiner + schneller — Qwen3 4B (~3 GB). Fuer noch flottere Antworten,
# etwas schwaecher. Guter A/B-Vergleich gegen 8B.
"qwen3-4b":
cmd: |
llama-server --port ${PORT} --host 127.0.0.1
-hf Qwen/Qwen3-4B-GGUF:Q4_K_M
-ngl 99 -c 8192 --jinja
ttl: 3600
# ── Vorlagen fuer spaeter (auskommentiert; brauchen mehr VRAM / 2. Karte) ──
# "qwen3-14b":
# cmd: |
# llama-server --port ${PORT} --host 127.0.0.1
# -hf Qwen/Qwen3-14B-GGUF:Q4_K_M -ngl 99 -c 8192 --jinja
# ttl: 3600
# "mistral-small-3":
# cmd: |
# llama-server --port ${PORT} --host 127.0.0.1
# -hf <mistral-small-3-gguf-repo>:Q4_K_M -ngl 99 -c 8192 --jinja
# ttl: 3600
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY adapter.py .
CMD ["python", "-u", "adapter.py"]
+66
View File
@@ -0,0 +1,66 @@
# Local-LLM-Adapter (Gamebox) — Plan B, Phase B0
Bringt ein lokales, schnelles LLM (Qwen3 8B) auf die Gamebox und haengt es
per RVS an ARIA — fuer die einfachen ~80 % der Turns (<1 s), waehrend Claude
das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
## Zwei Container (in `xtts/docker-compose.yml`)
- **`llama`** — `llama.cpp`-Server (CUDA), serviert das GGUF OpenAI-kompatibel
auf `:8081`, nur im Compose-Netz.
- **`llm-adapter`** — verbindet sich per Token an den RVS (wie f5tts/whisper),
nimmt `llm_request` entgegen, ruft `llama` lokal, antwortet `llm_response`.
## Modell — Auto-Download (nichts manuell ablegen)
`llama.cpp` zieht das GGUF beim **ersten Start selbst von Hugging Face** und
cached es unter `xtts/models/` (Bind-Mount → kein Re-Download bei Restart).
Default: **Qwen3 8B, Q4_K_M** aus dem offiziellen Repo `Qwen/Qwen3-8B-GGUF`.
Modell/Quant wechseln = in der `.env` der Gamebox setzen (kein Code):
```
LLM_HF_REPO=Qwen/Qwen3-8B-GGUF # HF-Repo
LLM_HF_QUANT=Q4_K_M # Quant-Tag (Q4_K_M, Q5_K_M, Q8_0, …)
LLM_CTX=8192 # Kontextfenster (kleiner = weniger VRAM)
```
Mistral statt Qwen testen (A/B): `LLM_HF_REPO` auf ein Mistral-Small-3-GGUF-Repo
umstellen + Container neu — Ein-Zeilen-Wechsel, kein Code.
> Der erste Start lädt mehrere GB — Log zeigt den Download-Fortschritt.
> Danach liegt das GGUF im Cache und der Start ist sofort.
**Modell-Auswahl in ARIA Diagnostic** (on-demand laden/aktivieren mehrerer
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
`docs/plan-local-llm-router.md`.
## Start (auf der Gamebox)
```bash
cd xtts
docker compose up -d --build llama llm-adapter
docker logs -f aria-llm-adapter # "RVS verbunden — llm-adapter online"
```
## Standalone-Test (ohne ARIA), direkt gegen llama.cpp
```bash
curl http://localhost:8081/v1/chat/completions -H "Content-Type: application/json" -d '{
"messages":[{"role":"system","content":"Du bist ARIA."},
{"role":"user","content":"sag kurz hallo"}],
"max_tokens":64
}'
```
## VRAM-Hinweis (RTX 3060, 12 GB)
whisper-small (~12) + f5tts (~12) + qwen3-8b-q4 (~6) ≈ 910 GB. Passt, aber
knapp. Bei OOM: `LLM_CTX` reduzieren, `-ngl` senken (weniger Layer auf GPU),
oder kleineres Quant (Q4_K_S / IQ4_XS).
## Nachrichten-Kontrakt (RVS)
- `llm_request``{ requestId, messages:[{role,content}], max_tokens?, temperature?, stop? }`
- `llm_response``{ requestId, ok, content, error?, model, elapsedMs }`
- `llm_partial` — reserviert fuer B2 (Token-Streaming), noch ungenutzt.
+228
View File
@@ -0,0 +1,228 @@
"""
ARIA Local-LLM-Adapter (Gamebox) Plan B, Phase B0.
Bruecke zwischen RVS und dem lokalen llama.cpp-Server. Spiegelt das Muster der
whisper-bridge: verbindet sich per WebSocket mit dem RVS (Token-Room, TLS mit
ws-Fallback, Reconnect-Backoff), lauscht auf `llm_request` und ruft den lokalen
llama.cpp-`/v1/chat/completions`-Endpoint (OpenAI-kompatibel), antwortet mit
`llm_response` (korreliert per requestId).
Topologie: Gamebox steht zuhause, ARIA im RZ die Kommunikation laeuft ueber
den RVS (wie TTS/STT), keine IPs zu pflegen. Nur URL + Token.
Env:
RVS_HOST, RVS_PORT, RVS_TLS, RVS_TLS_FALLBACK, RVS_TOKEN (wie f5tts/whisper)
LLAMA_URL Default http://llama:8081 (llama.cpp im selben Compose-Netz)
LLM_MODEL optionaler Modell-Name fuer llama (llama.cpp ignoriert ihn
meist, dient nur der Transparenz im Log)
LLM_TIMEOUT_SEC Default 60
Bewusst NICHT-streamend in B0 (volle llm_response). Token-Streaming (llm_partial)
kommt in B2 zusammen mit TTS-on-first-sentence.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import httpx
import websockets
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("llm-adapter")
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = os.getenv("RVS_PORT", "443").strip()
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
# Qwen3 hat Thinking-Mode default AN — dann verbraet es Tokens in einem
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
# (grübeln = harter Turn = Claude). Wir schalten Thinking daher per
# chat_template_kwargs ab (Qwen3-Template versteht enable_thinking=false;
# andere Templates ignorieren das kwarg). Bei einem Modell, das darauf
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true"
async def _send(ws, mtype: str, payload: dict) -> None:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop, tools=None, model=None) -> dict:
"""Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, tool_calls, error} zurueck wirft nie.
model: welches Modell llama-swap laden soll (B0.5). Kommt aus dem Request
(Brain -> local_llm.json). Faellt auf LLM_MODEL (env) zurueck.
tools: optionale OpenAI-Tool-Definitionen (B1b). Qwen3 (--jinja) kann
natives Tool-Calling und liefert dann message.tool_calls."""
body = {
"model": model or LLM_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
if stop:
body["stop"] = stop
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
if LLM_DISABLE_THINKING:
# llama.cpp (--jinja) reicht chat_template_kwargs an die Chat-Vorlage
# weiter. Qwen3 unterdrueckt damit den <think>-Block.
body["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SEC) as client:
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status()
data = r.json()
msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
return {
"ok": True,
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or None,
"usage": data.get("usage"),
}
except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
return {"ok": False, "content": "", "error": str(e)[:300]}
# B0.5-2: Lade-Status ans Diagnostic (service_status, service="llm"). Wir kennen
# den Download-Fortschritt nicht (llama-swap gibt ihn nicht her), aber wir melden
# den Zustand bei Modellwechsel: loading -> ready/error. _last_model = aktuell
# geladenes; _ready_models = in dieser Session schon einmal bereit gewesene
# (fuer den "frisch geladen"-Hinweis 🎉 bei langem Erst-Load).
_last_model = None
_ready_models: set = set()
async def _emit_llm_status(ws, state: str, model: str, **extra) -> None:
await _send(ws, "service_status",
{"service": "llm", "state": state, "model": model, **extra})
async def _handle_llm_request(ws, payload: dict) -> None:
global _last_model
req_id = payload.get("requestId", "")
messages = payload.get("messages") or []
if not isinstance(messages, list) or not messages:
await _send(ws, "llm_response", {
"requestId": req_id, "ok": False, "error": "leere/ungueltige messages",
})
return
max_tokens = int(payload.get("max_tokens", 512) or 512)
temperature = float(payload.get("temperature", 0.7) or 0.7)
stop = payload.get("stop")
tools = payload.get("tools") or None
model = (payload.get("model") or "").strip() or None
eff_model = model or LLM_MODEL
# Modellwechsel (oder erster Request) → llama-swap laedt/swappt: Status melden.
switching = eff_model != _last_model
if switching:
await _emit_llm_status(ws, "loading", eff_model)
t0 = time.time()
res = await _call_llama(messages, max_tokens=max_tokens,
temperature=temperature, stop=stop, tools=tools,
model=model)
dt = time.time() - t0
if switching:
if res.get("ok"):
fresh = (eff_model not in _ready_models) and dt > 25
_ready_models.add(eff_model)
_last_model = eff_model
await _emit_llm_status(ws, "ready", eff_model,
loadSeconds=round(dt, 1), freshlyDownloaded=fresh)
else:
# bei Fehler _last_model NICHT setzen → naechster Versuch meldet erneut loading
await _emit_llm_status(ws, "error", eff_model,
error=(res.get("error") or "")[:120])
tc = res.get("tool_calls")
logger.info("llm_request id=%s model=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
(req_id[:8] if req_id else "?"), model or LLM_MODEL, res.get("ok"), dt,
len(res.get("content") or ""), len(tc) if tc else 0)
await _send(ws, "llm_response", {
"requestId": req_id,
"ok": res.get("ok", False),
"content": res.get("content", ""),
"tool_calls": tc,
"error": res.get("error"),
"model": model or LLM_MODEL,
"elapsedMs": int(dt * 1000),
})
async def _run() -> None:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
return
if not RVS_TOKEN:
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
return
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try:
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
async with websockets.connect(
url, ping_interval=20, ping_timeout=10, max_size=16 * 1024 * 1024
) as ws:
logger.info("RVS verbunden — llm-adapter online")
retry_s = 2
tls_fallback_tried = False
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
if msg.get("type") != "llm_request":
continue
payload = msg.get("payload", {}) or {}
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
# aber wir blockieren so nicht den Empfang weiterer Messages.
asyncio.create_task(_handle_llm_request(ws, payload))
except Exception as e:
logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
tls_fallback_tried = True
use_tls = False
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
continue
await asyncio.sleep(min(retry_s, 30))
retry_s = min(retry_s * 2, 30)
use_tls = RVS_TLS
if __name__ == "__main__":
asyncio.run(_run())
+2
View File
@@ -0,0 +1,2 @@
websockets>=12.0
httpx>=0.27.0
View File