Compare commits

...
76 Commits
Author SHA1 Message Date
duffyduck 96012dc986 release(agent): bump to 0.0.0.4 2026-09-24 21:16:56 +02:00
duffyduckandClaude Opus 4.8 80d7f62eaa fix(android-agent): Screenshot-Timeout — nie stumm scheitern + gekappte Aufnahme
Timeout hiess: kein host_result kam zurueck. Ursachen adressiert:
1) handle() umschliesst JEDE Aktion mit try/catch(Throwable) -> auch OOM/Exception
   liefert jetzt ein host_result mit Fehlertext statt Stille (kein Timeout mehr).
2) ScreenCapturer nimmt direkt in gekappter Aufloesung auf (max 1280 lange Seite,
   MediaProjection skaliert) statt Vollbild-Bitmap + Nachskalieren -> viel weniger
   Speicher/Zeit (kein 10-MB-ARGB-Bitmap -> kein OOM), Frame-Warten bis ~3s.
lastError fliesst in die Screenshot-Fehlermeldung. Version 0.0.0.4/code 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:14:15 +02:00
duffyduck 5f5234ee62 release(agent): bump to 0.0.0.3 2026-09-24 21:01:05 +02:00
duffyduckandClaude Opus 4.8 28e58089aa fix(android-agent): fester Signaturschluessel -> Updates statt Paket-Konflikt
Jeder Build signierte bisher mit einem zufaelligen Auto-Debug-Key -> neue APK =
andere Signatur -> Android verweigert Update ('Konflikt mit bestehendem Paket').
Fester signingConfig (rootProject/aria-agent.keystore) fuer debug+release, per
Datei-Existenz-Guard. Keystore liegt nur lokal (gitignored, NICHT im public Repo
— Backup!). Verifiziert: Signer-DN CN=ARIA Host-Agent. Version 0.0.0.3/code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:59:00 +02:00
duffyduck c1a90102f8 release(agent): bump to 0.0.0.2 2026-09-24 20:52:21 +02:00
duffyduckandClaude Opus 4.8 ba531adc74 fix(android-agent): Crash bei Bildschirm-Zugriff (startForeground-Pflicht)
Der Projection-Consent kommt per startForegroundService, obwohl der Dienst schon
laeuft. Android verlangt danach binnen ~5s ein startForeground() -> fehlte im
Projection-Zweig -> ForegroundServiceDidNotStartInTimeException -> Prozess-Crash
(Diagnostic zeigt den Host bis zum Ping-Timeout noch gruen). Fix: onStartCommand
ruft IMMER zuerst startForeground(). Zusaetzlich ScreenCapturer.start in try/catch
mit lastError, das in der Screenshot-Fehlermeldung und der Notification erscheint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:50:33 +02:00
duffyduck 04d29b256e fix(host-agent): Release-Notes mit echten Zeilenumbruechen (literal \n weg) 2026-09-24 20:43:08 +02:00
duffyduck e6e07f672a release(agent): bump to 0.0.0.1 2026-09-24 20:41:50 +02:00
duffyduckandClaude Opus 4.8 d32e7e59c3 feat(host-agent): Windows-Build aus Docker (Wine) + setup.exe (Dienst)
Dockerfile.win (tobix/pywine): baut die Windows-.exe via Wine+PyInstaller und
per NSIS ein setup.exe, das den Agent via nssm als Autostart-Windows-Dienst
einrichtet und die .env aus %ProgramData%\ARIA-Host-Agent liest (AppDirectory).
build-win.sh als Einstieg; release_agent.sh baut Windows jetzt mit (SKIP_WINDOWS=1
ueberspringt). _load_dotenv haertet: sucht .env auch neben sys.executable (onefile-
.exe-Ort), nicht nur __file__/CWD. README: Windows-Build + Dienst + Release.
macOS bleibt self-build (nicht aus Docker moeglich).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:39:08 +02:00
duffyduckandClaude Opus 4.8 4f8e65b76b feat(host-agent): release_agent.sh — Binaries als Gitea-Assets, nicht im Tree
release_agent.sh <version> (wie die App-release.sh): setzt Version
(host_agent.py AGENT_VERSION + Android versionName/Code), baut Linux-Binary +
Android-APK per Docker, taggt agent-v<version> (eigener Namespace, keine
Kollision mit App-Tags v<version>), legt Gitea-Release an und laedt Assets hoch.
mac/win optional falls in dist/ vorgebaut. Agent meldet AGENT_VERSION jetzt in
host_hello + info. README-Release-Sektion aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:32:11 +02:00
duffyduckandClaude Opus 4.8 a2605a2802 fix(host-agent): binutils im Build-Image (PyInstaller braucht objdump)
Das slim-bullseye-Image bringt kein objdump mit -> PyInstaller bricht mit
'On Linux, objdump is required' ab. binutils per apt nachinstalliert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:28:08 +02:00
duffyduckandClaude Opus 4.8 0588a8d9b3 docs(android-agent): README-Build-Abschnitt praezisiert; Version 0.2.0
Ehrlicher Bau-Weg (nur Docker noetig, dist/aria-android-agent.apk, Debug-Key,
Installieren, Erst-Build-Kosten/Stolperer). release.sh klar als M4 geplant
markiert statt als vorhandener Befehl. versionCode 2 / versionName 0.2.0 (M1+M2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:26:50 +02:00
duffyduckandClaude Opus 4.8 be202b1fa1 feat(android-agent): Meilenstein 2 — sehen (Screenshot + ui_dump)
Screenshot via MediaProjection (ScreenCapturer, gleicher {format,bytes,base64}-
Vertrag wie der Desktop-Agent -> host_screenshot, inkl. Vision). UI-Baum via
AriaAccessibilityService (nur lesend) -> neues Brain-Tool host_ui_dump. Freigabe
einmalig in der App: 'Bildschirm-Zugriff erlauben' + 'Bedienungshilfe oeffnen'.
caps = [info, screenshot, ui_dump]. targetSdk 33 -> keine mediaProjection-FGS-
Typpflicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:22:52 +02:00
duffyduckandClaude Opus 4.8 1ad85fb687 feat(android-agent): Meilenstein 1 — verbinden + in Diagnostic sichtbar
Nativer Kotlin-Agent (host-agent/android/): Gradle-Projekt, AndroidManifest,
RVS-WebSocket-Client (OkHttp) mit host_hello/host_ping/host_command->host_result,
Foreground-Service (Weg A, Auto-Reconnect, BootReceiver), Connect-UI (QR-Scan via
ZXing ODER manuell: host/port/token/name/TLS/Steuerung-Schalter). QR-Format =
{host,port,token,tls} wie die ARIA-App -> derselbe QR nutzbar.

M1-Aktion: info (Modell/Android/Akku). screenshot/ui_* liefern 'kommt in M2/M3'.
Gate: CONTROL_ENABLED-Schalter in der App. Build: Docker (Android-SDK+Gradle) ->
dist/aria-android-agent.apk (Debug, auto-signiert). Kein Google.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:08:54 +02:00
duffyduckandClaude Opus 4.8 558afee239 docs(android-agent): Push OHNE Google — self-hosted ntfy/UnifiedPush
Klargestellt: FCM (Google) ist NUR eine Option, keine Pflicht. Akkuschonender
Push geht self-hosted via UnifiedPush + ntfy auf dem ARIA-Server (laeuft auch auf
Custom-ROMs ohne Play Services). Fuer ein dediziertes Ziel-Handy ist der eigene
RVS-Socket (Weg A) oft die einfachste Dauerloesung. Empfehlung entsprechend
angepasst: A -> Push-Hybrid mit ntfy, FCM nur optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:50:14 +02:00
duffyduckandClaude Opus 4.8 b2fd8d8953 docs(android-agent): Push-Hybrid-Modell (Leerlauf=Push, Session=Socket) ergaenzt
Akku-optimal wie WhatsApp: idle nur FCM-Push, bei Befehl kurz Socket + Wakelock
fuer die Interaktion, danach wieder schlafen. Requirements (Firebase/Play Services/
Server-Push-Trigger) + Latenz dokumentiert. Empfehlung: erst Foreground-Service
(M1-3, sofort lauffaehig), dann Push-Hybrid als Akku-Ausbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:47:33 +02:00
duffyduckandClaude Opus 4.8 737915e267 docs(android-agent): Design fuer nativen Android-Agenten (Pfad B)
Nativer Kotlin-Agent unter host-agent/android/ zur Handy-Fernsteuerung inkl.
Bedienen fremder App-UIs (E-Mail-Setup) via AccessibilityService. Verbindung wie
die ARIA-App (QR-Scan/manuell), erscheint in der Diagnostic (host_hello).

Festgehalten: Android-Action-Set (screenshot/ui_dump/ui_tap/ui_text/ui_swipe/
ui_key/app_launch/info/notify statt Shell), Sicherheit (CONTROL_ENABLED + explizite
Accessibility/MediaProjection-Freigabe), Dauerbetrieb (A: Foreground-Service —
einfach, reutzt host_command 1:1; B: FCM-Push wie WhatsApp — akkuschonend, spaeter),
4 Meilensteine, Build via Docker/Gradle + release.sh <version> -> Gitea-Asset.

Status: Design. Naechster Schritt: Meilenstein 1 (Verbinden + sichtbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:46:19 +02:00
duffyduckandClaude Opus 4.8 1140934e15 feat(host-agent): cross-platform — Linux, macOS UND Windows
Eine Codebasis fuer alle drei Desktop-OS. Der Agent waehlt je OS automatisch:
- exec: bash -lc (Linux/macOS) bzw. PowerShell (Windows)
- Root/Admin: sudo (Unix) bzw. 'als Administrator starten' (Windows, kein sudo)
- Screenshot: grim/scrot (Linux) · screencapture (macOS) · PowerShell/System.
  Drawing (Windows)
- Root-Check: _is_admin() (os.geteuid Unix / IsUserAnAdmin Windows) statt hartem
  os.geteuid (crashte auf Windows)
- info: user aus USER|USERNAME; getloadavg bleibt guarded

Build: build.sh (Linux/Docker), build-native.sh (Linux/macOS), build-native.bat
(Windows). PyInstaller cross-kompiliert nicht -> je OS bauen. README-Plattform-
Tabelle ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:40:17 +02:00
duffyduck 29bf3370b4 release: bump version to 0.2.5.0 2026-09-24 18:51:30 +02:00
duffyduckandClaude Opus 4.8 6b5d847615 fix(app+brain): leere Bubble bei Tool-Loop/Fehler beheben + Limit hoch
Symptom: ARIA laeuft ins Tool-Loop-Limit (host_agent-Exploration), postet die
Meldung, aber die App zeigt eine LEERE Bubble.

Ursache: stripSystemHints (App) entfernt fuehrende [..]-Bloecke (fuer Hinweise
wie '[Kontext:..] Hallo'). Die Tool-Loop-/Fehler-Meldung ist KOMPLETT ein
[..]-Block -> restlos gestrippt -> leer.

Fixes:
- App (ChatScreen): stripSystemHints zeigt das Original, wenn nach dem Strippen
  nichts uebrig bleibt -> Fehler-/Meta-Meldungen (auch '[Fehler: ...]') sind
  wieder sichtbar statt leer.
- Brain: MAX_TOOL_ITERATIONS 8 -> 20 (env-tunebar) — 8 war zu knapp fuer echte
  agentische Arbeit (Host-Exploration); ARIA brach mittendrin ab.
- Brain: Loop-Limit-Meldung ARIA-stimmig + handlungsleitend statt technischer
  Marker (liest sich natuerlich in der Bubble).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 18:47:58 +02:00
duffyduckandClaude Opus 4.8 145c64e67f feat(diagnostic): RVS-Raum-Monitor + Server/GPU-Icon fuer die Compute-Flotte
Raum-Monitor (haette uns die Nacht erspart): der RVS-Log kuerzt Tokens auf 8
Zeichen -> zwei verschiedene Tokens mit gleichem Prefix ergeben zwei Raeume,
die im Log IDENTISCH aussehen (Prefix-Kollision). Jetzt zeigt die Diagnostic
(Satelliten-Tab, Panel 'RVS-Raeume 🚪') alle Raeume mit token8 + laengerem
Fingerprint (sha256) + Token-Laenge + Client-Zahl, ★ = eigener Raum. >1 Raum =
Warnung 'Token-Mismatch'. Keine vollen Tokens (nur Fingerprint).

- rvs: rooms_query -> direkt rooms_info antworten (wie update_check); Typen in
  ALLOWED_TYPES; sha256-Fingerprint via crypto.
- diagnostic: rooms_query durchreichen, rooms_info an Browser; Panel + Tabelle.

Dazu: Compute-Flotte-Ueberschrift bekommt statt 🖥️ ein Inline-SVG (Server mit
GPU-Karte, currentColor -> theme-aware).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 18:29:37 +02:00
duffyduckandClaude Opus 4.8 6cb29a28ce feat(host-agent,satellite): RVS_TLS_FALLBACK wie in den Compute-Bridges
Host-Agent und Satellit hatten keinen TLS-Fallback (nur die vier Compute-Bridges).
Jetzt konsistent: bei TLS-Fehlschlag einmal auf ws:// zurueckfallen, danach wieder
mit RVS_TLS starten (kein Sticky-Fallback, wie bei den Bridges). uri_host/proto
werden pro Versuch aus use_tls berechnet, damit der RVS_SNI-Pfad nur bei wss gilt.

Hilft nur wo der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss. RVS_TLS_
FALLBACK in beiden .env.example dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:58:19 +02:00
duffyduckandClaude Opus 4.8 df5cd3be99 fix(rvs-clients): RVS_SNI via getaddrinfo-Override statt host=/port= (HTTP-200-Fix)
Symptom mit RVS_SNI: 'server rejected WebSocket connection: HTTP 200'. Ursache:
die IP stand in der URI -> HTTP-Host-Header = IP -> Caddy findet keinen RVS-Site-
Block (routet nach Host) und liefert eine Default-200-Seite statt WS-Upgrade. Der
vorige Fix korrigierte nur SNI/Cert (server_hostname), nicht den Host-Header.

Zudem: host=/port= als websockets.connect-kwargs kollidieren in der Legacy-API
mit dem aus der URI abgeleiteten Host (TypeError).

Loesung (host-agent, satellite, f5tts/whisper/voxtral/llm-adapter): die URI nutzt
den HOSTNAMEN (RVS_SNI) -> Host-Header + SNI + Cert stimmen; ein In-Process-
getaddrinfo-Override mappt RVS_SNI -> RVS_HOST (IP) fuer den TCP-Connect.
Versionsunabhaengig, nicht-blockierend, nur aktiv wenn RVS_SNI gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:54:38 +02:00
duffyduckandClaude Opus 4.8 4125ab8160 chore(seed): Pentest-Vorfalls-Absatz anonymisiert (Kundendomains raus)
Nennt keine realen Kunden-Produktions-/Staging-Domains mehr; die Sicherheits-
regel (nie gegen Produktion testen) bleibt vollstaendig wirksam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:44:16 +02:00
duffyduckandClaude Opus 4.8 edd0b4823f chore: .claude/settings.json aus Tracking nehmen (lokale Permission-Allowlist)
Enthaelt echte Server-IPs fuer den Dev-Zugriff -> gehoert nicht ins oeffentliche
Repo. In .gitignore aufgenommen; lokale Datei bleibt (mit echter IP) erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:38:17 +02:00
duffyduckandClaude Opus 4.8 d4e3a73e89 feat(host-agent): systemd-Service-Installer + SNI-Doku ueberall
install-service.sh: installiert Binary (/usr/local/bin) + .env (/etc/aria-host-
agent/.env, 0600) + systemd-Unit und macht enable --now. .env-Pfad als Argument
ODER ohne Argument ein ncurses-Dateidialog (dialog --fselect, bietet Installation
von dialog an). Findet die Binary unter dist/ bzw. ./ oder als 2. Argument.

Doku aktualisiert (RVS_SNI fuer RZ-interne Clients + Installer):
- host-agent/README: Installer-Abschnitt + TLS/SNI-Abschnitt.
- README (zentral): Installer + SNI im Host-Agent-Abschnitt (Verweis auf
  Satellit/Compute-Nodes).
- satellite/README + xtts/README: RVS_SNI-Hinweis fuer Boxen/Satelliten im
  RVS-Netz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:27:07 +02:00
duffyduckandClaude Opus 4.8 9d93c3fe3d feat(xtts): RVS_SNI in allen Compute-Bridges — AI-Box im RZ-Netz auf interne IP
Damit eine AI-Box (f5tts/whisper/voxtral/llm-adapter) auch im selben Netz wie
der RVS direkt auf dessen interne IP verbinden kann (kein NAT-Hairpin ueber den
externen Hostnamen), ohne am SNI/Cert zu scheitern: RVS_HOST=<ip> + RVS_SNI=<name>.
server_hostname im ssl-Context, gated auf use_tls (respektiert den TLS-Fallback).
Leer = Verhalten wie bisher. Szenario: zweite Box im RZ + eine zuhause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:22:27 +02:00
duffyduckandClaude Opus 4.8 39f7947da9 feat(satellite): RVS_SNI — wie beim Host-Agent (interne IP + Cert-Hostname)
Gleiche Faehigkeit wie beim host-agent: ein Satellit im selben Netz wie der RVS
kann direkt auf die interne IP verbinden und trotzdem den Cert-Namen im TLS-SNI
praesentieren (Caddy findet sonst kein Zertifikat -> tlsv1 alert internal error).
Nuetzlich z.B. fuer einen RZ-internen Satelliten, nur um Credentials zu hinterlegen.
RVS_HOST=<ip> + RVS_SNI=<name>; leer = Verhalten wie bisher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:19:59 +02:00
duffyduckandClaude Opus 4.8 855cc0333e feat(host-agent): RVS_SNI — auf interne IP verbinden, aber Cert-Hostname im TLS
Fuer Agenten im selben Netz wie der RVS: direkt auf die interne IP verbinden
(kein NAT-Hairpin ueber den externen Hostnamen), aber im TLS-Handshake den
Namen praesentieren, fuer den das Caddy-Zertifikat gilt. Ohne das schickt der
Client die IP als SNI -> Caddy findet kein Cert -> 'tlsv1 alert internal error'.

RVS_HOST=<interne-ip> + RVS_SNI=<zert-name>. server_hostname im ssl-Context.
Leeres RVS_SNI = Verhalten wie bisher. Robuster als /etc/hosts (ueberlebt
Reboots, wichtig auf Live-ISOs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:16:45 +02:00
duffyduckandClaude Opus 4.8 2cac1cad0a feat(host-agent): Docker-loser Native-Build + Live-ISO-Hinweis
Docker-Build scheitert auf Live-ISOs (overlayfs-Root -> overlay2 kann kein
Overlay-auf-Overlay stapeln: 'failed to mount ... invalid argument'). Ergaenzt:
- build-native.sh: PyInstaller in einem venv, ohne Docker (Warnung: linkt gegen
  lokales glibc).
- README: Live-ISO-Ursache erklaert + zwei Auswege (Binary woanders bauen und
  kopieren [empfohlen, portabel] oder nativ bauen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:42:39 +02:00
duffyduck f89e7ac36d release: bump version to 0.2.4.9 2026-09-24 16:38:20 +02:00
duffyduckandClaude Opus 4.8 cb1f571ad0 docs: Vision-Roadmap-Punkt geschlossen — ARIA sieht Bilder via Read-Tool
"Claude Vision direkt (ohne Dateipfad-Umweg)" abgehakt und umformuliert:
Bildanalyse funktioniert bereits (App-Uploads + Screenshots via Read-Tool,
/shared/uploads im Proxy-Container gemountet). Die "direkte" Variante waere
reine Politur (ein Read weniger) und ist bewusst nicht geplant — vermeidet,
dass jemand den heiklen Proxy-Umbau spaeter grundlos wieder aufmacht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:26:45 +02:00
duffyduckandClaude Opus 4.8 179fe53143 docs: Host-Agent-Deployment in der README (Tabelle + eigener Abschnitt)
Der Host-Agent stand nur in host-agent/README.md, nicht in der zentralen Doku.
Ergaenzt: Zeile in der Deploy-Tabelle + Abschnitt "Host-Agenten" (build.sh ->
Binary, Install, sudo-Faelle inkl. Live-ISO, Sicherheit, Vision via Read-Tool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:22:20 +02:00
duffyduckandClaude Opus 4.8 b73e89e9bb feat(host-agent): ARIA SIEHT den Screenshot (via eigenes Read-Tool)
Der Proxy reduziert Multimodal-Content zu Text (openai-to-cli), ARIA laeuft auf
der Claude-Code-CLI. Aber: der Proxy-Container hat /shared gemountet und ARIA
laeuft mit vollen Tools (--dangerously-skip-permissions) -> sie kann die unter
/shared/uploads/ gespeicherte Screenshot-PNG mit ihrem eigenen Read-Tool
oeffnen, das Claude Code nativ als Bild rendert. Damit SIEHT sie den Bildschirm
und kann 'was ist auf meinem Bildschirm' beantworten — ganz ohne Proxy-/Vision-
Umbau. host_screenshot-Result + Tool-Beschreibung weisen sie an, den Pfad zu
Read'en (sehen) UND als [FILE:]-Marker zu setzen (im Chat zeigen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:20:08 +02:00
duffyduckandClaude Opus 4.8 2b4428d823 feat(host-agent): host_screenshot erscheint inline im Chat (FILE-Marker)
Statt den Screenshot nur nach /shared/host-screenshots zu legen und den Pfad
zu nennen, speichert host_screenshot das PNG jetzt nach /shared/uploads/ und
liefert ARIA die Anweisung, den Pfad als [FILE: ...]-Marker in die Antwort zu
schreiben — exakt das flux_generate-Muster. Die Bridge erkennt den Marker,
broadcastet file_from_aria, und App/Diagnostic zeigen das Bild inline im Chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:16:15 +02:00
duffyduckandClaude Opus 4.8 74fcf7fd03 feat(diagnostic): Host-Agenten in der Satelliten-Flotte anzeigen (Phase E)
Diagnostic-Server trackt host_hello/host_ping (hosts-Map), broadcastet
host_update und beantwortet die host_list-Action. UI: neues Panel
"Host-Agenten" im Satelliten-Tab — zeigt Name/OS/Caps/online + ob
CONTROL_ENABLED. Reine Sichtbarkeit; Steuerung laeuft ueber ARIA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:12:50 +02:00
duffyduckandClaude Opus 4.8 c7761284c7 feat(host-agent): Backend-Anbindung — RVS + Bridge-Registry + Brain-Tools (Phase B-D)
Damit kann ARIA Host-Agenten tatsaechlich nutzen (Ende-zu-Ende):

- RVS: host_hello/host_ping/host_command/host_result in ALLOWED_TYPES.
- Bridge: _hosts-Registry (host_hello/host_ping/host_result), _host_list,
  _host_request (requestId->Future wie Satelliten), HTTP-Endpoints
  /internal/host-list und /internal/host (op via action).
- Brain: Tools host_list/host_exec/host_read/host_write/host_info/
  host_screenshot + _dispatch_host (ruft /internal/host*). host_screenshot
  speichert das PNG unter /shared/host-screenshots. host_exec kann sudo=true.

Naechste (optionale) Phase E: Host-Agenten in der Diagnostic-Flotte anzeigen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:11:06 +02:00
duffyduckandClaude Opus 4.8 223692d4c7 feat(host-agent): Direktzugriffs-Agent fuer einen Rechner (Phase A: Agent + Build)
Schlanker Agent, der DIREKT auf einem Linux-Rechner laeuft und sich ausgehend
zum RVS verbindet -> ARIA steuert den Rechner auch hinter NAT/Firewall, wo er
sonst nicht erreichbar ist. Anders als der Satellit (LAN-Gateway) steuert der
Agent den Rechner, auf dem er laeuft.

Faehigkeiten (host_command -> host_result): exec (opt. sudo), read, write,
info (CPU/RAM/Disk/Uptime via psutil), screenshot (grim/scrot/maim/import).
sudo-Logik: root -> direkt; SUDO_PASSWORD -> sudo -S; SUDO_NOPASSWD (Live-ISO)
-> sudo -n; sonst klare Fehlermeldung. Gate: CONTROL_ENABLED + RVS-Token.
stdout wird wie beim Satelliten gefenstert (contains/offset/max_chars).

Als portable Onefile-Binary verteilbar (PyInstaller im bullseye-Container fuer
breite glibc-Kompatibilitaet): build.sh + Dockerfile.build. Plus .env.example,
systemd-Unit und README.

Naechste Phasen: RVS ALLOWED_TYPES (host_*), Bridge-Registry + /internal/host*,
Brain-Tools host_list/exec/read/write/info/screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:07:27 +02:00
duffyduckandClaude Opus 4.8 a99ea32664 feat(satellite): ssh.exec — ARIA fuehrt Kommandos per SSH auf Geraeten aus
Neuer Credential-Typ 'ssh' (Benutzer + Passwort ODER privater Key, Port) und
Aktion ssh.exec (params: ip, cmd). paramiko-Client; grosse Ausgaben werden wie
bei http.get gefenstert (contains/offset/max_chars gemeinsam via _window_text),
Antwort mit exit_code + stdout + stderr. Auth kommt aus dem Credential-Store,
ARIA muss keine Passwoerter mitgeben.

- satellite: _do_ssh + _ssh_load_key (RSA/Ed25519/ECDSA/DSS aus String),
  ssh.exec in _control + Allowlist, 'ssh' in beide Cred-Typ-Listen; paramiko
  in requirements; .env.example ergaenzt.
- diagnostic: SSH-Sektion im Credentials-Modal (User/Port/Passwort/Key) +
  Save-Logik (User + Passwort|Key).
- brain: satellite_command-Tool um ssh.exec erweitert.

Laeuft ueber den bestehenden sat_command/sat_result-Pfad — keine RVS-Aenderung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:00:54 +02:00
duffyduckandClaude Opus 4.8 4e1ab95684 feat(app): Datei-Upload erlaubt alle Dateitypen (Bueroklammer)
Der DocumentPicker war auf images/pdf/docx/plainText beschraenkt -> vom
Smartphone liessen sich nur Bilder/Dokumente hochladen. Auf allFiles
umgestellt; die Komponente verarbeitet beliebige Typen ohnehin (Base64 +
octet-stream-Fallback), und die Server-Seite hat kein MIME-Gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 15:57:56 +02:00
duffyduckandClaude Opus 4.8 bb7a19f818 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 6ffbc77387 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 3893c89d5f 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 9f741df10e 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 22f5f5c954 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 e3f6b3626d 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 67ea68514a 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 d6cca88455 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 d0d06defb1 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 056637ab7a 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 ee654a6f6c 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 f1c042c60a 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 042fd63d33 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 3a908d246a 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 7f864bb431 debug(diagnostic): Roh-Empfang aller worker_*/sat_hello-Nachrichten loggen (BEVOR Handler) 2026-09-19 10:46:38 +02:00
duffyduckandClaude Opus 4.8 3474d2eb6f 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 3df35d2293 debug(diagnostic): worker_hello-Empfang loggen (Flotten-Diagnose) 2026-09-19 04:19:30 +02:00
duffyduckandClaude Opus 4.8 fc198f90da 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 387b14a8ef 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 18147eb24d 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 8d3637079c 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 23d104a7c3 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 51985f7c2e 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 f854303e7b 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 39426dc70d 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 99e4b63a06 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 254031bb7d 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 eab43f5ea2 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 f6f2b128c4 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 e39599ecea 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 e255d7a659 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 ebd25a1cc6 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 633ed06adf 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 4f68a2a411 fix(voice): 'gute Nacht' loest nicht mehr faelschlich Ohr-Aus aus
Regression aus dem Schlaf-Idiom-Commit (70fbc16): '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 5689474c03 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 70fbc164f4 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
81 changed files with 6967 additions and 590 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ssh root@10.0.0.1 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
]
}
}
+2
View File
@@ -15,6 +15,8 @@
# Werte pro Maschine selbst pflegen.
.claude/*.env
!.claude/*.env.example
# Lokale Claude-Permission-Allowlist mit echten Server-IPs — nicht ins (oeffentliche) Repo.
.claude/settings.json
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
+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")
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
+151 -53
View File
@@ -35,18 +35,17 @@ ARIA hat zwei Rollen:
│ WebSocket Tunnel │ WebSocket Tunnel
▼ ▼
┌─────────────────────────────────┐
│ Gamebox (Windows + WSL2) │
│ RTX 3060, Docker Desktop │
│ Compute-Node(s) (NVIDIA GPU) │
│ beliebig viele, je per .env │
│ konfiguriert (COMPOSE_PROFILES) │
│ ┌──────────────────────────┐ │
│ │ aria-f5tts-bridge │ │
│ │ F5-TTS Voice Cloning │ │
│ │ PCM-Streaming an die App │ │
│ ├──────────────────────────┤ │
│ │ aria-whisper-bridge │ │
│ │ Faster-Whisper CUDA │ │
│ │ STT in fast-Echtzeit │ │
│ │ aria-voxtral-bridge │ │ Profil: voxtral (Default-STT)
│ │ aria-f5tts-bridge │ │ Profil: f5tts (TTS)
│ │ aria-llm-adapter+swap │ │ Profil: llm (lokales LLM)
│ │ aria-whisper-bridge │ │ Profil: whisper (STT-Fallback)
│ └──────────────────────────┘ │
│ Beide teilen ./voices Volume │
│ Aufteilbar: 1 Node pro Dienst │
│ ODER All-in-One. GPU per *_GPU. │
│ xtts/docker-compose.yml │
└─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
@@ -95,12 +94,17 @@ ARIA hat zwei Rollen:
|-----|----|-----|
| RVS | Rechenzentrum | `cd rvs && 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` |
| Host-Agent(en) 💻 (optional) | Direkt auf einem Rechner | `cd host-agent && ./build.sh` → Binary + `.env` auf den Rechner, starten (Details unten & in [`host-agent/README.md`](host-agent/README.md)) |
| 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).
> 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 +139,8 @@ RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Gamebox im gleichen RVS-Room
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Gaming-PC, App)
# Pairing-Token: Verbindet App, Bridge, Diagnostic und Compute-Nodes im gleichen RVS-Room
# MUSS auf allen Geraeten identisch sein (ARIA-VM, Compute-Nodes, App)
RVS_TOKEN= # ./generate-token.sh
```
@@ -304,28 +308,28 @@ Danach wird der Proxy gepatcht:
## Voice Bridge
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Services
auf der Gamebox.
Die Bridge verbindet die Android App mit ARIA und orchestriert die GPU-Dienste
auf den Compute-Nodes.
**Nachrichtenfluss:**
```
Text: App → RVS → Bridge → aria-brain (HTTP)
Audio: App → RVS → Bridge → stt_request (RVS) → whisper-bridge (Gamebox)
→ stt_response → Bridge → aria-brain
Audio: App → RVS → STT-Node (voxtral/whisper) direkt (Streaming)
→ stt_endpoint/stt_stream_done → Bridge → aria-brain
Fallback bei Timeout: lokales faster-whisper (CPU)
Datei: App → RVS → Bridge → /shared/uploads/ → aria-brain (mit Pfad)
aria-brain → Antwort → Bridge → RVS → App
→ xtts_request (RVS) → f5tts-bridge
→ xtts_request (RVS) → f5tts-Node
→ audio_pcm Stream → RVS → App AudioTrack
```
### Features
- **STT primaer remote**: aria-bridge sendet `stt_request` an die Gamebox-Whisper
(faster-whisper CUDA, fast Echtzeit). 45s Timeout, dann Fallback auf lokales
CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config-Broadcast.
- **TTS via F5-TTS**: aria-f5tts-bridge auf der Gamebox. Voice Cloning mit
- **STT primaer remote**: die App streamt Audio direkt an einen STT-Node
(Voxtral-3B default, faster-whisper Fallback-Profil), fast Echtzeit. Timeout →
Fallback auf lokales CPU-Whisper. Modell-Wahl in Diagnostic, Hot-Swap via config.
- **TTS via F5-TTS**: aria-f5tts-bridge auf einem Compute-Node. Voice Cloning mit
Referenz-Audio + automatisch transkribiertem Referenz-Text.
- **Text-Cleanup**: `<voice>...</voice>` Tag bevorzugt; Markdown, Code,
Einheiten und URLs werden TTS-gerecht aufbereitet. Dezimalzahlen werden
@@ -486,7 +490,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **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%.
- **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
- **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
@@ -515,7 +519,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
- **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
- **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
- **TTS-Wiedergabe**: F5-TTS PCM-Streaming direkt in AudioTrack mit konfigurierbarem Pre-Roll-Buffer (1.0–6.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
@@ -655,7 +659,7 @@ Der Update-Flow:
App (Mikrofon) → AAC/MP4 Aufnahme → Base64 → RVS → Bridge
Bridge: FFmpeg (16kHz PCM) → Whisper STT → Text → aria-brain
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
```
@@ -797,36 +801,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).
Verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein VPN noetig, funktioniert
ueber verschiedene Netze hinweg.
Die GPU-Dienste laufen auf einem oder mehreren separaten Rechnern mit NVIDIA GPU.
Jeder **Compute-Node** verbindet sich ueber RVS mit der ARIA-Infrastruktur — kein
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)
├── aria-f5tts-bridge F5-TTS Voice Cloning + RVS-Relay
│ Hoert auf xtts_request, streamt audio_pcm
├── aria-whisper-bridge faster-whisper auf CUDA (float16)
│ Hoert auf stt_request, antwortet mit stt_response
└── ./voices/ Geteilt zwischen beiden:
{name}.wav — Referenz-Audio (~6-10s)
{name}.txt — Referenz-Text (auto via Whisper)
STT-Box COMPOSE_PROFILES=voxtral VOXTRAL_GPU=0
TTS-Box COMPOSE_PROFILES=f5tts F5TTS_GPU=0
LLM-Box COMPOSE_PROFILES=llm LLM_GPU=0
-- kleine Karte: Whisper (klein) statt Voxtral, neben F5-TTS --
Klein-Box COMPOSE_PROFILES=whisper,f5tts WHISPER_GPU=0 F5TTS_GPU=0
── oder All-in-One ──
AI-Box COMPOSE_PROFILES=voxtral,f5tts,llm VOXTRAL_GPU=1 F5TTS_GPU=0 LLM_GPU=0
↕ RVS (Rechenzentrum, WebSocket Relay)
ARIA-VM
└── aria-bridge: STT primaer remote (45s Timeout, dann lokaler CPU-Fallback)
TTS via xtts_request → audio_pcm Stream
└── aria-bridge: orchestriert TTS/LLM (xtts_request/llm_request),
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)
- NVIDIA Container Toolkit
- GPU mit mindestens 6GB VRAM (Whisper-large + F5-TTS gemeinsam)
Die STT-Node teilt sich das `./voices/`-Volume mit F5-TTS nur, wenn beide auf
demselben Node laufen (Referenz-Text-Transkription beim Voice-Upload). Auf
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!**
### Setup
@@ -834,13 +859,17 @@ ARIA-VM
```bash
cd xtts
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
# 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
und muessen nur einmal geladen werden.
Die Modelle liegen im Bind-Mount `./hf-cache/` (bzw. `./models/` fuer LLM-GGUFs)
und muessen pro Node nur einmal geladen werden.
### Features
@@ -862,7 +891,7 @@ In der Diagnostic unter Einstellungen → Sprachausgabe:
- **TTS aktiv**: Global An/Aus
- **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).
### Stimme klonen
@@ -908,6 +937,75 @@ dem Cache wiederverwendet.
---
## Host-Agenten 💻 — Direktzugriff auf einen Rechner
Ein **Host-Agent** läuft als **Standalone-Binary direkt auf einem Rechner**
(Linux) und verbindet sich **ausgehend** zum RVS (gleicher Token). Damit steuert
ARIA diesen Rechner direkt — auch wenn er sonst aus dem Netz **nicht erreichbar**
ist (hinter NAT/Firewall, kein offener Port). Unterschied zum Satelliten: der
Satellit ist ein LAN-Gateway (entdeckt/steuert *andere* Geräte); der Host-Agent
steuert den Rechner, auf dem er *läuft*.
**Fähigkeiten** (ARIA-Tools `host_list` / `host_exec` / `host_read` /
`host_write` / `host_info` / `host_screenshot`): Shell-Kommandos (optional
`sudo`), Datei lesen/schreiben, System-Info (CPU/RAM/Disk/Uptime), Screenshot
(ARIA öffnet ihn mit ihrem Read-Tool und *sieht* den Bildschirm; erscheint zudem
inline im Chat).
### 1. Binary bauen (portabel, Linux x86_64)
```bash
cd host-agent
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
```
Gebaut wird via PyInstaller in einem bullseye-Container (altes glibc) → läuft auf
möglichst vielen Distributionen. Keine Runtime auf dem Ziel nötig.
### 2. Auf dem Ziel-Rechner installieren
```bash
# dist/aria-host-agent auf den Rechner kopieren, dann:
cp .env.example .env # RVS-Zugang + CONTROL_ENABLED=true eintragen
chmod +x aria-host-agent && ./aria-host-agent
```
**Als systemd-Dienst** (Dauerbetrieb) — der Installer kopiert Binary + `.env`
an ihre Plätze und richtet den Dienst ein:
```bash
sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt
sudo ./install-service.sh # oder: ncurses-Dateidialog (dialog)
```
→ Binary nach `/usr/local/bin`, `.env` nach `/etc/aria-host-agent/.env` (0600),
Unit installiert + `enable --now`. Danach `journalctl -u aria-host-agent -f`.
### TLS / SNI — Agent im selben Netz wie der RVS
Verbindet der Agent direkt auf die **interne RVS-IP** (statt über den externen
Hostnamen per NAT-Hairpin), scheitert TLS sonst am fehlenden Cert für die IP
(`tlsv1 alert internal error`). Dann in der `.env`: `RVS_HOST=<interne-ip>` +
`RVS_SNI=<zert-name>` (z.B. `example.com`). Gilt genauso für Satelliten
und Compute-Nodes im RZ-Netz (`RVS_SNI` in deren `.env`).
### sudo
Der Agent wählt automatisch: **root** → direkt · `SUDO_PASSWORD` in der `.env`
→ `sudo -S` · **`SUDO_NOPASSWD=true`** → `sudo -n` (Live-ISO / passwortloses
sudo, z.B. Linux Mint vom Stick) · sonst klare Fehlermeldung.
### Sicherheit
Reagiert nur auf den eigenen RVS-Raum (Token) und nur bei `CONTROL_ENABLED=true`;
keine offenen Ports; alle Kommandos werden geloggt. Gibt **vollen** Rechnerzugriff
— nur auf Maschinen einsetzen, denen du ARIA anvertraust. Details:
[`host-agent/README.md`](host-agent/README.md).
> Sichtbar in der Diagnostic unter **Satelliten → Host-Agenten 💻**.
---
## Docker Volumes
| Volume / Bind | Pfad im Container | Zweck |
@@ -1024,7 +1122,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] Pre-Roll-Buffer einstellbar in App-Settings
- [x] Decimal-zu-Worte fuer TTS + generisches Acronym-Buchstabieren
- [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] 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)
@@ -1079,7 +1177,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
- [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] **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] **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
@@ -1106,4 +1204,4 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [ ] Desktop Client (Tauri)
- [ ] bKVM Remote IT-Support
- [ ] Custom-`.onnx`-Upload fuer Wake-Word ueber Diagnostic (ohne App-Rebuild)
- [ ] Claude Vision direkt (Bildanalyse ohne Dateipfad-Umweg)
- [x] Bildanalyse / Vision — ARIA sieht App-Uploads & Screenshots via Read-Tool (`/shared/uploads/` ist im Proxy-Container gemountet). „Direkt ohne Dateipfad" waere reine Politur (ein Read weniger) und ist bewusst NICHT geplant.
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20408
versionName "0.2.4.8"
versionCode 20500
versionName "0.2.5.0"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.4.8",
"version": "0.2.5.0",
"private": true,
"scripts": {
"android": "react-native run-android",
+5 -8
View File
@@ -34,13 +34,10 @@ interface FileUploadProps {
onCancel: () => void;
}
// Unterstuetzte Dateitypen
const SUPPORTED_TYPES = [
DocumentPicker.types.images,
DocumentPicker.types.pdf,
DocumentPicker.types.docx,
DocumentPicker.types.plainText,
];
// Alle Dateitypen zulassen — Stefan will ueber die Bueroklammer jede Datei
// hochladen koennen, nicht nur Bilder/Dokumente. Die Komponente verarbeitet
// beliebige Typen ohnehin (Base64 + application/octet-stream als Fallback).
const SUPPORTED_TYPES = [DocumentPicker.types.allFiles];
// --- Komponente ---
@@ -112,7 +109,7 @@ const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) =>
<TouchableOpacity style={styles.pickButton} onPress={pickFile} activeOpacity={0.7}>
<Text style={styles.pickIcon}>{'\uD83D\uDCC1'}</Text>
<Text style={styles.pickText}>Datei ausw\u00E4hlen</Text>
<Text style={styles.pickHint}>JPG, PNG, PDF, DOCX, TXT</Text>
<Text style={styles.pickHint}>Alle Dateitypen</Text>
</TouchableOpacity>
) : (
// Vorschau und Senden
+8 -5
View File
@@ -185,7 +185,10 @@ function stripSystemHints(text: string): string {
if (!m) break;
out = out.slice(m[0].length);
}
return out;
// Bestand die Nachricht NUR aus Klammer-Bloecken (z.B. "[Tool-Loop-Limit ...]"
// oder "[Fehler: ...]"), waere sie jetzt leer → dann das ORIGINAL zeigen. Sonst
// haette der User eine leere Bubble. Fehler-/Meta-Meldungen sollen sichtbar sein.
return out.trim() ? out : text;
}
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
function formatDur(sec: number): string {
@@ -357,7 +360,7 @@ const ChatScreen: React.FC = () => {
// folgende identische Events (z.B. zwei 'thinking' hintereinander) den
// Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
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 [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
@@ -1591,7 +1594,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
// soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
// Banner gucken zu muessen.
@@ -2289,7 +2292,7 @@ const ChatScreen: React.FC = () => {
// Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// 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.
// Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
// langsame STTs nicht versehentlich aufgeraeumt werden.
@@ -3060,7 +3063,7 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity>
</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);
if (entries.length === 0 || serviceBannerDismissed) return null;
+49 -2
View File
@@ -204,6 +204,39 @@ export async function transcribeBlob(pcmBase64: string, timeoutMs = 2500): Promi
});
}
/** 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> {
try {
const raw = await AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY);
@@ -383,6 +416,9 @@ class AudioService {
// lich Chunks einer alten Session in eine neue mischen.
private streamRequestId: 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
// worden? Wird auf false gesetzt beim startStreamingRecording, auf true beim
// ersten Endpoint (egal ob via RVS oder Fallback). Verhindert Doppel-Fires.
@@ -1126,6 +1162,15 @@ class AudioService {
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
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.streamEndpointFired = false;
this.recordingStartTime = Date.now();
@@ -1146,6 +1191,7 @@ class AudioService {
requestId: sessionId,
pcm: String(e?.pcm || ''),
seq: Number(e?.seq || 0),
targetInstance: this.streamTargetInstance,
});
});
this.streamPcmErrorSub = emitter.addListener('PcmStreamError', (e: any) => {
@@ -1181,6 +1227,7 @@ class AudioService {
hardCapMs: typeof opts.hardCapMs === 'number' ? opts.hardCapMs : 60000,
sampleRate: 16000,
projectId: opts.projectId || '',
targetInstance: this.streamTargetInstance,
});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
@@ -1230,7 +1277,7 @@ class AudioService {
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
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) {
console.warn('[Audio] stt_stream_end senden fehlgeschlagen:', e);
}
@@ -1265,7 +1312,7 @@ class AudioService {
if (!reqId) return;
const audioReqId = this.streamAudioRequestId;
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 {}
this._cleanupStreamLocal(`cancel:${reason}`);
// Listener feuern damit ChatScreen reagieren kann (endConversation etc.)
+363 -15
View File
@@ -20,6 +20,7 @@ import json
import logging
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -132,6 +133,34 @@ WEB_SEARCH_TOOL = {
# Meta-Tool: ARIA kann selbst neue Skills bauen
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",
"function": {
@@ -831,7 +860,7 @@ META_TOOLS = [
"function": {
"name": "flux_generate",
"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, "
"Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan "
"kurz an, dann ist er nicht ueberrascht.\n\n"
@@ -1156,9 +1185,11 @@ META_TOOLS = [
"description": (
"Zeigt welche ARIA-Satelliten (Aussenposten-Container in FREMDEN Netzen, "
"z.B. 'Buero') gerade ONLINE sind und was sie koennen (discover / "
"dial.launch / wol / http). Nutze das ZUERST, wenn Stefan etwas 'im "
"Buero' / 'im Netz X' / 'auf dem <Geraet> dort' machen will — so weisst "
"Du welche Netze erreichbar sind."
"dial.launch / wol / http). Nutze das ZUERST, wenn es um ein Geraet/einen "
"Host in einem Netz geht, auf dem Du nicht direkt sitzt (Zuhause, Buero, "
"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": {}},
},
@@ -1169,8 +1200,11 @@ META_TOOLS = [
"name": "satellite_devices",
"description": (
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
"Smart-TVs, Drucker, Switches, Router, APs, NAS, Hosts ...). Nutze es um "
"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": {
"type": "object",
@@ -1190,8 +1224,32 @@ META_TOOLS = [
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks. "
"Geht nur, wenn der Satellit Steuerung erlaubt (siehe satellite_list)."
"'snmp.printer' (params={'ip':'<drucker-ip>'}) — BEVORZUGT fuer Drucker-"
"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). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
"oder Key). Fuer Geraete mit hinterlegten Zugangsdaten "
"(community/v3/Login/SSH) 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": {
"type": "object",
@@ -1205,6 +1263,127 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "host_list",
"description": (
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
"(exec/read/write/info/screenshot/ui_dump; Android-Agenten koennen "
"screenshot+ui_dump). Ein Host-Agent gibt Dir vollen "
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
"machen will, das kein Geraet im Netz ist."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "host_exec",
"description": (
"Fuehrt EIN Shell-Kommando auf einem Host-Agenten aus (bash -lc). "
"Gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr. Fuer Root-Rechte sudo=true setzen (Agent nutzt root/"
"SUDO_PASSWORD/NOPASSWD automatisch). Sei vorsichtig — das ist "
"vollwertiger Rechnerzugriff."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name aus host_list."},
"cmd": {"type": "string", "description": "Shell-Kommando."},
"sudo": {"type": "boolean", "description": "Mit Root-Rechten ausfuehren."},
"timeout": {"type": "number", "description": "max. Laufzeit in Sekunden (Default 60)."},
},
"required": ["host", "cmd"],
},
},
},
{
"type": "function",
"function": {
"name": "host_read",
"description": "Liest eine Datei von einem Host-Agenten (Text). Fuer Binaerdateien liefert der Agent Base64.",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"path": {"type": "string", "description": "Absoluter Pfad auf dem Rechner."},
},
"required": ["host", "path"],
},
},
},
{
"type": "function",
"function": {
"name": "host_write",
"description": "Schreibt Text in eine Datei auf einem Host-Agenten (ueberschreibt; append=true haengt an).",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"path": {"type": "string", "description": "Zielpfad."},
"text": {"type": "string", "description": "Inhalt."},
"append": {"type": "boolean", "description": "Anhaengen statt ueberschreiben."},
},
"required": ["host", "path", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_info",
"description": "System-Info eines Host-Agenten: OS, CPU/RAM/Disk-Auslastung, Uptime, IP.",
"parameters": {
"type": "object",
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
"name": "host_screenshot",
"description": (
"Macht ein Bildschirmfoto auf einem Host-Agenten (nur wenn dort eine "
"grafische Session laeuft). Liefert einen Datei-Pfad unter /shared/uploads/. "
"Du KANNST den Screenshot selbst ansehen: oeffne den Pfad mit deinem "
"Read-Tool (Bild wird gerendert) — so beantwortest Du 'was ist auf meinem "
"Bildschirm'. Und schreibe den Pfad als [FILE: ...]-Marker in die Antwort, "
"damit die App das Bild inline zeigt."
),
"parameters": {
"type": "object",
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_dump",
"description": (
"Liest die sichtbaren Bedienelemente eines Host-Agenten als strukturierte "
"Liste (Text, Beschriftung, Klasse, Bildschirm-Position x/y, Rahmen, ob "
"klickbar/editierbar). Vor allem fuer Android-Agenten: ergaenzt "
"host_screenshot — der Screenshot zeigt Dir das Bild, ui_dump liefert die "
"exakten Element-Texte und Koordinaten, um spaeter gezielt zu tippen. Setzt "
"auf dem Geraet eine aktive Bedienungshilfe voraus."
),
"parameters": {
"type": "object",
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
@@ -1455,7 +1634,12 @@ _WAKE_OFF_RE = re.compile(
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+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,
)
@@ -1685,9 +1869,12 @@ class Agent:
return False
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b): web_search (local-only,
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill.
# Bewusst klein (Speed + Sicherheit); alles andere → Claude.
_LOCAL_TOOL_NAMES = {"memory_search", "trigger_timer"}
# SearXNG) + memory_search/trigger_timer (aus META_TOOLS) + Spotify-Skill +
# die Satelliten-Tools (Augen/Haende in fremden Netzen — auch das lokale Tier
# 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:
tools = [WEB_SEARCH_TOOL]
@@ -1877,7 +2064,10 @@ class Agent:
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
# Schutz vor Endlos-Loops, aber hoch genug fuer echte agentische Arbeit
# (Host-Agent-Exploration, Multi-Datei-Tasks brauchen viele Tool-Runden).
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None,
@@ -2132,6 +2322,7 @@ class Agent:
# Konversation bleiben gesprochen.
self._claude_turn_speak = True
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
result = self.proxy.chat_full(messages, tools=tools,
@@ -2175,7 +2366,10 @@ class Agent:
break
else:
# Loop-Limit erreicht
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
final_reply = (
f"Ich hab die Aufgabe nach {self.MAX_TOOL_ITERATIONS} Arbeitsschritten "
f"gestoppt, damit ich nicht endlos weiterlaufe — war aber noch mittendrin. "
f"Sag mir, ob ich weitermachen oder es anders angehen soll.")
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
if not final_reply:
@@ -2245,8 +2439,16 @@ class Agent:
converse = False
elif _wants_continue:
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).
return (final_reply, "claude", speak, converse, awaiting_reply)
return (final_reply, answered_by, speak, converse, awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
@@ -2354,6 +2556,140 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""host_list / host_exec / host_read / host_write / host_info /
host_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent."""
import base64 as _b64
def _post(path: str, body: dict, timeout: float) -> dict:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", "ignore"))
try:
if name == "host_list":
result = _post("/internal/host-list", {}, 10)
hosts = result.get("hosts") or []
if not hosts:
return ("Gerade ist kein Host-Agent online. (Ein Host-Agent laeuft "
"direkt auf einem Rechner und erlaubt Dir, ihn zu steuern — "
"auch hinter NAT/Firewall.)")
lines = []
for h in hosts:
status = "online" if h.get("online") else "offline"
caps = ", ".join(h.get("caps") or [])
lines.append(f"- {h.get('name')} (id={h.get('hostId')}, {status}, "
f"{h.get('os', '')}; kann: {caps})")
return "Host-Agenten (Rechner):\n" + "\n".join(lines)
host = (arguments.get("host") or "").strip()
if not host:
return "FEHLER: host ist Pflicht (siehe host_list)."
if name == "host_exec":
cmd = (arguments.get("cmd") or "").strip()
if not cmd:
return "FEHLER: cmd ist Pflicht."
params = {"cmd": cmd}
if arguments.get("sudo"):
params["sudo"] = True
timeout = float(arguments.get("timeout") or 60)
result = _post("/internal/host", {"host": host, "action": "exec",
"params": params, "timeout": timeout + 10},
timeout + 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
note = f"exit={r.get('exit_code')}"
if r.get("truncated"):
note += f" (stdout gekuerzt, {r.get('total_chars')} Zeichen gesamt)"
out = f"[{note}]\n{r.get('body', '')}"
stderr = (r.get("stderr") or "").strip()
if stderr:
out += f"\n--- stderr ---\n{stderr[:1500]}"
return out
if name == "host_read":
path = (arguments.get("path") or "").strip()
result = _post("/internal/host", {"host": host, "action": "read",
"params": {"path": path}}, 30)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
try:
text = _b64.b64decode(r.get("base64", "")).decode("utf-8", "replace")
except Exception:
text = "(Binaerdatei — nicht als Text darstellbar)"
trunc = " (gekuerzt)" if r.get("truncated") else ""
return f"{r.get('path')} ({r.get('size')} Bytes){trunc}:\n{text[:8000]}"
if name == "host_write":
text = arguments.get("text") or ""
params = {"path": (arguments.get("path") or "").strip(),
"base64": _b64.b64encode(text.encode("utf-8")).decode("ascii"),
"append": bool(arguments.get("append"))}
result = _post("/internal/host", {"host": host, "action": "write",
"params": params}, 30)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
return f"OK — {r.get('bytes')} Bytes nach {r.get('path')} geschrieben."
if name == "host_info":
result = _post("/internal/host", {"host": host, "action": "info",
"params": {}}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
return f"System-Info {host}:\n" + json.dumps(result.get("result") or {},
ensure_ascii=False, indent=2)
if name == "host_screenshot":
result = _post("/internal/host", {"host": host, "action": "screenshot",
"params": {}}, 30)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
# Wie flux_generate: PNG nach /shared/uploads/ (nur dieser Pfad
# matcht den [FILE:]-Marker) -> Bridge broadcastet file_from_aria,
# App/Diagnostic zeigen es inline im Chat.
try:
d = "/shared/uploads"
os.makedirs(d, exist_ok=True)
safe = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-") or "host"
fp = os.path.join(d, f"aria_screenshot_{safe}_{int(time.time())}.png")
with open(fp, "wb") as f:
f.write(_b64.b64decode(r.get("base64", "")))
except Exception as exc:
return f"Screenshot erstellt ({r.get('bytes')} Bytes), Speichern fehlgeschlagen: {exc}"
return (
f"OK — Screenshot von {host} erstellt ({r.get('bytes', 0) // 1024} KB).\n"
f"path: {fp}\n\n"
f"UM DEN BILDSCHIRM ZU SEHEN: Oeffne die Datei mit deinem Read-Tool "
f"(Read {fp}) — Du bekommst das Bild dann angezeigt und kannst "
f"beschreiben/entscheiden, was darauf zu sehen ist.\n"
f"UM ES STEFAN ZU ZEIGEN: Schreibe den Pfad EXAKT als Marker "
f"[FILE: {fp}] in deine Antwort — dann erscheint das Bild inline im Chat."
)
if name == "host_ui_dump":
result = _post("/internal/host", {"host": host, "action": "ui_dump",
"params": {}}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
nodes = r.get("nodes") or []
return (
f"UI-Baum von {host} (App: {r.get('package', '?')}, "
f"{r.get('count', len(nodes))} Elemente). Jeder Eintrag hat x/y = "
f"Mittelpunkt zum Antippen:\n"
+ json.dumps(nodes, ensure_ascii=False, indent=2)
)
return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
@@ -2841,6 +3177,15 @@ class Agent:
f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als "
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":
query = (arguments.get("query") or "").strip()
if not query:
@@ -3137,6 +3482,9 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot", "host_ui_dump"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
if not pid:
+1 -1
View File
@@ -1,7 +1,7 @@
"""
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
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 "
"danach kurz weiter (Stefan kann einfach antworten, ohne 'Computer').",
"- 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.",
])
+24 -6
View File
@@ -53,11 +53,10 @@ SEED_RULES: List[dict] = [
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
"\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
"ein Kunden-Produktionssystem (Production!) angesetzt statt gegen "
"dessen Staging-Umgebung (Staging). Stefan "
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen ein "
"Kunden-PRODUKTIONSSYSTEM angesetzt statt gegen dessen Staging-"
"Umgebung. Stefan musste explizit korrigieren. Haette ARIA einen "
"Factory-Reset-Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"\n"
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
"Anweisung. Stefan kann sie temporaer per expliziter "
@@ -475,13 +474,32 @@ SEED_RULES: List[dict] = [
"zeigt UND steuerbar macht. So erreichst Du Netze, in denen der Haupt-Stack "
"NICHT steht.\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"
" 2. `satellite_devices(satellite='Buero')` — welche Geraete gibt es dort "
"(Fire TV, Chromecast, Smart-TVs, Drucker, NAS ...). Nutze es um das "
"gemeinte Geraet zu finden, BEVOR Du steuerst.\n"
" 3. `satellite_command(...)` — Aktion ausfuehren.\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"
" satellite_command(satellite='Buero', device='Fire TV', "
"action='dial.launch', params={'app':'YouTube','v':'<videoId>'})\n"
+272 -35
View File
@@ -2,7 +2,7 @@
ARIA Voice Bridge — Hauptmodul.
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).
Nachrichtenfluss:
@@ -479,7 +479,7 @@ class STTEngine:
Erkannter Text oder leerer String.
"""
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.
logger.info("Lokales Whisper-Fallback — Modell wird nachgeladen...")
try:
@@ -654,7 +654,7 @@ class ARIABridge:
self._seen_client_msg_ids: "OrderedDict[str, float]" = OrderedDict()
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.xtts_voice = ""
self._f5tts_config: dict = {}
@@ -686,7 +686,7 @@ class ARIABridge:
except (TypeError, ValueError):
self._persistent_xtts_speed = None
# 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)
for k in ("f5ttsModel", "f5ttsCkptFile", "f5ttsVocabFile",
"f5ttsCfgStrength", "f5ttsNfeStep"):
@@ -746,7 +746,7 @@ class ARIABridge:
# Gleiche Logik fuer die Wiedergabegeschwindigkeit (F5-TTS speed-Param,
# App-Setting aria_tts_speed, 1.0 = normal).
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).
self._pending_stt: dict[str, asyncio.Future] = {}
# whisper-bridge service_status: True wenn ready, False/None wenn loading/unbekannt.
@@ -766,7 +766,17 @@ class ARIABridge:
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
self._satellites: dict[str, dict] = {}
self._pending_sat: dict[str, asyncio.Future] = {}
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
# Host-Agenten (Direktzugriff auf einen Rechner). hostId → {name, os,
# caps, control, last_seen}. Registrierung via host_hello/host_ping.
# _pending_host: requestId → Future (host_result), analog _pending_sat.
self._hosts: dict[str, dict] = {}
self._pending_host: dict[str, asyncio.Future] = {}
# 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).
self._pending_flux: dict[str, asyncio.Future] = {}
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
@@ -774,7 +784,7 @@ class ARIABridge:
self._remote_flux_ready: bool = False
# Lokales LLM (Plan B): requestId → Future mit dem llm_response-Payload.
# Analog zu _pending_flux — Brain ruft /internal/local-llm, wir relayen
# llm_request via RVS an den llm-adapter (Gamebox) und warten auf
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
# llm_response.
self._pending_llm: dict[str, asyncio.Future] = {}
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
@@ -810,7 +820,7 @@ class ARIABridge:
logger.info("ARIA Voice Bridge startet...")
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
# antwortet. Das spart RAM auf der VM und Startup-Zeit.
@@ -1679,20 +1689,27 @@ class ARIABridge:
if len(self._xtts_request_to_message) > 100:
oldest = next(iter(self._xtts_request_to_message))
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({
"type": "xtts_request",
"payload": {
"text": tts_text,
"voice": xtts_voice,
"speed": xtts_speed,
"language": "de",
"requestId": xtts_request_id,
"messageId": message_id,
},
"payload": tts_payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx): '%s'",
xtts_voice or "default", xtts_speed, tts_text[:60])
logger.info("[core] XTTS-Request gesendet (voice=%s, speed=%.2fx, target=%s): '%s'",
xtts_voice or "default", xtts_speed, tts_target or "(broadcast)", tts_text[:60])
except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
@@ -1747,7 +1764,7 @@ class ARIABridge:
"""Broadcastet die aktuelle voice_config.json einmalig nach RVS-Connect.
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.
"""
try:
@@ -2190,7 +2207,7 @@ class ARIABridge:
await self._broadcast_current_mode()
# 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
# ihren Hard-Defaults).
asyncio.create_task(self._broadcast_persisted_config())
@@ -2551,7 +2568,7 @@ class ARIABridge:
elif msg_type == "config":
# Konfiguration von App/Diagnostic empfangen + persistent speichern.
# 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.
changed = False
if "ttsEnabled" in payload:
@@ -2575,7 +2592,7 @@ class ARIABridge:
new_model = payload["whisperModel"]
allowed = {"tiny", "base", "small", "medium", "large-v3"}
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)
self.stt_engine.model_size = new_model
self.stt_engine.model = None
@@ -2794,8 +2811,18 @@ class ARIABridge:
"message": payload.get("message", ""),
"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")
# 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",
line["level"], line["scope"], line["message"][:120])
except Exception as exc:
@@ -3436,7 +3463,7 @@ class ARIABridge:
return
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", "")
future = self._pending_llm.get(request_id)
if future is None or future.done():
@@ -3445,7 +3472,7 @@ class ARIABridge:
return
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
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
@@ -3528,6 +3555,96 @@ class ARIABridge:
self._satellites[sid]["caps"], self._satellites[sid]["control"])
return
elif msg_type == "host_hello":
hid = (payload.get("hostId") or "").strip()
if hid:
new = hid not in self._hosts
self._hosts[hid] = {
"hostId": hid,
"name": payload.get("name") or hid,
"os": payload.get("os") or "",
"caps": payload.get("caps") or [],
"control": bool(payload.get("control")),
"last_seen": time.time(),
}
if new:
logger.info("[host] Agent online: %s (%s) caps=%s control=%s",
hid, self._hosts[hid]["name"],
self._hosts[hid]["caps"], self._hosts[hid]["control"])
return
elif msg_type == "host_ping":
hid = (payload.get("hostId") or "").strip()
if hid and hid in self._hosts:
self._hosts[hid]["last_seen"] = time.time()
return
elif msg_type == "host_result":
req_id = payload.get("requestId", "")
future = self._pending_host.get(req_id)
if future is not None and not future.done():
future.set_result(payload)
hid = (payload.get("hostId") or "").strip()
if hid and hid in self._hosts:
self._hosts[hid]["last_seen"] = time.time()
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"):
req_id = payload.get("requestId", "")
future = self._pending_sat.get(req_id)
@@ -3553,11 +3670,11 @@ class ARIABridge:
else:
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:
# ready=True → 45s reicht selbst fuer lange Audios
# 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_LOADING_S = 300.0
@@ -3910,15 +4027,15 @@ class ARIABridge:
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
# ── Local-LLM-Roundtrip: Brain → Bridge → RVS → llm-adapter → zurueck ──
# Qwen3 auf der Gamebox antwortet auf kurze Turns in <1 s. Grosszuegiger
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (Gamebox@home)
# Qwen3 auf der AI-Box antwortet auf kurze Turns in <1 s. Grosszuegiger
# Timeout deckt Kaltstart / laengere Antworten / Netz-Jitter (AI-Box@home)
# ab. Bei Timeout faellt der Router im Brain per Escalation auf Claude.
_LLM_TIMEOUT_S = 30.0
async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf
"""Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
if self.ws_rvs is None:
@@ -3943,8 +4060,15 @@ class ARIABridge:
req_payload["tools"] = tools
if model:
req_payload["model"] = model
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d, model=%s)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0, model or "-")
# Redundanz/Multitasking: freie llm-Instanz gezielt adressieren, die
# 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({
"type": "llm_request",
"payload": req_payload,
@@ -3955,7 +4079,7 @@ class ARIABridge:
try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — Gamebox nicht erreichbar?"}
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"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "llm-adapter Fehler"}
@@ -4435,6 +4559,27 @@ class ARIABridge:
elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
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/host-list":
# Brain fragt: welche Host-Agenten (Rechner) sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "hosts": self._host_list()})
elif method == "POST" and path == "/internal/host":
# Brain-Tool: Kommando an einen Host-Agenten.
# body: {host, action, params?, timeout?}
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
result = await self._host_request(
host=str(data.get("host") or ""),
action=str(data.get("action") or ""),
params=data.get("params") if isinstance(data.get("params"), dict) else {},
timeout=float(data.get("timeout") or 60.0),
)
await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4455,7 +4600,7 @@ class ARIABridge:
await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/flux-generate":
# 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
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
# Brain referenziert das Bild dann mit [FILE:]-Marker in
@@ -4492,7 +4637,7 @@ class ARIABridge:
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/local-llm":
# Vom Brain (Router / Testchat) gefeuert. Wir relayen den
# Chat-Request via RVS an den llm-adapter (Gamebox Qwen3),
# Chat-Request via RVS an den llm-adapter (AI-Box Qwen3),
# warten synchron auf llm_response und geben content zurueck.
try:
data = json.loads(body.decode("utf-8", "ignore"))
@@ -4684,6 +4829,60 @@ class ARIABridge:
})
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 = "",
device: str = "", action: str = "",
params: Optional[dict] = None,
@@ -4720,6 +4919,44 @@ class ARIABridge:
finally:
self._pending_sat.pop(request_id, None)
def _host_list(self) -> list[dict]:
"""Bekannte Host-Agenten (frisch = in den letzten 5 Min gesehen)."""
now = time.time()
return [{
"hostId": h["hostId"], "name": h.get("name") or h["hostId"],
"os": h.get("os") or "", "caps": h.get("caps") or [],
"control": bool(h.get("control")),
"online": (now - h.get("last_seen", 0)) < 300,
} for h in self._hosts.values()]
async def _host_request(self, host: str = "", action: str = "",
params: Optional[dict] = None,
timeout: float = 60.0) -> dict:
"""Schickt host_command an einen Host-Agenten (via RVS) und wartet auf
host_result. Muster identisch zu _satellite_request."""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_host[request_id] = future
try:
msg = {"type": "host_command",
"payload": {"requestId": request_id, "host": host,
"action": action, "params": params or {}},
"timestamp": int(time.time() * 1000)}
ok = await self._send_to_rvs(msg)
if not ok:
return {"ok": False, "error": "Host-Request konnte nicht gesendet werden"}
result = await asyncio.wait_for(future, timeout=timeout)
return result if isinstance(result, dict) else {"ok": False, "error": "ungueltige Antwort"}
except asyncio.TimeoutError:
return {"ok": False, "error": f"Host '{host or 'all'}' antwortet nicht (Timeout)."}
except Exception as exc:
return {"ok": False, "error": str(exc)}
finally:
self._pending_host.pop(request_id, None)
async def _delete_chat_message(self, ts: int) -> dict:
"""Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation,
broadcastet chat_message_deleted via RVS.
+922 -267
View File
File diff suppressed because it is too large Load Diff
+310 -6
View File
@@ -349,6 +349,66 @@ function loadLocalModels() {
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 ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt.
@@ -490,6 +550,97 @@ function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() });
}
// ── Host-Agenten: Direktzugriff auf einen Rechner ────────────────
const hosts = new Map(); // hostId → {hostId, name, os, caps, control, last_seen}
function hostList() {
const now = Date.now();
return Array.from(hosts.values()).map(h => ({
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
online: (now - (h.last_seen || 0)) < 300000,
}));
}
function broadcastHosts() {
broadcast({ type: "host_update", hosts: hostList() });
}
// ── 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 ─────────────────────────
async function connectGateway() {
@@ -929,12 +1080,90 @@ function connectRVS(forcePlain) {
});
broadcastSatellites();
}
} else if (msg.type === "host_hello") {
// Ein Host-Agent (Direktzugriff auf einen Rechner) meldet sich.
const p = msg.payload || {};
if (p.hostId) {
const wasKnown = hosts.has(p.hostId);
hosts.set(p.hostId, {
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
caps: p.caps || [], control: !!p.control, last_seen: Date.now(),
});
if (!wasKnown) broadcastHosts();
else hosts.get(p.hostId).last_seen = Date.now();
}
} else if (msg.type === "host_ping") {
const p = msg.payload || {};
if (p.hostId && hosts.has(p.hostId)) hosts.get(p.hostId).last_seen = Date.now();
} else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} });
} else if (msg.type === "sat_devices") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {};
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
broadcast({ type: "sat_devices", satellite: p.satellite || "",
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") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
@@ -981,7 +1210,7 @@ function connectRVS(forcePlain) {
}
broadcast({ type: "voice_ready", payload: msg.payload });
} 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
const svc = msg.payload?.service || "?";
const state = msg.payload?.state || "?";
@@ -996,6 +1225,12 @@ function connectRVS(forcePlain) {
log("info", "rvs", `service_status ${svc} ${state}${model ? ` (${model})` : ""}`);
}
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) {
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
_handlePreviewChunk(msg.payload);
@@ -1040,7 +1275,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;
const proto = RVS_TLS === "true" ? "wss" : "ws";
const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`;
@@ -1048,7 +1283,7 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) {
const timeout = setTimeout(() => {
try { freshWs.close(); } catch (_) {}
clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() }));
}, 15000);
}, timeoutMs);
freshWs.on("open", () => {
freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() }));
});
@@ -1738,6 +1973,20 @@ const server = http.createServer((req, res) => {
} else if (req.url === "/api/local-models-list" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: loadLocalModels() }));
} else if (req.url === "/api/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") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig()));
@@ -2505,6 +2754,10 @@ wss.on("connection", (ws) => {
if (currentDiskStatus) ws.send(JSON.stringify(currentDiskStatus));
// Aktuell bekannte Satelliten mitgeben (RVS replayt sat_hello nicht).
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
// Aktuell bekannte Host-Agenten mitgeben.
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
// Aktuell bekannte Compute-Worker mitgeben (RVS replayt worker_hello nicht).
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
ws.on("message", (raw) => {
try {
@@ -2536,11 +2789,35 @@ wss.on("connection", (ws) => {
} else if (msg.action === "sat_list") {
// Browser will die aktuelle Satelliten-Liste.
ws.send(JSON.stringify({ type: "sat_update", satellites: satelliteList() }));
} else if (msg.action === "host_list") {
// Browser will die aktuelle Host-Agenten-Liste.
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
} else if (msg.action === "rooms_query") {
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
} 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") {
// Browser triggert einen Geraete-Scan auf einem Satelliten.
sendToRVS_raw({ type: "sat_discover",
payload: { satellite: msg.satellite || "", force: true },
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") {
testProxy(msg.text);
} else if (msg.action === "check_proxy_auth") {
@@ -2568,7 +2845,7 @@ wss.on("connection", (ws) => {
// Datei von Diagnostic an Bridge via RVS senden
sendToRVS_raw({
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(),
});
log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`);
@@ -2611,9 +2888,11 @@ wss.on("connection", (ws) => {
// 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);
} 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() });
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") {
// Bubble loeschen — Bridge raeumt chat_backup.jsonl + Brain-conversation
// + broadcastet chat_message_deleted via RVS.
@@ -2702,6 +2981,31 @@ wss.on("connection", (ws) => {
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
// 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") {
handleRestartSession(ws);
// ── Einstellungen ──
+2 -2
View File
@@ -1,7 +1,7 @@
# FLUX.1-dev Bildgenerierung — Architektur & Stand
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
RVS verbindet und auf seinen Request-Typ lauscht.
@@ -23,7 +23,7 @@ aria-bridge ── send_to_core ──▶ aria-brain
RVS
│ fanout
▼
flux-bridge (Gamebox)
flux-bridge (AI-Box)
│ FluxPipeline.from_pretrained(...)
│ pipeline(prompt, width, height, steps, guidance).images[0]
│ 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
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
brechen, kostet aber API-Geld → verliert die Max-Subscription. Ein lokales
LLM für die einfachen Turns umgeht den 3,5-s-Boden komplett und ist **gratis**
(läuft auf vorhandener Gamebox-GPU). Echtes Speech-to-Speech-Duplex (Gemini
(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.
## 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-
Klasse, solides Deutsch, Apache-2.0. Alt.: Mistral Small 3 7B (schneller,
weniger Tool-Calling).
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der Gamebox
- **Serving:** **llama.cpp `llama-server`** im Docker-Container auf der AI-Box
(kein Ollama nötig — nativer OpenAI-kompatibler `/v1/chat/completions`).
- **VRAM-Budget:** 12-GB-Karte, Whisper-small (~1–2 GB) + F5-TTS (~1–2 GB) →
~8–9 GB frei → passt. (FLUX ist auf 12 GB eh raus.)
## Anbindung: über den RVS, wie TTS/STT (kein IP-Pflegen)
Die Gamebox ist ein anderer Host als das Brain. Statt direktem HTTP (IP/Port/
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:
- 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
sich mit dem RVS-Token, lauscht auf `llm_request`, ruft lokal llama-server,
schickt `llm_response` (korreliert per requestId) zurück.
@@ -115,7 +115,7 @@ mit Ziel lokal.
## 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").
- **B1 — Router + lokale Tools:** Heuristik Tier-1/2 + Escalation, schlanke
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 +
Broadcast-Kollisions-Vermeidung (nicht 2× dieselbe Faehigkeit). = Mini-Nomad.
**Empfehlung:** Fuer 2 Gameboxen NICHT bauen — statische Platzierung reicht
(Gamebox1=LLM, Gamebox2=Voice). Dynamisches Laden/Entladen zum VRAM-Freimachen
**Empfehlung:** Fuer 2 AI-Boxen NICHT bauen — statische Platzierung reicht
(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
billigen Heartbeat-Teil; fuer echte Orchestrierung Docker Swarm / Nomad nehmen
statt selbst einen Scheduler zu bauen.
@@ -227,7 +227,7 @@ lohnt nicht):
einen Heartbeat via RVS (Host, GPU-Util, VRAM frei/belegt, laufende
GPU-Container). Diagnostic zeigt pro Host VRAM-Balken + Dienste + „Host X hat
N GB frei". Kein Start/Stop, nur Sicht + Hinweis wohin verschiebbar.
- **Zukunft (Gamebox3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
- **Zukunft (AI-Box3, 4×3060 = 48 GB):** neuer Host, eigenes Profil, `up` →
erscheint im Dashboard; grosses lokales LLM oder FLUX-Vollausbau dorthin.
Ohne Orchestrator.
+1 -1
View File
@@ -1,6 +1,6 @@
#!/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
flux_response mit base64-PNG zurueck an die aria-bridge. Diese speichert
+38
View File
@@ -0,0 +1,38 @@
# ─── ARIA Host-Agent — Konfiguration ───────────────────────────────
# Kopiere diese Datei nach .env (neben die Binary) und passe sie an.
# RVS-Zugang (identisch zum Haupt-Stack — gleicher Raum/Token, damit ARIA
# diesen Rechner erreicht). Werte aus der Haupt-.env.
RVS_HOST=rvs.example.de
RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
RVS_TOKEN=
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Agent im selben Netz wie der
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── Identitaet dieses Rechners ────────────────────────────────────
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
# HOST_NAME = menschlicher Name, so spricht ARIA den Rechner an ("Stefans Laptop").
HOST_ID=
HOST_NAME=
# ─── Steuerung (Sicherheit!) ───────────────────────────────────────
# MUSS auf true, sonst fuehrt der Agent nichts aus (reiner Idle-Client).
CONTROL_ENABLED=true
# ─── sudo ──────────────────────────────────────────────────────────
# Reihenfolge: 1) Agent laeuft als root -> braucht kein sudo. 2) SUDO_PASSWORD
# gesetzt -> sudo -S mit Passwort. 3) SUDO_NOPASSWD=true -> sudo -n (Live-ISO /
# passwortloses sudo, z.B. Linux Mint vom Stick). 4) sonst schlaegt sudo fehl.
SUDO_PASSWORD=
SUDO_NOPASSWD=false
# ─── Limits (optional) ─────────────────────────────────────────────
EXEC_TIMEOUT=60 # max. Laufzeit eines Kommandos (s)
OUT_MAX_CHARS=20000 # stdout-Ausschnitt (offset/max_chars pro Request)
FILE_MAX_BYTES=10485760 # max. Datei-Transfer (10 MB)
+6
View File
@@ -0,0 +1,6 @@
.env
build/
dist/
.buildenv/
*.spec
__pycache__/
+24
View File
@@ -0,0 +1,24 @@
# Baut die Host-Agent-Binary mit PyInstaller in einem Container mit ALTEM glibc
# (bullseye, glibc 2.31), damit die Onefile-Binary auf moeglichst vielen Linux-
# Distributionen laeuft (glibc ist abwaerts-, nicht aufwaertskompatibel).
FROM python:3.11-slim-bullseye
WORKDIR /build
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
RUN apt-get update \
&& apt-get install -y --no-install-recommends binutils \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir pyinstaller
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY host_agent.py .
# Onefile-Binary; psutil-Hidden-Imports werden von PyInstaller erkannt.
RUN pyinstaller --onefile --name aria-host-agent \
--collect-all psutil \
host_agent.py
# Ergebnis liegt in /build/dist/aria-host-agent
CMD ["sh", "-c", "cp /build/dist/aria-host-agent /out/ && echo 'Binary -> /out/aria-host-agent'"]
+36
View File
@@ -0,0 +1,36 @@
# Baut die Windows-.exe des Host-Agents AUF LINUX — via Wine + Windows-Python +
# PyInstaller. tobix/pywine bringt Wine + Windows-Python 3.11 mit (PyInstaller
# kann NICHT cross-compilen, deshalb der Wine-Umweg).
#
# Zusaetzlich baut NSIS ein setup.exe, das die Agent-.exe installiert, eine .env
# in C:\ProgramData\ARIA-Host-Agent anlegt (falls keine da ist) und den Agent als
# automatisch startenden Windows-Dienst (via nssm) einrichtet.
FROM tobix/pywine:3.11
ARG VERSION=0.0.0
# NSIS (Installer-Compiler, laeuft nativ auf Linux) + Tools fuer nssm.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nsis curl unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
&& unzip -q /tmp/nssm.zip -d /tmp \
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
&& rm -rf /tmp/nssm*
COPY requirements.txt host_agent.py ./
COPY windows/installer.nsi ./
# Windows-Python-Deps + PyInstaller, dann die Onefile-.exe bauen.
RUN wine pip install --no-cache-dir -r requirements.txt pyinstaller
RUN wine pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py \
&& cp dist/aria-host-agent.exe ./aria-host-agent.exe \
&& makensis -DVERSION=${VERSION} installer.nsi
# Beide Artefakte rausreichen (dist/ wird vom build-win.sh als Volume gemountet).
CMD ["bash","-lc","cp dist/aria-host-agent.exe /out/ && cp aria-host-agent-setup.exe /out/ && echo 'OK -> /out/aria-host-agent.exe + /out/aria-host-agent-setup.exe'"]
+178
View File
@@ -0,0 +1,178 @@
# ARIA Host-Agent
Ein schlanker Agent, der **direkt auf einem Rechner** läuft und ARIA erlaubt,
diesen Rechner zu steuern — auch wenn er sonst aus dem Netz **nicht erreichbar**
ist (hinter NAT/Firewall, kein offener Port). Der Agent verbindet sich
**ausgehend** zum RVS (gleicher Token wie der Rest von ARIA).
Unterschied zum **Satelliten**: der Satellit entdeckt und steuert *andere*
Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
## Fähigkeiten
| Aktion | Was |
|--------------|-----|
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
| `read` | Datei lesen (Base64, mit Offset/Limit) |
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP |
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
`host_write` / `host_info` / `host_screenshot`.
## Plattformen
Eine Codebasis, läuft auf **Linux, macOS und Windows** (der Agent wählt Shell,
Screenshot-Methode und Root/Admin-Check je OS automatisch):
| | exec | Root/Admin | Screenshot |
|---|---|---|---|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) |
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) |
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) |
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
## Bauen
**Linux (portabel, empfohlen)** — Docker-Container mit altem glibc:
```bash
./build.sh # -> dist/aria-host-agent (~15 MB, läuft auf vielen Distros)
```
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
```bash
./build-native.sh # -> dist/aria-host-agent
```
**Windows — nativ** (auf einem Windows-Rechner, Python 3 im PATH nötig):
```bat
build-native.bat REM -> dist\aria-host-agent.exe
```
**Windows — aus Docker heraus (auf Linux!), inkl. Installer** — Wine baut die
`.exe`, NSIS packt ein `setup.exe`, das den Agent als Windows-Dienst einrichtet:
```bash
./build-win.sh [version]
# -> dist/aria-host-agent.exe (Konsolen-Binary)
# -> dist/aria-host-agent-setup.exe (Installer: Dienst + .env in ProgramData)
```
Der erste Lauf zieht das `tobix/pywine`-Image (~1–2 GB) und richtet die Wine-
Python-Umgebung ein — das dauert; Folge-Builds sind schnell. PyInstaller kann
nicht cross-compilen, deshalb der Wine-Umweg. **macOS geht so NICHT** (Apple
lässt sich nicht legal aus Docker bauen) — dort `./build-native.sh` auf einem Mac.
### Docker scheitert? (Live-ISO / overlayfs-Root)
Wenn `build.sh` mit `failed to mount … overlayfs … invalid argument` abbricht,
läufst du wahrscheinlich auf einem **Live-System** (Live-ISO). Dessen Root ist
selbst ein overlayfs, und Dockers `overlay2`-Treiber kann kein Overlay-auf-
Overlay stapeln. Zwei Auswege:
- **Empfohlen:** Binary auf einem normal installierten Linux bauen (`./build.sh`)
und nur die fertige `dist/aria-host-agent` aufs Live-System kopieren. Die
Binary ist portabel — Ziel braucht weder Docker noch Python.
- **Nativ bauen (ohne Docker):**
```bash
sudo apt install -y python3-pip python3-venv
./build-native.sh
```
Achtung: nativ gebaut linkt die Binary gegen das glibc **dieser** Maschine —
sie läuft dann nur auf Systemen mit gleichem oder neuerem glibc.
## Installieren
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
### Als systemd-Dienst (empfohlen für Dauerbetrieb)
Der Installer kopiert Binary + `.env` an ihre Plätze und richtet den Dienst ein:
```bash
# .env-Pfad direkt übergeben:
sudo ./install-service.sh /pfad/zur/.env
# ODER ohne Argument -> ncurses-Dateidialog (dialog) zum Auswählen der .env:
sudo ./install-service.sh
```
Er legt ab:
- Binary → `/usr/local/bin/aria-host-agent`
- `.env` → `/etc/aria-host-agent/.env` (Rechte `0600`, enthält Token/Passwörter)
- Unit → `/etc/systemd/system/aria-host-agent.service`, dann `enable --now`.
Danach: `systemctl status aria-host-agent` · `journalctl -u aria-host-agent -f`.
Die Binary sucht er unter `dist/aria-host-agent` bzw. `./aria-host-agent` (oder
2. Argument). Braucht `dialog` für den Dateibrowser (bietet die Installation an).
### Windows-Dienst (setup.exe)
`aria-host-agent-setup.exe` (aus `build-win.sh` oder dem Gitea-Release) als
Administrator ausführen. Der Installer:
- kopiert die `.exe` nach `%ProgramFiles%\ARIA Host-Agent`,
- legt `%ProgramData%\ARIA-Host-Agent\.env` an (nur falls noch keine da ist),
- richtet über **nssm** den Dienst **ARIA Host-Agent** ein (Autostart) und startet ihn.
Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
`CONTROL_ENABLED=true` füllen und den Dienst neu starten (`services.msc` →
*ARIA Host-Agent*, oder `nssm restart ARIAHostAgent`). Deinstallation über
*Apps & Features* → *ARIA Host-Agent* (die `.env` in ProgramData bleibt erhalten).
## Release (Binaries als Gitea-Assets)
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
```bash
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
```
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
Skript es automatisch mit. Gitea-Zugang via `.env`/Umgebung (`GITEA_URL`,
`GITEA_REPO`, `GITEA_USER`), Kennwort wird abgefragt.
## TLS / SNI — Agent im selben Netz wie der RVS
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
direkt auf dessen **interne IP** verbinden (kein NAT-Hairpin über den externen
Hostnamen), scheitert der TLS-Handshake sonst an `tlsv1 alert internal error`
(Caddy hat kein Zertifikat für die IP). Lösung — in der `.env`:
```
RVS_HOST=10.0.0.2 # interne RVS-IP
RVS_SNI=example.com # Name, für den das Caddy-Zert gilt
```
Der Agent verbindet dann auf die IP, präsentiert aber den Namen im TLS-SNI.
Zuhause / im Normalfall `RVS_SNI` leer lassen und den Hostnamen als `RVS_HOST`.
## sudo
Vier Fälle, der Agent wählt automatisch:
1. **Agent läuft als root** (z.B. systemd `User=root`) → volle Rechte, kein sudo nötig.
2. `SUDO_PASSWORD=…` in der `.env` → `sudo -S` mit Passwort.
3. `SUDO_NOPASSWD=true` → `sudo -n` (Live-ISO / passwortloses sudo, z.B. Linux
Mint vom Stick).
4. sonst → sudo-Kommandos scheitern mit klarer Meldung.
## Sicherheit
- Reagiert **nur** auf den eigenen RVS-Raum (Token) und **nur**, wenn
`CONTROL_ENABLED=true`.
- Keine offenen Ports (reiner ausgehender Client).
- Alle Kommandos werden geloggt.
- Der Agent gibt **vollen** Zugriff auf den Rechner — nur auf Maschinen
einsetzen, denen du ARIA anvertraust.
## Hinweis Screenshot
Als Systemdienst fehlt die grafische Session. Für `screenshot` den Agent in der
Desktop-Session starten (Autostart) oder `DISPLAY`/`XAUTHORITY` in der Unit setzen.
+11
View File
@@ -0,0 +1,11 @@
build/
.gradle/
dist/
*.apk
local.properties
.idea/
*.iml
captures/
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
aria-agent.keystore
+32
View File
@@ -0,0 +1,32 @@
# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
# APK-Builds gehen nur unter Linux — deshalb im Container.
FROM eclipse-temurin:17-jdk-jammy
ARG GRADLE_VERSION=8.5
ARG CMDLINE_TOOLS=11076708
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_HOME=/opt/android-sdk
RUN apt-get update && apt-get install -y --no-install-recommends \
unzip wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Gradle
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
&& unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
# Android cmdline-tools + SDK
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
&& unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
WORKDIR /project
COPY . /project
CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
+194
View File
@@ -0,0 +1,194 @@
# ARIA Android-Agent
Ein **nativer Android-Agent** (eigene APK), der ARIA erlaubt, ein Smartphone
**fernzusteuern** — inkl. Bedienen fremder App-UIs (z.B. „ARIA, richte auf dem
Handy ein E-Mail-Konto ein"). Gegenstück zum Desktop-`host-agent` (Linux/macOS/
Windows), aber Android ist kein Unix-Shell-System — deshalb ein **anderes
Action-Set** (UI-Automation statt beliebiger Shell-Kommandos).
Verbindet sich wie die ARIA-App **ausgehend** zum RVS (gleicher Token/Raum),
Verbindungs-Setup per **QR-Scan oder manueller Eingabe**. Taucht in der
Diagnostic unter **Satelliten → Host-Agenten 💻** auf (`host_hello` mit
`os="Android …"` + Android-Caps).
## Warum nativ (Kotlin), nicht Termux/RN
- **UI-Automation** (fremde Apps bedienen) geht auf Android nur über einen
**AccessibilityService** — den kann nur eine native App bereitstellen.
- **Screenshots** einer laufenden Session: **MediaProjection** (native).
- **Dauerbetrieb**: Foreground-Service mit Notification (native).
- Termux gäbe nur Shell + `termux-api` (SMS/Anruf/Standort …), **kein** Bedienen
anderer App-UIs. Für „E-Mail-Konto durchklicken" reicht das nicht.
Tech: **Kotlin**, OkHttp-WebSocket (RVS-Client), CameraX/ML-Kit (QR),
AccessibilityService (Input), MediaProjection (Screenshot). Build via Docker
(Android-SDK + Gradle) → APK. Nur Linux baut APKs (Docker), Deploy manuell.
## Action-Set (host_command → host_result)
Android-spezifisch (statt exec/read/write des Desktop-Agents):
| Action | Was |
|---|---|
| `screenshot` | Bildschirmfoto (MediaProjection) — ARIA *sieht* den Schirm |
| `ui_dump` | Sichtbare UI als Baum (Texte, Buttons, Felder + Koordinaten) — ARIAs „Augen" für gezieltes Tippen |
| `ui_tap` | Tippen (x,y ODER auf ein Element aus ui_dump) |
| `ui_text` | Text in das fokussierte/angegebene Feld schreiben |
| `ui_swipe` | Wischen/Scrollen |
| `ui_key` | Systemtasten (BACK, HOME, ENTER …) |
| `app_launch` | App per Paketname starten (z.B. E-Mail-App) |
| `app_list` | installierte Apps auflisten |
| `info` | Gerät: Modell, Android-Version, Akku, Netz, IP |
| `notify` | Benachrichtigung anzeigen |
| *(später)* | `sms_send`, `call`, `location`, `clipboard` (je nach Bedarf + Berechtigung) |
ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_dump`
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
## Dauerbetrieb — Foreground-Service vs. Push (FCM)
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
aggressiv killt (Doze, App-Standby, OEM-Batterie-Manager wie Xiaomi/Huawei).
Zwei Wege, deine WhatsApp-Intuition trifft ins Schwarze:
**A) Foreground-Service (persistente WebSocket)** — der einfache Start:
- Dauerhafte RVS-Verbindung + Foreground-Notification („Agent aktiv").
- Braucht: `FOREGROUND_SERVICE`, **Akku-Optimierung ausnehmen**
(`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — User whitelistet die App),
`RECEIVE_BOOT_COMPLETED` + BootReceiver (Neustart nach Reboot), Auto-Reconnect
(haben wir im Protokoll schon).
- **Reutzt unser bestehendes `host_hello`/`host_command`/`host_result` 1:1.**
- Nachteil: etwas Akku; manche OEMs killen trotzdem → „Autostart" manuell erlauben.
**B) Self-hosted Push (KEIN Google!)** — genau wie WhatsApp, aber auf eigenem Server:
- **UnifiedPush + self-hosted ntfy**: Auf dem ARIA-Server läuft **ntfy** (freier,
self-hostbarer Push-Server). Der Agent nutzt **UnifiedPush** (offener Standard,
de-Google-Welt/F-Droid) mit dem ntfy-Distributor auf dem Handy. Will ARIA etwas,
POSTet die Bridge/RVS an ntfy → weckt die App → sie holt den Befehl vom RVS,
arbeitet, antwortet. **Läuft auch auf Custom-ROMs OHNE Google Play Services.**
- Akkuschonend wie FCM, aber ohne jede Google-Abhängigkeit. Nur ein Dienst mehr
(ntfy) im Stack + der Push-Auslöser serverseitig.
**C) FCM (Google) — optional:** Wer ein Stock-Android mit Play Services hat und
Googles Push-Kanal will, kann FCM statt ntfy nehmen (bester Akku auf GMS-Geräten).
Braucht Firebase-Projekt + Play Services. **Nur eine Option, keine Pflicht.**
**Custom-ROM ohne Google:** → Weg **A** (eigener Socket) oder **B** (self-hosted
ntfy). Beide brauchen KEIN Google. Für Stefans dediziertes Ziel-Handy ist **A**
sogar oft die einfachste Dauerlösung (unser eigener „Push" über den RVS-Socket).
**Hybrid (ideal, End-Ausbau):** Im Leerlauf nur Push (max. Akku). Ein Push weckt
die App → sie öffnet die RVS-Verbindung, hält sich per Wakelock für die Interaktion
wach (mehrere Befehle flüssig, z.B. E-Mail-Setup durchklicken) → schläft nach ein
paar Sekunden Ruhe wieder ein. So WhatsApp-Akku UND schnelle Multi-Befehl-Sessions.
Der Push kommt dabei von **B (self-hosted ntfy)** oder C (FCM) — freie Wahl.
Erste Push-Latenz aus tiefem Doze ~1–3 s; danach bleibt der Socket die Session offen.
**Empfehlung:** Meilenstein 1–3 mit **A (Foreground-Service)** — läuft sofort und
nutzt alles Vorhandene, damit wir schnell einen funktionierenden Agenten haben, ganz
ohne externe Dienste. Dann **Push-Hybrid mit self-hosted ntfy (B)** als Akku-Ausbau —
KEIN Google. FCM (C) nur optional für Stock-Android. Action-Set/Protokoll bleiben
identisch, nur der Wecker ändert sich.
## Sicherheit
- Reagiert nur auf den eigenen RVS-Raum (Token); Setup per QR/manuell.
- **CONTROL_ENABLED**-Schalter in der App (Default AUS) — erst wenn Stefan es
bewusst aktiviert, führt der Agent Aktionen aus.
- AccessibilityService + MediaProjection müssen vom User **explizit** in den
Android-Einstellungen freigegeben werden (kein stiller Zugriff möglich).
- Alle Aktionen werden protokolliert (In-App-Log + optional an ARIA).
- Voller Gerätezugriff — nur auf eigenen/anvertrauten Geräten nutzen.
## Meilensteine
1. **✅ Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client,
Foreground-Service, Connect-UI (QR-Scan + manuell), `host_hello`/`host_ping`.
→ Agent erscheint in der Diagnostic. `info` funktioniert.
2. **✅ Sehen** — MediaProjection-Screenshot (`ScreenCapturer`, gleicher
`{format,bytes,base64}`-Vertrag wie der Desktop-Agent → `host_screenshot`) +
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh`
(Version-Param → Gitea-Release-Asset, wie die App).
## Bauen
APK-Builds laufen **nur unter Linux** — deshalb im Docker-Container. Du brauchst
nichts Android-spezifisches installiert, **nur Docker**. Android-SDK, Gradle und
Build-Tools zieht der Container selbst (`Dockerfile.build`).
```bash
cd host-agent/android
./build.sh
```
`build.sh` baut das Image `aria-android-agent-build` und lässt darin
`gradle assembleDebug` laufen. Ergebnis:
```
host-agent/android/dist/aria-android-agent.apk
```
Das ist ein **Debug-APK**: auto-signiert mit dem Android-Debug-Key, also direkt
installierbar — ohne eigenen Keystore, ohne Play Store.
### Auf dem Handy installieren
1. `dist/aria-android-agent.apk` aufs Zielgerät kopieren (USB, Cloud, `adb install
dist/aria-android-agent.apk`, …).
2. Antippen → Android fragt nach **„Unbekannte Quellen / Aus dieser Quelle
installieren erlauben"** → erlauben.
3. App öffnen → verbinden (QR/manuell), „Steuerung erlauben" an, für M2 zusätzlich
„Bildschirm-Zugriff erlauben" + „Bedienungshilfe öffnen".
### Was der erste Build kostet
Der **erste** Lauf lädt viel (JDK-Image, Gradle, Android-SDK, Dependencies) und
dauert entsprechend — mehrere Minuten. Folge-Builds sind schnell (Docker-Layer +
Gradle-Cache im Image). Häufige Stolperer:
- **Docker fehlt / kein Zugriff** → `docker`-Rechte prüfen (`docker ps`).
- **Overlay-on-Overlay auf einem Live-ISO** („invalid argument" beim Image-Bau) —
gleiches Problem wie beim Desktop-Agent auf dem Mint-Live-System; von einer
installierten Linux-Kiste bauen.
- **Kotlin-/Manifest-Fehler** beim allerersten Bau eines neuen Meilensteins: den
Gradle-Fehler posten, das glätten wir schnell.
### Version setzen
Bis es `release.sh` gibt, wird die Version in `app/build.gradle` gepflegt
(`versionCode` / `versionName`). Aktuell `1` / `0.2.0` (M1+M2).
## Release
Das APK ist **kein** Teil des Git-Trees (blaeht sonst die History dauerhaft auf) —
es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh`
(liegt eine Ebene hoeher, in `host-agent/`):
```bash
cd host-agent
./release_agent.sh 0.2.0
```
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
- setzt die Version (`host_agent.py` → `AGENT_VERSION`, `app/build.gradle` →
`versionName`/`versionCode`),
- baut **Linux-Binary + Android-APK** per Docker,
- committet den Version-Bump, taggt **`agent-v<version>`** (eigener Namespace,
kollidiert nicht mit den App-Tags `v<version>`) und pusht,
- legt ein Gitea-Release an und laedt die Assets hoch:
`aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<version>.apk`,
optional `-macos` / `-windows.exe` (falls in `host-agent/dist/` vorgebaut).
Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung oder
einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
„Releases", nie im Tree.**
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`),
> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern).
+57
View File
@@ -0,0 +1,57 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'de.hackersoft.ariaagent'
compileSdk 34
defaultConfig {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 4
versionName '0.0.0.4'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
// Android neue APKs als Update derselben App akzeptiert (sonst "Konflikt mit
// bestehendem Paket"). Die Keystore-Datei liegt NUR lokal (gitignored, nicht
// im oeffentlichen Repo) — UNBEDINGT sichern, sonst brechen kuenftige Updates.
def ariaKeystore = rootProject.file('aria-agent.keystore')
signingConfigs {
aria {
if (ariaKeystore.exists()) {
storeFile ariaKeystore
storePassword 'ariaagent'
keyAlias 'aria'
keyPassword 'ariaagent'
}
}
}
buildTypes {
debug {
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
release {
minifyEnabled false
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' // RVS-WebSocket
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // QR-Scan (FOSS, kein Google-Dienst)
}
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.DayNight">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AgentService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".AriaAccessibilityService"
android:exported="false"
android:label="ARIA Host-Agent"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
</application>
</manifest>
@@ -0,0 +1,53 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.os.Build
/** Verbindungs-/Agent-Einstellungen (in SharedPreferences persistiert). */
data class AgentConfig(
var host: String = "",
var port: Int = 443,
var tls: Boolean = true,
var token: String = "",
var name: String = "",
var controlEnabled: Boolean = false,
) {
companion object {
private const val PREFS = "aria_agent"
fun load(ctx: Context): AgentConfig {
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AgentConfig(
host = p.getString("host", "") ?: "",
port = p.getInt("port", 443),
tls = p.getBoolean("tls", true),
token = p.getString("token", "") ?: "",
name = p.getString("name", "") ?: "",
controlEnabled = p.getBoolean("control", false),
)
}
}
fun save(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
putString("host", host)
putInt("port", port)
putBoolean("tls", tls)
putString("token", token)
putString("name", name)
putBoolean("control", controlEnabled)
apply()
}
}
fun isValid(): Boolean = host.isNotBlank() && token.isNotBlank()
fun displayName(): String = if (name.isNotBlank()) name else Build.MODEL
/** Stabile, technische Host-ID (a-z0-9_-), wie beim Desktop-Agent. */
fun hostId(): String {
val base = displayName().lowercase()
.replace(Regex("[^a-z0-9_-]+"), "-").trim('-')
return base.ifBlank { "android" }
}
}
@@ -0,0 +1,132 @@
package de.hackersoft.ariaagent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Foreground-Service (Weg A): haelt die RVS-Verbindung dauerhaft, damit ARIA
* den Agenten jederzeit erreicht. Persistente Notification + Auto-Reconnect.
*/
class AgentService : Service() {
private var rvs: RvsClient? = null
companion object {
private const val CH = "aria_agent"
private const val NOTIF_ID = 1
const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION"
const val EXTRA_RESULT_CODE = "resultCode"
const val EXTRA_RESULT_DATA = "resultData"
@Volatile var status: String = "gestoppt"
@Volatile var connected: Boolean = false
fun start(ctx: Context) {
val i = Intent(ctx, AgentService::class.java)
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, AgentService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
startForeground(NOTIF_ID, buildNotification("startet …"))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// WICHTIG: Jeder startForegroundService()-Aufruf MUSS binnen ~5s mit
// startForeground() beantwortet werden — sonst crasht Android den Prozess
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
// zuerst startForeground aufrufen (idempotent).
startForeground(NOTIF_ID, buildNotification(status))
if (intent?.action == ACTION_PROJECTION) {
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (code != 0 && data != null) {
try {
ScreenCapturer.start(applicationContext, code, data)
updateNotification("$status · Bildschirm-Zugriff aktiv")
} catch (e: Throwable) {
ScreenCapturer.lastError = e.message ?: e.javaClass.simpleName
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
}
}
if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen
return START_STICKY
}
return startRvs()
}
/** (Re-)Startet die RVS-Verbindung anhand der gespeicherten Config. */
private fun startRvs(): Int {
val cfg = AgentConfig.load(this)
if (!cfg.isValid()) {
stopSelf()
return START_NOT_STICKY
}
rvs?.stop()
rvs = RvsClient(applicationContext, cfg) { conn, msg ->
connected = conn
status = msg
updateNotification(msg)
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
}
rvs?.start()
return START_STICKY
}
override fun onDestroy() {
rvs?.stop()
ScreenCapturer.stop()
connected = false
status = "gestoppt"
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val ch = NotificationChannel(CH, "ARIA Agent", NotificationManager.IMPORTANCE_LOW)
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(ch)
}
}
private fun buildNotification(text: String): Notification {
val pi = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CH)
.setContentTitle("ARIA Host-Agent")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher)
.setOngoing(true)
.setContentIntent(pi)
.build()
}
private fun updateNotification(text: String) {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIF_ID, buildNotification(text))
}
}
@@ -0,0 +1,91 @@
package de.hackersoft.ariaagent
import android.accessibilityservice.AccessibilityService
import android.graphics.Rect
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray
import org.json.JSONObject
/**
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
*
* Liefert einen strukturierten Baum der sichtbaren Bildschirm-Elemente: Text,
* Beschriftung (contentDescription), Klasse, Bildschirm-Position (Mittelpunkt +
* Rahmen) und Flags (klickbar/editierbar/ankreuzbar). ARIA nutzt das ergaenzend
* zum Screenshot, um Ziele exakt zu benennen. Tippen/Text folgt in M3.
*
* Der Nutzer schaltet den Dienst einmalig unter Einstellungen > Bedienungshilfen
* frei. Er fuehrt hier nichts autonom aus — reagiert nur auf `dump()`.
*/
class AriaAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
instance = this
}
override fun onUnbind(intent: android.content.Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
override fun onInterrupt() { /* passiv */ }
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
fun dump(): JSONObject {
val root = rootInActiveWindow
?: return JSONObject().put("ok", false)
.put("error", "Kein aktives Fenster lesbar (Bildschirm evtl. aus oder gesperrt).")
val nodes = JSONArray()
try {
walk(root, nodes, 0)
} finally {
@Suppress("DEPRECATION") try { root.recycle() } catch (_: Exception) {}
}
val result = JSONObject()
.put("package", root.packageName?.toString() ?: "")
.put("count", nodes.length())
.put("nodes", nodes)
return JSONObject().put("ok", true).put("result", result)
}
private fun walk(node: AccessibilityNodeInfo?, out: JSONArray, depth: Int) {
if (node == null || depth > 40 || out.length() >= 400) return
val text = node.text?.toString()?.trim()
val desc = node.contentDescription?.toString()?.trim()
val cls = node.className?.toString()?.substringAfterLast('.')
val interesting = !text.isNullOrBlank() || !desc.isNullOrBlank() ||
node.isClickable || node.isEditable || node.isCheckable
if (interesting) {
val r = Rect()
node.getBoundsInScreen(r)
val o = JSONObject()
if (!text.isNullOrBlank()) o.put("text", text)
if (!desc.isNullOrBlank()) o.put("desc", desc)
if (cls != null) o.put("cls", cls)
if (node.isClickable) o.put("clickable", true)
if (node.isEditable) o.put("editable", true)
if (node.isCheckable) o.put("checked", node.isChecked)
o.put("x", r.centerX())
o.put("y", r.centerY())
o.put("bounds", "${r.left},${r.top},${r.right},${r.bottom}")
out.put(o)
}
for (i in 0 until node.childCount) {
walk(node.getChild(i), out, depth + 1)
}
}
companion object {
@Volatile
var instance: AriaAccessibilityService? = null
val available: Boolean get() = instance != null
}
}
@@ -0,0 +1,16 @@
package de.hackersoft.ariaagent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Startet den Agent-Service nach dem Booten wieder (wenn konfiguriert). */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
if (AgentConfig.load(ctx).isValid()) {
AgentService.start(ctx)
}
}
}
}
@@ -0,0 +1,179 @@
package de.hackersoft.ariaagent
import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.ContextCompat
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var host: EditText
private lateinit var port: EditText
private lateinit var token: EditText
private lateinit var name: EditText
private lateinit var tls: SwitchCompat
private lateinit var control: SwitchCompat
private lateinit var statusView: TextView
private val scan = registerForActivityResult(ScanContract()) { res ->
res.contents?.let { applyQr(it) }
}
private val camPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) launchScan() else toast("Kamera-Berechtigung nötig für QR-Scan")
}
private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val projection = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { res ->
if (res.resultCode == Activity.RESULT_OK && res.data != null) {
val i = Intent(this, AgentService::class.java)
.setAction(AgentService.ACTION_PROJECTION)
.putExtra(AgentService.EXTRA_RESULT_CODE, res.resultCode)
.putExtra(AgentService.EXTRA_RESULT_DATA, res.data)
ContextCompat.startForegroundService(this, i)
toast("Bildschirm-Zugriff aktiv — ARIA kann jetzt Screenshots machen")
} else {
toast("Bildschirm-Zugriff abgelehnt")
}
}
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, i: Intent?) = refreshStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
host = findViewById(R.id.host)
port = findViewById(R.id.port)
token = findViewById(R.id.token)
name = findViewById(R.id.name)
tls = findViewById(R.id.tls)
control = findViewById(R.id.control)
statusView = findViewById(R.id.status)
findViewById<Button>(R.id.btnScan).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED) launchScan()
else camPerm.launch(Manifest.permission.CAMERA)
}
findViewById<Button>(R.id.btnConnect).setOnClickListener { saveAndConnect() }
findViewById<Button>(R.id.btnStop).setOnClickListener {
AgentService.stop(this)
refreshStatus()
}
findViewById<Button>(R.id.btnScreen).setOnClickListener {
if (!AgentService.connected && !AgentConfig.load(this).isValid()) {
toast("Erst verbinden, dann Bildschirm-Zugriff erlauben")
return@setOnClickListener
}
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
projection.launch(mpm.createScreenCaptureIntent())
}
findViewById<Button>(R.id.btnAccessibility).setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
toast("'ARIA Host-Agent' in der Liste einschalten")
} catch (_: Exception) {
toast("Bedienungshilfe-Einstellungen nicht gefunden")
}
}
loadIntoUi(AgentConfig.load(this))
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
override fun onResume() {
super.onResume()
val filter = IntentFilter(AgentService.ACTION_STATUS)
ContextCompat.registerReceiver(this, statusReceiver, filter,
ContextCompat.RECEIVER_NOT_EXPORTED)
refreshStatus()
}
override fun onPause() {
super.onPause()
try { unregisterReceiver(statusReceiver) } catch (_: Exception) {}
}
private fun launchScan() {
val o = ScanOptions()
.setBeepEnabled(false)
.setOrientationLocked(false)
.setPrompt("ARIA-Verbindungs-QR scannen")
scan.launch(o)
}
private fun applyQr(content: String) {
try {
val j = JSONObject(content)
host.setText(j.optString("host"))
port.setText((if (j.has("port")) j.optInt("port", 443) else 443).toString())
token.setText(j.optString("token"))
if (j.has("tls")) tls.isChecked = j.optBoolean("tls", true)
toast("QR übernommen — jetzt 'Speichern & Verbinden'")
} catch (_: Exception) {
toast("Das ist kein ARIA-Verbindungs-QR")
}
}
private fun loadIntoUi(c: AgentConfig) {
host.setText(c.host)
port.setText(c.port.toString())
token.setText(c.token)
name.setText(c.name)
tls.isChecked = c.tls
control.isChecked = c.controlEnabled
}
private fun saveAndConnect() {
val c = AgentConfig(
host = host.text.toString().trim(),
port = port.text.toString().trim().toIntOrNull() ?: 443,
tls = tls.isChecked,
token = token.text.toString().trim(),
name = name.text.toString().trim(),
controlEnabled = control.isChecked,
)
if (!c.isValid()) {
toast("Host und Token sind Pflicht")
return
}
c.save(this)
AgentService.start(this)
toast("Agent gestartet")
refreshStatus()
}
private fun refreshStatus() {
val dot = if (AgentService.connected) "🟢" else "🔴"
statusView.text = "Status: ${AgentService.status} $dot"
}
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,190 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.os.BatteryManager
import android.os.Build
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* RVS-WebSocket-Client (Meilenstein 1). Spricht dasselbe Protokoll wie der
* Desktop-Agent: meldet sich per host_hello, haelt sich per host_ping frisch,
* beantwortet host_command -> host_result.
*
* M1-Aktionen: nur `info`. screenshot/ui_* folgen in M2/M3.
*/
class RvsClient(
private val appCtx: Context,
private val config: AgentConfig,
private val onStatus: (connected: Boolean, msg: String) -> Unit,
) {
private val client = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Server-Push: nie lesen-timeouten
.build()
private var ws: WebSocket? = null
@Volatile private var running = false
private var pingThread: Thread? = null
private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/…
fun start() {
running = true
connect()
}
fun stop() {
running = false
pingThread?.interrupt()
try { ws?.close(1000, "bye") } catch (_: Exception) {}
ws = null
}
private fun url(): String {
val proto = if (config.tls) "wss" else "ws"
return "$proto://${config.host}:${config.port}?token=${config.token}"
}
private fun connect() {
if (!running) return
onStatus(false, "verbinde …")
val req = Request.Builder().url(url()).build()
ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
onStatus(true, "verbunden")
sendHello(webSocket)
startPing(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handle(webSocket, text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
onStatus(false, "getrennt: ${t.message ?: "?"}")
reconnectLater()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
onStatus(false, "geschlossen")
reconnectLater()
}
})
}
private fun reconnectLater() {
pingThread?.interrupt()
if (!running) return
Thread {
try { Thread.sleep(3000) } catch (_: InterruptedException) { return@Thread }
connect()
}.start()
}
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
val o = JSONObject()
o.put("type", type)
o.put("payload", payload)
o.put("timestamp", System.currentTimeMillis())
try { webSocket.send(o.toString()) } catch (_: Exception) {}
}
private fun sendHello(webSocket: WebSocket) {
val p = JSONObject()
p.put("hostId", config.hostId())
p.put("name", config.displayName())
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
}
private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt()
pingThread = Thread {
while (running && !Thread.currentThread().isInterrupted) {
try { Thread.sleep(25000) } catch (_: InterruptedException) { break }
val p = JSONObject().put("hostId", config.hostId())
send(webSocket, "host_ping", p)
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
}
}.also { it.start() }
}
private fun handle(webSocket: WebSocket, text: String) {
val msg = try { JSONObject(text) } catch (_: Exception) { return }
if (msg.optString("type") != "host_command") return
val payload = msg.optJSONObject("payload") ?: JSONObject()
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
if (target.isNotBlank()
&& !target.equals(config.hostId(), true)
&& !target.equals(config.displayName(), true)) return
val action = payload.optString("action")
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
val result: JSONObject = try {
when {
!config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
"app_launch", "app_list", "notify") ->
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
else -> err("Aktion '$action' unbekannt.")
}
} catch (t: Throwable) {
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
}
result.put("requestId", payload.optString("requestId"))
result.put("hostId", config.hostId())
result.put("action", action)
send(webSocket, "host_result", result)
}
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
private fun doScreenshot(): JSONObject {
if (!ScreenCapturer.active) {
val why = ScreenCapturer.lastError?.let { " (letzter Fehler: $it)" } ?: ""
return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " +
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
}
val png = ScreenCapturer.capture()
?: return err("Screenshot fehlgeschlagen: ${ScreenCapturer.lastError ?: "unbekannt"}")
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
return JSONObject().put("ok", true).put("result", res)
}
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
private fun doUiDump(): JSONObject {
val svc = AriaAccessibilityService.instance
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
return svc.dump()
}
private fun doInfo(): JSONObject {
val res = JSONObject()
res.put("host", config.displayName())
res.put("model", Build.MODEL)
res.put("manufacturer", Build.MANUFACTURER)
res.put("android", Build.VERSION.RELEASE)
res.put("sdk", Build.VERSION.SDK_INT)
try {
val bm = appCtx.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
res.put("battery_percent", bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
} catch (_: Exception) {}
return JSONObject().put("ok", true).put("result", res)
}
}
@@ -0,0 +1,149 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.Image
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.HandlerThread
import android.util.DisplayMetrics
import android.view.WindowManager
import java.io.ByteArrayOutputStream
/**
* Bildschirm-Aufnahme via MediaProjection (Meilenstein 2 — "sehen").
*
* Der Nutzer erlaubt den Zugriff EINMALIG in der Agent-App (System-Dialog).
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
*
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
*
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
*/
object ScreenCapturer {
private const val MAX_SIDE = 1280
private var projection: MediaProjection? = null
private var reader: ImageReader? = null
private var vdisplay: VirtualDisplay? = null
private var handlerThread: HandlerThread? = null
private var handler: Handler? = null
private var w = 0
private var h = 0
private var dpi = 0
/** Letzter Init-/Capture-Fehler (fuer die Fehlermeldung an ARIA). */
@Volatile
var lastError: String? = null
val active: Boolean
@Synchronized get() = projection != null
@Synchronized
fun start(ctx: Context, resultCode: Int, data: Intent) {
stop()
val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val mp = mpm.getMediaProjection(resultCode, data) ?: run {
lastError = "getMediaProjection lieferte null"
return
}
val metrics = DisplayMetrics()
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
dpi = metrics.densityDpi
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
handlerThread = HandlerThread("aria-capture").also { it.start() }
handler = Handler(handlerThread!!.looper)
// Ab Android 14 Pflicht VOR createVirtualDisplay; frueher unschaedlich.
mp.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { stop() }
}, handler)
val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
reader = ir
// AUTO_MIRROR = Standard-Flag fuer MediaProjection-Capture (die Projection
// selbst autorisiert die Aufnahme, kein Sonderrecht noetig).
vdisplay = mp.createVirtualDisplay(
"aria-screen", w, h, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
ir.surface, null, handler,
)
projection = mp
lastError = null
}
@Synchronized
fun stop() {
try { vdisplay?.release() } catch (_: Exception) {}
try { reader?.close() } catch (_: Exception) {}
try { projection?.stop() } catch (_: Exception) {}
try { handlerThread?.quitSafely() } catch (_: Exception) {}
vdisplay = null
reader = null
projection = null
handlerThread = null
handler = null
}
/** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
fun capture(): ByteArray? {
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
var image: Image? = null
var tries = 0
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
while (tries < 37) {
image = r.acquireLatestImage()
if (image != null) break
try { Thread.sleep(80) } catch (_: InterruptedException) {}
tries++
}
if (image == null) {
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
return null
}
return try {
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * w
val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(buffer)
val out = ByteArrayOutputStream()
if (rowPadding == 0) {
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
} else {
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
cropped.recycle()
}
bmp.recycle()
lastError = null
out.toByteArray()
} catch (t: Throwable) {
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
null
} finally {
try { image?.close() } catch (_: Exception) {}
}
}
}
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#3FFF9F"
android:pathData="M54,26 m-26,0 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z M54,26 m-13,0 a13,13 0 1,1 26,0 a13,13 0 1,1 -26,0 Z" />
<path
android:fillColor="#3FFF9F"
android:pathData="M52,58 h4 v24 h-4 z" />
</vector>
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="22sp"
android:textStyle="bold"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status: —"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="QR-Code scannen" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="— oder manuell —"
android:paddingTop="12dp"
android:paddingBottom="4dp" />
<EditText
android:id="@+id/host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="RVS-Host (z.B. rvs.example.com)"
android:inputType="textUri" />
<EditText
android:id="@+id/port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Port"
android:text="443"
android:inputType="number" />
<EditText
android:id="@+id/token"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="RVS-Token"
android:inputType="textNoSuggestions" />
<EditText
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Anzeigename (optional, z.B. 'Stefans Handy')"
android:inputType="text" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/tls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TLS (wss)"
android:checked="true"
android:paddingTop="12dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/control"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Steuerung erlauben (Aktionen ausführen)"
android:paddingTop="8dp"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Speichern &amp; Verbinden" />
<Button
android:id="@+id/btnStop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Agent stoppen"
android:paddingTop="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sehen (Meilenstein 2)"
android:textStyle="bold"
android:paddingTop="24dp"
android:paddingBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Damit ARIA Screenshots machen und die Oberfläche lesen kann. Beides einmalig freigeben."
android:textSize="13sp"
android:paddingBottom="8dp" />
<Button
android:id="@+id/btnScreen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Bildschirm-Zugriff erlauben" />
<Button
android:id="@+id/btnAccessibility"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Bedienungshilfe öffnen"
android:paddingTop="8dp" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ARIA Host-Agent</string>
<string name="accessibility_desc">Erlaubt ARIA, die sichtbaren Bildschirm-Elemente zu lesen (Text und Position), um Dich fernzusteuern. Nur aktiv, wenn Du \'Steuerung erlauben\' eingeschaltet hast.</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />
+5
View File
@@ -0,0 +1,5 @@
// Root-Build. Plugin-Versionen zentral, in den Modulen nur angewandt.
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Baut die Android-Agent-APK per Docker (Android-SDK + Gradle).
# ./build.sh
# Ergebnis: dist/aria-android-agent.apk -> aufs Handy kopieren + installieren
# ("Unbekannte Quellen erlauben").
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
docker build -f Dockerfile.build -t aria-android-agent-build .
docker run --rm -v "$(pwd)/dist:/out" aria-android-agent-build
echo
echo "Fertig: dist/aria-android-agent.apk"
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.caching=true
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "aria-android-agent"
include(":app")
+30
View File
@@ -0,0 +1,30 @@
# systemd-Unit fuer den ARIA Host-Agent.
#
# Installation:
# sudo cp aria-host-agent /usr/local/bin/
# sudo mkdir -p /etc/aria-host-agent && sudo cp .env /etc/aria-host-agent/.env
# sudo cp aria-host-agent.service /etc/systemd/system/
# sudo systemctl enable --now aria-host-agent
#
# Als root (User=root): Kommandos haben volle Rechte, kein sudo/Passwort noetig.
# Fuer einen normalen User: User=<name> setzen und in der .env SUDO_PASSWORD
# oder SUDO_NOPASSWD konfigurieren.
#
# HINWEIS Screenshot: als Systemdienst fehlt die grafische Session (DISPLAY/
# WAYLAND_DISPLAY). Fuer host_screenshot den Agent stattdessen in der Desktop-
# Session starten (Autostart) oder DISPLAY/XAUTHORITY in der Unit setzen.
[Unit]
Description=ARIA Host-Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/etc/aria-host-agent
ExecStart=/usr/local/bin/aria-host-agent
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+28
View File
@@ -0,0 +1,28 @@
@echo off
REM ARIA Host-Agent — Windows-Build mit PyInstaller (kein Docker).
REM Voraussetzung: Python 3 installiert und im PATH (python.org, "Add to PATH").
REM
REM build-native.bat
REM
REM Ergebnis: dist\aria-host-agent.exe (Onefile). Danach .env danebenlegen
REM (siehe .env.example) und starten. Fuer Admin-Rechte die .exe per Rechtsklick
REM "Als Administrator ausfuehren".
setlocal
cd /d "%~dp0"
python -m venv .buildenv || goto :err
call .buildenv\Scripts\activate.bat
python -m pip install --quiet --upgrade pip
python -m pip install --quiet pyinstaller -r requirements.txt || goto :err
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py || goto :err
call deactivate
echo.
echo Fertig: dist\aria-host-agent.exe
echo .env danebenlegen (siehe .env.example), dann starten (ggf. als Administrator).
goto :eof
:err
echo.
echo FEHLER beim Bauen. Ist Python 3 installiert und im PATH? (python --version)
exit /b 1
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Baut die Host-Agent-Binary OHNE Docker — direkt mit PyInstaller.
# Nutze das, wenn Docker nicht geht (z.B. Live-ISO mit overlayfs-Root, wo
# Dockers overlay2-Treiber kein Overlay-auf-Overlay stapeln kann).
#
# sudo apt install -y python3-pip python3-venv # falls noch nicht da
# ./build-native.sh
#
# WICHTIG: Nativ gebaut linkt die Binary gegen das glibc DIESER Maschine. Sie
# laeuft dann nur auf Systemen mit glibc >= dem hier. Fuer breite Kompatibilitaet
# lieber ./build.sh (Docker/bullseye) auf einem normalen Rechner nutzen.
set -euo pipefail
cd "$(dirname "$0")"
python3 -m venv .buildenv
# shellcheck disable=SC1091
. .buildenv/bin/activate
pip install --quiet --upgrade pip
pip install --quiet pyinstaller -r requirements.txt
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py
deactivate
echo
echo "Fertig: dist/aria-host-agent"
echo ".env danebenlegen (siehe .env.example), dann: chmod +x aria-host-agent && ./aria-host-agent"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Baut die Windows-.exe + setup.exe des Host-Agents AUF LINUX (Wine im Docker).
# ./build-win.sh [version]
# Ergebnis:
# dist/aria-host-agent.exe (Konsolen-Binary)
# dist/aria-host-agent-setup.exe (Installer: richtet Windows-Dienst ein)
#
# Hinweis: Der erste Lauf zieht das tobix/pywine-Image (~1-2 GB) + baut die
# Wine-Umgebung — das dauert. Folge-Builds sind schnell.
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${1:-0.0.0}"
mkdir -p dist
docker build -f Dockerfile.win --build-arg VERSION="$VERSION" -t aria-host-agent-win .
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-win
echo
echo "Fertig: dist/aria-host-agent.exe + dist/aria-host-agent-setup.exe"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Baut die portable Host-Agent-Binary (Linux x86_64) via Docker + PyInstaller.
# Ergebnis: ./dist/aria-host-agent (Onefile, ~15 MB, keine Runtime noetig).
#
# ./build.sh
#
# Danach auf den Ziel-Rechner kopieren, .env danebenlegen und starten:
# ./aria-host-agent
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
docker build -f Dockerfile.build -t aria-host-agent-build .
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-build
echo
echo "Fertig: dist/aria-host-agent"
echo "Auf den Ziel-Rechner kopieren, .env danebenlegen (siehe .env.example), dann:"
echo " chmod +x aria-host-agent && ./aria-host-agent"
+526
View File
@@ -0,0 +1,526 @@
"""
ARIA Host-Agent — Direktzugriff auf EINEN Rechner.
Laeuft direkt auf dem Ziel-Rechner (Linux) und verbindet sich AUSGEHEND als
RVS-Client in Stefans Raum (gleicher Token). Damit kann ARIA diesen Rechner
direkt steuern, auch wenn er sonst aus dem Netz nicht erreichbar ist (hinter
NAT/Firewall, kein offener Port). Anders als der Satellit (der ein LAN
entdeckt/steuert) ist beim Agent das "Geraet" der Rechner selbst.
Als reine Binary verteilbar (PyInstaller onefile) + .env fuer die Zugangsdaten.
Faehigkeiten (host_command → host_result):
exec Shell-Kommando ausfuehren (optional sudo)
read/write Datei lesen/schreiben (Base64)
info OS / CPU / RAM / Disk / Uptime / Netz
screenshot Bildschirmfoto (X11/Wayland, wenn grafische Session da ist)
Sicherheit: reagiert nur auf den eigenen RVS-Raum (Token) und nur, wenn
CONTROL_ENABLED=true. Alles wird geloggt. Keine offenen Ports.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import platform
import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
import websockets
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
IS_WINDOWS = os.name == "nt"
IS_MAC = sys.platform == "darwin"
def _is_admin() -> bool:
"""root (Unix) bzw. Administrator (Windows)."""
try:
return os.geteuid() == 0 # Unix (Linux/macOS)
except AttributeError:
try:
import ctypes
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
except Exception:
return False
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
)
logger = logging.getLogger("host-agent")
def _load_dotenv() -> None:
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
here = os.path.dirname(os.path.abspath(__file__))
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
seen = set()
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
if not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
if key.startswith("export "):
key = key[len("export "):].strip()
val = val.strip()
if val[:1] in ("'", '"'):
q = val[0]
end = val.find(q, 1)
val = val[1:end] if end != -1 else val[1:]
else:
m = re.search(r"\s+#", val)
if m:
val = val[:m.start()]
val = val.strip()
if key and key not in os.environ:
os.environ[key] = val
except Exception as exc:
logger.warning(".env laden fehlgeschlagen (%s): %s", path, exc)
break
_load_dotenv()
# ─── Konfiguration ──────────────────────────────────────────────────
def _env_bool(name: str, default: bool) -> bool:
v = os.environ.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on", "ja")
def _default_id() -> str:
host = socket.gethostname() or "host"
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-").lower()
return slug or "host"
RVS_HOST = os.environ.get("RVS_HOST", "")
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
RVS_TLS = _env_bool("RVS_TLS", True)
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
# Hilft nur, wenn der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss.
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. der
# Agent laeuft im selben Netz wie der RVS und verbindet direkt auf die interne IP,
# das Caddy-Zertifikat gilt aber fuer den Namen. Dann: RVS_HOST=<interne-ip>,
# RVS_SNI=<zert-name>. Leer = SNI = RVS_HOST (Normalfall).
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
# Steuerung ist der Sinn des Agents — aber bewusst opt-in (Sicherheit).
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
# sudo: 1) Agent laeuft als root -> direkt. 2) SUDO_PASSWORD gesetzt -> sudo -S.
# 3) SUDO_NOPASSWD=true (Live-ISO / NOPASSWD-sudoers) -> sudo -n. 4) sonst Fehler.
SUDO_PASSWORD = os.environ.get("SUDO_PASSWORD", "")
SUDO_NOPASSWD = _env_bool("SUDO_NOPASSWD", False)
EXEC_TIMEOUT = float(os.environ.get("EXEC_TIMEOUT", "60") or "60")
# Ausgabe-Fenster (wie beim Satelliten): grosse stdout klein halten fuers RVS.
OUT_MAX_CHARS = int(os.environ.get("OUT_MAX_CHARS", "20000") or "20000")
OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "200000")
# Datei-Transfer-Limit (Base64 durchs RVS).
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.0.4"
HEARTBEAT_SEC = 25
CAPS = ["exec", "read", "write", "info", "screenshot"]
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
def _to_int(s):
try:
return int(str(s).strip())
except (TypeError, ValueError):
return None
def _to_float(s):
try:
return float(str(s).strip())
except (TypeError, ValueError):
return None
def _window_text(text: str, params: dict) -> dict:
"""contains-Zeilenfilter + offset/max_chars-Fenster. Gibt body + Metadaten."""
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:
text = "\n".join(ln for ln in text.splitlines()
if any(t in ln.lower() for t in terms))
offset = max(0, _to_int(params.get("offset")) or 0)
max_chars = _to_int(params.get("max_chars")) or OUT_MAX_CHARS
max_chars = max(1, min(max_chars, OUT_MAX_CHARS_HARD))
body = text[offset:offset + max_chars]
return {"body": body, "total_chars": total, "filtered": bool(contains),
"offset": offset, "returned_chars": len(body),
"truncated": offset + len(body) < len(text)}
# ─── Aktionen ───────────────────────────────────────────────────────
def _wrap_sudo(cmd: str, use_sudo: bool):
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
Gibt (argv, stdin_data) oder (None, fehlertext)."""
if IS_WINDOWS:
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
if use_sudo and not _is_admin():
return None, ("Windows kennt kein sudo. Starte den Agent als "
"Administrator ('Als Administrator ausfuehren'), dann "
"laufen Kommandos mit vollen Rechten.")
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
if not use_sudo or _is_admin():
return ["bash", "-lc", cmd], None
if SUDO_PASSWORD:
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
if SUDO_NOPASSWD:
return ["sudo", "-n", "bash", "-lc", cmd], None
return None, ("sudo verlangt ein Passwort. Setze SUDO_PASSWORD in der .env, "
"oder SUDO_NOPASSWD=true (Live-ISO / passwortloses sudo), oder "
"starte den Agent als root.")
def _do_exec(params: dict) -> dict:
cmd = params.get("cmd") or params.get("command") or ""
if not cmd:
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
argv, stdin_data = _wrap_sudo(cmd, bool(params.get("sudo")))
if argv is None:
return {"ok": False, "error": stdin_data}
timeout = _to_float(params.get("timeout")) or EXEC_TIMEOUT
try:
r = subprocess.run(argv, input=stdin_data, capture_output=True,
text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return {"ok": False, "error": f"Timeout nach {timeout:.0f}s."}
except Exception as exc:
return {"ok": False, "error": f"exec fehlgeschlagen: {exc}"}
win = _window_text(r.stdout, params)
return {"ok": True, "result": {"exit_code": r.returncode,
"stderr": (r.stderr or "")[:4000], **win}}
def _do_read(params: dict) -> dict:
path = params.get("path") or ""
if not path:
return {"ok": False, "error": "path erforderlich."}
p = Path(path).expanduser()
if not p.is_file():
return {"ok": False, "error": f"Datei nicht gefunden: {path}"}
size = p.stat().st_size
offset = max(0, _to_int(params.get("offset")) or 0)
max_bytes = _to_int(params.get("max_bytes")) or FILE_MAX_BYTES
max_bytes = max(1, min(max_bytes, FILE_MAX_BYTES))
try:
with p.open("rb") as f:
f.seek(offset)
data = f.read(max_bytes)
except Exception as exc:
return {"ok": False, "error": f"Lesen fehlgeschlagen: {exc}"}
return {"ok": True, "result": {
"path": str(p), "size": size, "offset": offset,
"returned_bytes": len(data), "truncated": offset + len(data) < size,
"base64": base64.b64encode(data).decode("ascii"),
}}
def _do_write(params: dict) -> dict:
path = params.get("path") or ""
if not path:
return {"ok": False, "error": "path erforderlich."}
b64 = params.get("base64")
text = params.get("text")
if b64 is None and text is None:
return {"ok": False, "error": "base64 ODER text erforderlich."}
try:
data = base64.b64decode(b64) if b64 is not None else str(text).encode("utf-8")
except Exception as exc:
return {"ok": False, "error": f"base64 ungueltig: {exc}"}
p = Path(path).expanduser()
try:
p.parent.mkdir(parents=True, exist_ok=True)
mode = "ab" if params.get("append") else "wb"
with p.open(mode) as f:
f.write(data)
if params.get("chmod"):
os.chmod(p, int(str(params["chmod"]), 8))
except Exception as exc:
return {"ok": False, "error": f"Schreiben fehlgeschlagen: {exc}"}
return {"ok": True, "result": {"path": str(p), "bytes": len(data)}}
def _do_info(params: dict) -> dict:
info = {
"host": HOST_NAME, "hostname": socket.gethostname(),
"agent_version": AGENT_VERSION,
"os": platform.platform(), "kernel": platform.release(),
"arch": platform.machine(), "python": platform.python_version(),
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
"is_root": _is_admin(),
}
try:
import psutil
info["cpu_percent"] = psutil.cpu_percent(interval=0.3)
info["cpu_count"] = psutil.cpu_count()
vm = psutil.virtual_memory()
info["ram_used_mb"] = round(vm.used / 1024 / 1024)
info["ram_total_mb"] = round(vm.total / 1024 / 1024)
info["ram_percent"] = vm.percent
du = psutil.disk_usage("/")
info["disk_used_gb"] = round(du.used / 1024 / 1024 / 1024, 1)
info["disk_total_gb"] = round(du.total / 1024 / 1024 / 1024, 1)
info["disk_percent"] = du.percent
info["uptime_s"] = round(time.time() - psutil.boot_time())
info["load_avg"] = list(os.getloadavg()) if hasattr(os, "getloadavg") else None
except Exception:
# Fallback ohne psutil: das Noetigste aus os/shutil.
try:
info["load_avg"] = list(os.getloadavg())
except Exception:
info["load_avg"] = None
try:
total, used, free = shutil.disk_usage("/")
info["disk_used_gb"] = round(used / 1024 ** 3, 1)
info["disk_total_gb"] = round(total / 1024 ** 3, 1)
except Exception:
pass
# Primaere IP (best effort).
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
info["primary_ip"] = s.getsockname()[0]
s.close()
except Exception:
info["primary_ip"] = ""
return {"ok": True, "result": info}
def _do_screenshot(params: dict) -> dict:
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
Braucht eine aktive grafische Session."""
import tempfile
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
candidates = []
if IS_WINDOWS:
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
elif IS_MAC:
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
else:
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
candidates.append(["grim", tmp])
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
("maim", ["maim", tmp]),
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
("import", ["import", "-window", "root", tmp])):
if shutil.which(tool):
candidates.append(argv)
if not candidates:
return {"ok": False, "error":
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
last_err = ""
for argv in candidates:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
if r.returncode == 0 and os.path.isfile(tmp) and os.path.getsize(tmp) > 0:
with open(tmp, "rb") as f:
b = f.read()
os.remove(tmp)
return {"ok": True, "result": {"format": "png", "bytes": len(b),
"base64": base64.b64encode(b).decode("ascii")}}
last_err = (r.stderr or r.stdout or "").strip()[:200]
except Exception as exc:
last_err = str(exc)[:200]
return {"ok": False, "error": f"Screenshot fehlgeschlagen ({last_err}). "
"Laeuft der Agent in derselben grafischen Session?"}
ACTIONS = {
"exec": _do_exec, "read": _do_read, "write": _do_write,
"info": _do_info, "screenshot": _do_screenshot,
}
# ─── RVS-Client ─────────────────────────────────────────────────────
class HostAgent:
def __init__(self) -> None:
self.ws = None
def _for_me(self, payload: dict) -> bool:
"""Command gilt uns, wenn kein host-Feld gesetzt ist (Broadcast) oder es
auf unsere ID/Name passt."""
target = (payload.get("host") or payload.get("hostId") or "").strip()
if not target:
return True
return target.lower() in (HOST_ID.lower(), HOST_NAME.lower())
async def _send(self, message: dict) -> None:
if self.ws is None:
return
try:
await self.ws.send(json.dumps(message))
except Exception as exc:
logger.warning("Senden fehlgeschlagen: %s", exc)
async def _hello(self, log: bool = False) -> None:
if log:
logger.info("host_hello: id=%s name=%s caps=%s control=%s",
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
await self._send({"type": "host_hello", "payload": {
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
}, "timestamp": int(time.time() * 1000)})
async def _heartbeat(self) -> None:
while True:
await asyncio.sleep(HEARTBEAT_SEC)
await self._send({"type": "host_ping", "payload": {"hostId": HOST_ID},
"timestamp": int(time.time() * 1000)})
await self._hello()
async def _handle(self, raw: str) -> None:
try:
msg = json.loads(raw)
except Exception:
return
if msg.get("type") != "host_command":
return
payload = msg.get("payload") or {}
if not self._for_me(payload):
return
req_id = payload.get("requestId", "")
action = (payload.get("action") or "").strip()
params = payload.get("params") or {}
if not CONTROL_ENABLED:
result = {"ok": False, "error": "Steuerung ist deaktiviert (CONTROL_ENABLED=false)."}
elif action not in ACTIONS:
result = {"ok": False, "error": f"Aktion '{action}' unbekannt (bekannt: {', '.join(ACTIONS)})."}
else:
logger.info("[cmd] %s params=%s", action,
{k: str(v)[:60] for k, v in params.items() if k not in ("base64",)})
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(None, ACTIONS[action], params)
except Exception as exc:
result = {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
await self._send({"type": "host_result",
"payload": {"requestId": req_id, "hostId": HOST_ID,
"action": action, **result},
"timestamp": int(time.time() * 1000)})
async def run(self) -> None:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return
backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback).
use_tls = RVS_TLS
tls_fallback_tried = False
connect_kwargs = {"max_size": 16 * 1024 * 1024,
"ping_interval": 20, "ping_timeout": 20}
while True:
proto = "wss" if use_tls else "ws"
# Bei TLS + RVS_SNI: URI nutzt den HOSTNAMEN (Host-Header/SNI/Cert),
# getaddrinfo mappt ihn auf die IP in RVS_HOST. Bei ws:// direkt die IP.
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
fallback = False
try:
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
self.ws = ws
backoff = 1
tls_fallback_tried = False
await self._hello(log=True)
hb = asyncio.create_task(self._heartbeat())
try:
async for raw in ws:
await self._handle(raw)
finally:
hb.cancel()
except Exception as exc:
logger.warning("RVS-Verbindung verloren: %s", exc)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
fallback = True
finally:
self.ws = None
if fallback:
continue # sofort erneut mit ws://
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
use_tls = RVS_TLS # kein Sticky-Fallback
tls_fallback_tried = False
def main() -> None:
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
try:
asyncio.run(HostAgent().run())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# ARIA Host-Agent — Service-Installer (systemd).
#
# Installiert die Binary nach /usr/local/bin, die .env nach /etc/aria-host-agent
# und richtet den systemd-Dienst ein (enable + start).
#
# Nutzung:
# sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt uebergeben
# sudo ./install-service.sh # ncurses-Dateidialog (dialog)
#
# Die Binary wird unter ./dist/aria-host-agent oder ./aria-host-agent erwartet
# (vorher ./build.sh oder ./build-native.sh ausfuehren), oder als 2. Argument.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DST="/usr/local/bin/aria-host-agent"
ETC_DIR="/etc/aria-host-agent"
UNIT_DST="/etc/systemd/system/aria-host-agent.service"
die() { echo "FEHLER: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte mit sudo/root ausfuehren: sudo $0 $*"
# ── Binary finden (Arg 2 > dist/ > ./) ──────────────────────────────
BIN_SRC="${2:-}"
if [ -z "$BIN_SRC" ]; then
if [ -x "$SCRIPT_DIR/dist/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/dist/aria-host-agent"
elif [ -x "$SCRIPT_DIR/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/aria-host-agent"
fi
fi
[ -n "$BIN_SRC" ] && [ -f "$BIN_SRC" ] || die "Binary nicht gefunden. Erst bauen (./build.sh) oder als 2. Argument uebergeben."
# ── .env bestimmen: Arg 1, sonst ncurses-Dateidialog ────────────────
ENV_SRC="${1:-}"
if [ -z "$ENV_SRC" ]; then
if ! command -v dialog >/dev/null 2>&1; then
echo "Kein .env-Pfad uebergeben und 'dialog' ist nicht installiert."
read -r -p "dialog jetzt installieren (apt)? [j/N] " a
case "$a" in
j|J|y|Y) (apt-get update && apt-get install -y dialog) || die "dialog-Installation fehlgeschlagen — .env-Pfad bitte als Argument uebergeben." ;;
*) die "Ohne dialog bitte den .env-Pfad als Argument uebergeben: sudo $0 /pfad/zur/.env" ;;
esac
fi
# dialog --fselect: Dateibrowser; --stdout gibt die Auswahl auf stdout.
START_DIR="${SUDO_USER:+/home/$SUDO_USER/}"
[ -d "$START_DIR" ] || START_DIR="$SCRIPT_DIR/"
ENV_SRC="$(dialog --stdout --title 'ARIA Host-Agent — .env auswaehlen' \
--fselect "$START_DIR" 14 72)" || true
clear
[ -n "$ENV_SRC" ] || die "Abgebrochen — keine .env ausgewaehlt."
fi
[ -f "$ENV_SRC" ] || die ".env nicht gefunden: $ENV_SRC"
echo "Binary : $BIN_SRC"
echo ".env : $ENV_SRC"
# ── Installieren ────────────────────────────────────────────────────
install -m 0755 "$BIN_SRC" "$BIN_DST"
install -d -m 0755 "$ETC_DIR"
install -m 0600 "$ENV_SRC" "$ETC_DIR/.env" # 0600: enthaelt Token/Passwoerter
if [ -f "$SCRIPT_DIR/aria-host-agent.service" ]; then
install -m 0644 "$SCRIPT_DIR/aria-host-agent.service" "$UNIT_DST"
else
die "aria-host-agent.service nicht gefunden neben dem Installer."
fi
systemctl daemon-reload
systemctl enable --now aria-host-agent
echo
echo "✓ Installiert & gestartet."
echo " Status: systemctl status aria-host-agent"
echo " Log: journalctl -u aria-host-agent -f"
echo " .env: $ETC_DIR/.env (aendern -> systemctl restart aria-host-agent)"
+150
View File
@@ -0,0 +1,150 @@
#!/bin/bash
# ════════════════════════════════════════════════════════════════════
# ARIA Host-Agent — Release Script
# Baut die auf Linux+Docker moeglichen Artefakte und haengt sie als
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
#
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
#
# Artefakte:
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
# - Android-APK (Docker, Gradle) -> immer
# - macOS / Windows (falls in dist/ vorgebaut) -> optional
# (mac/win koennen auf Linux nicht cross-gebaut werden -> build-native.*
# auf dem jeweiligen OS laufen lassen, Ergebnis nach host-agent/dist/ legen)
#
# Eigener Tag-Namespace agent-v<version> (kollidiert NICHT mit den
# App-Tags v<version>).
#
# Gitea-Zugang (GITEA_URL, GITEA_REPO, GITEA_USER) wird aus der Umgebung
# oder aus host-agent/.env bzw. der Repo-Wurzel-.env gelesen; das Kennwort
# wird interaktiv abgefragt.
# ════════════════════════════════════════════════════════════════════
set -e
cd "$(dirname "$0")" # host-agent/
SCRIPT_DIR="$(pwd)"
ROOT_DIR="$(cd .. && pwd)"
# ── Farben ───────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# ── Parameter ────────────────────────────────────────────────────────
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
TAG="agent-v$VERSION"
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
[ -f "$ROOT_DIR/.env" ] && source "$ROOT_DIR/.env"
GITEA_URL="${GITEA_URL:?"GITEA_URL nicht gesetzt (in .env oder als Umgebungsvariable)"}"
GITEA_REPO="${GITEA_REPO:?"GITEA_REPO nicht gesetzt (z.B. stefan/aria-agent)"}"
GITEA_USER="${GITEA_USER:-$(echo "$GITEA_REPO" | cut -d'/' -f1)}"
echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ ARIA Host-Agent Release — ${TAG}$(printf '%*s' $((14 - ${#TAG})) '')║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}\n"
# ── Kennwort ─────────────────────────────────────────────────────────
echo -e "${YELLOW}Gitea-Login: ${GITEA_USER}${NC}"
read -s -p "Gitea-Kennwort: " GITEA_PASS; echo ""
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITEA_USER}:${GITEA_PASS}" "$GITEA_URL/api/v1/user")
if [ "$HTTP_CODE" != "200" ]; then
echo -e "${RED}Login fehlgeschlagen (HTTP $HTTP_CODE). Kennwort korrekt?${NC}"; exit 1
fi
echo -e " ${GREEN}✓${NC} Login erfolgreich\n"
# ── Versionsnummern setzen ───────────────────────────────────────────
echo -e "${GREEN}[1/5] Version auf $VERSION setzen...${NC}"
# Desktop-Agent
sed -i "s/^AGENT_VERSION = \"[^\"]*\"/AGENT_VERSION = \"$VERSION\"/" host_agent.py
echo -e " ${GREEN}✓${NC} host_agent.py → AGENT_VERSION $VERSION"
# Android: versionName + versionCode (aus Version berechnen; 3- oder 4-stellig)
IFS='.' read -ra VP <<< "$VERSION"
V1=${VP[0]:-0}; V2=${VP[1]:-0}; V3=${VP[2]:-0}; V4=${VP[3]:-0}
VERSION_CODE=$((V1 * 1000000 + V2 * 10000 + V3 * 100 + V4)); [ "$VERSION_CODE" -lt 1 ] && VERSION_CODE=1
sed -i "s/versionName '[^']*'/versionName '$VERSION'/" android/app/build.gradle
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle
echo -e " ${GREEN}✓${NC} android/app/build.gradle → versionName $VERSION, versionCode $VERSION_CODE\n"
# ── Bauen (Docker) ───────────────────────────────────────────────────
echo -e "${GREEN}[2/5] Linux-Binary + Android-APK bauen (Docker)...${NC}"
./build.sh >/dev/null
LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
( cd android && ./build.sh >/dev/null )
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Android-APK ($(du -h "$APK" | cut -f1))"
# Windows (.exe + setup.exe) via Wine im Docker. Dauert (pywine-Image ~1-2 GB) —
# mit SKIP_WINDOWS=1 ./release_agent.sh <v> ueberspringbar.
if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
else
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
else
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
fi
fi
echo ""
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
ASSETS=(
"$LINUX_BIN::aria-host-agent-linux-x64"
"$APK::aria-host-agent-android-$TAG.apk"
)
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
# vorgebaut (build-native.sh auf einem Mac -> dist/aria-host-agent-macos legen).
[ -f "$SCRIPT_DIR/dist/aria-host-agent-macos" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-macos::aria-host-agent-macos")
[ -f "$SCRIPT_DIR/dist/aria-host-agent.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent.exe::aria-host-agent-windows.exe")
[ -f "$SCRIPT_DIR/dist/aria-host-agent-setup.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-setup.exe::aria-host-agent-windows-setup.exe")
# ── Git-Tag ──────────────────────────────────────────────────────────
echo -e "${GREEN}[3/5] Git-Tag $TAG...${NC}"
git add host_agent.py android/app/build.gradle
git commit -m "release(agent): bump to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
if git rev-parse "$TAG" &>/dev/null; then
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
else
git tag "$TAG"; echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
fi
git push origin main "$TAG"
echo -e " ${GREEN}✓${NC} Tag gepusht\n"
# ── Gitea-Release ────────────────────────────────────────────────────
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
BODY=$(printf 'ARIA Host-Agent %s\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README.' "$TAG")
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Host-Agent $TAG\",\"body\":$BODY_JSON,\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then echo -e "${RED}Release fehlgeschlagen:${NC}\n$RESP"; exit 1; fi
echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt\n"
# ── Assets hochladen ─────────────────────────────────────────────────
echo -e "${GREEN}[5/5] Assets hochladen (${#ASSETS[@]})...${NC}"
for entry in "${ASSETS[@]}"; do
path="${entry%%::*}"; name="${entry##*::}"
UP=$(curl -s -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$name" \
-u "${GITEA_USER}:${GITEA_PASS}" -F "attachment=@${path}")
if echo "$UP" | grep -q '"name"'; then
echo -e " ${GREEN}✓${NC} $name"
else
echo -e " ${RED}✗ $name fehlgeschlagen:${NC} $UP"
fi
done
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
+2
View File
@@ -0,0 +1,2 @@
websockets>=12.0
psutil>=5.9 # System-Info (CPU/RAM/Disk/Uptime) fuer host_info
+83
View File
@@ -0,0 +1,83 @@
; ARIA Host-Agent — Windows-Installer (NSIS, auf Linux mit makensis gebaut).
;
; Installiert die Agent-.exe nach %ProgramFiles%\ARIA Host-Agent, legt eine .env
; in %ProgramData%\ARIA-Host-Agent an (nur falls noch keine da ist — User-Config
; bleibt erhalten) und richtet einen automatisch startenden Windows-Dienst via
; nssm ein. Der Dienst laeuft mit AppDirectory = ProgramData-Ordner, damit der
; Agent die .env von dort (aus dem CWD) liest.
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!define SVC "ARIAHostAgent"
Name "ARIA Host-Agent ${VERSION}"
OutFile "aria-host-agent-setup.exe"
InstallDir "$PROGRAMFILES64\ARIA Host-Agent"
RequestExecutionLevel admin
Unicode true
ShowInstDetails show
ShowUninstDetails show
Var DataDir
Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles
Section "Install"
SetOutPath "$INSTDIR"
File "aria-host-agent.exe"
File "nssm.exe"
; ProgramData-Ordner fuer die .env bestimmen
ReadEnvStr $0 "ProgramData"
StrCmp $0 "" 0 +2
StrCpy $0 "$PROFILE" ; Fallback, falls %ProgramData% fehlt
StrCpy $DataDir "$0\ARIA-Host-Agent"
CreateDirectory "$DataDir"
; .env nur schreiben, wenn noch keine existiert (User-Config nicht ueberschreiben)
IfFileExists "$DataDir\.env" env_done 0
FileOpen $1 "$DataDir\.env" w
FileWrite $1 "# ARIA Host-Agent — Konfiguration (dieser Windows-Dienst liest diese Datei).$\r$\n"
FileWrite $1 "# Nach dem Aendern den Dienst neu starten: services.msc -> ARIA Host-Agent.$\r$\n"
FileWrite $1 "RVS_HOST=rvs.example.de$\r$\n"
FileWrite $1 "RVS_PORT=443$\r$\n"
FileWrite $1 "RVS_TLS=true$\r$\n"
FileWrite $1 "RVS_TLS_FALLBACK=true$\r$\n"
FileWrite $1 "RVS_TOKEN=$\r$\n"
FileWrite $1 "RVS_SNI=$\r$\n"
FileWrite $1 "HOST_NAME=$\r$\n"
FileWrite $1 "CONTROL_ENABLED=true$\r$\n"
FileClose $1
env_done:
; Dienst (neu) einrichten — evtl. alten sauber entfernen, dann installieren
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" install ${SVC} "$INSTDIR\aria-host-agent.exe"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} AppDirectory "$DataDir"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} DisplayName "ARIA Host-Agent"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Description "ARIA-Fernsteuerung dieses Rechners (RVS-Agent)."'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Start SERVICE_AUTO_START'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" start ${SVC}'
; Uninstaller + Eintrag unter "Apps & Features"
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayName" "ARIA Host-Agent"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "UninstallString" '"$INSTDIR\uninstall.exe"'
SectionEnd
Section "Uninstall"
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
Delete "$INSTDIR\aria-host-agent.exe"
Delete "$INSTDIR\nssm.exe"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent"
; Die .env in %ProgramData%\ARIA-Host-Agent bleibt bewusst erhalten (User-Config).
SectionEnd
+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] 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] Whisper STT auf die Gamebox ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (Gamebox), Fallback lokal nach 45s Timeout
- [x] Whisper-Modell hot-swap auf Gamebox via config-Broadcast aus Diagnostic
- [x] Whisper STT auf die AI-Box ausgelagert (faster-whisper CUDA, float16) — neuer aria-whisper-bridge Container
- [x] aria-bridge: STT primaer remote (AI-Box), Fallback lokal nach 45s Timeout
- [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] 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)
@@ -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] 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] App Chat-Suche umgebaut: Highlight + Next/Prev statt Filter
- [x] App Pinch-Zoom in Bildern rewriten (Multi-Touch-Race-Bugs)
@@ -399,7 +399,7 @@ Skills mit Tool-Use.
### Architektur
- [ ] Diagnostic: System-Info Tab (Container-Status, Disk, RAM, CPU)
- [ ] 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
- [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)
+46 -1
View File
@@ -63,7 +63,7 @@ const ALLOWED_TYPES = new Set([
"agent_stream",
"oauth_callback",
// Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das
// Qwen3 auf der Gamebox (via Bridge → RVS → llm-adapter → llama.cpp).
// Qwen3 auf der AI-Box (via Bridge → RVS → llm-adapter → llama.cpp).
// llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt.
"llm_request", "llm_response", "llm_partial",
// Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App)
@@ -80,6 +80,28 @@ const ALLOWED_TYPES = new Set([
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
// fuehren Aktionen aus (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",
// Host-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
"host_hello", "host_ping", "host_command", "host_result",
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
"rooms_query", "rooms_info",
// 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> }
@@ -331,6 +353,29 @@ function registerClient(ws, token) {
return;
}
// Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en).
// Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und
// der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen-
// Log, aber verschiedene volle Tokens) sofort sichtbar. KEINE vollen Tokens.
if (msg.type === "rooms_query") {
const crypto = require("crypto");
const out = [];
for (const [tok, room] of rooms) {
let live = 0;
for (const c of room.clients) if (c.readyState === 1) live++;
out.push({
token8: tok.slice(0, 8),
fp: crypto.createHash("sha256").update(tok).digest("hex").slice(0, 12),
len: tok.length,
clients: live,
you: tok === ws._token,
});
}
out.sort((a, b) => b.clients - a.clients);
ws.send(JSON.stringify({ type: "rooms_info", payload: { rooms: out }, timestamp: Date.now() }));
return;
}
// Update-Download: APK als Base64 ueber WebSocket senden
if (msg.type === "update_download") {
const apkInfo = getLatestAPK();
+39 -2
View File
@@ -6,7 +6,14 @@
RVS_HOST=rvs.example.de
RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
RVS_TOKEN=
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Satellit im selben Netz wie der
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
@@ -24,9 +31,39 @@ CONTROL_ENABLED=true
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
# 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
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)
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec
# ─── 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) ───────────────────────────────────
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
# 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 .
RUN pip install --no-cache-dir -r requirements.txt
+5
View File
@@ -48,6 +48,11 @@ python satellite.py # liest .env automatisch
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
anspricht („Büro").
**Satellit im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die
interne IP statt NAT-Hairpin): `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
(der Name, für den das Caddy-Zertifikat gilt). Ohne das scheitert TLS an
`tlsv1 alert internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
(`192.168.0.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
falschen (Docker-)Netz.
+3
View File
@@ -21,3 +21,6 @@ services:
network_mode: host
env_file: .env
restart: unless-stopped
volumes:
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
- ./data:/data
+2
View File
@@ -1,3 +1,5 @@
websockets>=12.0
zeroconf>=0.131.0
requests>=2.31.0
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
paramiko>=3.4 # SSH-Client fuer ssh.exec (Passwort ODER Key)
+737 -11
View File
@@ -35,6 +35,7 @@ import re
import socket
import struct
import time
from pathlib import Path
from typing import Optional
import websockets
@@ -105,7 +106,25 @@ def _default_id() -> str:
RVS_HOST = os.environ.get("RVS_HOST", "")
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
RVS_TLS = _env_bool("RVS_TLS", True)
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. ein
# Satellit im selben Netz wie der RVS, der direkt auf die interne IP verbindet
# (das Caddy-Zertifikat gilt aber fuer den Namen). Leer = SNI = RVS_HOST.
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
@@ -113,7 +132,9 @@ SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).stri
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
CONTROL_ALLOWLIST = [
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,ssh.exec").split(",")
if a.strip()
]
@@ -121,6 +142,118 @@ 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")
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")
# SSH (ssh.exec): Verbindungs-/Kommando-Timeout. Auth aus dem Credential-Store
# (Typ 'ssh': user + pass ODER key). Default-Port 22, pro Request/Cred ueberschreibbar.
SSH_TIMEOUT_SEC = float(os.environ.get("SSH_TIMEOUT_SEC", "20") or "20")
# 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", "ssh") if entry.get(t)]
out.append({"ip": ip, "types": types})
return out
HEARTBEAT_SEC = 25
# mDNS-Servicetypen, die fuer ARIA interessant sind.
@@ -420,6 +553,18 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_wol, params)
if action in ("http.get", "http.post"):
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)
if action == "ssh.exec":
return await loop.run_in_executor(None, _do_ssh, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -471,15 +616,484 @@ def _do_wol(params: dict) -> dict:
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
def _window_text(text: str, params: dict) -> dict:
"""Schneidet grosse Text-Ausgaben zu: optionaler contains-Zeilenfilter, dann
offset/max_chars-Fenster. Gibt body + Metadaten (total/returned/truncated)."""
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:
text = "\n".join(ln for ln in text.splitlines()
if any(t in ln.lower() for t in terms))
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]
return {"body": body, "total_chars": total, "filtered": bool(contains),
"offset": offset, "returned_chars": len(body),
"truncated": offset + len(body) < len(text)}
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
url = params.get("url") or ""
if not url.startswith(("http://", "https://")):
return {"ok": False, "error": "url (http/https) erforderlich."}
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"),
headers=params.get("headers"), timeout=6)
return {"ok": True, "result": {"status": r.status_code, "body": r.text[:2000]}}
headers=params.get("headers"), auth=auth,
timeout=HTTP_TIMEOUT_SEC)
return {"ok": True, "result": {"status": r.status_code, **_window_text(r.text, params)}}
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}
# ─── SSH ───────────────────────────────────────────────────────────
def _ssh_load_key(key_str: str):
"""Laedt einen privaten Schluessel aus einem String (RSA/Ed25519/ECDSA/DSS)."""
import io
import paramiko
for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey, paramiko.DSSKey):
try:
return cls.from_private_key(io.StringIO(key_str))
except Exception:
continue
return None
def _do_ssh(params: dict) -> dict:
"""Fuehrt EIN Kommando per SSH auf einem Geraet aus. params: {ip|host, cmd,
user?, pass?, key?, port?}. Auth bevorzugt aus dem Credential-Store (Typ 'ssh').
Grosse Ausgaben werden wie bei http.get gefenstert (contains/offset/max_chars)."""
import paramiko
host = _snmp_target(params)
if not host:
return {"ok": False, "error": "ip/host erforderlich."}
cmd = params.get("cmd") or params.get("command") or ""
if not cmd:
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
creds = _creds_for(host).get("ssh", {})
user = str(params.get("user") or creds.get("user") or "")
if not user:
return {"ok": False, "error": "SSH-Benutzer fehlt (Credentials fuer diese IP setzen)."}
port = _to_int(params.get("port") or creds.get("port")) or 22
password = params.get("pass") or params.get("password") or creds.get("pass")
key_str = params.get("key") or creds.get("key")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
kwargs = {"hostname": host, "port": port, "username": user,
"timeout": SSH_TIMEOUT_SEC, "banner_timeout": SSH_TIMEOUT_SEC,
"auth_timeout": SSH_TIMEOUT_SEC, "allow_agent": False,
"look_for_keys": False}
if key_str:
pkey = _ssh_load_key(str(key_str))
if pkey is None:
return {"ok": False, "error": "Privater SSH-Key nicht lesbar (Format?)."}
kwargs["pkey"] = pkey
if password:
kwargs["password"] = str(password)
client.connect(**kwargs)
stdin, stdout, stderr = client.exec_command(cmd, timeout=SSH_TIMEOUT_SEC)
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", "ignore")
err = stderr.read().decode("utf-8", "ignore")
except paramiko.AuthenticationException:
return {"ok": False, "error": "SSH-Auth fehlgeschlagen (User/Passwort/Key pruefen)."}
except Exception as exc:
return {"ok": False, "error": f"SSH fehlgeschlagen: {exc}"}
finally:
try:
client.close()
except Exception:
pass
win = _window_text(out, params)
return {"ok": True, "result": {"host": host, "exit_code": exit_code,
"stderr": err[:4000], **win}}
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 ────────────────────────────────────────────────────────
@@ -531,13 +1145,42 @@ class Satellite:
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
arp = await loop.run_in_executor(None, _discover_arp)
self._devices = _merge_devices(mdns, ssdp, arp)
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
self._devices_ts = time.time()
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp))
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
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:
self._scanning = False
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:
if self.ws is None:
return
@@ -597,7 +1240,12 @@ class Satellite:
params = payload.get("params") or {}
if payload.get("device") and "device" not in params:
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)
await self._send({
"type": "sat_result",
@@ -605,6 +1253,63 @@ class Satellite:
"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", "ssh"):
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:
while True:
try:
@@ -626,17 +1331,29 @@ class Satellite:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return
_creds_load()
asyncio.create_task(self._periodic_scan())
backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI
# nutzt die URI den HOSTNAMEN (Host-Header/SNI/Cert), getaddrinfo mappt ihn
# auf die IP in RVS_HOST.
use_tls = RVS_TLS
tls_fallback_tried = False
connect_kwargs = {"max_size": 8 * 1024 * 1024,
"ping_interval": 20, "ping_timeout": 20}
while True:
proto = "wss" if RVS_TLS else "ws"
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
proto = "wss" if use_tls else "ws"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
fallback = False
try:
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
async with websockets.connect(url, max_size=8 * 1024 * 1024,
ping_interval=20, ping_timeout=20) as ws:
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
self.ws = ws
backoff = 1
tls_fallback_tried = False
await self._hello(log=True)
hb = asyncio.create_task(self._heartbeat())
try:
@@ -646,10 +1363,19 @@ class Satellite:
hb.cancel()
except Exception as exc:
logger.warning("RVS-Verbindung verloren: %s", exc)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
fallback = True
finally:
self.ws = None
if fallback:
continue
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
use_tls = RVS_TLS
tls_fallback_tried = False
def main() -> None:
+50 -3
View File
@@ -1,11 +1,58 @@
# ════════════════════════════════════════════════
# ARIA XTTS v2 — Konfiguration
# Kopieren nach .env und anpassen
# ARIA Compute-Node — Konfiguration
# 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=example.com
RVS_PORT=444
RVS_TLS=true
RVS_TLS_FALLBACK=true
RVS_TOKEN=dein_token_hier
# RVS_SNI: nur noetig, wenn die Box im SELBEN Netz wie der RVS steht und direkt
# auf dessen interne IP verbinden soll (kein NAT-Hairpin ueber den externen
# Hostnamen). Dann RVS_HOST=<interne-ip> und hier den Zertifikats-/Hostnamen, fuer
# den Caddy sein Cert hat. Zuhause / normal: leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── 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
+14 -5
View File
@@ -1,7 +1,12 @@
# ai-box — KI-Box (gpubox) Bootstrap
# xtts — AI-Box (Compute-Node) Setup & Bootstrap
Macht aus einem frisch installierten **Debian Trixie** (headless, nur SSH) einen
startklaren GPU-Satelliten-Host für den `xtts`-Stack (STT/TTS/LLM).
Der `xtts`-Stack sind ARIAs GPU-Dienste (STT: Voxtral/Whisper · TTS: F5-TTS ·
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
@@ -20,13 +25,13 @@ Alles **idempotent** — mehrfach ausführbar.
```bash
git clone <repo> ARIA-AGENT
cd ARIA-AGENT/ai-box
cd ARIA-AGENT/xtts
sudo ./bootstrap.sh
# → Wenn der Treiber frisch installiert wurde: einmal neu starten, dann nochmal:
sudo reboot
# … nach dem Boot:
cd ARIA-AGENT/ai-box
cd ARIA-AGENT/xtts
sudo ./bootstrap.sh --up --token <DEIN_RVS_TOKEN>
```
@@ -53,6 +58,10 @@ GPU-Test + Stack-Start.
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
- **Box im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die interne
IP statt NAT-Hairpin): in `xtts/.env` `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
(Name, für den das Caddy-Zert gilt). Ohne das scheitert TLS an `tlsv1 alert
internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
## Verifizieren
+2 -2
View File
@@ -4,7 +4,7 @@
# GPU-Satelliten-Host fuer den xtts-Stack (Voxtral/Whisper STT, F5-TTS, lokales LLM).
#
# Ablauf nach `git clone`:
# cd ARIA-AGENT/ai-box
# cd ARIA-AGENT/xtts
# sudo ./bootstrap.sh # richtet Treiber + Docker + NVIDIA-Toolkit ein
# # (falls Treiber frisch installiert: einmal `sudo reboot`, dann Script erneut)
# sudo ./bootstrap.sh --up # dazu: xtts-Stack (Whisper+F5+LLM) hochziehen
@@ -122,7 +122,7 @@ EOF
ok "nvidia-driver aus backports installiert: ${NEWV}"
echo
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}"
exit 0
else
+101 -98
View File
@@ -1,45 +1,50 @@
# ════════════════════════════════════════════════
# ARIA Gamebox Stack — GPU F5-TTS + Whisper STT
# Laeuft auf dem Gaming-PC (RTX 3060)
# Verbindet sich zum RVS fuer TTS/STT-Requests
# ARIA Compute-Node — GPU-Dienste (STT / TTS / LLM)
#
# FLUX-Bildgenerierung liegt im /flux Verzeichnis im Repo-Root —
# eigener Compose-Stack, kann auch auf einer anderen Maschine laufen.
# KEINE feste "AI-Box" mehr: dieser Stack laeuft auf beliebig vielen
# 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:
# - Docker Desktop mit WSL2
# - NVIDIA Container Toolkit
# - .env mit RVS-Verbindungsdaten
# - Docker + NVIDIA Container Toolkit (registriert die `nvidia`-Runtime)
# - .env mit RVS-Verbindungsdaten, COMPOSE_PROFILES, NODE_NAME, *_GPU
#
# Start: docker compose up -d
# Start: docker compose up -d (liest COMPOSE_PROFILES aus der .env)
# ════════════════════════════════════════════════
services:
# ─── F5-TTS Bridge (GPU) ──────────────────────
# Ersetzt den frueheren XTTS-Stack. Empfaengt xtts_request via RVS,
# rendert via F5-TTS mit Voice-Cloning, streamt PCM an die App.
# Voice-Upload: speichert WAV und laesst whisper-bridge den Referenz-
# text transkribieren — der User muss nichts eintippen.
# Empfaengt xtts_request via RVS, rendert via F5-TTS mit Voice-Cloning,
# streamt PCM an die App. Voice-Upload: speichert WAV und laesst eine
# STT-Bridge den Referenztext transkribieren — der User tippt nichts.
f5tts-bridge:
build: ./f5tts
container_name: aria-f5tts-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["0"] # TTS → GPU 0 (8 GB; F5 ist klein)
capabilities: [gpu]
profiles: ["f5tts"] # startet nur mit COMPOSE_PROFILES=…f5tts…
runtime: nvidia
volumes:
- ./voices:/voices # WAV + TXT Referenz
- ./hf-cache:/root/.cache/huggingface # HF-Cache als Bind-Mount.
# Direkt sichtbar im xtts/hf-cache/,
# einfach manuell zu loeschen, kein
# Docker-Desktop .vhdx Bloat.
# Wird mit whisper-bridge geteilt.
# Wird mit STT-Bridges geteilt.
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,
# nfe_step, Custom-Checkpoint) kommen ueber Diagnostic via RVS-config.
- RVS_HOST=${RVS_HOST}
@@ -51,27 +56,22 @@ services:
- VOICES_DIR=/voices
restart: unless-stopped
# ─── Whisper STT (GPU) ────────────────────────
# Faster-Whisper auf der Gamebox statt auf der VM (CPU) —
# deutlich schneller. Verbindet sich selbst per WebSocket an
# den RVS und nimmt dort stt_request Nachrichten der aria-bridge
# entgegen, antwortet mit stt_response. Zusaetzlich nutzt die
# f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
# Voice-Uploads. Laedt das Modell beim Start vor; auf Config-
# Broadcasts (Diagnostic → whisperModel) wird zur Laufzeit hot-
# swapped.
# ─── Whisper STT (GPU) — opt-in Fallback-STT ──
# Faster-Whisper auf CUDA. Verbindet sich selbst per WebSocket an den RVS
# und nimmt stt_request / stt_stream_* Nachrichten entgegen. Zusaetzlich
# nutzt die f5tts-bridge Whisper intern fuer die Referenz-Transkription bei
# Voice-Uploads. Modell-Hot-Swap via Diagnostic (config-Broadcast).
# Nur EINEN STT-Provider pro Node laufen lassen (voxtral ODER whisper) —
# sonst beantworten beide dieselbe Anfrage doppelt.
whisper-bridge:
build: ./whisper
container_name: aria-whisper-bridge
profiles: ["whisper"] # Fallback-STT — startet nur mit --profile whisper
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["1"] # STT/groesstes Modell → GPU 1 (12 GB; spaeter Voxtral-STT-3B ~9 GB)
capabilities: [gpu]
profiles: ["whisper"] # startet nur mit COMPOSE_PROFILES=…whisper…
runtime: nvidia
environment:
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST}
- RVS_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
@@ -91,74 +91,22 @@ services:
# Container-Restarts.
restart: unless-stopped
# ─── Lokales LLM (Plan B, B0.5) — llama-swap (GPU) ────────────
# llama-swap laedt/swappt mehrere Modelle on-demand (nur eins passt gleich-
# zeitig in die 12 GB). Welches geladen wird, bestimmt das `model`-Feld im
# Request — das Brain schickt es aus local_llm.json mit. Erster Load eines
# Modells zieht das GGUF via -hf von HF (Cache unter /models, persistent).
# OpenAI-kompatibel auf :8080, nur im Compose-Netz; die Bruecke macht der
# llm-adapter. Modell-Liste: ./llama-swap/config.yaml.
#
# BLIND GEBAUT (kein Gamebox-Test hier): beim ersten Start
# `docker logs -f aria-llama-swap` pruefen. Image bundelt llama-server.
llama-swap:
image: ghcr.io/mostlygeek/llama-swap:unified-cuda
container_name: aria-llama-swap
deploy:
resources:
reservations:
devices:
- driver: nvidia
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 ─────────
# Ersetzt whisper als STT. Laeuft auf Treiber 550/CUDA 12.4 (torch cu124, KEIN
# Treiber-Upgrade noetig). Modell Voxtral-Mini-3B-2507 (~9 GB bf16) → GPU 1.
# Startet bei jedem `docker compose up -d`. Whisper ist der opt-in Fallback
# (Profil "whisper") — beide zusammen wuerden stt_* doppelt beantworten, also
# immer nur EINEN STT laufen lassen.
# Voxtral-Mini-3B-2507 (~9 GB bf16). Laeuft auf Treiber 550/CUDA 12.4
# (torch cu124, KEIN Treiber-Upgrade noetig). Whisper ist der opt-in
# Fallback — immer nur EINEN STT-Provider pro Node aktiv haben.
voxtral-bridge:
build: ./voxtral
container_name: aria-voxtral-bridge
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["1"] # 12-GB-Karte (STT-3B ~9 GB); GPU 0 (8 GB) bleibt fuer F5/LLM
capabilities: [gpu]
profiles: ["voxtral"] # startet nur mit COMPOSE_PROFILES=…voxtral…
runtime: nvidia
volumes:
- ./hf-cache:/root/.cache/huggingface # gleicher Modell-Cache wie whisper/f5
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
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_PORT=${RVS_PORT:-443}
- RVS_TLS=${RVS_TLS:-true}
@@ -169,3 +117,58 @@ services:
- HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} # falls das Modell HF-gated ist
- PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True # weniger VRAM-Fragmentierung
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 && \
pip3 install --no-cache-dir -c /tmp/torch-constraint.txt -r requirements.txt
COPY node_stats.py .
COPY bridge.py .
CMD ["python3", "bridge.py"]
+106 -10
View File
@@ -1,6 +1,6 @@
#!/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
16-bit PCM Chunks als audio_pcm Nachrichten zurueck an die App.
@@ -51,6 +51,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
# F5-TTS Konfiguration
# ─────────────────────────────────────────────────────────────────
@@ -60,6 +75,24 @@ RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# f5ttsCkptFile, f5ttsVocabFile, f5ttsCfgStrength, f5ttsNfeStep).
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_CKPT_FILE = "" # leer = Default-Checkpoint von HF
DEFAULT_F5TTS_VOCAB_FILE = "" # leer = Default-Vocab vom Modell
@@ -378,7 +411,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
# ──────────────────────────────────────────────────────────────
# 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
# unterschiedlichen Hosts laufen. Logs ueber RVS heisst: ein Pfad.
#
@@ -460,13 +493,16 @@ _tts_queue: asyncio.Queue[tuple] = asyncio.Queue()
async def _tts_worker(ws, runner: F5Runner) -> None:
"""Serialisiert Synthesen — GPU kann sonst OOM gehen."""
global _tts_busy
while True:
text, voice, request_id, message_id, language, speed = await _tts_queue.get()
_tts_busy = True
try:
await _do_tts(ws, runner, text, voice, request_id, message_id, language, speed)
except Exception:
logger.exception("TTS-Worker Fehler")
finally:
_tts_busy = False
_tts_queue.task_done()
@@ -663,6 +699,18 @@ async def handle_voice_upload(ws, payload: dict) -> None:
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:
try:
voices = []
@@ -699,13 +747,16 @@ async def handle_delete_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."""
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:
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
try:
wav, txt = voice_paths(name)
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
import io, tarfile
buf = io.BytesIO()
@@ -715,10 +766,10 @@ async def handle_export_voice(ws, payload: dict) -> None:
tar.add(txt, arcname=txt.name)
data = base64.b64encode(buf.getvalue()).decode("ascii")
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:
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:
@@ -808,6 +859,31 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
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:
use_tls = RVS_TLS
retry_s = 2
@@ -816,12 +892,14 @@ async def run_loop(runner: F5Runner) -> None:
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
@@ -855,15 +933,31 @@ async def run_loop(runner: F5Runner) -> None:
# TTS-Worker fuer diese Verbindung starten
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:
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:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
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":
try:
@@ -958,6 +1052,7 @@ async def run_loop(runner: F5Runner) -> None:
_last_diag_voice = ""
finally:
worker.cancel()
ping_task.cancel()
try:
await worker
except asyncio.CancelledError:
@@ -985,6 +1080,7 @@ async def main() -> None:
sys.exit(1)
VOICES_DIR.mkdir(parents=True, exist_ok=True)
runner = F5Runner()
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
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
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY node_stats.py .
COPY 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
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).
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
@@ -35,7 +35,7 @@ umstellen + Container neu — Ein-Zeilen-Wechsel, kein Code.
Modelle) ist ein geplanter Folge-Baustein via `llama-swap` — siehe
`docs/plan-local-llm-router.md`.
## Start (auf der Gamebox)
## Start (auf der AI-Box)
```bash
cd xtts
+280 -14
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
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
`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.
Env:
@@ -43,10 +43,38 @@ RVS_PORT = os.getenv("RVS_PORT", "443").strip()
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
# ── 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
# <think>-Block und liefert (bei kleinem max_tokens) leeren/abgeschnittenen
# content, ausserdem 3x langsamer. ARIAs schnelles Tier will KEIN Grübeln
@@ -56,6 +84,94 @@ LLM_TIMEOUT_SEC = float(os.getenv("LLM_TIMEOUT_SEC", "60"))
# empfindlich reagiert: LLM_DISABLE_THINKING=false setzen.
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:
try:
@@ -99,11 +215,17 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
r.raise_for_status()
data = r.json()
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 {
"ok": True,
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or None,
"usage": data.get("usage"),
"usage": usage,
}
except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
@@ -124,7 +246,63 @@ async def _emit_llm_status(ws, state: str, model: str, **extra) -> None:
{"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:
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
req_id = payload.get("requestId", "")
messages = payload.get("messages") or []
@@ -177,6 +355,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:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
@@ -185,35 +418,68 @@ async def _run() -> None:
logger.error("RVS_TOKEN nicht gesetzt — Abbruch")
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
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
async with websockets.connect(
url, ping_interval=20, ping_timeout=10, max_size=16 * 1024 * 1024
) as ws:
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden — llm-adapter online")
retry_s = 2
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:
msg = json.loads(raw)
except Exception:
continue
if msg.get("type") != "llm_request":
continue
mtype = msg.get("type")
payload = msg.get("payload", {}) or {}
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
# aber wir blockieren so nicht den Empfang weiterer Messages.
asyncio.create_task(_handle_llm_request(ws, payload))
# Redundanz-Routing: gezielt an eine andere Instanz adressiert
# → ignorieren. Ohne targetInstance → wie bisher (jeder nimmt).
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:
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:
tls_fallback_tried = True
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
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 && \
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"]
+84 -5
View File
@@ -53,11 +53,45 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
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_DEFAULT_ENDPOINT_MS = 2400
STREAM_DEFAULT_HARD_CAP_MS = 300000
@@ -695,31 +729,71 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
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:
use_tls = RVS_TLS
retry_s = 2
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
max_size=50 * 1024 * 1024) as ws:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
sessions.attach_ws(ws)
await _broadcast_status(ws, "ready", model=VOXTRAL_MODEL)
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:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
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":
sessions.start_session(payload)
elif mtype == "stt_audio_chunk":
@@ -797,6 +871,10 @@ async def run_loop(sessions: SessionManager) -> None:
"AN" if SPEAKER_ID_ENABLED else "AUS")
except Exception as e:
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:
use_tls = False
tls_fallback_tried = True
@@ -815,6 +893,7 @@ async def main() -> None:
await loop.run_in_executor(None, runner.load) # Modell laden (blockierend)
sessions = SessionManager(runner)
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())
+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 .
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"]
+86 -7
View File
@@ -1,6 +1,6 @@
#!/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:
@@ -53,12 +53,44 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")
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"}
# Streaming-Parameter (Defaults — koennen pro Session vom App-Payload ueberschrieben werden)
@@ -284,7 +316,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
# ──────────────────────────────────────────────────────────────
# 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,
# aria-bridge schreibt sie in /shared/logs/app.log. Diagnostic / App-
# Logs-Tab zeigen sie dann mit platform="whisper".
@@ -822,6 +854,30 @@ async def _broadcast_status(ws, state: str, **extra) -> None:
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
# ──────────────────────────────────────────────────────────────
@@ -833,11 +889,13 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/ws?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
@@ -855,21 +913,37 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
logger.info("Initial: sende config_request an aria-bridge")
await _send(ws, "config_request", {"service": "whisper"})
# 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).
await _debug_log(ws, "boot",
"whisper-bridge online — streaming-mode ENABLED, debug-log ON")
except Exception as e:
logger.exception("Initial-Handshake crashed: %s", e)
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:
msg = json.loads(raw)
except Exception:
continue
mtype = msg.get("type", "")
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":
req_id = payload.get("requestId", "?")
@@ -1038,6 +1112,10 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
except Exception as e:
logger.warning("Verbindung verloren: %s", e)
sessions.detach_ws()
try:
ping_task.cancel()
except NameError:
pass
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS-Verbindung fehlgeschlagen — Fallback auf ws://")
use_tls = False
@@ -1058,6 +1136,7 @@ async def main() -> None:
# Endpointer-Loop nebenbei laufen lassen — er pruefst _ws is None und
# schlaeft solange das nicht gesetzt ist.
asyncio.create_task(sessions.run_endpointer())
asyncio.create_task(_stats.run_sampler()) # Auslastungs-Sampler (Stage E)
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