Compare commits

...
35 Commits
Author SHA1 Message Date
duffyduck d474447e54 release(agent): bump to 0.0.1.7 2026-10-25 14:14:01 +01:00
duffyduckandClaude Opus 5.5 15ec8f1be1 fix(host-agent): Windows-Build scheitert nicht mehr an nssm.cc-Ausfaellen
nssm.cc lieferte 503 → Windows-Build im Release fehlgeschlagen, und das
Skript hat die Ursache verschluckt (>/dev/null). Jetzt: 3 Versuche je Quelle,
Ersatz aus dem Internet Archive, feste SHA-256-Pruefsumme (beide Quellen
liefern dieselbe Datei). release_agent.sh schreibt das Build-Log nach
dist/build-win.log und zeigt bei Fehlern die letzten Zeilen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:09:59 +01:00
duffyduck f5f287da54 release(agent): bump to 0.0.1.6 2026-10-25 14:04:25 +01:00
duffyduckandClaude Opus 5.5 9c4b8cbdd7 feat(release): Versionsnummer zaehlt wie ein Kilometerzaehler
Hintere Stellen laufen 0..9 und uebertragen nach vorne (0.2.6.9 → 0.2.7.0,
0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach weiter (9.9.9.9 → 10.0.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:00:47 +01:00
duffyduckandClaude Opus 5.5 9ad402876d feat(release): Versionsnummer zaehlt automatisch hoch
./release.sh und host-agent/release_agent.sh ohne Argument nehmen die hoechste
Version aus Datei (package.json bzw. build.gradle) und letztem Git-Tag und
zaehlen die letzte Stelle hoch (0.2.6.1 → 0.2.6.2). Mit Argument wie bisher.
Vor dem Bauen: Anzeige alt → neu und Rueckfrage; existiert der Tag schon oder
ist die Version ungueltig, bricht das Skript vorher ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:59:16 +01:00
duffyduck 55b7a02b85 release: bump version to 0.2.6.1 2026-10-25 13:32:19 +01:00
duffyduckandClaude Opus 5.5 ae2024ef55 feat(app): "Auf Updates pruefen" sagt immer, was los ist
Keine neue Version: Meldung mit installierter Version, neuester Version auf
dem Server und ob Update-Server und Download-Weg erreichbar sind (HEAD auf
/update/latest.apk). Keine Verbindung: Meldung mit Server-Adresse, Zustand
und letztem Verbindungsfehler. Keine Antwort in 10 s: Hinweis auf alten RVS.
Neue Version: wie bisher der Update-Dialog (nur einmal, nicht doppelt ueber
den ChatScreen-Listener).

RVS antwortet auf update_check jetzt auch ohne neue Version (update_none
mit latest/current).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:26:23 +01:00
duffyduck 27f4fbdd12 release: bump version to 0.2.6.0 2026-10-25 13:25:27 +01:00
duffyduckandClaude Opus 5.5 0acf96860e feat(host-agent): Auto-Update fuer den Android-Agent ueber den RVS
Agent fragt beim Verbinden und alle ~6 h per agent_update_check nach; der
RVS antwortet mit agent_update_available, wenn in rvs/updates/agent/ eine
neuere APK liegt (eigener Ordner — die Cockpit-App bekommt sie so nicht
angeboten). Download im Hintergrund per HTTP (/update/agent/latest.apk,
Raum-Token im Header, 3 Versuche, Groessen-Check), dann Benachrichtigung
"Tippen zum Installieren" + Knopf in der App. Installieren per FileProvider
und Android-Installer (REQUEST_INSTALL_PACKAGES; ein Tippen bleibt Pflicht).
host_hello meldet jetzt die Version. release_agent.sh kopiert die APK auf den
RVS, wenn RVS_UPDATE_HOST gesetzt ist.

RVS: APK-Auslieferung als serveApk() fuer beide Apps zusammengefasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:19:54 +01:00
duffyduck c9988e7244 release: bump version to 0.2.5.9 2026-10-25 13:13:29 +01:00
duffyduckandClaude Opus 5.5 a00199c121 feat(update): App laedt Updates per HTTP mit Fortschrittsanzeige statt als 65-MB-WebSocket-Block
Bisher kam die APK als EINE WebSocket-Nachricht (~65 MB Base64) mit starrem
60-s-Timeout — ueber den externen Proxy (Heim-Upload → Hetzner → Handy) zu
knapp, Abbruch ohne Fortschritt.

RVS: GET/HEAD /update/latest.apk — nur mit gueltigem Raum-Token im Header
X-RVS-Token (sonst 403), Content-Length, Range/206 zum Fortsetzen. Version
im Dateinamen wird ohne angehaengten Punkt erkannt.

App: Download per HTTP (react-native-fs) mit Fortschritt in % und MB,
Abbrechen-Knopf, Abbruch nur bei 30 s ohne Daten statt fester Gesamtzeit,
3 Versuche, Groessen-Check gegen Content-Length. Alter RVS (404): Fallback
auf den WebSocket-Weg mit 5 min Timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:10:07 +01:00
duffyduck baf9e93c2a release: bump version to 0.2.5.8 2026-10-25 12:55:10 +01:00
duffyduckandClaude Opus 5.5 db2055ff3b fix: alte App-Versionen in der Raum-Liste erkennen + HF-Token aus zwei weiteren Log-Zeilen
RVS: die ARIA-App wird auch ohne client_hello erkannt, sobald sie eine
Nachricht schickt, die nur sie sendet (app_log mit platform android/ios,
location_update, stt_lease_request, update_download, vnc_open).

Bridge: "Voice-Config geladen" und "Voice-Config gespeichert" schrieben
den huggingfaceToken noch im Klartext ins Log — jetzt ***.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:52:36 +01:00
duffyduckandClaude Opus 5.5 5a59ea3930 fix(xtts): Flotte zeigt die echte Karte statt "GPU void"
Das NVIDIA-Container-Toolkit ersetzt NVIDIA_VISIBLE_DEVICES im Prozess durch
"void" — whisper/f5tts/voxtral meldeten das als GPU. Die Compose gibt die
Karte jetzt zusaetzlich als ARIA_GPU mit (nur Anzeige).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:51:41 +01:00
duffyduckandClaude Opus 5.5 7b4a1f4429 feat(diagnostic): Liste, wer gerade mit dem RVS-Raum verbunden ist
Einstellungen, zwischen Runtime-Konfiguration und App-Onboarding: alle
Verbindungen im Raum des konfigurierten Tokens — Art, Name, Details,
Adresse, verbunden seit, zuletzt aktiv.

RVS merkt sich pro Verbindung, wer sie ist: aus Nachrichten, die ohnehin
fliessen (sat_hello, host_hello, worker_hello, update_check der App,
llm_cluster_*), oder aus dem neuen client_hello (Bridge, Diagnostic, App
mit Geraetename). Adresse aus X-Forwarded-For hinter einem Reverse-Proxy.
room_clients_query liefert NUR den eigenen Raum; room_clients von Clients
wird nicht weitergereicht. Typen in der Allow-List ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:34:14 +01:00
duffyduckandClaude Opus 5.5 2267f0a726 docs(rvs): WebSocket-Weiterleitung beim Reverse-Proxy deutlich markieren + Testbefehl
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:29:30 +01:00
duffyduckandClaude Opus 5.5 8cc85c1fe1 feat(rvs): interner Port optional verschluesselt (RVS_HTTP_TLS)
Mit RVS_HTTP_TLS=true spricht der RVS auf RVS_HTTP_PORT HTTPS/WSS statt
Klartext — damit ist auch die Strecke externer Proxy → RVS verschluesselt.
Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes,
das beim ersten Start in data/rvs-tls/ erzeugt wird (eigenes darf dort
liegen). Image bekommt dafuer openssl. Nur ohne den Caddy im Stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:23:24 +01:00
duffyduckandClaude Opus 5.5 88036bcad3 feat(rvs): Caddy optional per COMPOSE_PROFILES — oder eigener Reverse-Proxy
Neu in rvs/.env: COMPOSE_PROFILES=caddy (wie bisher, Caddy macht TLS) oder
leer (ohne Caddy). Der RVS bietet dafuer immer einen unverschluesselten Port
an (RVS_HTTP_BIND:RVS_HTTP_PORT, Default 127.0.0.1:3000), auf den ein
vorhandener nginx/Traefik zeigt — statt nginx → Caddy → RVS mit doppeltem TLS.
Caddys HTTPS-Port ist jetzt CADDY_HTTPS_PORT (Default 444 wie bisher).

README: beide Modi, nginx-Vorlage mit WebSocket-Headern und IPv6, Hinweis
dass Caddy fuer die Zertifikats-Erneuerung Port 80 von aussen braucht.
Bestehende Installationen mit Caddy muessen COMPOSE_PROFILES=caddy eintragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:22:11 +01:00
duffyduck 35436bff3c release: bump version to 0.2.5.7 2026-10-25 12:01:35 +01:00
duffyduck b82df7dfca release: bump version to 0.2.5.6 2026-10-25 10:40:09 +01:00
duffyduckandClaude Opus 5.5 3b63831c86 feat(app): Schloss-Symbol fuer die Verbindung + abgelaufenes Zertifikat erlaubbar
Neben dem Zahnrad zeigt ein Schloss, wie sicher die Verbindung zu ARIA ist:
Schloss = verschluesselt und in Ordnung, Schloss mit ! = Zertifikat-Problem
(abgelaufen oder abgelehnt), durchgestrichen = unverschluesselt (TLS-Fallback
oder so eingestellt). Antippen erklaert es in einfachen Worten; einmal pro
App-Start kommt ein Hinweis, wenn das Zertifikat abgelaufen ist.

Einstellungen → Verbindung: "Abgelaufenes Zertifikat akzeptieren". Toleriert
GENAU das Ablaufdatum: die Kette wird zum Zeitpunkt kurz vor dem Ablauf gegen
die System-CAs geprueft, der Hostname weiter von OkHttp. Selbstsignierte oder
fremde Zertifikate kommen auch mit Schalter nicht durch (gegen das echte,
abgelaufene RVS-Zertifikat und badssl.com getestet). Gilt fuer WebSocket und
fetch (z.B. App-Update vom RVS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:31:51 +02:00
duffyduckandClaude Opus 5.5 7da552f249 fix(log): HuggingFace-Token und andere Geheimnisse nicht mehr im Klartext im Log
Die Bridge schrieb beim Config-Broadcast den kompletten Payload ins Log,
inklusive huggingfaceToken. Jetzt werden Werte von Schluesseln wie token,
secret, passw, apikey, credential als *** ausgegeben (Zaehler wie maxTokens
bleiben sichtbar). Dasselbe fuer das Debug-Log der RVS-Nachrichten in der
Diagnostic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:18:05 +02:00
duffyduckandClaude Opus 5.5 79a901089b feat(llm): Kontextfenster pro Karte einstellbar + Fehlerart context_exceeded
Anlass: die Software-Schmiede bekam "request (8274 tokens) exceeds the
available context size (8192 tokens)". Sie holt ctx vom Cluster zwar ab,
kuerzt aber nach einem eigenen, von Hand eingetragenen Wert (32768).

ARIA-Seite:
- Neue Fehlerart context_exceeded (statt model_error) mit promptTokens und ctx
  — Programme koennen automatisch kuerzen und nochmal senden, statt Text
  auszuwerten. llm-adapter liest es aus llama.cpps exceed_context_size_error,
  der Cluster-Router reicht es durch und wiederholt es nicht anderswo.
- Diagnostic → LLM-Karten: Kontextfenster pro Karte (4k … 128k). Laedt das
  Modell mit neuem -c; Programme sehen den neuen Wert ueber ctx. Passt es
  nicht in den Grafikspeicher, setzt die Box zurueck — jetzt inkl. Registry
  (vorher blieb bei einem Fehlschlag der zu grosse ctx in aria_models.json
  stehen und waere beim naechsten Start wieder gescheitert).
- Doku: context_exceeded, ctx kann sich aendern, "danach kuerzen, nicht nur
  anzeigen".

Getestet gegen llama-swap: Kontext 512 → 1024 (gleiche Karte), Fehlschlag
bei 65536 → Registry + Config wieder 1024, zu langer Verlauf →
context_exceeded mit promptTokens=6030, ctx=1024.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:32:37 +02:00
duffyduckandClaude Opus 5.5 f8ae2f4613 fix(llm): kaputte Werkzeug-Argumente im Verlauf lassen nicht mehr die ganze Anfrage scheitern
Zwischendurch kam bei Programmen (Software-Schmiede) "model_error: Server
error '500 …'" — nach "nochmal versuchen" lief es weiter. Ursache, auf der
Box nachgestellt: steht im Verlauf ein frueherer Werkzeugaufruf, dessen
arguments kein gueltiges JSON sind (typisch bei Code: Dateiinhalt mit
Anfuehrungszeichen/Umbruechen), lehnt llama.cpp (Stand der Box, Aug. 2026)
die GANZE Anfrage mit 500 ab — und jede weitere mit demselben Verlauf.
Neuere llama.cpp-Versionen nehmen es an.

llm-adapter:
- _sanitize_messages: solche arguments werden als {"_raw": "…"} verpackt,
  dict-arguments zu JSON, fehlende zu "{}". Nur Kopien; gueltige bleiben
  unangetastet. Auf der Box: kaputt → 500, eingepackt → 200.
- _llama_error_text: bei HTTP-Fehlern kommt der GRUND von llama.cpp mit
  (z.B. "request (9017 tokens) exceeds the available context size (8192)")
  statt nur "Server error '500'". Volle Antwort ins Box-Log — falls es noch
  einen anderen Ausloeser gibt, steht er dort.

Hinweis: ob das der einzige Ausloeser ist, ist offen — die urspruengliche
Fehlerantwort war 4,6 KB gross, diese Ablehnung nur 265 Byte. Mit dem
durchgereichten Grund ist der naechste Fall sofort zu sehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:56:42 +02:00
duffyduckandClaude Opus 5.5 32d34f39cd fix(xtts): LLM auf der GPU + Container finden nach dem Reboot den RVS
Zwei Ursachen, beide auf der KI-Box nachgewiesen:

1. llama.cpp fand auch mit Treiber 615 keine Karte ("unsupported display
   driver / cuda driver combination"). Das llama-swap-Image traegt in seiner
   ld.so.conf die eigene CUDA-Kompatibilitaets-libcuda (575) VOR dem vom
   Container-Toolkit eingehaengten Host-Treiber (615) ein. Die Kompatibilitaets-
   bibliothek hilft nur Rechenzentrumskarten; bei GeForce scheitert llama.cpp
   damit — mit aelterem Treiber ("forward compatibility …") wie mit neuerem —
   und rechnet still auf der CPU. Jetzt LD_LIBRARY_PATH mit dem Host-Treiber
   zuerst. Auf der Box getestet: llama.cpp sieht CUDA0 + CUDA1. Whisper und
   F5-TTS (andere Images) sehen ihre Karte mit 615 ohnehin.

2. Nach dem Reboot erreichte kein Container den RVS ("Temporary failure in name
   resolution"). Debian traegt die Netzwerkkarte als allow-hotplug ein; darauf
   wartet network-online.target nicht. Docker startete die Container 2 s nach
   "online", bevor DHCP den DNS-Server lieferte ("DNS resolver has no external
   nameservers"), und die Container behalten diesen leeren Stand. Passiert bei
   jedem Reboot. bootstrap.sh legt jetzt ein systemd-Drop-in an: Docker wartet
   vor dem Start bis zu 90 s auf einen Nameserver in /etc/resolv.conf, dann
   startet es trotzdem. Netzkonfiguration bleibt unangetastet. Escaping und
   beide Pfade (DNS da / nicht da) in einer echten systemd-Unit getestet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:42:06 +02:00
duffyduckandClaude Opus 5.5 f0d6ae63a0 fix(xtts): Treiber-Upgrade liess die Box ohne GPU zurueck — Nachbereitung
Nach "--upgrade-driver" + Reboot lud der freie nouveau-Treiber statt nvidia,
nvidia-smi fehlte, und Docker startete keinen GPU-Container mehr ("failed to
initialize NVML: Driver Not Loaded"). Zwei Stolperfallen, die in der
apt-Simulation vorher nicht sichtbar waren:

1. NVIDIAs Paket "nvidia-smi" ist nur noch Doku. Das Programm — und
   Bibliotheken, die das Container-Toolkit braucht — steckt bei NVIDIA in
   "nvidia-driver-cuda". Jetzt in der Paketliste.
2. /etc/modprobe.d/nvidia.conf (sperrt nouveau) war ein Verweis ueber das
   Alternativen-System. Die entfernten Altpakete nehmen ihn mit, dpkg legt die
   neue Datei nur als nvidia.conf.dpkg-new ab. Jetzt: tote Verweise weg,
   .dpkg-new aktivieren (oder eigene Sperre schreiben), initramfs fuer alle
   Kernel neu bauen.

nv_postfix laeuft nach der Installation UND, wenn der Treiber schon neu ist —
ein erneuter --upgrade-driver-Lauf repariert also eine Box, die schon in
diesem Zustand steckt. Idempotent; meldet nur Erfolg, wenn das Schreiben
geklappt hat. Getestet gegen den nachgestellten Zustand der Box (erst
Reparatur + Reboot-Hinweis, zweiter Lauf: nichts zu tun).

Dazu: die DKMS-Pruefung las den Kernelnamen als Regex — "6.12.101+deb13"
passte darum nie, die Warnung "nicht gebaut" war falsch. Jetzt -F, und fuer
jeden installierten Kernel (apt hatte nebenbei 6.12.113 mitgebracht, der dann
startete).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:34:02 +02:00
duffyduckandClaude Opus 5.5 9be14eef5e fix(diagnostic): Karten kommen im Browser an, lokales Modell springt nicht zurueck
1. "Die LLM-Box meldet noch keine Karten": die Box schickte die Karten-
   Belegung, aber workerList() im Diagnostic-Server gibt nur eine feste
   Feldliste an den Browser — slots stand nicht drauf. Meine UI-Tests haben
   die Flotte direkt im Browser gesetzt und sind an genau dieser Stelle
   vorbeigelaufen. Jetzt Ende-zu-Ende getestet: echter Diagnostic-Server +
   RVS + Box-Meldung → Browser (per DevTools-Protokoll gelesen).

2. Lokales Modell sprang auf "Qwen3 8B" zurueck: die Auswahl wurde gespeichert,
   aber die Seite behielt intern das alte Modell und stellte das Dropdown beim
   naechsten Lebenszeichen der Box (10 s) darauf zurueck. Dazu wie gewuenscht
   umgebaut: nur Modelle, die im Cluster geladen sind, jedes einmal, ohne
   Karte. Ist das gewaehlte gerade nirgends geladen: "(offline)" — geht
   automatisch weiter, sobald eine Box es hat. Nichts da: "kein Modell
   verfuegbar". Waehrend die Auswahl offen ist, wird nicht umgebaut.

3. "Bereit", aber Karte leer: der llm-adapter meldet pro Karte den belegten
   VRAM. Laeuft ein Modell "ready" und die Karte bleibt unter 400 MB, rechnet
   llama.cpp auf der CPU (Treiber zu alt fuer das CUDA im Image) — das sagt
   llama.cpp nicht nach aussen. Diagnostic zeigt es jetzt in Flotte,
   LLM-Karten und Modell-Auswahl ("laeuft auf der CPU").

4. bootstrap.sh: der normale Lauf meldete "✓ Treiber aktiv", auch wenn er fuer
   das LLM zu alt ist. Jetzt Warnung mit Version und dem Befehl
   (--upgrade-driver), wenn das LLM-Profil aktiv ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:11:02 +02:00
duffyduckandClaude Opus 5.5 901cd1c16b fix(xtts): --upgrade-driver holt den Treiber von NVIDIA statt aus den Backports
Das lokale LLM rechnete auf der KI-Box die ganze Zeit auf der CPU: Treiber
550 (Debian, auch trixie-backports — mehr gibt es dort nicht) kann CUDA 12.4,
die llama.cpp-/llama-swap-Images brauchen 12.8/12.9. llama.cpp meldet dann
"failed to initialize CUDA … forward compatibility …", findet keine Karte
und laeuft still auf der CPU weiter — "ready" meldet es trotzdem. Whisper und
F5-TTS (CUDA 12.2) waren nicht betroffen.

--upgrade-driver installierte bisher "nvidia-driver" aus den Backports und
meldete Erfolg — es kam aber derselbe 550er. Jetzt: NVIDIAs Paketquelle fuer
Debian 13 (Treiber 615), Upgrade an Ort und Stelle.

Vorher auf der Box simuliert (apt -s in einem Temp-Verzeichnis, nichts
veraendert): ALLE installierten NVIDIA-Pakete mit hochzuziehen scheitert an
Konflikten — NVIDIA hat Debians Teilpakete (nvidia-driver-bin,
nvidia-alternative, …) zusammengefasst. Nur nvidia-driver, nvidia-kernel-dkms,
nvidia-smi, libcuda1 (+ persistenced/modprobe, wenn installiert) anfordern →
apt loest es sauber: 30 aktualisiert, 8 neu, 10 Altpakete entfernt,
Container-Toolkit unberuehrt.

Sicherungen: spielt erst durch (apt -s); bricht OHNE Aenderung ab und nimmt
die Paketquelle wieder raus, wenn apt keinen Weg findet oder das
Container-Toolkit anfassen wuerde. Zeigt den Plan und fragt nach (--yes zum
Ueberspringen). Prueft danach Version und DKMS-Build. Ablauf in einem
frischen Debian-13-Container getestet (Installation abgefangen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:43:09 +02:00
duffyduckandClaude Opus 5.5 c26b27f09f feat(llm): Cluster — Programme fragen ein Modell, der Cluster waehlt die Karte
Programme sollen nicht Box und Karte aussuchen muessen. Die ARIA-Bridge kennt
alle Boxen samt Karten-Belegung und verteilt (bridge/llm_cluster.py):

- Auswahl: unter allen online Boxen mit dem Modell die mit den wenigsten
  laufenden Anfragen, bei Gleichstand reihum. Eine Karte / alles frei →
  alles dorthin ("verteilen, wenn noetig").
- Ausfall: geht eine Box waehrend der Anfrage offline (kein Ping mehr), wird
  sie EINMAL an eine andere Box mit demselben Modell gegeben. Modell-Fehler
  (z.B. Kontext zu lang) werden nicht wiederholt — waeren ueberall gleich.
- Fehler kommen als Fehler (ok=false + errorCode), nie als Antwort-Text: ein
  Programm wuerde ihn sonst als Aussage der KI in seinen Verlauf schreiben —
  so ist bei ARIA schon mal ein ganzer Chat kaputtgegangen.

RVS: llm_cluster_models_request → llm_cluster_models (jedes Modell EINMAL,
ctx = kleinstes Kontextfenster unter seinen Karten), llm_cluster_request →
llm_cluster_response. In der RVS-Allow-List freigegeben.

ARIA nutzt denselben Router (mit ihrer 30-s-Frist fuer den lokalen
Schnellweg) — vorher Round-Robin ohne Ausfall-Ersatz, und errorCode ging
verloren.

xtts/openai_rvs_wrapper: fuer Programme, die nur "OpenAI-kompatibel" koennen
(OpenHands & Co.). Kleiner Container NEBEN dem Programm, fuer das der Cluster
dann http://127.0.0.1:8099/v1 ist; braucht nur eine ausgehende Verbindung zum
RVS. /v1/models (jedes Modell einmal, context_length), /v1/chat/completions
(auch stream und tools), Fehler als HTTP mit OpenAI-Fehlerobjekt. Hilfsseite
auf / zeigt Status, Base URL und Modelle. Fuer Programme in Docker: Netz
aria-llm, Name openai-rvs-wrapper. .env.example mit RVS_HOST/PORT/TOKEN.
(Ein OpenAI-Endpunkt direkt auf der VM war kurz drin und ist wieder raus —
der Wrapper braucht keinen offenen Port auf der VM.)

docs/LLM-SCHNITTSTELLE.md neu gegliedert: A) Cluster ueber RVS (empfohlen),
B) Wrapper, C) direkt an eine Box (Sonderfall), plus "Umstellen" fuer
bestehende Clients (Software-Schmiede).

Tests: mit echtem RVS und zwei echten llm-adaptern als zwei Boxen —
Modell einmal gelistet, 6 parallele Anfragen 3/3 verteilt, Box friert
mitten in der Anfrage ein → nach 15 s von der anderen beantwortet
(attempts=2), danach direkt die gesunde, nicht geladenes Modell →
model_unavailable ohne content. Wrapper als Docker-Container gegen das
offizielle OpenAI-SDK: models.list, create mit/ohne stream, NotFoundError,
AuthenticationError. Das Doku-Beispiel lief unveraendert (nur Adresse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:43:09 +02:00
duffyduckandClaude Opus 5.5 60f2d2e087 feat(diagnostic): pro LLM-Karte ein Dropdown — und Kontextfenster an Clients melden
Einstellungen → Modelle & KI → "LLM-Karten": fuer jede Karte, die auf einer
Box fuers LLM vorgesehen ist, ein Dropdown mit den Katalog-Modellen und
"— leer —" (entlaedt die Karte). Nur echte LLM-Karten erscheinen. Modelle,
die auf derselben Box schon auf einer anderen Karte liegen, werden nicht
angeboten; zu grosse (Katalog-Groesse > VRAM der Karte) sind als "passt
nicht" markiert. Waehrend eines Ladevorgangs ist das Dropdown gesperrt und
zeigt den Fortschritt.

Box meldet dafuer pro Karte den VRAM (nvidia-smi memory.total) und das
Kontextfenster, mit dem llama-server das Modell startet (-c).

Kontext an Clients: llm_models enthaelt jetzt "ctx". Anlass: die Software-
Schmiede rechnete mit 32768 Tokens, die Box startet die Modelle mit 8192 —
lange Auftraege waeren gescheitert oder falsch gekuerzt worden, und der Client
konnte es nicht wissen. Doku entsprechend ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:22:06 +02:00
duffyduckandClaude Opus 5.5 a1aa90177d fix(diagnostic): Status-Meldung unten rechts immer wegklickbar
Das X war absichtlich versteckt, sobald ein Dienst einen Fehler meldete — es
erschien nur, wenn alles gruen war. Gerade eine Fehlermeldung blieb also
stehen. Jetzt ist das X immer da; Wegklicken vergisst Fehler- und
Fertig-Meldungen (sonst kaemen sie mit der naechsten Statusmeldung eines
anderen Dienstes wieder hoch), ein laufender Ladevorgang darf wiederkommen.

Dazu: httpx haengt an jeden Fehler "For more information check: <Mozilla-
Link>" — raus, in der Anzeige und im llm-adapter an der Quelle. Bei nur einer
Meldung stand das ❌ doppelt (grosses Symbol + Zeile) — jetzt einmal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:46:25 +02:00
duffyduckandClaude Opus 5.5 ae79d9e8f6 fix(llm): Modelle mit anderer Quantisierung laden (Gemma 4 von unsloth)
Laden von unsloth/gemma-4-12B-it-qat-GGUF scheiterte mit "500": der Katalog
trug pauschal Q4_K_M ein, das Repo hat aber nur UD-Q4_K_XL. llama-server fand
die Datei nicht und beendete sich sofort; llama-swap reicht den Grund nicht
weiter ("upstream command exited prematurely"), es kam nur "500" an.

llm-adapter: fragt vor dem Laden die Dateiliste des Repos ab. Gibt es die
gewuenschte Quantisierung nicht, nimmt er die naechstbeste aus einer festen
Reihenfolge (Q4_K_M, UD-Q4_K_XL, Q4_K_XL, Q4_K_S, IQ4_XS, …) und sagt das im
Download-Fenster an. Kein ladbares GGUF im Repo → sofortige Absage, die Karte
bleibt unberuehrt. Bild-Projektor (mmproj), MTP-Zusatzdateien und imatrix
zaehlen nicht als Modell. Endet llama-server trotzdem sofort, nennt die
Meldung die haeufigsten Gruende statt nur "500".

Diagnostic: "Von HuggingFace aktualisieren" traegt pro HF-Repo die tatsaechlich
vorhandene Quantisierung und die echte Groesse ein (bisher 0 GB) — auch bei
schon vorhandenen HF-Eintraegen. Repos ohne ladbares Modell kommen nicht mehr
in den Katalog bzw. werden als "nicht ladbar" markiert. Katalog zeigt die
Quantisierung pro Modell.

Gemma 4 selbst: llama.cpp kann es seit Juni (#24077), der Stand auf der Box ist
vom 14.08. — es lag nur an der Datei.

Getestet gegen echte HF-Repos (Gemma → UD-Q4_K_XL 6,7 GB, Qwen/Mistral bleiben
Q4_K_M) in Adapter und Diagnostic identisch, und gegen llama-swap: Ausweichen
angesagt + geladen; Repo ohne GGUF → Absage ohne Download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:43:55 +02:00
duffyduckandClaude Opus 5.5 23feb3ba2d feat(llm): ein Modell pro Karte, Clients waehlen nur aus — und Doku fuer Drittprogramme
Bisher konnte jeder Client per Modellname llama-swap zum Wechseln bringen —
ein Fremdprogramm haette ARIA das Modell unter den Fuessen weggetauscht.
Jetzt:

- Jede LLM-Karte einer Box (LLM_GPUS=1,2, alt LLM_GPU=1) haelt GENAU ein
  Modell, dauerhaft. Welches, legt nur die Diagnostic fest. Karten, die nicht
  fuers LLM vorgesehen sind, tauchen beim LLM nicht auf und werden als Ziel
  abgelehnt.
- Clients (ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port)
  sehen nur die geladenen Modelle und laden nie: Anfrage fuer ein anderes →
  model_not_loaded mit Liste des Geladenen (RVS) bzw. HTTP 404 (OpenAI-Port).
  Nichts wird verdraengt.
- Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen.

llm-adapter: Belegung in /models/aria_slots.json. Die llama-swap-Config
enthaelt nur belegte Karten: pro Modell CUDA_VISIBLE_DEVICES=<Karte>, ttl 0,
groups.swap=false, hooks.on_startup.preload. Alles gegen llama-swap
ausprobiert (zwei Modelle gleichzeitig ready, keins verdraengt, Unbekanntes
404, env kommt beim jeweiligen llama-server an). llama-swap laeuft mit
--watch-config; die frueher aufgerufenen Reload-Endpunkte (/reload,
/api/config/reload) gibt es nicht (404). Neu: llm_unload_model (Karte leeren),
llm_models_request → llm_models (Erkundung, jede Box antwortet einzeln).
Fehlgeschlagenes Laden setzt die Karte auf ihr vorheriges Modell zurueck,
sonst risse jedes Neuladen die anderen Karten mit. Uebernahme: ohne
Belegungsdatei landet LLM_MODEL auf der ersten LLM-Karte.

Compose: llama-swap sieht alle Karten (NVIDIA_VISIBLE_DEVICES=all) und
zaehlt sie wie nvidia-smi (CUDA_DEVICE_ORDER=PCI_BUS_ID); das Pinnen pro
Modell macht der Adapter.

Bridge: uebernimmt models/slots auch aus worker_ping (nach "Entladen" sonst
30 s veraltet) und sagt sofort ab, wenn das Modell auf keiner Box liegt,
statt per Broadcast in den Timeout zu laufen.

Diagnostic: Flotte zeigt pro LLM-Karte Modell + Zustand + "Entladen";
Katalog waehlt eine Ziel-KARTE (leere vorausgewaehlt, Ersetzen mit
Rueckfrage); Download-Fenster nennt Karte und was ersetzt wird.

RVS: llm_unload_model, llm_models_request, llm_models freigegeben.

docs/LLM-SCHNITTSTELLE.md: Schnittstelle fuer Drittprogramme (RVS + OpenAI-
Port), Fehlercodes, Gleichzeitigkeit, Python-Beispiel.

Tests: 23 gegen llama-swap (Adapter direkt) + Ende-zu-Ende ueber den echten
RVS mit dem Adapter als Prozess: Laden per RVS mit Fortschritt, das
Doku-Beispiel unveraendert (nur Adresse) findet beide Karten und bekommt eine
Antwort, Absage/Schweigen wie dokumentiert, zwei Clients parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:04:24 +02:00
duffyduckandClaude Opus 5.5 89cb3a765b feat(llm): Diagnostic zeigt, welches Modell gerade in der Karte liegt
Die Flotte listete "qwen3-4b, qwen3-8b" — das sind die Modelle, die die Box
fahren KANN (llama-swap /v1/models), nicht die geladenen. Ohne groups-Config
liegt immer nur eins in der Karte; ein anderes anzufragen entlaedt es
(gegen llama-swap ausprobiert: A laden, B anfragen → A unloaded).

Der Adapter fragt jetzt llama-swap /running und schickt das Ergebnis in jedem
worker_ping (10 s) und im worker_hello mit; nach einem Modellwechsel sofort.
None = llama-swap nicht erreichbar, [] = nichts geladen — die Anzeige
unterscheidet das ("geladen: ?" vs. "keins geladen").

Diagnostic: das geladene Modell gruen mit "● geladen", die anderen grau mit
Hinweis, dass sie beim ersten Aufruf laden und das geladene verdraengen.

"GPU void": die LLM-Zeile zeigt jetzt LLM_GPU (die Karte von llama-swap) statt
der NVIDIA_VISIBLE_DEVICES des Adapters, die mit der Modell-Karte nichts zu
tun hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:17 +02:00
duffyduckandClaude Opus 5.5 864237ff2c feat(llm): Download-Fortschritt beim Modell-Laden — und "Laden" kommt endlich an
Eigentlicher Fehler zuerst: llm_provision_model/_result und llm_remove_model
standen nie in der RVS-Allow-List. Der RVS verwirft unbekannte Typen mit einem
stillen return — jeder Klick auf "Laden" im Modell-Katalog ist seit Stage D
(19.09.) an dieser Liste gestorben, die Box hat nie davon erfahren. Laden
liessen sich nur die Modelle aus der Basis-Config der Box.

Gefunden, weil grep rvs/server.js fuer eine Binaerdatei hielt und still
uebersprang: in Zeile 164 stand seit a7998bcd ein ECHTES Nullbyte im
Template-String statt eines Escapes. Node stoert das nicht, aber git zeigte
fuer jede Aenderung am RVS nur noch "Binary files differ". Jetzt \u0000 —
zur Laufzeit derselbe String.

Fortschritt: llama.cpp zieht das GGUF per -hf selbst und meldet nichts. Der
llm-adapter fragt vorab bei Hugging Face, welche Dateien (Groesse, sha256)
kommen, und misst genau diese im HF-Cache-Layout, das llama.cpp verwendet:
  /models/models--org--repo/blobs/<sha256>.downloadInProgress  (waechst)
  /models/models--org--repo/blobs/<sha256>                     (fertig)
Layout gegen den echten llama.cpp-Server ausprobiert, nicht angenommen. Geht
auch bei gesplitteten Modellen und zwei Downloads gleichzeitig; Fallback fuer
aeltere Layouts ueber alle *.downloadInProgress. Phasen: queued → start →
download (%, MB/s, Restzeit) → load ("laedt in die Karte") → ready ("in Karte
N geladen", Name + VRAM aus nvidia-smi) bzw. error.

Mitgefixt: der Warmup wartete LLM_TIMEOUT_SEC (Default 60 s). Ein Modell mit
mehreren GB meldete deshalb "Fehler", waehrend llama-swap weiterlud. Jetzt
LLM_WARMUP_TIMEOUT_S (1900 s, ueber llama-swaps healthCheckTimeout).

Diagnostic: Knopf "⬇ Downloads" neben "Von HuggingFace aktualisieren" mit
Zaehler fuer laufende; "Laden" oeffnet das Fenster direkt. Der Server merkt
sich den Stand pro (Box, Modell), ein spaeter geoeffneter Tab sieht laufende
Downloads. Antwortet die Box nicht, steht nach 30 s eine Warnung da statt
nichts.

Compose (KI-Box): llm-adapter bekommt LLM_GPU (Karte fuer die Meldung) und
HF_TOKEN (Dateiliste gesperrter Repos).

Test: Fortschritt gegen einen echten llama.cpp-Download (Qwen2.5-0.5B Q8,
676 MB): Prozent steigend 0 → 91.6, dann load; zweiter Lauf aus dem Cache
direkt load mit cached=true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:33:54 +02:00
52 changed files with 4215 additions and 346 deletions
+7
View File
@@ -73,3 +73,10 @@ GITEA_USER=duffyduck
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
RVS_UPDATE_HOST=
# ── LLM-Cluster ───────────────────────────────────────────────────────
# Die ARIA-Bridge verteilt Anfragen an die lokalen Modelle aller KI-Boxen
# (llm_cluster_* ueber RVS). Wie lange sie auf eine Antwort wartet, bevor sie
# eine andere Box versucht bzw. aufgibt:
#CLUSTER_LLM_TIMEOUT_S=600
# OpenAI-kompatible Programme (OpenHands & Co.): xtts/openai_rvs_wrapper
+94 -13
View File
@@ -544,6 +544,9 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
- **LLM-Cluster: Programme fragen ein Modell, der Cluster waehlt die Karte** — die ARIA-Bridge ([`bridge/llm_cluster.py`](bridge/llm_cluster.py)) kennt alle Boxen und Karten. Programme schicken `llm_cluster_request` ueber RVS (ohne Box/Karte), die Bridge waehlt unter allen Karten mit diesem Modell die mit den wenigsten laufenden Anfragen, und faellt eine Box mitten in der Anfrage aus (kein Ping mehr), geht sie einmal an eine andere. `llm_cluster_models` liefert jedes Modell **einmal** samt `ctx` (kleinstes Kontextfenster unter seinen Karten). Fehler kommen als Fehler (`model_unavailable` …), nie als Antwort-Text. ARIA selbst nutzt denselben Router (mit ihrer kurzen 30-s-Frist). Fuer Programme, die nur OpenAI koennen: [`xtts/openai_rvs_wrapper/`](xtts/openai_rvs_wrapper/) — kleiner Container NEBEN dem Programm, fuer das der Cluster dann `http://127.0.0.1:8099/v1` ist; Hilfsseite auf `/` zeigt Status und Modelle; braucht nur eine ausgehende Verbindung zum RVS (getestet mit dem offiziellen OpenAI-SDK). Schnittstelle: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md)
- **Lokale Modelle: ein Modell pro Karte, Clients waehlen nur aus** — Schnittstelle fuer Drittprogramme: [`docs/LLM-SCHNITTSTELLE.md`](docs/LLM-SCHNITTSTELLE.md). Jede LLM-Karte einer Box (`LLM_GPUS=1,2` in der `.env`, alt `LLM_GPU=1`) haelt genau ein Modell, dauerhaft geladen; welches, legt nur die Diagnostic fest (Modell-Katalog → „Laden" → Ziel-**Karte**, in der Flotte pro Karte „Entladen"). Karten, die nicht fuers LLM vorgesehen sind, erscheinen dort gar nicht. Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port — sehen nur die geladenen Modelle aller Boxen und laden **nie** etwas: eine Anfrage fuer ein anderes Modell wird abgelehnt (`model_not_loaded` bzw. HTTP 404), nichts wird verdraengt. Mehrere Clients koennen dasselbe Modell gleichzeitig nutzen (gemessen: zwei parallel in 4,5 s statt 10 s nacheinander). Technik: der llm-adapter schreibt eine llama-swap-Config nur aus den belegten Karten — pro Modell `CUDA_VISIBLE_DEVICES=<Karte>` (mit `CUDA_DEVICE_ORDER=PCI_BUS_ID`, damit die Nummern wie bei nvidia-smi zaehlen), `ttl: 0`, eine Gruppe mit `swap: false` und Preload beim Start; llama-swap laeuft mit `--watch-config` (die frueher aufgerufenen Reload-Endpunkte gab es nicht). Laden oder Entladen startet die anderen Karten der Box kurz neu. Schlaegt das Laden fehl, wird die Karte auf ihr vorheriges Modell zurueckgesetzt. Bestehende Boxen: das bisherige Standardmodell (`LLM_MODEL`) landet beim ersten Start auf der ersten LLM-Karte
- **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
- **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude"
@@ -669,7 +672,8 @@ cd android
### Release auf Gitea veroeffentlichen
```bash
./release.sh 1.2.0
./release.sh # Version automatisch +1 (0.2.6.1 → 0.2.6.2, 0.2.6.9 → 0.2.7.0)
./release.sh 1.2.0 # oder Version selbst setzen
```
Das Script macht alles in einem Schritt:
@@ -703,7 +707,7 @@ die VM voll laeuft:
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
Der Update-Flow:
1. `./release.sh 0.0.3.0` → APK wird auf RVS kopiert (via scp)
1. `./release.sh` (Version automatisch +1) → APK wird auf RVS kopiert (via scp)
2. Alternativ: `git pull` auf dem RVS-Server → APK in `rvs/updates/`
3. App sendet `update_check` mit aktueller Version
4. RVS vergleicht → sendet `update_available`
@@ -822,23 +826,39 @@ Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
```bash
cd rvs
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
cp .env.example .env # Modus waehlen (COMPOSE_PROFILES) + Werte eintragen
docker compose up -d
```
**Zwei Modi** — in der `rvs/.env` per `COMPOSE_PROFILES`:
| Modus | `.env` | Wer macht TLS | Apps/Satelliten verbinden auf |
|---|---|---|---|
| **mit Caddy** | `COMPOSE_PROFILES=caddy` + `PUBLIC_URL` | Caddy im Stack (Let's Encrypt) | `PUBLIC_URL:CADDY_HTTPS_PORT` |
| **ohne Caddy** | `COMPOSE_PROFILES=` (leer) | dein Reverse-Proxy (nginx, Traefik …) | Name + Port deines Proxys (meist 443) |
**Stack:**
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP). Unverschluesselter Port
`RVS_HTTP_BIND:RVS_HTTP_PORT` (Default `127.0.0.1:3000`) — fuer den Reverse-Proxy.
- `caddy` (nur Modus Caddy: TLS + Let's Encrypt auf 80 + `CADDY_HTTPS_PORT`)
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
Dafuer muss **Port 80 von aussen auf diesen Rechner** gehen — sonst schlaegt die
Erneuerung still fehl und das Zertifikat laeuft nach 90 Tagen ab (Apps und
Satelliten verbinden dann nicht mehr). ACME-State persistent in `./data/caddy/`
(gitignored). WebSocket-Upgrades reicht Caddy transparent durch.
> **Bestehende Installation aktualisieren:** Caddy startet nur noch mit
> `COMPOSE_PROFILES=caddy` in der `rvs/.env`. Wer Caddy weiter nutzt, traegt das
> dort ein (sonst laeuft nach `git pull` + `up -d` kein TLS mehr).
**Features:**
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
- Auto-Update: App fragt per WebSocket nach der Version, laedt die APK per HTTP
(`/update/latest.apk`, nur mit gueltigem Raum-Token im Header `X-RVS-Token`,
mit Fortschrittsanzeige + Fortsetzen per Range). Alte Apps: Fallback ueber WebSocket
- Heartbeat + tote Verbindungen aufraeumen
**Auto-Update APK bereitstellen:**
@@ -850,10 +870,71 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
**Ohne Caddy — eigener Reverse-Proxy:** in der `rvs/.env`
```bash
COMPOSE_PROFILES=
RVS_HTTP_BIND=0.0.0.0 # Proxy auf anderem Rechner; gleicher Rechner: 127.0.0.1
RVS_HTTP_PORT=3000
```
Steht der Proxy auf einem anderen Rechner, den Port per Firewall **nur fuer den
Proxy** freigeben.
**Strecke Proxy → RVS verschluesseln:** `RVS_HTTP_TLS=true` — der RVS spricht
dann HTTPS auf `RVS_HTTP_PORT`, mit einem selbstsignierten Zertifikat (beim
ersten Start in `rvs/data/rvs-tls/` erzeugt; eigenes `rvs.crt`/`rvs.key` darf
dort liegen). Im Proxy als Ziel `https://<ip>:<port>`, das Zertifikat dabei
**nicht** pruefen (nginx: Default; Nginx Proxy Manager: Scheme `https`,
„Websockets Support" an). Nur ohne Caddy — der spricht den RVS per HTTP an.
Umstellen von Caddy:
`docker compose rm -sf caddy && docker compose up -d`.
> ⚠️ **WebSocket nicht vergessen!** Der RVS ist fast nur WebSocket. Ohne
> WebSocket-Weiterleitung laedt im Browser zwar eine Seite (404 o.ae.), aber
> App, Bridge, Satelliten und KI-Boxen bekommen **keine Verbindung** — ohne
> klare Fehlermeldung. Beim Neueinrichten des Proxys immer pruefen:
>
> - **Nginx Proxy Manager:** im Proxy Host den Schalter **„Websockets Support"**
> einschalten (bei `RVS_HTTP_TLS=true` zusaetzlich Scheme **`https`**).
> - **nginx von Hand:** `proxy_http_version 1.1;` plus die beiden Header
> `Upgrade` / `Connection "upgrade"` (siehe Beispiel unten) und lange
> Timeouts — sonst trennt nginx die Verbindung nach 60 s Stille.
> - **Test:** `curl -i -H "Connection: Upgrade" -H "Upgrade: websocket"
> -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGVzdHRlc3R0ZXN0dGVzdA=="
> "https://<dein-name>/?token=test"` muss mit **`101 Switching Protocols`**
> antworten. Kommt 200/400/404, fehlt die WebSocket-Weiterleitung.
nginx-Beispiel (WebSocket braucht HTTP/1.1 + Upgrade-Header und lange Timeouts):
```nginx
server {
listen 443 ssl;
listen [::]:443 ssl; # Handys nehmen bevorzugt IPv6 — mit AAAA-Eintrag Pflicht
server_name rvs.example.de;
ssl_certificate /etc/letsencrypt/live/rvs.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rvs.example.de/privkey.pem;
location / {
proxy_pass http://192.168.1.10:3000; # RVS_HTTP_BIND-IP : RVS_HTTP_PORT (https:// bei RVS_HTTP_TLS=true)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 3600s; # WebSocket bleibt lange offen
proxy_send_timeout 3600s;
proxy_buffering off;
client_max_body_size 0; # APK-Download, Uploads
}
}
```
Apps, Satelliten, KI-Boxen, Host-Agenten und die Bridge (`RVS_HOST`/`RVS_PORT`
in deren `.env`) dann auf den Namen und Port des Proxys umstellen.
---
@@ -1158,7 +1239,7 @@ cd host-agent/android && ./build.sh # Docker (Android-SDK + Gradle)
# -> dist/aria-android-agent.apk
# Release mit allen Agent-Artefakten (Linux/Windows/Android) als Gitea-Assets:
cd host-agent && ./release_agent.sh 0.0.1.4
cd host-agent && ./release_agent.sh # Version automatisch +1
```
Der Signaturschluessel liegt fest in `host-agent/android/aria-agent.keystore`
+23 -3
View File
@@ -6,15 +6,17 @@
*/
import React, { useEffect } from 'react';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet, View } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ViewModeToggle from './src/components/ViewModeToggle';
import SecurityLock from './src/components/SecurityLock';
import UpdateProgress from './src/components/UpdateProgress';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
@@ -138,6 +140,15 @@ const App: React.FC = () => {
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
<NavigationContainer theme={DarkTheme}>
<Tab.Navigator
tabBar={(props) => (
// Tab-Leiste + Schloss-Symbol rechts neben dem Zahnrad
<View style={styles.tabBarRow}>
<View style={styles.tabBarTabs}>
<BottomTabBar {...props} />
</View>
<SecurityLock onOpenSettings={() => props.navigation.navigate('Einstellungen')} />
</View>
)}
screenOptions={({ route }) => ({
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
@@ -183,6 +194,7 @@ const App: React.FC = () => {
/>
</Tab.Navigator>
</NavigationContainer>
<UpdateProgress />
</GestureHandlerRootView>
);
};
@@ -205,10 +217,18 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
},
tabBar: {
tabBarRow: {
flexDirection: 'row',
backgroundColor: '#12122A',
borderTopColor: '#1E1E2E',
borderTopWidth: 1,
},
tabBarTabs: {
flex: 1,
},
tabBar: {
backgroundColor: '#12122A',
borderTopWidth: 0,
height: 60,
paddingBottom: 6,
paddingTop: 4,
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20505
versionName "0.2.5.5"
versionCode 20601
versionName "0.2.6.1"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -26,6 +26,7 @@ class MainApplication : Application(), ReactApplication {
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
add(TlsTrustPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -42,6 +43,8 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, false)
// Vor der ersten Verbindung: Zertifikatspruefung (abgelaufen erlaubt?)
TlsTrust.install(this)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
@@ -0,0 +1,185 @@
package com.ariacockpit
import android.content.Context
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.network.NetworkingModule
import com.facebook.react.modules.websocket.WebSocketModule
import java.net.Socket
import java.security.KeyStore
import java.security.cert.CertPathValidator
import java.security.cert.CertificateException
import java.security.cert.CertificateExpiredException
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.X509Certificate
import java.util.Date
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/**
* Zertifikatspruefung fuer RVS-Verbindung (WebSocket) und fetch.
*
* Normal pruefen wir wie Android selbst. Mit dem Schalter "Abgelaufenes
* Zertifikat akzeptieren" wird GENAU EIN Fehler toleriert: das Ablaufdatum.
* Kette und Aussteller werden dann zum Zeitpunkt kurz vor dem Ablauf geprueft
* (war das Zertifikat damals gueltig, ist es echt), der Hostname prueft OkHttp
* ohnehin separat. Ein fremdes oder selbstgebautes Zertifikat kommt also auch
* mit Schalter nicht durch — sonst koennte sich jeder dazwischenhaengen.
*
* Nebenbei merkt sich das Modul, wie die letzte Pruefung ausging, damit die
* App das Schloss-Symbol richtig zeigen kann.
*/
object TlsTrust {
private const val PREFS = "aria_tls"
private const val KEY_ALLOW_EXPIRED = "allow_expired"
// ok | expired_accepted | expired | invalid | unknown
@Volatile var lastStatus: String = "unknown"
@Volatile var lastDetail: String = ""
@Volatile var lastNotAfter: Long = 0
@Volatile var lastSubject: String = ""
@Volatile var allowExpired: Boolean = false
private set
private var appContext: Context? = null
fun install(context: Context) {
appContext = context.applicationContext
allowExpired = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_ALLOW_EXPIRED, false)
try {
val tm = ExpiryTolerantTrustManager(defaultTrustManager())
val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
val factory = ssl.socketFactory
WebSocketModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
NetworkingModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
} catch (e: Exception) {
// Lieber normale Pruefung als gar keine Verbindung
android.util.Log.w("TlsTrust", "Eigene Zertifikatspruefung nicht aktiv: ${e.message}")
}
}
fun saveAllowExpired(value: Boolean) {
allowExpired = value
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
?.edit()?.putBoolean(KEY_ALLOW_EXPIRED, value)?.apply()
}
private fun defaultTrustManager(): X509TrustManager {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?)
return tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
}
private fun remember(status: String, chain: Array<X509Certificate>?, detail: String = "") {
lastStatus = status
lastDetail = detail
val leaf = chain?.firstOrNull()
lastNotAfter = leaf?.notAfter?.time ?: 0
lastSubject = leaf?.subjectX500Principal?.name ?: ""
}
private fun isExpired(chain: Array<X509Certificate>): Boolean =
chain.any { c ->
try { c.checkValidity(); false } catch (_: CertificateExpiredException) { true } catch (_: Exception) { false }
}
/** Kette gegen die System-Zertifizierungsstellen pruefen — zum Zeitpunkt
* kurz vor dem fruehesten Ablauf in der Kette. */
private fun chainValidBeforeExpiry(chain: Array<X509Certificate>) {
val ks = KeyStore.getInstance("AndroidCAStore").apply { load(null) }
val params = PKIXParameters(ks).apply {
isRevocationEnabled = false
date = Date(chain.minOf { it.notAfter.time } - 60_000)
}
// Mitgeschickte Wurzel (selbstsigniert) gehoert nicht in den Pfad — aber NIE
// das Server-Zertifikat selbst: ein leerer Pfad gilt als gueltig, ein
// selbstgebautes Zertifikat kaeme sonst durch.
if (chain.isEmpty()) throw CertificateException("Leere Zertifikatskette")
val path = listOf(chain[0]) + chain.drop(1).filter { it.subjectX500Principal != it.issuerX500Principal }
val certPath = CertificateFactory.getInstance("X.509").generateCertPath(path)
CertPathValidator.getInstance("PKIX").validate(certPath, params)
}
private class ExpiryTolerantTrustManager(private val inner: X509TrustManager) : X509ExtendedTrustManager() {
private val ext = inner as? X509ExtendedTrustManager
private fun check(chain: Array<X509Certificate>, run: () -> Unit) {
try {
run()
remember("ok", chain)
} catch (e: CertificateException) {
if (!isExpired(chain)) {
remember("invalid", chain, e.message ?: "")
throw e
}
if (!allowExpired) {
remember("expired", chain, e.message ?: "")
throw e
}
try {
chainValidBeforeExpiry(chain)
} catch (inner: Exception) {
// Abgelaufen UND sonst nicht in Ordnung → nicht akzeptieren
remember("invalid", chain, inner.message ?: "")
throw e
}
remember("expired_accepted", chain)
}
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) =
check(chain) { inner.checkServerTrusted(chain, authType) }
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
check(chain) {
if (ext != null) ext.checkServerTrusted(chain, authType, socket)
else inner.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
check(chain) {
if (ext != null) ext.checkServerTrusted(chain, authType, engine)
else inner.checkServerTrusted(chain, authType)
}
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
inner.checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
if (ext != null) ext.checkClientTrusted(chain, authType, socket) else inner.checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
if (ext != null) ext.checkClientTrusted(chain, authType, engine) else inner.checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = inner.acceptedIssuers
}
}
class TlsTrustModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "TlsTrust"
@ReactMethod
fun setAllowExpired(value: Boolean, promise: Promise) {
TlsTrust.saveAllowExpired(value)
promise.resolve(value)
}
@ReactMethod
fun getStatus(promise: Promise) {
val m = Arguments.createMap()
m.putString("status", TlsTrust.lastStatus)
m.putString("detail", TlsTrust.lastDetail)
m.putDouble("notAfter", TlsTrust.lastNotAfter.toDouble())
m.putString("subject", TlsTrust.lastSubject)
m.putBoolean("allowExpired", TlsTrust.allowExpired)
promise.resolve(m)
}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class TlsTrustPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(TlsTrustModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.5",
"version": "0.2.6.1",
"private": true,
"scripts": {
"android": "react-native run-android",
+150
View File
@@ -0,0 +1,150 @@
/**
* Schloss-Symbol in der Tab-Leiste (neben dem Zahnrad): zeigt, ob die
* Verbindung zu ARIA verschluesselt ist.
*
* 🔒 verschluesselt, alles in Ordnung
* 🔒 + ! Problem mit dem Zertifikat (abgelaufen / abgelehnt)
* 🔒 durchgestrichen keine Verschluesselung (Fallback oder so eingestellt)
*
* Antippen erklaert in einfachen Worten, was das Symbol bedeutet.
* Einmal pro App-Start gibt's ausserdem einen Hinweis, wenn das Zertifikat
* abgelaufen ist — damit es nicht unbemerkt bleibt.
*/
import React, { useEffect, useRef, useState } from 'react';
import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import rvs, { SecurityState } from '../services/rvs';
interface Props {
onOpenSettings: () => void;
}
const fmtDate = (ms: number) =>
ms ? new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit' }) : 'unbekannt';
const SecurityLock: React.FC<Props> = ({ onOpenSettings }) => {
const [sec, setSec] = useState<SecurityState>(rvs.getSecurity());
const [open, setOpen] = useState(false);
const alerted = useRef<Set<string>>(new Set());
useEffect(() => rvs.onSecurityChange(setSec), []);
// Einmaliger Hinweis pro App-Start
useEffect(() => {
if (sec.kind === 'expired' && !alerted.current.has('expired')) {
alerted.current.add('expired');
Alert.alert('Zertifikat abgelaufen',
`Die Verbindung zu ARIA ist weiterhin verschlüsselt, aber das Sicherheits-Zertifikat `
+ `des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Bitte dem Admin Bescheid geben.`);
} else if (sec.kind === 'cert_error' && sec.certStatus === 'expired' && !alerted.current.has('cert_error')) {
alerted.current.add('cert_error');
Alert.alert('Keine Verbindung — Zertifikat abgelaufen',
`Das Sicherheits-Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen, `
+ `deshalb verbindet sich die App nicht.\n\nIn den Einstellungen unter „Verbindung“ kann man `
+ `abgelaufene Zertifikate vorübergehend erlauben.`,
[{ text: 'OK' }, { text: 'Einstellungen', onPress: onOpenSettings }]);
}
}, [sec, onOpenSettings]);
const warn = sec.kind === 'expired' || sec.kind === 'cert_error';
const crossed = sec.kind === 'fallback' || sec.kind === 'plain';
if (sec.kind === 'unknown') return <View style={styles.slot} />;
let title = '';
let text = '';
if (sec.kind === 'secure') {
title = 'Verbindung verschlüsselt';
text = 'Alles in Ordnung. Die Verbindung zu ARIA ist verschlüsselt und das Zertifikat des Servers ist gültig.';
} else if (sec.kind === 'expired') {
title = 'Zertifikat abgelaufen';
text = `Die Verbindung ist verschlüsselt, aber das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} `
+ 'abgelaufen. Die App akzeptiert es, weil das in den Einstellungen so erlaubt ist. '
+ 'Geprüft wird trotzdem, dass es wirklich das Zertifikat dieses Servers ist.\n\n'
+ 'Das Zertifikat sollte erneuert werden.';
} else if (sec.kind === 'cert_error') {
title = 'Keine Verbindung — Zertifikat-Problem';
text = sec.certStatus === 'expired'
? `Das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Deshalb verbindet sich die App nicht.\n\n`
+ 'In den Einstellungen unter „Verbindung“ kann man abgelaufene Zertifikate vorübergehend erlauben.'
: 'Das Zertifikat des Servers ist ungültig oder gehört nicht zu diesem Server. Zur Sicherheit verbindet sich '
+ 'die App nicht.' + (sec.reason ? `\n\nGrund: ${sec.reason}` : '');
} else if (sec.kind === 'fallback') {
title = 'Nicht verschlüsselt (Notlösung)';
text = 'Die verschlüsselte Verbindung hat nicht geklappt. Die App ist deshalb ersatzweise UNVERSCHLÜSSELT '
+ 'verbunden (TLS-Fallback).'
+ (sec.certStatus === 'expired' ? `\n\nGrund: Das Zertifikat ist seit ${fmtDate(sec.notAfter)} abgelaufen.`
: sec.certStatus === 'invalid' ? '\n\nGrund: Das Zertifikat des Servers ist ungültig.'
: '\n\nGrund: Die verschlüsselte Verbindung kam nicht zustande.');
} else if (sec.kind === 'plain') {
title = 'Nicht verschlüsselt';
text = 'Für diese Verbindung ist keine Verschlüsselung eingestellt. Das ist nur im eigenen Heimnetz in Ordnung.';
}
return (
<>
<TouchableOpacity style={styles.slot} onPress={() => setOpen(true)} accessibilityLabel={title}>
<View>
<Text style={[styles.lock, crossed && styles.lockDim]}>{'🔒'}</Text>
{crossed && <View style={styles.strike} />}
{warn && <Text style={styles.bang}>!</Text>}
</View>
</TouchableOpacity>
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={() => setOpen(false)}>
<View style={styles.box}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.text}>{text}</Text>
<View style={styles.legend}>
<Text style={styles.legendRow}>{'🔒'} verschlüsselt, alles in Ordnung</Text>
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendBang}>!</Text> Problem mit dem Zertifikat</Text>
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendStrike}>/</Text> nicht verschlüsselt</Text>
</View>
<View style={styles.buttons}>
{(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && (
<TouchableOpacity style={styles.btn} onPress={() => { setOpen(false); onOpenSettings(); }}>
<Text style={styles.btnText}>Einstellungen</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.btn, styles.btnPrimary]} onPress={() => setOpen(false)}>
<Text style={styles.btnText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
</>
);
};
const styles = StyleSheet.create({
slot: { width: 52, alignItems: 'center', justifyContent: 'center' },
lock: { fontSize: 20 },
lockDim: { opacity: 0.7 },
strike: {
position: 'absolute', left: -3, right: -3, top: '50%', height: 2.5,
backgroundColor: '#FF3B30', transform: [{ rotate: '-45deg' }],
},
bang: {
position: 'absolute', right: -8, top: -6, minWidth: 15, height: 15, borderRadius: 8,
backgroundColor: '#FF9F0A', color: '#000', fontSize: 11, fontWeight: '900',
textAlign: 'center', lineHeight: 15, overflow: 'hidden',
},
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700', marginBottom: 10 },
text: { color: '#C8C8D8', fontSize: 14, lineHeight: 20 },
legend: { marginTop: 16, paddingTop: 12, borderTopWidth: 1, borderTopColor: '#2A2A3E' },
legendRow: { color: '#9090A8', fontSize: 13, marginVertical: 2 },
legendBang: { color: '#FF9F0A', fontWeight: '900' },
legendStrike: { color: '#FF3B30', fontWeight: '900' },
buttons: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16, gap: 10 },
btn: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#2A2A3E' },
btnPrimary: { backgroundColor: '#0096FF' },
btnText: { color: '#FFFFFF', fontWeight: '600' },
});
export default SecurityLock;
+57
View File
@@ -0,0 +1,57 @@
/**
* Fortschrittsanzeige beim App-Update: Balken mit Prozent und MB, Knopf
* zum Abbrechen. Erscheint nur, waehrend ein Update laedt/installiert.
*/
import React, { useEffect, useState } from 'react';
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import updateService, { UpdateProgress as Progress } from '../services/updater';
const UpdateProgress: React.FC = () => {
const [p, setP] = useState<Progress | null>(null);
useEffect(() => updateService.onProgress(setP), []);
if (!p || !p.active) return null;
const known = p.fraction >= 0;
const pct = known ? Math.round(p.fraction * 100) : 0;
return (
<Modal visible transparent animationType="fade" onRequestClose={() => {}}>
<View style={styles.overlay}>
<View style={styles.box}>
<Text style={styles.title}>Update auf Version {p.version}</Text>
<Text style={styles.status}>{p.status}</Text>
<View style={styles.track}>
<View style={[styles.bar, { width: known ? `${pct}%` : '100%' }, !known && styles.barUnknown]} />
</View>
<Text style={styles.numbers}>
{known ? `${pct} % · ` : ''}
{p.receivedMB > 0 ? `${p.receivedMB.toFixed(1)} von ${p.totalMB.toFixed(1)} MB` : `${p.totalMB.toFixed(1)} MB`}
</Text>
{p.status !== 'Installiere …' && (
<TouchableOpacity style={styles.btn} onPress={() => updateService.cancelDownload()}>
<Text style={styles.btnText}>Abbrechen</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700' },
status: { color: '#C8C8D8', fontSize: 14, marginTop: 6 },
track: { height: 10, borderRadius: 5, backgroundColor: '#2A2A3E', marginTop: 14, overflow: 'hidden' },
bar: { height: 10, borderRadius: 5, backgroundColor: '#0096FF' },
barUnknown: { opacity: 0.35 },
numbers: { color: '#9090A8', fontSize: 13, marginTop: 8 },
btn: { alignSelf: 'flex-end', marginTop: 14, paddingVertical: 8, paddingHorizontal: 16,
borderRadius: 8, backgroundColor: '#2A2A3E' },
btnText: { color: '#FFFFFF', fontWeight: '600' },
});
export default UpdateProgress;
+27 -2
View File
@@ -178,6 +178,14 @@ const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [manualToken, setManualToken] = useState('');
// Abgelaufenes Server-Zertifikat trotzdem akzeptieren (Kette + Hostname werden weiter geprueft)
const [allowExpiredCert, setAllowExpiredCert] = useState(false);
useEffect(() => { rvs.getAllowExpiredCert().then(setAllowExpiredCert); }, []);
const handleAllowExpiredCert = useCallback(async (value: boolean) => {
setAllowExpiredCert(value);
await rvs.setAllowExpiredCert(value);
rvs.connect(true); // gleich mit der neuen Regel neu verbinden
}, []);
const [manualHost, setManualHost] = useState('');
const [manualPort, setManualPort] = useState('8765');
const [currentMode, setCurrentMode] = useState('normal');
@@ -1450,6 +1458,24 @@ const SettingsScreen: React.FC = () => {
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
<Text style={styles.connectButtonText}>Verbinden</Text>
</TouchableOpacity>
<View style={[styles.toggleRow, {marginTop: 16, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Abgelaufenes Zertifikat akzeptieren</Text>
<Text style={styles.toggleHint}>
Notlösung, wenn das Zertifikat des Servers abgelaufen ist. Die Verbindung bleibt
verschlüsselt, und es wird weiter geprüft, dass es wirklich das Zertifikat dieses
Servers ist — nur das Ablaufdatum wird ignoriert. Wieder ausschalten, sobald das
Zertifikat erneuert ist.
</Text>
</View>
<Switch
value={allowExpiredCert}
onValueChange={handleAllowExpiredCert}
trackColor={{ false: '#2A2A3E', true: '#FF9F0A' }}
thumbColor={allowExpiredCert ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Verbindungslog === */}
@@ -2513,8 +2539,7 @@ const SettingsScreen: React.FC = () => {
style={[styles.connectButton, {marginTop: 12}]}
onPress={() => {
const updateService = require('../services/updater').default;
updateService.checkForUpdate();
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
updateService.checkForUpdateManual();
}}
>
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
+126
View File
@@ -7,6 +7,9 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules, Platform } from 'react-native';
const TlsTrust: any = NativeModules.TlsTrust;
// --- Typen ---
@@ -27,6 +30,29 @@ export interface ConnectionConfig {
useTLS: boolean;
}
/**
* Wie sicher ist die Verbindung? Fuer das Schloss-Symbol in der Tab-Leiste.
* secure — verschluesselt, Zertifikat in Ordnung
* expired — verschluesselt, aber das Zertifikat ist abgelaufen (per Schalter erlaubt)
* cert_error— keine Verbindung, weil das Zertifikat abgelehnt wurde
* fallback — verbunden OHNE Verschluesselung, weil TLS fehlschlug
* plain — verbunden ohne Verschluesselung, so konfiguriert
* unknown — (noch) nichts Genaues
*/
export type SecurityKind = 'secure' | 'expired' | 'cert_error' | 'fallback' | 'plain' | 'unknown';
export interface SecurityState {
kind: SecurityKind;
/** Pruefergebnis der App: ok | expired_accepted | expired | invalid | unknown */
certStatus: string;
/** Ablaufdatum des Server-Zertifikats (ms), 0 = unbekannt */
notAfter: number;
/** Bei fallback/cert_error: warum TLS nicht ging */
reason: string;
}
type SecurityCallback = (state: SecurityState) => void;
type MessageCallback = (message: RVSMessage) => void;
type StateCallback = (state: ConnectionState) => void;
@@ -64,6 +90,10 @@ class RVSConnection {
private logListeners: LogCallback[] = [];
private connectionLog: ConnectionLogEntry[] = [];
private usingTLSFallback: boolean = false;
private securityListeners: SecurityCallback[] = [];
private security: SecurityState = { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' };
/** Letzter Zertifikats-Fehler beim wss://-Versuch (bleibt bis wss:// wieder klappt) */
private certFailure: { status: string; notAfter: number; detail: string } | null = null;
// --- Konfiguration ---
@@ -216,6 +246,8 @@ class RVSConnection {
const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : '';
this.log('info', `Verbunden${tlsInfo}`);
this.setState('connected');
this.updateSecurityOnOpen(useTLS);
this.sendClientHello();
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.startHeartbeat();
};
@@ -239,6 +271,10 @@ class RVSConnection {
this.clearTimers();
this.ws = null;
this.setState('disconnected');
this.setSecurity(this.certFailure
? { kind: 'cert_error', certStatus: this.certFailure.status, notAfter: this.certFailure.notAfter,
reason: this.certFailure.detail }
: { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' });
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
@@ -258,6 +294,7 @@ class RVSConnection {
this.ws.onerror = (error) => {
const errorMsg = (error as any)?.message || 'Unbekannter Fehler';
this.log('error', `WebSocket-Fehler: ${errorMsg}`);
if (useTLS) this.checkCertFailure(errorMsg);
// TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln
if (this.config?.useTLS && !this.usingTLSFallback) {
@@ -287,6 +324,95 @@ class RVSConnection {
}
}
/** Beim RVS vorstellen — Diagnostic zeigt so, welches Handy im Raum ist. */
private sendClientHello(): void {
try {
const c: any = (Platform as any).constants || {};
const device = [c.Manufacturer, c.Model].filter(Boolean).join(' ') || Platform.OS;
let version = '';
try { version = require('../../package.json').version || ''; } catch (_) {}
this.send('client_hello', { kind: 'App', name: device,
detail: `Android ${c.Release || Platform.Version}`, version });
} catch (_) {}
}
// --- Verbindungs-Sicherheit (Schloss-Symbol) ---
getSecurity(): SecurityState {
return this.security;
}
onSecurityChange(callback: SecurityCallback): () => void {
this.securityListeners.push(callback);
return () => {
this.securityListeners = this.securityListeners.filter(cb => cb !== callback);
};
}
private setSecurity(next: SecurityState): void {
const cur = this.security;
if (cur.kind === next.kind && cur.certStatus === next.certStatus
&& cur.notAfter === next.notAfter && cur.reason === next.reason) return;
this.security = next;
this.securityListeners.forEach(cb => cb(next));
}
private async nativeCertStatus(): Promise<{ status: string; notAfter: number; detail: string }> {
try {
const r = await TlsTrust?.getStatus();
return { status: r?.status || 'unknown', notAfter: Number(r?.notAfter) || 0, detail: r?.detail || '' };
} catch (_) {
return { status: 'unknown', notAfter: 0, detail: '' };
}
}
private async updateSecurityOnOpen(viaTLS: boolean): Promise<void> {
if (viaTLS) {
this.certFailure = null;
const c = await this.nativeCertStatus();
if (c.status === 'expired_accepted') {
this.log('warn', `Zertifikat abgelaufen (${new Date(c.notAfter).toLocaleDateString()}) — per Einstellung akzeptiert`);
}
this.setSecurity({ kind: c.status === 'expired_accepted' ? 'expired' : 'secure',
certStatus: c.status, notAfter: c.notAfter, reason: '' });
} else if (this.config?.useTLS) {
// wss:// ging nicht, ws:// schon → unverschluesselt per Fallback
this.setSecurity({ kind: 'fallback', certStatus: this.certFailure?.status || 'unknown',
notAfter: this.certFailure?.notAfter || 0, reason: this.certFailure?.detail || '' });
} else {
this.setSecurity({ kind: 'plain', certStatus: 'unknown', notAfter: 0, reason: '' });
}
}
/** Lag der wss://-Fehler am Zertifikat? Dann merken (fuer Schloss + Hinweis). */
private async checkCertFailure(errorMsg: string): Promise<void> {
if (!/cert|trust anchor|chain validation|handshake|ssl/i.test(errorMsg)) return;
const c = await this.nativeCertStatus();
if (c.status !== 'expired' && c.status !== 'invalid') return;
this.certFailure = { status: c.status, notAfter: c.notAfter, detail: c.detail || errorMsg };
this.log('error', c.status === 'expired'
? `Server-Zertifikat abgelaufen seit ${new Date(c.notAfter).toLocaleString()}`
: 'Server-Zertifikat ungueltig');
if (this.state !== 'connected') {
this.setSecurity({ kind: 'cert_error', certStatus: c.status, notAfter: c.notAfter,
reason: this.certFailure.detail });
} else if (this.security.kind === 'fallback') {
// ws://-Fallback war schneller verbunden als diese Pruefung — Grund nachtragen
this.setSecurity({ ...this.security, certStatus: c.status, notAfter: c.notAfter,
reason: this.certFailure.detail });
}
}
/** Schalter "Abgelaufenes Zertifikat akzeptieren" */
async getAllowExpiredCert(): Promise<boolean> {
try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; }
}
async setAllowExpiredCert(value: boolean): Promise<void> {
try { await TlsTrust?.setAllowExpired(value); } catch (_) {}
this.log('info', `Abgelaufene Zertifikate ${value ? 'werden akzeptiert' : 'werden abgelehnt'}`);
}
/** Reconnect mit exponentiellem Backoff planen */
private scheduleReconnect(): void {
this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`);
+209 -34
View File
@@ -17,6 +17,26 @@ const APP_VERSION = packageJson.version || '0.0.0.0';
type UpdateCallback = (info: UpdateInfo) => void;
/** Fortschritt fuer die Anzeige (UpdateProgress-Komponente) */
export interface UpdateProgress {
active: boolean;
version: string;
/** 0..1, -1 = unbekannt */
fraction: number;
receivedMB: number;
totalMB: number;
/** z.B. "Lade herunter …", "Neuer Versuch (2/3) …", "Installiere …" */
status: string;
}
type ProgressCallback = (p: UpdateProgress) => void;
const HTTP_ATTEMPTS = 3;
// Abbruch nur, wenn der Download HAENGT (so lange kommt kein Byte) — nicht
// nach einer festen Gesamtzeit wie frueher (60 s fuer 65 MB war zu knapp).
const READ_STALL_MS = 30_000;
const WS_TIMEOUT_MS = 5 * 60_000;
export interface UpdateInfo {
version: string;
downloadUrl: string;
@@ -27,6 +47,13 @@ class UpdateService {
private listeners: UpdateCallback[] = [];
private checking = false;
private downloading = false;
private progressListeners: ProgressCallback[] = [];
private progress: UpdateProgress = { active: false, version: '', fraction: -1, receivedMB: 0, totalMB: 0, status: '' };
private jobId: number | null = null;
/** Laeuft gerade eine Pruefung per Knopf? Dann geht die Antwort NUR dorthin
* (sonst zeigt der ChatScreen-Listener denselben Dialog ein zweites Mal). */
private manualWaiter: ((r: { type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string }) => void) | null = null;
private cancelled = false;
constructor() {
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
@@ -42,10 +69,19 @@ class UpdateService {
downloadUrl: (msg.payload.downloadUrl as string) || '',
size: (msg.payload.size as number) || 0,
};
if (this.manualWaiter) {
const w = this.manualWaiter; this.manualWaiter = null;
if (info.version && this.isNewer(info.version)) w({ type: 'available', info });
else w({ type: 'none', latest: info.version });
return;
}
if (info.version && this.isNewer(info.version)) {
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
this.listeners.forEach(cb => cb(info));
}
} else if ((msg.type as string) === 'update_none' && this.manualWaiter) {
const w = this.manualWaiter; this.manualWaiter = null;
w({ type: 'none', latest: String(msg.payload.latest || '') });
}
});
}
@@ -126,6 +162,64 @@ class UpdateService {
setTimeout(() => { this.checking = false; }, 10000);
}
/**
* Pruefung per Knopf (Einstellungen → Ueber): sagt IMMER etwas —
* neue Version (Update-Dialog), "keine neue Version" mit der Version auf dem
* Server, oder warum der Update-Server nicht erreichbar ist (mit Details).
*/
async checkForUpdateManual(): Promise<void> {
const cfg = rvs.getConfig();
const where = cfg ? `${cfg.useTLS ? 'wss' : 'ws'}://${cfg.host}:${cfg.port}` : '(nicht eingerichtet)';
if (!cfg) {
Alert.alert('Update-Server nicht erreichbar', 'Es ist noch kein Server eingerichtet (Einstellungen → Verbindung).');
return;
}
if (!rvs.isSocketOpen()) {
const lastErr = [...rvs.getConnectionLog()].reverse().find(e => e.level === 'error');
Alert.alert('Update-Server nicht erreichbar',
`Keine Verbindung zu ${where}.\n\nZustand: ${rvs.getState()}`
+ (lastErr ? `\nLetzter Fehler (${new Date(lastErr.timestamp).toLocaleTimeString()}):\n${lastErr.message}` : ''));
return;
}
const result = await new Promise<{ type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string } | { type: 'timeout' }>(resolve => {
const t = setTimeout(() => { this.manualWaiter = null; resolve({ type: 'timeout' }); }, 10_000);
this.manualWaiter = (r) => { clearTimeout(t); resolve(r); };
rvs.send('update_check' as any, { version: APP_VERSION });
});
if (result.type === 'available') {
this.promptUpdate(result.info);
return;
}
if (result.type === 'timeout') {
Alert.alert('Update-Server antwortet nicht',
`Verbunden mit ${where}, aber nach 10 s keine Antwort auf die Update-Anfrage.\n\n`
+ 'Moeglicherweise laeuft auf dem Server eine alte RVS-Version.');
return;
}
// Keine neue Version — zusaetzlich pruefen, ob der Download-Weg funktioniert
let dl = '';
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10_000);
const r = await fetch(`${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`,
{ method: 'HEAD', headers: { 'X-RVS-Token': cfg.token }, signal: ctrl.signal });
clearTimeout(t);
dl = r.ok ? 'Download-Weg: erreichbar ✓'
: r.status === 404 && result.latest ? 'Download-Weg: vom Server noch nicht unterstuetzt (alter RVS)'
: r.status === 404 ? 'Download-Weg: erreichbar, aber auf dem Server liegt keine APK'
: `Download-Weg: Fehler HTTP ${r.status}`;
} catch (e: any) {
dl = `Download-Weg: nicht erreichbar (${e?.name === 'AbortError' ? 'Zeitueberschreitung' : (e?.message || e)})`;
}
Alert.alert('Keine neue Version',
`Installiert: ${APP_VERSION}\n`
+ `Neueste auf dem Server: ${result.latest || '(keine APK hinterlegt)'}\n\n`
+ `Update-Server: ${where} ✓\n${dl}`);
}
/** Callback registrieren */
onUpdateAvailable(callback: UpdateCallback): () => void {
this.listeners.push(callback);
@@ -150,46 +244,125 @@ class UpdateService {
);
}
/** APK ueber WebSocket herunterladen und installieren */
onProgress(callback: ProgressCallback): () => void {
this.progressListeners.push(callback);
callback(this.progress);
return () => {
this.progressListeners = this.progressListeners.filter(cb => cb !== callback);
};
}
private setProgress(p: Partial<UpdateProgress>): void {
this.progress = { ...this.progress, ...p };
this.progressListeners.forEach(cb => cb(this.progress));
}
/** Laufenden Download abbrechen (Knopf in der Fortschrittsanzeige) */
cancelDownload(): void {
this.cancelled = true;
if (this.jobId !== null) {
try { RNFS.stopDownload(this.jobId); } catch (_) {}
}
}
/**
* APK per HTTP vom RVS laden (/update/latest.apk, Token im Header).
* Gibt false zurueck, wenn der RVS den Weg noch nicht kennt (alter RVS) —
* dann nimmt downloadAndInstall den alten WebSocket-Weg.
*/
private async downloadViaHttp(info: UpdateInfo, destPath: string): Promise<boolean> {
const cfg = rvs.getConfig();
if (!cfg) return false;
const url = `${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`;
let lastErr = '';
for (let attempt = 1; attempt <= HTTP_ATTEMPTS; attempt++) {
if (this.cancelled) throw new Error('Abgebrochen');
this.setProgress({ status: attempt === 1 ? 'Lade herunter …' : `Neuer Versuch (${attempt}/${HTTP_ATTEMPTS}) …`,
fraction: -1, receivedMB: 0 });
let expected = 0;
try {
const job = RNFS.downloadFile({
fromUrl: url,
toFile: destPath,
headers: { 'X-RVS-Token': cfg.token },
connectionTimeout: 15_000,
readTimeout: READ_STALL_MS,
progressInterval: 300,
begin: (r) => { expected = r.contentLength > 0 ? r.contentLength : 0; },
progress: (r) => {
const total = r.contentLength > 0 ? r.contentLength : info.size;
this.setProgress({
fraction: total > 0 ? Math.min(1, r.bytesWritten / total) : -1,
receivedMB: r.bytesWritten / 1048576,
totalMB: total / 1048576,
});
},
});
this.jobId = job.jobId;
const res = await job.promise;
this.jobId = null;
if (res.statusCode === 403 || res.statusCode === 404) {
// Alter RVS ohne HTTP-Download (404) bzw. Token unbekannt (403)
console.warn(`[Update] HTTP-Download nicht verfuegbar (${res.statusCode}) — nehme WebSocket`);
return false;
}
if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`);
const st = await RNFS.stat(destPath);
if (expected && Number(st.size) !== expected) {
throw new Error(`Datei unvollstaendig (${st.size} von ${expected} Bytes)`);
}
return true;
} catch (err: any) {
this.jobId = null;
if (this.cancelled) throw new Error('Abgebrochen');
lastErr = err?.message || String(err);
console.warn(`[Update] HTTP-Download Versuch ${attempt} fehlgeschlagen: ${lastErr}`);
await new Promise<void>(r => setTimeout(r, 2000 * attempt));
}
}
throw new Error(`Download fehlgeschlagen: ${lastErr}`);
}
/** Alter Weg: APK als ein grosser Block ueber die WebSocket-Verbindung */
private async downloadViaWebSocket(info: UpdateInfo, destPath: string): Promise<void> {
this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 });
rvs.send('update_download' as any, {});
const base64 = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => { unsub(); reject(new Error('Download-Timeout (5 min)')); }, WS_TIMEOUT_MS);
const unsub = rvs.onMessage((msg: RVSMessage) => {
if ((msg.type as string) === 'update_data') {
clearTimeout(timeout);
unsub();
if (msg.payload.error) reject(new Error(msg.payload.error as string));
else resolve(msg.payload.base64 as string);
}
});
});
if (this.cancelled) throw new Error('Abgebrochen');
await RNFS.writeFile(destPath, base64, 'base64');
}
/** APK herunterladen (HTTP mit Fortschritt, sonst WebSocket) und installieren */
async downloadAndInstall(info: UpdateInfo): Promise<void> {
if (this.downloading) return;
this.downloading = true;
this.cancelled = false;
this.setProgress({ active: true, version: info.version, fraction: -1, receivedMB: 0,
totalMB: info.size / 1048576, status: 'Starte …' });
const fileName = `ARIA-v${info.version}.apk`;
const destPath = `${RNFS.CachesDirectoryPath}/${fileName}`;
try {
console.log(`[Update] Fordere APK v${info.version} an...`);
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`);
// APK ueber WebSocket anfordern
rvs.send('update_download' as any, {});
// Auf update_data warten (einmalig)
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
const unsub = rvs.onMessage((msg: RVSMessage) => {
if ((msg.type as string) === 'update_data') {
clearTimeout(timeout);
unsub();
if (msg.payload.error) {
reject(new Error(msg.payload.error as string));
} else {
resolve({
base64: msg.payload.base64 as string,
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
});
}
}
});
});
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
// Updates in einer Session gezogen werden
console.log(`[Update] Lade APK v${info.version} …`);
// Vor dem Download alte APKs im Cache wegraeumen
await this.cleanupOldApks();
// Base64 als APK-Datei speichern
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
await RNFS.writeFile(destPath, apkData.base64, 'base64');
const viaHttp = await this.downloadViaHttp(info, destPath);
if (!viaHttp) await this.downloadViaWebSocket(info, destPath);
const fileSize = await RNFS.stat(destPath);
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB, ${viaHttp ? 'HTTP' : 'WebSocket'})`);
this.setProgress({ fraction: 1, status: 'Installiere …' });
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
if (Platform.OS === 'android') {
@@ -199,15 +372,17 @@ class UpdateService {
} catch (installErr: any) {
Alert.alert(
'APK heruntergeladen',
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${fileName} antippen.\n\n(${installErr.message})`,
);
}
}
} catch (err: any) {
console.error(`[Update] Fehler: ${err.message}`);
Alert.alert('Update fehlgeschlagen', err.message);
try { await RNFS.unlink(destPath); } catch (_) {}
if (!this.cancelled) Alert.alert('Update fehlgeschlagen', err.message);
} finally {
this.downloading = false;
this.setProgress({ active: false });
}
}
+1
View File
@@ -21,5 +21,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY aria_bridge.py .
COPY modes.py .
COPY llm_cluster.py .
CMD ["python", "aria_bridge.py"]
+70 -52
View File
@@ -41,6 +41,7 @@ import websockets
from faster_whisper import WhisperModel
from openwakeword.model import Model as WakeWordModel
from llm_cluster import LlmCluster
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
@@ -72,6 +73,20 @@ logging.basicConfig(
logger = logging.getLogger("aria-bridge")
# Schluessel, deren Werte nie ins Log duerfen (HF-Token, Passwoerter, API-Keys).
_SECRET_KEY_RE = re.compile(r"token(?!s)|secret|passw|apikey|api_key|credential", re.I)
def _redact(obj):
"""Kopie von obj fuer Log-Zeilen: Geheimnis-Werte werden zu '***'."""
if isinstance(obj, dict):
return {k: ("***" if v and _SECRET_KEY_RE.search(str(k)) else _redact(v))
for k, v in obj.items()}
if isinstance(obj, list):
return [_redact(v) for v in obj]
return obj
# ── TCP-Keepalive Helper ────────────────────────────────────
#
# Aktiviert TCP-Level Keepalive auf einer websockets-Verbindung mit
@@ -705,7 +720,7 @@ class ARIABridge:
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s flux=%s",
self.tts_enabled, self.xtts_voice or "default",
self._f5tts_config or "defaults",
self._flux_config or "defaults")
_redact(self._flux_config) or "defaults")
except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
@@ -806,6 +821,9 @@ class ARIABridge:
# llm_request via RVS an den llm-adapter (AI-Box) und warten auf
# llm_response.
self._pending_llm: dict[str, asyncio.Future] = {}
# LLM-Cluster: ein Eingang fuer alle lokalen Modelle aller Boxen
# (Auswahl nach Last, Ausfall-Ersatz). Siehe llm_cluster.py.
self._llm_cluster = LlmCluster(self)
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -1826,7 +1844,7 @@ class ARIABridge:
"payload": payload,
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[rvs] Persistierte Config broadcastet: %s", payload)
logger.info("[rvs] Persistierte Config broadcastet: %s", _redact(payload))
except Exception as e:
logger.debug("[rvs] Config-Broadcast fehlgeschlagen: %s", e)
@@ -2254,6 +2272,13 @@ class ARIABridge:
self.ws_rvs = ws
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
# Beim RVS vorstellen (Diagnostic → "Wer ist im Raum?")
try:
await ws.send(json.dumps({"type": "client_hello", "payload": {
"kind": "Bridge", "name": socket.gethostname(),
"detail": "ARIA-Bridge"}, "timestamp": int(time.time() * 1000)}))
except Exception:
pass
# TCP-Keepalive auf dem unterliegenden Socket aktivieren —
# damit NAT-Tabellen-Verfall oder "halb-tote" Verbindungen
@@ -2453,6 +2478,9 @@ class ARIABridge:
msg_type = message.get("type", "")
payload = message.get("payload", {})
if msg_type.startswith("llm_cluster_") and isinstance(payload, dict):
if await self._llm_cluster.handle_rvs(msg_type, payload):
return
if msg_type == "chat":
# Nur User-Nachrichten weiterleiten — ARIA/Diagnostic-Antworten ignorieren (sonst Loop!)
@@ -2706,7 +2734,7 @@ class ARIABridge:
config_data.update(getattr(self, "_debug_log_config", {}))
with open("/shared/config/voice_config.json", "w") as f:
json.dump(config_data, f, indent=2)
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
logger.info("[rvs] Voice-Config gespeichert: %s", _redact(config_data))
except Exception as e:
logger.warning("[rvs] Config speichern fehlgeschlagen: %s", e)
return
@@ -3774,6 +3802,9 @@ class ARIABridge:
# 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 []),
# Karten-Belegung (neuer llm-adapter): dann ist "models" nur
# noch das GELADENE — Clients laden nichts nach.
**({"slots": payload["slots"]} if isinstance(payload.get("slots"), list) else {}),
"busy": bool(prev.get("busy", False)),
"last_seen": time.time(),
}
@@ -3819,6 +3850,13 @@ class ARIABridge:
"model": "", "busy": False, "last_seen": 0.0,
}
w["busy"] = bool(payload.get("busy", False))
# LLM-Boxen melden mit jedem Ping, welche Modelle GELADEN sind.
# Nach "Karte leeren" in der Diagnostic sonst bis zum naechsten
# hello (30 s) veraltet — ARIA wuerde ein entladenes Modell anfragen.
if isinstance(payload.get("models"), list):
w["models"] = payload["models"]
if isinstance(payload.get("slots"), list):
w["slots"] = payload["slots"]
w["last_seen"] = time.time()
return
@@ -4242,55 +4280,28 @@ class ARIABridge:
if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_llm[request_id] = future
try:
req_payload = {
"requestId": request_id,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if stop:
req_payload["stop"] = stop
if tools:
req_payload["tools"] = tools
if model:
req_payload["model"] = model
# 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,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "llm_request konnte nicht gesendet werden"}
try:
result = await asyncio.wait_for(future, timeout=self._LLM_TIMEOUT_S)
except asyncio.TimeoutError:
return {"ok": False, "error": f"Timeout ({self._LLM_TIMEOUT_S:.0f}s) — 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"}
return {
"ok": True,
"content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"),
"elapsedMs": result.get("elapsedMs"),
}
finally:
self._pending_llm.pop(request_id, None)
# Derselbe Router wie fuer Fremdprogramme: Box nach Last waehlen, bei
# Ausfall einmal anderswo versuchen, ehrliche Absage, wenn das Modell
# nirgends geladen ist (statt Broadcast + Timeout).
res = await self._llm_cluster.route({
"model": model or "", "messages": messages, "max_tokens": max_tokens,
"temperature": temperature, "stop": stop, "tools": tools,
}, timeout=self._LLM_TIMEOUT_S)
if not res.get("ok"):
err = res.get("error") or "llm-adapter Fehler"
if res.get("errorCode") == "model_unavailable":
err += " Laden: Diagnostic → Einstellungen → LLM-Karten."
return {"ok": False, "error": err, "errorCode": res.get("errorCode")}
logger.info("[cluster] ARIA ← %s (Karte %s, %s ms, Versuche %s)",
res.get("node") or res.get("instanceId"), res.get("gpu", "?"),
res.get("elapsedMs"), res.get("attempts"))
return {
"ok": True,
"content": res.get("content", ""),
"tool_calls": res.get("tool_calls"),
"model": res.get("model"),
"elapsedMs": res.get("elapsedMs"),
}
async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float],
@@ -5234,6 +5245,13 @@ class ARIABridge:
})
return out
def _llm_boxes_with_slots(self) -> list:
"""Online-LLM-Boxen, die schon Karten-Belegung melden (neuer Adapter)."""
now = time.time()
return [w for w in self._workers.values()
if w.get("service") == "llm" and isinstance(w.get("slots"), list)
and (now - w.get("last_seen", 0)) < self.WORKER_OFFLINE_S]
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
+219
View File
@@ -0,0 +1,219 @@
"""LLM-Cluster: ein Eingang fuer alle lokalen Sprachmodelle aller KI-Boxen.
Clients waehlen ein MODELL, nicht Box oder Karte. Wo es laeuft, entscheidet
dieser Router:
* Auswahl — unter allen online Boxen, die das Modell GELADEN haben
(Karten-Belegung aus worker_hello/worker_ping), die mit den
wenigsten laufenden Anfragen; bei Gleichstand reihum.
Ist nur eine Karte da oder alles frei, landet alles dort —
"verteilen, wenn noetig, sonst nicht".
* Ausfall — verschwindet eine Box mitten in der Anfrage (kein Ping mehr)
oder antwortet nicht, geht die Anfrage EINMAL an eine andere
Box mit demselben Modell. Fehler des Modells selbst (z.B.
Kontext zu lang) werden nicht wiederholt.
* Fehler — kommen als FEHLER zurueck (ok=false / HTTP-Status), nie als
normale Antwort. Ein Client wuerde einen Fehlertext sonst
als Aussage der KI in seinen Verlauf schreiben — genau das
hat bei ARIA schon mal einen ganzen Chat vergiftet.
Eingang ist der RVS:
llm_cluster_models_request → llm_cluster_models
llm_cluster_request → llm_cluster_response
Programme brauchen nur eine ausgehende Verbindung zum RVS, egal in welchem
Netz sie stehen. Werkzeuge, die nur "OpenAI-kompatibel" koennen (OpenHands
& Co.), bekommen den OpenAI-RVS-Wrapper neben sich gestellt
(xtts/openai_rvs_wrapper) — fuer sie ist der Cluster dann 127.0.0.1.
Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
import uuid
from typing import Optional
logger = logging.getLogger("aria-bridge.llm-cluster")
# Diese errorCodes heissen "die BOX war das Problem" → anderswo nochmal versuchen.
# Alles andere (Modell-Fehler, z.B. Kontext zu lang) waere ueberall gleich.
RETRY_CODES = {"timeout", "box_offline", "send_failed", "model_not_loaded"}
MAX_ATTEMPTS = 2
class LlmCluster:
def __init__(self, bridge):
self.b = bridge
self.inflight: dict[str, int] = {} # instanceId → laufende Anfragen (von uns)
self._rr: dict[str, int] = {}
self.timeout = float(os.getenv("CLUSTER_LLM_TIMEOUT_S", "600"))
# ── Flotte ──────────────────────────────────────────────────────────
def _online_llm(self) -> list:
now = time.time()
return [w for w in self.b._workers.values()
if w.get("service") == "llm"
and (now - w.get("last_seen", 0)) < self.b.WORKER_OFFLINE_S]
def _is_online(self, instance_id: str) -> bool:
return any(w.get("instanceId") == instance_id for w in self._online_llm())
def models(self) -> list:
"""Jedes geladene Modell GENAU EINMAL, egal auf wie vielen Karten.
ctx = das kleinste Kontextfenster unter seinen Karten (darauf muss sich
ein Client verlassen koennen, egal wohin der Router verteilt)."""
out: dict[str, dict] = {}
for w in self._online_llm():
slots = w.get("slots")
if isinstance(slots, list):
for sl in slots:
m = sl.get("model")
if not m or m not in (w.get("models") or []):
continue
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
"gpu": sl.get("gpu", ""), "gpuName": sl.get("gpuName", ""),
"state": sl.get("state", "")})
c = int(sl.get("ctx") or 0)
if c and (not e["ctx"] or c < e["ctx"]):
e["ctx"] = c
else:
# Box mit altem Adapter (ohne Karten): nur die Modell-Liste
for m in (w.get("models") or []):
e = out.setdefault(m, {"model": m, "ctx": 0, "cards": []})
e["cards"].append({"node": w.get("node", ""), "instanceId": w.get("instanceId", ""),
"gpu": "", "gpuName": "", "state": ""})
for e in out.values():
e["available"] = len(e["cards"])
return sorted(out.values(), key=lambda e: e["model"])
def candidates(self, model: str) -> list:
return [w for w in self._online_llm() if model in (w.get("models") or [])]
def pick(self, model: str, exclude=()) -> Optional[str]:
"""Die Box mit den wenigsten laufenden Anfragen; Gleichstand reihum."""
cands = [w["instanceId"] for w in self.candidates(model) if w["instanceId"] not in exclude]
if not cands:
return None
low = min(self.inflight.get(c, 0) for c in cands)
tied = sorted(c for c in cands if self.inflight.get(c, 0) == low)
i = self._rr.get(model, 0) % len(tied)
self._rr[model] = i + 1
return tied[i]
# ── Anfrage an eine bestimmte Box ───────────────────────────────────
async def _send_to(self, target: str, req: dict, deadline: float) -> dict:
"""llm_request an GENAU diese Box, Antwort abwarten. Bricht frueh ab,
wenn die Box unterwegs offline geht — dann kann route() woanders hin."""
rid = str(uuid.uuid4())
fut: asyncio.Future = asyncio.get_running_loop().create_future()
self.b._pending_llm[rid] = fut
self.inflight[target] = self.inflight.get(target, 0) + 1
try:
payload = {k: v for k, v in req.items() if v is not None}
payload.update(requestId=rid, targetInstance=target)
ok = await self.b._send_to_rvs({"type": "llm_request", "payload": payload,
"timestamp": int(time.time() * 1000)})
if not ok:
return {"ok": False, "errorCode": "send_failed", "error": "RVS: Anfrage nicht gesendet"}
while True:
left = deadline - time.time()
if left <= 0:
return {"ok": False, "errorCode": "timeout",
"error": f"Keine Antwort von {target} in der Frist"}
try:
return await asyncio.wait_for(asyncio.shield(fut), timeout=min(5.0, left))
except asyncio.TimeoutError:
if not self._is_online(target):
return {"ok": False, "errorCode": "box_offline",
"error": f"{target} ist waehrend der Anfrage offline gegangen"}
finally:
self.inflight[target] = max(0, self.inflight.get(target, 1) - 1)
self.b._pending_llm.pop(rid, None)
async def route(self, req: dict, timeout: Optional[float] = None) -> dict:
"""Eine Anfrage an den Cluster. req: model, messages, max_tokens,
temperature, stop, tools. Antwort: wie llm_response der Box, plus
attempts; bei Fehler errorCode + error (+ available).
timeout: Gesamtfrist inkl. Ausfall-Ersatz. ARIA nutzt eine kurze (lokal
ist ihr Schnellweg — dauert es zu lange, nimmt sie Claude)."""
model = (req.get("model") or "").strip()
messages = req.get("messages")
if not isinstance(messages, list) or not messages:
return {"ok": False, "errorCode": "bad_request", "error": "messages fehlt oder ist leer"}
available = [m["model"] for m in self.models()]
if not model:
if len(available) == 1:
model = available[0] # eindeutig — dann darf es fehlen
else:
return {"ok": False, "errorCode": "bad_request", "available": available,
"error": "model fehlt. Geladen: " + (", ".join(available) or "nichts")}
body = {"model": model, "messages": messages,
"max_tokens": int(req.get("max_tokens") or 512),
"temperature": float(req["temperature"]) if req.get("temperature") is not None else 0.7,
"stop": req.get("stop") or None, "tools": req.get("tools") or None}
limit = timeout or self.timeout
deadline = time.time() + limit
tried: list = []
last: dict = {}
for _ in range(MAX_ATTEMPTS):
target = self.pick(model, exclude=tried)
if not target:
break
tried.append(target)
t0 = time.time()
last = await self._send_to(target, body, deadline)
if last.get("ok"):
last["attempts"] = len(tried)
return last
logger.warning("[cluster] %s auf %s: %s (%s)%s", model, target, last.get("errorCode") or "Fehler",
(last.get("error") or "")[:120], " — versuche andere Box" if
last.get("errorCode") in RETRY_CODES else "")
if last.get("errorCode") not in RETRY_CODES or time.time() >= deadline:
break
if not tried:
return {"ok": False, "errorCode": "model_unavailable", "available": available,
"error": (f"Modell {model!r} ist gerade auf keiner KI-Box im Cluster geladen. "
f"Geladen: {', '.join(available) or 'nichts'}.")}
last.setdefault("errorCode", "model_error")
last["attempts"] = len(tried)
if last["errorCode"] in RETRY_CODES and len(tried) >= 1:
others = [c for c in self.candidates(model) if c["instanceId"] not in tried]
if not others:
last["error"] = (last.get("error") or "") + (
" — keine weitere Box mit diesem Modell erreichbar.")
return last
# ── RVS-Eingang ─────────────────────────────────────────────────────
async def handle_rvs(self, mtype: str, payload: dict) -> bool:
if mtype == "llm_cluster_models_request":
await self.b._send_to_rvs({"type": "llm_cluster_models", "payload": {
"requestId": payload.get("requestId", ""), "models": self.models()},
"timestamp": int(time.time() * 1000)})
return True
if mtype == "llm_cluster_request":
asyncio.create_task(self._rvs_request(payload))
return True
return False
async def _rvs_request(self, payload: dict) -> None:
rid = payload.get("requestId", "")
res = await self.route(payload)
out = {"requestId": rid, "ok": bool(res.get("ok"))}
for k in ("content", "tool_calls", "model", "node", "gpu", "instanceId", "usage",
"elapsedMs", "attempts", "error", "errorCode", "available",
"promptTokens", "ctx"):
if res.get(k) is not None:
out[k] = res[k]
await self.b._send_to_rvs({"type": "llm_cluster_response", "payload": out,
"timestamp": int(time.time() * 1000)})
+452 -34
View File
@@ -103,6 +103,7 @@
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
z-index:1000; justify-content:center; align-items:center; }
.modal-overlay.open { display:flex; }
@keyframes llmdlpulse { 0%,100% { opacity:.15; } 50% { opacity:.45; } }
.modal-box { background:#0D0D1A; border:1px solid #FFD60A44; border-radius:10px; width:90vw; max-width:860px;
max-height:90vh; display:flex; flex-direction:column; overflow:hidden; }
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px;
@@ -167,7 +168,7 @@
<div style="display:flex;align-items:flex-start;gap:8px;">
<span id="service-status-icon" style="font-size:18px;line-height:1;">&#x23F3;</span>
<div id="service-status-list" style="flex:1;display:flex;flex-direction:column;gap:6px;"></div>
<button id="service-status-close" onclick="document.getElementById('service-status-banner').style.display='none'" style="background:none;border:none;color:#666680;font-size:16px;cursor:pointer;padding:0;line-height:1;display:none;">&times;</button>
<button id="service-status-close" onclick="dismissServiceStatus()" title="Schließen" style="background:none;border:none;color:#8888AA;font-size:18px;cursor:pointer;padding:0 2px;line-height:1;">&times;</button>
</div>
</div>
@@ -838,11 +839,31 @@
</div>
</div>
<!-- LLM-Karten: pro Karte ein Dropdown — welches Modell dort liegt -->
<div class="settings-section">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
<h2 style="margin:0;">LLM-Karten</h2>
<button class="btn secondary" onclick="openLlmDownloads()" style="padding:4px 10px;font-size:11px;">⬇ Downloads</button>
</div>
<div class="card" style="max-width:1100px;">
<p style="color:#8888AA;font-size:12px;margin:0 0 10px;line-height:1.5;">
Jede Grafikkarte, die auf einer KI-Box fürs LLM vorgesehen ist (<code>LLM_GPUS</code> in deren
<code>.env</code>), hält genau ein Modell — dauerhaft geladen. Hier legst du fest, welches.
ARIA und angebundene Programme wählen nur unter diesen Modellen.
„— leer —" entlädt die Karte. Beim Wechsel starten die anderen Karten derselben Box kurz neu.
</p>
<div id="llm-card-settings" style="display:flex;flex-direction:column;gap:8px;">
<span style="color:#6a6a88;">Keine LLM-Box online.</span>
</div>
</div>
</div>
<!-- Modell-Katalog: GGUF-Modelle herunterladen/aktivieren (Stage D) -->
<div class="settings-section">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
<h2 style="margin:0;">Modell-Katalog</h2>
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
</div>
<div class="card" style="max-width:1100px;">
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
@@ -1367,6 +1388,22 @@
</div>
</div>
<!-- Wer ist im Raum? -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h2 style="margin:0;">Verbunden mit diesem Raum</h2>
<button class="btn secondary" onclick="requestRoomClients()" style="padding:4px 12px;">Aktualisieren</button>
</div>
<div style="font-size:11px;color:#8888AA;margin:6px 0 8px;">
Alle Verbindungen, die gerade mit dem RVS-Token oben im selben Raum sind
(so wie der RVS sie sieht). Die Art erkennt der RVS an der Vorstellung der
Clients — „unbekannt" ist ein Programm, das sich (noch) nicht vorgestellt hat.
</div>
<div class="card" style="max-width:900px;">
<div id="room-clients" style="font-size:12px;color:#8888AA;">(Aktualisieren klicken)</div>
</div>
</div>
<!-- App-Onboarding via QR-Code -->
<div class="settings-section">
<h2>App-Onboarding (QR-Code)</h2>
@@ -1859,6 +1896,26 @@
</div>
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
<!-- Modell-Downloads auf LLM-Boxen: Fortschritt, dann „in die Karte geladen" -->
<div class="modal-overlay" id="llm-dl-modal" onclick="if(event.target===this)closeLlmDownloads()">
<div class="modal-box" style="max-width:720px;">
<div class="modal-header">
<h3>⬇ Modell-Downloads</h3>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="send({action:'clear_llm_jobs'})" title="Fertige und fehlgeschlagene aus der Liste nehmen" style="padding:3px 10px;font-size:11px;">Erledigte entfernen</button>
<button class="modal-close" onclick="closeLlmDownloads()">&times;</button>
</div>
</div>
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
Jede LLM-Karte hält genau <strong style="color:#8888AA;">ein</strong> Modell, dauerhaft. Welche
Karten dem LLM gehören, steht in der .env der Box (<code>LLM_GPUS</code>). Laden ersetzt, was auf
der Karte lag; die anderen Karten der Box starten dabei kurz neu. Clients (ARIA, Fremdprogramme,
OpenHands) wählen nur unter den geladenen Modellen und laden nie selbst.
</div>
</div>
</div>
<div class="modal-overlay" id="info-modal">
<div class="modal-box" style="max-width:640px;">
<div class="modal-header">
@@ -2313,6 +2370,8 @@
try { loadModelList(); } catch {}
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
try { loadSafeguard(); } catch {}
// Laufende Modell-Downloads (Badge am ⬇-Knopf)
send({ action: 'get_llm_jobs' });
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
};
@@ -2530,6 +2589,7 @@
return;
}
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
if (msg.type === 'room_clients') { renderRoomClients(msg.payload || {}); return; }
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
@@ -2563,7 +2623,7 @@
if (p.ok) setTimeout(closeDeviceCreds, 700);
return;
}
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); renderLlmCardSettings(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
if (msg.type === 'llm_response') {
const p = msg.payload || {};
const out = document.getElementById('llm-test-result');
@@ -2574,11 +2634,19 @@
}
return;
}
if (msg.type === 'llm_provision_progress') { onLlmJob(msg.payload); return; }
if (msg.type === 'llm_jobs') {
_llmJobs = {};
for (const j of ((msg.payload || {}).jobs || [])) _llmJobs[_llmJobId(j)] = j;
renderLlmDownloads();
return;
}
if (msg.type === 'llm_provision_result') {
const p = msg.payload || {};
const st = document.getElementById('llm-catalog-status');
if (st) {
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
}
@@ -3797,13 +3865,19 @@
text = `${labels[s] || s}: bereit${info.model ? ' ' + info.model : ''}${sec}${downloadedHint}`;
} else if (info.state === 'error') {
dot = '❌'; color = '#FF3B30'; anyError = true;
text = `${labels[s] || s}: Fehler ${info.error || ''}`;
// httpx haengt an jeden Fehler "For more information check: <mozilla-Link>"
// — kostet den halben Platz und erklaert nichts.
const err = String(info.error || '').replace(/\s*For more information check:\s*\S+/gi, '').trim();
text = `${labels[s] || s}: Fehler ${escapeHtml(err)}`;
} else {
text = `${labels[s] || s}: ${info.state}`;
}
row.innerHTML = `<span style="color:${color}">${dot}</span><span>${text}</span>`;
row.innerHTML = `<span class="svc-dot" style="color:${color}">${dot}</span><span>${text}</span>`;
list.appendChild(row);
}
// Nur eine Meldung: das grosse Symbol links sagt schon alles — sonst
// stuende z.B. das ❌ doppelt da.
if (list.children.length === 1) list.querySelector('.svc-dot').style.display = 'none';
// Icon spiegelt Gesamt-Status
if (anyError) icon.innerHTML = '&#x274C;';
@@ -3812,20 +3886,27 @@
banner.style.display = 'block';
// Wenn alles ready (kein Loading, kein Error): X-Button anzeigen
// + nach 8s automatisch wegfaden
// X immer da — auch (gerade) bei Fehlern. Alles ready → nach 8 s von selbst weg.
closeBtn.style.display = 'block';
clearTimeout(_serviceFadeTimer);
if (!anyLoading && !anyError) {
closeBtn.style.display = 'block';
clearTimeout(_serviceFadeTimer);
_serviceFadeTimer = setTimeout(() => {
banner.style.display = 'none';
}, 8000);
} else {
closeBtn.style.display = 'none';
clearTimeout(_serviceFadeTimer);
}
}
// Wegklicken: Fehler und Fertig-Meldungen vergessen, sonst kaemen sie mit
// der naechsten Statusmeldung eines anderen Dienstes gleich wieder hoch.
// Was noch laedt, bleibt gemerkt und zeigt sich beim naechsten Update.
function dismissServiceStatus() {
for (const [k, v] of Object.entries(_serviceState)) {
if (v.state !== 'loading') delete _serviceState[k];
}
clearTimeout(_serviceFadeTimer);
document.getElementById('service-status-banner').style.display = 'none';
}
function updateThinkingIndicator(msg) {
// Gedanken-Stream fuettern — JEDES Event (auch idle als ✓ fertig)
pushThought(msg.activity || '', msg.tool || '');
@@ -5070,6 +5151,7 @@
if (tab === 'settings') {
send({ action: 'get_voice_config' });
loadRuntimeConfig();
requestRoomClients();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
@@ -5126,16 +5208,26 @@
const meta = WORKER_SVC_META[w.service] || { icon: '⚙️', label: w.service };
const dot = !w.online ? '#666' : (w.busy ? '#FFB020' : '#3FFF3F');
const stat = !w.online ? 'offline' : (w.busy ? 'beschaeftigt' : 'frei – idle – keine Auslastung');
// llm-Boxen koennen mehrere Modelle fahren (llama-swap) → Liste zeigen.
const modelText = (w.service === 'llm' && Array.isArray(w.models) && w.models.length)
? w.models.join(', ') : (w.model || '');
// LLM-Boxen: pro Karte genau ein Modell, dauerhaft geladen. Laden und
// Entladen nur hier (Modell-Katalog), Clients waehlen nur aus.
let modelHtml;
if (w.service === 'llm' && Array.isArray(w.slots)) {
modelHtml = ''; // Karten folgen als eigene Zeilen unten
} else if (w.service === 'llm' && Array.isArray(w.models) && w.models.length) {
// Box mit altem Adapter (ohne Karten): nur die Liste
modelHtml = '<span style="color:#8888AA;" title="Box meldet noch keine Karten — llm-adapter aktualisieren">'
+ escapeHtml(w.models.join(', ')) + '</span>';
} else {
modelHtml = '<span style="color:#8888AA;">' + escapeHtml(w.model || '') + '</span>';
}
return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">' +
'<span style="width:8px;height:8px;border-radius:50%;background:' + dot + ';display:inline-block;"></span>' +
'<span>' + meta.icon + ' <b>' + escapeHtml(meta.label) + '</b></span>' +
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
'<span>' + modelHtml + '</span>' +
(w.gpus && !(w.service === 'llm' && Array.isArray(w.slots)) ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
'</div>' +
(w.service === 'llm' && Array.isArray(w.slots) ? renderLlmSlots(w) : '') +
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
}).join('');
@@ -5285,6 +5377,52 @@
function requestSatCreds(id) {
send({ action: 'sat_creds_list', satellite: id });
}
// ── Wer ist im Raum? ─────────────────────────
function requestRoomClients() {
const box = document.getElementById('room-clients');
if (box && !box.dataset.loaded) box.textContent = 'Frage den RVS …';
send({ action: 'room_clients_query' });
}
function _ago(ms) {
if (!ms) return '–';
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 60) return s + ' s';
if (s < 3600) return Math.round(s / 60) + ' min';
if (s < 86400) return Math.round(s / 3600) + ' h';
return Math.round(s / 86400) + ' d';
}
const ROOM_KIND_ICON = { 'Bridge': '🌉', 'Diagnostic': '🩺', 'App': '📱', 'Satellit': '🛰️',
'Host-Agent': '🖥️', 'KI-Box': '🧠', 'LLM-Client': '🤖' };
function renderRoomClients(p) {
const box = document.getElementById('room-clients');
if (!box) return;
box.dataset.loaded = '1';
if (p.error) { box.innerHTML = '<span style="color:#FF6B6B;">' + escapeHtml(p.error) + '</span>'; return; }
const list = p.clients || [];
if (!list.length) { box.innerHTML = '<span style="color:#8888AA;">(niemand — oder der RVS kennt die Abfrage noch nicht: RVS aktualisieren)</span>'; return; }
const td = 'padding:5px 8px;border-top:1px solid #1E1E2E;vertical-align:top;';
box.innerHTML = '<div style="color:#E0E0F0;margin-bottom:6px;">' + list.length + ' Verbindung' + (list.length === 1 ? '' : 'en') + '</div>' +
'<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:12px;">' +
'<tr style="color:#8888AA;text-align:left;"><th style="padding:4px 8px;">Art</th><th style="padding:4px 8px;">Name</th>' +
'<th style="padding:4px 8px;">Details</th><th style="padding:4px 8px;">Adresse</th>' +
'<th style="padding:4px 8px;">verbunden seit</th><th style="padding:4px 8px;">zuletzt aktiv</th></tr>' +
list.map(c => {
const kind = c.kind || '';
const icon = ROOM_KIND_ICON[kind] || '❔';
const kindTxt = kind ? escapeHtml(kind) : '<span style="color:#8888AA;">unbekannt</span>';
const you = c.you ? ' <span style="color:#0096FF;">★ diese Diagnostic</span>' : '';
const details = [c.detail, c.version ? 'v' + c.version : ''].filter(Boolean).join(' · ')
|| (!kind && c.userAgent ? c.userAgent : '');
return '<tr>' +
'<td style="' + td + 'white-space:nowrap;">' + icon + ' ' + kindTxt + '</td>' +
'<td style="' + td + 'color:#E0E0F0;">' + escapeHtml(c.name || '–') + you + '</td>' +
'<td style="' + td + 'color:#8888AA;">' + escapeHtml(details || '–') + '</td>' +
'<td style="' + td + 'color:#8888AA;font-family:monospace;">' + escapeHtml(c.ip || '–') + '</td>' +
'<td style="' + td + 'color:#8888AA;white-space:nowrap;">' + _ago(c.connectedAt) + '</td>' +
'<td style="' + td + 'color:#8888AA;white-space:nowrap;">' + _ago(c.lastSeen) + '</td></tr>';
}).join('') + '</table></div>';
}
let rooms = []; // [{token8, fp, len, clients, you}]
function requestRooms() { send({ action: 'rooms_query' }); }
function renderRooms() {
@@ -8812,22 +8950,24 @@
// Baut das Modell-Dropdown aus der Vereinigung von live-Flotte + kuratierter
// Liste; zeigt pro Modell "· N Box(en)" bzw. "· offline". Auswahl bleibt
// erhalten (auch wenn das gewaehlte Modell gerade keine online Box hat).
// ARIAs lokales Modell: nur was im Cluster GELADEN ist, jedes einmal, ohne
// Karte — wo es laeuft, entscheidet der Cluster. Ist das gewaehlte gerade
// nirgends geladen, bleibt es mit "(offline)" stehen; sobald eine Box es
// wieder hat, geht es von selbst weiter (bis dahin antwortet Claude).
function refreshLocalLlmModelChoices() {
const sel = document.getElementById('local-llm-model');
if (!sel) return;
if (document.activeElement === sel) return; // nicht unter der offenen Auswahl umbauen
const counts = fleetLlmModelCounts();
const nameOf = id => { const m = _localModelsCache.find(x => x.id === id); return (m && m.display_name) || id; };
const ids = new Set();
Object.keys(counts).forEach(id => ids.add(id));
_localModelsCache.forEach(m => ids.add(m.id));
if (_currentLocalModel) ids.add(_currentLocalModel);
const order = Array.from(ids).sort();
sel.innerHTML = order.map(id => {
const n = counts[id] || 0;
const avail = n > 0 ? ` · ${n} Box${n > 1 ? 'en' : ''}` : ' · offline';
return `<option value="${id}">${escapeHtml(nameOf(id))}${avail}</option>`;
}).join('') || '<option value="">(keine)</option>';
if (order.includes(_currentLocalModel)) sel.value = _currentLocalModel;
const cpu = id => workers.some(w => w.online && (w.slots || []).some(sl => sl.model === id && sl.onCpu));
const ids = Object.keys(counts).sort();
const opts = ids.map(id => `<option value="${escapeHtml(id)}">${escapeHtml(nameOf(id))}${cpu(id) ? ' · läuft auf der CPU' : ''}</option>`);
if (_currentLocalModel && !ids.includes(_currentLocalModel)) {
opts.unshift(`<option value="${escapeHtml(_currentLocalModel)}">${escapeHtml(nameOf(_currentLocalModel))} (offline)</option>`);
}
sel.innerHTML = opts.join('') || '<option value="">kein Modell verfügbar</option>';
sel.value = _currentLocalModel && (ids.includes(_currentLocalModel) || opts.length) ? _currentLocalModel : (ids[0] || '');
updateLocalModelDesc();
renderLocalLlmFleet();
}
@@ -8876,6 +9016,9 @@
toolVariant: document.getElementById('local-llm-toolvariant').value,
localLlmModel: (modelSel && modelSel.value) || '',
};
// Sofort merken — sonst stellt das naechste Flotten-Update (alle 10 s) das
// Dropdown auf das alte Modell zurueck, obwohl das neue gespeichert ist.
if (body.localLlmModel) _currentLocalModel = body.localLlmModel;
updateLocalModelDesc();
try {
const r = await fetch('/api/local-llm-config', {
@@ -8915,21 +9058,32 @@
function renderLlmCatalog() {
const box = document.getElementById('llm-catalog-list');
if (!box) return;
renderLlmCardSettings();
if (!_llmCatalog.length) { box.innerHTML = '<span style="color:#6a6a88;">Katalog leer.</span>'; return; }
const boxes = onlineLlmBoxes();
const th = 'style="padding:6px 8px;text-align:left;position:sticky;top:0;background:#12122A;"';
const td = 'style="padding:6px 8px;vertical-align:top;"';
const rows = _llmCatalog.map((m, i) => {
const have = boxesServing(m.id);
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(b => escapeHtml(b.node)).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
const where = b => {
const sl = (b.slots || []).find(x => x.model === m.id);
return escapeHtml(b.node) + (sl ? ' · Karte ' + escapeHtml(sl.gpu) : '');
};
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
const opts = boxes.length
? boxes.map(b => `<option value="${escapeHtml(b.instanceId)}">${escapeHtml(b.node)}</option>`).join('')
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
const targets = llmCardTargets();
const pre = (targets.find(t => t.slot && !t.slot.model) || targets[0] || {}).value;
const opts = targets.length
? targets.map(t => `<option value="${escapeHtml(t.value)}"${t.value === pre ? ' selected' : ''}>${escapeHtml(t.label)}</option>`).join('')
: '<option value="">(keine Box online)</option>';
return '<tr style="border-bottom:1px solid #1E1E2E;">' +
'<td ' + td + 'white-space:nowrap;"><b>' + escapeHtml(m.id) + '</b></td>' +
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '') + '</td>' +
'<td ' + td + 'color:#9a9ab5;min-width:220px;">' + escapeHtml(m.description || m.hfRepo || '')
+ (m.quant ? ' <span style="color:#555570;" title="Quantisierung, die geladen wird' + (m.quants ? ' — vorhanden: ' + escapeHtml(m.quants.join(', ')) : '') + '">· ' + escapeHtml(m.quant) + '</span>' : '')
+ (m.unusable ? ' <span style="color:#FF6B6B;" title="Im Repo liegt keine ladbare GGUF-Modelldatei mehr">· nicht ladbar</span>' : '')
+ '</td>' +
'<td ' + td + vramStyle + 'white-space:nowrap;">' + vram + '</td>' +
'<td ' + td + 'white-space:nowrap;">' + haveTxt + '</td>' +
'<td ' + td + '"><select id="llm-cat-box-' + i + '" style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:3px 6px;color:#E0E0F0;font-size:11px;">' + opts + '</select></td>' +
@@ -8939,18 +9093,282 @@
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}
// ── Download-Fenster ──────────────────────────────────────────────
// Zustand kommt vom Diagnostic-Server (llm_provision_progress / llm_jobs);
// der kennt auch Downloads, die vor dem Oeffnen dieses Tabs gestartet sind.
let _llmJobs = {};
const _llmJobId = j => j.instanceId + '|' + j.key;
function _fmtBytes(b) {
if (!b && b !== 0) return '?';
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(0) + ' MB';
return Math.round(b / 1e3) + ' KB';
}
function _fmtEta(s) {
if (s == null) return '';
if (s < 60) return s + ' s';
return Math.floor(s / 60) + ' min ' + String(s % 60).padStart(2, '0') + ' s';
}
function _boxName(instanceId) {
const w = (typeof workers !== 'undefined' ? workers : []).find(x => x.instanceId === instanceId);
return (w && w.node) || (instanceId || '').replace(/^llm@/, '');
}
function onLlmJob(job) {
if (!job || !job.key) return;
_llmJobs[_llmJobId(job)] = job;
renderLlmDownloads();
renderLlmCardSettings();
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
}
function renderLlmDownloads() {
const jobs = Object.values(_llmJobs).sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
const running = jobs.filter(j => !['ready', 'error'].includes(j.phase)).length;
const badge = document.getElementById('llm-dl-badge');
if (badge) { badge.textContent = running; badge.style.display = running ? 'inline-block' : 'none'; }
const box = document.getElementById('llm-dl-list');
if (!box) return;
if (!jobs.length) {
box.innerHTML = '<div style="color:#6a6a88;text-align:center;padding:20px;">Keine Downloads. „Laden" im Modell-Katalog startet einen.</div>';
return;
}
box.innerHTML = jobs.map(j => {
const box_ = escapeHtml(_boxName(j.instanceId)) + (j.gpu != null && j.gpu !== '' ? ' · Karte ' + escapeHtml(String(j.gpu)) : '')
+ (j.replaces ? ' <span style="color:#6a6a88;">(ersetzt ' + escapeHtml(j.replaces) + ')</span>' : '');
let pct = 0, color = '#0096FF', line = '', sub = '';
if (j.phase === 'queued') {
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
const age = (Date.now() - (j.requestedAt || j.startedAt || Date.now())) / 1000;
line = 'Angefragt — warte auf die Box …';
if (age > 30) { line = '⚠ Die Box antwortet nicht.'; color = '#FF9F0A';
sub = 'Ist sie online? Nach einem Update von RVS und Box erneut versuchen.'; }
} else if (j.phase === 'start') {
line = 'Box hat den Auftrag — prüfe, ob das Modell schon da ist …';
} else if (j.phase === 'download') {
pct = j.percent != null ? j.percent : 0;
line = j.percent != null
? `${pct.toFixed(1)} % · ${_fmtBytes(j.bytes)} / ${_fmtBytes(j.total)}`
: `${_fmtBytes(j.bytes)} heruntergeladen (Größe unbekannt)`;
sub = (j.speedBps ? (j.speedBps / 1e6).toFixed(1) + ' MB/s' : '') + (j.etaSeconds != null ? ' · noch ' + _fmtEta(j.etaSeconds) : '');
} else if (j.phase === 'load') {
pct = 100; color = '#FFD60A';
line = j.cached ? 'Liegt schon auf der Box — lädt in die Karte …' : '100 % heruntergeladen — lädt jetzt in die Karte …';
sub = 'Das Modell wandert in den Grafikspeicher; das dauert je nach Größe ein paar Sekunden bis Minuten.';
} else if (j.phase === 'ready') {
pct = 100; color = '#34C759';
const card = j.gpu != null && j.gpu !== '' ? `Karte ${escapeHtml(String(j.gpu))}${j.gpuName ? ' (' + escapeHtml(j.gpuName) + ')' : ''}` : 'die Karte';
line = `✓ In ${card} geladen — bereit`;
const vram = j.vramTotalMB ? ` · VRAM ${(j.vramUsedMB / 1024).toFixed(1)} / ${(j.vramTotalMB / 1024).toFixed(1)} GB belegt` : '';
sub = (j.loadSeconds != null ? `Dauer ${_fmtEta(Math.round(j.loadSeconds))}` : '') + vram;
} else if (j.phase === 'error') {
color = '#FF6B6B'; pct = j.percent || 0;
line = '✗ ' + escapeHtml(j.error || 'Fehler');
}
const bar = `<div style="height:8px;background:#1E1E2E;border-radius:4px;overflow:hidden;margin:6px 0 4px;">
<div style="height:100%;width:${Math.max(0, Math.min(100, pct))}%;background:${color};transition:width .6s;
${j.phase === 'queued' || j.phase === 'start' ? 'opacity:.35;width:100%;animation:llmdlpulse 1.2s ease-in-out infinite;' : ''}"></div></div>`;
return `<div style="background:#12121E;border:1px solid #2A2A3E;border-radius:8px;padding:10px 12px;">
<div style="display:flex;justify-content:space-between;gap:10px;">
<strong style="color:#E0E0F0;">${escapeHtml(j.key)}</strong>
<span style="color:#8888AA;font-size:11px;">→ ${box_}</span>
</div>${bar}
<div style="color:${color === '#0096FF' ? '#E0E0F0' : color};font-size:12px;">${line}</div>
${sub ? `<div style="color:#6a6a88;font-size:11px;margin-top:2px;">${sub}</div>` : ''}
${j.note && j.phase !== 'error' ? `<div style="color:#FFD60A;font-size:11px;margin-top:2px;">ℹ ${escapeHtml(j.note)}</div>` : ''}
</div>`;
}).join('');
}
function openLlmDownloads() {
send({ action: 'get_llm_jobs' });
renderLlmDownloads();
document.getElementById('llm-dl-modal').classList.add('open');
}
function closeLlmDownloads() {
document.getElementById('llm-dl-modal').classList.remove('open');
}
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
setInterval(() => {
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
}, 5000);
// ── LLM-Karten: Dropdown pro Karte (Einstellungen → Modelle & KI) ──
// Optionen = Modell-Katalog + was gerade geladen ist (falls nicht im
// Katalog). Zu gross fuer die Karte → markiert, aber waehlbar (Groesse ist
// nur eine Schaetzung, der Kontext braucht zusaetzlich Platz).
function renderLlmCardSettings() {
const box = document.getElementById('llm-card-settings');
if (!box) return;
if (!_llmCatalog.length && !window._llmCatalogRequested) {
window._llmCatalogRequested = true;
loadLlmCatalog(); // ruft renderLlmCatalog → renderLlmCardSettings
}
const boxes = onlineLlmBoxes().filter(b => Array.isArray(b.slots));
if (!boxes.length) {
const old = onlineLlmBoxes().length;
box.innerHTML = '<span style="color:#6a6a88;">' + (old
? 'Die LLM-Box meldet noch keine Karten — llm-adapter auf der Box aktualisieren.'
: 'Keine LLM-Box online.') + '</span>';
return;
}
const rows = [];
for (const b of boxes) {
for (const sl of b.slots) {
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
const ids = new Set(_llmCatalog.map(m => m.id));
const opts = ['<option value="">— leer —</option>'];
const list = _llmCatalog.filter(m => !m.unusable);
if (sl.model && !ids.has(sl.model)) list.unshift({ id: sl.model });
for (const m of list) {
// Liegt das Modell schon auf einer ANDEREN Karte dieser Box? → nicht anbieten.
const elsewhere = (b.slots || []).find(x => x.model === m.id && x.gpu !== sl.gpu);
if (elsewhere) continue;
const tooBig = vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92;
const label = m.id + (m.sizeGB ? ` · ~${m.sizeGB} GB` : '') + (m.quant ? ` · ${m.quant}` : '')
+ (tooBig ? ' · passt nicht' : '');
opts.push(`<option value="${escapeHtml(m.id)}"${m.id === sl.model ? ' selected' : ''}${tooBig ? ' style="color:#FF9F0A;"' : ''}>${escapeHtml(label)}</option>`);
}
let [c, st] = sl.model ? (SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')]) : ['#555570', 'leer'];
if (sl.onCpu) { c = '#FF9F0A'; st = '⚠ auf der CPU'; }
const job = Object.values(_llmJobs || {}).find(j => j.instanceId === b.instanceId && String(j.gpu) === String(sl.gpu)
&& !['ready', 'error'].includes(j.phase));
const jobTxt = job ? (job.phase === 'download' && job.percent != null ? `lädt ${job.percent.toFixed(0)} %` : 'lädt …') : '';
// Kontextfenster dieser Karte. Mehr Kontext = mehr VRAM; passt es
// nicht, setzt die Box die Karte von selbst zurueck.
const CTX = [4096, 8192, 16384, 32768, 65536, 131072];
const curCtx = sl.ctx || 0;
const ctxOpts = (curCtx && !CTX.includes(curCtx) ? [curCtx, ...CTX] : CTX)
.map(n => `<option value="${n}"${n === curCtx ? ' selected' : ''}>${n >= 1024 ? (n / 1024) + 'k' : n} Tokens</option>`).join('');
const ctxSel = sl.model
? `<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-model="${escapeHtml(sl.model)}" data-current="${curCtx}"
onchange="onLlmCardCtx(this)" ${job ? 'disabled' : ''} title="Kontextfenster (Eingabe + Ausgabe). Mehr braucht mehr Grafikspeicher."
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 6px;color:#E0E0F0;font-size:12px;">${ctxOpts}</select>`
: '<span></span>';
rows.push(`<div style="display:grid;grid-template-columns:minmax(220px,1fr) minmax(260px,2fr) 130px 160px;gap:10px;align-items:center;">
<div><b style="color:#E0E0F0;">${escapeHtml(b.node)}</b> · Karte ${escapeHtml(sl.gpu)}
<div style="color:#555570;font-size:11px;">${escapeHtml(sl.gpuName || '')}${vramGB ? ' · ' + vramGB.toFixed(0) + ' GB' : ''}${sl.vramUsedMB >= 0 ? ' · belegt ' + (sl.vramUsedMB / 1024).toFixed(1) + ' GB' : ''}${sl.ctx ? ' · Kontext ' + sl.ctx : ''}</div>
${sl.onCpu ? '<div style="color:#FF9F0A;font-size:11px;" title="llama.cpp findet die Karte nicht und rechnet langsam auf der CPU. Ursache meist: Treiber zu alt fuer das CUDA im llama-swap-Image. Abhilfe: auf der Box sudo ./bootstrap.sh --upgrade-driver">⚠ Modell läuft auf der CPU, Karte leer — Treiber zu alt? (bootstrap.sh --upgrade-driver)</div>' : ''}</div>
<select data-box="${escapeHtml(b.instanceId)}" data-gpu="${escapeHtml(sl.gpu)}" data-current="${escapeHtml(sl.model || '')}"
onchange="onLlmCardSelect(this)" ${job ? 'disabled' : ''}
style="background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:5px 8px;color:#E0E0F0;font-size:12px;">${opts.join('')}</select>
${ctxSel}
<span style="color:${job ? '#FFD60A' : c};font-size:12px;">${jobTxt || st}</span>
</div>`);
}
}
box.innerHTML = rows.join('');
}
// Kontextfenster einer Karte aendern = dasselbe Modell mit anderem -c neu
// laden. Programme bekommen den neuen Wert automatisch (ctx in
// llm_cluster_models). Passt es nicht in den Grafikspeicher, setzt die Box
// die Karte auf den alten Stand zurueck.
function onLlmCardCtx(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, model = sel.dataset.model;
const cur = Number(sel.dataset.current || 0), next = Number(sel.value);
if (next === cur) return;
const m = _llmCatalog.find(x => x.id === model);
if (!m || !m.hfRepo) { alert(`${model} steht nicht im Modell-Katalog — Kontext so nicht änderbar.`); sel.value = cur; return; }
const more = next > cur;
if (!confirm(`${model} auf ${_boxName(target)} · Karte ${gpu} mit ${next} statt ${cur || '?'} Tokens Kontext neu laden?\n\n`
+ (more ? 'Mehr Kontext braucht mehr Grafikspeicher. Passt es nicht, setzt die Box die Karte von selbst auf den alten Stand zurück.\n\n' : '')
+ 'Die Box startet ihre Modelle dabei kurz neu. Programme bekommen den neuen Wert automatisch.')) { sel.value = cur; return; }
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: next });
openLlmDownloads();
}
function onLlmCardSelect(sel) {
const target = sel.dataset.box, gpu = sel.dataset.gpu, current = sel.dataset.current, next = sel.value;
const node = _boxName(target);
if (next === current) return;
if (!next) {
if (!confirm(`Karte ${gpu} auf ${node} leeren?\n\n${current} wird entladen; ARIA und Programme können es dann nicht mehr wählen. `
+ `Die Datei bleibt auf der Box.\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
send({ action: 'llm_unload_model', targetInstance: target, gpu });
return;
}
const m = _llmCatalog.find(x => x.id === next);
if (!m || !m.hfRepo) { alert(`${next} steht nicht im Modell-Katalog — Herkunft unbekannt.`); sel.value = current; return; }
const sl = (workers.find(w => w.instanceId === target)?.slots || []).find(x => String(x.gpu) === String(gpu)) || {};
const vramGB = sl.vramMB ? sl.vramMB / 1024 : 0;
const warn = (vramGB && m.sizeGB && m.sizeGB > vramGB * 0.92)
? `\n\n⚠ ${next} ist etwa ${m.sizeGB} GB groß, die Karte hat ${vramGB.toFixed(0)} GB — wahrscheinlich passt es nicht.` : '';
const repl = current ? `\n\nErsetzt ${current} — Programme, die es nutzen, verlieren es.` : '';
if (!confirm(`${next} auf ${node} · Karte ${gpu} laden?${repl}${warn}\n\nDie anderen Karten dieser Box starten dabei kurz neu.`)) { sel.value = current; return; }
send({ action: 'llm_provision_model', targetInstance: target, gpu, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
openLlmDownloads();
}
// ── LLM-Karten ────────────────────────────────────────────────────
const SLOT_STATE = {
ready: ['#3FFF3F', '● geladen'], starting: ['#FFD60A', '◐ startet …'],
stopping: ['#FF9F0A', 'stoppt …'], stopped: ['#FF6B6B', '✗ nicht gestartet'],
unknown: ['#8888AA', '? (llama-swap antwortet nicht)'],
};
function renderLlmSlots(w) {
const rows = (w.slots || []).map(sl => {
const card = 'Karte ' + escapeHtml(sl.gpu) + (sl.gpuName ? ' <span style="color:#555570;">(' + escapeHtml(sl.gpuName) + ')</span>' : '');
if (!sl.model) {
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
<span style="min-width:200px;color:#8888AA;">${card}</span>
<span style="color:#555570;">— leer · im Modell-Katalog „Laden" → diese Karte</span></div>`;
}
let [c, label] = SLOT_STATE[sl.state] || ['#8888AA', escapeHtml(sl.state || '?')];
if (sl.onCpu) { c = '#FF9F0A'; label = '⚠ läuft auf der CPU — Karte leer (Treiber zu alt?)'; }
return `<div style="display:flex;gap:10px;align-items:center;padding:2px 0;">
<span style="min-width:200px;color:#8888AA;">${card}</span>
<b style="color:#E0E0F0;">${escapeHtml(sl.model)}</b>
<span style="color:${c};">${label}</span>
<button class="btn secondary" onclick="unloadLlmCard('${escapeHtmlAttr(w.instanceId)}','${escapeHtmlAttr(sl.gpu)}','${escapeHtmlAttr(sl.model)}')"
title="Karte leeren — das Modell bleibt auf der Box gespeichert" style="margin-left:auto;padding:2px 8px;font-size:10px;">Entladen</button>
</div>`;
}).join('');
return `<div style="margin:2px 0 6px 18px;padding:6px 10px;background:#0D0D18;border-left:2px solid #2A2A3E;font-size:11px;">${rows || '<span style="color:#555570;">Keine LLM-Karte konfiguriert (LLM_GPUS in der .env der Box).</span>'}</div>`;
}
function unloadLlmCard(instanceId, gpu, model) {
if (!confirm(`Karte ${gpu} leeren?\n\n${model} wird entladen; Clients können es danach nicht mehr wählen. `
+ `Die Datei bleibt auf der Box — erneutes Laden geht ohne Download.\n\n`
+ `Die anderen Karten dieser Box starten dabei kurz neu (wenige Sekunden).`)) return;
send({ action: 'llm_unload_model', targetInstance: instanceId, gpu });
}
// Ziele im Katalog: jede LLM-Karte jeder Box ("instanceId|karte").
function llmCardTargets() {
const out = [];
for (const b of onlineLlmBoxes()) {
if (Array.isArray(b.slots) && b.slots.length) {
for (const sl of b.slots) out.push({ value: b.instanceId + '|' + sl.gpu, box: b, slot: sl,
label: `${b.node} · Karte ${sl.gpu}` + (sl.model ? ` (${sl.model})` : ' (leer)') });
} else {
out.push({ value: b.instanceId + '|', box: b, slot: null, label: b.node }); // alter Adapter
}
}
return out;
}
function provisionModel(i) {
const m = _llmCatalog[i];
if (!m) return;
const sel = document.getElementById('llm-cat-box-' + i);
const target = sel && sel.value;
const [target, gpu] = String((sel && sel.value) || '').split('|');
if (!target) return;
const tgt = llmCardTargets().find(t => t.value === sel.value);
if (tgt && tgt.slot && tgt.slot.model === m.id) { alert(`${m.id} liegt schon auf dieser Karte.`); return; }
const busyBox = ((tgt && tgt.box.slots) || []).find(sl => sl.model === m.id);
if (busyBox) { alert(`${m.id} liegt auf dieser Box schon auf Karte ${busyBox.gpu}. Ein Modell pro Box nur einmal.`); return; }
if (tgt && tgt.slot && tgt.slot.model &&
!confirm(`Karte ${gpu} auf ${tgt.box.node} hält gerade ${tgt.slot.model}.\n\n`
+ `${m.id} ersetzt es. Clients, die ${tgt.slot.model} nutzen, verlieren es dann.\n`
+ `Die anderen Karten dieser Box starten dabei kurz neu.\n\nErsetzen?`)) return;
const st = document.getElementById('llm-catalog-status');
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
send({ action: 'llm_provision_model', targetInstance: target, gpu: gpu || '', key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
openLlmDownloads();
}
function runLlmTest() {
const inp = document.getElementById('llm-test-input');
+146 -5
View File
@@ -473,8 +473,50 @@ function saveLlmCatalog(arr) {
function slugModelId(repo) {
return String(repo).toLowerCase().replace(/^.*\//, "").replace(/-gguf$/,"").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
}
// ── Quantisierung pro Repo ──────────────────────────────────────────
// Nicht jedes Repo hat eine Q4_K_M-Datei (unsloth z.B. "UD-Q4_K_XL"). Steht
// eine Quantisierung im Katalog, die es nicht gibt, beendet sich llama-server
// sofort und die Box meldet nur "500". Darum fragen wir pro Repo die Dateiliste
// ab und nehmen die beste vorhandene — dieselbe Reihenfolge wie im llm-adapter.
const QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0",
"Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"];
const QUANT_RE = /((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))/gi;
const SPLIT_RE = /-(\d{5})-of-(\d{5})\.gguf$/i;
function ggufQuant(p) {
const base = String(p).split("/").pop().replace(SPLIT_RE, ".gguf").replace(/\.gguf$/i, "");
const m = base.match(QUANT_RE);
return m ? m[m.length - 1].toUpperCase() : "";
}
function isMainGguf(p) {
const low = String(p).toLowerCase();
const base = low.split("/").pop();
return low.endsWith(".gguf") && !base.includes("mmproj") && !base.startsWith("mtp")
&& !("/" + low).includes("/mtp/") && !base.includes("imatrix");
}
// → { quant, sizeGB, quants } oder null (Repo nicht lesbar / kein Modell darin)
async function hfRepoQuant(repo, wanted = "Q4_K_M") {
try {
const r = await fetch(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1`,
{ headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return null;
const files = (await r.json()).filter(e => e.type === "file" && isMainGguf(e.path))
.map(e => ({ path: e.path, size: (e.lfs && e.lfs.size) || e.size || 0, quant: ggufQuant(e.path) }));
const have = new Set(files.map(f => f.quant).filter(Boolean));
const quant = have.has(String(wanted).toUpperCase()) ? String(wanted).toUpperCase()
: QUANT_PREFERENCE.find(q => have.has(q)) || "";
if (!quant) return null;
const match = files.filter(f => f.quant === quant);
const parts = match.some(f => SPLIT_RE.test(f.path)) ? match.filter(f => SPLIT_RE.test(f.path))
: [match.reduce((a, b) => (a.path.length <= b.path.length ? a : b))];
const bytes = parts.reduce((n, f) => n + f.size, 0);
return { quant, sizeGB: Math.round(bytes / 1e8) / 10, quants: [...have].sort() };
} catch { return null; }
}
// Holt populaere GGUF-Modelle von der HuggingFace-API und merged sie in den
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten).
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
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" } });
@@ -489,9 +531,24 @@ async function refreshLlmCatalogFromHF() {
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++;
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true };
byId.set(id, entry);
}
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
const toCheck = [...byId.values()].filter(e => e.source === "hf");
for (let i = 0; i < toCheck.length; i += 6) {
await Promise.all(toCheck.slice(i, i + 6).map(async e => {
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
if (!q) {
// Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else e.unusable = true;
return;
}
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
}));
}
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
const merged = Array.from(byId.values());
saveLlmCatalog(merged);
return { models: merged, added };
@@ -601,6 +658,13 @@ function nextReqId() {
return `diag-${++reqIdCounter}`;
}
// JSON.stringify-Replacer fuer Log-Zeilen: Werte von Geheimnis-Schluesseln
// (HF-Token, Passwoerter, API-Keys) kommen nie ins Log.
const SECRET_KEY_RE = /token(?!s)|secret|passw|apikey|api_key|credential/i;
function redactSecrets(key, value) {
return key && value && SECRET_KEY_RE.test(key) && typeof value !== "object" ? "***" : value;
}
function log(level, source, message) {
const entry = { ts: new Date().toISOString(), level, source, message };
logs.push(entry);
@@ -705,6 +769,10 @@ function workerList() {
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
// LLM-Karten (Belegung, Zustand, VRAM). Fehlte hier — die Box schickte sie,
// aber diese Feldliste liess sie nie zum Browser durch ("meldet keine Karten").
slots: Array.isArray(w.slots) ? w.slots : null,
knownModels: w.knownModels || [],
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
@@ -1148,6 +1216,12 @@ function connectRVS(forcePlain) {
state.rvs.lastError = null;
rvsWs = ws;
broadcastState();
// Beim RVS vorstellen (Liste "Wer ist im Raum?")
try {
ws.send(JSON.stringify({ type: "client_hello", payload: {
kind: "Diagnostic", name: require("os").hostname(), detail: "ARIA-Diagnostic" },
timestamp: Date.now() }));
} catch (_) {}
// Keepalive: alle 25s ein Ping senden damit die Verbindung nicht stirbt
const keepalive = setInterval(() => {
@@ -1226,6 +1300,9 @@ function connectRVS(forcePlain) {
} 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 === "room_clients") {
// Antwort des RVS: wer ist in unserem Raum → an den Browser
broadcast({ type: "room_clients", payload: msg.payload || {} });
} else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} });
@@ -1260,6 +1337,11 @@ function connectRVS(forcePlain) {
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] : []),
// Nur llm: pro LLM-Karte das Modell + Zustand. "models" ist dann nur
// noch die Auswahl der GELADENEN Modelle; "knownModels" das, was man
// in eine Karte laden koennte.
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
@@ -1282,6 +1364,10 @@ function connectRVS(forcePlain) {
workers.set(p.instanceId, w);
}
w.busy = !!p.busy;
// LLM-Boxen schicken mit jedem Ping die Karten-Belegung — Laden,
// Entladen und Neustarts sind so nach spaetestens 10 s sichtbar.
if (Array.isArray(p.slots)) w.slots = p.slots;
if (Array.isArray(p.models)) w.models = p.models;
w.last_seen = Date.now();
broadcastWorkers();
}
@@ -1372,7 +1458,17 @@ function connectRVS(forcePlain) {
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 || {} });
const p = msg.payload || {};
if (p.key && p.instanceId && !p.removed && !p.unloaded) {
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
if (job) broadcast({ type: "llm_provision_progress", payload: job });
}
broadcast({ type: "llm_provision_result", payload: p });
} else if (msg.type === "llm_provision_progress") {
// Download-Fortschritt einer Box (Prozent, Tempo, Restzeit, dann
// "laedt in die Karte" und "geladen"). Merken fuer spaeter geoeffnete Tabs.
const job = llmJobUpdate((msg.payload || {}).instanceId, (msg.payload || {}).key, msg.payload || {});
if (job) broadcast({ type: "llm_provision_progress", payload: job });
} 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 || {} });
@@ -1380,7 +1476,7 @@ function connectRVS(forcePlain) {
// PCM-Chunks einer laufenden Voice-Preview — sammeln + WAV bauen
_handlePreviewChunk(msg.payload);
} else {
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`);
log("debug", "rvs", `Nachricht: ${JSON.stringify(msg, redactSecrets).slice(0, 150)}`);
}
} catch {}
});
@@ -2074,6 +2170,27 @@ setInterval(checkDiskSpace, 30000);
// ── HTTP Server + WebSocket fuer Browser ────────────────
// ── Modell-Downloads auf LLM-Boxen ──────────────────────────────────
// Letzter bekannter Stand pro (Box, Modell). Im Speicher reicht: das Fenster
// soll zeigen, was gerade laeuft und was eben fertig wurde, keine Historie.
const llmJobs = new Map();
function llmJobUpdate(instanceId, key, patch) {
if (!instanceId || !key) return null;
const id = `${instanceId}\u0000${key}`;
const job = { ...(llmJobs.get(id) || { instanceId, key, startedAt: Date.now() }), ...patch,
instanceId, key, updatedAt: Date.now() };
// Ein neuer Auftrag fuer dasselbe Modell faengt frisch an.
if (patch.phase === "queued") { delete job.error; delete job.percent; delete job.bytes; job.startedAt = Date.now(); }
llmJobs.set(id, job);
// Alte Abgeschlossene nicht ewig mitschleppen.
if (llmJobs.size > 30) {
const done = [...llmJobs.entries()].filter(([, j]) => j.phase === "ready" || j.phase === "error")
.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
while (llmJobs.size > 30 && done.length) llmJobs.delete(done.shift()[0]);
}
return job;
}
const htmlPath = path.join(__dirname, "index.html");
const mailHtmlPath = path.join(__dirname, "email.html");
@@ -3073,6 +3190,13 @@ wss.on("connection", (ws) => {
log("info", "host", `Agent '${id}' aus der Liste entfernt`);
broadcastHosts();
}
} else if (msg.action === "room_clients_query") {
// Nur ueber die feste Verbindung — die Antwort kommt auf ihr zurueck
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
rvsWs.send(JSON.stringify({ type: "room_clients_query", payload: {}, timestamp: Date.now() }));
} else {
ws.send(JSON.stringify({ type: "room_clients", payload: { error: "Diagnostic ist nicht mit dem RVS verbunden", clients: [] } }));
}
} 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.
@@ -3320,13 +3444,30 @@ 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 === "get_llm_jobs") {
ws.send(JSON.stringify({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } }));
} else if (msg.action === "clear_llm_jobs") {
// Nur Abgeschlossene wegraeumen — laufende Downloads bleiben sichtbar.
for (const [k, j] of llmJobs) if (j.phase === "ready" || j.phase === "error") llmJobs.delete(k);
broadcast({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } });
} else if (msg.action === "llm_provision_model") {
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
{ phase: "queued", requestedAt: Date.now() });
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
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,
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
}, timestamp: Date.now() });
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_unload_model") {
// LLM-Karte leeren (das GGUF bleibt auf der Box).
sendToRVS_raw({ type: "llm_unload_model", payload: {
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
log("info", "llm", `Karte ${msg.gpu} leeren → ${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() });
+1
View File
@@ -136,6 +136,7 @@ services:
- RVS_TLS=${RVS_TLS:-true}
- RVS_TLS_FALLBACK=${RVS_TLS_FALLBACK:-true}
- RVS_TOKEN=${RVS_TOKEN:-}
- CLUSTER_LLM_TIMEOUT_S=${CLUSTER_LLM_TIMEOUT_S:-600}
restart: unless-stopped
# ─── Diagnostic (Selbstcheck-UI und Einstellungen) ────
+362
View File
@@ -0,0 +1,362 @@
# Lokale Sprachmodelle nutzen — Schnittstelle für Programme
Wie ein Programm die lokalen Sprachmodelle auf den KI-Boxen von ARIA nutzt.
**Kurz:** Du fragst ein **Modell**, nicht eine Box oder Karte. Der Cluster
sucht sich aus, wo es läuft. Eigene Programme sprechen dafür über den RVS
(Abschnitt A). Programme, die nur „OpenAI-kompatibel" können, bekommen den
OpenAI-RVS-Wrapper neben sich gestellt (Abschnitt B).
## Die Grundregel
**Programme wählen ein Modell aus — laden aber nie.**
- Jede KI-Box hat eine oder mehrere Grafikkarten fürs LLM. Jede dieser Karten
hält **genau ein Modell, dauerhaft geladen**.
- Welches Modell wo liegt, legt nur der Betreiber in der ARIA-Diagnostic fest
(Einstellungen → LLM-Karten).
- Ein Programm sieht die geladenen Modelle des ganzen Clusters — jedes
**einmal**, egal auf wie vielen Karten es liegt — und wählt eins.
- Liegt das Modell auf mehreren Karten, **verteilt der Cluster bei Last**.
Fällt eine Box mitten in einer Anfrage aus, übernimmt eine andere mit
demselben Modell. Davon merkt das Programm nichts.
- Ist das Modell nirgends geladen, gibt es einen **Fehler** — nie eine normale
Antwort mit Fehlertext. Ein Programm würde so einen Text sonst als Aussage
der KI in seinen Verlauf schreiben.
- Mehrere Programme dürfen **gleichzeitig dasselbe Modell** nutzen. Die Karte
bedient sie parallel; jede einzelne Antwort wird dabei etwas langsamer.
## Welcher Zugang?
| | A) Cluster über RVS | B) OpenAI-RVS-Wrapper | C) Direkt an eine Box |
|---|---|---|---|
| Für | eigene Programme | OpenHands, Continue, Open WebUI … | Sonderfälle |
| Sieht | alle Modelle, jedes einmal | alle Modelle, jedes einmal | nur diese Box |
| Verteilung + Ausfall-Ersatz | ja | ja | nein |
| Braucht | ausgehende Verbindung zum RVS | Wrapper-Container auf demselben Rechner | ausgehende Verbindung zum RVS |
---
## A) Cluster über RVS — empfohlen
### Verbinden
```
wss://<RVS_HOST>:<RVS_PORT>/?token=<RVS_TOKEN>
```
- Das **Token ist der Raum** — dasselbe `RVS_TOKEN` wie bei ARIA und den
KI-Boxen. Wer es hat, ist im Raum.
- Der Programm-Rechner braucht nur eine **ausgehende** Verbindung zum RVS.
Keine Portfreigabe, kein VPN — er kann in jedem Netz stehen.
- Der RVS ist ein **Verteiler**: Jede Nachricht geht an alle anderen im Raum.
Du siehst also auch Antworten für andere — ordne sie immer über die
`requestId` zu.
- Unbekannte Nachrichtentypen verwirft der RVS **ohne Fehlermeldung**. Die hier
beschriebenen sind freigegeben.
- **Nur ein Leser pro Verbindung.** Schickst du mehrere Anfragen gleichzeitig,
liest eine Schleife alle Nachrichten und verteilt sie per `requestId` an die
Wartenden (siehe Beispiel). Mehrere gleichzeitige `recv()` auf derselben
Verbindung gehen nicht.
Jede Nachricht hat diese Hülle:
```json
{ "type": "<typ>", "payload": { … }, "timestamp": 1760000000000 }
```
Beantwortet werden `llm_cluster_*`-Nachrichten von der **ARIA-Bridge** — sie
kennt alle Boxen und entscheidet, wohin eine Anfrage geht. Läuft ARIA nicht,
kommt keine Antwort (dein Programm sollte einen Timeout haben).
### 1. Welche Modelle gibt es? — `llm_cluster_models_request`
```json
{ "type": "llm_cluster_models_request", "payload": { "requestId": "a1" } }
```
Antwort:
```json
{
"type": "llm_cluster_models",
"payload": {
"requestId": "a1",
"models": [
{
"model": "qwen3-8b",
"ctx": 8192,
"available": 2,
"cards": [
{ "node": "ki-box", "instanceId": "llm@ki-box", "gpu": "1", "gpuName": "NVIDIA GeForce RTX 3060", "state": "ready" },
{ "node": "werkstatt", "instanceId": "llm@werkstatt", "gpu": "0", "gpuName": "NVIDIA RTX A4000", "state": "ready" }
]
}
]
}
}
```
| Feld | Bedeutung |
|---|---|
| `model` | Name — genau so in der Anfrage angeben. **Das** zeigst du dem Nutzer zur Auswahl. |
| `ctx` | **Kontextfenster in Tokens** (Eingabe + Ausgabe zusammen). Das kleinste unter allen Karten mit diesem Modell — darauf kannst du dich verlassen, egal wohin verteilt wird. Nicht mehr annehmen. `0` = unbekannt. **Kann sich ändern** (der Betreiber stellt es pro Karte ein) — vor längeren Aufträgen neu holen. |
| `available` | auf wie vielen Karten es gerade liegt |
| `cards` | nur zur Info (Anzeige, Fehlersuche) — für die Anfrage nicht nötig |
### 2. Fragen — `llm_cluster_request`
```json
{
"type": "llm_cluster_request",
"payload": {
"requestId": "a2",
"model": "qwen3-8b",
"messages": [
{ "role": "system", "content": "Du antwortest kurz." },
{ "role": "user", "content": "Wie hoch ist die Zugspitze?" }
],
"max_tokens": 256,
"temperature": 0.7
}
}
```
| Feld | Pflicht | Bedeutung |
|---|---|---|
| `requestId` | ja | eindeutig pro Anfrage (z.B. UUID) |
| `model` | ja* | aus `llm_cluster_models`. *Darf fehlen, wenn im Cluster genau ein Modell geladen ist. |
| `messages` | ja | Verlauf im OpenAI-Format (`system` / `user` / `assistant` / `tool`) |
| `max_tokens` | nein | Standard 512 |
| `temperature` | nein | Standard 0.7 |
| `stop` | nein | Liste von Stop-Strings |
| `tools` | nein | Tool-Definitionen im OpenAI-Format; die Antwort enthält dann ggf. `tool_calls` |
### 3. Antwort — `llm_cluster_response`
Erfolg:
```json
{
"type": "llm_cluster_response",
"payload": {
"requestId": "a2",
"ok": true,
"content": "Die Zugspitze ist 2962 Meter hoch.",
"model": "qwen3-8b",
"node": "werkstatt",
"gpu": "0",
"usage": { "prompt_tokens": 31, "completion_tokens": 12, "total_tokens": 43 },
"elapsedMs": 640,
"attempts": 1
}
}
```
`tool_calls` steht drin, wenn das Modell ein Werkzeug aufrufen will (OpenAI-Format).
`node`/`gpu` sagen, wer geantwortet hat; `attempts: 2` heißt, die erste Box ist
ausgefallen und eine andere hat übernommen.
Fehler — `ok: false`, dazu `error` (Text für Menschen) und `errorCode`:
| `errorCode` | Bedeutung | Was tun |
|---|---|---|
| `model_unavailable` | Das Modell ist gerade auf keiner Karte im Cluster geladen. `available` nennt, was es gibt. | Dem Nutzer anzeigen; Liste neu holen. |
| `bad_request` | Anfrage unvollständig, z.B. `messages` leer oder `model` fehlt bei mehreren Modellen | Anfrage korrigieren |
| `timeout` | Keine Antwort innerhalb der Frist (Standard 600 s) — auch nach Ersatzversuch | später nochmal |
| `box_offline` | Box ist während der Anfrage weggefallen, und es gab keine Ersatz-Box | später nochmal |
| `context_exceeded` | Der Verlauf ist länger als das Kontextfenster. Dazu `promptTokens` (so lang war die Anfrage) und `ctx` (so viel geht). | Verlauf kürzen und nochmal senden — z.B. auf ~80 % von `ctx` abzüglich der gewünschten Antwortlänge. |
| `model_error` | Das Modell selbst hat einen anderen Fehler gemeldet. Wird nicht anderswo wiederholt — wäre dort genauso. | `error` lesen |
**Fehler nie als Antwort der KI behandeln.** Zeig `error` dem Nutzer als
Fehlermeldung an, aber schreib ihn nicht als Assistant-Nachricht in den
Verlauf.
### Beispiel (Python)
Braucht Python 3.11+ und `websockets` (`pip install websockets`). Ein Leser
pro Verbindung, mehrere Anfragen gleichzeitig möglich.
```python
import asyncio, json, uuid
import websockets
RVS = "wss://rvs.example.org:444/?token=DEIN_RVS_TOKEN"
class AriaCluster:
"""Eine RVS-Verbindung, ein Leser. Antworten gehen per requestId an die
wartende Anfrage — der Raum ist ein Verteiler, Fremdes wird ignoriert."""
def __init__(self, ws):
self.ws, self.wait = ws, {}
self.reader = asyncio.create_task(self._read())
async def _read(self):
async for raw in self.ws:
msg = json.loads(raw)
p = msg.get("payload") or {}
fut = self.wait.get((msg.get("type"), p.get("requestId")))
if fut and not fut.done():
fut.set_result(p)
async def _call(self, mtype, answer, payload, timeout):
rid = str(uuid.uuid4())
fut = asyncio.get_running_loop().create_future()
self.wait[(answer, rid)] = fut
try:
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid)}))
return await asyncio.wait_for(fut, timeout)
finally:
self.wait.pop((answer, rid), None)
async def models(self):
res = await self._call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
return res["models"]
async def chat(self, model, messages, timeout=660, **opts):
res = await self._call("llm_cluster_request", "llm_cluster_response",
dict(opts, model=model, messages=messages), timeout)
if not res["ok"]:
raise RuntimeError(f"{res.get('errorCode')}: {res.get('error')}")
return res
async def main():
async with websockets.connect(RVS, max_size=2**24) as ws:
cluster = AriaCluster(ws)
models = await cluster.models()
for m in models:
print(f"{m['model']} (Kontext {m['ctx']}, auf {m['available']} Karte(n))")
if models:
res = await cluster.chat(models[0]["model"],
[{"role": "user", "content": "Sag Hallo in einem Satz."}],
max_tokens=64)
print(res["content"], f"— von {res['node']} / Karte {res['gpu']}")
asyncio.run(main())
```
### Umstellen von der direkten Box-Ansprache
Wer schon gegen Abschnitt C gebaut hat (`llm_models_request` /
`llm_request` mit `targetInstance`), stellt so um:
1. **Modelliste:** `llm_cluster_models_request` statt `llm_models_request`.
Statt einer Antwort pro Box kommt **eine** Antwort mit jedem Modell
einmal. Kein Einsammeln über Sekunden mehr.
2. **Auswahl:** Dem Nutzer nur den **Modellnamen** zeigen — nicht mehr
„Modell · Karte · Box". Wo es läuft, entscheidet der Cluster.
3. **Kontextfenster:** `ctx` aus der Liste übernehmen statt einen eigenen Wert
einzustellen — und **danach kürzen**, nicht nur anzeigen. Die Box gibt nur
so viel her, wie dort steht; rechnet ein Programm mit mehr, kommt
`context_exceeded`. Den Fehler zusätzlich abfangen: kürzen und nochmal
senden (`promptTokens`/`ctx` stehen dabei). Tokens grob schätzen reicht
nicht immer — Code und Deutsch brauchen mehr Tokens pro Zeichen als
englischer Fließtext.
4. **Anfrage:** `llm_cluster_request` statt `llm_request`; **kein**
`targetInstance` mehr. Die Antwort kommt als `llm_cluster_response`.
5. **Fehler:** `ok: false` als Fehler anzeigen, nicht als Antwort in den
Verlauf. Neue Codes siehe Tabelle oben.
6. **Lesen:** ein Leser pro Verbindung, Antworten per `requestId` zuordnen.
Felder und Format von `messages`, `tools`, `tool_calls` und `usage` bleiben
gleich.
---
## B) OpenAI-RVS-Wrapper — für Programme, die nur OpenAI können
Ein kleiner Container, der **auf demselben Rechner** wie das Programm läuft.
Für das Programm ist der Cluster dann ein ganz normaler OpenAI-Endpunkt:
```
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
```
Liegt im Repo unter [`xtts/openai_rvs_wrapper/`](../xtts/openai_rvs_wrapper/):
```bash
cd xtts/openai_rvs_wrapper
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
docker compose up -d --build
```
Im Browser zeigt **http://127.0.0.1:8099/** eine Hilfsseite: ob die Verbindung
steht und welche Modelle es gibt. Im Programm eintragen:
| Feld | Wert |
|---|---|
| Base URL | `http://127.0.0.1:8099/v1` |
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
| Modell | ein Name von der Hilfsseite |
| Kontextfenster | der Wert von der Hilfsseite |
- `GET /v1/models` — jedes Modell einmal, mit `context_length`
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
Rutsch) und `tools`
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504
- Antwort-Header `X-Aria-Node` / `X-Aria-Gpu` sagen, wer geantwortet hat
- **Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
Container. Dann das Docker-Netz `aria-llm` anhängen und
`http://openai-rvs-wrapper:8099/v1` nehmen (Details in der README des Wrappers).
Mit dem offiziellen OpenAI-Python-SDK getestet (`models.list`,
`chat.completions.create` mit und ohne Stream, Fehlerklassen).
---
## C) Direkt an eine Box — Sonderfall
Ohne den Cluster, eine bestimmte Box gezielt. Keine Verteilung, kein
Ausfall-Ersatz. Nur nötig, wenn ein Programm bewusst eine bestimmte Box will
oder ARIA nicht läuft.
**Modelle der Boxen:** `llm_models_request` `{requestId}` → **jede** Box
antwortet einzeln mit `llm_models` `{requestId, instanceId, node, models:
[{model, gpu, gpuName, ctx, state}]}`. Ein paar Sekunden einsammeln.
**Anfrage:** `llm_request` `{requestId, targetInstance, model, messages, …}`
→ `llm_response` `{requestId, ok, content, tool_calls, model, instanceId,
node, gpu, usage, elapsedMs}`. Bei Fehler `errorCode`:
`model_not_loaded` (mit `loadedModels`) oder `no_model_loaded`.
Ohne `targetInstance` antworten nur Boxen, die das Modell geladen haben — die
anderen schweigen. Hat keine es, kommt **keine** Antwort. Darum hier immer
`targetInstance` setzen.
**OpenAI-Port einer Box:** Jede Box hat zusätzlich einen eigenen
OpenAI-Endpunkt (`http://<IP der Box>:8081/v1`, Standard nur `localhost` der
Box). Er sieht nur diese Box; nicht geladene Modelle → HTTP 404. Für mehrere
Boxen den Wrapper (B) nehmen.
---
## Was Programme nicht senden
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
ARIA-Diagnostic vorbehalten. Technisch könnte jeder im Raum sie senden — der
RVS unterscheidet die Teilnehmer nicht. Ein Programm, das sich nicht daran
hält, tauscht anderen das Modell unter den Füßen weg.
## Gleichzeitige Nutzung und Geschwindigkeit
- Mehrere Anfragen an dasselbe Modell laufen auf einer Karte **parallel**.
Jede einzelne wird etwas langsamer, in Summe schafft die Karte mehr.
- Liegt ein Modell auf mehreren Karten, schickt der Cluster neue Anfragen an
die Karte mit den wenigsten laufenden.
- Der Kontext (`ctx`) ist bei aktuellen llama.cpp-Versionen ein **gemeinsamer
Vorrat** aller gleichzeitigen Anfragen auf einer Karte. Schicken mehrere
Programme gleichzeitig sehr lange Verläufe, wird es dort eng.
## Wenn sich etwas ändert
- Lädt der Betreiber ein anderes Modell in eine Karte, ist das alte dort weg.
Liegt es noch auf einer anderen Karte, merkt ein Programm nichts; sonst kommt
`model_unavailable` — dann die Liste neu holen.
- Beim Laden oder Entladen startet die betroffene Box **alle** ihre Modelle
kurz neu (wenige Sekunden). Anfragen in diesem Moment warten oder gehen an
eine andere Box.
- Diese Schnittstelle wird erweitert, nicht umgebaut: Neue Felder können
dazukommen. Unbekannte Felder ignorieren.
+10 -1
View File
@@ -18,7 +18,16 @@ 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 \
# nssm.cc faellt oefter aus (503) — dann Ersatz aus dem Internet Archive.
# Beide liefern dieselbe Datei; die Pruefsumme stellt das sicher.
ARG NSSM_SHA256=727d1e42275c605e0f04aba98095c38a8e1e46def453cdffce42869428aa6743
RUN for url in https://nssm.cc/release/nssm-2.24.zip \
"https://web.archive.org/web/2024id_/https://nssm.cc/release/nssm-2.24.zip"; do \
curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors "$url" -o /tmp/nssm.zip \
&& echo "$NSSM_SHA256 /tmp/nssm.zip" | sha256sum -c - && break; \
echo "nssm: $url fehlgeschlagen, versuche naechste Quelle" >&2; rm -f /tmp/nssm.zip; \
done \
&& test -f /tmp/nssm.zip \
&& unzip -q /tmp/nssm.zip -d /tmp \
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
&& rm -rf /tmp/nssm*
+4 -3
View File
@@ -142,12 +142,13 @@ Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
## Release (Binaries als Gitea-Assets)
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
`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)
./release_agent.sh # Version automatisch +1 (0.0.1.6 → 0.0.1.7)
./release_agent.sh 0.2.0 # Version selbst setzen; Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh # ohne Windows (schneller)
```
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
+17 -1
View File
@@ -246,7 +246,7 @@ es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh
```bash
cd host-agent
./release_agent.sh 0.2.0
./release_agent.sh # Version automatisch +1 (oder: ./release_agent.sh 0.2.0)
```
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
@@ -268,3 +268,19 @@ einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
## Auto-Update
Der Agent fragt beim Verbinden (und danach alle ~6 h) den RVS nach einer neueren
Version (`agent_update_check`). Liegt auf dem RVS eine neuere APK in
`rvs/updates/agent/`, lädt er sie im Hintergrund per HTTP
(`/update/agent/latest.apk`, Token im Header) und zeigt
**„ARIA Host-Agent x.y bereit — Tippen zum Installieren"** — zusätzlich einen
Knopf in der App. Den Install-Dialog zeigt Android selbst; still installieren
darf eine normale App nicht. Beim ersten Mal fragt Android, ob die App
„unbekannte Apps installieren" darf.
`release_agent.sh` kopiert die APK automatisch auf den RVS, wenn `RVS_UPDATE_HOST`
gesetzt ist (wie bei `release.sh`). Manuell:
`scp aria-android-agent.apk <rvs-host>:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v<version>.apk`
— die Version muss im Dateinamen stehen.
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 105
versionName '0.0.1.5'
versionCode 107
versionName '0.0.1.7'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
@@ -9,6 +9,8 @@
<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" />
<!-- Auto-Update: neue Agent-APK vom RVS installieren (Android fragt trotzdem nach) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
@@ -60,6 +62,17 @@
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Auto-Update: geladene APK an den Android-Installer uebergeben -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".AgentService"
android:exported="false"
@@ -32,6 +32,11 @@ class AgentService : Service() {
@Volatile var status: String = "gestoppt"
@Volatile var connected: Boolean = false
/** App-Oberflaeche auffrischen (z.B. "Update bereit"-Knopf) */
fun broadcastStatus(ctx: Context) {
ctx.sendBroadcast(Intent(ACTION_STATUS).setPackage(ctx.packageName))
}
fun start(ctx: Context) {
val i = Intent(ctx, AgentService::class.java)
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
@@ -0,0 +1,145 @@
package de.hackersoft.ariaagent
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.content.FileProvider
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Auto-Update fuer den Host-Agent (Android / Fire TV).
*
* 1. RvsClient fragt beim Verbinden (und alle paar Stunden) per
* agent_update_check nach; der RVS antwortet mit agent_update_available.
* 2. Wir laden die APK im Hintergrund per HTTP vom RVS
* (/update/agent/latest.apk, Token im Header) — sie ist nur ein paar MB.
* 3. Dann eine Benachrichtigung "Tippen zum Installieren" (und ein Knopf in
* der App). Den Install-Dialog zeigt Android selbst — still installieren
* darf eine normale App nicht, ein Tippen braucht es immer.
*/
object AgentUpdater {
private const val CH = "aria_agent_update"
private const val NOTIF_ID = 4711
private const val DIR = "updates"
/** Fertig geladene, noch nicht installierte Version ("" = keine) */
@Volatile var readyVersion: String = ""
private set
@Volatile private var downloading = false
private val http = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) // Abbruch nur, wenn der Download haengt
.build()
fun installedVersion(ctx: Context): String = try {
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0"
} catch (_: Exception) { "0" }
/** a > b ? (Versionen wie 0.0.1.6) */
fun isNewer(a: String, b: String): Boolean {
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(pa.size, pb.size)) {
val d = pa.getOrElse(i) { 0 } - pb.getOrElse(i) { 0 }
if (d != 0) return d > 0
}
return false
}
private fun apkFile(ctx: Context, version: String) =
File(File(ctx.cacheDir, DIR).apply { mkdirs() }, "aria-host-agent-v$version.apk")
/** RVS meldet eine neue Version → im Hintergrund laden, dann melden. */
fun onAvailable(ctx: Context, config: AgentConfig, version: String, size: Long) {
val app = ctx.applicationContext
if (!isNewer(version, installedVersion(app))) return
val file = apkFile(app, version)
if (readyVersion == version && file.exists()) { notifyReady(app, version); return }
if (downloading) return
downloading = true
Thread {
try {
// Reste frueherer Versionen wegraeumen
File(app.cacheDir, DIR).listFiles()?.forEach { if (it != file) it.delete() }
val proto = if (config.tls) "https" else "http"
val req = Request.Builder()
.url("$proto://${config.host}:${config.port}/update/agent/latest.apk")
.header("X-RVS-Token", config.token)
.build()
var lastErr = ""
for (attempt in 1..3) {
try {
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) throw IllegalStateException("HTTP ${res.code}")
val body = res.body ?: throw IllegalStateException("leere Antwort")
val expected = body.contentLength()
val tmp = File(file.path + ".part")
body.byteStream().use { input -> tmp.outputStream().use { input.copyTo(it) } }
if (expected > 0 && tmp.length() != expected) {
throw IllegalStateException("unvollstaendig (${tmp.length()} von $expected)")
}
if (!tmp.renameTo(file)) throw IllegalStateException("Speichern fehlgeschlagen")
}
readyVersion = version
notifyReady(app, version)
AgentService.broadcastStatus(app)
return@Thread
} catch (e: Exception) {
lastErr = e.message ?: e.toString()
try { Thread.sleep(3000L * attempt) } catch (_: InterruptedException) { return@Thread }
}
}
android.util.Log.w("AgentUpdater", "Update $version nicht geladen: $lastErr")
} finally {
downloading = false
}
}.start()
}
/** Install-Dialog von Android oeffnen (aus Benachrichtigung oder App-Knopf). */
fun install(ctx: Context): Boolean {
val v = readyVersion
if (v.isEmpty()) return false
val file = apkFile(ctx, v)
if (!file.exists()) { readyVersion = ""; return false }
// Android 8+: "Unbekannte Apps installieren" muss fuer DIESE App erlaubt sein
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ctx.packageManager.canRequestPackageInstalls()) {
ctx.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${ctx.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return true
}
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
ctx.startActivity(Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK))
return true
}
private fun notifyReady(ctx: Context, version: String) {
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(NotificationChannel(CH, "Updates", NotificationManager.IMPORTANCE_DEFAULT))
}
val open = PendingIntent.getActivity(ctx, 1,
Intent(ctx, MainActivity::class.java).setAction(MainActivity.ACTION_INSTALL_UPDATE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
nm.notify(NOTIF_ID, NotificationCompat.Builder(ctx, CH)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("ARIA Host-Agent $version bereit")
.setContentText("Tippen zum Installieren")
.setContentIntent(open)
.setAutoCancel(true)
.build())
}
}
@@ -26,6 +26,11 @@ import org.json.JSONObject
class MainActivity : AppCompatActivity() {
companion object {
/** Aus der "Update bereit"-Benachrichtigung: gleich den Install-Dialog oeffnen */
const val ACTION_INSTALL_UPDATE = "de.hackersoft.ariaagent.INSTALL_UPDATE"
}
private lateinit var host: EditText
private lateinit var port: EditText
private lateinit var token: EditText
@@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() {
tls = findViewById(R.id.tls)
control = findViewById(R.id.control)
statusView = findViewById(R.id.status)
findViewById<Button>(R.id.btnUpdate).setOnClickListener {
if (!AgentUpdater.install(this)) toast("Kein Update bereit")
}
if (intent?.action == ACTION_INSTALL_UPDATE) AgentUpdater.install(this)
findViewById<Button>(R.id.btnScan).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
private fun refreshStatus() {
val dot = if (AgentService.connected) "🟢" else "🔴"
statusView.text = "Status: ${AgentService.status} $dot"
statusView.text = "Status: ${AgentService.status} $dot · v${AgentUpdater.installedVersion(this)}"
val ready = AgentUpdater.readyVersion
findViewById<Button>(R.id.btnUpdate).apply {
visibility = if (ready.isNotEmpty()) android.view.View.VISIBLE else android.view.View.GONE
text = "Update auf $ready installieren"
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent == null) return
setIntent(intent)
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
applyProvisioning(intent)
}
@@ -87,6 +87,7 @@ class RvsClient(
override fun onOpen(webSocket: WebSocket, response: Response) {
onStatus(true, "verbunden")
sendHello(webSocket)
sendUpdateCheck(webSocket)
startPing(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -141,9 +142,17 @@ class RvsClient(
localIp()?.let { p.put("ip", it) }
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
p.put("version", AgentUpdater.installedVersion(appCtx))
send(webSocket, "host_hello", p)
}
/** Beim RVS nach einer neueren Agent-Version fragen (Antwort: agent_update_available) */
private fun sendUpdateCheck(webSocket: WebSocket) {
send(webSocket, "agent_update_check", JSONObject()
.put("version", AgentUpdater.installedVersion(appCtx))
.put("platform", "android"))
}
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
private fun localIp(): String? = try {
java.net.NetworkInterface.getNetworkInterfaces().toList()
@@ -153,6 +162,8 @@ class RvsClient(
?.hostAddress
} catch (_: Throwable) { null }
private var pingCount = 0
private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt()
pingThread = Thread {
@@ -161,6 +172,8 @@ class RvsClient(
val p = JSONObject().put("hostId", config.hostId())
send(webSocket, "host_ping", p)
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
// Alle ~6 h nach Updates fragen (25 s × 864)
if (++pingCount % 864 == 0) sendUpdateCheck(webSocket)
}
}.also { it.start() }
}
@@ -171,6 +184,11 @@ class RvsClient(
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
if (type == "agent_update_available") {
val p = msg.optJSONObject("payload") ?: JSONObject()
AgentUpdater.onAvailable(appCtx, config, p.optString("version"), p.optLong("size"))
return
}
if (type != "host_command") {
if (type in VOICE_TYPES) {
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
@@ -25,6 +25,15 @@
android:text="Status: —"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnUpdate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="false"
android:visibility="gone"
android:text="Update installieren" />
<Button
android:id="@+id/btnScan"
android:layout_width="match_parent"
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Auto-Update: nur der Update-Ordner im Cache wird an den Installer freigegeben -->
<paths>
<cache-path name="updates" path="updates/" />
</paths>
+1 -1
View File
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
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.1.5"
AGENT_VERSION = "0.0.1.7"
HEARTBEAT_SEC = 25
+52 -4
View File
@@ -5,7 +5,8 @@
# 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)
# Verwendung: ./release_agent.sh (Version automatisch +1)
# ./release_agent.sh 0.1.0.0 (Version selbst setzen)
#
# Artefakte:
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
@@ -31,8 +32,38 @@ ROOT_DIR="$(cd .. && pwd)"
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)"}
# Ohne Angabe: letzte Stelle automatisch hochzaehlen. Mit Angabe: genau diese.
# Naechste Version: hoechste aus Datei-Version und letztem Git-Tag, dann wie
# ein Kilometerzaehler +1: hintere Stellen laufen 0..9 und uebertragen nach
# vorne (0.2.6.9 → 0.2.7.0, 0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach
# weiter (9.9.9.9 → 10.0.0.0). Aufruf: next_version <aktuelle> <tag-prefix>
next_version() {
local cur="$1" prefix="$2" tagv best
tagv=$(git tag -l "${prefix}*" | sed "s/^${prefix}//" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
best=$(printf '%s\n%s\n' "$cur" "$tagv" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
[ -z "$best" ] && best="0.0.0.0"
awk -F. -v OFS=. '{
for (i = NF; i >= 1; i--) {
if (i == 1 || $i < 9) { $i = $i + 1; break }
$i = 0 # 9 → 0, Uebertrag nach vorne
}
print
}' <<< "$best"
}
CURRENT=$(grep -m1 "versionName" android/app/build.gradle | sed -E "s/.*versionName '([^']*)'.*/\1/")
VERSION=${1:-$(next_version "$CURRENT" "agent-v")}
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
echo -e "${RED}Ungueltige Version: $VERSION (z.B. 0.0.1.7)${NC}"; exit 1
fi
TAG="agent-v$VERSION"
if git rev-parse "$TAG" &>/dev/null; then
echo -e "${RED}Tag $TAG gibt es schon — andere Version angeben.${NC}"; exit 1
fi
echo -e "${CYAN}Agent-Version: $CURRENT → ${GREEN}$VERSION${NC}"
read -p "Release $VERSION bauen? [J/n] " OK
[[ "$OK" =~ ^[nN] ]] && { echo "Abgebrochen."; exit 0; }
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
@@ -86,11 +117,13 @@ 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
WIN_LOG="$SCRIPT_DIR/dist/build-win.log"
if ./build-win.sh "$VERSION" >"$WIN_LOG" 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}"
echo -e " ${YELLOW}Letzte Zeilen (ganzes Log: $WIN_LOG):${NC}"
tail -n 8 "$WIN_LOG" | sed 's/^/ /'
fi
fi
echo ""
@@ -143,6 +176,21 @@ for entry in "${ASSETS[@]}"; do
fi
done
# ── Auto-Update: Android-APK auf den RVS (eigener Ordner updates/agent/) ──
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
if [ -n "$RVS_UPDATE_HOST" ]; then
echo -e "\n${GREEN}[+] Android-APK auf den RVS kopieren (Auto-Update)...${NC}"
if ssh "$RVS_UPDATE_HOST" "mkdir -p ~/ARIA-AGENT/rvs/updates/agent && rm -f ~/ARIA-AGENT/rvs/updates/agent/*.apk" 2>/dev/null \
&& scp "$APK" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk" 2>/dev/null; then
echo -e " ${GREEN}✓${NC} Agents bekommen v$VERSION beim naechsten Verbinden angeboten"
else
echo -e " ${YELLOW}Kopieren fehlgeschlagen (RVS_UPDATE_HOST=$RVS_UPDATE_HOST). Manuell:${NC}"
echo -e " ${YELLOW}scp $APK $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk${NC}"
fi
else
echo -e "\n${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}"
fi
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
+33 -2
View File
@@ -2,7 +2,8 @@
# ════════════════════════════════════════════════
# ARIA — Release Script
# Baut APK, taggt, erstellt Gitea Release
# Verwendung: ./release.sh 1.2.0
# Verwendung: ./release.sh (Version automatisch +1)
# ./release.sh 0.3.0.0 (Version selbst setzen)
# Gitea-Kennwort wird interaktiv abgefragt.
# ════════════════════════════════════════════════
@@ -16,8 +17,38 @@ CYAN='\033[0;36m'
NC='\033[0m'
# ── Parameter ─────────────────────────────────
VERSION=${1:?"Usage: ./release.sh <version> (z.B. 1.2.0)"}
# Ohne Angabe: letzte Stelle automatisch hochzaehlen. Mit Angabe: genau diese.
# Naechste Version: hoechste aus Datei-Version und letztem Git-Tag, dann wie
# ein Kilometerzaehler +1: hintere Stellen laufen 0..9 und uebertragen nach
# vorne (0.2.6.9 → 0.2.7.0, 0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach
# weiter (9.9.9.9 → 10.0.0.0). Aufruf: next_version <aktuelle> <tag-prefix>
next_version() {
local cur="$1" prefix="$2" tagv best
tagv=$(git tag -l "${prefix}*" | sed "s/^${prefix}//" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
best=$(printf '%s\n%s\n' "$cur" "$tagv" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
[ -z "$best" ] && best="0.0.0.0"
awk -F. -v OFS=. '{
for (i = NF; i >= 1; i--) {
if (i == 1 || $i < 9) { $i = $i + 1; break }
$i = 0 # 9 → 0, Uebertrag nach vorne
}
print
}' <<< "$best"
}
CURRENT=$(grep -m1 '"version"' android/package.json | sed -E 's/.*"version": *"([^"]*)".*/\1/')
VERSION=${1:-$(next_version "$CURRENT" "v")}
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
echo -e "${RED}Ungueltige Version: $VERSION (z.B. 0.2.6.2)${NC}"; exit 1
fi
TAG="v$VERSION"
if git rev-parse "$TAG" &>/dev/null; then
echo -e "${RED}Tag $TAG gibt es schon — andere Version angeben.${NC}"; exit 1
fi
echo -e "${CYAN}App-Version: $CURRENT → ${GREEN}$VERSION${NC}"
read -p "Release $VERSION bauen? [J/n] " OK
[[ "$OK" =~ ^[nN] ]] && { echo "Abgebrochen."; exit 0; }
# ── Gitea-Konfiguration ──────────────────────
# Aus .env lesen falls vorhanden (GITEA_URL, GITEA_REPO, GITEA_USER)
+25 -4
View File
@@ -3,12 +3,33 @@
# Kopieren nach .env und Werte eintragen.
# ════════════════════════════════════════════════════════
# ── Wer macht TLS? ──────────────────────────────────────
# caddy = Caddy hier im Stack holt das Zertifikat selbst (Let's Encrypt)
# (leer) = OHNE Caddy: ein vorhandener Reverse-Proxy (nginx, Traefik ...)
# macht TLS und zeigt auf den RVS-Port unten
COMPOSE_PROFILES=caddy
# ── Modus caddy ─────────────────────────────────────────
# Oeffentlich erreichbarer DNS-Name dieses Servers. Caddy holt darauf ein
# Let's Encrypt-Zertifikat (HTTP-01 Challenge ueber Port 80) und routet
# WebSocket + HTTP weiter an den RVS-Container.
#
# WICHTIG:
# - Die Domain muss per DNS-A-Record/AAAA auf diese Maschine zeigen
# - Port 80 + 443 muessen vom Internet aus erreichbar sein
# - Kein anderer Reverse-Proxy davor (sonst Cert-Konflikt)
# - Port 80 muss von aussen HIERHER gehen (sonst laeuft das Zertifikat
# nach 90 Tagen still ab — Fritzbox: Freigabe auf 80 pruefen!)
PUBLIC_URL=rvs.example.de
# Port, auf dem Caddy HTTPS anbietet (Apps/Satelliten: dieser Port)
CADDY_HTTPS_PORT=444
# ── Modus ohne Caddy ────────────────────────────────────
# Unverschluesselter RVS-Port, auf den der Reverse-Proxy zeigt.
# 127.0.0.1 = nur von diesem Rechner. Steht der Proxy woanders:
# 0.0.0.0 bzw. die interne IP — und den Port per Firewall NUR fuer
# den Proxy freigeben (Klartext!).
RVS_HTTP_BIND=127.0.0.1
RVS_HTTP_PORT=3000
# Auch die Strecke Proxy → RVS verschluesseln: der RVS spricht dann HTTPS
# auf RVS_HTTP_PORT, mit selbstsigniertem Zertifikat (wird beim ersten Start
# in ./data/rvs-tls/ erzeugt; eigenes rvs.crt/rvs.key darf dort liegen).
# Im Proxy dann https://<ip>:<port> als Ziel, Zertifikat NICHT pruefen.
# Nur ohne Caddy — der Caddy im Stack spricht den RVS per HTTP an.
RVS_HTTP_TLS=false
+3
View File
@@ -2,6 +2,9 @@ FROM node:22-alpine
WORKDIR /app
# openssl: erzeugt bei RVS_HTTP_TLS=true das interne Zertifikat
RUN apk add --no-cache openssl
# Erst package.json kopieren — Docker-Cache für npm install nutzen
COPY package.json ./
RUN npm install --production
+22 -15
View File
@@ -1,30 +1,36 @@
# ════════════════════════════════════════════════════════
# ARIA RVS Stack — WebSocket Relay + OAuth Callback HTTP
# Caddy davor terminiert TLS via Let's Encrypt (HTTP-01
# Challenge ueber Port 80). OAuth-Provider wie Spotify
# verlangen HTTPS fuer non-localhost Redirect-URIs.
# Apps/Satelliten/Boxen brauchen TLS (wss://), OAuth-Provider
# wie Spotify HTTPS. Wer TLS macht, waehlt die .env:
#
# A) COMPOSE_PROFILES=caddy — Caddy hier im Stack holt das
# Zertifikat selbst (Let's Encrypt, HTTP-01 ueber Port 80).
# Voraussetzung: Port 80 + CADDY_HTTPS_PORT von aussen auf
# diese Maschine, PUBLIC_URL zeigt per DNS hierher.
#
# B) COMPOSE_PROFILES= — OHNE Caddy. Ein vorhandener
# Reverse-Proxy (nginx, Traefik, ...) macht TLS und zeigt auf
# den unverschluesselten RVS-Port (RVS_HTTP_BIND:RVS_HTTP_PORT).
# nginx-Beispiel: README → RVS → "Ohne Caddy".
# ════════════════════════════════════════════════════════
#
# Voraussetzungen:
# - Port 80 + 443 frei (kein anderer Reverse-Proxy davor)
# - Domain (PUBLIC_URL) zeigt per DNS auf diese Maschine
# - .env mit PUBLIC_URL gesetzt
#
# Start: docker compose up -d
# Wenn Du einen eigenen TLS-Terminator nutzt (z.B. nginx,
# externer Caddy): caddy-service auskommentieren und
# rvs-Container den ports-Block geben (3000 → public Port).
services:
rvs:
build: .
restart: always
# KEIN ports-Block — Caddy ist davor, RVS nur intern
# via aria-rvs-net erreichbar. Wenn Du Caddy nicht nutzt,
# diesen ports-Block reaktivieren: ports: ["${RVS_PORT:-443}:3000"]
# Unverschluesselter RVS-Port fuer einen externen Reverse-Proxy (Modus B).
# Default nur 127.0.0.1 — steht der Proxy auf einem ANDEREN Rechner,
# RVS_HTTP_BIND=0.0.0.0 (oder die interne IP) setzen und den Port per
# Firewall nur fuer den Proxy freigeben: hier laeuft alles im Klartext.
ports:
- "${RVS_HTTP_BIND:-127.0.0.1}:${RVS_HTTP_PORT:-3000}:3000"
volumes:
- ./updates:/updates # APK-Dateien fuer Auto-Update
- ./data/rvs-tls:/certs # internes Zertifikat (RVS_HTTP_TLS)
environment:
- RVS_HTTP_TLS=${RVS_HTTP_TLS:-false}
- MAX_SESSIONS=10
# 4 GB V8-Heap — sonst OOM beim Empfang von 1 GB-Files
# (base64 inflated ~1.34 GB plus WS-Frame-Margin).
@@ -39,10 +45,11 @@ services:
# damit Restart nicht jedes Mal ein neues Cert holt (Rate-Limit!).
caddy:
image: caddy:latest
profiles: ["caddy"] # nur mit COMPOSE_PROFILES=caddy
restart: always
ports:
- "80:80"
- "444:443"
- "${CADDY_HTTPS_PORT:-444}:443"
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
volumes:
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)
+226 -9
View File
@@ -2,6 +2,8 @@
const { WebSocketServer } = require("ws");
const http = require("http");
const https = require("https");
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
@@ -9,6 +11,15 @@ const path = require("path");
const PORT = parseInt(process.env.PORT || "3000", 10);
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
// Host-Agent (Android) hat einen EIGENEN Ordner — sonst bekaeme die Cockpit-App
// die Agent-APK angeboten (die Versions-Erkennung schaut nur auf den Dateinamen).
const AGENT_UPDATES_DIR = path.join(UPDATES_DIR, "agent");
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
// (der spricht den RVS per HTTP an).
const HTTP_TLS = /^(1|true|yes|ja)$/i.test(process.env.RVS_HTTP_TLS || "");
const TLS_DIR = process.env.RVS_TLS_DIR || "/certs";
// Kein Polling — APK wird manuell per git pull bereitgestellt
// Erlaubte Nachrichtentypen — alles andere wird verworfen
@@ -16,7 +27,7 @@ const ALLOWED_TYPES = new Set([
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
"update_check", "update_available", "update_download", "update_data",
"update_check", "update_available", "update_none", "update_download", "update_data",
"agent_activity", "cancel_request", "interject",
"audio_pcm",
"file_from_aria",
@@ -124,6 +135,24 @@ const ALLOWED_TYPES = new Set([
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
"llm_openai_config", "llm_openai_config_result",
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
"llm_provision_model", "llm_provision_result", "llm_provision_progress",
"llm_remove_model",
// Karten: Diagnostic leert eine LLM-Karte; Clients fragen, welche Modelle
// auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models).
// Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md
"llm_unload_model", "llm_models_request", "llm_models",
// LLM-Cluster: Clients fragen MODELLE, die ARIA-Bridge waehlt die Box
// (Last, Ausfall-Ersatz) und antwortet. Empfohlener Weg fuer Programme.
"llm_cluster_models_request", "llm_cluster_models",
"llm_cluster_request", "llm_cluster_response",
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
"client_hello", "room_clients_query", "room_clients",
// Host-Agent (Android): Update-Pruefung, beantwortet der RVS selbst
"agent_update_check", "agent_update_available",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
@@ -161,7 +190,7 @@ const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000;
function pendingKey(replyType, requestId) {
return `${replyType}${requestId}`;
return `${replyType}\u0000${requestId}`;
}
/** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */
@@ -261,7 +290,25 @@ function cleanupRooms() {
// WICHTIG: Node-Default-Heap ist ~1.5 GB. Fuer 1 GB-Files muss der
// Container mit --max-old-space-size=4096 (oder NODE_OPTIONS env var)
// gestartet werden, sonst OOM-Crash beim Empfang.
const httpServer = http.createServer(handleHttpRequest);
/** Zertifikat fuer den internen HTTPS-Port: vorhandenes aus TLS_DIR nehmen
* (eigenes darf man dort ablegen), sonst einmalig selbstsigniert erzeugen. */
function loadInternalTls() {
const key = path.join(TLS_DIR, "rvs.key");
const crt = path.join(TLS_DIR, "rvs.crt");
if (!fs.existsSync(key) || !fs.existsSync(crt)) {
fs.mkdirSync(TLS_DIR, { recursive: true });
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256",
"-days", "3650", "-subj", "/CN=aria-rvs-intern", "-keyout", key, "-out", crt],
{ stdio: "ignore" });
fs.chmodSync(key, 0o600);
log(`Internes TLS-Zertifikat erzeugt: ${crt} (selbstsigniert, 10 Jahre)`);
}
return { key: fs.readFileSync(key), cert: fs.readFileSync(crt) };
}
const httpServer = HTTP_TLS
? https.createServer(loadInternalTls(), handleHttpRequest)
: http.createServer(handleHttpRequest);
const wss = new WebSocketServer({ noServer: true, maxPayload: 1500 * 1024 * 1024 });
// HTTP-Upgrade-Pfad → an WebSocket-Server reichen
@@ -272,7 +319,7 @@ httpServer.on("upgrade", (req, socket, head) => {
});
httpServer.listen(PORT, () => {
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
log(`RVS läuft auf Port ${PORT} (${HTTP_TLS ? "HTTPS + WSS, internes Zertifikat" : "HTTP + WS"}) | Max Sessions: ${MAX_SESSIONS}`);
// Beim Start pruefen ob eine APK da ist
const apkInfo = getLatestAPK();
if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`);
@@ -361,6 +408,23 @@ function handleHttpRequest(req, res) {
return;
}
// Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks):
// Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter
// /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token).
if ((req.method === "GET" || req.method === "HEAD")
&& (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) {
const tok = String(req.headers["x-rvs-token"] || "");
if (!tok || !rooms.has(tok)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden\n");
return;
}
const isAgent = pathname.startsWith("/update/agent/");
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
isAgent ? "aria-host-agent" : "ARIA");
return;
}
// Health-Endpoint
if (req.method === "GET" && pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
@@ -383,6 +447,11 @@ function escapeHtml(s) {
}
wss.on("connection", (ws, req) => {
// Fuer die Raum-Liste: Herkunft (hinter einem Reverse-Proxy steht die echte
// Adresse in X-Forwarded-For) und Programm-Kennung
const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
ws._ip = (xff || req.socket.remoteAddress || "").replace(/^::ffff:/, "");
ws._ua = String(req.headers["user-agent"] || "").slice(0, 120);
// Token aus URL-Query lesen: ws://host:port/?token=abc123
const url = new URL(req.url, `http://${req.headers.host}`);
let token = url.searchParams.get("token");
@@ -407,6 +476,81 @@ wss.on("connection", (ws, req) => {
registerClient(ws, token);
});
/** Wer steckt hinter einer Verbindung? Aus Nachrichten, die ohnehin fliessen
* (sat_hello, host_hello, worker_hello, update_check der App, Cluster-
* Anfragen) oder aus einem expliziten client_hello. Nur zur Anzeige. */
function learnIdentity(ws, msg) {
const p = msg.payload || {};
const id = ws._ident;
const set = (kind, name, detail, version) => {
// client_hello gewinnt; abgeleitete Angaben ueberschreiben es nicht
if (id.explicit && msg.type !== "client_hello") return;
id.kind = kind;
id.name = String(name || "").slice(0, 80);
id.detail = String(detail || "").slice(0, 120);
if (version) id.version = String(version).slice(0, 40);
};
switch (msg.type) {
case "client_hello":
id.explicit = true;
set(p.kind || "Client", p.name, p.detail, p.version);
break;
case "sat_hello":
set("Satellit", p.id, p.location, "");
break;
case "host_hello":
set("Host-Agent", p.name || p.hostId, p.os, p.version);
break;
case "worker_hello": {
const svc = p.service || "?";
if (!id.services) id.services = new Set();
id.services.add(svc);
set("KI-Box", p.node || p.instanceId, [...id.services].join(", ") + (p.gpus ? ` · GPU ${p.gpus}` : ""), "");
break;
}
case "update_check":
if (!id.kind) set("App", "", "", p.version);
else if (p.version && !id.version) id.version = String(p.version).slice(0, 40);
break;
// Nachrichten, die nur die ARIA-App schickt — erkennt auch alte APKs
// ohne client_hello (sobald sie irgendetwas davon senden)
case "app_log":
if (!id.kind && /^(android|ios)$/.test(String(p.platform || ""))) set("App", "", `${p.platform}`, "");
break;
case "location_update":
case "stt_lease_request":
case "update_download":
case "vnc_open":
if (!id.kind) set("App", "", "", "");
break;
case "llm_cluster_request":
case "llm_cluster_models_request":
if (!id.kind) {
const rid = String(p.requestId || "");
set("LLM-Client", rid.includes("-") ? rid.split("-")[0] : "", "", "");
}
break;
}
}
function roomClients(ws) {
const room = rooms.get(ws._token);
const out = [];
if (!room) return out;
for (const c of room.clients) {
if (c.readyState !== 1) continue;
const id = c._ident || {};
out.push({
kind: id.kind || "", name: id.name || "", detail: id.detail || "",
version: id.version || "", ip: c._ip || "", userAgent: c._ua || "",
connectedAt: c._connectedAt || 0, lastSeen: c._lastSeen || 0,
you: c === ws,
});
}
out.sort((a, b) => (a.kind || "~").localeCompare(b.kind || "~") || a.name.localeCompare(b.name));
return out;
}
function registerClient(ws, token) {
// Maximale Anzahl aktiver Sessions prüfen
if (!rooms.has(token) && rooms.size >= MAX_SESSIONS) {
@@ -424,6 +568,9 @@ function registerClient(ws, token) {
const room = rooms.get(token);
room.clients.add(ws);
ws._token = token;
ws._ident = {};
ws._connectedAt = Date.now();
ws._lastSeen = ws._connectedAt;
log(`Client verbunden: ${token.slice(0, 8)}... (${room.clients.size} im Raum)`);
@@ -440,6 +587,18 @@ function registerClient(ws, token) {
if (!msg.type || !ALLOWED_TYPES.has(msg.type)) {
return;
}
ws._lastSeen = Date.now();
try { learnIdentity(ws, msg); } catch (_) {}
// Vorstellung bleibt beim RVS, Raum-Liste nur an den Fragenden
if (msg.type === "client_hello") return;
if (msg.type === "room_clients_query") {
ws.send(JSON.stringify({ type: "room_clients", payload: {
requestId: (msg.payload && msg.payload.requestId) || "", clients: roomClients(ws),
}, timestamp: Date.now() }));
return;
}
if (msg.type === "room_clients") return; // kommt nur vom RVS
// Update-Check: direkt an den anfragenden Client antworten (nicht relay'en)
if (msg.type === "update_check") {
@@ -455,6 +614,29 @@ function registerClient(ws, token) {
},
timestamp: Date.now(),
}));
} else {
// Auch "nichts Neues" beantworten — die App zeigt dann, dass der
// Update-Server erreichbar ist und welche Version er hat
ws.send(JSON.stringify({
type: "update_none",
payload: { latest: apkInfo ? apkInfo.version : "", current: clientVersion },
timestamp: Date.now(),
}));
}
return;
}
// Host-Agent (Android) fragt nach einem Update — direkt antworten
if (msg.type === "agent_update_check") {
const have = String(msg.payload?.version || "0.0.0.0");
const apk = getLatestAPK(AGENT_UPDATES_DIR);
if (apk && compareVersions(apk.version, have) > 0) {
ws.send(JSON.stringify({
type: "agent_update_available",
payload: { version: apk.version, downloadUrl: "/update/agent/latest.apk",
size: fs.statSync(apk.path).size },
timestamp: Date.now(),
}));
}
return;
}
@@ -599,15 +781,50 @@ wss.on("close", () => {
let latestVersion = null;
function getLatestAPK() {
/** APK per HTTP ausliefern: Content-Length (Fortschritt), Range (Fortsetzen). */
function serveApk(req, res, apk, label) {
if (!apk) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Keine APK verfuegbar\n");
return;
}
const size = fs.statSync(apk.path).size;
const headers = {
"Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": `attachment; filename="${label}-v${apk.version}.apk"`,
"Accept-Ranges": "bytes",
"Cache-Control": "no-store",
"X-APK-Version": apk.version,
};
let start = 0, end = size - 1, status = 200;
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
if (m && (m[1] || m[2])) {
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
else { start = Math.max(0, size - parseInt(m[2], 10)); }
if (start > end || start >= size) {
res.writeHead(416, { "Content-Range": `bytes */${size}` });
res.end();
return;
}
status = 206;
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
}
headers["Content-Length"] = end - start + 1;
res.writeHead(status, headers);
if (req.method === "HEAD") { res.end(); return; }
log(`APK-Download (HTTP) ${label} v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
fs.createReadStream(apk.path, { start, end }).pipe(res);
}
function getLatestAPK(dir = UPDATES_DIR) {
try {
if (!fs.existsSync(UPDATES_DIR)) return null;
const files = fs.readdirSync(UPDATES_DIR)
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir)
.filter(f => f.endsWith(".apk"))
.map(f => {
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/);
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
const match = f.match(/(\d+(?:\.\d+){2,})/);
return { file: f, path: path.join(dir, f), version: match ? match[1] : null };
})
.filter(f => f.version)
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst
+8 -1
View File
@@ -37,7 +37,14 @@ NODE_NAME=ai-box
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)
LLM_GPU=0 # lokales LLM auf EINER Karte (teilt sich ggf. die Karte mit F5-TTS)
# Mehrere Karten fuers LLM: Liste statt LLM_GPU. Jede Karte haelt genau EIN
# Modell, dauerhaft geladen — welches, legst du in der Diagnostic fest
# (Modell-Katalog → Laden → Karte). Clients (ARIA, Fremdprogramme, OpenHands)
# koennen nur die geladenen nutzen und nie selbst etwas laden.
# Beispiel 3 Karten: 0 = Whisper + F5-TTS, 1 und 2 nur LLM:
# WHISPER_GPU=0 F5TTS_GPU=0 LLM_GPUS=1,2
#LLM_GPUS=
# ─── RVS-Verbindung ───────────────────────────────────────
# WICHTIG: Host, Port UND Token muessen EXAKT mit dem ARIA-Stack (Bridge/
+27
View File
@@ -46,6 +46,33 @@ GPU-Test + Stack-Start.
| `--up` | am Ende `docker compose up -d --build` (Default-Profil) |
| `--token <TOK>` | `RVS_TOKEN` in `xtts/.env` eintragen (auch via `RVS_TOKEN=…` env) |
| `--rvs-host <H>` | `RVS_HOST` setzen |
| `--upgrade-driver` | NVIDIA-Treiber aus **NVIDIAs** Paketquelle für Debian 13 (siehe unten). Spielt erst durch, zeigt den Plan, fragt nach. Danach Reboot. |
| `--yes` | bei `--upgrade-driver` nicht nachfragen |
## Treiber für das lokale LLM: `--upgrade-driver`
Debian hat höchstens Treiber **550** (CUDA 12.4). Die aktuellen llama.cpp- und
llama-swap-Images brauchen CUDA 12.8/12.9. Mit 550 meldet llama.cpp beim Start
`failed to initialize CUDA … forward compatibility …`, findet **keine** Karte und
rechnet still auf der **CPU** — das Modell meldet trotzdem „bereit". Erkennbar
daran, dass `nvidia-smi` auf der LLM-Karte kaum Speicher belegt zeigt.
```bash
cd ARIA-AGENT/xtts
sudo ./bootstrap.sh --upgrade-driver
sudo reboot
nvidia-smi # zeigt den neuen Treiber und eine CUDA-Version >= 12.9
docker compose up -d
```
Das Script trägt NVIDIAs Paketquelle ein und aktualisiert Treiber, Kernelmodul,
`nvidia-smi` und `libcuda1` an Ort und Stelle (gleiche Paketnamen, neuere
Version). Alte Debian-Teilpakete, die in den neuen aufgegangen sind, werden
entfernt. Das **NVIDIA-Container-Toolkit bleibt unberührt** — würde apt es
anfassen wollen, bricht das Script ab, ohne etwas zu ändern. Ebenso, wenn apt
keinen sauberen Weg findet; die Paketquelle wird dann wieder entfernt.
Whisper und F5-TTS laufen mit dem neuen Treiber weiter (neuere Treiber können
ältere CUDA-Versionen).
## Wichtig
+188 -33
View File
@@ -11,8 +11,13 @@
#
# Optionen:
# --up am Ende den xtts-Stack starten (Default-Profil, OHNE voxtral)
# --upgrade-driver NVIDIA-Treiber aus trixie-backports (modernes CUDA fuer Voxtral).
# Danach REBOOT noetig. Default-Bootstrap laesst 550 unangetastet.
# --upgrade-driver NVIDIA-Treiber aus NVIDIAs eigener Paketquelle fuer Debian 13
# (Debian selbst hat max. 550 = CUDA 12.4 — zu alt fuer die
# aktuellen llama.cpp/llama-swap-Images, die CUDA 12.8/12.9
# brauchen; ohne Upgrade rechnet das LLM still auf der CPU).
# Spielt erst durch, zeigt den Plan, fragt nach. Danach REBOOT.
# Default-Bootstrap laesst den Treiber unangetastet.
# --yes bei --upgrade-driver nicht nachfragen
# --token <TOK> RVS_TOKEN in xtts/.env eintragen (alternativ: env RVS_TOKEN=...)
# --rvs-host <H> RVS_HOST setzen (Default aus .env.example)
#
@@ -24,12 +29,14 @@ set -euo pipefail
# ── CLI ──
DO_UP=0
DO_UPGRADE_DRIVER=0
ASSUME_YES=0
RVS_TOKEN_ARG="${RVS_TOKEN:-}"
RVS_HOST_ARG="${RVS_HOST:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--up) DO_UP=1; shift ;;
--upgrade-driver) DO_UPGRADE_DRIVER=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--token) RVS_TOKEN_ARG="${2:-}"; shift 2 ;;
--rvs-host) RVS_HOST_ARG="${2:-}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
@@ -94,50 +101,198 @@ fi
[[ $ENABLED_ANY -eq 0 ]] && ok "non-free schon aktiv"
apt-get update -qq # immer neu einlesen, damit der Kandidat sicher da ist
# ── 2b. (opt-in) Treiber-Upgrade via trixie-backports — fuer Voxtral/modernes CUDA ──
# Der Trixie-Standardtreiber (550, CUDA 12.4) ist zu alt fuer den modernen Stack
# (torch 2.13, vLLM). Backports bringt einen neueren, apt-verwalteten Treiber.
# ── 2b. (opt-in) Treiber-Upgrade ueber NVIDIAs Paketquelle fuer Debian 13 ──
# Debian (auch trixie-backports) hat hoechstens 550 = CUDA 12.4. Die aktuellen
# llama.cpp-/llama-swap-Images brauchen CUDA 12.8/12.9 → mit 550 meldet
# llama.cpp "failed to initialize CUDA … forward compatibility …", findet keine
# Karte und rechnet STILL auf der CPU ("ready" meldet es trotzdem).
# NVIDIAs Quelle hat die Pakete unter denselben Namen, nur neuer — apt
# aktualisiert an Ort und Stelle. Alte Debian-Teilpakete (nvidia-driver-bin,
# nvidia-alternative, …) sind in den neuen aufgegangen und werden entfernt.
# Das Container-Toolkit bleibt unberuehrt — ohne es saehe kein Container die GPU,
# darum bricht das Script ab, falls apt es anfassen wollte.
# NUR mit --upgrade-driver, damit ein laufendes Setup nicht ungewollt angefasst wird.
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
step "NVIDIA-Treiber-Upgrade (trixie-backports)"
BP_FILE="/etc/apt/sources.list.d/backports.sources"
if ! grep -rqs "trixie-backports" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
cat > "$BP_FILE" <<'EOF'
Types: deb
URIs: http://deb.debian.org/debian
Suites: trixie-backports
Components: main contrib non-free non-free-firmware
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
ok "trixie-backports hinzugefuegt"
else
ok "trixie-backports bereits aktiv"
NV_REPO="https://developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64"
MIN_DRIVER_MAJOR=575 # CUDA 12.9 braucht mindestens Treiber 575
# Nachbereitung nach dem Wechsel auf NVIDIAs Pakete. Zwei Stolperfallen, die
# erst bei der echten Installation sichtbar wurden (nicht in apt -s):
# 1. NVIDIAs Paket "nvidia-smi" ist nur noch ein leeres Doku-Paket — das
# Programm (und Bibliotheken, die das Container-Toolkit braucht) steckt in
# "nvidia-driver-cuda". Ohne: kein nvidia-smi, Container ohne GPU.
# 2. /etc/modprobe.d/nvidia.conf (sperrt den freien nouveau-Treiber) war
# vorher ein Verweis ueber das Alternativen-System. Die entfernten
# Altpakete nehmen ihn mit, dpkg legt die neue Datei nur als .dpkg-new ab.
# Ohne Sperre laedt nach dem Reboot nouveau statt nvidia.
# Idempotent — repariert auch eine Box, die schon auf dem neuen Treiber ist.
nv_postfix() {
if ! command -v nvidia-smi >/dev/null 2>&1 && apt-cache show nvidia-driver-cuda >/dev/null 2>&1; then
warn "nvidia-smi fehlt — installiere nvidia-driver-cuda (enthaelt es)"
apt-get install -y nvidia-driver-cuda
fi
apt-get update
apt-get install -y linux-headers-amd64 || true
apt-get install -y "linux-headers-$(uname -r)" || true
if apt-get install -y -t trixie-backports nvidia-driver; then
find /etc/modprobe.d -maxdepth 1 -name 'nvidia*.conf' -xtype l -print -delete 2>/dev/null \
| sed 's/^/ toter Verweis entfernt: /' || true
if [[ ! -e /etc/modprobe.d/nvidia.conf && -e /etc/modprobe.d/nvidia.conf.dpkg-new ]]; then
mv /etc/modprobe.d/nvidia.conf.dpkg-new /etc/modprobe.d/nvidia.conf
ok "/etc/modprobe.d/nvidia.conf aktiviert (sperrt nouveau)"
NV_INITRAMFS=1
fi
if ! grep -rqs "^blacklist nouveau" /etc/modprobe.d /usr/lib/modprobe.d /lib/modprobe.d; then
mkdir -p /etc/modprobe.d
if printf 'blacklist nouveau\nblacklist nova-core\n' > /etc/modprobe.d/aria-blacklist-nouveau.conf; then
ok "nouveau gesperrt (/etc/modprobe.d/aria-blacklist-nouveau.conf)"
NV_INITRAMFS=1
else
warn "nouveau-Sperre konnte nicht geschrieben werden — nach dem Reboot laedt evtl. nouveau statt nvidia"
fi
fi
if [[ "${NV_INITRAMFS:-0}" -eq 1 ]] || lsmod | grep -q '^nouveau'; then
if update-initramfs -u -k all >/dev/null 2>&1; then
ok "initramfs neu gebaut (Sperre greift schon beim Booten)"
else
warn "update-initramfs fehlgeschlagen — bitte von Hand: update-initramfs -u -k all"
fi
NV_NEED_REBOOT=1
fi
}
if [[ "$DO_UPGRADE_DRIVER" -eq 1 ]]; then
step "NVIDIA-Treiber-Upgrade (NVIDIAs Paketquelle fuer Debian 13)"
CUR="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 || true)"
if [[ -z "$CUR" ]]; then CUR="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || true)"; fi
CUR_MAJOR="${CUR%%.*}"
if [[ "$CUR_MAJOR" =~ ^[0-9]+$ && "$CUR_MAJOR" -ge "$MIN_DRIVER_MAJOR" ]]; then
ok "Treiber ${CUR} ist neu genug (>= ${MIN_DRIVER_MAJOR})"
nv_postfix
if lsmod | grep -q '^nouveau' || ! nvidia-smi >/dev/null 2>&1; then
echo
echo -e "${c_y}==> REBOOT noetig (nouveau ist noch geladen / Treiber noch nicht aktiv):${c_0}"
echo -e "${c_y} sudo reboot → danach: nvidia-smi und cd xtts && docker compose up -d${c_0}"
exit 0
fi
ok "nvidia-smi laeuft, nouveau nicht geladen — alles in Ordnung"
else
ok "aktuell: ${CUR:-kein Treiber} — Ziel: >= ${MIN_DRIVER_MAJOR}"
KEYRING_NEW=0
if ! dpkg -s cuda-keyring >/dev/null 2>&1; then
curl -fsSL "$NV_REPO/cuda-keyring_1.1-1_all.deb" -o /tmp/cuda-keyring.deb \
|| die "NVIDIA-Paketquelle nicht erreichbar ($NV_REPO)"
dpkg -i /tmp/cuda-keyring.deb >/dev/null && KEYRING_NEW=1
ok "NVIDIA-Paketquelle eingetragen"
fi
undo_keyring() { [[ "$KEYRING_NEW" -eq 1 ]] && dpkg -r cuda-keyring >/dev/null 2>&1 && apt-get update -qq || true; }
apt-get update -qq
apt-get install -y -qq linux-headers-amd64 >/dev/null 2>&1 || true
apt-get install -y -qq "linux-headers-$(uname -r)" >/dev/null 2>&1 || true
NV_PKGS="nvidia-driver nvidia-kernel-dkms libcuda1"
# Bei NVIDIA steckt nvidia-smi in nvidia-driver-cuda (das Paket "nvidia-smi"
# ist dort leer). Fallback fuer eine Quelle, die es anders schneidet.
if apt-cache show nvidia-driver-cuda >/dev/null 2>&1; then
NV_PKGS="$NV_PKGS nvidia-driver-cuda"
else
NV_PKGS="$NV_PKGS nvidia-smi"
fi
for p in nvidia-persistenced nvidia-modprobe; do
if dpkg -s "$p" >/dev/null 2>&1; then NV_PKGS="$NV_PKGS $p"; fi
done
# Erst durchspielen — aendert nichts.
if ! SIM="$(apt-get -s install -y $NV_PKGS 2>&1)"; then
echo "$SIM" | grep -E "^E:|Kollidiert|Hängt ab|Depends|Conflicts" | head -15 || true
undo_keyring
die "apt findet keinen sauberen Weg — NICHTS geaendert (Quelle wieder entfernt)."
fi
if echo "$SIM" | grep -qE '^(Remv|Inst) (nvidia-container|libnvidia-container)'; then
undo_keyring
die "Das Upgrade wuerde das NVIDIA-Container-Toolkit anfassen — abgebrochen, NICHTS geaendert."
fi
NEWV="$(echo "$SIM" | sed -nE 's/^Inst nvidia-driver (\[[^]]*\] )?\(([^ ]+).*/\2/p' | head -1 || true)"
echo
echo -e " Plan: Treiber ${CUR:-?} → ${c_g}${NEWV:-?}${c_0}"
echo "$SIM" | grep -E "^[0-9]+ (aktualisiert|upgraded)" | head -1 | sed 's/^/ /' || true
REMV="$(echo "$SIM" | sed -nE 's/^Remv ([^ ]+).*/\1/p' | paste -sd' ' || true)"
if [[ -n "$REMV" ]]; then echo " entfernt (in den neuen Paketen aufgegangen): $REMV"; fi
echo " Container-Toolkit: bleibt unberuehrt"
echo " Danach ist ein REBOOT noetig (neues Kernelmodul)."
if [[ "$ASSUME_YES" -ne 1 && -t 0 ]]; then
read -r -p " Jetzt ausfuehren? [j/N] " ans
if [[ ! "$ans" =~ ^[jJyY] ]]; then
undo_keyring
die "Abgebrochen — NICHTS geaendert."
fi
fi
apt-get install -y $NV_PKGS
dkms autoinstall >/dev/null 2>&1 || true
NEWV="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
ok "nvidia-driver aus backports installiert: ${NEWV}"
nv_postfix
GOT="$(dpkg-query -W -f='${Version}' nvidia-driver 2>/dev/null || echo '?')"
[[ "${GOT%%.*}" =~ ^[0-9]+$ && "${GOT%%.*}" -ge "$MIN_DRIVER_MAJOR" ]] \
|| die "Installiert ist ${GOT} — erwartet >= ${MIN_DRIVER_MAJOR}. Bitte Ausgabe oben pruefen."
# -F: der Kernelname enthaelt "+" — als Regex gelesen passte er nie.
# Geprueft wird jeder installierte Kernel (apt kann gerade einen neueren
# mitgebracht haben, der beim Reboot startet).
for k in /lib/modules/*/; do
k="$(basename "$k")"
if [[ -d "/usr/src/linux-headers-$k" ]]; then
if dkms status 2>/dev/null | grep -F "$k" | grep -q installed; then
ok "Kernelmodul fuer $k gebaut"
else
warn "Kernelmodul fuer $k NICHT gebaut — vor dem Reboot: dkms autoinstall -k $k"
fi
fi
done
command -v nvidia-smi >/dev/null 2>&1 || warn "nvidia-smi fehlt nach der Installation — bitte melden"
ok "nvidia-driver ${GOT} installiert"
echo
echo -e "${c_y}==> REBOOT noetig, damit der neue Treiber laedt:${c_0}"
echo -e "${c_y} sudo reboot && danach: cd xtts && sudo ./bootstrap.sh --up --token <TOKEN>${c_0}"
echo -e "${c_y} (nvidia-smi zeigt dann die neue Version + CUDA-Level)${c_0}"
echo -e "${c_y} sudo reboot${c_0}"
echo -e "${c_y} danach: nvidia-smi (zeigt ${GOT%%-*} und eine CUDA-Version >= 12.9)${c_0}"
echo -e "${c_y} und: cd xtts && docker compose up -d${c_0}"
exit 0
else
warn "backports-Install fehlgeschlagen — 550er bleibt aktiv."
warn "Alternative fuer den neuesten Treiber: NVIDIAs CUDA-Repo fuer debian13"
warn " (developer.download.nvidia.com/compute/cuda/repos/debian13/x86_64) → Paket 'cuda-drivers'."
die "Treiber-Upgrade nicht moeglich — siehe oben."
fi
fi
# ── 2c. Docker wartet beim Booten auf DNS ──
# Debian traegt die Netzwerkkarte als "allow-hotplug" ein — darauf wartet
# network-online.target NICHT. Docker startet die Container dann, bevor DHCP den
# DNS-Server geliefert hat ("DNS resolver has no external nameservers"), und die
# Container behalten diesen leeren Stand: kein RVS, keine Verbindung, bis man
# sie neu erzeugt. Passiert bei jedem Reboot.
# Abhilfe ohne die Netzkonfiguration anzufassen: Docker wartet vor dem Start bis
# zu 90 s, bis /etc/resolv.conf einen Nameserver hat.
step "Docker wartet beim Booten auf DNS"
DROPIN=/etc/systemd/system/docker.service.d/aria-wait-dns.conf
if [[ -f "$DROPIN" ]]; then
ok "schon eingerichtet ($DROPIN)"
else
mkdir -p "$(dirname "$DROPIN")"
cat > "$DROPIN" <<'EOF'
# ARIA bootstrap.sh: Container erst starten, wenn DNS da ist (sonst merken sie
# sich "keine Nameserver" und erreichen den RVS nie). Siehe xtts/bootstrap.sh.
[Unit]
Wants=network-online.target
After=network-online.target
[Service]
ExecStartPre=/bin/sh -c 'for i in $(seq 1 90); do grep -q "^nameserver" /etc/resolv.conf && exit 0; sleep 1; done; echo "kein DNS nach 90 s — starte trotzdem"; exit 0'
EOF
systemctl daemon-reload
ok "eingerichtet — greift ab dem naechsten Boot"
fi
# ── 3. NVIDIA-Treiber ──
step "NVIDIA-Treiber"
if nvidia-smi >/dev/null 2>&1; then
ok "Treiber aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
DRV="$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 || true)"
ok "Treiber ${DRV} aktiv: $(nvidia-smi --query-gpu=name --format=csv,noheader | paste -sd', ')"
DRIVER_ACTIVE=1
# Fuer das lokale LLM zu alt? Dann laeuft es still auf der CPU — das soll man
# hier sehen, nicht erst an einer leeren Karte in nvidia-smi.
PROFILES="$(grep -E '^COMPOSE_PROFILES=' "$XTTS_DIR/.env" 2>/dev/null | cut -d= -f2- || true)"
if [[ "${DRV%%.*}" =~ ^[0-9]+$ && "${DRV%%.*}" -lt "$MIN_DRIVER_MAJOR" && ( -z "$PROFILES" || "$PROFILES" == *llm* ) ]]; then
warn "Treiber ${DRV} ist zu alt fuer das lokale LLM (braucht >= ${MIN_DRIVER_MAJOR})."
warn " Folge: llama.cpp findet keine Karte und rechnet auf der CPU."
warn " Abhilfe: sudo ./bootstrap.sh --upgrade-driver (zeigt erst den Plan, fragt nach)"
fi
else
if dpkg -l | grep -q '^ii nvidia-driver '; then
warn "Treiber installiert, aber nvidia-smi antwortet nicht → REBOOT noetig."
+30 -4
View File
@@ -43,6 +43,7 @@ services:
environment:
# GPU-Wahl: NVIDIA_VISIBLE_DEVICES (mehrere via "0,1"). Default GPU 0.
- NVIDIA_VISIBLE_DEVICES=${F5TTS_GPU:-0}
- ARIA_GPU=${F5TTS_GPU:-0} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node} # erscheint in Diagnostic + Logs
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
@@ -70,6 +71,7 @@ services:
runtime: nvidia
environment:
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
- ARIA_GPU=${WHISPER_GPU:-1} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST}
@@ -105,6 +107,7 @@ services:
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
environment:
- NVIDIA_VISIBLE_DEVICES=${VOXTRAL_GPU:-1} # STT-3B ~9 GB → 12-GB-Karte
- ARIA_GPU=${VOXTRAL_GPU:-1} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST}
@@ -133,12 +136,28 @@ services:
volumes:
- ./models:/models # HF-Cache + generierte Config
environment:
- NVIDIA_VISIBLE_DEVICES=${LLM_GPU:-0}
# Alle Karten sichtbar — welches Modell auf welcher Karte laeuft, legt der
# llm-adapter pro Modell fest (CUDA_VISIBLE_DEVICES=<Karte> in der
# generierten Config). Erlaubt sind nur die LLM-Karten aus LLM_GPUS.
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
# CUDA zaehlt Karten sonst "schnellste zuerst" — dann waere CUDA-Karte 0
# nicht unbedingt nvidia-smi-Karte 0. PCI-Reihenfolge = wie nvidia-smi.
- CUDA_DEVICE_ORDER=PCI_BUS_ID
# Echten Treiber vom Host zuerst laden. Das Image traegt in seiner
# ld.so.conf die eigene CUDA-Kompatibilitaets-libcuda (fuer Treiber 575)
# VOR dem eingehaengten Host-Treiber ein. Die hilft nur Rechenzentrums-
# karten; bei GeForce scheitert llama.cpp damit ("forward compatibility …"
# bei aelterem, "unsupported display driver / cuda driver combination" bei
# neuerem Treiber) und rechnet still auf der CPU. Auf der Box getestet:
# so sieht llama.cpp beide Karten.
- LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu:/usr/local/cuda/lib64
- 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"]
# Liest die vom llm-adapter generierte Config. --watch-config: Aenderungen
# (Modell in Karte geladen/entladen) greifen von selbst — einen Reload-
# Endpunkt hat llama-swap nicht. Beim allerersten Boot faengt restart:
# unless-stopped die Reihenfolge ab, bis der Adapter die Config geschrieben hat.
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080", "--watch-config"]
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
@@ -178,6 +197,13 @@ services:
- LLM_REGISTRY=/models/aria_models.json
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
# Welche Karten dem LLM gehoeren. Jede haelt genau ein Modell, geladen
# wird nur ueber die Diagnostic. Mehrere: LLM_GPUS=1,2 — eine: LLM_GPU=1
# (alte Schreibweise, gilt weiter, wenn LLM_GPUS leer ist).
- LLM_GPUS=${LLM_GPUS:-}
- LLM_GPU=${LLM_GPU:-0}
# Fuer gesperrte (gated) HF-Repos: die Dateiliste fuer den Fortschritt.
- HF_TOKEN=${HF_TOKEN:-}
restart: unless-stopped
# ─── OpenHands (optional, Standalone) ────────────────
+5 -1
View File
@@ -78,7 +78,11 @@ 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()
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
# durch "void", taugt also nicht zur Anzeige.
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
WORKER_SERVICE = "f5tts"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
+710 -116
View File
@@ -96,6 +96,62 @@ 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")
# ── Karten-Belegung ─────────────────────────────────────────────────
# Grundregel: Modelle LADEN nur ueber die Diagnostic, und zwar pro Karte. Jede
# LLM-Karte haelt genau ein Modell, dauerhaft (ttl 0, beim Start vorgeladen).
# Clients — ARIA, Fremdprogramme ueber RVS, OpenHands ueber den OpenAI-Port —
# sehen nur die geladenen Modelle und waehlen daraus. Eine Anfrage fuer ein
# anderes Modell wird abgelehnt und laedt NIE etwas nach. So kann kein Client
# einem anderen das Modell unter den Fuessen wegtauschen.
#
# Welche Karten dem LLM gehoeren, steht in der .env der Box:
# LLM_GPUS=0,1 (mehrere) — oder wie bisher — LLM_GPU=0 (eine)
# Die Belegung (Karte → Modell) liegt in SLOTS_PATH und wird nur von
# llm_provision_model / llm_unload_model geaendert.
SLOTS_PATH = os.getenv("LLM_SLOTS", "/models/aria_slots.json")
def _llm_cards() -> list:
raw = os.getenv("LLM_GPUS", "").strip() or os.getenv("LLM_GPU", "0").strip() or "0"
cards = [c.strip() for c in raw.split(",") if c.strip()]
return [c for i, c in enumerate(cards) if c not in cards[:i]] # Reihenfolge, ohne Doppelte
def _load_slots() -> dict:
"""{karte: modell}. Fehlt die Datei (erste Version mit Karten), wird das
bisherige Standardmodell (LLM_MODEL) auf die erste Karte gelegt — so laeuft
eine bestehende Box nach dem Update genau so weiter wie vorher."""
try:
with open(SLOTS_PATH) as f:
data = json.load(f)
if isinstance(data, dict):
cards = set(_llm_cards())
return {str(k): str(v) for k, v in data.items() if str(k) in cards and v}
except FileNotFoundError:
cards = _llm_cards()
if cards and _model_cmd(LLM_MODEL):
slots = {cards[0]: LLM_MODEL}
_save_slots(slots)
logger.info("Karten-Belegung neu angelegt: Karte %s → %s", cards[0], LLM_MODEL)
return slots
except Exception as e:
logger.warning("Karten-Belegung nicht lesbar (%s) — keine Modelle", e)
return {}
def _save_slots(slots: dict) -> None:
tmp = SLOTS_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(slots, f, indent=2)
os.replace(tmp, SLOTS_PATH)
def _card_of(model: str) -> str:
for card, m in _load_slots().items():
if m == model:
return card
return ""
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
@@ -161,69 +217,93 @@ def _save_registry(reg: list) -> None:
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 = {}
def _base_config() -> dict:
try:
with open(BASE_CONFIG_PATH) as f:
base = yaml.safe_load(f) or {}
return 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,
}
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
key = _openai_key()
for k, spec in list(models.items()):
cmd = str((spec or {}).get("cmd") or "")
if not cmd:
continue
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
if key:
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
models[k] = dict(spec, cmd=cmd)
return {}
out = dict(base)
def _model_cmd(key: str) -> str:
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.)."""
for e in _load_registry():
if (e.get("key") or "").strip() == key and (e.get("hfRepo") or "").strip():
quant = (e.get("quant") or "Q4_K_M").strip()
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
f"-hf {e['hfRepo'].strip()}:{quant}\n"
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja")
spec = (_base_config().get("models") or {}).get(key) or {}
return str(spec.get("cmd") or "")
def _known_models() -> list:
"""Alles, was diese Box laden KOENNTE (Basis-Template + Registry)."""
keys = list((_base_config().get("models") or {}).keys())
keys += [e["key"] for e in _load_registry() if e.get("key") and e["key"] not in keys]
return keys
def _generate_config() -> int:
"""Schreibt die llama-swap-Config: NUR die Modelle, die einer Karte
zugeordnet sind. Was nicht drinsteht, kennt llama-swap nicht — auch der
OpenAI-Port lehnt es dann ab (404 "no router for requested model"). Das ist
der Kern der Regel "Clients laden nichts".
env CUDA_VISIBLE_DEVICES=<karte> pinnt das Modell auf seine Karte
ttl 0 nie wegen Leerlauf entladen
groups.swap false alle gleichzeitig geladen, keins
verdraengt ein anderes
hooks.on_startup.preload beim Start (und nach jedem Reload)
sofort laden, nicht erst beim 1. Aufruf
Getestet gegen llama-swap: genau dieses Verhalten.
"""
base = _base_config()
slots = _load_slots()
key = _openai_key()
models = {}
for card in _llm_cards():
m = slots.get(card)
if not m:
continue
cmd = _model_cmd(m)
if not cmd:
logger.error("Karte %s: Modell %r unbekannt (weder Registry noch Basis) — uebersprungen", card, m)
continue
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd)
if key:
# llama-server prueft dann den Authorization-Header (OpenAI-Port).
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
models[m] = {"cmd": cmd, "env": [f"CUDA_VISIBLE_DEVICES={card}"], "ttl": 0}
out = {k: v for k, v in base.items() if k not in ("models", "groups", "hooks")}
out["models"] = models
if models:
out["groups"] = {"aria-karten": {"swap": False, "exclusive": True,
"persistent": True, "members": list(models)}}
out["hooks"] = {"on_startup": {"preload": list(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)
logger.info("llama-swap-Config: %s",
", ".join(f"Karte {_card_of(m) or '?'} → {m}" for m in models) or "keine Karte belegt")
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")
"""llama-swap laeuft mit --watch-config und liest die Datei selbst neu
(Poll alle 2 s). Die frueher hier aufgerufenen Endpunkte /reload und
/api/config/reload gibt es nicht (404, ausprobiert). Beim Neuladen startet
llama-swap ALLE Modelle neu und laedt sie per Preload wieder — die anderen
Karten sind also kurz nicht erreichbar."""
await asyncio.sleep(3) # Poll-Intervall + Puffer, bevor jemand fragt
async def _send(ws, mtype: str, payload: dict) -> None:
@@ -237,8 +317,82 @@ async def _send(ws, mtype: str, payload: dict) -> None:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
def _sanitize_messages(messages: list) -> list:
"""Verlauf so aufbereiten, dass llama.cpp ihn nicht als Ganzes ablehnt.
Steht im Verlauf ein frueherer Werkzeugaufruf, dessen arguments KEIN
gueltiges JSON sind, antwortet llama.cpp auf die GANZE Anfrage mit 500
("Failed to parse tool call arguments as JSON") — und jede weitere Anfrage
mit demselben Verlauf auch. Bei Programmen, die Code schreiben, passiert
das leicht (Dateiinhalt mit Anfuehrungszeichen/Umbruechen). Statt den
ganzen Auftrag scheitern zu lassen: solche Argumente als {"_raw": "…"}
verpacken. Ein dict als arguments (statt String) wird zu JSON.
Nur Kopien — die Nachrichten des Aufrufers bleiben unveraendert."""
out = []
for m in messages or []:
tcs = m.get("tool_calls") if isinstance(m, dict) else None
if not tcs:
out.append(m)
continue
fixed = []
for tc in tcs:
fn = dict((tc or {}).get("function") or {})
args = fn.get("arguments")
if isinstance(args, (dict, list)):
fn["arguments"] = json.dumps(args, ensure_ascii=False)
elif isinstance(args, str):
try:
json.loads(args or "{}")
if not args.strip():
fn["arguments"] = "{}"
except Exception:
logger.warning("Werkzeugaufruf %r im Verlauf hat kein gueltiges JSON als "
"arguments (%d Zeichen) — eingepackt", fn.get("name"), len(args))
fn["arguments"] = json.dumps({"_raw": args}, ensure_ascii=False)
elif args is None:
fn["arguments"] = "{}"
fixed.append(dict(tc, function=fn))
out.append(dict(m, tool_calls=fixed))
return out
def _llama_error_info(exc: Exception) -> dict:
"""Maschinenlesbare Fehlerart. Wichtigster Fall: der Verlauf ist laenger
als das Kontextfenster — dann soll ein Programm automatisch kuerzen und
nochmal fragen koennen, statt einen Text auszuwerten."""
resp = getattr(exc, "response", None)
if resp is None:
return {}
try:
e = (resp.json() or {}).get("error") or {}
except Exception:
return {}
if isinstance(e, dict) and e.get("type") == "exceed_context_size_error":
return {"errorCode": "context_exceeded",
"promptTokens": int(e.get("n_prompt_tokens") or 0),
"ctx": int(e.get("n_ctx") or 0)}
return {}
def _llama_error_text(exc: Exception) -> str:
"""Bei HTTP-Fehlern den GRUND von llama.cpp mitnehmen. Bisher kam nur
"Server error '500 …'" an — die eigentliche Erklaerung steht im Body."""
resp = getattr(exc, "response", None)
if resp is None:
return re.sub(r"\s*For more information check:\s*\S+", "", str(exc)).strip()
detail = ""
try:
j = resp.json()
e = j.get("error") if isinstance(j, dict) else None
detail = (e.get("message") if isinstance(e, dict) else e) or json.dumps(j)[:600]
except Exception:
detail = (resp.text or "")[:600]
logger.warning("llama.cpp HTTP %s — Antwort: %s", resp.status_code, (resp.text or "")[:2000])
return f"llama.cpp HTTP {resp.status_code}: {str(detail).strip()[:600]}"
async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop, tools=None, model=None) -> dict:
stop, tools=None, model=None, timeout: float | None = None) -> dict:
"""Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, tool_calls, error} zurueck — wirft nie.
@@ -248,7 +402,7 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
natives Tool-Calling und liefert dann message.tool_calls."""
body = {
"model": model or LLM_MODEL,
"messages": messages,
"messages": _sanitize_messages(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
@@ -263,7 +417,7 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
# weiter. Qwen3 unterdrueckt damit den <think>-Block.
body["chat_template_kwargs"] = {"enable_thinking": False}
try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SEC) as client:
async with httpx.AsyncClient(timeout=timeout or LLM_TIMEOUT_SEC) as client:
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status()
data = r.json()
@@ -281,8 +435,9 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
"usage": usage,
}
except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
return {"ok": False, "content": "", "error": str(e)[:300]}
msg = _llama_error_text(e)
logger.warning("llama.cpp-Call fehlgeschlagen: %s", msg[:300])
return {"ok": False, "content": "", "error": msg[:700], **_llama_error_info(e)}
# B0.5-2: Lade-Status ans Diagnostic (service_status, service="llm"). Wir kennen
@@ -315,14 +470,129 @@ async def _fetch_available_models() -> list:
return [LLM_MODEL]
async def _fetch_loaded() -> list:
"""Welche Modelle liegen GERADE in der Karte? llama-swap /running →
[{"model", "state"}]. Ohne groups-Config ist das hoechstens eins — ein
anderes anzufragen entlaedt es. None, wenn llama-swap nicht antwortet
("unbekannt" ist etwas anderes als "nichts geladen")."""
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{LLAMA_URL}/running")
r.raise_for_status()
return [{"model": m.get("model", ""), "state": m.get("state", "")}
for m in (r.json().get("running") or []) if m.get("model")]
except Exception as e:
logger.debug("llama-swap /running nicht abfragbar: %s", e)
return None
_gpu_name_cache: dict = {}
async def _gpu_names() -> dict:
"""{index: {"name", "vramMB"}} aller Karten, einmal per nvidia-smi geholt.
Der VRAM steht fest — die Diagnostic warnt damit vor Modellen, die nicht
auf die Karte passen."""
if _gpu_name_cache:
return _gpu_name_cache
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi", "--query-gpu=index,name,memory.total", "--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), 10)
for line in out.decode().splitlines():
parts = [x.strip() for x in line.split(",")]
if len(parts) >= 3:
_gpu_name_cache[parts[0]] = {"name": parts[1], "vramMB": int(float(parts[2]))}
except Exception:
pass
return _gpu_name_cache
def _model_ctx(key: str) -> int:
"""Kontextfenster, mit dem llama-server das Modell startet (-c). Clients
MUESSEN das wissen: rechnen sie mit mehr, scheitern lange Auftraege oder
werden falsch gekuerzt."""
m = re.search(r"(?:^|\s)(?:-c|--ctx-size)\s+(\d+)", _model_cmd(key) or "")
return int(m.group(1)) if m else 0
async def _vram_used() -> dict:
"""{index: belegte MB} — frisch bei jedem Aufruf (aendert sich laufend)."""
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi", "--query-gpu=index,memory.used", "--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), 10)
return {a.strip(): int(float(b)) for a, b in
(l.split(",", 1) for l in out.decode().splitlines() if "," in l)}
except Exception:
return {}
# Ein geladenes Modell belegt GB auf seiner Karte. Bleibt die Karte trotz
# "ready" praktisch leer, rechnet llama.cpp auf der CPU — typisch, wenn der
# Treiber zu alt fuer das CUDA im Image ist ("forward compatibility …").
# llama.cpp meldet das nicht nach aussen; wir sehen es nur am Speicher.
ON_CPU_BELOW_MB = 400
async def _slots_info() -> list:
"""Pro LLM-Karte: [{gpu, gpuName, model, state}]. state aus llama-swap
/running: ready | starting | stopping | stopped; "unknown", wenn llama-swap
nicht antwortet; "" bei leerer Karte."""
slots = _load_slots()
running = await _fetch_loaded()
rmap = {r["model"]: r["state"] for r in running} if running is not None else None
gpus = await _gpu_names()
used = await _vram_used()
out = []
for card in _llm_cards():
m = slots.get(card, "")
state = "" if not m else ("unknown" if rmap is None else rmap.get(m, "stopped"))
g = gpus.get(card) or {}
u = used.get(card)
on_cpu = bool(m and state == "ready" and u is not None and u < ON_CPU_BELOW_MB)
out.append({"gpu": card, "gpuName": g.get("name", ""), "vramMB": g.get("vramMB", 0),
"vramUsedMB": u if u is not None else -1, "onCpu": on_cpu,
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0})
return out
def _selectable(slots_info: list) -> list:
"""Was Clients auswaehlen duerfen: belegte Karten, deren Modell laeuft oder
gerade (neu) startet — llama-swap haelt Anfragen bis "ready" fest. Auch
"unknown": wenn llama-swap /running nicht kennt (aeltere Version), lieber
die Belegung zeigen als die Box unbenutzbar machen."""
return [s["model"] for s in slots_info
if s["model"] and s["state"] in ("ready", "starting", "unknown")]
async def _ping(ws) -> None:
"""Lebenszeichen: busy + Karten-Belegung + auswaehlbare Modelle."""
info = await _slots_info()
await _send(ws, "worker_ping",
{"instanceId": INSTANCE_ID, "busy": _inflight > 0,
"slots": info, "models": _selectable(info)})
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()
info = await _slots_info()
models = _selectable(info)
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)
# Die LLM-Karten (LLM_GPUS/LLM_GPU). Die eigene NVIDIA_VISIBLE_DEVICES
# des Adapters ist "all" bzw. wird vom Container-Toolkit ersetzt
# (Anzeige "GPU void") — sagt nichts darueber, wo die Modelle laufen.
"node": NODE_NAME, "gpus": ",".join(_llm_cards()),
"model": models[0] if models else "",
# NUR geladene Modelle — das ist die Auswahl fuer Clients (ARIA, Fremd-
# programme). Laden geht ausschliesslich ueber die Diagnostic.
"models": models,
"slots": info, # pro LLM-Karte: Modell + Zustand (Diagnostic)
"knownModels": _known_models(), # was in eine Karte geladen werden KOENNTE
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
@@ -331,7 +601,8 @@ async def _announce(ws) -> None:
"openaiKeySet": bool(_openai_key()),
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
})
logger.info("worker_hello: models=%s", models)
logger.info("worker_hello: %s", ", ".join(
f"Karte {x['gpu']}={x['model'] or '-'}({x['state'] or 'leer'}{', CPU!' if x.get('onCpu') else ''})" for x in info))
async def _worker_register(ws) -> None:
@@ -343,8 +614,7 @@ async def _worker_register(ws) -> None:
while True:
await asyncio.sleep(WORKER_PING_INTERVAL_S)
n += 1
await _send(ws, "worker_ping",
{"instanceId": INSTANCE_ID, "busy": _inflight > 0})
await _ping(ws)
if n % 3 == 0: # ~30s worker_hello wiederholen (wie der Satellit) →
await _announce(ws) # auch neu gestartetes Diagnostic/Bridge lernt uns
except asyncio.CancelledError:
@@ -376,33 +646,48 @@ async def _do_llm_request(ws, payload: dict) -> None:
stop = payload.get("stop")
tools = payload.get("tools") or None
model = (payload.get("model") or "").strip() or None
eff_model = model or LLM_MODEL
slots = _load_slots()
loaded = [slots[c] for c in _llm_cards() if slots.get(c)]
targeted = payload.get("targetInstance") == INSTANCE_ID
# Modellwechsel (oder erster Request) → llama-swap laedt/swappt: Status melden.
switching = eff_model != _last_model
if switching:
await _emit_llm_status(ws, "loading", eff_model)
async def _refuse(code: str, msg: str) -> None:
await _send(ws, "llm_response", {
"requestId": req_id, "ok": False, "error": msg, "errorCode": code,
"instanceId": INSTANCE_ID, "node": NODE_NAME, "loadedModels": loaded,
})
# Clients laden nichts: ein Modell, das auf keiner Karte liegt, gibt es
# fuer sie nicht. Gezielt an uns adressiert → klare Absage mit der Liste,
# was geladen IST. Als Broadcast → schweigen: eine andere Box hat es
# vielleicht (sonst antworteten alle Boxen ohne das Modell mit Fehlern).
if model and model not in loaded:
if targeted:
await _refuse("model_not_loaded",
f"Modell {model!r} ist auf {NODE_NAME} nicht geladen. Geladen: "
+ (", ".join(loaded) or "nichts")
+ ". Laden geht nur ueber die ARIA-Diagnostic.")
return
if not model:
if not loaded:
if targeted:
await _refuse("no_model_loaded",
f"Auf {NODE_NAME} ist keine Karte mit einem Modell belegt.")
return
# Ohne Modellangabe: das der ersten LLM-Karte (Verhalten wie frueher,
# als es nur eins gab). Clients sollten das Modell immer angeben.
model = loaded[0]
eff_model = model
t0 = time.time()
res = await _call_llama(messages, max_tokens=max_tokens,
temperature=temperature, stop=stop, tools=tools,
model=model)
dt = time.time() - t0
if switching:
if res.get("ok"):
fresh = (eff_model not in _ready_models) and dt > 25
_ready_models.add(eff_model)
_last_model = eff_model
await _emit_llm_status(ws, "ready", eff_model,
loadSeconds=round(dt, 1), freshlyDownloaded=fresh)
else:
# bei Fehler _last_model NICHT setzen → naechster Versuch meldet erneut loading
await _emit_llm_status(ws, "error", eff_model,
error=(res.get("error") or "")[:120])
if not res.get("ok"):
await _emit_llm_status(ws, "error", eff_model, error=(res.get("error") or "")[:120])
tc = res.get("tool_calls")
logger.info("llm_request id=%s model=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
(req_id[:8] if req_id else "?"), model or LLM_MODEL, res.get("ok"), dt,
(req_id[:8] if req_id else "?"), model, res.get("ok"), dt,
len(res.get("content") or ""), len(tc) if tc else 0)
await _send(ws, "llm_response", {
"requestId": req_id,
@@ -410,7 +695,12 @@ async def _do_llm_request(ws, payload: dict) -> None:
"content": res.get("content", ""),
"tool_calls": tc,
"error": res.get("error"),
"model": model or LLM_MODEL,
"model": model,
# Fehlerart fuer Programme (z.B. context_exceeded + promptTokens + ctx)
**{k: res[k] for k in ("errorCode", "promptTokens", "ctx") if res.get(k)},
# Wer hat geantwortet: Box + Karte. Hilft Clients mit mehreren Boxen.
"instanceId": INSTANCE_ID, "node": NODE_NAME, "gpu": _card_of(model),
"usage": res.get("usage") or {},
"elapsedMs": int(dt * 1000),
})
@@ -441,74 +731,373 @@ async def _handle_openai_config(ws, payload: dict) -> None:
return
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
_generate_config()
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
logger.info("OpenAI-API-Key %s (llama-swap laedt die Modelle neu)",
"gesetzt" if key else "entfernt")
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
"keySet": bool(key),
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
"geladen wird — bereits laufende Modelle einmal entladen lassen "
"(TTL) oder ein anderes Modell anfragen."
"message": ("API-Key gesetzt — greift in wenigen Sekunden. llama-swap startet "
"die Modelle dafuer kurz neu."
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
})
await _announce(ws)
# ── Download-Fortschritt beim Modell-Laden ─────────────────────────────
# llama.cpp zieht das GGUF per -hf selbst und meldet keinen Fortschritt. Wir
# messen von aussen: Hugging Face sagt vorab, welche Dateien kommen (Name,
# Groesse, sha256). llama.cpp legt sie im HF-Cache-Layout ab —
# /models/models--org--repo/blobs/<sha256>.downloadInProgress (waechst)
# /models/models--org--repo/blobs/<sha256> (fertig)
# Genau diese Dateien werden gemessen. Das klappt auch bei gesplitteten
# Modellen und bei zwei Downloads gleichzeitig. Fallback fuer aeltere
# llama.cpp-Layouts: alle *.downloadInProgress unter /models.
MODELS_DIR = os.getenv("LLAMA_CACHE_DIR", "/models")
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
WARMUP_TIMEOUT_S = float(os.getenv("LLM_WARMUP_TIMEOUT_S", "1900")) # > healthCheckTimeout
_INPROGRESS = ".downloadInProgress"
_SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE)
# Quantisierung aus einem Dateinamen: "gemma-4-12B-it-qat-UD-Q4_K_XL.gguf" → "UD-Q4_K_XL"
_QUANT_RE = re.compile(r"(?i)((?:UD-)?(?:IQ[1-4]_(?:XXS|XS|S|M|NL)|Q[2-8]_K(?:_(?:XL|L|M|S))?|Q[4-8]_[01]|BF16|F16|F32))")
# Wenn die gewuenschte fehlt: die erste vorhandene aus dieser Liste. 4-Bit zuerst
# (beste Groesse/Qualitaet fuer 12-GB-Karten), dann groessere.
QUANT_PREFERENCE = ["Q4_K_M", "UD-Q4_K_XL", "Q4_K_XL", "Q4_K_S", "IQ4_XS", "IQ4_NL", "Q4_0",
"Q5_K_M", "UD-Q5_K_XL", "Q6_K", "Q8_0"]
def _quant_of(path: str) -> str:
base = os.path.basename(path)
base = _SPLIT_RE.sub(".gguf", base)
m = _QUANT_RE.findall(base[:-5] if base.lower().endswith(".gguf") else base)
return m[-1].upper() if m else ""
def _is_main_gguf(path: str) -> bool:
"""Das eigentliche Modell — nicht Bild-Projektor (mmproj), nicht die
Zusatzdatei fuers spekulative Dekodieren (mtp), keine imatrix."""
low = path.lower()
base = os.path.basename(low)
return (low.endswith(".gguf") and "mmproj" not in base and not base.startswith("mtp")
and "/mtp/" not in ("/" + low) and "imatrix" not in base)
async def _hf_repo_files(repo: str):
"""Alle Modell-GGUFs eines Repos: [{path, oid, size, quant}] — oder None,
wenn Hugging Face nicht erreichbar ist (dann pruefen wir eben nicht)."""
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=1"
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url, headers=headers)
r.raise_for_status()
entries = r.json()
except Exception as e:
logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s)", repo, e)
return None
out = []
for e in entries if isinstance(entries, list) else []:
path = e.get("path") or ""
if e.get("type") != "file" or not _is_main_gguf(path):
continue
lfs = e.get("lfs") or {}
out.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "",
"size": int(lfs.get("size") or e.get("size") or 0), "quant": _quant_of(path)})
return out
def _pick_quant(files: list, wanted: str) -> str:
"""Die gewuenschte Quantisierung, wenn es sie gibt — sonst die erste aus
QUANT_PREFERENCE, die das Repo hat. "" wenn gar nichts Brauchbares da ist."""
have = {f["quant"] for f in files if f["quant"]}
if wanted and wanted.upper() in have:
return wanted.upper()
return next((q for q in QUANT_PREFERENCE if q in have), "")
def _files_for_quant(files: list, quant: str) -> list:
"""Die Datei(en), die llama.cpp fuer repo:quant zieht. Gesplittet → alle
Teile; sonst die eine (bei mehreren die mit dem kuerzesten Namen)."""
match = [f for f in files if f["quant"] == quant.upper()]
split = [f for f in match if _SPLIT_RE.search(f["path"])]
if split:
return split
return [min(match, key=lambda f: len(f["path"]))] if match else []
async def _hf_expected_files(repo: str, quant: str) -> list:
"""Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: []."""
files = await _hf_repo_files(repo)
return _files_for_quant(files, quant) if files else []
def _scan_download(oids: set) -> tuple:
"""(bytes_da, laeuft_noch, gefunden) fuer die erwarteten Blobs."""
done, active, found = 0, False, 0
fallback, fallback_active = 0, False
for root, _dirs, fns in os.walk(MODELS_DIR):
for fn in fns:
inprog = fn.endswith(_INPROGRESS)
base = fn[:-len(_INPROGRESS)] if inprog else fn
try:
size = os.path.getsize(os.path.join(root, fn))
except OSError:
continue
if base in oids:
done += size
found += 1
active |= inprog
elif inprog:
fallback += size
fallback_active = True
if not found and fallback_active:
return fallback, True, 0
return done, active, found
async def _gpu_info(card: str) -> dict:
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
if not card:
return {}
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi", f"--id={card}",
"--query-gpu=index,name,memory.used,memory.total", "--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), 10)
idx, name, used, total = [x.strip() for x in out.decode().strip().split(",")[:4]]
return {"gpu": idx, "gpuName": name, "vramUsedMB": int(used), "vramTotalMB": int(total)}
except Exception as e:
logger.info("GPU-Info nicht verfuegbar: %s", e)
return {"gpu": card}
async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
await _send(ws, "llm_provision_progress",
{"instanceId": INSTANCE_ID, "node": NODE_NAME, "key": key,
"phase": phase, "ts": int(time.time() * 1000), **extra})
async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> None:
"""Meldet jede Sekunde den Download-Stand, bis der Warmup fertig ist.
Ist alles da, wechselt die Phase auf 'load' (llama.cpp laedt in die Karte)."""
oids = {f["oid"] for f in files if f.get("oid")}
total = sum(f["size"] for f in files)
phase = ""
downloaded = False # gab es einen echten Download? sonst: lag schon im Cache
last_b, last_t, speed = None, time.time(), 0.0
while not stop.is_set():
b, active, found = await asyncio.to_thread(_scan_download, oids)
now = time.time()
if last_b is not None and now > last_t and b >= last_b:
inst = (b - last_b) / (now - last_t)
speed = inst if speed == 0 else 0.7 * speed + 0.3 * inst
last_b, last_t = b, now
if active:
downloaded = True
phase = "download"
pct = round(min(100.0, 100.0 * b / total), 1) if total else None
eta = int(max(0, total - b) / speed) if total and speed > 1 else None
await _emit_progress(ws, key, "download", bytes=b, total=total, percent=pct,
speedBps=int(speed), etaSeconds=eta)
else:
# Nichts laeuft mehr: entweder fertig geladen (alle Blobs da) oder
# es lag schon im Cache. Im Notfallmodus (keine HF-Liste) zaehlt
# nur: es lief ein Download, und jetzt nicht mehr.
all_there = bool(oids) and found >= len(oids)
if phase != "load" and (all_there or downloaded):
phase = "load"
await _emit_progress(ws, key, "load", bytes=total or b, total=total,
percent=100, cached=not downloaded)
try:
await asyncio.wait_for(stop.wait(), 1.0)
except asyncio.TimeoutError:
pass
async def _provision_result(ws, key: str, ok: bool, **extra) -> None:
await _send(ws, "llm_provision_result",
{"instanceId": INSTANCE_ID, "key": key, "ok": ok, **extra})
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."""
"""Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt,
was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per
--watch-config + Preload) → Warmup, der wartet bis es bereit ist; daneben
misst _watch_download den Download."""
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)
cards = _llm_cards()
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
if not card and len(cards) == 1:
card = cards[0] # aeltere Diagnostic ohne Kartenwahl, Box mit einer Karte
if not key:
return await _provision_result(ws, key, False, error="key fehlt")
if card not in cards:
return await _provision_result(
ws, key, False, gpu=card,
error=(f"Karte {card or '?'} ist auf {NODE_NAME} nicht fuers LLM vorgesehen "
f"(LLM-Karten: {', '.join(cards)} — LLM_GPUS in der .env der Box)."))
slots = _load_slots()
elsewhere = next((c for c, m in slots.items() if m == key and c != card), None)
if elsewhere is not None:
return await _provision_result(
ws, key, False, gpu=card,
error=f"{key} liegt schon auf Karte {elsewhere} dieser Box — ein Modell pro Box nur einmal.")
quant_note = ""
repo_files = None
# Fuer ein Zuruecksetzen: der bisherige Registry-Eintrag dieses Modells.
# Noetig, wenn nur das Kontextfenster geaendert wird (gleiches Modell, gleiche
# Karte) — sonst bliebe nach einem Fehlschlag der zu grosse Wert stehen.
old_entry = next((dict(e) for e in _load_registry() if e.get("key") == key), None)
if repo:
wanted = (payload.get("quant") or "Q4_K_M").strip()
quant = wanted
# Gibt es die Datei ueberhaupt? Fehlt sie, beendet sich llama-server
# sofort und llama-swap meldet nur "500" — der Grund geht verloren.
# Lieber vorher pruefen und ggf. die naechstbeste Quantisierung nehmen
# (unsloth z.B. hat statt Q4_K_M ein UD-Q4_K_XL).
repo_files = await _hf_repo_files(repo)
if repo_files is not None:
if not repo_files:
return await _provision_result(
ws, key, False, gpu=card,
error=f"In {repo} gibt es keine GGUF-Modelldatei.")
quant = _pick_quant(repo_files, wanted)
if not quant:
have = sorted({f["quant"] for f in repo_files if f["quant"]}) or ["?"]
return await _provision_result(
ws, key, False, gpu=card,
error=f"{repo}: keine brauchbare Quantisierung. Vorhanden: {', '.join(have)}")
if quant != wanted.upper():
quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
logger.info("Karte %s: %s", card, quant_note)
entry = {
"key": key, "hfRepo": repo, "quant": quant,
"ctx": int(payload.get("ctx") or 8192),
"ngl": int(payload.get("ngl") or 99),
}
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
elif not _model_cmd(key):
return await _provision_result(ws, key, False, gpu=card, error="hfRepo fehlt (unbekanntes Modell)")
previous = slots.get(card, "")
slots[card] = key
_save_slots(slots)
_generate_config()
quant = next((e.get("quant") for e in _load_registry() if e.get("key") == key), "") or "Q4_K_M"
files = _files_for_quant(repo_files, quant) if repo_files else []
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files),
files=len(files), gpu=card, replaces=previous, quant=quant,
note=quant_note)
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)
await _announce(ws)
stop = asyncio.Event()
watcher = asyncio.create_task(_watch_download(ws, key, files, stop))
t0 = time.time()
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
# LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud.
res = await _call_llama([{"role": "user", "content": "hi"}],
max_tokens=1, temperature=0.0, stop=None, model=key)
max_tokens=1, temperature=0.0, stop=None, model=key,
timeout=WARMUP_TIMEOUT_S)
dt = time.time() - t0
stop.set()
try:
await watcher
except Exception:
pass
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:
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1), freshlyDownloaded=dt > 25)
await _emit_progress(ws, key, "ready", percent=100, loadSeconds=round(dt, 1),
replaces=previous, **(await _gpu_info(card)))
await _ping(ws)
await _provision_result(ws, key, True, gpu=card, elapsedMs=int(dt * 1000))
logger.info("Karte %s: %s geladen (%.1fs)%s", card, key, dt,
f", ersetzt {previous}" if previous else "")
return
# Fehlgeschlagen (z.B. VRAM zu klein): Karte zuruecksetzen. Sonst stuende
# ein kaputtes Modell in der Config, und jedes Neuladen von llama-swap
# risse die anderen Karten mit.
err = (res.get("error") or "Laden fehlgeschlagen")[:200]
if "500" in err and dt < 120:
# llama-swap reicht den Grund nicht weiter ("upstream command exited
# prematurely"). Kam der Abbruch schnell, lief kein Download — dann ist
# es fast immer eins von dreien.
err = ("llama.cpp hat sich beim Start sofort beendet. Haeufigste Gruende: "
"die Modell-Architektur ist neuer als die llama.cpp-Version der Box, "
"zu wenig (V)RAM, oder die Datei passt nicht. (llama-swap gibt den "
"genauen Grund nicht weiter.)")
slots = _load_slots()
if previous:
slots[card] = previous
else:
slots.pop(card, None)
_save_slots(slots)
reg = [e for e in _load_registry() if e.get("key") != key]
if old_entry:
reg.append(old_entry)
_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})
note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer"
await _emit_llm_status(ws, "error", key, error=err)
await _emit_progress(ws, key, "error", gpu=card, error=err + note)
await _provision_result(ws, key, False, gpu=card, error=err + note, elapsedMs=int(dt * 1000))
logger.warning("Karte %s: %s fehlgeschlagen (%s)%s", card, key, err, note)
async def _handle_unload(ws, payload: dict) -> None:
"""Karte leeren (nur Diagnostic). Das GGUF bleibt im Cache — erneutes Laden
geht dann ohne Download."""
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
slots = _load_slots()
key = slots.pop(card, "")
if not key:
return await _provision_result(ws, "", False, gpu=card, error=f"Karte {card} ist schon leer")
_save_slots(slots)
_generate_config()
await _reload_llama()
await _announce(ws)
await _provision_result(ws, key, True, gpu=card, unloaded=True)
logger.info("Karte %s geleert (war %s)", card, key)
async def _handle_remove(ws, payload: dict) -> None:
"""Modell aus der Registry entfernen (und von seiner Karte, falls belegt).
Das GGUF bleibt im Cache."""
key = (payload.get("key") or "").strip()
if not key:
return
_save_registry([e for e in _load_registry() if e.get("key") != key])
slots = _load_slots()
for c in [c for c, m in slots.items() if m == key]:
slots.pop(c)
_save_slots(slots)
_generate_config()
await _reload_llama()
await _announce(ws)
await _provision_result(ws, key, True, removed=True)
logger.info("removed model %s", key)
async def _handle_models_request(ws, payload: dict) -> None:
"""Erkundung fuer Clients: welche Modelle kann ich bei DIESER Box fragen?
Jede Box antwortet einzeln (RVS ist ein Broadcast-Raum) — der Client
sammelt die Antworten ein. Nur geladene Modelle, mit Karte."""
info = await _slots_info()
sel = set(_selectable(info))
await _send(ws, "llm_models", {
"requestId": payload.get("requestId", ""),
"instanceId": INSTANCE_ID, "node": NODE_NAME,
"models": [{"model": x["model"], "gpu": x["gpu"], "gpuName": x["gpuName"], "ctx": x["ctx"],
"state": x["state"]} for x in info if x["model"] in sel],
})
async def _run() -> None:
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
@@ -573,12 +1162,17 @@ async def _run() -> None:
if mtype == "llm_openai_config":
asyncio.create_task(_handle_openai_config(ws, payload))
continue
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
"llm_unload_model", "llm_models_request"):
continue
if mtype == "llm_provision_model":
asyncio.create_task(_handle_provision(ws, payload))
elif mtype == "llm_unload_model":
asyncio.create_task(_handle_unload(ws, payload))
elif mtype == "llm_remove_model":
asyncio.create_task(_handle_remove(ws, payload))
elif mtype == "llm_models_request":
asyncio.create_task(_handle_models_request(ws, payload))
else:
# Jede Anfrage nebenlaeufig — llama.cpp serialisiert intern,
# aber wir blockieren so nicht den Empfang weiterer Messages.
+21
View File
@@ -0,0 +1,21 @@
# ── Verbindung zum RVS ───────────────────────────────────────────────
# Dieselben Werte wie RVS_HOST / RVS_PORT / RVS_TOKEN in ARIAs .env.
# Der Wrapper braucht nur eine AUSGEHENDE Verbindung dorthin — keine
# Portfreigabe, kein VPN, egal in welchem Netz dieser Rechner steht.
RVS_HOST=mobil.example.org
RVS_PORT=444
RVS_TLS=true
RVS_TOKEN=
# ── Endpunkt fuer die Programme auf diesem Rechner ───────────────────
# Port am Host (Base URL: http://127.0.0.1:<Port>/v1)
WRAPPER_PORT=8099
# 127.0.0.1 = nur dieser Rechner (empfohlen). 0.0.0.0 = auch aus dem LAN —
# dann unbedingt einen API-Key setzen.
WRAPPER_BIND=127.0.0.1
# Optional. Leer = jeder Key wird akzeptiert (ok, solange nur lokal erreichbar).
WRAPPER_API_KEY=
# Wie lange auf eine Antwort gewartet wird (muss laenger sein als
# CLUSTER_LLM_TIMEOUT_S der ARIA-Bridge, Standard 600).
#REQUEST_TIMEOUT_S=660
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY wrapper.py .
EXPOSE 8099
CMD ["python", "-u", "wrapper.py"]
+59
View File
@@ -0,0 +1,59 @@
# OpenAI-RVS-Wrapper
Macht den ARIA-LLM-Cluster für Programme, die nur „OpenAI-kompatibel" können
(OpenHands, Continue, Open WebUI …), zu einem ganz normalen OpenAI-Endpunkt
auf **diesem** Rechner:
```
Programm ──HTTP──▶ Wrapper (127.0.0.1:8099) ──RVS──▶ ARIA-Bridge ──▶ KI-Box / Karte
```
Der Wrapper braucht nur eine ausgehende Verbindung zum RVS. Das Programm kann
also stehen, wo es will — keine Portfreigabe, kein VPN.
## Einrichten
```bash
cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
docker compose up -d --build
```
Dann im Browser **http://127.0.0.1:8099/** öffnen. Die Hilfsseite zeigt, ob
die Verbindung steht, welche Modelle es gibt und was man einträgt.
## Im Programm eintragen
| Feld | Wert |
|---|---|
| Base URL | `http://127.0.0.1:8099/v1` |
| API-Key | beliebig — oder `WRAPPER_API_KEY`, falls gesetzt |
| Modell | ein Name von der Hilfsseite, z.B. `qwen3-8b` |
| Kontextfenster | der Wert von der Hilfsseite (nicht mehr eintragen, die Box gibt nur so viel her) |
**Läuft das Programm selbst in Docker**, ist `127.0.0.1` dort der eigene
Container. Dann das Netz `aria-llm` anhängen und `http://openai-rvs-wrapper:8099/v1`
nehmen:
```yaml
services:
openhands:
# …
networks: [default, aria-llm]
networks:
aria-llm:
external: true
```
## Was der Cluster dahinter macht
- Jedes Modell steht in der Liste **einmal**, egal auf wie vielen Karten es liegt.
- Liegt es auf mehreren Karten, verteilt der Cluster bei Last. Fällt eine Box
mitten in der Anfrage aus, übernimmt eine andere mit demselben Modell.
- Welche Modelle es gibt, legt nur die ARIA-Diagnostic fest
(Einstellungen → LLM-Karten). Ein Programm kann nichts laden oder entladen.
- Fehler (Modell nicht geladen, Cluster nicht erreichbar) kommen als
HTTP-Fehler mit OpenAI-Fehlerobjekt, nie als normale Antwort.
- `stream: true` geht; die Antwort kommt derzeit in einem Rutsch statt Wort für Wort.
Endpunkte: `GET /v1/models`, `POST /v1/chat/completions`, `GET /health`, `GET /` (Hilfsseite).
Die Schnittstelle dahinter: [`docs/LLM-SCHNITTSTELLE.md`](../../docs/LLM-SCHNITTSTELLE.md).
@@ -0,0 +1,25 @@
# OpenAI-RVS-Wrapper — der ARIA-LLM-Cluster als OpenAI-Endpunkt auf diesem Rechner.
# Laeuft neben dem Programm, das ihn nutzen soll (OpenHands, Continue, …).
#
# cp .env.example .env # RVS_HOST, RVS_PORT, RVS_TOKEN eintragen
# docker compose up -d --build
# → Hilfsseite: http://127.0.0.1:8099/
services:
openai-rvs-wrapper:
build: .
container_name: openai-rvs-wrapper
env_file: .env
ports:
# Standard: nur dieser Rechner. Fuer Zugriff aus dem LAN WRAPPER_BIND=0.0.0.0
# setzen — dann unbedingt WRAPPER_API_KEY vergeben.
- "${WRAPPER_BIND:-127.0.0.1}:${WRAPPER_PORT:-8099}:8099"
environment:
- WRAPPER_PORT=8099
networks: [aria-llm]
restart: unless-stopped
# Programme, die selbst in Docker laufen, erreichen 127.0.0.1 des Hosts nicht —
# sie haengen sich an dieses Netz und nehmen http://openai-rvs-wrapper:8099/v1
networks:
aria-llm:
name: aria-llm
+1
View File
@@ -0,0 +1 @@
websockets==13.1
+367
View File
@@ -0,0 +1,367 @@
"""OpenAI-RVS-Wrapper — der ARIA-LLM-Cluster als OpenAI-Endpunkt auf 127.0.0.1.
Laeuft als kleiner Container NEBEN einem Programm, das nur "OpenAI-kompatibel"
kann (OpenHands, Continue, Open WebUI …). Fuer das Programm ist der Cluster
dann einfach http://127.0.0.1:8099/v1. Der Wrapper selbst braucht nur eine
ausgehende Verbindung zum RVS — wie jede KI-Box auch.
Programm ──HTTP──▶ Wrapper ──RVS──▶ ARIA-Bridge (Router) ──RVS──▶ KI-Box / Karte
GET / Hilfsseite: welche Modelle gibt es, was eintragen
GET /v1/models jedes geladene Modell EINMAL (egal auf wie vielen Karten)
POST /v1/chat/completions auch mit stream=true und tools
GET /health RVS verbunden? Antwortet die ARIA-Bridge?
Welche Box/Karte antwortet, entscheidet die ARIA-Bridge (Last, Ausfall-Ersatz).
Fehler kommen als HTTP-Fehler mit OpenAI-Fehlerobjekt zurueck, nie als normale
Antwort — ein Programm wuerde einen Fehlertext sonst als Aussage der KI in
seinen Verlauf schreiben.
Schnittstelle dahinter: docs/LLM-SCHNITTSTELLE.md
"""
from __future__ import annotations
import asyncio
import hmac
import json
import logging
import os
import ssl
import time
import uuid
import websockets
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("openai-rvs-wrapper")
RVS_HOST = os.getenv("RVS_HOST", "").strip()
RVS_PORT = os.getenv("RVS_PORT", "443").strip()
RVS_TLS = os.getenv("RVS_TLS", "true").strip().lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
PORT = int(os.getenv("WRAPPER_PORT", "8099"))
API_KEY = os.getenv("WRAPPER_API_KEY", "").strip()
REQUEST_TIMEOUT_S = float(os.getenv("REQUEST_TIMEOUT_S", "660")) # > CLUSTER_LLM_TIMEOUT_S der Bridge
MODELS_CACHE_S = float(os.getenv("MODELS_CACHE_S", "10"))
MAX_BODY = 32 * 1024 * 1024
# ── RVS ─────────────────────────────────────────────────────────────
class Rvs:
"""Eine Verbindung, ein Leser. Antworten werden per (Typ, requestId) an
die wartende Anfrage gegeben — der RVS-Raum ist ein Verteiler, wir sehen
auch Nachrichten fuer andere Teilnehmer und ignorieren sie."""
def __init__(self):
self.ws = None
self.wait: dict = {}
self.connected_since = 0.0
@property
def connected(self) -> bool:
return self.ws is not None
async def run(self) -> None:
scheme = "wss" if RVS_TLS else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}/?token={RVS_TOKEN}"
backoff = 1
while True:
try:
ctx = ssl.create_default_context() if RVS_TLS else None
async with websockets.connect(url, ssl=ctx, max_size=64 * 1024 * 1024,
ping_interval=20, ping_timeout=20) as ws:
self.ws, self.connected_since, backoff = ws, time.time(), 1
logger.info("RVS verbunden: %s://%s:%s", scheme, RVS_HOST, RVS_PORT)
async for raw in ws:
try:
m = json.loads(raw)
except Exception:
continue
p = m.get("payload") or {}
f = self.wait.get((m.get("type"), p.get("requestId")))
if f and not f.done():
f.set_result(p)
except Exception as e:
logger.warning("RVS-Verbindung weg (%s) — neuer Versuch in %ds", e, backoff)
finally:
self.ws = None
for f in list(self.wait.values()):
if not f.done():
f.set_exception(ConnectionError("RVS-Verbindung unterbrochen"))
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
async def call(self, mtype: str, answer: str, payload: dict, timeout: float) -> dict:
if not self.ws:
raise ConnectionError("keine Verbindung zum RVS")
rid = str(uuid.uuid4())
fut = asyncio.get_running_loop().create_future()
self.wait[(answer, rid)] = fut
try:
await self.ws.send(json.dumps({"type": mtype, "payload": dict(payload, requestId=rid),
"timestamp": int(time.time() * 1000)}))
return await asyncio.wait_for(fut, timeout)
finally:
self.wait.pop((answer, rid), None)
rvs = Rvs()
_models_cache = {"at": 0.0, "models": None}
async def cluster_models() -> list:
"""[{model, ctx, available, cards}] — jedes Modell einmal. Kurz gecacht,
damit ein Programm, das /v1/models dauernd fragt, den Cluster nicht flutet."""
if _models_cache["models"] is not None and time.time() - _models_cache["at"] < MODELS_CACHE_S:
return _models_cache["models"]
res = await rvs.call("llm_cluster_models_request", "llm_cluster_models", {}, 6)
_models_cache.update(at=time.time(), models=res.get("models") or [])
return _models_cache["models"]
# ── HTTP ────────────────────────────────────────────────────────────
def oai_error(message: str, code: str, etype: str = "invalid_request_error") -> dict:
return {"error": {"message": message, "type": etype, "param": None, "code": code}}
async def reply(writer, status: int, payload, ctype="application/json; charset=utf-8", headers=None):
body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode("utf-8")
reason = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 404: "Not Found", 413: "Payload Too Large",
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout"}.get(status, "Error")
head = (f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {len(body)}\r\n"
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n")
for k, v in (headers or {}).items():
head += f"{k}: {v}\r\n"
writer.write((head + "\r\n").encode("utf-8") + body)
await writer.drain()
def authorized(headers: dict) -> bool:
if not API_KEY:
return True
auth = headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
return bool(token) and hmac.compare_digest(token, API_KEY)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
line = await asyncio.wait_for(reader.readline(), 15)
if not line:
return
try:
method, path, _ = line.decode("utf-8", "ignore").strip().split(" ", 2)
except ValueError:
return await reply(writer, 400, oai_error("bad request line", "bad_request"))
headers = {}
while True:
h = await asyncio.wait_for(reader.readline(), 10)
if not h or h in (b"\r\n", b"\n"):
break
k, _, v = h.decode("utf-8", "ignore").partition(":")
headers[k.strip().lower()] = v.strip()
n = int(headers.get("content-length", "0") or "0")
if n > MAX_BODY:
return await reply(writer, 413, oai_error("Anfrage zu gross", "too_large"))
body = await reader.readexactly(n) if n else b""
path = path.split("?", 1)[0].rstrip("/") or "/"
if method == "OPTIONS":
return await reply(writer, 200, b"", "text/plain", {
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"})
if method == "GET" and path == "/":
return await reply(writer, 200, HELP_PAGE.encode("utf-8"), "text/html; charset=utf-8")
if method == "GET" and path == "/health":
bridge_ok = False
if rvs.connected:
try:
await rvs.call("llm_cluster_models_request", "llm_cluster_models", {}, 4)
bridge_ok = True
except Exception:
pass
return await reply(writer, 200 if bridge_ok else 503, {
"rvs": rvs.connected, "bridge": bridge_ok,
"rvsUrl": f"{'wss' if RVS_TLS else 'ws'}://{RVS_HOST}:{RVS_PORT}", "apiKey": bool(API_KEY)})
if method == "GET" and path == "/api/models":
# Fuer die Hilfsseite. Nur Modellnamen — kein Schutz noetig.
try:
return await reply(writer, 200, {"ok": True, "models": await cluster_models()})
except Exception as e:
return await reply(writer, 503, {"ok": False, "error": _unreachable(e)})
if not path.startswith("/v1/"):
return await reply(writer, 404, oai_error(f"{method} {path} gibt es nicht", "not_found"))
if not authorized(headers):
return await reply(writer, 401, oai_error("API-Key fehlt oder ist falsch (Authorization: Bearer <KEY>).",
"invalid_api_key", "authentication_error"))
if method == "GET" and path == "/v1/models":
try:
models = await cluster_models()
except Exception as e:
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
now = int(time.time())
return await reply(writer, 200, {"object": "list", "data": [
{"id": m["model"], "object": "model", "created": now, "owned_by": "aria-cluster",
"context_length": m.get("ctx") or None} for m in models]})
if method == "POST" and path == "/v1/chat/completions":
try:
req = json.loads(body.decode("utf-8", "ignore") or "{}")
except Exception as e:
return await reply(writer, 400, oai_error(f"kein gueltiges JSON: {e}", "bad_json"))
payload = {"model": req.get("model") or "", "messages": req.get("messages"),
"max_tokens": req.get("max_completion_tokens") or req.get("max_tokens"),
"temperature": req.get("temperature"), "stop": req.get("stop"), "tools": req.get("tools")}
try:
res = await rvs.call("llm_cluster_request", "llm_cluster_response",
{k: v for k, v in payload.items() if v is not None}, REQUEST_TIMEOUT_S)
except asyncio.TimeoutError:
return await reply(writer, 504, oai_error(
"Der Cluster hat nicht rechtzeitig geantwortet.", "timeout", "server_error"))
except Exception as e:
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
if not res.get("ok"):
code = res.get("errorCode") or "model_error"
status = {"bad_request": 400, "model_unavailable": 404, "timeout": 504}.get(code, 502)
return await reply(writer, status, oai_error(
res.get("error") or "Fehler", "model_not_found" if code == "model_unavailable" else code,
"invalid_request_error" if status < 500 else "server_error"))
return await completion(writer, req, res)
return await reply(writer, 404, oai_error(f"{method} {path} gibt es nicht", "not_found"))
except (asyncio.IncompleteReadError, asyncio.TimeoutError, ConnectionError):
pass
except Exception:
logger.exception("HTTP-Fehler")
try:
await reply(writer, 502, oai_error("interner Fehler im Wrapper", "internal", "server_error"))
except Exception:
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
def _unreachable(e: Exception) -> str:
if not rvs.connected:
return f"Keine Verbindung zum RVS ({RVS_HOST}:{RVS_PORT}) — RVS_HOST/RVS_TOKEN in der .env pruefen."
if isinstance(e, asyncio.TimeoutError):
return "RVS verbunden, aber die ARIA-Bridge antwortet nicht (laeuft ARIA? stimmt das RVS_TOKEN?)."
return str(e)
async def completion(writer, req: dict, res: dict) -> None:
cid = "chatcmpl-" + uuid.uuid4().hex[:24]
created = int(time.time())
model = res.get("model") or req.get("model") or ""
content = res.get("content") or ""
tcs = res.get("tool_calls") or None
finish = "tool_calls" if tcs else "stop"
usage = res.get("usage") or {}
extra = {"X-Aria-Node": str(res.get("node", "")), "X-Aria-Gpu": str(res.get("gpu", "")),
"X-Aria-Attempts": str(res.get("attempts", 1))}
if not req.get("stream"):
msg = {"role": "assistant", "content": content if (content or not tcs) else None}
if tcs:
msg["tool_calls"] = tcs
return await reply(writer, 200, {
"id": cid, "object": "chat.completion", "created": created, "model": model,
"choices": [{"index": 0, "message": msg, "finish_reason": finish}], "usage": usage}, headers=extra)
# stream=true: die Boxen liefern (noch) am Stueck — die fertige Antwort geht
# im Stream-Format in einem Rutsch raus. Funktioniert mit jedem Client.
head = ("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\n"
"Connection: close\r\nAccess-Control-Allow-Origin: *\r\n"
+ "".join(f"{k}: {v}\r\n" for k, v in extra.items()) + "\r\n")
writer.write(head.encode("utf-8"))
def chunk(delta: dict, fin=None) -> bytes:
return ("data: " + json.dumps({"id": cid, "object": "chat.completion.chunk", "created": created,
"model": model, "choices": [{"index": 0, "delta": delta,
"finish_reason": fin}]},
ensure_ascii=False) + "\n\n").encode("utf-8")
delta = {"role": "assistant", "content": content}
if tcs:
delta["tool_calls"] = [dict(tc, index=i) for i, tc in enumerate(tcs)]
writer.write(chunk(delta))
writer.write(chunk({}, finish))
if usage and (req.get("stream_options") or {}).get("include_usage"):
writer.write(("data: " + json.dumps({"id": cid, "object": "chat.completion.chunk", "created": created,
"model": model, "choices": [], "usage": usage}) + "\n\n").encode())
writer.write(b"data: [DONE]\n\n")
await writer.drain()
HELP_PAGE = """<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ARIA LLM-Cluster</title>
<style>
body{margin:0;background:#0A0A14;color:#E0E0F0;font:14px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:760px;margin:0 auto;padding:24px 16px}
h1{font-size:20px;margin:0 0 4px} .sub{color:#8888AA;margin:0 0 20px}
.card{background:#12121E;border:1px solid #2A2A3E;border-radius:10px;padding:14px 16px;margin-bottom:14px}
.k{color:#8888AA;font-size:12px} code{background:#0A0A14;border:1px solid #2A2A3E;border-radius:5px;padding:2px 6px}
table{width:100%;border-collapse:collapse} td,th{padding:7px 6px;text-align:left;border-bottom:1px solid #1E1E2E}
th{color:#8888AA;font-weight:500;font-size:12px} button{background:#1E1E2E;color:#E0E0F0;border:1px solid #333;
border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px} button:hover{border-color:#0096FF}
.ok{color:#34C759}.err{color:#FF6B6B}.mute{color:#555570}
</style></head><body><main>
<h1>ARIA LLM-Cluster</h1>
<p class="sub">OpenAI-Endpunkt für Programme auf diesem Rechner — dahinter alle Modelle, die auf den KI-Boxen geladen sind.</p>
<div class="card"><div class="k">Status</div><div id="status" class="mute">prüfe …</div></div>
<div class="card">
<div class="k">Im Programm eintragen (OpenAI-kompatibel)</div>
<p style="margin:6px 0">Base URL: <code id="base"></code> <button onclick="copy(document.getElementById('base').textContent)">kopieren</button></p>
<p style="margin:6px 0">API-Key: <span id="keyinfo" class="mute"></span></p>
<p style="margin:6px 0" class="k">Läuft das Programm selbst in Docker, statt 127.0.0.1 den Namen
<code>openai-rvs-wrapper</code> nehmen und das Netz <code>aria-llm</code> anhängen (siehe README).</p>
</div>
<div class="card"><div class="k">Modelle im Cluster — Name genau so eintragen</div>
<table><thead><tr><th>Modell</th><th>Kontext</th><th>Karten</th><th></th></tr></thead><tbody id="models">
<tr><td colspan="4" class="mute">lade …</td></tr></tbody></table></div>
<p class="mute" style="font-size:12px">Auf welcher Box und Karte ein Auftrag läuft, entscheidet der Cluster — bei
Last verteilt, bei Ausfall einer Box übernimmt eine andere mit demselben Modell.</p>
</main><script>
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function copy(t){navigator.clipboard&&navigator.clipboard.writeText(t)}
document.getElementById('base').textContent=location.origin+'/v1';
async function load(){
try{const h=await (await fetch('/health')).json();
document.getElementById('status').innerHTML=h.bridge?'<span class="ok">✓ verbunden mit dem Cluster</span> <span class="mute">('+esc(h.rvsUrl)+')</span>'
:(h.rvs?'<span class="err">RVS verbunden, aber die ARIA-Bridge antwortet nicht</span>':'<span class="err">keine Verbindung zum RVS — .env prüfen</span>');
document.getElementById('keyinfo').innerHTML=h.apiKey?'der WRAPPER_API_KEY aus der .env':'beliebig (kein Key gesetzt — der Wrapper ist nur lokal erreichbar)';
}catch(e){document.getElementById('status').innerHTML='<span class="err">Wrapper antwortet nicht</span>'}
try{const d=await (await fetch('/api/models')).json();const tb=document.getElementById('models');
if(!d.ok){tb.innerHTML='<tr><td colspan="4" class="err">'+esc(d.error)+'</td></tr>';return}
if(!d.models.length){tb.innerHTML='<tr><td colspan="4" class="mute">Kein Modell geladen — in der ARIA-Diagnostic unter Einstellungen → LLM-Karten.</td></tr>';return}
tb.innerHTML=d.models.map(m=>'<tr><td><code>'+esc(m.model)+'</code></td><td>'+(m.ctx?esc(m.ctx)+' Tokens':'<span class="mute">?</span>')
+'</td><td>'+esc(m.available)+'</td><td><button onclick="copy(\\''+esc(m.model)+'\\')">kopieren</button></td></tr>').join('');
}catch(e){}
}
load();setInterval(load,10000);
</script></body></html>
"""
async def main() -> None:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN muessen in der .env stehen — Abbruch")
return
asyncio.create_task(rvs.run())
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
logger.info("OpenAI-Endpunkt auf :%d (%s) — Hilfsseite: http://127.0.0.1:%d/", PORT,
"mit API-Key" if API_KEY else "ohne API-Key", PORT)
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())
+5 -1
View File
@@ -78,7 +78,11 @@ VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
# 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()
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
# durch "void", taugt also nicht zur Anzeige.
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
WORKER_SERVICE = "voxtral"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
+5 -1
View File
@@ -77,7 +77,11 @@ 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()
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
# durch "void", taugt also nicht zur Anzeige.
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
WORKER_SERVICE = "whisper"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))