Compare commits

...
44 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 0612395bbf fix(creds): HTTP-Basic-Benutzer optional (Password-only-Geraete)
Wie bei der FritzBox: manche Geraete nutzen HTTP-Basic-Auth nur mit Passwort.
Modal speichert HTTP-Creds jetzt bei User ODER Passwort; _do_http wendet die
Auth auch bei leerem Benutzer an. SNMP v1/v2c hat ohnehin nur die Community
(kein User); v3 braucht den securityName zwingend und bleibt Pflicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:45:39 +02:00
duffyduckandClaude Opus 4.8 16b6fbeaf6 fix(diagnostic): FritzBox-Benutzer optional — Password-only-Boxen speicherbar
Das Credentials-Modal speicherte FritzBox-Zugangsdaten nur, wenn das Benutzer-
Feld ausgefuellt war (if fu) — Username war damit faktisch Pflicht. FritzBoxen
ohne benannten Benutzer haben aber nur ein Passwort. Jetzt: gespeichert sobald
User ODER Passwort gesetzt ist; Placeholder kennzeichnet den User als optional.
Backend (_do_fritzbox) verlangte ohnehin nur das Passwort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:44:34 +02:00
duffyduckandClaude Opus 4.8 ea0c7e21f8 fix(satellite): fehlender Path-Import — Crash-Loop beim Start (_creds_load)
Der Credential-Store nutzt pathlib.Path, das Modul importierte es aber nicht
-> NameError in _creds_load(), Satellit crashte in Endlosschleife beim Start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:40:30 +02:00
duffyduckandClaude Opus 4.8 f9cc10433a feat(satellite): Credential-Store + snmp.ports/info + FritzBox-Reader
Baut SNMP zur generellen Geraete-Auskunft aus und macht Zugangsdaten pro Geraet
hinterlegbar, damit der Satellit tiefer lesen kann.

Satellit:
- Verschluesselter Credential-Store (Fernet) pro IP im Bind-Volume ./data:
  SNMP (community/v2c oder v3 user/auth/priv), HTTP-Basic, FritzBox-Login.
  Neue Messages sat_creds_set/list/delete + *_result; Secrets werden NIE
  zurueckgeliefert (list gibt nur Typen). snmp.*/http/fritzbox nutzen die
  Creds automatisch (params haben Vorrang).
- snmp.ports: ifTable -> aktive/freie Ports, Linkspeed ('sind noch Ports frei').
- snmp.info: sysName/Descr + Entity-MIB Modell/Serial/Firmware (installierte
  Version; Update-Check ist Hersteller-Sache, kein SNMP).
- fritzbox.info/hosts: TR-064 (SOAP+Digest) — Verbindung/Datenrate/externe IP
  bzw. verbundene Geraete. Bewusst als Reader, weil TR-064 fuer on-the-fly
  http.post zu fummelig ist.
- CONTROL_ALLOWLIST-Default + .env.example erweitert; data/ ge-gitignore-t.

RVS: sat_creds_* in ALLOWED_TYPES (sonst verworfen).

Diagnostic: pro entdecktem Geraet ein Schluessel-Button -> Modal (SNMP v2c/v3,
HTTP-Basic, FritzBox); Speichern/Loeschen via RVS an den Satelliten; gesetzte
Typen werden pro Geraet angezeigt (🔑 ✓). Server relayed die Cred-Messages.

Brain: satellite_command-Tool um snmp.ports/info + fritzbox.* erweitert; ARIA
muss keine Passwoerter mitgeben (Satellit nutzt den Store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:16:06 +02:00
duffyduckandClaude Opus 4.8 8b1555f926 feat(satellite): SNMP-Anreicherung bei Discovery — Switches/Router/APs/NAS geben Infos preis
SNMP soll nicht nur Drucker abfragen: jedes beim Scan entdeckte Geraet mit IP
wird jetzt kurz nach seiner SNMP-System-Group (RFC 1213) gefragt. Antwortet es,
haengt der Satellit ein 'snmp'-Feld an (sysName/sysDescr/sysContact/sysLocation/
sysUpTime) und leitet einen praeziseren 'type' aus sysDescr ab (switch/router/
access-point/nas/printer/ups). Aus einer nackten ARP-IP wird so ein benanntes,
klassifiziertes Geraet im Inventar, das ARIA via satellite_devices sieht.

- parallel (Semaphore, Default 16) mit kurzem Timeout (-t1 -r0, 2s) -> Nicht-
  SNMP-Hosts fallen sofort raus, der Scan bleibt flott.
- SNMP_DISCOVERY (Default true) schaltet es ab; Concurrency/Timeout per env.
- satellite_devices-Tool: ARIA weiss jetzt vom snmp-Feld + genaueren type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:03:50 +02:00
duffyduckandClaude Opus 4.8 02303c35ee feat(satellite): SNMP-Aktionen (snmp.get/walk/printer) — Drucker-Tinte zuverlaessig
HTML-Scrapen der Drucker-Statusseite ist fragil (grosse Seite, Werte teils in
JS/Grafik). SNMP/Printer-MIB liefert die Fuellstaende als saubere Zahlen.

- Dockerfile: net-snmp-CLI (Paket 'snmp') installiert.
- satellite.py: Aktionen snmp.get / snmp.walk (generisch, params ip+oid) und
  snmp.printer (Komfort: liest prtMarkerSupplies aus der Printer-MIB und rechnet
  je Patrone name+level+max+percent). net-snmp via subprocess, numerische OIDs
  (keine MIB-Files noetig), -t2 -r1 + Timeout = schnell scheitern statt haengen.
  RFC-Sonderwerte (-1/-2 unbekannt, -3 vorhanden) behandelt.
- CONTROL_ALLOWLIST-Default + .env.example um snmp.* erweitert, SNMP_*-Envs
  dokumentiert.
- agent.py + seed_rules.py: satellite_command-Tool + Drucker-Beispiel — snmp.
  printer ist jetzt der BEVORZUGTE Weg fuer Tinte, http.get nur Fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:01:08 +02:00
duffyduckandClaude Opus 4.8 c2472a7958 chore(logging): Debug-Logs raus + app.log-Rotation gegen volllaufende Platte
- diagnostic/server.js: die beiden Debug-Logs der ai-box-Fehlersuche entfernt
  (roher RX-Dump jeder worker_*/sat_hello-Nachricht + 'worker_hello empfangen'
  bei jedem 30s-Resend). Die Flotte laeuft, der Firehose kann weg.
- bridge/aria_bridge.py: /shared/logs/app.log (JSONL, wuchs unbegrenzt bei
  jeder App-Log-Zeile) rotiert jetzt bei >5 MB auf die letzten 2000 Zeilen
  (Muster wie metrics._maybe_rotate).

RVS selbst loggt nur Lifecycle (Connect/Disconnect) — kein Message-Firehose,
unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:47:50 +02:00
duffyduckandClaude Opus 4.8 5b952812ee fix(satellite): http.get liefert ganze Statusseiten + contains-Filter, kein Scan-Overhead
ARIA erreichte den Drucker per Satellit, bekam aber nur die ersten 2000
Zeichen (r.text[:2000]) — die Tintenbalken stehen weiter unten und fielen
weg; ein Offset-Versuch wurde ignoriert (nicht unterstuetzt). Zudem lief vor
JEDEM sat_command ein voller LAN-Scan, auch fuer http.get, das die URL direkt
nutzt -> sehr lange Antwortzeiten.

_do_http:
- Body-Limit env-konfigurierbar (HTTP_MAX_CHARS Default 20000, harter Deckel
  HTTP_MAX_CHARS_HARD 200000) statt fixer 2000.
- params.offset + params.max_chars zum Paginieren.
- params.contains (String/Liste): nur Zeilen mit einem der Begriffe -> zieht
  aus einer grossen Statusseite gezielt die Tintenwerte, ohne Paging.
- Antwort meldet total_chars/returned_chars/offset/truncated/filtered.
- HTTP_TIMEOUT_SEC (Default 10s) statt fixer 6s.

sat_command: LAN-Scan nur noch bei dial.launch oder wenn ein device-Ref
mitkommt; http.get/http.post/wol nutzen die gecachte Liste -> deutlich schneller.

agent.py + seed_rules.py: satellite_command-Tool + Drucker-Beispiel um
contains/offset/max_chars ergaenzt, damit ARIA den Filter nutzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 12:09:52 +02:00
duffyduckandClaude Opus 4.8 512e24bb6f ui(diagnostic): Flotten-Status 'frei' -> 'frei – idle – keine Auslastung'
Klarer lesbar in der Compute-Flotte: der idle-Zustand eines Workers heisst
jetzt ausgeschrieben. beschaeftigt/offline unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:48:37 +02:00
duffyduckandClaude Opus 4.8 af1a3bf386 feat(diagnostic): Link von LLM-Einstellungen zur Compute-Flotte (Auslastung)
Unter der LLM-Boxen-Liste in Modelle & KI jetzt ein Link "Auslastung &
Details in der Compute-Flotte", der zum Satelliten-Tab wechselt und zur
Compute-Flotte scrollt — dort sitzt der Auslastung-Button pro Node (Live
nvidia-smi + GPU-/Token-Graphen). Anker #compute-fleet + gotoComputeFleet().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:46:51 +02:00
duffyduckandClaude Opus 4.8 76e8b8e04c fix(llm): Test-Chat-Timeout 15s -> 120s fuer kalten Modell-Swap
qwen3-8b antwortete in ~13s (unter 15s) und kam durch; qwen3-4b lief in
"Timeout", obwohl kleiner. Ursache ist nicht die Modellgroesse, sondern der
KALTE Modell-Swap: beim ersten Wechsel entlaedt llama-swap das alte Modell
und laedt das neue GGUF frisch in den VRAM (+ erste Inferenz) — das dauert
laenger als die 15s, die sendToRVS_withResponse hart als Timeout hatte.

sendToRVS_withResponse nimmt jetzt ein optionales timeoutMs (Default 15s
unveraendert fuer Voice-List etc.); llm_test uebergibt 120s. Der Adapter
selbst deckt den Swap mit LLM_TIMEOUT_SEC=60 ab und meldet waehrenddessen
llm_status "loading".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:45:45 +02:00
duffyduckandClaude Opus 4.8 6918ddca61 fix(satellite): ARIA nutzt Satelliten fuer Geraete in fremden Netzen — statt zu fabulieren
Symptom: Frage nach dem Patronenstand des Heim-Druckers -> ARIA antwortet
"da komm ich nicht ran", obwohl im Heimnetz ein Satellit online ist, der den
Drucker findet UND http.get kann. Sie zieht Drucker-Fakten aus dem Gedaechtnis
und gibt auf, ohne den Satelliten zu nutzen.

Ursachen + Fixes:
- Claude-Pfad: die Seed-Regel triggerte nur auf Formulierungen wie "im Buero"/
  "im Netz X". Eine schlichte Geraetefrage (Patronenstand) griff sie nicht.
  -> Regel umformuliert: IMMER wenn ein Geraet/Host in einem Netz liegt, auf
  dem ARIA nicht direkt sitzt (Zuhause/Buero/private IP), ZUERST satellite_list
  pruefen; NIEMALS "erreiche ich nicht" sagen, bevor satellite_list lief.
  Drucker-http.get-Beispiel ergaenzt.
- satellite_list-Tool-Beschreibung als dauerhaften Anker verstaerkt (gilt auch,
  wenn ein Seed mal driftet).
- Lokales Tier hatte gar keine Satelliten-Tools (_LOCAL_TOOL_NAMES) -> konnte
  strukturell kein Geraet im fremden Netz erreichen und fabulierte. satellite_
  list/devices/command ergaenzt, damit auch das lokale Qwen Satelliten nutzt.

Greift beim naechsten Brain-Neustart (seed_rules.apply im Lifespan, idempotent
per migration_key).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:35:35 +02:00
duffyduckandClaude Opus 4.8 318bb47590 fix(rvs): Compute-Flotte-Typen in ALLOWED_TYPES — ai-box wird sichtbar
Der RVS filtert eingehende Nachrichten gegen die Allow-List ALLOWED_TYPES
und verwirft jeden nicht gelisteten Typ still (server.js ~Z. 312), bevor
er relayed wird. Die neuen Worker-/Auslastungs-Typen der Compute-Flotte
standen nicht drin -> worker_hello & Co. wurden lautlos weggeworfen, die
ai-box blieb in der Diagnostic-Flotte unsichtbar, obwohl sie sauberer
alle 30s sendete. sat_hello war gelistet -> Satellit sichtbar, Worker nicht.

Ergaenzt: worker_hello, worker_ping, worker_update, worker_list,
node_stats, node_stats_stream_start/stop, node_stats_history_request,
node_stats_history, node_stats_reset, node_stats_reset_done.

Das war die eigentliche Ursache der langen Fehlersuche; Token/IP/Port/RVS-
Topologie waren korrekt und nie das Problem (kein Infra-/Caddy-Bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:27:57 +02:00
duffyduckandClaude Opus 4.8 348e9783aa fix(fleet): Empfangs-Watchdog in Workern — halb-tote RVS-Verbindung erkennen + reconnect
Root Cause der unsichtbaren Box: die WS-Verbindung wird ueber die Zeit halb-tot
— Caddy (TLS-Terminator vor dem RVS) haelt sie offen und pongt die WS-Pings
selbst, waehrend der rvs-Backend sie laengst fallen liess (rooms:1 = Box nicht
mehr Raum-Mitglied). Die Box sendet worker_hello ins Leere; der WS-Ping erkennt
den toten Backend nie. (Token/URL/Port/IP/RVS-Instanz alle bewiesen identisch.)

Fix: recv() mit Timeout (RX_STALE_S=60s) statt `async for`. In einem echten Raum
kommt staendig Broadcast-Traffic rein (sat_hello alle 25s, Brain-Polling) →
Timer wird laufend resettet. Bleibt der Traffic aus, ist die Verbindung tot →
ConnectionError → Reconnect ueber die bestehende run_loop-Backoff-Logik.
Betrifft alle vier Worker (f5tts/whisper/voxtral/llm-adapter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 11:10:49 +02:00
duffyduck 1c7e07cb67 debug(diagnostic): Roh-Empfang aller worker_*/sat_hello-Nachrichten loggen (BEVOR Handler) 2026-09-19 10:46:38 +02:00
duffyduckandClaude Opus 4.8 778d75ae61 fix(fleet): Worker-RVS-URL ohne /ws — an Bridge/App/Satellit/Diagnostic angleichen
DIE Ursache, warum die Box nie in der Compute-Flotte auftauchte: die vier
xtts-Worker verbanden zu wss://host:PORT/ws?token=… — ALLE anderen Komponenten
(Bridge, App, Satellit, Diagnostic) verbinden ohne /ws (…:PORT?token=…). Das RVS
selbst ignoriert den Pfad, aber der TLS-Terminator vor :444 routet nach Pfad →
/ws landete in einem anderen Endpunkt/Raum. Folge: worker_hello/ping erreichten
den ARIA-Raum nie (rooms:1 = nur ARIA), das Diagnostic sah die Worker nicht (und
das erklaert auch das RVS-Flappen, das NUR die Worker hatten).

Fix: /ws aus der URL aller vier Worker entfernt → gleicher Pfad wie alle anderen
→ gleicher Raum. Danach: Box neu bauen, Worker erscheinen in der Flotte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 04:47:03 +02:00
duffyduck f3e3aedc6b debug(diagnostic): worker_hello-Empfang loggen (Flotten-Diagnose) 2026-09-19 04:19:30 +02:00
duffyduckandClaude Opus 4.8 2d2c182a0c fix(fleet): worker_hello periodisch wiederholen (wie Satellit) — Box wird nach Diagnostic/Bridge-Neustart wieder sichtbar
Worker sendeten worker_hello nur EINMAL beim Connect. Startet das Diagnostic
oder die Bridge NACH der Box neu, verpassen sie das hello (RVS spielt es nicht
nach) und sehen nur noch worker_ping → die Box taucht nicht in der Compute-
Flotte auf. Der Satellit ist genau deshalb zuverlaessig sichtbar: er wiederholt
sat_hello periodisch.

- alle vier Worker (f5tts/whisper/voxtral/llm-adapter): worker_hello wird jetzt
  zusaetzlich alle ~30s (jeder 3. Ping-Zyklus) wiederholt → ein neu gestartetes
  Diagnostic/Bridge lernt die Box innerhalb von 30s, ohne Box-Neustart.
- diagnostic/server.js: Voice-Reconcile (f5tts) laeuft nur beim ERSTEN/erneuten
  Auftauchen der Box, nicht bei jedem 30s-hello-Resend (sonst Push/Pull-Dauerlauf).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 04:12:16 +02:00
duffyduck 831fbfab58 revert(compute): xtts RVS_PORT zurueck auf 444 — ARIA-Stack laeuft auf 444, nicht 443 2026-09-19 04:03:52 +02:00
duffyduckandClaude Opus 4.8 a0dedd8c97 fix(compute): xtts RVS_PORT-Default 444→443 (an ARIA-Stack angleichen)
Der ganze Stack (Bridge/Diagnostic/Satellit/App) nutzt Port 443, nur
xtts/.env.example stand auf 444. Das RVS gruppiert pro Server+Token in einen
Raum — bei abweichendem Port landet die Box in einem ANDEREN Raum: sie taucht
nicht in der Compute-Flotte auf und STT/TTS/LLM erreichen ARIA nicht (Satellit
auf 443 blieb sichtbar → typisches Symptom). Kommentar verschaerft: Host+Port
+Token muessen EXAKT zum ARIA-Stack passen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:58:01 +02:00
duffyduckandClaude Opus 4.8 8633840f94 fix(diagnostic): Modell-Katalog als Tabelle (Beschreibung + VRAM-Spalten), Button-Layout, Flotte nachziehen
- Katalog jetzt echte Tabelle: Spalten Modell | Kann gut/Beschreibung | VRAM |
  Status | Ziel-Box | Laden. VRAM aus sizeGB (>=12 GB gelb markiert).
- "Von HuggingFace aktualisieren" sitzt jetzt direkt neben der Ueberschrift
  (statt space-between ans rechte Ende); Card breiter (720→1100px), Tabelle
  horizontal scrollbar.
- loadLlmCatalog() zieht die Worker-Flotte aktiv nach (requestWorkers) — falls
  ein worker_update-Push waehrend RVS-Flappens verpasst wurde, erscheint die
  Box beim Oeffnen trotzdem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:48:38 +02:00
duffyduckandClaude Opus 4.8 45167b7bb5 refactor: ai-box/ (bootstrap + README) nach xtts/ verschieben
Das Host-Bootstrap bereitet genau den xtts-Stack vor (legt xtts/.env an, startet
xtts-Compose) und gilt fuer alle Dienste (f5tts/whisper/voxtral/llm). Nach dem
Klonen sucht man das in xtts/ — dort war bisher kein README.

- git mv ai-box/bootstrap.sh xtts/bootstrap.sh; ai-box/README.md → xtts/README.md
- interne Verweise cd ARIA-AGENT/ai-box → cd ARIA-AGENT/xtts (Pfad-Logik
  SCRIPT_DIR/../xtts funktioniert unveraendert, da xtts jetzt das Script-Dir ist)
- README neu betitelt: "xtts — AI-Box (Compute-Node) Setup & Bootstrap" +
  Kontext (Profile/GPU im Haupt-README)
- leeres ai-box/ entfernt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:33:15 +02:00
duffyduckandClaude Opus 4.8 66781d8d90 feat(fleet): Auslastungs-Monitor pro Box — live nvidia-smi + Graphen (Stage E)
Pro Box ein "Auslastung"-Button in der Compute-Flotte → Modal mit live
nvidia-smi (1s), Graphen (GPU-Auslastung + Tokens/Intervall) und Besen-Reset.
Historie liegt auf der Box, Diagnostic holt sie via RVS.

- node_stats.py (identisch in allen 4 Worker-Build-Contexts): Sampler alle 15s
  (nvidia-smi + Token-Delta → Ringpuffer ~500 Punkte, persistent als JSON auf
  der Box), Live-Stream (node_stats, 1s, Auto-Stop 300s), History-Request,
  Reset. nvidia-smi via async subprocess, fail-safe ohne GPU.
- Worker-Wiring (f5tts/whisper/voxtral/llm-adapter): Import, Sampler-Task,
  _stats.handle() nach dem targetInstance-Filter. llm-adapter zaehlt Tokens
  (usage.total_tokens) → Token-Graph nur bei LLM-Boxen. Dockerfiles kopieren
  node_stats.py.
- compose: llm-adapter bekommt runtime:nvidia + NVIDIA_VISIBLE_DEVICES=all +
  DRIVER_CAPABILITIES=utility (nur nvidia-smi, KEIN VRAM/Compute).
- diagnostic/server.js: relay node_stats_* (Browser→Box) + forward (Box→Browser).
- diagnostic/index.html: Auslastung-Button pro Node (Ziel bevorzugt llm-Instanz),
  Modal mit live nvidia-smi + Inline-SVG-Sparklines, Besen-Reset.

Reporter-Wahl bevorzugt die llm-Instanz (sieht alle GPUs + Tokens); GPU-Worker
sehen ihre gepinnte Karte. Gitignored Historie stoert git-Baum der Box nicht.
Deploy: diagnostic + GPU-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:26:17 +02:00
duffyduckandClaude Opus 4.8 5c25d6abeb feat(llm): Modell-Download + HuggingFace-Katalog (Stage D)
Neue lokale GGUF-Modelle per Knopf auf eine Box laden — ohne Image-Rebuild.

- llm-adapter besitzt jetzt llama-swaps Config: generiert
  /models/llama-swap.config.yaml aus Basis-Template (xtts/llama-swap/config.yaml)
  + persistenter Registry /models/aria_models.json. Neue RVS-Handler
  llm_provision_model / llm_remove_model (targetInstance-gefiltert): Registry+
  Config schreiben, llama-swap-Reload anstossen, neu announcen, Warmup (zieht das
  GGUF via -hf, Fortschritt via service_status loading→ready). pyyaml ergaenzt.
- compose: llama-swap liest --config /models/llama-swap.config.yaml; llm-adapter
  mountet ./models (rw) + ./llama-swap (ro Template).
- diagnostic/server.js: /shared/config/llm_catalog.json (kuratierte GGUF-Liste)
  + GET /api/llm-catalog + POST /api/llm-catalog/refresh (HuggingFace-API-Merge);
  Actions llm_provision_model / llm_remove_model / llm_test; llm_provision_result
  an Browser durchgereicht.
- diagnostic/index.html: "Modell-Katalog"-Card (HF-Refresh, Ziel-Box waehlen,
  Laden, Verfuegbarkeit) + Test-Chat-Zeile ans lokale LLM (Antwort + Latenz).

Download nutzt llama-swaps vorhandenen -hf-Pfad (kein neuer Download-Code).
Reload ist der einzige Deploy-Verify-Punkt (llama-swap-Image); Fallback Box-up.
Deploy: diagnostic neu bauen (VM) + llm-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 03:12:31 +02:00
duffyduckandClaude Opus 4.8 05c6c7687a feat(llm): Flotten-Katalog + modell-bewusstes LLM-Routing (Stage C)
ARIA nutzt lokale LLMs auf mehreren ai-boxen; jede Box (llama-swap) kann
mehrere Modelle fahren. Auswahl nach MODELL, Box wird automatisch gewaehlt.

- xtts/llm-adapter/adapter.py: fragt beim Connect llama-swap GET /v1/models ab
  und meldet die Modell-Liste in worker_hello (models:[...]), Fallback [LLM_MODEL].
- bridge/aria_bridge.py: Worker-Registry speichert models[]; _pick_worker(service,
  model=) beruecksichtigt nur Boxen, die das Modell fahren koennen (nachsichtig:
  keine → None → Broadcast/Claude-Fallback); Round-Robin je service+model
  verteilt mehrere Projekte auf mehrere Boxen; _local_llm reicht das Modell durch;
  _worker_list traegt models[].
- diagnostic/server.js: workers-Map + workerList um models[] erweitert.
- diagnostic/index.html: Compute-Flotte zeigt bei llm die Modell-Liste; das
  "Lokales Modell"-Dropdown wird LIVE aus den angemeldeten Boxen gebaut
  (Vereinigung + kuratierte Namen aus local_models.json, "· N Box(en)"/"offline"),
  darunter eine kompakte LLM-Box-Liste (Node→Modelle→Health). Speisung aus dem
  vorhandenen worker_update-Broadcast.

Kein Brain-/App-Eingriff. Routing greift nur bei aktivem Lokal-Schalter.
Deploy: diagnostic + bridge + llm-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 02:49:30 +02:00
duffyduckandClaude Opus 4.8 0f122a1ad7 feat(voice): zentraler Stimmen-Store + Auto-Provisioning der f5tts-Flotte
Stimmen lagen bisher nur pro Box (xtts/voices/). Jetzt haelt der Diagnostic-
Server sie zentral als /shared/voices/{name}.tar.gz und versorgt jede f5tts-Box
automatisch.

- diagnostic/server.js (Bibliothekar):
  * zentraler Store + Helfer (list/read/write/delete tar.gz)
  * reconcileVoices() beim worker_hello einer f5tts-Box: push fehlende zentrale
    Stimmen (xtts_import_voice, targetInstance) + pull box-eigene, zentral
    fehlende (xtts_export_voice) → seedet den Store aus der ersten Box
  * autonome RVS-Listener: xtts_voice_saved → zentraler Ingest (auch App-Uploads);
    xtts_voice_exported (nur eigene requestId) → in Store schreiben;
    xtts_delete_voice → zentrale Kopie mitloeschen
  * Delete-Action loescht zentrale Kopie direkt mit
- xtts/f5tts/bridge.py: worker_hello traegt jetzt voices:[names] (ohne
  default_ref) fuer den Abgleich; handle_export_voice echot requestId zurueck
  (Korrelation zentral vs. browser-initiiert)

Wiederverwendet den vorhandenen export/import-tar.gz-Transport + Stage-3-
targetInstance-Filter. Playback (preview_voice) und Lastrouting (freieste Box)
existierten bereits. Keine App-/aria-bridge-Aenderung.

Deploy: diagnostic + f5tts-Boxen neu bauen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 01:32:06 +02:00
duffyduckandClaude Opus 4.8 a773832bca refactor: "Gamebox" ueberall durch "AI-Box(en)" ersetzen
Rein terminologisch: die feste "Gamebox" ist konzeptuell zu beliebig vielen
AI-Boxen geworden (Multi-Node-Compute-Fleet). Wort-Sweep ueber Code-Kommentare,
Strings, README, docs, .env.example, CHANGELOG — keine Identifier/Keys betroffen
(Gamebox kam nirgends in Variablennamen vor). Casing erhalten: Gamebox→AI-Box,
gamebox→ai-box. NODE_NAME-Default gamebox→ai-box, Instanz-IDs f5tts@ai-box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 00:34:21 +02:00
duffyduckandClaude Opus 4.8 17a60e5d3e refactor(diagnostic): Einstellungen in 3 Sub-Tabs gliedern
Der Settings-Tab war auf 13 Sektionen untereinander gewachsen (unuebersichtlich).
Jetzt drei Sub-Tabs im #tab-settings:
- Modelle & KI: Sprachmodell (Brain), Lokales LLM, Externe Anbieter (OpenRouter
  & Co — Platzhalter), Sprachausgabe/F5-TTS, Spracherkennung (STT: Voxtral/
  Whisper), Voice-ID
- Integrationen: OAuth-Apps, FLUX Bildgenerierung
- System & Wartung: Reparatur/Restart, Komplett-Reset, Betriebsmodus,
  Runtime-Konfiguration, App-Onboarding-QR

Nur Re-Parenting: jede Sektion wird byte-identisch in ihr Panel verschoben,
alle Panels bleiben immer im DOM (nur CSS zeigt/versteckt), Parent bleibt
#tab-settings → alle Loader (get_voice_config, loadRuntimeConfig, …) und
Save/Load-Handler laufen unveraendert. Verifiziert: 46 Element-ids vorher =
46 nachher, keine verloren/doppelt; JS-Block node --check sauber.

Neu: settings-subnav + switchSettingsTab() (merkt den zuletzt gewaehlten
Sub-Tab in localStorage), STT-Section-Hinweis dass die Engine pro Compute-Node
via COMPOSE_PROFILES gewaehlt wird, Platzhalter-Section fuer externe Anbieter
(kein Backend, nur reservierter Platz). App unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 23:49:25 +02:00
duffyduckandClaude Opus 4.8 182351e022 docs(compute): Whisper-als-STT-Beispiel fuer kleine GPUs
.env.example + README: eigene Beispiel-Zeile "kleine Karte" —
COMPOSE_PROFILES=whisper,f5tts (Whisper statt Voxtral, beide auf EINER GPU),
Default der Vorlage auf whisper,f5tts gesetzt (Voxtral-3B braucht ~9 GB).
GPU-Defaults angepasst (WHISPER_GPU=0 neben F5TTS_GPU=0) + Klarstellung:
pro Node genau EIN STT (Voxtral ODER Whisper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 14:13:35 +02:00
duffyduckandClaude Opus 4.8 ec78eb8efe feat(compute): Redundanz-Routing per targetInstance (Stage 3)
Mehrere Instanzen pro Dienst nutzbar: Anfragen werden gezielt an eine freie
Instanz adressiert statt an alle gebroadcastet. Mehrere f5tts → naechstes
freies; mehrere LLM → parallele Turns (Multitasking); STT-Redundanz ueber
mehrere Apps via Lease.

- Worker (alle vier): filtern am Loop-Eingang — targetInstance gesetzt und
  != eigener INSTANCE_ID → Nachricht ignorieren. Feld fehlt → wie bisher.
- bridge (TTS/LLM, emittiert die Bridge selbst): _pick_worker() waehlt eine
  online+freie Instanz (Round-Robin), stempelt targetInstance auf
  xtts_request / llm_request. Keine Instanz bekannt → Broadcast.
- bridge (STT-Lease, emittiert die App): neuer stt_lease_request-Handler →
  _pick_stt_worker() (voxtral vor whisper) → stt_lease {instanceId}.
- app (audio.ts): requestSttLease() vor dem Stream, stempelt targetInstance
  auf stt_stream_start / stt_audio_chunk / stt_stream_end (+cancel). Kurzer
  Timeout → '' (Broadcast), Aufnahme haengt nie.

Voll rueckwaertskompatibel: Routing aktiviert sich erst, wenn Worker sich per
worker_hello (Stage 2) registriert haben — sonst bleibt alles Broadcast.
Braucht APK-Rebuild fuer die STT-Lease-Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:27:51 +02:00
duffyduckandClaude Opus 4.8 f2ead1242f feat(compute): Worker-Selbstanmeldung ueber RVS + Flotten-Anzeige (Stage 2)
Jeder GPU-Dienst meldet sich beim Connect mit worker_hello {instanceId,
service, node, gpus, model} und haelt die Registry per periodischem
worker_ping {instanceId, busy} (~10s) frisch. So weiss ARIA, was wo laeuft.

- xtts/{voxtral,whisper,f5tts}/bridge.py + llm-adapter/adapter.py:
  INSTANCE_ID=service@NODE_NAME, _worker_register()-Coroutine (hello + ping),
  busy-Quelle je Worker (aktive STT-Sessions / TTS-Render / in-flight LLM);
  Task sauber gecancelt bei Reconnect.
- bridge/aria_bridge.py: self._workers-Registry + Handler worker_hello/
  worker_ping (spiegelt sat_hello), _worker_list() (35s-Offline-TTL),
  _pick_worker() (Round-Robin freie Instanz, fuer Stage-3-Routing),
  /internal/worker-list-Endpoint.
- diagnostic/server.js: workers-Map, worker_hello/worker_ping-Tracking,
  worker_update-Broadcast + worker_list-Action + on-connect-Snapshot.
- diagnostic/index.html: "Compute-Flotte"-Panel im Satelliten-Tab — pro Node
  gruppiert, mit Dienst/Modell/GPU und frei/beschaeftigt/offline-Status.

Stage 2 von 3. Reine Sichtbarkeit, kein Routing-Verhalten geaendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:22:47 +02:00
duffyduckandClaude Opus 4.8 e75f1eeb6a feat(compute): Multi-Node-Aufteilung via Compose-Profile + GPU-Wahl per .env
Die feste "Gamebox" wird zu beliebig vielen Compute-Nodes. Jeder Node startet
ueber COMPOSE_PROFILES nur die Dienste, die er anbieten soll, und pinnt sie per
*_GPU auf bestimmte Grafikkarten.

- xtts/docker-compose.yml: jeder GPU-Dienst hinter einem Profil
  (voxtral/whisper/f5tts/llm); deploy.devices-Block ersetzt durch
  runtime: nvidia + NVIDIA_VISIBLE_DEVICES=${SVC_GPU} (erlaubt auch "0,1");
  NODE_NAME an alle RVS-Dienste.
- xtts/.env.example: COMPOSE_PROFILES, NODE_NAME, VOXTRAL/WHISPER/F5TTS/LLM_GPU
  mit Beschreibungen; Beispiele STT-Box / TTS-Box / LLM-Box / All-in-One.
- README: "Gamebox-Stack" → "Compute-Nodes"; Diagramm, Deploy-Tabelle und
  Setup-Abschnitt auf Multi-Node umgeschrieben.

Stage 1 von 3 (Aufteilung+Config+Docs). Reine Config/Docs, kein Verhaltens-
Risiko: Single-Node mit COMPOSE_PROFILES=voxtral,f5tts,llm laeuft wie bisher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 12:14:58 +02:00
duffyduckandClaude Opus 4.8 2bd7c747d9 fix(diagnostic): Bild-Upload landet im fokussierten Projekt statt Hauptchat
Datei-Uploads (Copy-Paste UND Datei-Picker) trugen im Diagnostic-Portal
den projectId des fokussierten Projekts nicht mit — anders als Text und
Zwischenrufe. Die Bridge las payload.projectId, bekam nichts und routete
die Datei-Bubble in den Hauptchat.

- index.html: sendDiagAttachments schickt projectId: focusedContextId
- server.js: send_file-Relay reicht projectId in die RVS-Payload weiter

Beide Upload-Wege laufen ueber handleDiagFileSelect → ein Fix deckt beide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 08:38:58 +02:00
duffyduckandClaude Opus 4.8 d0694cb637 fix(voice): 'gute Nacht' loest nicht mehr faelschlich Ohr-Aus aus
Regression aus dem Schlaf-Idiom-Commit (954ad9a): 'gute nacht' im wake-off-Regex
matchte jeden Verabschiedungs-Gruss ('Ich wünsche dir eine gute Nacht') → Ohr
ging aus, Stefan hing in einer 'Ohr aus'-Schleife fest. 'gute nacht'/'schlaf
gut' sind Gruesse, kein Ohr-Aus-Befehl → raus aus dem Regex. Nur klare Imperative
an ARIA bleiben ('geh/leg dich schlafen', 'leg dich aufs Ohr/hin'); mehrdeutige
Faelle faengt ARIA per ear_control aus dem Kontext ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:43:18 +02:00
duffyduckandClaude Opus 4.8 8f313eb68b feat(voice): ear_control-Tool — ARIA versteht Ohr-Aus/-An in jeder Formulierung
Statt jede Schlaf-/Aus-Formulierung per Regex aufzuzählen: ein Tool ear_control
(action off|on), das ARIA aufruft, wenn Stefan sinngemäß "hör auf/wieder zu
zuhören" sagt — egal wie ("leg dich schlafen", "ich geh ins Bett, du kannst
aus", "mach Pause vom Zuhören"). Tools ruft ARIA zuverlässig (im Gegensatz zu
den [[..]]-Markern, die sie oft ignoriert).

Nutzt die komplette bestehende Plumbing: der Tool-Call setzt _ear_control →
answered_by wird "wake-off"/"wake-on" → main.py setzt wake_off/wake_on → Bridge
→ App stoppt/startet Listener. Reiner Steuerbefehl (still, kein Weiterlauschen).
Der deterministische Regex-Detektor bleibt als schnelles Netz für die
Standard-Sätze (instant, kein Claude-Call). Prompt-Hinweis in der Voice-Flow-
Sektion ergänzt. Brain-only — kein neues APK nötig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:24:04 +02:00
duffyduckandClaude Opus 4.8 954ad9ade8 fix(voice): wake-off erkennt mehr Schlaf-Idiome
"leg dich schlafen" / "leg dich aufs Ohr" / "leg dich hin" / "gute Nacht" /
"schlaf gut" zusätzlich zu "geh schlafen". Sofort-Netz (instant, kein Claude-
Call). Der robustere Weg (ear_control-Tool, ARIA versteht jede Formulierung)
folgt separat, wenn Stefan will.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:19:29 +02:00
duffyduck 75fa574a85 release: bump version to 0.2.4.8 2026-08-16 22:01:07 +02:00
duffyduckandClaude Opus 4.8 a3d7933949 fix(voxtral): Speech-Endpoint gegen laute Umgebungsmusik
Problem (Stefan): laeuft Musik, endet eine Aufnahme nie von selbst — die Energie
bleibt oben, der RMS-Stille-Endpoint feuert nicht (last_voice_at immer frisch).
Erst Stop druecken oder Musik leiser (dann echte Stille) beendet.

Fix: waehrend lauter Phasen (rms ueber Schwelle) periodisch (~700ms, gedrosselt)
mit Silero pruefen, ob im letzten endpoint_ms-Fenster ueberhaupt Sprache ist.
Nur Musik/Stille → Turn beenden (finalize endpoint). Real gesprochene Turns
haben Sprache im Fenster → laufen normal weiter; eine kurze Denk-Pause auch,
solange innerhalb der Toleranz noch Sprache im Fenster liegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:57:06 +02:00
duffyduck 626281dead release: bump version to 0.2.4.7 2026-08-16 21:39:35 +02:00
duffyduckandClaude Opus 4.8 49ea172617 feat(wake): Voxtral-Bestätigungsstufe gegen Musik-Fehltrigger
openWakeWord triggert oft auf Musik (Pet Shop Boys "West End Girls" & Co.). Neu:
nach dem Trigger prüft Voxtral den Vor-Trigger-Audio ("war das wirklich das
Wake-Wort oder nur Musik?") — erst bei Bestätigung Gong + Mikro, sonst still
verworfen + re-arm. Kostet ~0,5-1s vor dem Gong.

- Native (OpenWakeWordModule.kt): 2s Roh-PCM-Ringpuffer; bei Erkennung wird der
  1,5s-Vor-Trigger-Schnipsel base64 (s16le 16kHz) ans WakeWordDetected-Event
  gehängt (preTriggerPcm).
- Bridge (voxtral): neuer One-Shot-Handler stt_transcribe_blob → Silero + Voxtral
  → stt_transcribe_result. Musik/Rauschen → leerer Text.
- App: audio.transcribeBlob() schickt den Schnipsel, wakeword.confirmWake() prüft
  ob das distinktive Keyword (>=4 Zeichen) im Transkript steht. Gate sitzt in
  onWakeDetected VOR Gong/Dialog. Setting "Wake-Wort per Voxtral bestätigen"
  (default aus, opt-in).

FAIL-OPEN durchgängig: kein preTriggerPcm (altes APK) / Timeout / Fehler / VAD
weg → Wake läuft normal durch. Nie schlechter als heute. NATIVER TEIL UNGETESTET
(kein Gerät hier) — braucht Build + Test auf dem Handy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:37:15 +02:00
duffyduck a3650bb0e4 release: bump version to 0.2.4.6 2026-08-16 21:17:07 +02:00
duffyduckandClaude Opus 4.8 514ba44e51 fix(app): Umgebungsmusik killt Weiterreden-Fenster nicht mehr (re-listen)
Wechselwirkung entdeckt: im Passiv-Lausch-Fenster (Weiterreden nach ARIAs
Antwort) nimmt das Mikro Umgebungsmusik auf → Silero verwirft sie korrekt als
vad_no_speech → leerer Endpoint. Die App wertete JEDEN leeren Endpoint als
"nichts gesagt" → sofort zurueck aufs Wake-Word. Bei laufender Musik in einer
Sprechpause endete so das Gespraech, obwohl die Stille-Toleranz noch lief.

Fix: bei leerem Endpoint im Passiv-Fenster wird — solange die Stille-Toleranz ab
Fenster-Oeffnung noch laeuft (Budget) und < 15 Runden — nochmal gelauscht statt
zu beenden. Musik wird verworfen, das Fenster bleibt bis zur Toleranz offen, du
kannst innerhalb reden. Erst wenn die Toleranz wirklich um ist → aufs Wake-Word.
Sprichst du (Text-Endpoint), geht es sofort normal weiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:14:37 +02:00
duffyduckandClaude Opus 4.8 617dddf3d8 fix(app): Konversationsmodus auch im Hintergrund, wenn Background-Wake an
Diagnose aus dem App-State-Trail (Bridge-Log): nach jeder gesprochenen Antwort
rief die App endConversation(bg || !converse) — der bg-Term (AppState != active)
erzwang "zurueck aufs Wake-Word", sobald die App im Hintergrund war. Das war vor
dem Background-Wake-Feature Absicht (kein Multi-Turn im Hintergrund). Jetzt, mit
aktivem Background-Wake, killt genau das den Konversationsmodus im Hintergrund —
im Vordergrund lief er weiter (daher "kommt beim Vorholen wieder"). converse kam
korrekt als true vom Brain (keine Marker, Default true); die App warf es im
Hintergrund weg.

Fix: bg erzwingt "armed" nur noch, wenn Background-Wake AUS ist
(isBgWakeEnabled()). Bei aktivem Background-Wake bleibt der Konversationsmodus
auch im Hintergrund offen — der User hat das Zuhoeren ja bewusst eingeschaltet.
Vordergrund-Verhalten unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:06:40 +02:00
duffyduck 88faf57253 release: bump version to 0.2.4.5 2026-08-16 20:40:47 +02:00
45 changed files with 3818 additions and 563 deletions
+1 -1
View File
@@ -235,7 +235,7 @@ Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
## [0.2.0.4 0.2.0.5] — 2026-07-11 — Plan B: Lokales LLM („Gemini-Feeling") ## [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. Ein kleines, schnelles Modell (**Qwen3 8B** via llama.cpp/llama-swap auf der AI-Box-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 ### Hinzugefügt
+80 -52
View File
@@ -35,18 +35,17 @@ ARIA hat zwei Rollen:
│ WebSocket Tunnel │ WebSocket Tunnel │ WebSocket Tunnel │ WebSocket Tunnel
▼ ▼ ▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
Gamebox (Windows + WSL2) Compute-Node(s) (NVIDIA GPU)
RTX 3060, Docker Desktop beliebig viele, je per .env
│ konfiguriert (COMPOSE_PROFILES) │
│ ┌──────────────────────────┐ │ │ ┌──────────────────────────┐ │
│ │ aria-f5tts-bridge │ │ │ aria-voxtral-bridge Profil: voxtral (Default-STT)
│ │ F5-TTS Voice Cloning │ │ │ │ aria-f5tts-bridge │ │ Profil: f5tts (TTS)
│ │ PCM-Streaming an die App │ │ │ │ aria-llm-adapter+swap │ │ Profil: llm (lokales LLM)
├──────────────────────────┤ │ │ aria-whisper-bridge │ │ Profil: whisper (STT-Fallback)
│ │ aria-whisper-bridge │ │
│ │ Faster-Whisper CUDA │ │
│ │ STT in fast-Echtzeit │ │
│ └──────────────────────────┘ │ │ └──────────────────────────┘ │
Beide teilen ./voices Volume Aufteilbar: 1 Node pro Dienst
│ ODER All-in-One. GPU per *_GPU. │
│ xtts/docker-compose.yml │ │ xtts/docker-compose.yml │
└─────────────────────────────────┘ └─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
@@ -95,12 +94,16 @@ ARIA hat zwei Rollen:
|-----|----|-----| |-----|----|-----|
| RVS | Rechenzentrum | `cd rvs && docker compose up -d` | | RVS | Rechenzentrum | `cd rvs && docker compose up -d` |
| ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` | | ARIA Brain/Bridge/Diagnostic | Debian 13 VM | `./init.sh && ./aria-setup.sh && docker compose up -d` |
| Gamebox-Stack (F5-TTS + Whisper) | Gamebox (GPU) | `cd xtts && docker compose up -d` | | Compute-Node(s) (STT/TTS/LLM) | 1..n GPU-Rechner | `cd xtts && cp .env.example .env && docker compose up -d` |
| Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` | | Satellit(en) 🛰️ (optional) | Fremdes Netz (Büro …) | `cd satellite && cp .env.example .env && docker compose up -d --build` |
| Android App | Stefans Handy | APK installieren (Auto-Update via RVS) | | Android App | Stefans Handy | APK installieren (Auto-Update via RVS) |
> Der Gamebox-Stack ist optional: ohne ihn faellt STT auf lokales Whisper (CPU, > Compute-Nodes sind optional: ohne sie faellt STT auf lokales Whisper (CPU,
> langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text). > langsamer) zurueck; TTS bleibt aus (ARIA antwortet dann nur als Text).
> Jeder Node startet per `COMPOSE_PROFILES` in seiner `.env` nur die Dienste,
> die er anbieten soll (`voxtral`/`f5tts`/`llm`/`whisper`) — so laesst sich der
> GPU-Stack auf mehrere Maschinen verteilen (STT-Box, TTS-Box, LLM-Box) oder
> als All-in-One auf einer Kiste fahren (`voxtral,f5tts,llm`).
--- ---
@@ -135,8 +138,8 @@ RVS_PORT=443
RVS_TLS=true RVS_TLS=true
RVS_TLS_FALLBACK=true RVS_TLS_FALLBACK=true
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Gamebox im gleichen RVS-Room # Pairing-Token: Verbindet App, Bridge, Diagnostic und Compute-Nodes im gleichen RVS-Room
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Gaming-PC, App) # MUSS auf allen Geraeten identisch sein (ARIA-VM, Compute-Nodes, App)
RVS_TOKEN= # ./generate-token.sh RVS_TOKEN= # ./generate-token.sh
``` ```
@@ -304,28 +307,28 @@ Danach wird der Proxy gepatcht:
## Voice Bridge ## Voice Bridge
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Dienste
auf der Gamebox. auf den Compute-Nodes.
**Nachrichtenfluss:** **Nachrichtenfluss:**
``` ```
Text: App → RVS → Bridge → aria-brain (HTTP) Text: App → RVS → Bridge → aria-brain (HTTP)
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox) Audio: App → RVS → STT-Node (voxtral/whisper) direkt (Streaming)
→ stt_response → Bridge → aria-brain → stt_endpoint/stt_stream_done → Bridge → aria-brain
Fallback bei Timeout: lokales faster-whisper (CPU) Fallback bei Timeout: lokales faster-whisper (CPU)
Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad) Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad)
aria-brain → Antwort → Bridge → RVS → App aria-brain → Antwort → Bridge → RVS → App
→ xtts_request (RVS) → f5tts-bridge → xtts_request (RVS) → f5tts-Node
→ audio_pcm Stream → RVS → App AudioTrack → audio_pcm Stream → RVS → App AudioTrack
``` ```
### Features ### Features
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper - **STT primaer remote**: die App streamt Audio direkt an einen STT-Node
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales (Voxtral-3B default, faster-whisper Fallback-Profil), fast Echtzeit. Timeout
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast. Fallback auf lokales CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config.
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit - **TTS via F5-TTS**: aria-f5tts-bridge auf einem Compute-Node. Voice Cloning mit
Referenz-Audio + automatisch transkribiertem Referenz-Text. Referenz-Audio + automatisch transkribiertem Referenz-Text.
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code, - **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
@@ -486,7 +489,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv) - **Disk-Voll Banner** mit copy-baren Cleanup-Befehlen (safe + aggressiv)
- **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%. - **Token/Call-Metrics**: pro Claude-Call ein Eintrag in `/data/metrics.jsonl` mit ts + Token-Schaetzung. Gehirn-Tab zeigt 1h/5h/24h/30d-Aggregat plus Progress-Bar gegen Plan-Limit (Pro / Max 5x / Max 20x / Custom). Warn-Schwelle 80%, kritisch 90%.
- **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch - **Voice Cloning**: Audio-Samples hochladen, Whisper transkribiert den Ref-Text automatisch
- **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Gameboxen mitnehmen - **Voice Export/Import**: einzelne Stimmen als `.tar.gz` zwischen Compute-Nodes mitnehmen
- **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle - **Settings Export/Import**: `voice_config.json` + `highlight_triggers.json` als JSON-Bundle
- **Claude Login**: Browser-Terminal zum Einloggen in den Proxy - **Claude Login**: Browser-Terminal zum Einloggen in den Proxy
- **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt - **ARIA Live**: read-only Mirror der Claude-Code-Session — alle Tool-Calls + Inputs + Outputs live in einer Monospace-Liste, farbcodiert. **Persistenz**: jeder `agent_stream`-Event wird parallel in `/shared/logs/agent_stream.jsonl` (soft-cap 50 MB) geschrieben, Live-View laedt beim Tab-Oeffnen / Page-Reload die letzten 200 Eintraege — Browser-Standby wirft nichts mehr weg. Plus ⛔ **Not-Aus**-Button der per RVS einen `cancel_request` mit `hard:true` ausloest → aria-bridge ruft den proxy-internen `/cancel-all` Side-Channel → alle Claude-Subprocesses werden sofort gekillt
@@ -515,7 +518,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert - **Wake-Word waehrend TTS**: Du kannst "Computer" sagen waehrend ARIA noch redet — AcousticEchoCanceler verhindert dass ARIAs eigene Stimme das Wake-Word triggert
- **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort - **Anruf-Pause + Auto-Resume**: TTS verstummt bei klassischem Anruf oder VoIP-Call (WhatsApp/Signal/Discord). Nach dem Auflegen geht ARIA von der **genauen Stelle** weiter wo sie unterbrochen wurde — die App misst die Position vom Wiedergabe-Anfang und nutzt den WAV-Cache der Antwort
- **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt - **Speech Gate**: Aufnahme wird verworfen wenn keine Sprache erkannt
- **STT (Speech-to-Text)**: 16kHz mono → Bridge → Gamebox-Whisper (CUDA) → Text im Chat. Fast in Echtzeit. - **STT (Speech-to-Text)**: 16kHz mono → STT-Node (Voxtral-3B, CUDA) → Text im Chat. Fast in Echtzeit.
- **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button - **"ARIA denkt..." Indicator**: Zeigt live den Status vom Core (Denken, Tool, Schreiben) + Abbrechen-Button
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.06.0s, Default 3.5s) gegen Gaps bei Render-Pausen - **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.06.0s, Default 3.5s) gegen Gaps bei Render-Pausen
- **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende - **Audio-Pause**: Andere Apps (Spotify, YouTube etc.) pausieren komplett waehrend ARIA spricht und kommen erst wieder nach echtem Wiedergabe-Ende
@@ -655,7 +658,7 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain
Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt) Bridge: STT-Ergebnis → RVS → App (Placeholder wird durch transkribierten Text ersetzt)
aria-brain → Antwort → Bridge → F5-TTS (Gaming-PC) → PCM-Stream → RVS → App aria-brain → Antwort → Bridge → F5-TTS (Compute-Node) → PCM-Stream → RVS → App
App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message App: AudioTrack MODE_STREAM (nahtlos), Cache als WAV pro Message
``` ```
@@ -797,36 +800,57 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
--- ---
## Gamebox-Stack — F5-TTS + Whisper (GPU-Services) ## Compute-Nodes — STT / TTS / LLM (GPU-Dienste)
Laeuft auf einem separaten Rechner mit NVIDIA GPU (z.B. Gaming-PC mit RTX 3060). Die GPU-Dienste laufen auf einem oder mehreren separaten Rechnern mit NVIDIA GPU.
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert Jeder **Compute-Node** verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein
ueber verschiedene Netze hinweg. VPN noetig, funktioniert ueber verschiedene Netze hinweg. Frueher war das *eine*
feste „AI-Box"; jetzt sind es beliebig viele Nodes, jeder per `.env` konfiguriert.
### Architektur ### Dienste & Profile
Jeder Dienst haengt an einem Compose-Profil. Ein Node startet ueber
`COMPOSE_PROFILES` (in seiner `.env`) nur die Profile, die er anbieten soll:
| Profil | Container | Rolle |
|-----------|------------------------------|-------|
| `voxtral` | aria-voxtral-bridge | Default-STT (Voxtral-Mini-3B, ~9 GB) |
| `whisper` | aria-whisper-bridge | STT-Fallback (faster-whisper CUDA) |
| `f5tts` | aria-f5tts-bridge | TTS (F5-TTS Voice Cloning) |
| `llm` | aria-llama-swap + llm-adapter| Lokales LLM (llama-swap, OpenAI-kompat.) |
### Architektur (Aufteilung auf mehrere Nodes)
``` ```
Gamebox (Windows, RTX 3060, Docker Desktop + WSL2) STT-Box COMPOSE_PROFILES=voxtral VOXTRAL_GPU=0
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay TTS-Box COMPOSE_PROFILES=f5tts F5TTS_GPU=0
│ Hoert auf xtts_request, streamt audio_pcm LLM-Box COMPOSE_PROFILES=llm LLM_GPU=0
├── aria-whisper-bridge faster-whisper auf CUDA (float16) -- kleine Karte: Whisper (klein) statt Voxtral, neben F5-TTS --
│ Hoert auf stt_request, antwortet mit stt_response Klein-Box COMPOSE_PROFILES=whisper,f5tts WHISPER_GPU=0 F5TTS_GPU=0
└── ./voices/ Geteilt zwischen beiden: ── oder All-in-One ──
{name}.wav — Referenz-Audio (~6-10s) AI-Box COMPOSE_PROFILES=voxtral,f5tts,llm VOXTRAL_GPU=1 F5TTS_GPU=0 LLM_GPU=0
{name}.txt — Referenz-Text (auto via Whisper)
↕ RVS (Rechenzentrum, WebSocket Relay) ↕ RVS (Rechenzentrum, WebSocket Relay)
ARIA-VM ARIA-VM
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback) └── aria-bridge: orchestriert TTS/LLM (xtts_request/llm_request),
TTS via xtts_request → audio_pcm Stream lauscht passiv auf den STT-Stream App↔STT-Node.
STT-Timeout → lokaler CPU-Whisper-Fallback.
``` ```
### Voraussetzungen > STT: pro Node **genau einen** — Voxtral-3B (~9 GB, beste Qualitaet) *oder*
> Whisper (klein, passt neben F5-TTS auf eine GPU mit wenig VRAM). Beide zusammen
> beantworten dieselbe Anfrage doppelt.
- Docker Desktop mit WSL2 (Windows) oder Docker mit NVIDIA Runtime (Linux) Die STT-Node teilt sich das `./voices/`-Volume mit F5-TTS nur, wenn beide auf
- NVIDIA Container Toolkit demselben Node laufen (Referenz-Text-Transkription beim Voice-Upload). Auf
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam) getrennten Nodes transkribiert F5-TTS ueber den STT-Node via RVS.
### Voraussetzungen (pro Node)
- Docker + **NVIDIA Container Toolkit** (registriert die `nvidia`-Runtime — die
Compose nutzt `runtime: nvidia` + `NVIDIA_VISIBLE_DEVICES`).
- Genug VRAM fuer die gewaehlten Profile (Voxtral-3B ~9 GB, F5-TTS ~1 GB, LLM je Modell).
- **Gleicher RVS_TOKEN wie auf der ARIA-VM!** - **Gleicher RVS_TOKEN wie auf der ARIA-VM!**
### Setup ### Setup
@@ -834,13 +858,17 @@ ARIA-VM
```bash ```bash
cd xtts cd xtts
cp .env.example .env cp .env.example .env
# .env mit RVS-Verbindungsdaten fuellen (gleicher Token wie ARIA-VM!) # .env anpassen:
# COMPOSE_PROFILES → welche Dienste dieser Node fahren soll
# NODE_NAME → Name des Rechners (erscheint in Diagnostic + Logs)
# *_GPU → welche Grafikkarte pro Dienst (NVIDIA_VISIBLE_DEVICES)
# RVS_* → gleiche Verbindungsdaten wie die ARIA-VM
docker compose up -d docker compose up -d
# Erster Start laedt die Modelle (Whisper ~1-3GB je nach Groesse, F5-TTS ~1GB) # Erster Start laedt die Modelle der aktiven Profile (Voxtral ~9GB, F5-TTS ~1GB)
``` ```
Die Modelle werden in den Volumes `f5tts-models` und `whisper-models` gecacht Die Modelle liegen im Bind-Mount `./hf-cache/` (bzw. `./models/` fuer LLM-GGUFs)
und muessen nur einmal geladen werden. und muessen pro Node nur einmal geladen werden.
### Features ### Features
@@ -862,7 +890,7 @@ In der Diagnostic unter Einstellungen → Sprachausgabe:
- **TTS aktiv**: Global An/Aus - **TTS aktiv**: Global An/Aus
- **F5-TTS Stimme**: Default oder gecloned (Maia etc.) - **F5-TTS Stimme**: Default oder gecloned (Maia etc.)
> F5-TTS ist die einzige Engine — wenn die Gamebox offline ist, bleibt ARIA stumm. > F5-TTS ist die einzige Engine — wenn kein f5tts-Node online ist, bleibt ARIA stumm.
> Chat-Antworten kommen weiter an (nur kein Audio). > Chat-Antworten kommen weiter an (nur kein Audio).
### Stimme klonen ### Stimme klonen
@@ -1024,7 +1052,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] Pre-Roll-Buffer einstellbar in App-Settings - [x] Pre-Roll-Buffer einstellbar in App-Settings
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren - [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
- [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel - [x] voice_preload/voice_ready: visueller Status-Indikator beim Stimmen-Wechsel
- [x] Whisper STT auf die Gamebox ausgelagert (CUDA float16, fast Echtzeit) - [x] Whisper STT auf die AI-Box ausgelagert (CUDA float16, fast Echtzeit)
- [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text - [x] **F5-TTS ersetzt XTTS** — bessere Voice-Cloning-Qualitaet, Whisper-auto-transkribierter Referenz-Text
- [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix - [x] Audio-Pause statt Ducking (TRANSIENT statt MAY_DUCK) + release-Timing fix
- [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min) - [x] VAD-Stille-Toleranz einstellbar (1-8s) + adaptive Mikro-Baseline + Max-Aufnahme einstellbar (1-30 min)
@@ -1079,7 +1107,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter - [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom) - [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App) - [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
- [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der Gamebox (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig - [x] **FLUX.1 Bildgenerierung**: eigener `flux-bridge`-Container auf der AI-Box (analog xtts/whisper) mit Hot-Swap zwischen FLUX.1-dev (Quali) und FLUX.1-schnell (Tempo). Default-Modell + Raw-/Switch-Keywords + HuggingFace-Token in Diagnostic-UI verwaltet, automatischer Pipeline-Reload bei Modell-Wechsel. ARIA bekommt `flux_generate`-Tool, Output landet als `/shared/uploads/aria_generated_<ts>.png` und wird via `[FILE: ...]`-Marker als Anhang-Bubble in App + Diagnostic gerendert. Download-Status (mehrere GB) sichtbar als 🎉-Toast wenn fertig
- [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen - [x] **ARIA Live (Diagnostic) + Not-Aus**: read-only Mirror der Claude-Code-Session ersetzt den SSH-Tab. Tool-Calls + Inputs + Outputs (truncated 4 KB) live, farbcodiert. Roter ⛔ Not-Aus-Button schickt `cancel_request` mit `hard:true` → Bridge ruft den proxy-internen `/cancel-all` Side-Channel (Port 3457) → alle Claude-Subprocesses sofort tot. Plus: Idle-Watchdog im Proxy (20 min Inaktivitaet → Subprocess-Kill) + httpx-Timeout-Split im Brain (connect 10s / read 24h) damit lange Pentests durchlaufen
- [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage - [x] **OAuth2-Pipeline ueber RVS-Callback**: Caddy mit Let's Encrypt vor dem RVS, HTTP-Route `/oauth/callback/{service}` broadcastet als `oauth_callback`-WS-Message, aria-bridge forwarded an Brain, Token landet in `/shared/config/oauth_tokens.json` (mode 0600). ARIAs `oauth_register_provider`-Tool legt neue Provider on-demand an (URLs/scopes, nicht Credentials). Diagnostic + App haben beide Provider-Verwaltung inklusive Custom-Provider-Anlage
- [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills - [x] **Skill-Mgmt-Tools fuer ARIA**: `skill_update` (Code/README/pip_packages mit venv-Rebuild) + `skill_delete` — verhindert Skill-Friedhof mit `-v2`/`-fixed`-Suffixen. Plus App-seitiger SkillBrowser (Run + Live-Output + Logs der letzten 20 Runs) in Settings → 🛠️ Skills
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20404 versionCode 20408
versionName "0.2.4.4" versionName "0.2.4.8"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
@@ -60,6 +60,11 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
// weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026). // weil der False-Positive die AudioFocus-Switch-Logik anwirft (Stefan-Bug 06/2026).
// Loesung: in dieser Phase keine Detections an JS weiterleiten. // Loesung: in dieser Phase keine Detections an JS weiterleiten.
private const val STARTUP_SUPPRESSION_MS = 600L private const val STARTUP_SUPPRESSION_MS = 600L
// PCM-Ringpuffer fuer die Wake-Wort-Bestaetigung: letzte 2s roh (16kHz
// mono s16). Bei einer Erkennung wird der Vor-Trigger-Schnipsel an JS
// gereicht und dort von Voxtral verifiziert (gegen Musik-Fehltrigger).
private const val PCM_RING_SAMPLES = 32000 // 2.0s @ 16kHz
private const val PRE_TRIGGER_SAMPLES = 24000 // 1.5s Schnipsel an JS
} }
private val env: OrtEnvironment = OrtEnvironment.getEnvironment() private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
@@ -106,6 +111,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
private var consecutiveAboveThreshold: Int = 0 private var consecutiveAboveThreshold: Int = 0
private var lastDetectionMs: Long = 0L private var lastDetectionMs: Long = 0L
// Roh-PCM-Ringpuffer (letzte ~2s) fuer die Wake-Wort-Bestaetigung. Bei einer
// Erkennung wird der Vor-Trigger-Schnipsel base64-kodiert an JS gereicht und
// dort von Voxtral verifiziert ("war das wirklich 'Computer' oder Musik?").
private val pcmRing = ShortArray(PCM_RING_SAMPLES)
private var pcmRingPos = 0
private var pcmRingFilled = false
private val pcmRingLock = Any()
// Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster // Zeitpunkt des letzten startRecording — fuer STARTUP_SUPPRESSION_MS-Fenster
private var recordingStartedMs: Long = 0L private var recordingStartedMs: Long = 0L
@@ -430,6 +442,36 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
embBuffer.clear() embBuffer.clear()
consecutiveAboveThreshold = 0 consecutiveAboveThreshold = 0
lastDetectionMs = 0L lastDetectionMs = 0L
// PCM-Ring frisch: sonst koennte Alt-Audio aus dem vorigen Arm-Zyklus
// in den Bestaetigungs-Schnipsel bluten.
synchronized(pcmRingLock) { pcmRingPos = 0; pcmRingFilled = false }
}
/** Letzte ~1.5s Roh-PCM aus dem Ringpuffer als Base64 (s16le, 16kHz mono),
* fuer die Voxtral-Wake-Bestaetigung. null wenn noch zu wenig Audio da ist
* oder das Kodieren scheitert (dann macht JS fail-open weiter wie bisher). */
private fun snapshotPreTrigger(): String? {
val out: ByteArray
synchronized(pcmRingLock) {
val available = if (pcmRingFilled) PCM_RING_SAMPLES else pcmRingPos
val n = if (available < PRE_TRIGGER_SAMPLES) available else PRE_TRIGGER_SAMPLES
if (n <= 0) return null
out = ByteArray(n * 2)
var idx = (pcmRingPos - n + PCM_RING_SAMPLES) % PCM_RING_SAMPLES
for (i in 0 until n) {
val s = pcmRing[idx].toInt()
out[i * 2] = (s and 0xFF).toByte()
out[i * 2 + 1] = ((s shr 8) and 0xFF).toByte()
idx += 1
if (idx >= PCM_RING_SAMPLES) idx = 0
}
}
return try {
android.util.Base64.encodeToString(out, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "snapshotPreTrigger base64 fehlgeschlagen: ${e.message}")
null
}
} }
private fun emitDetected() { private fun emitDetected() {
@@ -438,8 +480,10 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)") Log.i(TAG, "Wake-Word emit unterdrueckt (sinceStart=${sinceStart}ms < ${STARTUP_SUPPRESSION_MS}ms — Mikro-Spin-up-Spike)")
return return
} }
val preTriggerB64 = snapshotPreTrigger()
val params = com.facebook.react.bridge.Arguments.createMap().apply { val params = com.facebook.react.bridge.Arguments.createMap().apply {
putString("model", modelName) putString("model", modelName)
if (preTriggerB64 != null) putString("preTriggerPcm", preTriggerB64)
} }
try { try {
reactApplicationContext reactApplicationContext
@@ -466,6 +510,14 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
read += n read += n
} }
if (!running.get()) break if (!running.get()) break
// Chunk in den PCM-Ringpuffer schreiben (fuer Wake-Wort-Bestaetigung).
synchronized(pcmRingLock) {
for (i in 0 until CHUNK_SAMPLES) {
pcmRing[pcmRingPos] = buf[i]
pcmRingPos += 1
if (pcmRingPos >= PCM_RING_SAMPLES) { pcmRingPos = 0; pcmRingFilled = true }
}
}
try { try {
processChunk(buf) processChunk(buf)
} catch (e: Exception) { } catch (e: Exception) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.4.4", "version": "0.2.4.8",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+41 -11
View File
@@ -357,7 +357,7 @@ const ChatScreen: React.FC = () => {
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den // folgende identische Events (z.B. zwei 'thinking' hintereinander) den
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen. // Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
const lastThoughtKeyRef = useRef<string>(''); const lastThoughtKeyRef = useRef<string>('');
// Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit // Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({}); const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false); const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button) // Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
@@ -384,6 +384,14 @@ const ChatScreen: React.FC = () => {
// stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest // stoppen? Kommt als 'converse' in der Chat-Payload; onPlaybackFinished liest
// es. Default true (Konversation). false = Einzelaktion/Skill-Antwort. // es. Default true (Konversation). false = Einzelaktion/Skill-Antwort.
const converseRef = useRef<boolean>(true); const converseRef = useRef<boolean>(true);
// Passiv-Lausch-Fenster (Weiterreden nach ARIAs Antwort): Umgebungsgeraeusch
// (Musik/TV) darf das Fenster NICHT vorzeitig beenden. Bei einem no-speech-
// Endpoint (Silero verwirft Musik) wird — solange die Stille-Toleranz ab
// Fenster-Oeffnung noch laeuft — nochmal gelauscht statt sofort aufs Wake-Word
// zurueckzufallen. Start-Zeit + Re-Listen-Zaehler + Budget hier gemerkt.
const passiveListenStartRef = useRef<number>(0);
const passiveReListenCountRef = useRef<number>(0);
const passiveToleranceRef = useRef<number>(5000);
// Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro). // Barge-in erlaubt? Default false = Halb-Duplex (waehrend TTS kein Mikro).
const bargeInEnabledRef = useRef<boolean>(false); const bargeInEnabledRef = useRef<boolean>(false);
@@ -1583,7 +1591,7 @@ const ChatScreen: React.FC = () => {
} }
} }
// Gamebox-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben. // AI-Box-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
// Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User // Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den // soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
// Banner gucken zu muessen. // Banner gucken zu muessen.
@@ -1690,8 +1698,12 @@ const ChatScreen: React.FC = () => {
// Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive). // Im Hintergrund gibt's kein Multi-Turn → direkt re-armen (skipPassive).
// converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch // converse=false (Skill-/Einzelantwort, z.B. 'was laeuft gerade') → auch
// ohne 30s: vorlesen + direkt zurueck aufs Wake-Word. // ohne 30s: vorlesen + direkt zurueck aufs Wake-Word.
// Im Hintergrund normalerweise direkt re-armen (kein Multi-Turn) — ABER
// wenn Hintergrund-Wake bewusst AN ist, will der User auch im Hintergrund
// ein Gespraech fuehren, also den Konversationsmodus offen halten.
const bg = AppState.currentState !== 'active'; const bg = AppState.currentState !== 'active';
wakeWordService.endConversation(bg || !converseRef.current).catch(() => {}); const bgForcesArmed = bg && !wakeWordService.isBgWakeEnabled();
wakeWordService.endConversation(bgForcesArmed || !converseRef.current).catch(() => {});
}); });
return () => unsubPlayback(); return () => unsubPlayback();
}, []); }, []);
@@ -1791,12 +1803,24 @@ const ChatScreen: React.FC = () => {
!(m.audioRequestId === ev.audioRequestId !(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet')))); && m.text.includes('Spracheingabe wird verarbeitet'))));
} }
// Kein Re-Arm mehr: nach ARIAs Antwort gab es EIN Stille-Fenster (= // Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// Stille-Toleranz). Kam nichts, ist Schluss → zurück aufs Wake-Word. // mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
// Kein 30s-Nachlauschen. (speaker_mismatch/no-speech landen beide hier.) // Fenster-Oeffnung noch laeuft, nochmal lauschen. So killt Umgebungs-
// musik das Weiterreden nicht: die Musik wird verworfen, das Fenster
// bleibt bis zur Toleranz offen, du kannst innerhalb reden. Erst wenn
// die Toleranz wirklich um ist (oder zu viele Runden) → aufs Wake-Word.
if (wakeWordService.getState() === 'listening') { if (wakeWordService.getState() === 'listening') {
console.log('[Chat] Passive-Listen: leeres Endpoint — Ende, zurueck aufs Wake-Word'); const elapsed = Date.now() - passiveListenStartRef.current;
wakeWordService.exitPassiveListening('timeout').catch(() => {}); const budget = passiveToleranceRef.current || 5000;
if (elapsed < budget && passiveReListenCountRef.current < 15) {
passiveReListenCountRef.current += 1;
console.log('[Chat] Passive-Listen: leeres Endpoint (%s) — Umgebung, re-listen (%dms/%dms, #%d)',
ev.reason, elapsed, budget, passiveReListenCountRef.current);
startPassiveStreamingRecording();
} else {
console.log('[Chat] Passive-Listen: Stille-Toleranz aufgebraucht (%dms) — Ende, zurueck aufs Wake-Word', elapsed);
wakeWordService.exitPassiveListening('timeout').catch(() => {});
}
} else { } else {
wakeWordService.endConversation(); wakeWordService.endConversation();
if (!wakeWordService.isActive()) setWakeWordActive(false); if (!wakeWordService.isActive()) setWakeWordActive(false);
@@ -1808,8 +1832,14 @@ const ChatScreen: React.FC = () => {
// geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme // geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
// OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der // OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
// Whisper-Bridge filtert fremde Stimmen weg. // Whisper-Bridge filtert fremde Stimmen weg.
const unsubPassive = wakeWordService.onPassiveListen(() => { const unsubPassive = wakeWordService.onPassiveListen(async () => {
console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme'); console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
// Fenster NEU geoeffnet (nach ARIAs Antwort): Budget-Uhr + Re-Listen-Zaehler
// zuruecksetzen. Re-Listen ruft startPassiveStreamingRecording direkt (nicht
// ueber diesen Callback), also bleibt der Startzeitpunkt erhalten.
passiveListenStartRef.current = Date.now();
passiveReListenCountRef.current = 0;
passiveToleranceRef.current = await loadSttEndpointMs();
startPassiveStreamingRecording(); startPassiveStreamingRecording();
}); });
@@ -2259,7 +2289,7 @@ const ChatScreen: React.FC = () => {
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen // Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert // haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5, // mit der Aufnahmedauer — Whisper braucht auf der AI-Box grob real-time/5,
// plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer. // plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit // Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
// langsame STTs nicht versehentlich aufgeraeumt werden. // langsame STTs nicht versehentlich aufgeraeumt werden.
@@ -3030,7 +3060,7 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */} {/* Service-Status Banner (AI-Box: F5-TTS / Whisper Lade-Status) */}
{(() => { {(() => {
const entries = Object.entries(serviceStatus); const entries = Object.entries(serviceStatus);
if (entries.length === 0 || serviceBannerDismissed) return null; if (entries.length === 0 || serviceBannerDismissed) return null;
+27
View File
@@ -114,6 +114,8 @@ import wakeWordService, {
saveWakeThreshold, saveWakeThreshold,
loadBgWakeEnabled, loadBgWakeEnabled,
saveBgWakeEnabled, saveBgWakeEnabled,
loadWakeConfirmEnabled,
saveWakeConfirmEnabled,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
@@ -216,6 +218,8 @@ const SettingsScreen: React.FC = () => {
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT); const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus. // Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
const [bgWake, setBgWake] = useState<boolean>(false); const [bgWake, setBgWake] = useState<boolean>(false);
// Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger). Default aus.
const [wakeConfirm, setWakeConfirm] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false); const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -341,6 +345,7 @@ const SettingsScreen: React.FC = () => {
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {}); loadBgWakeEnabled().then(setBgWake).catch(() => {});
loadWakeConfirmEnabled().then(setWakeConfirm).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1921,6 +1926,28 @@ const SettingsScreen: React.FC = () => {
/> />
</View> </View>
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Wake-Wort per Voxtral bestätigen</Text>
<Text style={styles.toggleHint}>
Gegen Musik-Fehltrigger: nach {KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}"
prüft Voxtral kurz nach, ob's wirklich das Wake-Wort war (oder nur
Musik/TV) — erst dann Gong + Mikro. Kostet ~0,51 s Extra vor dem
Gong. Empfohlen zusammen mit Hintergrund-Zuhören.
</Text>
</View>
<Switch
value={wakeConfirm}
onValueChange={(val) => {
setWakeConfirm(val);
saveWakeConfirmEnabled(val).catch(() => {});
wakeWordService.setWakeConfirmEnabled(val);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={wakeConfirm ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text> <Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne Nach einer gesprochenen ARIA-Antwort geht das Mikro auf — du kannst ohne
+82 -2
View File
@@ -171,6 +171,72 @@ export async function saveBargeInEnabled(enabled: boolean): Promise<void> {
} catch {} } catch {}
} }
/** One-Shot-Transkription eines PCM-Schnipsels (base64, s16le 16kHz mono) via
* Voxtral — fuer die Wake-Wort-Bestaetigung. Schickt stt_transcribe_blob und
* wartet auf stt_transcribe_result (matching requestId) mit Timeout.
* Rueckgabe: Text (evtl. '') bei Antwort, oder null bei Timeout/Fehler →
* Aufrufer macht dann fail-open (Wake normal durchlassen). */
export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promise<string | null> {
if (!pcmBase64) return null;
const requestId = `wakeverify_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string | null>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(null), timeoutMs);
function finish(val: string | null) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_transcribe_result') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.text === 'string' ? p.text : '');
});
rvs.send('stt_transcribe_blob' as any, { requestId, pcm: pcmBase64, language: 'de' });
} catch {
finish(null);
}
});
}
/** Fragt die Bridge vor dem Aufnahme-Stream, welche STT-Instanz adressiert
* werden soll (Redundanz ueber mehrere STT-Nodes/Apps). Schickt
* stt_lease_request, wartet kurz auf stt_lease (matching requestId).
* Rueckgabe: instanceId (z.B. "voxtral@box-a") oder '' bei Timeout/keine
* Instanz — dann streamt die App wie bisher an ALLE (Broadcast, Single-Node
* unveraendert). Bewusst kurzer Timeout, damit die Aufnahme nie haengt. */
export async function requestSttLease(timeoutMs = 250): Promise<string> {
const requestId = `sttlease_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
return new Promise<string>((resolve) => {
let done = false;
let unsub: (() => void) | null = null;
const timer = setTimeout(() => finish(''), timeoutMs);
function finish(val: string) {
if (done) return;
done = true;
try { unsub && unsub(); } catch {}
clearTimeout(timer);
resolve(val);
}
try {
unsub = rvs.onMessage((msg: any) => {
if (msg?.type !== 'stt_lease') return;
const p = (msg as any).payload || {};
if (String(p.requestId || '') !== requestId) return;
finish(typeof p.instanceId === 'string' ? p.instanceId : '');
});
rvs.send('stt_lease_request' as any, { requestId });
} catch {
finish('');
}
});
}
export async function loadSttEndpointMs(): Promise<number> { export async function loadSttEndpointMs(): Promise<number> {
try { try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY); const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
@@ -350,6 +416,9 @@ class AudioService {
// lich Chunks einer alten Session in eine neue mischen. // lich Chunks einer alten Session in eine neue mischen.
private streamRequestId: string = ''; private streamRequestId: string = '';
private streamAudioRequestId: string = ''; private streamAudioRequestId: string = '';
// Adressierte STT-Instanz fuer diesen Stream (Redundanz-Routing). '' =
// Broadcast an alle STT-Nodes (Single-Node / kein Lease = wie bisher).
private streamTargetInstance: string = '';
// Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert // Latch: ist endpointListeners fuer den aktuellen Session-Cycle schon gefeuert
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim // worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires. // ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
@@ -1093,6 +1162,15 @@ class AudioService {
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`; const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId; this.streamRequestId = requestId;
this.streamAudioRequestId = opts.audioRequestId || ''; this.streamAudioRequestId = opts.audioRequestId || '';
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
// '' (Broadcast) falls keine Instanz/keine Antwort. Nie blockierend genug
// um die Aufnahme spuerbar zu verzoegern.
try {
this.streamTargetInstance = await requestSttLease();
} catch {
this.streamTargetInstance = '';
}
this.streamGotPartial = false; this.streamGotPartial = false;
this.streamEndpointFired = false; this.streamEndpointFired = false;
this.recordingStartTime = Date.now(); this.recordingStartTime = Date.now();
@@ -1113,6 +1191,7 @@ class AudioService {
requestId: sessionId, requestId: sessionId,
pcm: String(e?.pcm || ''), pcm: String(e?.pcm || ''),
seq: Number(e?.seq || 0), seq: Number(e?.seq || 0),
targetInstance: this.streamTargetInstance,
}); });
}); });
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => { this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
@@ -1148,6 +1227,7 @@ class AudioService {
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000, hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000, sampleRate: 16000,
projectId: opts.projectId || '', projectId: opts.projectId || '',
targetInstance: this.streamTargetInstance,
}); });
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer. // No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
@@ -1197,7 +1277,7 @@ class AudioService {
if (!reqId) return; if (!reqId) return;
const audioReqId = this.streamAudioRequestId; const audioReqId = this.streamAudioRequestId;
try { try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason }); rvs.send('stt_stream_end' as any, { requestId: reqId, reason, targetInstance: this.streamTargetInstance });
} catch (e) { } catch (e) {
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e); console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
} }
@@ -1232,7 +1312,7 @@ class AudioService {
if (!reqId) return; if (!reqId) return;
const audioReqId = this.streamAudioRequestId; const audioReqId = this.streamAudioRequestId;
try { try {
rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}` }); rvs.send('stt_stream_end' as any, { requestId: reqId, reason: `cancel:${reason}`, targetInstance: this.streamTargetInstance });
} catch {} } catch {}
this._cleanupStreamLocal(`cancel:${reason}`); this._cleanupStreamLocal(`cancel:${reason}`);
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.) // Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
+92 -6
View File
@@ -87,6 +87,27 @@ export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
} catch {} } catch {}
} }
// Wake-Wort-Bestaetigung: nach einem openWakeWord-Trigger den Vor-Trigger-Audio
// von Voxtral gegenpruefen lassen ("war das wirklich 'Computer' oder Musik?").
// Killt Musik-Fehltrigger (z.B. Pet Shop Boys), kostet ~0.5-1s Extra-Latenz pro
// Wake. Default AUS (opt-in), fail-open. Braucht das native preTriggerPcm im
// Event (neueres APK) — ohne das macht die App normal weiter.
export const WAKE_CONFIRM_STORAGE_KEY = 'aria_wake_confirm_enabled';
export async function loadWakeConfirmEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(WAKE_CONFIRM_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveWakeConfirmEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(WAKE_CONFIRM_STORAGE_KEY, String(enabled));
} catch {}
}
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in /** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut * Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -159,6 +180,9 @@ class WakeWordService {
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und * Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */ * bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
private bgWakeEnabled: boolean = false; private bgWakeEnabled: boolean = false;
/** Wake-Wort per Voxtral bestaetigen (gegen Musik-Fehltrigger)? Default false.
* Wird beim Arm geladen + per setWakeConfirmEnabled aus den Einstellungen. */
private wakeConfirmEnabled: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS * WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite * resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -241,15 +265,19 @@ class WakeWordService {
try { try {
const threshold = await loadWakeThreshold(); const threshold = await loadWakeThreshold();
this.bgWakeEnabled = await loadBgWakeEnabled(); this.bgWakeEnabled = await loadBgWakeEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled); this.wakeConfirmEnabled = await loadWakeConfirmEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s, confirm=%s',
threshold, this.bgWakeEnabled, this.wakeConfirmEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS); await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal // Subscribe nur einmal
if (!this.eventSub) { if (!this.eventSub) {
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord); const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
this.eventSub = emitter.addListener('WakeWordDetected', () => { this.eventSub = emitter.addListener('WakeWordDetected', (payload: any) => {
console.log('[WakeWord] Native Detection-Event empfangen'); console.log('[WakeWord] Native Detection-Event empfangen');
this.onWakeDetected().catch(err => // payload.preTriggerPcm (base64 s16le 16kHz) fuer die Bestaetigung —
console.warn('[WakeWord] onWakeDetected crashed:', err)); // nur in neueren APKs vorhanden; ohne = fail-open (kein Verify).
this.onWakeDetected(payload && payload.preTriggerPcm ? String(payload.preTriggerPcm) : null)
.catch(err => console.warn('[WakeWord] onWakeDetected crashed:', err));
}); });
} }
this.nativeReady = true; this.nativeReady = true;
@@ -348,6 +376,18 @@ class WakeWordService {
console.log('[WakeWord] Hintergrund-Wake = %s', enabled); console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
} }
/** Wake-Wort-Bestaetigung (Voxtral) ein/aus (aus den Einstellungen). */
setWakeConfirmEnabled(enabled: boolean): void {
this.wakeConfirmEnabled = enabled;
console.log('[WakeWord] Wake-Bestaetigung = %s', enabled);
}
/** Ist Hintergrund-Wake an? Steuert u.a. ob der Konversationsmodus auch im
* Hintergrund weiterlaeuft (sonst: im Hintergrund direkt zurueck aufs Wake-Word). */
isBgWakeEnabled(): boolean {
return this.bgWakeEnabled;
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown /** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
* als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume. * als Schutz gegen den AudioFocus-/AudioTrack-Spike direkt nach dem Resume.
* 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem * 1s statt 3s — 3s hat sich "traege" angefuehlt (Trigger direkt nach dem
@@ -358,8 +398,10 @@ class WakeWordService {
console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv'); console.log('[WakeWord] App im Vordergrund — Cooldown 1s aktiv');
} }
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten.
private async onWakeDetected(): Promise<void> { * preTriggerPcm: base64 s16le 16kHz Vor-Trigger-Audio fuer die Bestaetigung
* (null = nicht verfuegbar → keine Bestaetigung, normal weiter). */
private async onWakeDetected(preTriggerPcm: string | null = null): Promise<void> {
if (this.inBackground && !this.bgWakeEnabled) { if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)'); console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{}); import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
@@ -405,6 +447,22 @@ class WakeWordService {
// Kein erneutes setState — wir bleiben in 'conversing'. // Kein erneutes setState — wir bleiben in 'conversing'.
return; return;
} }
// Wake-Wort-Bestaetigung (gegen Musik-Fehltrigger): den Vor-Trigger-Schnipsel
// von Voxtral gegenpruefen. Bestaetigt → weiter (Gong + Mikro). Verworfen
// (Musik/Rauschen, kein "Computer") → kein Dialog, kein Gong, re-arm. Fail-
// open: ohne PCM / bei Timeout/Fehler laeuft es normal durch.
if (this.wakeConfirmEnabled && preTriggerPcm) {
const confirmed = await this.confirmWake(preTriggerPcm);
if (!confirmed) {
this.detectionInProgress = false;
if (this.nativeReady && OpenWakeWord) {
try { await OpenWakeWord.start(); } catch (e) {
console.warn('[WakeWord] re-arm nach verworfener Bestaetigung failed:', e);
}
}
return;
}
}
this.setState('conversing'); this.setState('conversing');
// Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt // Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
// Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang // Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
@@ -418,6 +476,34 @@ class WakeWordService {
}); });
} }
/** Voxtral-Bestaetigung des Vor-Trigger-Schnipsels. true = Wake-Wort erkannt
* (oder fail-open bei Timeout/Fehler), false = Musik/Rauschen → verwerfen. */
private async confirmWake(pcm: string): Promise<boolean> {
try {
const audio = await import('./audio');
const text = await audio.transcribeBlob(pcm);
if (text === null) {
console.log('[WakeWord] Bestaetigung: Timeout/Fehler → fail-open (durchlassen)');
return true;
}
const norm = text.toLowerCase();
// Distinktive Wake-Wort-Bestandteile (>= 4 Zeichen; 'hey' o.ae. rausfiltern,
// taucht sonst in Song-Texten auf und wuerde faelschlich bestaetigen).
const kwWords = this.keyword.toLowerCase().replace(/_/g, ' ')
.split(/\s+/).filter(w => w.length >= 4);
if (kwWords.length === 0) return true; // zu kurzes Keyword → nicht pruefbar
const ok = kwWords.some(w => norm.includes(w));
console.log('[WakeWord] Bestaetigung: text=%o kw=%o → %s',
text, kwWords, ok ? 'BESTAETIGT' : 'verworfen (Musik-FP?)');
import('./logger').then(m => m.reportAppDebug('wake.confirm',
`text="${text.slice(0, 40)}" kw=${kwWords.join('|')}${ok ? 'ok' : 'reject'}`)).catch(() => {});
return ok;
} catch (e) {
console.warn('[WakeWord] confirmWake err → fail-open:', e);
return true;
}
}
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann /** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im * "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
* Native-Modul verhindert dass ARIAs eigene Stimme triggert. * Native-Modul verhindert dass ARIAs eigene Stimme triggert.
+92 -13
View File
@@ -132,6 +132,34 @@ WEB_SEARCH_TOOL = {
# Meta-Tool: ARIA kann selbst neue Skills bauen # Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [ META_TOOLS = [
{
"type": "function",
"function": {
"name": "ear_control",
"description": (
"Schaltet dein Ohr (den Wake-Word-Listener) an oder aus. Nutze das, "
"wenn Stefan will dass du aufhoerst zuzuhoeren — EGAL wie er es "
"formuliert: 'leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute "
"Nacht', 'mach mal Pause vom Zuhoeren', 'ich geh ins Bett, du kannst "
"aus' → action='off'. Wenn er dich wieder aktivieren will ('Ohr an', "
"'wach auf', 'hoer wieder zu') → action='on'. Reiner Steuerbefehl: "
"die App stoppt/startet den Listener, du bestaetigst nur kurz (wird "
"nicht vorgelesen). Nach 'off' geht Wieder-An per 'Ohr an' (Text/"
"Aufnahme-Knopf) oder App-Button."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["off", "on"],
"description": "off = Ohr aus (nicht mehr zuhoeren), on = Ohr wieder an",
},
},
"required": ["action"],
},
},
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -831,7 +859,7 @@ META_TOOLS = [
"function": { "function": {
"name": "flux_generate", "name": "flux_generate",
"description": ( "description": (
"Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. " "Generiere ein Bild aus einem Text-Prompt via FLUX auf der AI-Box-GPU. "
"Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, " "Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, "
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan " "Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
"kurz an, dann ist er nicht ueberrascht.\n\n" "kurz an, dann ist er nicht ueberrascht.\n\n"
@@ -1156,9 +1184,11 @@ META_TOOLS = [
"description": ( "description": (
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, " "Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / " "z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im " "dial.launch / wol / http). Nutze das ZUERST, wenn es um ein Geraet/einen "
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst " "Host in einem Netz geht, auf dem Du nicht direkt sitzt (Zuhause, Buero, "
"Du welche Netze erreichbar sind." "jede private IP wie 192.168.x) — z.B. Drucker-Fuellstand, NAS, Smart-TV. "
"Rufe es IMMER auf, BEVOR Du sagst ein Geraet sei nicht erreichbar — ein "
"Satellit im Zielnetz ist der Weg hinein."
), ),
"parameters": {"type": "object", "properties": {}}, "parameters": {"type": "object", "properties": {}},
}, },
@@ -1169,8 +1199,11 @@ META_TOOLS = [
"name": "satellite_devices", "name": "satellite_devices",
"description": ( "description": (
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, " "Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches " "Smart-TVs, Drucker, Switches, Router, APs, NAS, Hosts ...). Nutze es um "
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst." "herauszufinden welches Geraet gemeint ist, BEVOR Du satellite_command "
"aufrufst. SNMP-faehige Geraete tragen ein 'snmp'-Feld (Name, Beschreibung, "
"Standort, Uptime) und einen praeziseren 'type' (switch/router/nas ...) — "
"gute Quelle um Netz-Hardware zu erklaeren."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@@ -1190,8 +1223,28 @@ META_TOOLS = [
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', " "Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere " "device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, " "Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. " "'snmp.printer' (params={'ip':'<drucker-ip>'}) — BEVORZUGT fuer Drucker-"
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)." "Tinte/Toner: liest die Fuellstaende zuverlaessig als Prozent aus der "
"Printer-MIB (kein HTML-Scrapen). 'snmp.ports' (params={'ip':'...'}) — "
"Switch/Router-Interfaces: aktive/freie Ports + Linkspeed (beantwortet "
"'sind noch Ports frei'). 'snmp.info' (params={'ip':'...'}) — Modell, "
"Seriennummer, INSTALLIERTE Firmware/Software-Version (ob ein Update "
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
"hinterlegten FritzBox-Login). Fuer Geraete mit hinterlegten Zugangsdaten "
"(community/v3/Login) nutzt der Satellit diese automatisch — Du musst keine "
"Passwoerter mitgeben. "
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND "
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
"Liste) — dann kommen nur Zeilen zurueck, die einen der Begriffe enthalten "
"(z.B. contains=['ink','toner','cyan','magenta','yellow','black','%'] fuer "
"Tinte). Zusaetzlich moeglich: params['offset'] und params['max_chars'] "
"(Default 20000). Die Antwort meldet total_chars + truncated, damit Du "
"siehst, ob noch mehr da ist. Geht nur, wenn der Satellit Steuerung "
"erlaubt (siehe satellite_list)."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@@ -1455,7 +1508,12 @@ _WAKE_OFF_RE = re.compile(
rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b" rf"|\b(?:beende|beend\w*|deaktivier\w*|stopp?e?)\b[^.!?]{{0,20}}\b{_WAKE_NOUN}\b"
rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*" rf"|h[oö]r\s+auf\s+(?:zu\s*)?(?:zu)?(?:h[oö]r|lausch)\w*"
rf"|h[oö]r\s+nicht\s+mehr\s+zu" rf"|h[oö]r\s+nicht\s+mehr\s+zu"
rf"|geh\s+schlafen", # Schlaf-Idiome NUR als klare Imperative an ARIA ('geh/leg dich schlafen',
# 'leg dich aufs Ohr/hin'). Mehrdeutige Gruesse ('gute Nacht', 'schlaf gut')
# bewusst NICHT — das sind Verabschiedungen, kein Ohr-Aus-Befehl; die faengt
# ARIA per ear_control aus dem Kontext ab, wenn's wirklich gemeint ist.
rf"|(?:geh|leg\s+dich)\s+schlafen"
rf"|leg\s+dich\s+(?:aufs?\s+ohr|hin)",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -1685,9 +1743,12 @@ class Agent:
return False return False
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only, # Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill. # SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill +
# Bewusst klein (Speed + Sicherheit); alles andere → Claude. # die Satelliten-Tools (Augen/Haende in fremden Netzen — auch das lokale Tier
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"} # muss ein Geraet im Heim-/Buero-Netz erreichen koennen, statt zu fabulieren).
# Sonst bewusst klein (Speed + Sicherheit); alles andere → Claude.
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer",
"satellite_list", "satellite_devices", "satellite_command"}
def _build_local_tools(self) -> list: def _build_local_tools(self) -> list:
tools = [WEB_SEARCH_TOOL] tools = [WEB_SEARCH_TOOL]
@@ -2132,6 +2193,7 @@ class Agent:
# Konversation bleiben gesprochen. # Konversation bleiben gesprochen.
self._claude_turn_speak = True self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try: try:
for iteration in range(self.MAX_TOOL_ITERATIONS): for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools, result = self.proxy.chat_full(messages, tools=tools,
@@ -2245,8 +2307,16 @@ class Agent:
converse = False converse = False
elif _wants_continue: elif _wants_continue:
converse = True converse = True
# ear_control-Tool aufgerufen → Ohr schalten. answered_by=wake-off/wake-on
# nutzt die bestehende Plumbing (main.py → wake_off/wake_on → Bridge → App).
# Reiner Steuerbefehl: still + kein Weiterlauschen.
answered_by = "claude"
if self._ear_control == "off":
answered_by, speak, converse = "wake-off", False, False
elif self._ear_control == "on":
answered_by, speak, converse = "wake-on", False, False
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert). # awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude", speak, converse, awaiting_reply) return (final_reply, answered_by, speak, converse, awaiting_reply)
# ── Tool-Dispatcher ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
@@ -2841,6 +2911,15 @@ class Agent:
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als " f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline." f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline."
) )
if name == "ear_control":
action = (arguments.get("action") or "").strip().lower()
if action not in ("off", "on"):
return "FEHLER: action muss 'off' oder 'on' sein."
self._ear_control = action
logger.info("[ear_control] action=%s — App schaltet den Listener", action)
return ("Ohr wird ausgeschaltet — Wieder-An per 'Ohr an' (Text/"
"Aufnahme-Knopf) oder App-Button." if action == "off"
else "Ohr wird wieder eingeschaltet.")
if name == "memory_search": if name == "memory_search":
query = (arguments.get("query") or "").strip() query = (arguments.get("query") or "").strip()
if not query: if not query:
+1 -1
View File
@@ -1,7 +1,7 @@
""" """
Local-LLM-Client (Plan B) Brain-Seite. Local-LLM-Client (Plan B) Brain-Seite.
Ruft das schnelle lokale LLM (Qwen3 auf der Gamebox) ueber die Bridge: Ruft das schnelle lokale LLM (Qwen3 auf der AI-Box) ueber die Bridge:
Brain HTTP /internal/local-llm Bridge RVS llm-adapter llama.cpp 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; Analog zum Claude-`proxy_client`, nur ueber die Bridge (die ist der RVS-Client;
+7
View File
@@ -199,6 +199,13 @@ def build_voice_flow_section() -> str:
"- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche " "- Ohne Marker = normales Gespraech: du wirst vorgelesen und ich lausche "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').", "danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.", "- Nie widerspruechlich: `[[ENDE]]` schlaegt `[[WEITER]]`.",
"",
"**Ohr aus/an:** Wenn Stefan will dass du aufhoerst zuzuhoeren — egal wie "
"formuliert ('leg dich schlafen', 'geh schlafen', 'Ohr aus', 'gute Nacht', "
"'mach Pause vom Zuhoeren') — ruf das Tool `ear_control(action='off')`. "
"Wieder aktivieren ('Ohr an', 'wach auf', 'hoer wieder zu') → "
"`ear_control(action='on')`. Die App stoppt/startet dann den Listener; du "
"bestaetigst nur kurz.",
]) ])
+20 -1
View File
@@ -475,13 +475,32 @@ SEED_RULES: List[dict] = [
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack " "zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
"NICHT steht.\n" "NICHT steht.\n"
"\n" "\n"
"Wenn Stefan etwas 'im Buero' / 'im Netz X' / 'auf dem <Geraet> dort' will:\n" "WICHTIG — nicht nur bei 'im Buero'/'im Netz X': IMMER wenn eine Anfrage ein "
"GERAET oder einen HOST betrifft, das/der in einem Netz lebt, auf dem Du NICHT "
"direkt sitzt (Stefans Zuhause, Buero, Werkstatt, irgendein LAN mit privater "
"IP wie 192.168.x/10.x) — z.B. Drucker-Fuellstand, NAS, Smart-TV, ein Host per "
"IP — dann pruefe ZUERST `satellite_list`, ob ein Satellit dieses Netz abdeckt. "
"Sage NIEMALS 'da komm ich nicht ran' / 'bin nicht im Netz', BEVOR Du "
"`satellite_list` aufgerufen hast — ein Satellit im Zielnetz ist genau der Weg "
"hinein. Nur wenn wirklich keiner online ist, ist 'erreiche ich nicht' korrekt.\n"
"\n"
"Ablauf:\n"
" 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n" " 1. `satellite_list` — welche Satelliten/Netze sind online + was koennen sie.\n"
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort " " 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das " "(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n" "gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
" 3. `satellite_command(...)` — Aktion ausfuehren.\n" " 3. `satellite_command(...)` — Aktion ausfuehren.\n"
"\n" "\n"
"Beispiel 'Patronenstand vom Drucker zuhause': satellite_list -> Satellit im "
"Heimnetz online? -> BEVORZUGT satellite_command(satellite='<Heim>', "
"action='snmp.printer', params={'ip':'<drucker-ip>'}) -> liefert supplies mit "
"name + percent je Patrone (BK/C/M/Y), zuverlaessig aus der Printer-MIB. "
"NUR falls SNMP nichts liefert, als Fallback die HTML-Statusseite: "
"action='http.get', params={'url':'http://<drucker-ip>/general/status.html', "
"'contains':['ink','toner','cyan','magenta','yellow','black','%']} — der "
"contains-Filter zieht die relevanten Zeilen (sonst wird der Body bei "
"max_chars, Default 20000, abgeschnitten). NICHT aus dem Gedaechtnis raten.\n"
"\n"
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n" "Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
" satellite_command(satellite='Buero', device='Fire TV', " " satellite_command(satellite='Buero', device='Fire TV', "
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n" "action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
+177 -35
View File
@@ -2,7 +2,7 @@
ARIA Voice Bridge Hauptmodul. ARIA Voice Bridge Hauptmodul.
Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft Verbindet die Android App (via RVS) mit ARIA-Core. Spracheingabe laeuft
ueber die whisper-bridge (Gamebox, faster-whisper auf CUDA), Sprachausgabe ueber die whisper-bridge (AI-Box, faster-whisper auf CUDA), Sprachausgabe
ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming). ueber die f5tts-bridge (Voice Cloning, satzweises PCM-Streaming).
Nachrichtenfluss: Nachrichtenfluss:
@@ -479,7 +479,7 @@ class STTEngine:
Erkannter Text oder leerer String. Erkannter Text oder leerer String.
""" """
if self.model is None: if self.model is None:
# Lazy-Load: normalerweise laeuft STT remote auf der Gamebox. # Lazy-Load: normalerweise laeuft STT remote auf der AI-Box.
# Erst wenn das Fallback hier zuschlaegt, laden wir lokal. # Erst wenn das Fallback hier zuschlaegt, laden wir lokal.
logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...") logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...")
try: try:
@@ -654,7 +654,7 @@ class ARIABridge:
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict() self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
self._SEEN_CLIENT_MSG_LIMIT = 200 self._SEEN_CLIENT_MSG_LIMIT = 200
# Komponenten (TTS: F5-TTS remote auf der Gamebox, lokales TTS wurde entfernt) # Komponenten (TTS: F5-TTS remote auf der AI-Box, lokales TTS wurde entfernt)
self.tts_enabled = True self.tts_enabled = True
self.xtts_voice = "" self.xtts_voice = ""
self._f5tts_config: dict = {} self._f5tts_config: dict = {}
@@ -686,7 +686,7 @@ class ARIABridge:
except (TypeError, ValueError): except (TypeError, ValueError):
self._persistent_xtts_speed = None self._persistent_xtts_speed = None
# F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet, # F5-TTS-Felder aufsammeln (werden spaeter via RVS rebroadcastet,
# damit die f5tts-bridge auf der Gamebox die Settings auch nach # damit die f5tts-bridge auf der AI-Box die Settings auch nach
# Restart wiederbekommt — sonst stuende sie auf Hard-Defaults) # Restart wiederbekommt — sonst stuende sie auf Hard-Defaults)
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile", for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
"f5ttsCfgStrength", "f5ttsNfeStep"): "f5ttsCfgStrength", "f5ttsNfeStep"):
@@ -746,7 +746,7 @@ class ARIABridge:
# Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param, # Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param,
# App-Setting aria_tts_speed, 1.0 = normal). # App-Setting aria_tts_speed, 1.0 = normal).
self._next_speed_override: Optional[float] = None self._next_speed_override: Optional[float] = None
# STT-Requests die aktuell auf Antwort von der whisper-bridge (Gamebox) warten. # STT-Requests die aktuell auf Antwort von der whisper-bridge (AI-Box) warten.
# requestId → Future mit dem Text (oder None bei Fehler). # requestId → Future mit dem Text (oder None bei Fehler).
self._pending_stt: dict[str, asyncio.Future] = {} self._pending_stt: dict[str, asyncio.Future] = {}
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt. # whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
@@ -766,7 +766,12 @@ class ARIABridge:
# requestId → Future (sat_devices / sat_result), analog _pending_flux. # requestId → Future (sat_devices / sat_result), analog _pending_flux.
self._satellites: dict[str, dict] = {} self._satellites: dict[str, dict] = {}
self._pending_sat: dict[str, asyncio.Future] = {} self._pending_sat: dict[str, asyncio.Future] = {}
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten. # Compute-Fleet: GPU-Worker (voxtral/whisper/f5tts/llm) melden sich per
# worker_hello, halten sich per worker_ping frisch. instanceId →
# {service, node, gpus, model, busy, last_seen}. Genutzt fuer die
# Diagnostic-Flotten-Anzeige und (Stage 3) targetInstance-Routing.
self._workers: dict[str, dict] = {}
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (AI-Box) warten.
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler). # requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
self._pending_flux: dict[str, asyncio.Future] = {} self._pending_flux: dict[str, asyncio.Future] = {}
# flux-bridge service_status: True wenn ready. Render-Timeouts werden # flux-bridge service_status: True wenn ready. Render-Timeouts werden
@@ -774,7 +779,7 @@ class ARIABridge:
self._remote_flux_ready: bool = False self._remote_flux_ready: bool = False
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload. # Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
# Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen # Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen
# llm_request via RVS an den llm-adapter (Gamebox) und warten auf # llm_request via RVS an den llm-adapter (AI-Box) und warten auf
# llm_response. # llm_response.
self._pending_llm: dict[str, asyncio.Future] = {} self._pending_llm: dict[str, asyncio.Future] = {}
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation # User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
@@ -810,7 +815,7 @@ class ARIABridge:
logger.info("ARIA Voice Bridge startet...") logger.info("ARIA Voice Bridge startet...")
logger.info("=" * 50) logger.info("=" * 50)
# STT wird standardmaessig von der whisper-bridge (Gamebox) erledigt. # STT wird standardmaessig von der whisper-bridge (AI-Box) erledigt.
# Lokales Whisper ist nur Fallback und wird lazy geladen wenn remote nicht # Lokales Whisper ist nur Fallback und wird lazy geladen wenn remote nicht
# antwortet. Das spart RAM auf der VM und Startup-Zeit. # antwortet. Das spart RAM auf der VM und Startup-Zeit.
@@ -1679,20 +1684,27 @@ class ARIABridge:
if len(self._xtts_request_to_message) > 100: if len(self._xtts_request_to_message) > 100:
oldest = next(iter(self._xtts_request_to_message)) oldest = next(iter(self._xtts_request_to_message))
self._xtts_request_to_message.pop(oldest, None) self._xtts_request_to_message.pop(oldest, None)
# Redundanz: freie f5tts-Instanz waehlen und gezielt adressieren.
# None (keine Instanz bekannt / alle offline) → kein targetInstance,
# Broadcast wie bisher (Single-Node laeuft unveraendert).
tts_target = self._pick_worker("f5tts")
tts_payload = {
"text": tts_text,
"voice": xtts_voice,
"speed": xtts_speed,
"language": "de",
"requestId": xtts_request_id,
"messageId": message_id,
}
if tts_target:
tts_payload["targetInstance"] = tts_target
await self._send_to_rvs({ await self._send_to_rvs({
"type": "xtts_request", "type": "xtts_request",
"payload": { "payload": tts_payload,
"text": tts_text,
"voice": xtts_voice,
"speed": xtts_speed,
"language": "de",
"requestId": xtts_request_id,
"messageId": message_id,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'", logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx, target=%s): '%s'",
xtts_voice or "default", xtts_speed, tts_text[:60]) xtts_voice or "default", xtts_speed, tts_target or "(broadcast)", tts_text[:60])
except Exception as e: except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e) logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
@@ -1747,7 +1759,7 @@ class ARIABridge:
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect. """Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge Damit bekommen frisch verbundene Bridges (insbesondere die f5tts-bridge
auf der Gamebox nach Container-Restart) die zuletzt in Diagnostic auf der AI-Box nach Container-Restart) die zuletzt in Diagnostic
gewaehlten Settings ohne dass der User in Diagnostic was klicken muss. gewaehlten Settings ohne dass der User in Diagnostic was klicken muss.
""" """
try: try:
@@ -2190,7 +2202,7 @@ class ARIABridge:
await self._broadcast_current_mode() await self._broadcast_current_mode()
# Persistierte Voice-Config broadcasten — die f5tts-bridge auf # Persistierte Voice-Config broadcasten — die f5tts-bridge auf
# der Gamebox bekommt damit nach Restart die zuletzt in # der AI-Box bekommt damit nach Restart die zuletzt in
# Diagnostic gewaehlten Settings wieder (sonst stuende sie auf # Diagnostic gewaehlten Settings wieder (sonst stuende sie auf
# ihren Hard-Defaults). # ihren Hard-Defaults).
asyncio.create_task(self._broadcast_persisted_config()) asyncio.create_task(self._broadcast_persisted_config())
@@ -2551,7 +2563,7 @@ class ARIABridge:
elif msg_type == "config": elif msg_type == "config":
# Konfiguration von App/Diagnostic empfangen + persistent speichern. # Konfiguration von App/Diagnostic empfangen + persistent speichern.
# Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden # Felder die nicht direkt zur aria-bridge gehoeren (f5tts*) werden
# nur persistiert; die f5tts-bridge auf der Gamebox empfaengt den # nur persistiert; die f5tts-bridge auf der AI-Box empfaengt den
# gleichen RVS-Broadcast und reagiert selber. # gleichen RVS-Broadcast und reagiert selber.
changed = False changed = False
if "ttsEnabled" in payload: if "ttsEnabled" in payload:
@@ -2575,7 +2587,7 @@ class ARIABridge:
new_model = payload["whisperModel"] new_model = payload["whisperModel"]
allowed = {"tiny", "base", "small", "medium", "large-v3"} allowed = {"tiny", "base", "small", "medium", "large-v3"}
if new_model in allowed and new_model != self.stt_engine.model_size: if new_model in allowed and new_model != self.stt_engine.model_size:
logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt Gamebox)", logger.info("[rvs] Whisper-Modell → %s (nur Config; Modell laedt AI-Box)",
new_model) new_model)
self.stt_engine.model_size = new_model self.stt_engine.model_size = new_model
self.stt_engine.model = None self.stt_engine.model = None
@@ -2794,8 +2806,18 @@ class ARIABridge:
"message": payload.get("message", ""), "message": payload.get("message", ""),
"stack": payload.get("stack", ""), "stack": payload.get("stack", ""),
} }
with (log_dir / "app.log").open("a", encoding="utf-8") as f: log_path = log_dir / "app.log"
with log_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n") f.write(json.dumps(line, ensure_ascii=False) + "\n")
# Rotation: app.log waechst sonst unbegrenzt (jede App-Log-Zeile
# haengt an). Bei >5 MB die letzten 2000 Zeilen behalten.
try:
if log_path.stat().st_size > 5 * 1024 * 1024:
tail = log_path.read_text(encoding="utf-8",
errors="ignore").splitlines()[-2000:]
log_path.write_text("\n".join(tail) + "\n", encoding="utf-8")
except Exception:
pass
logger.info("[app-log] %s %s: %s", logger.info("[app-log] %s %s: %s",
line["level"], line["scope"], line["message"][:120]) line["level"], line["scope"], line["message"][:120])
except Exception as exc: except Exception as exc:
@@ -3436,7 +3458,7 @@ class ARIABridge:
return return
elif msg_type == "llm_response": elif msg_type == "llm_response":
# Antwort des llm-adapter (Gamebox) auf unseren llm_request. # Antwort des llm-adapter (AI-Box) auf unseren llm_request.
request_id = payload.get("requestId", "") request_id = payload.get("requestId", "")
future = self._pending_llm.get(request_id) future = self._pending_llm.get(request_id)
if future is None or future.done(): if future is None or future.done():
@@ -3445,7 +3467,7 @@ class ARIABridge:
return return
elif msg_type == "service_status": elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status. # AI-Box-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper # Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download # im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken. # kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
@@ -3528,6 +3550,62 @@ class ARIABridge:
self._satellites[sid]["caps"], self._satellites[sid]["control"]) self._satellites[sid]["caps"], self._satellites[sid]["control"])
return return
elif msg_type == "worker_hello":
iid = (payload.get("instanceId") or "").strip()
if iid:
prev = self._workers.get(iid, {})
_models = payload.get("models")
self._workers[iid] = {
"instanceId": iid,
"service": payload.get("service") or "",
"node": payload.get("node") or "",
"gpus": payload.get("gpus") or "",
"model": payload.get("model") or "",
# models: welche Modelle die Box fahren kann (llm/llama-swap).
# Fallback auf [model] fuer alte Adapter ohne models-Feld.
"models": [m for m in _models if m] if isinstance(_models, list)
else ([payload.get("model")] if payload.get("model") else []),
"busy": bool(prev.get("busy", False)),
"last_seen": time.time(),
}
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
iid, self._workers[iid]["service"], self._workers[iid]["node"],
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
return
elif msg_type == "stt_lease_request":
# Die App fragt vor dem Aufnahme-Stream, welche STT-Instanz sie
# adressieren soll (Redundanz ueber mehrere Apps/Nodes). Wir waehlen
# eine freie STT-Instanz und antworten per stt_lease. Ist keine
# Instanz bekannt (instanceId leer), streamt die App wie bisher an
# ALLE (Broadcast) — Single-Node bleibt unveraendert.
req_id = (payload.get("requestId") or "").strip()
iid = self._pick_stt_worker() or ""
await self._send_to_rvs({
"type": "stt_lease",
"payload": {"requestId": req_id, "instanceId": iid},
"timestamp": int(time.time() * 1000),
})
logger.info("[stt-lease] req=%s%s", req_id[:8] if req_id else "?",
iid or "(broadcast)")
return
elif msg_type == "worker_ping":
iid = (payload.get("instanceId") or "").strip()
if iid:
w = self._workers.get(iid)
if w is None:
# Ping ohne vorheriges hello (Bridge-Neustart) → Minimal-Eintrag,
# service aus der instanceId ableiten (Form: "service@node").
svc = iid.split("@", 1)[0]
w = self._workers[iid] = {
"instanceId": iid, "service": svc, "node": "", "gpus": "",
"model": "", "busy": False, "last_seen": 0.0,
}
w["busy"] = bool(payload.get("busy", False))
w["last_seen"] = time.time()
return
elif msg_type in ("sat_devices", "sat_result"): elif msg_type in ("sat_devices", "sat_result"):
req_id = payload.get("requestId", "") req_id = payload.get("requestId", "")
future = self._pending_sat.get(req_id) future = self._pending_sat.get(req_id)
@@ -3553,11 +3631,11 @@ class ARIABridge:
else: else:
logger.debug("[rvs] Unbekannter Typ: %s", msg_type) logger.debug("[rvs] Unbekannter Typ: %s", msg_type)
# STT-Orchestrierung: zuerst Remote (Gamebox), Fallback lokal. # STT-Orchestrierung: zuerst Remote (AI-Box), Fallback lokal.
# Zwei Timeouts: # Zwei Timeouts:
# ready=True → 45s reicht selbst fuer lange Audios # ready=True → 45s reicht selbst fuer lange Audios
# ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird # ready=False → 300s, weil das Modell evtl. noch heruntergeladen wird
# (large-v3 ~3GB, kann auf der Gamebox 1-2 Min dauern). # (large-v3 ~3GB, kann auf der AI-Box 1-2 Min dauern).
_STT_REMOTE_TIMEOUT_READY_S = 45.0 _STT_REMOTE_TIMEOUT_READY_S = 45.0
_STT_REMOTE_TIMEOUT_LOADING_S = 300.0 _STT_REMOTE_TIMEOUT_LOADING_S = 300.0
@@ -3910,15 +3988,15 @@ class ARIABridge:
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download) _FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ── # ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
# Qwen3 auf der Gamebox antwortet auf kurze Turns in <1 s. Grosszuegiger # Qwen3 auf der AI-Box antwortet auf kurze Turns in <1 s. Grosszuegiger
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (Gamebox@home) # Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (AI-Box@home)
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude. # ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
_LLM_TIMEOUT_S = 30.0 _LLM_TIMEOUT_S = 30.0
async def _local_llm(self, messages: list, max_tokens: int = 512, async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None, temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict: model=None) -> dict:
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf """Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck. llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}.""" Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
if self.ws_rvs is None: if self.ws_rvs is None:
@@ -3943,8 +4021,15 @@ class ARIABridge:
req_payload["tools"] = tools req_payload["tools"] = tools
if model: if model:
req_payload["model"] = model req_payload["model"] = model
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s)", # Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-") # das gewaehlte Modell fahren kann; None → Broadcast wie bisher.
# Mehrere Boxen mit demselben Modell → Round-Robin (pro Projekt verteilt).
llm_target = self._pick_worker("llm", model=model or None)
if llm_target:
req_payload["targetInstance"] = llm_target
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s, target=%s)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0,
model or "-", llm_target or "(broadcast)")
ok = await self._send_to_rvs({ ok = await self._send_to_rvs({
"type": "llm_request", "type": "llm_request",
"payload": req_payload, "payload": req_payload,
@@ -3955,7 +4040,7 @@ class ARIABridge:
try: try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S) result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — Gamebox nicht erreichbar?"} return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — AI-Box nicht erreichbar?"}
if not isinstance(result, dict) or not result.get("ok"): if not isinstance(result, dict) or not result.get("ok"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat" err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "llm-adapter Fehler"} return {"ok": False, "error": err or "llm-adapter Fehler"}
@@ -4435,6 +4520,9 @@ class ARIABridge:
elif method == "POST" and path == "/internal/satellite-list": elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities. # Brain fragt: welche Satelliten/Netze sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()}) await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
elif method == "POST" and path == "/internal/satellite": elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten. # Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?} # body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4455,7 +4543,7 @@ class ARIABridge:
await _send_response(writer, 200, result) await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/flux-generate": elif method == "POST" and path == "/internal/flux-generate":
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den # Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
# Render-Request via RVS an die flux-bridge (Gamebox), # Render-Request via RVS an die flux-bridge (AI-Box),
# warten synchron auf die PNG-Antwort, speichern sie nach # warten synchron auf die PNG-Antwort, speichern sie nach
# /shared/uploads/ und melden Pfad + Render-Stats zurueck. # /shared/uploads/ und melden Pfad + Render-Stats zurueck.
# Brain referenziert das Bild dann mit [FILE:]-Marker in # Brain referenziert das Bild dann mit [FILE:]-Marker in
@@ -4492,7 +4580,7 @@ class ARIABridge:
await _send_response(writer, status, result) await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/local-llm": elif method == "POST" and path == "/internal/local-llm":
# Vom Brain (Router / Testchat) gefeuert. Wir relayen den # Vom Brain (Router / Testchat) gefeuert. Wir relayen den
# Chat-Request via RVS an den llm-adapter (Gamebox Qwen3), # Chat-Request via RVS an den llm-adapter (AI-Box Qwen3),
# warten synchron auf llm_response und geben content zurueck. # warten synchron auf llm_response und geben content zurueck.
try: try:
data = json.loads(body.decode("utf-8", "ignore")) data = json.loads(body.decode("utf-8", "ignore"))
@@ -4684,6 +4772,60 @@ class ARIABridge:
}) })
return out return out
# worker_ping kommt alle ~10s; nach 35s ohne Ping gilt ein Worker als offline.
WORKER_OFFLINE_S = 35
def _worker_list(self) -> list[dict]:
"""Bekannte Compute-Worker (Flotte). online = kuerzlich per Ping gesehen."""
now = time.time()
out = []
for w in self._workers.values():
out.append({
"instanceId": w["instanceId"], "service": w.get("service") or "",
"node": w.get("node") or "", "gpus": w.get("gpus") or "",
"model": w.get("model") or "", "models": w.get("models") or [],
"busy": bool(w.get("busy")),
"online": (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S,
})
return out
def _pick_worker(self, service: str, model: Optional[str] = None) -> Optional[str]:
"""Waehlt eine online, moeglichst freie Instanz des Diensts (Round-Robin
ueber die freien). Gibt die instanceId oder None. Fuer Stage-3-Routing
(targetInstance).
model: wenn gesetzt (nur llm sinnvoll), kommen nur Boxen in Frage, die das
Modell fahren koennen (models-Liste oder legacy model-Feld). Meldet KEINE
Box das Modell None (nachsichtig: Aufrufer faellt auf Broadcast zurueck)."""
now = time.time()
online = [w for w in self._workers.values()
if w.get("service") == service
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
if model:
online = [w for w in online
if model in (w.get("models") or [])
or w.get("model") == model]
if not online:
return None
free = [w for w in online if not w.get("busy")]
pool = free or online # alle busy → trotzdem eine nehmen (least-bad)
# Round-Robin: rotierender Zeiger pro Dienst(+Modell).
rr_key = f"{service}:{model}" if model else service
rr = getattr(self, "_worker_rr", None)
if rr is None:
rr = self._worker_rr = {}
idx = rr.get(rr_key, 0) % len(pool)
rr[rr_key] = idx + 1
chosen = pool[idx]
chosen["busy"] = True # optimistisch, bis der naechste Ping korrigiert
return chosen["instanceId"]
def _pick_stt_worker(self) -> Optional[str]:
"""Waehlt eine STT-Instanz fuer ein App-Lease. Voxtral (Default-STT) hat
Vorrang, Whisper ist der Fallback. None keine online (App streamt dann
ohne targetInstance = heutiges Broadcast-Verhalten)."""
return self._pick_worker("voxtral") or self._pick_worker("whisper")
async def _satellite_request(self, op: str, satellite: str = "", async def _satellite_request(self, op: str, satellite: str = "",
device: str = "", action: str = "", device: str = "", action: str = "",
params: Optional[dict] = None, params: Optional[dict] = None,
+808 -267
View File
File diff suppressed because it is too large Load Diff
+268 -6
View File
@@ -349,6 +349,66 @@ function loadLocalModels() {
return DEFAULT_LOCAL_MODELS; return DEFAULT_LOCAL_MODELS;
} }
// ── LLM-Modell-Katalog (Stage D): herunterladbare GGUF-Modelle ───────
// /shared/config/llm_catalog.json — kuratierte Liste guter GGUF-Modelle plus
// per HuggingFace-Refresh nachgeladene. Der llm-adapter zieht ein Modell via
// -hf beim ersten Load. { id(key), hfRepo, quant, sizeGB, description, source }.
const LLM_CATALOG_FILE = "/shared/config/llm_catalog.json";
const DEFAULT_LLM_CATALOG = [
{ id: "qwen3-8b", hfRepo: "Qwen/Qwen3-8B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 6, description: "Bestes Tool-Calling, passt auf 12 GB.", source: "curated" },
{ id: "qwen3-4b", hfRepo: "Qwen/Qwen3-4B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 3, description: "Kleiner + flotter, etwas schwaecher.", source: "curated" },
{ id: "qwen3-14b", hfRepo: "Qwen/Qwen3-14B-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 10, description: "Staerker, braucht mehr VRAM (~16 GB).", source: "curated" },
{ id: "llama-3.1-8b", hfRepo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 5, description: "Llama 3.1 8B Instruct.", source: "curated" },
{ id: "mistral-small-3", hfRepo: "bartowski/Mistral-Small-24B-Instruct-2501-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 14, description: "Mistral Small 24B — stark, viel VRAM.", source: "curated" },
{ id: "gemma-2-9b", hfRepo: "bartowski/gemma-2-9b-it-GGUF", quant: "Q4_K_M", ctx: 8192, sizeGB: 6, description: "Google Gemma 2 9B Instruct.", source: "curated" },
];
function loadLlmCatalog() {
try {
const arr = JSON.parse(fs.readFileSync(LLM_CATALOG_FILE, "utf-8"));
if (Array.isArray(arr) && arr.length && arr.every(m => m && typeof m.id === "string")) return arr;
} catch {}
try {
fs.mkdirSync("/shared/config", { recursive: true });
fs.writeFileSync(LLM_CATALOG_FILE, JSON.stringify(DEFAULT_LLM_CATALOG, null, 2));
} catch {}
return DEFAULT_LLM_CATALOG;
}
function saveLlmCatalog(arr) {
try {
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = LLM_CATALOG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
fs.renameSync(tmp, LLM_CATALOG_FILE);
return true;
} catch (e) { log("warn", "llm", `Katalog speichern fehlgeschlagen: ${e.message}`); return false; }
}
function slugModelId(repo) {
return String(repo).toLowerCase().replace(/^.*\//, "").replace(/-gguf$/,"").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
}
// Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten).
async function refreshLlmCatalogFromHF() {
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) throw new Error(`HF API ${r.status}`);
const list = await r.json();
const existing = loadLlmCatalog();
const byId = new Map(existing.map(m => [m.id, m]));
let added = 0;
for (const m of (Array.isArray(list) ? list : [])) {
const repo = m.id || m.modelId;
if (!repo || !/gguf/i.test(repo)) continue;
const id = slugModelId(repo);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" };
byId.set(id, entry); added++;
}
const merged = Array.from(byId.values());
saveLlmCatalog(merged);
return { models: merged, added };
}
// ── File-Project-Manifest ─────────────────────────────────────────── // ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat). // Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt. // Wird vom files-list-Endpoint + files-set-project gepflegt.
@@ -490,6 +550,82 @@ function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() }); broadcast({ type: "sat_update", satellites: satelliteList() });
} }
// ── Compute-Fleet: GPU-Worker (voxtral/whisper/f5tts/llm) ──────────
// Worker melden sich per worker_hello + halten sich per worker_ping (busy) frisch.
const workers = new Map(); // instanceId → {instanceId, service, node, gpus, model, busy, last_seen}
const WORKER_OFFLINE_MS = 35000; // ping ~10s; nach 35s ohne Ping = offline
function workerList() {
const now = Date.now();
return Array.from(workers.values()).map(w => ({
instanceId: w.instanceId, service: w.service, node: w.node,
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
function broadcastWorkers() {
broadcast({ type: "worker_update", workers: workerList() });
}
// ── Voice-Flotte: zentraler Stimmen-Store + Auto-Provisioning ──────
// Der Diagnostic-Server ist der "Stimmen-Bibliothekar": Stimmen liegen
// zentral als /shared/voices/{name}.tar.gz (das f5tts-Export-Artefakt =
// wav+txt). Meldet sich eine f5tts-Box (worker_hello), gleichen wir ab und
// schieben ihr fehlende Stimmen (xtts_import_voice, targetInstance) bzw. ziehen
// bei ihr vorhandene, zentral fehlende Stimmen (xtts_export_voice) in den Store.
const CENTRAL_VOICES_DIR = "/shared/voices";
const centralExportPending = new Map(); // requestId -> name (unsere eigenen Export-Anfragen)
function ensureCentralVoicesDir() {
try { fs.mkdirSync(CENTRAL_VOICES_DIR, { recursive: true }); } catch (_) {}
}
function centralVoiceNames() {
ensureCentralVoicesDir();
try {
return fs.readdirSync(CENTRAL_VOICES_DIR)
.filter(f => f.endsWith(".tar.gz"))
.map(f => f.slice(0, -7));
} catch (_) { return []; }
}
function readCentralVoiceB64(name) {
try { return fs.readFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`).toString("base64"); }
catch (_) { return null; }
}
function writeCentralVoice(name, dataB64) {
ensureCentralVoicesDir();
try { fs.writeFileSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`, Buffer.from(dataB64, "base64")); return true; }
catch (e) { log("warn", "voice", `zentral schreiben ${name} fehlgeschlagen: ${e.message}`); return false; }
}
function deleteCentralVoice(name) {
try { fs.unlinkSync(`${CENTRAL_VOICES_DIR}/${name}.tar.gz`); log("info", "voice", `zentral geloescht: ${name}`); }
catch (_) {}
}
function requestCentralExport(name) {
// Broadcast-Export-Anfrage; die Box, die die Stimme hat, antwortet. Wir
// korrelieren die Antwort ueber requestId (nur unsere eigenen verarbeiten).
const requestId = "central_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
centralExportPending.set(requestId, name);
setTimeout(() => centralExportPending.delete(requestId), 30000);
sendToRVS_raw({ type: "xtts_export_voice", payload: { name, requestId }, timestamp: Date.now() });
}
function provisionVoiceToInstance(name, instanceId) {
const data = readCentralVoiceB64(name);
if (!data) return;
sendToRVS_raw({ type: "xtts_import_voice",
payload: { name, data, targetInstance: instanceId }, timestamp: Date.now() });
log("info", "voice", `provisioniere '${name}' → ${instanceId}`);
}
// Abgleich beim worker_hello einer f5tts-Box: push (zentral→Box) + pull (Box→zentral, seed).
function reconcileVoices(instanceId, boxVoices) {
const central = centralVoiceNames();
const boxSet = new Set(Array.isArray(boxVoices) ? boxVoices : []);
const centralSet = new Set(central);
for (const name of central) if (!boxSet.has(name)) provisionVoiceToInstance(name, instanceId);
for (const name of boxSet) if (!centralSet.has(name)) requestCentralExport(name);
log("info", "voice", `reconcile ${instanceId}: box=${boxSet.size} central=${central.length}`);
}
// ── OpenClaw Gateway Verbindung ───────────────────────── // ── OpenClaw Gateway Verbindung ─────────────────────────
async function connectGateway() { async function connectGateway() {
@@ -935,6 +1071,66 @@ function connectRVS(forcePlain) {
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now(); if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
broadcast({ type: "sat_devices", satellite: p.satellite || "", broadcast({ type: "sat_devices", satellite: p.satellite || "",
location: p.location || "", devices: p.devices || [] }); location: p.location || "", devices: p.devices || [] });
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
// Credential-Store-Antworten eines Satelliten → an den Browser.
broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "worker_hello") {
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
const p = msg.payload || {};
if (p.instanceId) {
const prev = workers.get(p.instanceId) || {};
// War die Box vor diesem hello schon frisch gesehen? worker_hello wird
// jetzt alle ~30s wiederholt — Reconcile nur beim ERSTEN/erneuten
// Auftauchen, nicht bei jedem Resend.
const wasFresh = prev.last_seen && (Date.now() - prev.last_seen < WORKER_OFFLINE_MS);
workers.set(p.instanceId, {
instanceId: p.instanceId, service: p.service || "",
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
busy: !!prev.busy, last_seen: Date.now(),
});
broadcastWorkers();
// Voice-Flotte: f5tts-Box NEU online → Stimmen abgleichen/provisionieren.
if ((p.service || "") === "f5tts" && !wasFresh) reconcileVoices(p.instanceId, p.voices);
}
} else if (msg.type === "worker_ping") {
// Heartbeat eines Workers (traegt busy-Status).
const p = msg.payload || {};
if (p.instanceId) {
let w = workers.get(p.instanceId);
if (!w) {
const svc = String(p.instanceId).split("@")[0];
w = { instanceId: p.instanceId, service: svc, node: "", gpus: "", model: "", models: [], busy: false, last_seen: 0 };
workers.set(p.instanceId, w);
}
w.busy = !!p.busy;
w.last_seen = Date.now();
broadcastWorkers();
}
} else if (msg.type === "xtts_voice_saved") {
// Neue Stimme (App- ODER Diagnostic-Upload, via RVS-Broadcast) → zentral
// sichern. Online-Boxen haben sie durch den voice_upload-Broadcast schon;
// der zentrale Store macht sie persistent + fuer spaeter joinende Boxen
// verfuegbar (die holt dann reconcileVoices ab).
const p = msg.payload || {};
if (p.name && !p.error) {
log("info", "voice", `Stimme '${p.name}' gespeichert → zentraler Ingest`);
requestCentralExport(p.name);
}
} else if (msg.type === "xtts_voice_exported") {
// Antwort auf eine UNSERER zentralen Export-Anfragen (requestId-Match) →
// in den zentralen Store schreiben. Browser-initiierte Exports tragen
// keinen centralExportPending-requestId und werden hier ignoriert.
const p = msg.payload || {};
const rid = p.requestId || "";
if (rid && centralExportPending.has(rid)) {
centralExportPending.delete(rid);
if (p.ok && p.name && p.data) writeCentralVoice(p.name, p.data);
}
} else if (msg.type === "xtts_delete_voice") {
// App-initiierter Delete (Broadcast) → zentrale Kopie mitloeschen.
const p = msg.payload || {};
if (p.name) deleteCentralVoice(p.name);
} else if (msg.type === "agent_activity") { } else if (msg.type === "agent_activity") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser // Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint. // weiterreichen, damit der Thinking-Indikator im Chat erscheint.
@@ -981,7 +1177,7 @@ function connectRVS(forcePlain) {
} }
broadcast({ type: "voice_ready", payload: msg.payload }); broadcast({ type: "voice_ready", payload: msg.payload });
} else if (msg.type === "service_status") { } else if (msg.type === "service_status") {
// Gamebox-Bridges (f5tts/whisper) melden ihren Lade-Status — // AI-Box-Bridges (f5tts/whisper) melden ihren Lade-Status —
// an Browser durchreichen fuer das Banner unten rechts // an Browser durchreichen fuer das Banner unten rechts
const svc = msg.payload?.service || "?"; const svc = msg.payload?.service || "?";
const state = msg.payload?.state || "?"; const state = msg.payload?.state || "?";
@@ -996,6 +1192,12 @@ function connectRVS(forcePlain) {
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`); log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
} }
broadcast({ type: "service_status", payload: msg.payload }); broadcast({ type: "service_status", payload: msg.payload });
} else if (msg.type === "llm_provision_result") {
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
broadcast({ type: "llm_provision_result", payload: msg.payload || {} });
} else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") {
// Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen.
broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) { } else if (msg.type === "audio_pcm" && msg.payload && _previewPending.size > 0) {
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen // PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
_handlePreviewChunk(msg.payload); _handlePreviewChunk(msg.payload);
@@ -1040,7 +1242,7 @@ function connectRVS(forcePlain) {
}); });
} }
function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) { function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, timeoutMs = 15000) {
if (!RVS_HOST || !RVS_TOKEN) return; if (!RVS_HOST || !RVS_TOKEN) return;
const proto = RVS_TLS === "true" ? "wss" : "ws"; const proto = RVS_TLS === "true" ? "wss" : "ws";
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
@@ -1048,7 +1250,7 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
try { freshWs.close(); } catch (_) {} try { freshWs.close(); } catch (_) {}
clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() })); clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
}, 15000); }, timeoutMs);
freshWs.on("open", () => { freshWs.on("open", () => {
freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })); freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
}); });
@@ -1738,6 +1940,20 @@ const server = http.createServer((req, res) => {
} else if (req.url === "/api/local-models-list" && req.method === "GET") { } else if (req.url === "/api/local-models-list" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: loadLocalModels() })); res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
} else if (req.url === "/api/llm-catalog" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: loadLlmCatalog() }));
} else if (req.url === "/api/llm-catalog/refresh" && req.method === "POST") {
refreshLlmCatalogFromHF()
.then(r => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
});
} else if (req.url === "/api/local-llm-config" && req.method === "GET") { } else if (req.url === "/api/local-llm-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig())); res.end(JSON.stringify(readLocalLlmConfig()));
@@ -2505,6 +2721,8 @@ wss.on("connection", (ws) => {
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus)); if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht). // Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() })); ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
// Aktuell bekannte Compute-Worker mitgeben (RVS replayt worker_hello nicht).
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
ws.on("message", (raw) => { ws.on("message", (raw) => {
try { try {
@@ -2536,11 +2754,28 @@ wss.on("connection", (ws) => {
} else if (msg.action === "sat_list") { } else if (msg.action === "sat_list") {
// Browser will die aktuelle Satelliten-Liste. // Browser will die aktuelle Satelliten-Liste.
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() })); ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
} else if (msg.action === "worker_list") {
// Browser will die aktuelle Compute-Flotte.
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
} else if (msg.action === "sat_discover") { } else if (msg.action === "sat_discover") {
// Browser triggert einen Geraete-Scan auf einem Satelliten. // Browser triggert einen Geraete-Scan auf einem Satelliten.
sendToRVS_raw({ type: "sat_discover", sendToRVS_raw({ type: "sat_discover",
payload: { satellite: msg.satellite || "", force: true }, payload: { satellite: msg.satellite || "", force: true },
timestamp: Date.now() }); timestamp: Date.now() });
} else if (msg.action === "sat_creds_list") {
sendToRVS_raw({ type: "sat_creds_list",
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_set") {
sendToRVS_raw({ type: "sat_creds_set",
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
creds: msg.creds || {}, requestId: "dc_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_delete") {
sendToRVS_raw({ type: "sat_creds_delete",
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
type: msg.credType || "", requestId: "dc_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "test_proxy") { } else if (msg.action === "test_proxy") {
testProxy(msg.text); testProxy(msg.text);
} else if (msg.action === "check_proxy_auth") { } else if (msg.action === "check_proxy_auth") {
@@ -2568,7 +2803,7 @@ wss.on("connection", (ws) => {
// Datei von Diagnostic an Bridge via RVS senden // Datei von Diagnostic an Bridge via RVS senden
sendToRVS_raw({ sendToRVS_raw({
type: "file", type: "file",
payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 }, payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64, projectId: msg.projectId || "" },
timestamp: Date.now(), timestamp: Date.now(),
}); });
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`); log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
@@ -2611,9 +2846,11 @@ wss.on("connection", (ws) => {
// tar.gz (base64) an XTTS-Bridge schicken — die packt aus // tar.gz (base64) an XTTS-Bridge schicken — die packt aus
sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws); sendToRVS_withResponse("xtts_import_voice", { name: msg.name, data: msg.data }, "xtts_voice_imported", ws);
} else if (msg.action === "xtts_delete_voice") { } else if (msg.action === "xtts_delete_voice") {
// Weiterleiten an XTTS-Bridge, die antwortet mit neuer Liste // Weiterleiten an alle f5tts-Boxen (Broadcast) + zentrale Kopie loeschen.
// (Der eigene Broadcast kommt nicht zu uns zurueck, daher hier direkt.)
sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() }); sendToRVS_raw({ type: "xtts_delete_voice", payload: { name: msg.name }, timestamp: Date.now() });
log("info", "server", `Voice-Delete '${msg.name}' an XTTS-Bridge gesendet`); if (msg.name) deleteCentralVoice(msg.name);
log("info", "server", `Voice-Delete '${msg.name}' an f5tts-Boxen + zentral geloescht`);
} else if (msg.action === "delete_chat_message") { } else if (msg.action === "delete_chat_message") {
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation // Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
// + broadcastet chat_message_deleted via RVS. // + broadcastet chat_message_deleted via RVS.
@@ -2702,6 +2939,31 @@ wss.on("connection", (ws) => {
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory // Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab). // laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
// restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert. // restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
} else if (msg.action === "llm_provision_model") {
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
sendToRVS_raw({ type: "llm_provision_model", payload: {
targetInstance: msg.targetInstance || "",
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
}, timestamp: Date.now() });
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_remove_model") {
sendToRVS_raw({ type: "llm_remove_model", payload: {
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_test") {
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
const reqId = "diagtest_" + Date.now();
sendToRVS_withResponse("llm_request", {
requestId: reqId,
messages: [{ role: "user", content: String(msg.text || "Sag kurz Hallo.") }],
max_tokens: 256, temperature: 0.5,
model: msg.model || "", targetInstance: msg.targetInstance || "",
}, "llm_response", ws, 120000); // 2min: erster Modell-Swap laedt das GGUF kalt (mehrere GB) — 15s reichen dann nicht
log("info", "llm", `Test-Chat → ${msg.model || "?"} @ ${msg.targetInstance || "(broadcast)"}`);
} else if (msg.action === "node_stats_stream_start" || msg.action === "node_stats_stream_stop"
|| msg.action === "node_stats_history_request" || msg.action === "node_stats_reset") {
// Auslastungs-Monitor (Stage E): an die Box (targetInstance) durchreichen.
sendToRVS_raw({ type: msg.action, payload: { targetInstance: msg.targetInstance || "" }, timestamp: Date.now() });
} else if (msg.action === "restart_session") { } else if (msg.action === "restart_session") {
handleRestartSession(ws); handleRestartSession(ws);
// ── Einstellungen ── // ── Einstellungen ──
+2 -2
View File
@@ -1,7 +1,7 @@
# FLUX.1-dev Bildgenerierung — Architektur & Stand # FLUX.1-dev Bildgenerierung — Architektur & Stand
Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via Ergaenzung des ARIA-Agent-Stacks um native Text-to-Image-Generierung via
FLUX.1-dev auf der Gamebox. Folgt dem **gleichen Pattern wie f5tts / whisper**: FLUX.1-dev auf der AI-Box. Folgt dem **gleichen Pattern wie f5tts / whisper**:
ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum ein eigener Container auf dem Gaming-PC, der sich selbst per WebSocket zum
RVS verbindet und auf seinen Request-Typ lauscht. RVS verbindet und auf seinen Request-Typ lauscht.
@@ -23,7 +23,7 @@ aria-bridge ── send_to_core ──▶ aria-brain
RVS RVS
│ fanout │ fanout
flux-bridge (Gamebox) flux-bridge (AI-Box)
│ FluxPipeline.from_pretrained(...) │ FluxPipeline.from_pretrained(...)
│ pipeline(prompt, width, height, steps, guidance).images[0] │ pipeline(prompt, width, height, steps, guidance).images[0]
│ PIL → PNG → base64 │ PIL → PNG → base64
+9 -9
View File
@@ -1,4 +1,4 @@
# Plan B — Lokaler LLM-Router (Gamebox) neben Claude # Plan B — Lokaler LLM-Router (AI-Box) neben Claude
**Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription **Ziel:** „Gemini-Feeling" für den Alltag, ohne die Claude-Max-Subscription
aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns aufzugeben. Ein schnelles lokales LLM beantwortet die einfachen ~80 % der Turns
@@ -11,7 +11,7 @@ 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 **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 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** 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 (läuft auf vorhandener AI-Box-GPU). Echtes Speech-to-Speech-Duplex (Gemini
Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin. Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
## Modell & Serving (entschieden) ## Modell & Serving (entschieden)
@@ -19,17 +19,17 @@ Live nativ) ist mit einem Text-Modell als Hirn prinzipiell nicht drin.
- **Modell:** Qwen3 8B, GGUF **Q4_K_M** (~6 GB). Bestes Tool-Calling der 7/8B- - **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, Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
weniger Tool-Calling). weniger Tool-Calling).
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox - **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der AI-Box
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`). (kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~12 GB) + F5-TTS (~12 GB) → - **VRAM-Budget:** 12-GB-Karte, Whisper-small (~12 GB) + F5-TTS (~12 GB) →
~89 GB frei → passt. (FLUX ist auf 12 GB eh raus.) ~89 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen) ## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/ Die AI-Box 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: Firewall) läuft das LLM **über den RVS-Token-Room**, exakt wie Whisper/F5-TTS:
- llama.cpp hört nur auf localhost der Gamebox. - llama.cpp hört nur auf localhost der AI-Box.
- Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet - Ein **dünner RVS-Adapter** daneben (Vorbild: whisper-/xtts-Bridge) verbindet
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server, sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
schickt `llm_response` (korreliert per requestId) zurück. schickt `llm_response` (korreliert per requestId) zurück.
@@ -115,7 +115,7 @@ mit Ziel lokal.
## Phasen ## Phasen
- **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der Gamebox, - **B0 — Infra:** llama.cpp-Container + RVS-Adapter auf der AI-Box,
`ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo"). `ALLOWED_TYPES`, `local_llm_chat()` im Brain. Isoliert testen („sag hallo").
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke - **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool- Persona lokal, **kuratierte Tool-Auswahl lokal** (Adapter/Bridge/Brain-Tool-
@@ -207,8 +207,8 @@ Zerfaellt in zwei Teile:
(Docker-Socket) + Controller mit Placement-Policy + Reconciliation + (Docker-Socket) + Controller mit Placement-Policy + Reconciliation +
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad. Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht **Empfehlung:** Fuer 2 AI-Boxen NICHT bauen — statische Platzierung reicht
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen (AI-Box1=LLM, AI-Box2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den deckt `llama-swap` innerhalb eines Hosts (B0.5). Waechst die Flotte: erst den
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
statt selbst einen Scheduler zu bauen. statt selbst einen Scheduler zu bauen.
@@ -227,7 +227,7 @@ lohnt nicht):
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar. N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` - **Zukunft (AI-Box3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up`
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin. erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
Ohne Orchestrator. Ohne Orchestrator.
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ARIA FLUX-Bridge laeuft auf der Gamebox (RTX 3060). ARIA FLUX-Bridge laeuft auf der AI-Box (RTX 3060).
Empfaengt flux_request via RVS FLUX.1-dev/-schnell auf GPU sendet Empfaengt flux_request via RVS FLUX.1-dev/-schnell auf GPU sendet
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
+5 -5
View File
@@ -183,9 +183,9 @@ Wichtige Mechanismen:
- [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead) - [x] Decimal-zu-Worte fuer TTS (0.1 → null komma eins, mit IP-Schutz-Lookahead)
- [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste) - [x] Generic Acronym-Buchstabieren (XTTS → X T T S, USB → U S B, ueber expliziter Liste)
- [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit" - [x] voice_preload/voice_ready: Stille Mini-Render bei Voice-Wechsel + Toast/Status "bereit"
- [x] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container - [x] Whisper STT auf die AI-Box ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout - [x] aria-bridge: STT primaer remote (AI-Box), Fallback lokal nach 45s Timeout
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic - [x] Whisper-Modell hot-swap auf AI-Box via config-Broadcast aus Diagnostic
- [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming - [x] **F5-TTS ersetzt XTTS komplett** — neuer aria-f5tts-bridge Container, Voice Cloning, satzweises Streaming
- [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen - [x] Voice-Upload mit Whisper-Auto-Transkription — User muss keinen Referenz-Text eintippen
- [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK) - [x] Audio-Pause statt Ducking: Spotify/YouTube pausieren komplett waehrend TTS (TRANSIENT statt MAY_DUCK)
@@ -334,7 +334,7 @@ Skills mit Tool-Use.
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete - [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
- [x] Wipe-All-Button (Memory + Stimmen + Settings) - [x] Wipe-All-Button (Memory + Stimmen + Settings)
- [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf Gamebox) - [x] Voice Export/Import pro Stimme (Diagnostic + XTTS-Bridge auf AI-Box)
- [x] F5/Whisper-Settings als JSON-Bundle Export/Import - [x] F5/Whisper-Settings als JSON-Bundle Export/Import
- [x] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter - [x] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs) - [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
@@ -399,7 +399,7 @@ Skills mit Tool-Use.
### Architektur ### Architektur
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU) - [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] RVS Zombie-Connections endgueltig loesen - [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push - [ ] AI-Box: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an - [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
- [ ] Heartbeat (periodische Selbst-Checks) - [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call) - [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
+15 -1
View File
@@ -63,7 +63,7 @@ const ALLOWED_TYPES = new Set([
"agent_stream", "agent_stream",
"oauth_callback", "oauth_callback",
// Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das // Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp). // Qwen3 auf der AI-Box (via Bridge → RVS → llm-adapter → llama.cpp).
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt. // llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
"llm_request", "llm_response", "llm_partial", "llm_request", "llm_response", "llm_partial",
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App) // Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
@@ -80,6 +80,20 @@ const ALLOWED_TYPES = new Set([
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und // sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
// fuehren Aktionen aus (sat_command → sat_result). // fuehren Aktionen aus (sat_command → sat_result).
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result", "sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
// Satelliten-Credential-Store: Diagnostic legt pro Geraet Zugangsdaten ab
// (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt.
"sat_creds_set", "sat_creds_delete", "sat_creds_list",
"sat_creds_result", "sat_creds_list_result",
// Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden
// sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server
// aggregiert das und broadcastet worker_update/worker_list an die Browser-UI.
// node_stats_* speisen den Auslastungs-Monitor (live nvidia-smi + Historie).
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
"worker_hello", "worker_ping", "worker_update", "worker_list",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
]); ]);
// Token-Raum: token -> { clients: Set<ws> } // Token-Raum: token -> { clients: Set<ws> }
+31 -2
View File
@@ -24,9 +24,38 @@ CONTROL_ENABLED=true
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt. # Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV) # dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
# wol Wake-on-LAN (Geraet per MAC aufwecken) # wol Wake-on-LAN (Geraet per MAC aufwecken)
# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks) # http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks, Statusseiten)
# http.post generischer HTTP-POST # http.post generischer HTTP-POST
CONTROL_ALLOWLIST=dial.launch,wol,http.get # snmp.get einzelner SNMP-Wert (params: ip, oid)
# snmp.walk SNMP-Teilbaum (params: ip, oid)
# snmp.printer Drucker-Fuellstaende (Tinte/Toner) aus der Printer-MIB (params: ip)
# snmp.ports Switch/Router-Interfaces: aktive/freie Ports (params: ip)
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts
# ─── Credential-Store (optional) ───────────────────────────────────
# Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/
# v3, HTTP-Basic, FritzBox-Login). Der Satellit speichert sie VERSCHLUESSELT im
# Bind-Volume ./data. Der Schluessel wird beim ersten Start automatisch erzeugt
# (./data/creds.key) — oder hier fest vorgeben (Fernet-Key, base64):
# CREDS_KEY=
# ─── SNMP (optional) ───────────────────────────────────────────────
# Defaults fuer die snmp.*-Aktionen; pro Request per params ueberschreibbar.
SNMP_COMMUNITY=public # Drucker/Switches antworten meist auf 'public'
SNMP_VERSION=2c # 1 | 2c
SNMP_TIMEOUT_SEC=5
# Discovery-Anreicherung: jedes entdeckte Geraet wird beim Scan kurz per SNMP
# nach Name/Beschreibung/Standort/Uptime gefragt (Switches, Router, APs, NAS ...).
SNMP_DISCOVERY=true
SNMP_DISCOVERY_CONCURRENCY=16 # parallele SNMP-Abfragen pro Scan
SNMP_DISCOVERY_TIMEOUT=2 # Timeout je Geraet (s) — Nicht-SNMP-Hosts fallen schnell raus
# ─── HTTP (optional) ───────────────────────────────────────────────
HTTP_TIMEOUT_SEC=10 # Timeout fuer http.get/http.post
HTTP_MAX_CHARS=20000 # Default-Body-Ausschnitt (offset/max_chars pro Request)
# ─── Discovery-Tuning (optional) ─────────────────────────────────── # ─── Discovery-Tuning (optional) ───────────────────────────────────
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
+3
View File
@@ -0,0 +1,3 @@
.env
# Verschluesselter Credential-Store + Schluessel (nie einchecken!)
data/
+6
View File
@@ -5,6 +5,12 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# net-snmp-CLI (snmpget/snmpwalk) fuer die snmp.*-Aktionen — z.B. Drucker-
# Tintenstaende zuverlaessig aus der Printer-MIB statt HTML zu scrapen.
RUN apt-get update \
&& apt-get install -y --no-install-recommends snmp \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+3
View File
@@ -21,3 +21,6 @@ services:
network_mode: host network_mode: host
env_file: .env env_file: .env
restart: unless-stopped restart: unless-stopped
volumes:
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
- ./data:/data
+1
View File
@@ -1,3 +1,4 @@
websockets>=12.0 websockets>=12.0
zeroconf>=0.131.0 zeroconf>=0.131.0
requests>=2.31.0 requests>=2.31.0
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
+629 -6
View File
@@ -35,6 +35,7 @@ import re
import socket import socket
import struct import struct
import time import time
from pathlib import Path
from typing import Optional from typing import Optional
import websockets import websockets
@@ -113,7 +114,9 @@ SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).stri
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False) CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
CONTROL_ALLOWLIST = [ CONTROL_ALLOWLIST = [
a.strip() for a in a.strip() for a in
os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",") os.environ.get("CONTROL_ALLOWLIST",
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts").split(",")
if a.strip() if a.strip()
] ]
@@ -121,6 +124,114 @@ SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6") DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120") DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
# http.get/http.post: Body-Ausschnitt. Default grosszuegig (ganze Statusseiten
# passen), mit hartem Deckel gegen Riesen-Payloads durchs RVS. offset/max_chars
# pro Request ueberschreibbar; contains-Filter zieht nur relevante Zeilen.
HTTP_TIMEOUT_SEC = float(os.environ.get("HTTP_TIMEOUT_SEC", "10") or "10")
HTTP_MAX_CHARS = int(os.environ.get("HTTP_MAX_CHARS", "20000") or "20000")
HTTP_MAX_CHARS_HARD = int(os.environ.get("HTTP_MAX_CHARS_HARD", "200000") or "200000")
# SNMP (net-snmp-CLI): Default-Community/Version + Timeout. Drucker antworten
# i.d.R. auf community 'public', v2c.
SNMP_COMMUNITY = os.environ.get("SNMP_COMMUNITY", "public") or "public"
SNMP_VERSION = os.environ.get("SNMP_VERSION", "2c") or "2c"
SNMP_TIMEOUT_SEC = float(os.environ.get("SNMP_TIMEOUT_SEC", "5") or "5")
# Printer-MIB (RFC 3805) prtMarkerSuppliesEntry-Spalten (numerisch, ohne MIB-Files):
SNMP_SUPPLY_DESC = "1.3.6.1.2.1.43.11.1.1.6.1" # Beschreibung (z.B. "Black Ink")
SNMP_SUPPLY_MAX = "1.3.6.1.2.1.43.11.1.1.8.1" # Max-Kapazitaet
SNMP_SUPPLY_LVL = "1.3.6.1.2.1.43.11.1.1.9.1" # aktueller Fuellstand
# SNMP-Anreicherung bei der Discovery: jedes entdeckte Geraet mit IP wird kurz
# nach seiner System-Group (RFC 1213) gefragt. Switches/Router/APs/NAS geben so
# Name, Beschreibung, Standort & Uptime preis -> im Inventar (satellite_devices)
# sichtbar. Abschaltbar; kurzer Timeout + parallel, damit der Scan flott bleibt.
SNMP_DISCOVERY = _env_bool("SNMP_DISCOVERY", True)
SNMP_DISCOVERY_CONCURRENCY = int(os.environ.get("SNMP_DISCOVERY_CONCURRENCY", "16") or "16")
SNMP_DISCOVERY_TIMEOUT = float(os.environ.get("SNMP_DISCOVERY_TIMEOUT", "2") or "2")
# System-Group (RFC 1213) .0-Instanzen:
SNMP_SYS_OIDS = {
"descr": "1.3.6.1.2.1.1.1.0", # sysDescr
"objectid": "1.3.6.1.2.1.1.2.0", # sysObjectID
"uptime": "1.3.6.1.2.1.1.3.0", # sysUpTime
"contact": "1.3.6.1.2.1.1.4.0", # sysContact
"name": "1.3.6.1.2.1.1.5.0", # sysName
"location": "1.3.6.1.2.1.1.6.0", # sysLocation
}
# ─── Geraete-Credential-Store (verschluesselt, pro IP) ─────────────
# Diagnostic legt via sat_creds_set pro Geraet Zugangsdaten ab (SNMP-Community/
# v3, HTTP-Basic, FritzBox-Login). Der Satellit nutzt sie automatisch bei snmp.*/
# http/fritzbox. Persistiert verschluesselt (Fernet) in einem Bind-Volume.
CREDS_PATH = os.environ.get("CREDS_PATH", "/data/credentials.json.enc")
CREDS_KEY_PATH = os.environ.get("CREDS_KEY_PATH", "/data/creds.key")
_CREDS: dict = {} # {ip: {snmp:{...}, http:{...}, fritzbox:{...}}}
_creds_fernet = None # Fernet-Instanz (lazy)
def _creds_cipher():
"""Fernet-Instanz; Schluessel aus CREDS_KEY (env) oder Schluesseldatei im
Volume (wird beim ersten Start erzeugt, 0600)."""
global _creds_fernet
if _creds_fernet is not None:
return _creds_fernet
from cryptography.fernet import Fernet
key = os.environ.get("CREDS_KEY", "").strip().encode() or None
if not key:
kp = Path(CREDS_KEY_PATH)
if kp.exists():
key = kp.read_bytes().strip()
else:
key = Fernet.generate_key()
kp.parent.mkdir(parents=True, exist_ok=True)
kp.write_bytes(key)
try:
os.chmod(kp, 0o600)
except OSError:
pass
logger.info("[creds] neuer Verschluesselungs-Schluessel erzeugt: %s", CREDS_KEY_PATH)
_creds_fernet = Fernet(key)
return _creds_fernet
def _creds_load() -> None:
global _CREDS
p = Path(CREDS_PATH)
if not p.exists():
_CREDS = {}
return
try:
blob = p.read_bytes()
raw = _creds_cipher().decrypt(blob)
_CREDS = json.loads(raw.decode("utf-8")) or {}
logger.info("[creds] %d Geraete-Eintraege geladen", len(_CREDS))
except Exception as exc:
logger.warning("[creds] laden fehlgeschlagen (%s) — starte leer", exc)
_CREDS = {}
def _creds_save() -> None:
p = Path(CREDS_PATH)
p.parent.mkdir(parents=True, exist_ok=True)
blob = _creds_cipher().encrypt(json.dumps(_CREDS).encode("utf-8"))
p.write_bytes(blob)
try:
os.chmod(p, 0o600)
except OSError:
pass
def _creds_for(ip: str) -> dict:
return _CREDS.get((ip or "").strip(), {}) if ip else {}
def _creds_public_summary() -> list:
"""Fuer sat_creds_list: welche Geraete welche Cred-Typen haben — OHNE Secrets."""
out = []
for ip, entry in sorted(_CREDS.items()):
types = [t for t in ("snmp", "http", "fritzbox") if entry.get(t)]
out.append({"ip": ip, "types": types})
return out
HEARTBEAT_SEC = 25 HEARTBEAT_SEC = 25
# mDNS-Servicetypen, die fuer ARIA interessant sind. # mDNS-Servicetypen, die fuer ARIA interessant sind.
@@ -420,6 +531,16 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_wol, params) return await loop.run_in_executor(None, _do_wol, params)
if action in ("http.get", "http.post"): if action in ("http.get", "http.post"):
return await loop.run_in_executor(None, _do_http, action, params) return await loop.run_in_executor(None, _do_http, action, params)
if action in ("snmp.get", "snmp.walk"):
return await loop.run_in_executor(None, _do_snmp, action, params)
if action == "snmp.printer":
return await loop.run_in_executor(None, _do_snmp_printer, params)
if action == "snmp.ports":
return await loop.run_in_executor(None, _do_snmp_ports, params)
if action == "snmp.info":
return await loop.run_in_executor(None, _do_snmp_info, params)
if action in ("fritzbox.info", "fritzbox.hosts"):
return await loop.run_in_executor(None, _do_fritzbox, action, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."} return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc: except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"} return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -472,14 +593,424 @@ def _do_wol(params: dict) -> dict:
def _do_http(action: str, params: dict) -> dict: def _do_http(action: str, params: dict) -> dict:
"""HTTP-GET/POST vom Satelliten aus (lokale Webhooks, Geraete-Statusseiten …).
params:
url Pflicht (http/https).
body/headers optional (POST).
offset ab welchem Zeichen der Body zurueckgegeben wird (Default 0).
max_chars wie viele Zeichen max. (Default HTTP_MAX_CHARS, hart gedeckelt).
contains String oder Liste: nur Zeilen, die (case-insensitive) einen der
Begriffe enthalten, werden zurueckgegeben. Ideal um aus einer
grossen Statusseite nur die relevanten Werte (z.B. Tinte) zu
ziehen, ohne die ganze Seite zu paginieren.
Antwort enthaelt total_chars + truncated, damit der Aufrufer weiss, ob noch
mehr da ist."""
import requests import requests
url = params.get("url") or "" url = params.get("url") or ""
if not url.startswith(("http://", "https://")): if not url.startswith(("http://", "https://")):
return {"ok": False, "error": "url (http/https) erforderlich."} return {"ok": False, "error": "url (http/https) erforderlich."}
method = "GET" if action == "http.get" else "POST" method = "GET" if action == "http.get" else "POST"
# HTTP-Basic-Auth: explizite params > gespeicherte http-Creds fuer den Host.
auth = None
hcreds = {}
try:
from urllib.parse import urlparse
host = urlparse(url).hostname or ""
hcreds = _creds_for(host).get("http", {})
except Exception:
pass
user = params.get("user") or hcreds.get("user")
pw = params.get("pass") or params.get("password") or hcreds.get("pass")
if user or pw: # Benutzer optional — manche Geraete nutzen Password-only-Basic-Auth
auth = (str(user or ""), str(pw or ""))
r = requests.request(method, url, data=params.get("body"), r = requests.request(method, url, data=params.get("body"),
headers=params.get("headers"), timeout=6) headers=params.get("headers"), auth=auth,
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}} timeout=HTTP_TIMEOUT_SEC)
text = r.text
total = len(text)
contains = params.get("contains")
if contains:
terms = [contains] if isinstance(contains, str) else list(contains)
terms = [str(t).lower() for t in terms if str(t).strip()]
if terms:
lines = [ln for ln in text.splitlines()
if any(t in ln.lower() for t in terms)]
text = "\n".join(lines)
try:
offset = max(0, int(params.get("offset", 0)))
except (TypeError, ValueError):
offset = 0
try:
max_chars = int(params.get("max_chars", HTTP_MAX_CHARS))
except (TypeError, ValueError):
max_chars = HTTP_MAX_CHARS
max_chars = max(1, min(max_chars, HTTP_MAX_CHARS_HARD))
body = text[offset:offset + max_chars]
returned_end = offset + len(body)
truncated = returned_end < len(text)
return {"ok": True, "result": {
"status": r.status_code,
"body": body,
"total_chars": total, # Groesse der Roh-Antwort
"filtered": bool(contains), # contains-Filter aktiv?
"offset": offset,
"returned_chars": len(body),
"truncated": truncated, # noch mehr Text nach diesem Ausschnitt?
}}
def _snmp_run(args: list, timeout: float) -> tuple:
"""Fuehrt ein net-snmp-CLI-Tool aus. Gibt (ok, stdout|fehlertext)."""
import subprocess
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError:
return False, "snmp-Tools fehlen im Container (Paket 'snmp' im Dockerfile)."
except subprocess.TimeoutExpired:
return False, "SNMP-Timeout — Geraet antwortet nicht (community/version/IP pruefen)."
if r.returncode != 0:
return False, (r.stderr or r.stdout or "SNMP-Fehler").strip()[:200]
return True, r.stdout
def _snmp_base_args(params: dict, ip: str = "") -> list:
"""Version/Community bzw. v3-Auth. Prioritaet: explizite params > gespeicherte
Creds fuer die IP > globale Defaults. OHNE -t/-r (haengt der Aufrufer an)."""
creds = _creds_for(ip).get("snmp", {}) if ip else {}
version = str(params.get("version") or creds.get("version") or SNMP_VERSION)
if version == "3":
v3 = creds.get("v3", {}) or {}
user = str(params.get("user") or v3.get("user") or "")
level = str(params.get("level") or v3.get("level") or "authPriv")
args = ["-v", "3", "-u", user, "-l", level]
ap = params.get("authProto") or v3.get("authProto")
ak = params.get("authKey") or v3.get("authKey")
pp = params.get("privProto") or v3.get("privProto")
pk = params.get("privKey") or v3.get("privKey")
if ap and ak:
args += ["-a", str(ap), "-A", str(ak)]
if pp and pk:
args += ["-x", str(pp), "-X", str(pk)]
return args
community = str(params.get("community") or creds.get("community") or SNMP_COMMUNITY)
return ["-v", version, "-c", community]
def _snmp_target(params: dict) -> str:
return (params.get("ip") or params.get("host") or params.get("device") or "").strip()
def _do_snmp(action: str, params: dict) -> dict:
"""Generisches snmp.get / snmp.walk.
params: {ip|host, oid, community?='public', version?='2c'}."""
ip = _snmp_target(params)
if not ip:
return {"ok": False, "error": "ip/host erforderlich."}
oid = str(params.get("oid") or "").strip()
if not oid:
return {"ok": False, "error": "oid erforderlich (z.B. 1.3.6.1.2.1.1.5.0 fuer sysName)."}
tool = "snmpwalk" if action == "snmp.walk" else "snmpget"
# -OQ: OID = Wert, ohne Typannotation; numerische OIDs brauchen keine MIB-Files.
args = [tool, "-OQ", *_snmp_base_args(params, ip), "-t", "2", "-r", "1", ip, oid]
ok, out = _snmp_run(args, SNMP_TIMEOUT_SEC)
if not ok:
return {"ok": False, "error": out}
lines = [ln.strip() for ln in out.splitlines() if ln.strip()]
return {"ok": True, "result": {"ip": ip, "oid": oid, "lines": lines[:200]}}
def _snmp_walk_values(ip: str, base: list, oid: str) -> list:
"""snmpwalk -Oqv (nur Werte, in OID-Index-Reihenfolge)."""
ok, out = _snmp_run(["snmpwalk", "-Oqv", *base, ip, oid], SNMP_TIMEOUT_SEC)
if not ok:
return []
return [ln.strip().strip('"') for ln in out.splitlines() if ln.strip()]
def _do_snmp_printer(params: dict) -> dict:
"""Komfort: liest die Verbrauchsmaterialien (Tinte/Toner) aus der Printer-MIB
und rechnet Fuellstaende in Prozent. params: {ip|host, community?, version?}."""
ip = _snmp_target(params)
if not ip:
return {"ok": False, "error": "ip/host erforderlich."}
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
descs = _snmp_walk_values(ip, base, SNMP_SUPPLY_DESC)
if not descs:
return {"ok": False, "error":
"Keine Printer-MIB-Daten (Geraet unterstuetzt kein SNMP, falsche "
"community/version, oder es ist kein Drucker)."}
lvls = _snmp_walk_values(ip, base, SNMP_SUPPLY_LVL)
maxs = _snmp_walk_values(ip, base, SNMP_SUPPLY_MAX)
supplies = []
for i, name in enumerate(descs):
lvl = _to_int(lvls[i]) if i < len(lvls) else None
mx = _to_int(maxs[i]) if i < len(maxs) else None
percent = None
if lvl is not None and mx and mx > 0 and lvl >= 0:
percent = round(lvl / mx * 100)
elif lvl == -3:
percent = "vorhanden (Stand unbekannt)" # RFC: some remaining
elif lvl in (-1, -2):
percent = "unbekannt"
supplies.append({"name": name, "level": lvl, "max": mx, "percent": percent})
return {"ok": True, "result": {"ip": ip, "supplies": supplies}}
# ifTable (RFC 1213) Spalten:
_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
_IF_TYPE = "1.3.6.1.2.1.2.2.1.3"
_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
_IF_ADMIN = "1.3.6.1.2.1.2.2.1.7" # up(1) down(2)
_IF_OPER = "1.3.6.1.2.1.2.2.1.8" # up(1) down(2) ...
_IF_ALIAS = "1.3.6.1.2.1.31.1.1.1.18" # ifAlias (ifXTable, optional)
def _do_snmp_ports(params: dict) -> dict:
"""Interface-Uebersicht eines Switches/Routers: welche Ports sind aktiv (Link),
welche frei. params: {ip|host, community?/v3?}. ethernetCsmacd(6)=echte Ports;
Loopback/VLAN etc. werden als 'other' markiert, nicht als freier Port gezaehlt."""
ip = _snmp_target(params)
if not ip:
return {"ok": False, "error": "ip/host erforderlich."}
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
descr = _snmp_walk_values(ip, base, _IF_DESCR)
if not descr:
return {"ok": False, "error":
"Keine Interface-Daten (kein SNMP / falsche Credentials / kein Switch)."}
types = _snmp_walk_values(ip, base, _IF_TYPE)
opers = _snmp_walk_values(ip, base, _IF_OPER)
admins = _snmp_walk_values(ip, base, _IF_ADMIN)
speeds = _snmp_walk_values(ip, base, _IF_SPEED)
aliases = _snmp_walk_values(ip, base, _IF_ALIAS)
ports = []
up = down_free = disabled = 0
for i, name in enumerate(descr):
itype = _to_int(types[i]) if i < len(types) else None
oper = _to_int(opers[i]) if i < len(opers) else None
admin = _to_int(admins[i]) if i < len(admins) else None
speed = _to_int(speeds[i]) if i < len(speeds) else None
is_eth = (itype == 6) # ethernetCsmacd
state = ("up" if oper == 1 else
"disabled" if admin == 2 else "down")
if is_eth:
if state == "up":
up += 1
elif state == "disabled":
disabled += 1
else:
down_free += 1
ports.append({
"name": name.strip('"'),
"alias": (aliases[i].strip('"') if i < len(aliases) else ""),
"physical": is_eth,
"state": state,
"speedMbps": round(speed / 1_000_000) if speed else None,
})
return {"ok": True, "result": {
"ip": ip,
"summary": {"physical_ports": up + down_free + disabled,
"up": up, "free": down_free, "disabled": disabled},
"ports": ports,
}}
# entPhysicalTable (RFC 4133) — Modell/Serie/Firmware:
_ENT_MODEL = "1.3.6.1.2.1.47.1.1.1.1.13" # entPhysicalModelName
_ENT_SERIAL = "1.3.6.1.2.1.47.1.1.1.1.11" # entPhysicalSerialNum
_ENT_SWREV = "1.3.6.1.2.1.47.1.1.1.1.10" # entPhysicalSoftwareRev
_ENT_FWREV = "1.3.6.1.2.1.47.1.1.1.1.9" # entPhysicalFirmwareRev
def _do_snmp_info(params: dict) -> dict:
"""Geraeteinfo: sysName/sysDescr + (falls vorhanden) Modell, Seriennummer,
Firmware-/Software-Version aus der Entity-MIB. Sagt die INSTALLIERTE Version
ob ein Update existiert, weiss SNMP nicht (Hersteller-Sache)."""
ip = _snmp_target(params)
if not ip:
return {"ok": False, "error": "ip/host erforderlich."}
sysinfo = _snmp_system(ip, str(params.get("community") or ""), str(params.get("version") or ""))
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
def _first(oid):
vals = [v for v in _snmp_walk_values(ip, base, oid)
if v and "No Such" not in v]
return vals[0] if vals else None
result = {
"ip": ip,
"name": (sysinfo or {}).get("name"),
"descr": (sysinfo or {}).get("descr"),
"location": (sysinfo or {}).get("location"),
"uptime": (sysinfo or {}).get("uptime"),
"model": _first(_ENT_MODEL),
"serial": _first(_ENT_SERIAL),
"firmware": _first(_ENT_FWREV) or _first(_ENT_SWREV),
}
if not any(result[k] for k in ("name", "descr", "model", "firmware")):
return {"ok": False, "error": "Kein SNMP / keine verwertbaren Infos."}
return {"ok": True, "result": result}
# ─── FritzBox (TR-064) ─────────────────────────────────────────────
# TR-064 ist SOAP+Digest-Auth — zu fummelig fuer on-the-fly http.post, daher ein
# schlanker Reader. Braucht FritzBox-Login (Credential-Store, Typ 'fritzbox').
def _tr064(ip: str, user: str, pw: str, service: str, control: str,
action: str, args: Optional[dict] = None) -> dict:
"""Ein TR-064-SOAP-Call. Gibt {ok, fields|error}. fields = alle <NewX>-Tags."""
import requests
from requests.auth import HTTPDigestAuth
body = "".join(f"<{k}>{v}</{k}>" for k, v in (args or {}).items())
envelope = (
'<?xml version="1.0"?>'
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
f'<u:{action} xmlns:u="{service}">{body}</u:{action}>'
'</s:Body></s:Envelope>'
)
url = f"http://{ip}:49000{control}"
try:
r = requests.post(url, data=envelope.encode("utf-8"),
headers={"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": f"{service}#{action}"},
auth=HTTPDigestAuth(user, pw), timeout=HTTP_TIMEOUT_SEC)
except Exception as exc:
return {"ok": False, "error": f"TR-064 nicht erreichbar: {exc}"}
if r.status_code == 401:
return {"ok": False, "error": "TR-064 Auth fehlgeschlagen (FritzBox-Login pruefen)."}
if r.status_code != 200:
return {"ok": False, "error": f"TR-064 HTTP {r.status_code}"}
fields = {m.group(1): m.group(2) for m in
re.finditer(r"<(New[^>/]+)>(.*?)</\1>", r.text, re.DOTALL)}
return {"ok": True, "fields": fields}
def _do_fritzbox(action: str, params: dict) -> dict:
"""fritzbox.info -> Modell/Firmware/Verbindung/externe IP/Datenrate.
fritzbox.hosts -> Liste der bekannten Geraete (Name/IP/MAC/aktiv)."""
ip = _snmp_target(params)
if not ip:
return {"ok": False, "error": "ip/host erforderlich."}
fb = _creds_for(ip).get("fritzbox", {})
user = str(params.get("user") or fb.get("user") or "")
pw = str(params.get("pass") or params.get("password") or fb.get("pass") or "")
if not pw:
return {"ok": False, "error":
"Kein FritzBox-Login hinterlegt. In der Geraeteliste Credentials "
"(Typ 'fritzbox') fuer diese IP setzen."}
if action == "fritzbox.hosts":
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
"/upnp/control/hosts", "X_AVM-DE_GetHostListPath")
if not p.get("ok"):
return p
path = p["fields"].get("NewX_AVM-DE_HostListPath", "")
if not path:
return {"ok": False, "error": "FritzBox lieferte keinen Host-Listen-Pfad."}
import requests
from requests.auth import HTTPDigestAuth
try:
r = requests.get(f"http://{ip}:49000{path}",
auth=HTTPDigestAuth(user, pw), timeout=HTTP_TIMEOUT_SEC)
except Exception as exc:
return {"ok": False, "error": f"Host-Liste nicht abrufbar: {exc}"}
hosts = []
for item in re.finditer(r"<Item>(.*?)</Item>", r.text, re.DOTALL):
blk = item.group(1)
def _t(tag):
m = re.search(rf"<{tag}>(.*?)</{tag}>", blk, re.DOTALL)
return m.group(1) if m else ""
hosts.append({"name": _t("HostName"), "ip": _t("IPAddress"),
"mac": _t("MACAddress"),
"active": _t("Active") in ("1", "true")})
return {"ok": True, "result": {"ip": ip, "count": len(hosts), "hosts": hosts}}
# fritzbox.info (Default): mehrere Services, Teil-Fehler tolerieren.
info = {"ip": ip}
dev = _tr064(ip, user, pw, "urn:dslforum-org:service:DeviceInfo:1",
"/upnp/control/deviceinfo", "GetInfo")
if dev.get("ok"):
f = dev["fields"]
info.update({"model": f.get("NewModelName"), "firmware": f.get("NewSoftwareVersion"),
"serial": f.get("NewSerialNumber"), "uptime_s": _to_int(f.get("NewUpTime"))})
st = _tr064(ip, user, pw, "urn:dslforum-org:service:WANIPConnection:1",
"/upnp/control/wanipconnection1", "GetStatusInfo")
if st.get("ok"):
info["connection"] = st["fields"].get("NewConnectionStatus")
info["connection_uptime_s"] = _to_int(st["fields"].get("NewUptime"))
ext = _tr064(ip, user, pw, "urn:dslforum-org:service:WANIPConnection:1",
"/upnp/control/wanipconnection1", "GetExternalIPAddress")
if ext.get("ok"):
info["external_ip"] = ext["fields"].get("NewExternalIPAddress")
link = _tr064(ip, user, pw, "urn:dslforum-org:service:WANCommonInterfaceConfig:1",
"/upnp/control/wancommonifconfig1", "GetCommonLinkProperties")
if link.get("ok"):
f = link["fields"]
dn = _to_int(f.get("NewLayer1DownstreamMaxBitRate"))
upr = _to_int(f.get("NewLayer1UpstreamMaxBitRate"))
info["downstream_mbit"] = round(dn / 1_000_000, 1) if dn else None
info["upstream_mbit"] = round(upr / 1_000_000, 1) if upr else None
info["physical_link"] = f.get("NewPhysicalLinkStatus")
if len(info) == 1:
return {"ok": False, "error":
"FritzBox antwortet nicht auf TR-064 (Login/Rechte pruefen; TR-064 in "
"der FritzBox unter Heimnetz > Netzwerkeinstellungen aktivieren)."}
return {"ok": True, "result": info}
def _to_int(s: str):
try:
return int(str(s).strip())
except (TypeError, ValueError):
return None
def _snmp_system(ip: str, community: str = "", version: str = "") -> Optional[dict]:
"""Fragt die SNMP-System-Group eines Hosts ab (ein snmpget, alle 6 OIDs).
Gibt {descr,name,contact,location,uptime,objectid} oder None (kein SNMP).
Nutzt gespeicherte Creds fuer die IP; kurzer Timeout, keine Retries ->
Nicht-SNMP-Hosts scheitern schnell."""
params = {}
if community:
params["community"] = community
if version:
params["version"] = version
base = [*_snmp_base_args(params, ip), "-t", "1", "-r", "0"]
keys = list(SNMP_SYS_OIDS.keys())
ok, out = _snmp_run(["snmpget", "-Oqv", *base, ip, *SNMP_SYS_OIDS.values()],
SNMP_DISCOVERY_TIMEOUT)
if not ok:
return None
vals = out.splitlines()
info = {}
for k, v in zip(keys, vals):
v = (v or "").strip().strip('"')
if v and "No Such" not in v and "No more" not in v:
info[k] = v
return info or None
def _snmp_kind(descr: str) -> str:
"""Grobe Geraeteklasse aus sysDescr (fuer type im Inventar)."""
d = (descr or "").lower()
if any(k in d for k in ("switch", "catalyst", "procurve", "aruba", "powerconnect")):
return "switch"
if any(k in d for k in ("router", "mikrotik", "routeros", "edgeos", "openwrt", "pfsense", "fritz!box")):
return "router"
if any(k in d for k in ("access point", "accesspoint", "unifi", "wifi", "wlan")):
return "access-point"
if any(k in d for k in ("printer", "laserjet", "officejet", "brother", "epson", "kyocera")):
return "printer"
if any(k in d for k in ("nas", "synology", "qnap", "truenas", "diskstation")):
return "nas"
if any(k in d for k in ("ups", "usv", "smart-ups")):
return "ups"
return ""
# ─── Helpers ──────────────────────────────────────────────────────── # ─── Helpers ────────────────────────────────────────────────────────
@@ -531,13 +1062,42 @@ class Satellite:
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC) ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
arp = await loop.run_in_executor(None, _discover_arp) arp = await loop.run_in_executor(None, _discover_arp)
self._devices = _merge_devices(mdns, ssdp, arp) self._devices = _merge_devices(mdns, ssdp, arp)
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
self._devices_ts = time.time() self._devices_ts = time.time()
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)", n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
len(self._devices), len(mdns), len(ssdp), len(arp)) logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp)
finally: finally:
self._scanning = False self._scanning = False
return self._devices return self._devices
async def _enrich_snmp(self, devices: list) -> None:
"""Fragt jedes Geraet mit IP parallel per SNMP-System-Group ab und haengt
die Infos an. Verbessert Name/Typ, wenn bisher nur eine IP bekannt war."""
loop = asyncio.get_event_loop()
sem = asyncio.Semaphore(SNMP_DISCOVERY_CONCURRENCY)
async def _one(dev: dict) -> None:
ip = dev.get("ip") or ""
if not ip:
return
async with sem:
info = await loop.run_in_executor(None, _snmp_system, ip)
if not info:
return
dev["snmp"] = info
dev["snmpCapable"] = True
# Name aufwerten, wenn er bisher nur die IP/leer war.
if info.get("name") and dev.get("name", "") in ("", ip):
dev["name"] = info["name"]
# Typ aufwerten, wenn bisher generisch (host/leer).
kind = _snmp_kind(info.get("descr", ""))
if kind and dev.get("type", "") in ("", "host"):
dev["type"] = kind
await asyncio.gather(*(_one(d) for d in devices))
async def _send(self, message: dict) -> None: async def _send(self, message: dict) -> None:
if self.ws is None: if self.ws is None:
return return
@@ -597,7 +1157,12 @@ class Satellite:
params = payload.get("params") or {} params = payload.get("params") or {}
if payload.get("device") and "device" not in params: if payload.get("device") and "device" not in params:
params["device"] = payload.get("device") params["device"] = payload.get("device")
devices = await self._scan() # Geraeteliste nur scannen, wenn die Aktion sie wirklich braucht
# (dial.launch loest ein Geraet auf, oder es wurde ein device-Ref
# mitgegeben). http.get/http.post/wol arbeiten direkt mit url/mac —
# ein voller LAN-Scan davor kostete nur unnoetig viele Sekunden.
needs_devices = action == "dial.launch" or bool(params.get("device"))
devices = await self._scan() if needs_devices else self._devices
result = await _control(action, params, devices) result = await _control(action, params, devices)
await self._send({ await self._send({
"type": "sat_result", "type": "sat_result",
@@ -605,6 +1170,63 @@ class Satellite:
"timestamp": int(time.time() * 1000), "timestamp": int(time.time() * 1000),
}) })
elif mtype == "sat_creds_set":
if not self._for_me(payload):
return
ip = (payload.get("ip") or "").strip()
creds = payload.get("creds") or {}
ok = False
if ip and isinstance(creds, dict):
entry = _CREDS.setdefault(ip, {})
for t in ("snmp", "http", "fritzbox"):
if t in creds:
if creds[t]: # leeres Objekt = Typ loeschen
entry[t] = creds[t]
else:
entry.pop(t, None)
if not entry:
_CREDS.pop(ip, None)
try:
_creds_save()
ok = True
except Exception as exc:
logger.warning("[creds] speichern fehlgeschlagen: %s", exc)
await self._send({"type": "sat_creds_result",
"payload": {"requestId": payload.get("requestId", ""),
"satellite": SATELLITE_ID, "ok": ok, "ip": ip},
"timestamp": int(time.time() * 1000)})
elif mtype == "sat_creds_delete":
if not self._for_me(payload):
return
ip = (payload.get("ip") or "").strip()
ctype = (payload.get("type") or "").strip()
if ip in _CREDS:
if ctype:
_CREDS[ip].pop(ctype, None)
if not _CREDS[ip]:
_CREDS.pop(ip, None)
else:
_CREDS.pop(ip, None)
try:
_creds_save()
except Exception as exc:
logger.warning("[creds] speichern fehlgeschlagen: %s", exc)
await self._send({"type": "sat_creds_result",
"payload": {"requestId": payload.get("requestId", ""),
"satellite": SATELLITE_ID, "ok": True, "ip": ip},
"timestamp": int(time.time() * 1000)})
elif mtype == "sat_creds_list":
if not self._for_me(payload):
return
await self._send({"type": "sat_creds_list_result",
"payload": {"requestId": payload.get("requestId", ""),
"satellite": SATELLITE_ID,
"location": SATELLITE_LOCATION,
"items": _creds_public_summary()},
"timestamp": int(time.time() * 1000)})
async def _periodic_scan(self) -> None: async def _periodic_scan(self) -> None:
while True: while True:
try: try:
@@ -626,6 +1248,7 @@ class Satellite:
if not RVS_HOST or not RVS_TOKEN: if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).") logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return return
_creds_load()
asyncio.create_task(self._periodic_scan()) asyncio.create_task(self._periodic_scan())
backoff = 1 backoff = 1
while True: while True:
+43 -3
View File
@@ -1,11 +1,51 @@
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
# ARIA XTTS v2 — Konfiguration # ARIA Compute-Node — Konfiguration
# Kopieren nach .env und anpassen # Kopieren nach .env und anpassen (pro Worker-Node eine eigene .env)
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
# RVS Verbindung (gleiche Daten wie auf der ARIA-VM) # ─── Welche Dienste startet DIESER Node? ──────────
# Komma-getrennt aus: voxtral, whisper, f5tts, llm
# Nur die aufgefuehrten Dienste starten bei `docker compose up -d`.
# nur STT-Box (Voxtral) → COMPOSE_PROFILES=voxtral
# nur STT-Box (Whisper) → COMPOSE_PROFILES=whisper
# nur TTS-Box → COMPOSE_PROFILES=f5tts
# nur LLM-Box → COMPOSE_PROFILES=llm
# Kleine Karte (wenig VRAM): Whisper + F5-TTS auf EINER GPU
# → COMPOSE_PROFILES=whisper,f5tts
# All-in-One, grosse Karte → COMPOSE_PROFILES=voxtral,f5tts,llm
# Hinweis: voxtral UND whisper zusammen NICHT sinnvoll — beide sind STT und
# wuerden dieselbe Anfrage doppelt beantworten. Pro Node genau EINEN STT waehlen:
# Voxtral-3B (~9 GB, beste Qualitaet) ODER Whisper (klein, passt neben F5-TTS).
COMPOSE_PROFILES=whisper,f5tts
# ─── Node-Name ────────────────────────────────────
# Freier Name dieses Rechners. Erscheint in Diagnostic (Flotten-Anzeige) und
# in den Logs, und bildet die Instanz-ID der Dienste (z.B. f5tts@ai-box).
NODE_NAME=ai-box
# ─── GPU-Zuordnung pro Dienst ─────────────────────
# Setzt NVIDIA_VISIBLE_DEVICES fuer den jeweiligen Container.
# Einzelne Karte → "0" oder "1"; mehrere Karten → "0,1".
# Braucht das NVIDIA Container Toolkit (registriert die `nvidia`-Runtime).
# Nur die GPUs der aktiven Profile (COMPOSE_PROFILES) sind ueberhaupt relevant.
WHISPER_GPU=0 # kleines STT → passt neben F5-TTS auf EINE Karte
F5TTS_GPU=0 # TTS ist klein → gleiche Karte wie Whisper
VOXTRAL_GPU=1 # STT-3B ~9 GB → nur auf einer groesseren Karte (z.B. 12 GB)
LLM_GPU=0 # lokales LLM (teilt sich ggf. die Karte mit F5-TTS)
# ─── RVS-Verbindung ───────────────────────────────────────
# WICHTIG: Host, Port UND Token muessen EXAKT mit dem ARIA-Stack (Bridge/
# Diagnostic) uebereinstimmen — das RVS gruppiert Clients pro Server+Token in
# einen Raum. Bei abweichendem Port/Host landet die Box in einem ANDEREN Raum
# und taucht nicht in der Compute-Flotte auf.
RVS_HOST=mobil.hacker-net.de RVS_HOST=mobil.hacker-net.de
RVS_PORT=444 RVS_PORT=444
RVS_TLS=true RVS_TLS=true
RVS_TLS_FALLBACK=true RVS_TLS_FALLBACK=true
RVS_TOKEN=dein_token_hier RVS_TOKEN=dein_token_hier
# ─── Optional ─────────────────────────────────────
# HF_TOKEN= # nur falls ein HF-gated Modell (z.B. Voxtral) geladen wird
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
# WHISPER_LANGUAGE=de
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
+10 -5
View File
@@ -1,7 +1,12 @@
# ai-boxKI-Box (gpubox) Bootstrap # xttsAI-Box (Compute-Node) Setup & Bootstrap
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen Der `xtts`-Stack sind ARIAs GPU-Dienste (STT: Voxtral/Whisper · TTS: F5-TTS ·
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM). lokales LLM). Er läuft auf einer oder mehreren **AI-Boxen** — jede per `.env`
konfiguriert (`COMPOSE_PROFILES`, GPU-Zuordnung). Details zu Profilen/GPU-Wahl
stehen im Haupt-README (Abschnitt „Compute-Nodes").
`bootstrap.sh` macht aus einem frisch installierten **Debian Trixie** (headless,
nur SSH) einen startklaren GPU-Host für diesen Stack.
## Was das Script tut ## Was das Script tut
@@ -20,13 +25,13 @@ Alles **idempotent** — mehrfach ausführbar.
```bash ```bash
git clone <repo> ARIA-AGENT git clone <repo> ARIA-AGENT
cd ARIA-AGENT/ai-box cd ARIA-AGENT/xtts
sudo ./bootstrap.sh sudo ./bootstrap.sh
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal: # → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
sudo reboot sudo reboot
# … nach dem Boot: # … nach dem Boot:
cd ARIA-AGENT/ai-box cd ARIA-AGENT/xtts
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN> sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
``` ```
+2 -2
View File
@@ -4,7 +4,7 @@
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM). # GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
# #
# Ablauf nach `git clone`: # Ablauf nach `git clone`:
# cd ARIA-AGENT/ai-box # cd ARIA-AGENT/xtts
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein # sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut) # # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen # sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
@@ -122,7 +122,7 @@ EOF
ok "nvidia-driver aus backports installiert: ${NEWV}" ok "nvidia-driver aus backports installiert: ${NEWV}"
echo echo
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}" echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
echo -e "${c_y} sudo reboot && danach: cd ai-box && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}" echo -e "${c_y} sudo reboot && danach: cd xtts && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}" echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
exit 0 exit 0
else else
+101 -98
View File
@@ -1,45 +1,50 @@
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT # ARIA Compute-Node — GPU-Dienste (STT / TTS / LLM)
# Laeuft auf dem Gaming-PC (RTX 3060)
# Verbindet sich zum RVS fuer TTS/STT-Requests
# #
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root — # KEINE feste "AI-Box" mehr: dieser Stack laeuft auf beliebig vielen
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen. # Worker-Nodes. Jeder Node startet ueber COMPOSE_PROFILES nur die Dienste,
# die er anbieten soll, und pinnt sie per *_GPU auf bestimmte Grafikkarten.
# Alles verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN.
#
# Beispiele (in der jeweiligen .env):
# STT-Box → COMPOSE_PROFILES=voxtral
# TTS-Box → COMPOSE_PROFILES=f5tts
# LLM-Box → COMPOSE_PROFILES=llm
# All-in-One → COMPOSE_PROFILES=voxtral,f5tts,llm (die alte AI-Box)
#
# FLUX-Bildgenerierung liegt im /flux Verzeichnis — eigener Stack.
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
# #
# Voraussetzungen: # Voraussetzungen:
# - Docker Desktop mit WSL2 # - Docker + NVIDIA Container Toolkit (registriert die `nvidia`-Runtime)
# - NVIDIA Container Toolkit # - .env mit RVS-Verbindungsdaten, COMPOSE_PROFILES, NODE_NAME, *_GPU
# - .env mit RVS-Verbindungsdaten
# #
# Start: docker compose up -d # Start: docker compose up -d (liest COMPOSE_PROFILES aus der .env)
# ════════════════════════════════════════════════ # ════════════════════════════════════════════════
services: services:
# ─── F5-TTS Bridge (GPU) ────────────────────── # ─── F5-TTS Bridge (GPU) ──────────────────────
# Ersetzt den frueheren XTTS-Stack. Empfaengt xtts_request via RVS, # Empfaengt xtts_request via RVS, rendert via F5-TTS mit Voice-Cloning,
# rendert via F5-TTS mit Voice-Cloning, streamt PCM an die App. # streamt PCM an die App. Voice-Upload: speichert WAV und laesst eine
# Voice-Upload: speichert WAV und laesst whisper-bridge den Referenz- # STT-Bridge den Referenztext transkribieren — der User tippt nichts.
# text transkribieren — der User muss nichts eintippen.
f5tts-bridge: f5tts-bridge:
build: ./f5tts build: ./f5tts
container_name: aria-f5tts-bridge container_name: aria-f5tts-bridge
deploy: profiles: ["f5tts"] # startet nur mit COMPOSE_PROFILES=…f5tts…
resources: runtime: nvidia
reservations:
devices:
- driver: nvidia
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
capabilities: [gpu]
volumes: volumes:
- ./voices:/voices # WAV + TXT Referenz - ./voices:/voices # WAV + TXT Referenz
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount. - ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
# Direkt sichtbar im xtts/hf-cache/, # Direkt sichtbar im xtts/hf-cache/,
# einfach manuell zu loeschen, kein # einfach manuell zu loeschen, kein
# Docker-Desktop .vhdx Bloat. # Docker-Desktop .vhdx Bloat.
# Wird mit whisper-bridge geteilt. # Wird mit STT-Bridges geteilt.
environment: environment:
# GPU-Wahl: NVIDIA_VISIBLE_DEVICES (mehrere via "0,1"). Default GPU 0.
- NVIDIA_VISIBLE_DEVICES=${F5TTS_GPU:-0}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node} # erscheint in Diagnostic + Logs
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength, # Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config. # nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
- RVS_HOST=${RVS_HOST} - RVS_HOST=${RVS_HOST}
@@ -51,27 +56,22 @@ services:
- VOICES_DIR=/voices - VOICES_DIR=/voices
restart: unless-stopped restart: unless-stopped
# ─── Whisper STT (GPU) ──────────────────────── # ─── Whisper STT (GPU) — opt-in Fallback-STT ──
# Faster-Whisper auf der Gamebox statt auf der VM (CPU) — # Faster-Whisper auf CUDA. Verbindet sich selbst per WebSocket an den RVS
# deutlich schneller. Verbindet sich selbst per WebSocket an # und nimmt stt_request / stt_stream_* Nachrichten entgegen. Zusaetzlich
# den RVS und nimmt dort stt_request Nachrichten der aria-bridge # nutzt die f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
# entgegen, antwortet mit stt_response. Zusaetzlich nutzt die # Voice-Uploads. Modell-Hot-Swap via Diagnostic (config-Broadcast).
# f5tts-bridge Whisper intern fuer die Referenz-Transkription bei # Nur EINEN STT-Provider pro Node laufen lassen (voxtral ODER whisper) —
# Voice-Uploads. Laedt das Modell beim Start vor; auf Config- # sonst beantworten beide dieselbe Anfrage doppelt.
# Broadcasts (Diagnostic → whisperModel) wird zur Laufzeit hot-
# swapped.
whisper-bridge: whisper-bridge:
build: ./whisper build: ./whisper
container_name: aria-whisper-bridge container_name: aria-whisper-bridge
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper profiles: ["whisper"] # startet nur mit COMPOSE_PROFILES=…whisper
deploy: runtime: nvidia
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
capabilities: [gpu]
environment: environment:
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST} - RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443} - RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true} - RVS_TLS=${RVS_TLS:-true}
@@ -91,74 +91,22 @@ services:
# Container-Restarts. # Container-Restarts.
restart: unless-stopped 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
device_ids: ["0"] # LLM → GPU 0 (8 GB, teilt sich mit TTS)
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
# ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ───────── # ─── Voxtral STT-3B (Transformers, GPU) — DEFAULT-STT ─────────
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN # Voxtral-Mini-3B-2507 (~9 GB bf16). Laeuft auf Treiber 550/CUDA 12.4
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1. # (torch cu124, KEIN Treiber-Upgrade noetig). Whisper ist der opt-in
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback # Fallback — immer nur EINEN STT-Provider pro Node aktiv haben.
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
# immer nur EINEN STT laufen lassen.
voxtral-bridge: voxtral-bridge:
build: ./voxtral build: ./voxtral
container_name: aria-voxtral-bridge container_name: aria-voxtral-bridge
deploy: profiles: ["voxtral"] # startet nur mit COMPOSE_PROFILES=…voxtral…
resources: runtime: nvidia
reservations:
devices:
- driver: nvidia
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
capabilities: [gpu]
volumes: volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5 - ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper) - ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
environment: environment:
- NVIDIA_VISIBLE_DEVICES=${VOXTRAL_GPU:-1} # STT-3B ~9 GB → 12-GB-Karte
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST} - RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443} - RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true} - RVS_TLS=${RVS_TLS:-true}
@@ -169,3 +117,58 @@ services:
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist - HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung - PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
restart: unless-stopped restart: unless-stopped
# ─── Lokales LLM — llama-swap (GPU) ───────────
# llama-swap laedt/swappt mehrere Modelle on-demand. Welches geladen wird,
# bestimmt das `model`-Feld im Request (Brain schickt es aus local_llm.json).
# Erster Load 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. Die Modell-Liste erzeugt der llm-adapter dynamisch aus
# ./llama-swap/config.yaml (Basis) + Registry → /models/llama-swap.config.yaml.
llama-swap:
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
container_name: aria-llama-swap
profiles: ["llm"] # startet nur mit COMPOSE_PROFILES=…llm…
runtime: nvidia
volumes:
- ./models:/models # HF-Cache + generierte Config
environment:
- NVIDIA_VISIBLE_DEVICES=${LLM_GPU:-0}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- LLAMA_CACHE=/models # llama-server legt -hf-Downloads hier ab
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
restart: unless-stopped
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
# Verbindet sich per Token an den RVS (wie f5tts/whisper), nimmt llm_request
# entgegen, ruft llama.cpp lokal, antwortet llm_response. Verwaltet ausserdem
# llama-swaps Config (Modelle hinzufuegen/entfernen via llm_provision_model).
llm-adapter:
build: ./llm-adapter
container_name: aria-llm-adapter
profiles: ["llm"]
runtime: nvidia # nur fuer nvidia-smi (Auslastungs-Monitor) — kein Compute
depends_on:
- llama-swap
volumes:
- ./models:/models # generierte Config + Registry + Cache
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
environment:
- NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi)
- NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute
- NODE_NAME=${NODE_NAME:-node}
- 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}
- LLAMA_BASE_CONFIG=/llamaswap/config.yaml
- LLAMA_GEN_CONFIG=/models/llama-swap.config.yaml
- LLM_REGISTRY=/models/aria_models.json
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
+1
View File
@@ -20,6 +20,7 @@ COPY requirements.txt .
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \ RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
COPY node_stats.py .
COPY bridge.py . COPY bridge.py .
CMD ["python3", "bridge.py"] CMD ["python3", "bridge.py"]
+87 -8
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ARIA F5-TTS Bridge laeuft auf der Gamebox (RTX 3060). ARIA F5-TTS Bridge laeuft auf der AI-Box (RTX 3060).
Empfaengt xtts_request via RVS F5-TTS Voice Cloning auf GPU streamt Empfaengt xtts_request via RVS F5-TTS Voice Cloning auf GPU streamt
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App. 16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
@@ -60,6 +60,24 @@ RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep). # f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
WORKER_SERVICE = "f5tts"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
_tts_busy = False # True waehrend eine Synthese laeuft (busy-Report im ping)
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
import node_stats
STATS_PATH = os.getenv("STATS_PATH", f"/root/.cache/huggingface/aria_stats_{WORKER_SERVICE}.json")
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH, logger=logger)
DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base" DEFAULT_F5TTS_MODEL = "F5TTS_v1_Base"
DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF DEFAULT_F5TTS_CKPT_FILE = "" # leer = Default-Checkpoint von HF
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
@@ -378,7 +396,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# DEBUG-LOG ueber RVS → /shared/logs/app.log # DEBUG-LOG ueber RVS → /shared/logs/app.log
# #
# Gleiches Pattern wie in whisper-bridge: Stefan's Gamebox ist # Gleiches Pattern wie in whisper-bridge: Stefan's AI-Box ist
# Windows (kein SSH), in Zukunft koennten whisper + f5tts auf # Windows (kein SSH), in Zukunft koennten whisper + f5tts auf
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad. # unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
# #
@@ -460,13 +478,16 @@ _tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
async def _tts_worker(ws, runner: F5Runner) -> None: async def _tts_worker(ws, runner: F5Runner) -> None:
"""Serialisiert Synthesen — GPU kann sonst OOM gehen.""" """Serialisiert Synthesen — GPU kann sonst OOM gehen."""
global _tts_busy
while True: while True:
text, voice, request_id, message_id, language, speed = await _tts_queue.get() text, voice, request_id, message_id, language, speed = await _tts_queue.get()
_tts_busy = True
try: try:
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed) await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
except Exception: except Exception:
logger.exception("TTS-Worker Fehler") logger.exception("TTS-Worker Fehler")
finally: finally:
_tts_busy = False
_tts_queue.task_done() _tts_queue.task_done()
@@ -663,6 +684,18 @@ async def handle_voice_upload(ws, payload: dict) -> None:
await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]}) await _send(ws, "xtts_voice_saved", {"name": name, "error": str(e)[:200]})
def _local_voice_names() -> list:
"""Namen der lokal vorhandenen Nutzer-Stimmen (ohne den Box-lokalen
default_ref-Fallback) fuer die Provisioning-Reconciliation im worker_hello."""
names = []
if VOICES_DIR.exists():
for wav in sorted(VOICES_DIR.glob("*.wav")):
if wav.stem == "default_ref":
continue
names.append(wav.stem)
return names
async def handle_list_voices(ws) -> None: async def handle_list_voices(ws) -> None:
try: try:
voices = [] voices = []
@@ -699,13 +732,16 @@ async def handle_delete_voice(ws, payload: dict) -> None:
async def handle_export_voice(ws, payload: dict) -> None: async def handle_export_voice(ws, payload: dict) -> None:
"""Packt eine Stimme (.wav + .txt) als tar.gz und sendet sie base64 zurueck.""" """Packt eine Stimme (.wav + .txt) als tar.gz und sendet sie base64 zurueck."""
name = (payload.get("name") or "").strip() name = (payload.get("name") or "").strip()
# requestId aus dem Request zuruueckspiegeln — der Diagnostic-Bibliothekar
# korreliert damit zentrale Exports gegen browser-initiierte.
req_id = payload.get("requestId", "") or ""
if not name: if not name:
await _send(ws, "xtts_voice_exported", {"ok": False, "error": "name fehlt"}) await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "error": "name fehlt"})
return return
try: try:
wav, txt = voice_paths(name) wav, txt = voice_paths(name)
if not wav.exists(): if not wav.exists():
await _send(ws, "xtts_voice_exported", {"ok": False, "name": name, "error": "Stimme nicht gefunden"}) await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "name": name, "error": "Stimme nicht gefunden"})
return return
import io, tarfile import io, tarfile
buf = io.BytesIO() buf = io.BytesIO()
@@ -715,10 +751,10 @@ async def handle_export_voice(ws, payload: dict) -> None:
tar.add(txt, arcname=txt.name) tar.add(txt, arcname=txt.name)
data = base64.b64encode(buf.getvalue()).decode("ascii") data = base64.b64encode(buf.getvalue()).decode("ascii")
logger.info("Voice exportiert: %s (%d KB tar.gz)", name, len(buf.getvalue()) // 1024) logger.info("Voice exportiert: %s (%d KB tar.gz)", name, len(buf.getvalue()) // 1024)
await _send(ws, "xtts_voice_exported", {"ok": True, "name": name, "data": data}) await _send(ws, "xtts_voice_exported", {"ok": True, "requestId": req_id, "name": name, "data": data})
except Exception as e: except Exception as e:
logger.exception("handle_export_voice Fehler") logger.exception("handle_export_voice Fehler")
await _send(ws, "xtts_voice_exported", {"ok": False, "name": name, "error": str(e)[:200]}) await _send(ws, "xtts_voice_exported", {"ok": False, "requestId": req_id, "name": name, "error": str(e)[:200]})
async def handle_import_voice(ws, payload: dict) -> None: async def handle_import_voice(ws, payload: dict) -> None:
@@ -808,6 +844,31 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
await _send(ws, "service_status", payload) await _send(ws, "service_status", payload)
async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
Flotten-Registry per periodischem worker_ping frisch. worker_hello wird
zusaetzlich alle ~30s WIEDERHOLT (wie der Satellit sat_hello), damit auch ein
neu gestartetes Diagnostic/Bridge uns lernt RVS spielt hellos nicht nach."""
def _hello():
return {"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": model,
"voices": _local_voice_names()}
try:
await _send(ws, "worker_hello", _hello())
n = 0
while True:
await asyncio.sleep(WORKER_PING_INTERVAL_S)
n += 1
busy = bool(busy_fn()) if busy_fn else False
await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": busy})
if n % 3 == 0:
await _send(ws, "worker_hello", _hello())
except asyncio.CancelledError:
raise
except Exception:
return # Socket tot → still beenden; run_loop reconnectet + startet neu
async def run_loop(runner: F5Runner) -> None: async def run_loop(runner: F5Runner) -> None:
use_tls = RVS_TLS use_tls = RVS_TLS
retry_s = 2 retry_s = 2
@@ -816,7 +877,7 @@ async def run_loop(runner: F5Runner) -> None:
while True: while True:
scheme = "wss" if use_tls else "ws" scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}" url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try: try:
@@ -855,15 +916,31 @@ async def run_loop(runner: F5Runner) -> None:
# TTS-Worker fuer diese Verbindung starten # TTS-Worker fuer diese Verbindung starten
worker = asyncio.create_task(_tts_worker(ws, runner)) worker = asyncio.create_task(_tts_worker(ws, runner))
ping_task = asyncio.create_task(_worker_register(
ws, model=runner.model_id,
busy_fn=lambda: _tts_busy or not _tts_queue.empty()))
try: try:
async for raw in ws: while True:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
except asyncio.TimeoutError:
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
raise ConnectionError("rvs-stale")
try: try:
msg = json.loads(raw) msg = json.loads(raw)
except Exception: except Exception:
continue continue
mtype = msg.get("type", "") mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {} payload = msg.get("payload", {}) or {}
# Redundanz-Routing: gezielt an eine andere Instanz
# adressiert → ignorieren. Ohne targetInstance → wie bisher.
tgt = payload.get("targetInstance")
if tgt and tgt != INSTANCE_ID:
continue
# Auslastungs-Monitor (node_stats_*) abfangen.
if await _stats.handle(ws, mtype, payload, _send):
continue
if mtype == "xtts_request": if mtype == "xtts_request":
try: try:
@@ -958,6 +1035,7 @@ async def run_loop(runner: F5Runner) -> None:
_last_diag_voice = "" _last_diag_voice = ""
finally: finally:
worker.cancel() worker.cancel()
ping_task.cancel()
try: try:
await worker await worker
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -985,6 +1063,7 @@ async def main() -> None:
sys.exit(1) sys.exit(1)
VOICES_DIR.mkdir(parents=True, exist_ok=True) VOICES_DIR.mkdir(parents=True, exist_ok=True)
runner = F5Runner() runner = F5Runner()
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
await run_loop(runner) await run_loop(runner)
+167
View File
@@ -0,0 +1,167 @@
"""
ARIA Node-Stats Auslastungs-Monitor pro Box (GPU + optional Tokens).
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
weil jeder Worker ein eigener Docker-Build-Context ist.
Aufgaben:
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta Ringpuffer
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
senden (bis stop / Auto-Timeout).
- History-Request + Reset (Besen).
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
"""
import asyncio
import json
import os
import time
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
async def _run_cmd(*args, timeout=8) -> str:
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (out or b"").decode("utf-8", "replace")
except Exception:
return ""
class NodeStats:
def __init__(self, instance_id: str, node_name: str, history_path: str,
token_getter=None, logger=None):
self.instance_id = instance_id
self.node_name = node_name
self.history_path = history_path
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
self.log = logger
self.samples = self._load()
self._last_tokens = self._tokens_now()
self._stream_task = None
# ── Persistenz ──────────────────────────────────────────
def _load(self) -> list:
try:
with open(self.history_path) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def _persist(self) -> None:
try:
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
tmp = self.history_path + ".tmp"
with open(tmp, "w") as f:
json.dump(self.samples[-HISTORY_CAP:], f)
os.replace(tmp, self.history_path)
except Exception:
pass
def _tokens_now(self) -> int:
try:
return int(self.token_getter()) if self.token_getter else 0
except Exception:
return 0
# ── nvidia-smi ──────────────────────────────────────────
async def _query_gpu(self) -> dict:
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits")
utils, used, total = [], 0, 0
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
utils.append(float(parts[0]))
used += float(parts[1])
total += float(parts[2])
except ValueError:
continue
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
# ── Sampler (Verlauf) ───────────────────────────────────
async def run_sampler(self) -> None:
while True:
try:
g = await self._query_gpu()
now_tok = self._tokens_now()
dtok = max(0, now_tok - self._last_tokens)
self._last_tokens = now_tok
self.samples.append({
"ts": int(time.time()),
"gpu": g["gpu"], "memUsed": g["memUsed"],
"memTotal": g["memTotal"], "tokens": dtok,
})
if len(self.samples) > HISTORY_CAP:
self.samples = self.samples[-HISTORY_CAP:]
self._persist()
except Exception as e:
if self.log:
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
await asyncio.sleep(SAMPLE_SEC)
# ── Live-Stream ─────────────────────────────────────────
async def _stream(self, ws, send) -> None:
t0 = time.time()
try:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
raise
except Exception:
return
# ── Dispatch ────────────────────────────────────────────
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
if mtype == "node_stats_stream_start":
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
self._stream_task = asyncio.create_task(self._stream(ws, send))
return True
if mtype == "node_stats_stream_stop":
if self._stream_task:
self._stream_task.cancel()
self._stream_task = None
return True
if mtype == "node_stats_history_request":
await send(ws, "node_stats_history", {
"instanceId": self.instance_id, "node": self.node_name,
"samples": self.samples[-HISTORY_CAP:],
"tokenCapable": self.token_getter is not None,
"sampleSec": SAMPLE_SEC,
})
return True
if mtype == "node_stats_reset":
self.samples = []
self._persist()
await send(ws, "node_stats_reset_done",
{"instanceId": self.instance_id, "node": self.node_name})
return True
return False
+1
View File
@@ -3,6 +3,7 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY node_stats.py .
COPY adapter.py . COPY adapter.py .
CMD ["python", "-u", "adapter.py"] CMD ["python", "-u", "adapter.py"]
+4 -4
View File
@@ -1,6 +1,6 @@
# Local-LLM-Adapter (Gamebox) — Plan B, Phase B0 # Local-LLM-Adapter (AI-Box) — Plan B, Phase B0
Bringt ein lokales, schnelles LLM (Qwen3 8B) auf die Gamebox und haengt es Bringt ein lokales, schnelles LLM (Qwen3 8B) auf die AI-Box und haengt es
per RVS an ARIA — fuer die einfachen ~80 % der Turns (<1 s), waehrend Claude 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. das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
@@ -17,7 +17,7 @@ das Tiefen-Hirn bleibt. Siehe `docs/plan-local-llm-router.md` im Repo-Root.
cached es unter `xtts/models/` (Bind-Mount → kein Re-Download bei Restart). 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`. 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): Modell/Quant wechseln = in der `.env` der AI-Box setzen (kein Code):
``` ```
LLM_HF_REPO=Qwen/Qwen3-8B-GGUF # HF-Repo LLM_HF_REPO=Qwen/Qwen3-8B-GGUF # HF-Repo
@@ -35,7 +35,7 @@ umstellen + Container neu — Ein-Zeilen-Wechsel, kein Code.
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
`docs/plan-local-llm-router.md`. `docs/plan-local-llm-router.md`.
## Start (auf der Gamebox) ## Start (auf der AI-Box)
```bash ```bash
cd xtts cd xtts
+260 -10
View File
@@ -1,5 +1,5 @@
""" """
ARIA Local-LLM-Adapter (Gamebox) Plan B, Phase B0. ARIA Local-LLM-Adapter (AI-Box) Plan B, Phase B0.
Bruecke zwischen RVS und dem lokalen llama.cpp-Server. Spiegelt das Muster der 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 whisper-bridge: verbindet sich per WebSocket mit dem RVS (Token-Room, TLS mit
@@ -7,7 +7,7 @@ ws-Fallback, Reconnect-Backoff), lauscht auf `llm_request` und ruft den lokalen
llama.cpp-`/v1/chat/completions`-Endpoint (OpenAI-kompatibel), antwortet mit llama.cpp-`/v1/chat/completions`-Endpoint (OpenAI-kompatibel), antwortet mit
`llm_response` (korreliert per requestId). `llm_response` (korreliert per requestId).
Topologie: Gamebox steht zuhause, ARIA im RZ die Kommunikation laeuft ueber Topologie: AI-Box steht zuhause, ARIA im RZ die Kommunikation laeuft ueber
den RVS (wie TTS/STT), keine IPs zu pflegen. Nur URL + Token. den RVS (wie TTS/STT), keine IPs zu pflegen. Nur URL + Token.
Env: Env:
@@ -47,6 +47,19 @@ RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/") LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b") LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60")) LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
WORKER_SERVICE = "llm"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
_inflight = 0 # laufende llm_requests (busy-Report im ping)
# Qwen3 hat Thinking-Mode default AN — dann verbraet es Tokens in einem # Qwen3 hat Thinking-Mode default AN — dann verbraet es Tokens in einem
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen # <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln # content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
@@ -56,6 +69,94 @@ LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen. # empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true" LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() == "true"
# ── Modell-Verwaltung (Stage D): Adapter besitzt llama-swaps Config ──
# llama-swap liest die GENERIERTE Config (beschreibbar, im /models-Bind). Wir
# erzeugen sie aus dem Basis-Template (kuratierte Defaults) + der persistenten
# Box-Registry (per Diagnostic hinzugefuegte Modelle). So werden neue Modelle
# ohne Image-Rebuild waehlbar.
import yaml # pyyaml
BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
import node_stats
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
_total_tokens = 0 # kumulativ, fuer den Token-Graph
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH,
token_getter=lambda: _total_tokens, logger=logger)
def _load_registry() -> list:
try:
with open(REGISTRY_PATH) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def _save_registry(reg: list) -> None:
try:
tmp = REGISTRY_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(reg, f, indent=2)
os.replace(tmp, REGISTRY_PATH)
except Exception as e:
logger.warning("Registry speichern fehlgeschlagen: %s", e)
def _generate_config() -> int:
"""Schreibt die llama-swap-Config aus Basis-Template + Registry. Gibt die
Anzahl Modelle zurueck. Idempotent, bei jeder Aenderung + beim Start."""
base = {}
try:
with open(BASE_CONFIG_PATH) as f:
base = yaml.safe_load(f) or {}
except Exception as e:
logger.warning("Basis-Template %s nicht lesbar (%s)", BASE_CONFIG_PATH, e)
models = dict(base.get("models") or {})
for e in _load_registry():
key = (e.get("key") or "").strip()
repo = (e.get("hfRepo") or "").strip()
if not key or not repo:
continue
quant = (e.get("quant") or "Q4_K_M").strip()
ctx = int(e.get("ctx") or 8192)
ngl = int(e.get("ngl") or 99)
models[key] = {
"cmd": (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
"ttl": 3600,
}
out = dict(base)
out["models"] = models
try:
os.makedirs(os.path.dirname(GEN_CONFIG_PATH), exist_ok=True)
tmp = GEN_CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
yaml.safe_dump(out, f, sort_keys=False, default_flow_style=False)
os.replace(tmp, GEN_CONFIG_PATH)
logger.info("llama-swap-Config generiert: %d Modelle → %s", len(models), GEN_CONFIG_PATH)
except Exception as e:
logger.error("Config schreiben fehlgeschlagen: %s", e)
return len(models)
async def _reload_llama() -> None:
"""Stoesst llama-swap-Reload an. Viele Builds watchen die Config-Datei ohnehin;
zusaetzlich versuchen wir bekannte Reload-Endpunkte (Fehler ignoriert)."""
for path in ("/api/config/reload", "/reload"):
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.post(f"{LLAMA_URL}{path}")
if r.status_code < 400:
logger.info("llama-swap reload via %s", path)
return
except Exception:
pass
logger.info("llama-swap reload: kein Endpoint — verlasse mich auf File-Watch")
async def _send(ws, mtype: str, payload: dict) -> None: async def _send(ws, mtype: str, payload: dict) -> None:
try: try:
@@ -99,11 +200,17 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
msg = (data.get("choices") or [{}])[0].get("message", {}) or {} msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
usage = data.get("usage") or {}
try:
global _total_tokens
_total_tokens += int(usage.get("total_tokens") or 0)
except Exception:
pass
return { return {
"ok": True, "ok": True,
"content": msg.get("content") or "", "content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or None, "tool_calls": msg.get("tool_calls") or None,
"usage": data.get("usage"), "usage": usage,
} }
except Exception as e: except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e) logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
@@ -124,7 +231,63 @@ async def _emit_llm_status(ws, state: str, model: str, **extra) -> None:
{"service": "llm", "state": state, "model": model, **extra}) {"service": "llm", "state": state, "model": model, **extra})
async def _fetch_available_models() -> list:
"""Fragt llama-swap ab, welche Modelle diese Box fahren kann (GET /v1/models,
OpenAI-kompatibel {data:[{id},...]}). Das sind die config.yaml-Keys.
Defensiv: bei Fehler Fallback auf [LLM_MODEL]."""
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{LLAMA_URL}/v1/models")
r.raise_for_status()
data = r.json()
ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
return ids or [LLM_MODEL]
except Exception as e:
logger.warning("llama-swap /v1/models nicht abfragbar (%s) — Fallback [%s]", e, LLM_MODEL)
return [LLM_MODEL]
async def _announce(ws) -> None:
"""Sendet ein frisches worker_hello mit der aktuellen Modell-Liste (nach
Provision/Remove aufrufen, damit Bridge+Diagnostic das neue Modell lernen)."""
models = await _fetch_available_models()
await _send(ws, "worker_hello", {
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
})
logger.info("worker_hello: models=%s", models)
async def _worker_register(ws) -> None:
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
Flotten-Registry per periodischem worker_ping (mit busy-Status) frisch."""
try:
await _announce(ws)
n = 0
while True:
await asyncio.sleep(WORKER_PING_INTERVAL_S)
n += 1
await _send(ws, "worker_ping",
{"instanceId": INSTANCE_ID, "busy": _inflight > 0})
if n % 3 == 0: # ~30s worker_hello wiederholen (wie der Satellit) →
await _announce(ws) # auch neu gestartetes Diagnostic/Bridge lernt uns
except asyncio.CancelledError:
raise
except Exception:
return # Socket tot → still beenden; _run reconnectet + startet neu
async def _handle_llm_request(ws, payload: dict) -> None: async def _handle_llm_request(ws, payload: dict) -> None:
global _last_model, _inflight
_inflight += 1
try:
await _do_llm_request(ws, payload)
finally:
_inflight -= 1
async def _do_llm_request(ws, payload: dict) -> None:
global _last_model global _last_model
req_id = payload.get("requestId", "") req_id = payload.get("requestId", "")
messages = payload.get("messages") or [] messages = payload.get("messages") or []
@@ -177,6 +340,61 @@ async def _handle_llm_request(ws, payload: dict) -> None:
}) })
async def _handle_provision(ws, payload: dict) -> None:
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
key = (payload.get("key") or "").strip()
repo = (payload.get("hfRepo") or "").strip()
if not key or not repo:
await _send(ws, "llm_provision_result",
{"instanceId": INSTANCE_ID, "key": key, "ok": False, "error": "key/hfRepo fehlt"})
return
entry = {
"key": key, "hfRepo": repo,
"quant": (payload.get("quant") or "Q4_K_M").strip(),
"ctx": int(payload.get("ctx") or 8192),
"ngl": int(payload.get("ngl") or 99),
}
reg = [e for e in _load_registry() if e.get("key") != key]
reg.append(entry)
_save_registry(reg)
_generate_config()
await _reload_llama()
await _announce(ws) # Bridge/Diagnostic lernen das neue Modell
# Warmup: Mini-Request → llama-swap laedt/zieht das Modell (Fortschritt via
# service_status loading→ready, freshlyDownloaded).
await _emit_llm_status(ws, "loading", key)
t0 = time.time()
res = await _call_llama([{"role": "user", "content": "hi"}],
max_tokens=1, temperature=0.0, stop=None, model=key)
dt = time.time() - t0
if res.get("ok"):
_ready_models.add(key)
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1),
freshlyDownloaded=dt > 25)
else:
await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160])
await _send(ws, "llm_provision_result",
{"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False),
"error": res.get("error"), "elapsedMs": int(dt * 1000)})
logger.info("provision %s (%s) → ok=%s %.1fs", key, repo, res.get("ok"), dt)
async def _handle_remove(ws, payload: dict) -> None:
"""Entfernt ein Modell aus Registry+Config (GGUF bleibt im Cache)."""
key = (payload.get("key") or "").strip()
if not key:
return
reg = [e for e in _load_registry() if e.get("key") != key]
_save_registry(reg)
_generate_config()
await _reload_llama()
await _announce(ws)
await _send(ws, "llm_provision_result",
{"instanceId": INSTANCE_ID, "key": key, "ok": True, "removed": True})
logger.info("removed model %s", key)
async def _run() -> None: async def _run() -> None:
if not RVS_HOST: if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch") logger.error("RVS_HOST nicht gesetzt — Abbruch")
@@ -185,13 +403,21 @@ async def _run() -> None:
logger.error("RVS_TOKEN nicht gesetzt — Abbruch") logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
return return
# llama-swap-Config aus Basis-Template + Registry erzeugen, BEVOR llama-swap
# sie braucht (llama-swap restart: unless-stopped faengt die Erst-Boot-
# Reihenfolge ab, falls es kurz vor uns startet).
_generate_config()
# Auslastungs-Sampler (GPU + Tokens) laeuft unabhaengig vom RVS.
asyncio.create_task(_stats.run_sampler())
use_tls = RVS_TLS use_tls = RVS_TLS
retry_s = 2 retry_s = 2
tls_fallback_tried = False tls_fallback_tried = False
while True: while True:
scheme = "wss" if use_tls else "ws" scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}" url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try: try:
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL) logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
@@ -201,19 +427,43 @@ async def _run() -> None:
logger.info("RVS verbunden — llm-adapter online") logger.info("RVS verbunden — llm-adapter online")
retry_s = 2 retry_s = 2
tls_fallback_tried = False tls_fallback_tried = False
async for raw in ws: ping_task = asyncio.create_task(_worker_register(ws))
while True:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
except asyncio.TimeoutError:
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
raise ConnectionError("rvs-stale")
try: try:
msg = json.loads(raw) msg = json.loads(raw)
except Exception: except Exception:
continue continue
if msg.get("type") != "llm_request": mtype = msg.get("type")
continue
payload = msg.get("payload", {}) or {} payload = msg.get("payload", {}) or {}
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern, # Redundanz-Routing: gezielt an eine andere Instanz adressiert
# aber wir blockieren so nicht den Empfang weiterer Messages. # → ignorieren. Ohne targetInstance → wie bisher (jeder nimmt).
asyncio.create_task(_handle_llm_request(ws, payload)) tgt = payload.get("targetInstance")
if tgt and tgt != INSTANCE_ID:
continue
# Auslastungs-Monitor (node_stats_*) abfangen.
if await _stats.handle(ws, mtype, payload, _send):
continue
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
continue
if mtype == "llm_provision_model":
asyncio.create_task(_handle_provision(ws, payload))
elif mtype == "llm_remove_model":
asyncio.create_task(_handle_remove(ws, payload))
else:
# 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: except Exception as e:
logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e) logger.warning("RVS-Verbindung verloren/fehlgeschlagen: %s", e)
try:
ping_task.cancel()
except NameError:
pass
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
tls_fallback_tried = True tls_fallback_tried = True
use_tls = False use_tls = False
+167
View File
@@ -0,0 +1,167 @@
"""
ARIA Node-Stats Auslastungs-Monitor pro Box (GPU + optional Tokens).
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
weil jeder Worker ein eigener Docker-Build-Context ist.
Aufgaben:
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta Ringpuffer
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
senden (bis stop / Auto-Timeout).
- History-Request + Reset (Besen).
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
"""
import asyncio
import json
import os
import time
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
async def _run_cmd(*args, timeout=8) -> str:
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (out or b"").decode("utf-8", "replace")
except Exception:
return ""
class NodeStats:
def __init__(self, instance_id: str, node_name: str, history_path: str,
token_getter=None, logger=None):
self.instance_id = instance_id
self.node_name = node_name
self.history_path = history_path
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
self.log = logger
self.samples = self._load()
self._last_tokens = self._tokens_now()
self._stream_task = None
# ── Persistenz ──────────────────────────────────────────
def _load(self) -> list:
try:
with open(self.history_path) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def _persist(self) -> None:
try:
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
tmp = self.history_path + ".tmp"
with open(tmp, "w") as f:
json.dump(self.samples[-HISTORY_CAP:], f)
os.replace(tmp, self.history_path)
except Exception:
pass
def _tokens_now(self) -> int:
try:
return int(self.token_getter()) if self.token_getter else 0
except Exception:
return 0
# ── nvidia-smi ──────────────────────────────────────────
async def _query_gpu(self) -> dict:
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits")
utils, used, total = [], 0, 0
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
utils.append(float(parts[0]))
used += float(parts[1])
total += float(parts[2])
except ValueError:
continue
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
# ── Sampler (Verlauf) ───────────────────────────────────
async def run_sampler(self) -> None:
while True:
try:
g = await self._query_gpu()
now_tok = self._tokens_now()
dtok = max(0, now_tok - self._last_tokens)
self._last_tokens = now_tok
self.samples.append({
"ts": int(time.time()),
"gpu": g["gpu"], "memUsed": g["memUsed"],
"memTotal": g["memTotal"], "tokens": dtok,
})
if len(self.samples) > HISTORY_CAP:
self.samples = self.samples[-HISTORY_CAP:]
self._persist()
except Exception as e:
if self.log:
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
await asyncio.sleep(SAMPLE_SEC)
# ── Live-Stream ─────────────────────────────────────────
async def _stream(self, ws, send) -> None:
t0 = time.time()
try:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
raise
except Exception:
return
# ── Dispatch ────────────────────────────────────────────
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
if mtype == "node_stats_stream_start":
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
self._stream_task = asyncio.create_task(self._stream(ws, send))
return True
if mtype == "node_stats_stream_stop":
if self._stream_task:
self._stream_task.cancel()
self._stream_task = None
return True
if mtype == "node_stats_history_request":
await send(ws, "node_stats_history", {
"instanceId": self.instance_id, "node": self.node_name,
"samples": self.samples[-HISTORY_CAP:],
"tokenCapable": self.token_getter is not None,
"sampleSec": SAMPLE_SEC,
})
return True
if mtype == "node_stats_reset":
self.samples = []
self._persist()
await send(ws, "node_stats_reset_done",
{"instanceId": self.instance_id, "node": self.node_name})
return True
return False
+1
View File
@@ -1,2 +1,3 @@
websockets>=12.0 websockets>=12.0
httpx>=0.27.0 httpx>=0.27.0
pyyaml>=6.0
+1 -1
View File
@@ -21,6 +21,6 @@ COPY requirements.txt .
RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \ RUN printf 'torch==2.6.0\ntorchaudio==2.6.0\n' > /tmp/torch-constraint.txt && \
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
COPY bridge.py speaker_id.py ./ COPY bridge.py speaker_id.py node_stats.py ./
CMD ["python3", "bridge.py"] CMD ["python3", "bridge.py"]
+115 -2
View File
@@ -58,6 +58,25 @@ VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de") VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda") VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
# Jeder Node meldet sich bei der aria-bridge (worker_hello) und haelt die
# Registry per periodischem worker_ping frisch. INSTANCE_ID adressiert diesen
# Worker bei Redundanz (targetInstance-Routing, Stage 3).
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
WORKER_SERVICE = "voxtral"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
import node_stats
STATS_PATH = os.getenv("STATS_PATH", f"/root/.cache/huggingface/aria_stats_{WORKER_SERVICE}.json")
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH, logger=logger)
STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000")) STREAM_TRANSCRIBE_INTERVAL_MS = int(os.getenv("STREAM_TRANSCRIBE_INTERVAL_MS", "1000"))
STREAM_DEFAULT_ENDPOINT_MS = 2400 STREAM_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 300000 STREAM_DEFAULT_HARD_CAP_MS = 300000
@@ -126,6 +145,11 @@ SILERO_VAD_ENABLED = os.getenv("SILERO_VAD_ENABLED", "true").lower() in ("1", "t
SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5")) SILERO_VAD_THRESHOLD = float(os.getenv("SILERO_VAD_THRESHOLD", "0.5"))
SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150")) SILERO_MIN_SPEECH_MS = int(os.getenv("SILERO_MIN_SPEECH_MS", "150"))
SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200")) SILERO_PAD_MS = int(os.getenv("SILERO_PAD_MS", "200"))
# Speech-Endpoint gegen laute Umgebungsmusik: der RMS-Stille-Endpoint feuert bei
# durchgehender Musik NIE (Energie bleibt oben). Deshalb waehrend lauter Phasen
# periodisch (alle X ms) mit Silero pruefen, ob im letzten endpoint_ms-Fenster
# ueberhaupt noch Sprache ist — wenn nicht (nur Musik/Stille), Turn beenden.
STREAM_SPEECH_ENDPOINT_CHECK_MS = int(os.getenv("STREAM_SPEECH_ENDPOINT_CHECK_MS", "700"))
_vad_state = {"model": None, "get_ts": None, "failed": False} _vad_state = {"model": None, "get_ts": None, "failed": False}
@@ -281,6 +305,7 @@ class StreamSession:
last_growth_at: float = 0.0 last_growth_at: float = 0.0
last_transcribe_at: float = 0.0 last_transcribe_at: float = 0.0
last_voice_at: float = 0.0 last_voice_at: float = 0.0
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
noise_floor: float = 0.0 noise_floor: float = 0.0
closed: bool = False closed: bool = False
endpoint_sent: bool = False endpoint_sent: bool = False
@@ -506,6 +531,26 @@ class SessionManager:
"audioRequestId": sess.audio_request_id, "audioRequestId": sess.audio_request_id,
"text": "", "text": "",
}) })
# Speech-Endpoint gegen laute Umgebungsmusik: es ist gerade laut (rms
# ueber Schwelle) — aber ist es Sprache oder Musik? Der RMS-Endpoint
# unten wuerde bei Musik NIE feuern (last_voice_at bleibt frisch).
# Deshalb gedrosselt mit Silero das letzte endpoint_ms-Fenster pruefen:
# KEINE Sprache drin (nur Musik) → Turn ist zu Ende. Real gesprochene
# Turns haben Sprache im Fenster → laufen weiter.
if (self._buffer_ms(sess) >= sess.endpoint_ms
and (now - sess.last_speech_check_at) * 1000.0 >= STREAM_SPEECH_ENDPOINT_CHECK_MS):
sess.last_speech_check_at = now
try:
tail_bytes = int(sess.endpoint_ms / 1000.0 * sess.sample_rate) * 2
tail = pcm_s16le_to_float32(bytes(sess.pcm_buffer[-tail_bytes:]))
segs = _speech_segments(tail)
if segs is not None and len(segs) == 0:
logger.info("Stream %s: Speech-Endpoint (keine Sprache im letzten %dms — Musik/Stille) → finalize",
sess.request_id[:8], sess.endpoint_ms)
await self._finalize(sess, "endpoint")
return
except Exception:
logger.exception("Speech-Endpoint-Check fehlgeschlagen — ignoriert")
else: else:
self._update_noise_floor(sess, rms) self._update_noise_floor(sess, rms)
# No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt # No-Speech-Timeout: wurde die GANZE Zeit KEINE Stimme erkannt
@@ -669,13 +714,35 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
await _send(ws, "service_status", payload) await _send(ws, "service_status", payload)
async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
Flotten-Registry per periodischem worker_ping (mit busy-Status) frisch."""
def _hello():
return {"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": model}
try:
await _send(ws, "worker_hello", _hello())
n = 0
while True:
await asyncio.sleep(WORKER_PING_INTERVAL_S)
n += 1
busy = bool(busy_fn()) if busy_fn else False
await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": busy})
if n % 3 == 0: # ~30s worker_hello wiederholen (wie der Satellit)
await _send(ws, "worker_hello", _hello())
except asyncio.CancelledError:
raise
except Exception:
return # Socket tot → still beenden; run_loop reconnectet + startet neu
async def run_loop(sessions: SessionManager) -> None: async def run_loop(sessions: SessionManager) -> None:
use_tls = RVS_TLS use_tls = RVS_TLS
retry_s = 2 retry_s = 2
tls_fallback_tried = False tls_fallback_tried = False
while True: while True:
scheme = "wss" if use_tls else "ws" scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}" url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try: try:
logger.info("Verbinde zu RVS: %s", masked) logger.info("Verbinde zu RVS: %s", masked)
@@ -687,19 +754,60 @@ async def run_loop(sessions: SessionManager) -> None:
sessions.attach_ws(ws) sessions.attach_ws(ws)
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL) await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
await _send(ws, "config_request", {"service": "voxtral"}) await _send(ws, "config_request", {"service": "voxtral"})
async for raw in ws: ping_task = asyncio.create_task(_worker_register(
ws, model=VOXTRAL_MODEL,
busy_fn=lambda: bool(sessions._sessions)))
while True:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
except asyncio.TimeoutError:
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
raise ConnectionError("rvs-stale")
try: try:
msg = json.loads(raw) msg = json.loads(raw)
except Exception: except Exception:
continue continue
mtype = msg.get("type", "") mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {} payload = msg.get("payload", {}) or {}
# Redundanz-Routing: ist die Anfrage gezielt an eine andere
# Instanz adressiert, ignorieren. Ohne targetInstance (Feld
# fehlt) → wie bisher, jeder Worker nimmt sie an.
tgt = payload.get("targetInstance")
if tgt and tgt != INSTANCE_ID:
continue
# Auslastungs-Monitor (node_stats_*) abfangen.
if await _stats.handle(ws, mtype, payload, _send):
continue
if mtype == "stt_stream_start": if mtype == "stt_stream_start":
sessions.start_session(payload) sessions.start_session(payload)
elif mtype == "stt_audio_chunk": elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload) sessions.feed_chunk(payload)
elif mtype == "stt_stream_end": elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", "")) sessions.end_session(payload.get("requestId", ""))
elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
# den Vor-Trigger-Audio, wir sagen was gesagt wurde, die App
# prueft ob "Computer" drin ist. Silero vorgeschaltet:
# Musik/Rauschen → leerer Text (nicht bestaetigt).
req_id = payload.get("requestId", "")
try:
pcm = base64.b64decode(payload.get("pcm", ""))
audio = pcm_s16le_to_float32(pcm)
segs = _speech_segments(audio)
if segs is not None and len(segs) == 0:
text = ""
else:
text = (await sessions.runner.transcribe(
audio, payload.get("language", "de"))).strip()
logger.info("stt_transcribe_blob (%.1fs) → %r",
audio.size / 16000.0, text[:60])
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": text})
except Exception as exc:
logger.warning("stt_transcribe_blob fehlgeschlagen: %s", exc)
await _send(ws, "stt_transcribe_result",
{"requestId": req_id, "text": "", "error": str(exc)[:200]})
elif mtype == "voice_id_status_request": elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "") req_id = payload.get("requestId", "")
try: try:
@@ -747,6 +855,10 @@ async def run_loop(sessions: SessionManager) -> None:
"AN" if SPEAKER_ID_ENABLED else "AUS") "AN" if SPEAKER_ID_ENABLED else "AUS")
except Exception as e: except Exception as e:
logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s) logger.warning("RVS-Verbindung verloren: %s — retry in %ds", e, retry_s)
try:
ping_task.cancel()
except NameError:
pass
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
use_tls = False use_tls = False
tls_fallback_tried = True tls_fallback_tried = True
@@ -765,6 +877,7 @@ async def main() -> None:
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend) await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
sessions = SessionManager(runner) sessions = SessionManager(runner)
logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL) logger.info("Voxtral-Bridge startet — Modell=%s", VOXTRAL_MODEL)
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
await asyncio.gather(run_loop(sessions), sessions.run_endpointer()) await asyncio.gather(run_loop(sessions), sessions.run_endpointer())
+167
View File
@@ -0,0 +1,167 @@
"""
ARIA Node-Stats Auslastungs-Monitor pro Box (GPU + optional Tokens).
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
weil jeder Worker ein eigener Docker-Build-Context ist.
Aufgaben:
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta Ringpuffer
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
senden (bis stop / Auto-Timeout).
- History-Request + Reset (Besen).
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
"""
import asyncio
import json
import os
import time
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
async def _run_cmd(*args, timeout=8) -> str:
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (out or b"").decode("utf-8", "replace")
except Exception:
return ""
class NodeStats:
def __init__(self, instance_id: str, node_name: str, history_path: str,
token_getter=None, logger=None):
self.instance_id = instance_id
self.node_name = node_name
self.history_path = history_path
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
self.log = logger
self.samples = self._load()
self._last_tokens = self._tokens_now()
self._stream_task = None
# ── Persistenz ──────────────────────────────────────────
def _load(self) -> list:
try:
with open(self.history_path) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def _persist(self) -> None:
try:
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
tmp = self.history_path + ".tmp"
with open(tmp, "w") as f:
json.dump(self.samples[-HISTORY_CAP:], f)
os.replace(tmp, self.history_path)
except Exception:
pass
def _tokens_now(self) -> int:
try:
return int(self.token_getter()) if self.token_getter else 0
except Exception:
return 0
# ── nvidia-smi ──────────────────────────────────────────
async def _query_gpu(self) -> dict:
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits")
utils, used, total = [], 0, 0
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
utils.append(float(parts[0]))
used += float(parts[1])
total += float(parts[2])
except ValueError:
continue
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
# ── Sampler (Verlauf) ───────────────────────────────────
async def run_sampler(self) -> None:
while True:
try:
g = await self._query_gpu()
now_tok = self._tokens_now()
dtok = max(0, now_tok - self._last_tokens)
self._last_tokens = now_tok
self.samples.append({
"ts": int(time.time()),
"gpu": g["gpu"], "memUsed": g["memUsed"],
"memTotal": g["memTotal"], "tokens": dtok,
})
if len(self.samples) > HISTORY_CAP:
self.samples = self.samples[-HISTORY_CAP:]
self._persist()
except Exception as e:
if self.log:
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
await asyncio.sleep(SAMPLE_SEC)
# ── Live-Stream ─────────────────────────────────────────
async def _stream(self, ws, send) -> None:
t0 = time.time()
try:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
raise
except Exception:
return
# ── Dispatch ────────────────────────────────────────────
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
if mtype == "node_stats_stream_start":
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
self._stream_task = asyncio.create_task(self._stream(ws, send))
return True
if mtype == "node_stats_stream_stop":
if self._stream_task:
self._stream_task.cancel()
self._stream_task = None
return True
if mtype == "node_stats_history_request":
await send(ws, "node_stats_history", {
"instanceId": self.instance_id, "node": self.node_name,
"samples": self.samples[-HISTORY_CAP:],
"tokenCapable": self.token_getter is not None,
"sampleSec": SAMPLE_SEC,
})
return True
if mtype == "node_stats_reset":
self.samples = []
self._persist()
await send(ws, "node_stats_reset_done",
{"instanceId": self.instance_id, "node": self.node_name})
return True
return False
+1 -1
View File
@@ -17,6 +17,6 @@ RUN pip3 install --no-cache-dir torch==2.3.1 torchaudio==2.3.1 \
COPY requirements.txt . COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt RUN pip3 install --no-cache-dir -r requirements.txt
COPY bridge.py speaker_id.py ./ COPY bridge.py speaker_id.py node_stats.py ./
CMD ["python3", "bridge.py"] CMD ["python3", "bridge.py"]
+67 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
ARIA Whisper Bridge laeuft auf der Gamebox (RTX 3060). ARIA Whisper Bridge laeuft auf der AI-Box (RTX 3060).
Zwei Modi: Zwei Modi:
@@ -59,6 +59,23 @@ WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16") WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de") WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
WORKER_SERVICE = "whisper"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
# Empfangs-Watchdog: kommt in RX_STALE_S kein Broadcast rein (ein echter Raum hat
# staendig Traffic, z.B. sat_hello alle 25s / Brain-Polling), gilt die Verbindung
# als halb-tot (Caddy pongt die WS-Pings selbst) -> Zwangs-Reconnect.
RX_STALE_S = int(os.getenv("RX_STALE_S", "60"))
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
import node_stats
STATS_PATH = os.getenv("STATS_PATH", f"/root/.cache/huggingface/aria_stats_{WORKER_SERVICE}.json")
_stats = node_stats.NodeStats(INSTANCE_ID, NODE_NAME, STATS_PATH, logger=logger)
ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"} ALLOWED_MODELS = {"tiny", "base", "small", "medium", "large-v3"}
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden) # Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
@@ -284,7 +301,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# DEBUG-LOG ueber RVS → /shared/logs/app.log # DEBUG-LOG ueber RVS → /shared/logs/app.log
# #
# Stefan's Gamebox ist Windows, kein SSH → wir brauchen Whisper-Bridge- # Stefan's AI-Box ist Windows, kein SSH → wir brauchen Whisper-Bridge-
# Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS, # Logs ueber den gleichen Pfad wie die App: app_log-Messages via RVS,
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App- # aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
# Logs-Tab zeigen sie dann mit platform="whisper". # Logs-Tab zeigen sie dann mit platform="whisper".
@@ -822,6 +839,30 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
await _send(ws, "service_status", payload) await _send(ws, "service_status", payload)
async def _worker_register(ws, *, model: str = "", busy_fn=None) -> None:
"""Meldet diesen Worker bei der aria-bridge an (worker_hello) und haelt die
Flotten-Registry per periodischem worker_ping frisch. worker_hello wird alle
~30s WIEDERHOLT (wie der Satellit), damit ein neu gestartetes Diagnostic/
Bridge uns lernt RVS spielt hellos nicht nach."""
def _hello():
return {"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": model}
try:
await _send(ws, "worker_hello", _hello())
n = 0
while True:
await asyncio.sleep(WORKER_PING_INTERVAL_S)
n += 1
busy = bool(busy_fn()) if busy_fn else False
await _send(ws, "worker_ping", {"instanceId": INSTANCE_ID, "busy": busy})
if n % 3 == 0:
await _send(ws, "worker_hello", _hello())
except asyncio.CancelledError:
raise
except Exception:
return # Socket tot → still beenden; run_loop reconnectet + startet neu
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# WS-LOOP # WS-LOOP
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@@ -833,7 +874,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
while True: while True:
scheme = "wss" if use_tls else "ws" scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}" url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
try: try:
logger.info("Verbinde zu RVS: %s", masked) logger.info("Verbinde zu RVS: %s", masked)
@@ -855,21 +896,37 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
logger.info("Initial: sende config_request an aria-bridge") logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "whisper"}) await _send(ws, "config_request", {"service": "whisper"})
# Startup-Marker — App-Logs zeigen damit ob Streaming-Code # Startup-Marker — App-Logs zeigen damit ob Streaming-Code
# ueberhaupt aktiv ist (Stefan baut auf Gamebox via PS, # ueberhaupt aktiv ist (Stefan baut auf AI-Box via PS,
# Build/Restart kann unbeabsichtigt alte Version weiterfahren). # Build/Restart kann unbeabsichtigt alte Version weiterfahren).
await _debug_log(ws, "boot", await _debug_log(ws, "boot",
"whisper-bridge online — streaming-mode ENABLED, debug-log ON") "whisper-bridge online — streaming-mode ENABLED, debug-log ON")
except Exception as e: except Exception as e:
logger.exception("Initial-Handshake crashed: %s", e) logger.exception("Initial-Handshake crashed: %s", e)
asyncio.create_task(_initial_handshake()) asyncio.create_task(_initial_handshake())
ping_task = asyncio.create_task(_worker_register(
ws, model=(runner.model_size or WHISPER_MODEL),
busy_fn=lambda: bool(sessions._sessions)))
async for raw in ws: while True:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=RX_STALE_S)
except asyncio.TimeoutError:
logger.warning("Kein RVS-Traffic seit %ds — Verbindung halb-tot, reconnect", RX_STALE_S)
raise ConnectionError("rvs-stale")
try: try:
msg = json.loads(raw) msg = json.loads(raw)
except Exception: except Exception:
continue continue
mtype = msg.get("type", "") mtype = msg.get("type", "")
payload = msg.get("payload", {}) or {} payload = msg.get("payload", {}) or {}
# Redundanz-Routing: gezielt an eine andere Instanz adressiert
# → ignorieren. Ohne targetInstance → wie bisher.
tgt = payload.get("targetInstance")
if tgt and tgt != INSTANCE_ID:
continue
# Auslastungs-Monitor (node_stats_*) abfangen.
if await _stats.handle(ws, mtype, payload, _send):
continue
if mtype == "stt_request": if mtype == "stt_request":
req_id = payload.get("requestId", "?") req_id = payload.get("requestId", "?")
@@ -1038,6 +1095,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
except Exception as e: except Exception as e:
logger.warning("Verbindung verloren: %s", e) logger.warning("Verbindung verloren: %s", e)
sessions.detach_ws() sessions.detach_ws()
try:
ping_task.cancel()
except NameError:
pass
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried: if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://") logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
use_tls = False use_tls = False
@@ -1058,6 +1119,7 @@ async def main() -> None:
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und # Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
# schlaeft solange das nicht gesetzt ist. # schlaeft solange das nicht gesetzt ist.
asyncio.create_task(sessions.run_endpointer()) asyncio.create_task(sessions.run_endpointer())
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
await run_loop(runner, sessions) await run_loop(runner, sessions)
+167
View File
@@ -0,0 +1,167 @@
"""
ARIA Node-Stats Auslastungs-Monitor pro Box (GPU + optional Tokens).
Identische Kopie in jedem Worker-Build-Context (f5tts/whisper/voxtral/llm-adapter),
weil jeder Worker ein eigener Docker-Build-Context ist.
Aufgaben:
- Sampler-Loop (alle SAMPLE_SEC): nvidia-smi-Auslastung + Token-Delta Ringpuffer
(persistent als JSON auf der Box). Laeuft unabhaengig vom Modal.
- Live-Stream: bei node_stats_stream_start jede Sekunde rohes nvidia-smi + Werte
senden (bis stop / Auto-Timeout).
- History-Request + Reset (Besen).
Reicht `handle(ws, mtype, payload)` in die Worker-Message-Loop ein; gibt True
zurueck, wenn die Nachricht eine node_stats_*-Nachricht war.
"""
import asyncio
import json
import os
import time
SAMPLE_SEC = int(os.getenv("STATS_SAMPLE_SEC", "15"))
HISTORY_CAP = int(os.getenv("STATS_HISTORY_CAP", "500")) # ~2h bei 15s
STREAM_MAX_SEC = int(os.getenv("STATS_STREAM_MAX_SEC", "300"))
async def _run_cmd(*args, timeout=8) -> str:
"""Fuehrt ein Kommando aus, gibt stdout (str) zurueck; '' bei Fehler."""
try:
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (out or b"").decode("utf-8", "replace")
except Exception:
return ""
class NodeStats:
def __init__(self, instance_id: str, node_name: str, history_path: str,
token_getter=None, logger=None):
self.instance_id = instance_id
self.node_name = node_name
self.history_path = history_path
self.token_getter = token_getter # callable -> kumulative Token-Zahl (oder None)
self.log = logger
self.samples = self._load()
self._last_tokens = self._tokens_now()
self._stream_task = None
# ── Persistenz ──────────────────────────────────────────
def _load(self) -> list:
try:
with open(self.history_path) as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception:
return []
def _persist(self) -> None:
try:
os.makedirs(os.path.dirname(self.history_path) or ".", exist_ok=True)
tmp = self.history_path + ".tmp"
with open(tmp, "w") as f:
json.dump(self.samples[-HISTORY_CAP:], f)
os.replace(tmp, self.history_path)
except Exception:
pass
def _tokens_now(self) -> int:
try:
return int(self.token_getter()) if self.token_getter else 0
except Exception:
return 0
# ── nvidia-smi ──────────────────────────────────────────
async def _query_gpu(self) -> dict:
"""Aggregierte GPU-Werte ueber alle sichtbaren Karten."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits")
utils, used, total = [], 0, 0
for line in out.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 3:
continue
try:
utils.append(float(parts[0]))
used += float(parts[1])
total += float(parts[2])
except ValueError:
continue
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
# ── Sampler (Verlauf) ───────────────────────────────────
async def run_sampler(self) -> None:
while True:
try:
g = await self._query_gpu()
now_tok = self._tokens_now()
dtok = max(0, now_tok - self._last_tokens)
self._last_tokens = now_tok
self.samples.append({
"ts": int(time.time()),
"gpu": g["gpu"], "memUsed": g["memUsed"],
"memTotal": g["memTotal"], "tokens": dtok,
})
if len(self.samples) > HISTORY_CAP:
self.samples = self.samples[-HISTORY_CAP:]
self._persist()
except Exception as e:
if self.log:
self.log.debug("node_stats sample fehlgeschlagen: %s", e)
await asyncio.sleep(SAMPLE_SEC)
# ── Live-Stream ─────────────────────────────────────────
async def _stream(self, ws, send) -> None:
t0 = time.time()
try:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
raise
except Exception:
return
# ── Dispatch ────────────────────────────────────────────
async def handle(self, ws, mtype: str, payload: dict, send) -> bool:
if mtype == "node_stats_stream_start":
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
self._stream_task = asyncio.create_task(self._stream(ws, send))
return True
if mtype == "node_stats_stream_stop":
if self._stream_task:
self._stream_task.cancel()
self._stream_task = None
return True
if mtype == "node_stats_history_request":
await send(ws, "node_stats_history", {
"instanceId": self.instance_id, "node": self.node_name,
"samples": self.samples[-HISTORY_CAP:],
"tokenCapable": self.token_getter is not None,
"sampleSec": SAMPLE_SEC,
})
return True
if mtype == "node_stats_reset":
self.samples = []
self._persist()
await send(ws, "node_stats_reset_done",
{"instanceId": self.instance_id, "node": self.node_name})
return True
return False