Compare commits

...
87 Commits
Author SHA1 Message Date
duffyduck 3773178726 release: bump version to 0.2.6.2 2026-11-01 00:17:38 +01:00
duffyduckandClaude Opus 5.5 3fbe2dd090 feat(diagnostic): Beliebtheit im Modell-Katalog — Downloads und Likes von HuggingFace
HF hat keine Sternebewertung; es gibt Downloads (30 Tage, gesamt) und
Likes. Beide sagen 'viel benutzt', nicht 'gut' (viele Downloads sind
Automatik, Likes oft beim Entdecken). Angezeigt unter dem Modellnamen mit
Hinweis im Tooltip; beim Hinzufuegen sofort, beim taeglichen Abgleich
aufgefrischt. ℹ: Zeile Beliebtheit mit Einordnung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:19:24 +01:00
duffyduckandClaude Opus 5.5 237a216945 feat(diagnostic): Sicherheit im Modell-Katalog — Anbieter, HF-Scan, Skripte im Repo
Die Box laedt nur die GGUF-Datei (llama.cpp -hf / paralleler Download per
sha256); Skripte, Pickle u.ae. im selben Repo kommen nie auf die Box. Liegen
sie dort, ist das trotzdem ein Warnsignal fuer den Anbieter.

- hfRepoSafety: HF-Dateiliste mit securityFileStatus (ProtectAI, AV,
  Pickle-Import, VirusTotal) → Befunde (unsafe/suspicious/caution …),
  Anzahl geprueft-sicher, riskante Dateien (.py/.sh/.exe/.pkl/.pt …),
  bekannter Anbieter (Hersteller + bekannte GGUF-Umwandler).
- Katalog: "⚠ Befund" / "⚠ Skripte" / "✓" je Eintrag; beim Hinzufuegen
  sofort, Bestand beim naechsten Aktualisieren.
- ℹ: Abschnitt "Sicherheit".
- Laden/Nur downloaden: bei Befunden erst Rueckfrage.

Getestet gegen HuggingFace: unsloth/mradermacher bekannt und ohne Befund;
Virtue-AI-HUB/VulnLLM-R-7B (Original) mit Befund "caution" an
training_args.bin (Pickle mit Imports) — das GGUF von mradermacher ist sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:15:48 +01:00
duffyduckandClaude Opus 5.5 9529bb0b16 feat(app): Aufnahme verwerfen — Muelleimer neben dem Aufnahmeknopf
Bisher hiess "Stopp" immer "senden"; wer es sich anders ueberlegt hatte,
konnte die Aufnahme nicht zuruecknehmen.

- App: waehrend der Aufnahme 🗑 links neben dem roten Knopf. Verwirft ueber
  ariaStopRecording('user-discard') → cancelStreamingRecording; das leere
  Endpoint raeumt die Sprechblase ab, zurueck aufs Wake-Word.
- Fund dabei: "cancel" schickte der STT-Bridge nur stt_stream_end — die
  transkribierte trotzdem und der Text ging an ARIA (fiel nur nicht auf,
  weil cancel bisher bei Stille kam). Jetzt:
  - Whisper- und Voxtral-Bridge: stream_end mit reason "cancel:*" →
    discard, kein Transcribe, leerer Text (reason "cancelled").
  - aria-bridge merkt sich verworfene requestIds (2 min) und wirft einen
    trotzdem ankommenden Text weg — Schutz auch bei alter STT-Bridge.

Getestet: Whisper-Bridge mit nachgebautem Runner (Stopp → Text, Muelleimer
→ kein Transcribe, leer). App: TypeScript ohne Fehler; auf dem Handy noch
nicht ausprobiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:05:35 +01:00
duffyduckandClaude Opus 5.5 91619d9307 feat(diagnostic): Katalog zeigt, ob ein Modell Werkzeuge kann (🔧) + Filter
Erkennung: Chat-Vorlage des GGUF (HF expand[]=gguf) kennt ein Werkzeug-
Format, oder Schlagworte wie function-calling/tool-use. Zeigt, OB das
Format da ist — nicht, wie gut das Modell damit umgeht.
- Neue und hinzugefuegte Eintraege bekommen tools: true/false/null,
  bestehende beim naechsten "Von HuggingFace aktualisieren".
- Liste: 🔧-Abzeichen; Haken "nur Modelle mit Werkzeugen" (Standard an)
  blendet Modelle ohne Werkzeug-Format aus (unbekannt bleibt sichtbar).
- ℹ: Zeile "Werkzeuge: ja / nein / unbekannt".

Getestet gegen HuggingFace (7 Modelle, u.a. Hermes 3 ueber das Schlagwort,
weil dessen offizielle Vorlage minimal ist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:41:24 +01:00
duffyduckandClaude Opus 5.5 c8911316d7 feat(brain): lokale Modelle bewusst waehlen — pro Chat (🧠) oder "@lokal …"
Bisher gab es nur den Schalter "leichte Fragen lokal / alles lokal". Jetzt
waehlt Stefan selbst, wo ein lokales Modell antwortet:
- Pro Chat: der Modell-Picker (🧠, context-model) bietet zusaetzlich die
  gerade geladenen lokalen Modelle als "local:<modell>" (Gruppe "Lokal").
  Steht ein Chat darauf, laeuft JEDER Turn dort lokal — z.B. ein Pentest-
  Projekt auf einem unzensierten Modell. Claude ist in dem Chat raus.
- Eine Frage: "@lokal …" (geladenes Modell) oder "@lokal:<modell> …".
- Lehnt Claude ab, nennt ARIA beide Wege; sie leitet NICHT selbst weiter.

Lokaler Turn: Identitaet + Hinweis "keine Werkzeuge, nichts behaupten",
Notizzettel + Fenster nach Kontext (local_compact), <think> entfernt,
Metrik. Fehler (Modell nicht geladen …) werden angezeigt, aber nicht als
ARIAs Antwort in den Verlauf geschrieben. Antwort-Quelle "local" mit
Modellname und Grund ("pro-chat" / "einmalig").

Getestet: Erkennung von @lokal (auch mit Modell, nach GPS-Hinweis, nicht
bei "@lokalisierung"). Durchlauf mit echtem Modell noch nicht (Karte im
Gegenlauf belegt, Brain nicht neu gebaut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:33:23 +01:00
duffyduckandClaude Opus 5.5 d289d9404a feat(diagnostic): Katalog von HuggingFace — Trends, Code, unzensiert; taeglich; aufraeumen
"Von HuggingFace aktualisieren" holte nur die 40 meistgeladenen GGUF
aller Zeiten — neue und unzensierte Modelle kamen so kaum vor. Jetzt:
- Blickwinkel: beliebt, im Trend (trendingScore), Code, unzensiert
  (uncensored/heretic/abliterated).
- Nur Sprachmodelle (pipeline_tag + Namensfilter gegen Bild/Audio/Musik/
  Video/Embedding) und hoechstens LLM_CATALOG_MAX_GB (30).
- Markierungen in der Liste: "unzensiert", "neu" (letzte 7 Tage).
- Einmal am Tag automatisch (LLM_CATALOG_REFRESH_HOURS, 0 = aus).
- Aufraeumen: HF-Eintraege aelter als LLM_CATALOG_KEEP_DAYS (30), auf
  keiner Box und nicht mehr gelistet, fliegen raus; kuratierte und von
  Hand hinzugefuegte bleiben.

Getestet gegen HuggingFace mit Test-Katalog: 43 neue in 2 s, keine
Bild-/Audio-Modelle mehr, groesstes 29,2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:26:11 +01:00
duffyduckandClaude Opus 5.5 faa4868ee2 feat(diagnostic): ℹ im Modell-Katalog — was kann das Modell, ist es zensiert?
Neben jedem Katalog-Eintrag ℹ → Fenster mit:
- Kurzbeschreibung auf Deutsch, beim ersten Oeffnen vom LOKALEN Modell aus
  der Modellkarte (Grundmodell + GGUF-Repo) erzeugt und im Katalog
  gespeichert (summary_de) — so hat jedes neue Modell eine, ohne Pflege.
  Hinweis "kann Fehler enthalten". Laeuft kein Modell: Meldung.
- Feste deutsche Texte je Modellfamilie (Qwen3-Coder, Foundation-Sec,
  VulnLLM, Dolphin, Hermes, Lexi, Gemma, Qwen, Llama, Mistral, DeepSeek,
  Phi), erkannt als ganzes Wort in Name/Grundmodell/Schlagworten.
- "unzensiert" / "mit Schutzfiltern" (uncensored/abliterated/heretic/
  dolphin/lexi … in Name oder Schlagworten).
- Fakten von HF: Grundmodell, Autor, Lizenz, Beliebtheit, Stand,
  Schlagworte; Auszug der Modellkarte (Link-Absaetze uebersprungen).

Server: /api/llm-model-info, /api/llm-model-summary, rvsRequest() als
Promise-Variante fuer eigene RVS-Anfragen.

Getestet: Info-Teil gegen HuggingFace (4 Modelle). Die Zusammenfassung
durch das lokale Modell ungetestet (Karte gerade im Gegenlauf belegt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:24:33 +01:00
duffyduckandClaude Opus 5.5 848eee5d1c feat(diagnostic): Modell per HuggingFace-Link in den Katalog — mit GGUF-Vorschlaegen
Bisher kamen Modelle nur ueber "Von HuggingFace aktualisieren" und die
Trends in den Katalog. Jetzt: Feld "HuggingFace-Link oder org/name".
Hat das Repo kein GGUF (z.B. nur safetensors wie Virtue-AI-HUB/VulnLLM-R-7B
oder fdtn-ai/Foundation-Sec-8B), sucht der Server GGUF-Fassungen desselben
Modells (HF-Suche, filter=gguf, nach Downloads) und bietet sie mit
"Diese nehmen" an. Hinzugefuegte stehen danach gefiltert in der Liste.

Getestet gegen HuggingFace: beide Beispiele ohne GGUF erkannt, je 5
ladbare GGUF-Fassungen gefunden (Q4_K_M ~4,7–4,9 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:14:24 +01:00
duffyduckandClaude Opus 5.5 2cd8f1a0de feat: Kennwort je FritzBox-Sicherung — gespeichert, per 👁 anzeigen, kopieren
Bisher merkte sich der Satellit nur die QUELLE des Kennworts. Wurde das
Sicherungs-Kennwort spaeter geaendert, passte es nicht mehr zu aelteren
Sicherungen — auch "Wiederherstellen" haette das falsche genommen.

- Satellit: das benutzte Kennwort liegt verschluesselt (Fernet, wie der
  Zugangsdaten-Speicher) in der Begleitdatei; Liste/Ansicht geben es nie
  heraus. Wiederherstellen nimmt genau dieses. fritzbox.backup_password
  liefert es (aeltere Sicherungen: aktuelles der Quelle + Hinweis).
  Feld "kennwort" im Sicherungs-Ergebnis heisst jetzt "verschluesselt_mit".
- Diagnostic-Server: Ergebnisse mit Kennwort gehen nur an den Browser,
  der gefragt hat; ohne Fragenden werden sie verworfen, nie an alle.
- Brain: fritzbox.backup_password ist fuer ARIA gesperrt (Kennwoerter
  gehen nicht ans Sprachmodell).
- Diagnostic: 👁 in der Kennwort-Spalte → Zeile mit dem Kennwort und
  "Kopieren" — klappt auch ohne https (Fallback ueber execCommand).

Getestet: Satellit (Kennwort nicht im Klartext gespeichert, nach
Kennwort-Aenderung zeigt 👁 und nimmt Wiederherstellen das alte), Fenster
in headless Chromium ohne Clipboard-API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:26:37 +01:00
duffyduckandClaude Opus 5.5 e44e2c2a70 fix(diagnostic): Sicherungsliste — Kennwort-Spalte mit 🔒 und Erklaerung
Die Spalte zeigt, mit WELCHEM Kennwort die Sicherung verschluesselt ist
(Quelle, nie der Wert) — ohne Erklaerung war das unverstaendlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:50 +01:00
duffyduckandClaude Opus 5.5 c77526e92a feat: FritzBox-Sicherung herunterladen + maximale Anzahl je Box einstellbar
- Satellit: fritzbox.backup_get liefert eine vorhandene Sicherung (nur aus
  dem Sicherungsordner, bis 20 MB). Bei ARIA landet sie unter
  /shared/uploads/ und kann in den Chat.
- Zugangsdaten FritzBox: "Maximale Sicherungen" (1–100, leer = 10). Bei
  jeder neuen Sicherung fliegt die aelteste dieser Box raus; die Liste
  meldet die Zahl mit (Kopfzeile im 💾-Fenster).
- Diagnostic: ⬇ je Sicherung laedt die .export-Datei herunter.

Getestet: Satellit (Limit 2 → die zwei neuesten bleiben, Download, Pfad-
schutz), Diagnostic in headless Chromium (Download-Inhalt, Feld laden,
0 abgelehnt, 20 gespeichert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:12 +01:00
duffyduckandClaude Opus 5.5 0a1e334169 fix(satellite): FritzBox-Tastenbestaetigung — Token im SOAP-Header mitschicken
An der Kellerbox kam nach dem Tastendruck "stopped" statt "authenticated".
Laut AVM "TR-064 Support – Authentication" (Kap. 6.4) muss der Token aus
X_AVM-DE_Auth SetConfig(start) bei GetState UND beim Wiederholen der
Aktion als <avm:token> im SOAP-Header stehen — ohne ihn ordnet die Box die
Bestaetigung nicht unserem Vorgang zu.

- _tr064(token=…) setzt den Header.
- GetState und die wiederholte Aktion laufen mit dem Token; Stop ebenso.
- Zustaende werden geloggt und erklaert (stopped/blocked/
  anotherauthprocess/failure); 867 (gesperrt, bis 60 min) und 868
  (beschaeftigt, ~2 min) mit verstaendlicher Meldung.

Getestet gegen eine nachgebaute Box, die sich wie in AVMs Doku verhaelt
(ohne Token "stopped", mit Token "authenticated").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:11 +01:00
duffyduckandClaude Opus 5.5 236e94da4a feat: alle FritzBoxen sichern (ueberspringt statt abzubrechen) + Tastenbestaetigung anzeigen/schalten
Satellit:
- fritzbox.backup_all: alle Boxen mit hinterlegtem Login nacheinander;
  nicht erreichbar (Port 49000), Fehler oder Taste noetig → notieren und
  weiter. Ergebnis: x von y gesichert + Grund je Box.
- fritzbox.second_factor: ist die Zusatzbestaetigung an, gibt es sie, und
  laesst sie sich per TR-064 schalten (nur wenn die Firmware eine Set-
  Aktion mit Enable-Argument im Auth-Dienst anbietet).
- fritzbox.second_factor_set: schaltet sie (confirmed, Taste per
  button_wait); sonst not_switchable + Weg ueber die Oberflaeche.

Diagnostic (💾-Fenster): Zeile "Tastenbestaetigung: an/aus" mit Aus-/
Einschalten (Rueckfrage, Countdown; nicht schaltbar → Hinweisfenster),
Knopf "Alle FritzBoxen sichern" mit Ergebnis je Box. Ausschalten/
Deaktivieren gilt jetzt als gefaehrliche Rueckfrage (roter Knopf).

Brain: Beschreibung fuer backup_all (je Satellit, weiter bei Fehlern) und
second_factor; Timeout 15 min fuer backup_all.

Getestet gegen nachgebaute Box (eine Box unerreichbar → uebersprungen,
Taste noetig ohne/mit Warten, Status, Schalten mit und ohne Setter) und das
Fenster in headless Chromium. Ob echte FRITZ!OS-Versionen das Schalten
per TR-064 anbieten, ist ungeprueft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:55:06 +01:00
duffyduckandClaude Opus 5.5 2bfa064ef6 feat: FritzBox-Sicherungen verwalten — Liste, Ansicht, Wiederherstellen, Loeschen
Satellit:
- sat_command laeuft als eigener Task (eine Aktion, die 2 min auf die
  Taste wartet, blockiert den Satelliten nicht mehr).
- Sicherung bekommt eine Begleitdatei (Box, Modell, Firmware, Seriennr.,
  Datum, QUELLE des Kennworts — nie der Wert).
- fritzbox.backups: Sicherungen je Box, neueste zuerst.
- fritzbox.backup_view: lesbar — Kopfdaten, wichtigste Einstellungen
  (Name, LAN/DHCP, WLAN, Kanal, DNS, SIP …), mit full alle Bereiche;
  verschluesselte Werte als «verschluesselt»; nur Dateien aus dem
  Sicherungsordner (kein Pfad von aussen).
- fritzbox.restore: X_AVM-DE_SetConfigFile — der Satellit stellt die Datei
  kurz per HTTP bereit (Zufallspfad, endet nach Abruf/10 min), die Box holt
  sie ab und startet neu. Nur mit confirmed, Taste per button_wait, mit dem
  Kennwort aus der Quelle der Sicherung; fremde Box nur mit other_box.
- fritzbox.backup_delete (mit confirmed). no_file fuer das Diagnostic.

Diagnostic: 💾 bei FritzBox-Geraeten → Sicherungen: Jetzt sichern,
ℹ (lesbar + Suche), Wiederherstellen, Loeschen; Sichern/Wiederherstellen
mit 2-Minuten-Countdown fuer die Taste, Ergebnis im Fenster (✓ / Taste
nicht gedrueckt / Grund), Erfolg blendet sich aus.

Brain: Beschreibung fuer Liste/Ansicht/Wiederherstellen/Loeschen.

Getestet: Satellit gegen nachgebaute Box (Sichern, Liste, Ansicht ohne
Geheimnisse, Pfadschutz, Wiederherstellen inkl. Abholen der Datei,
Loeschen), Diagnostic-Fenster in headless Chromium mit simuliertem
Satelliten. Gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:52:04 +01:00
duffyduckandClaude Opus 5.5 5af03eedb9 fix(satellite): Geheimnisse aus FritzBox-Antworten erreichen ARIA nicht
Lesende TR-064-Aktionen geben teils Geheimnisse zurueck (GetSecurityKeys →
WLAN-Schluessel, Kennwoerter, PINs). Die gingen bisher im Ergebnis an ARIA
und damit ans Sprachmodell. Felder mit Passphrase/Password/PreSharedKey/
WEPKey/Secret/PIN werden jetzt als '•••• (verborgen)' geliefert. Schickt
ARIA den Platzhalter in einer Set-Aktion zurueck, lehnt der Satellit ab —
sonst setzte die Box ihn als neuen Schluessel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:43:04 +01:00
duffyduckandClaude Opus 5.5 c545a1cd77 feat(satellite): Sicherungs-Kennwort je FritzBox — hinterlegt, stark erzwungen, ARIA kennt es nicht
- Zugangsdaten FritzBox: neues Feld "Kennwort fuer Sicherungen"
  (backupPass, verschluesselt auf dem Satelliten, mit 👁 wie die anderen).
- fritzbox.backup nimmt: ausdruecklich angegebenes > hinterlegtes
  Sicherungs-Kennwort > FritzBox-Login. Das Ergebnis nennt nur die Quelle,
  nie das Kennwort. ARIAs Beschreibung: kein 'password' uebergeben.
- Kennwort-Regel (Formular UND Satellit): mind. 12 Zeichen, Gross- und
  Kleinbuchstabe, Ziffer, Sonderzeichen. Schwaches Kennwort wird beim
  Speichern abgelehnt (Meldung im Formular), und eine Sicherung mit
  schwachem Kennwort (auch ueber das Login) bricht mit Hinweis ab.
- Formular: Eingabefelder mit Auge gleich hoch wie die anderen.

Getestet im Satelliten: Regel, Speichern schwach/stark (Login bleibt),
Sicherung ohne/mit hinterlegtem Kennwort; das Kennwort taucht im Ergebnis
nicht auf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:42:24 +01:00
duffyduckandClaude Opus 5.5 e91435a9f6 feat(satellite): FritzBox-Bestaetigung per Taste (2 min) + Sicherung in den Chat
Der Export der Einstellungen scheiterte mit "HTTP 500" — dahinter steckt
UPnP-Fehler 866: die Box will die Aktion zusaetzlich am Geraet bestaetigt.

- _tr064 meldet jetzt den UPnP-Fehlercode statt nur "HTTP 500".
- 866 → ohne button_wait sofort zurueck mit needs_button (ARIA fragt Stefan,
  ob er an der Box steht). Mit button_wait=true: X_AVM-DE_Auth SetConfig
  (start), GetState alle 2 s bis FB_BUTTON_WAIT_SEC (120 s); "authenticated"
  → Aktion erneut, sonst Timeout "Taste nicht gedrueckt, nichts ausgefuehrt"
  (Bestaetigung wird gestoppt).
- fritzbox.backup: X_AVM-DE_GetConfigFile mit Kennwort (Standard: Login-
  Kennwort), Download mit Digest-Auth, Kopie auf dem Satelliten (die
  letzten 10 je Box) und Datei zurueck an ARIA.
- Brain: Datei aus dem Ergebnis landet in /shared/uploads/, ARIA legt sie
  mit [FILE: …] in den Chat; Timeout 200 s bei button_wait; Beschreibung
  erklaert den Tasten-Ablauf.

Getestet gegen nachgebaute Box: ohne Bestaetigung → needs_button, Taste
nach 3 s → Sicherung geladen, keine Taste → Timeout. Gegen eine echte Box
(X_AVM-DE_Auth-Ablauf) noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:40:42 +01:00
duffyduckandClaude Opus 5.5 a58756ddfd fix(satellite): Router als router erkennen, Docker-Netze nicht als Geraete
Auf ARIAs VM fand der neue Satellit 58 Geraete, darunter die Container der
Docker-Netze (docker0/br-*/veth) — die sind keine Geraete und fallen jetzt
aus der ARP-Liste. Die FRITZ!Box 5590 kam per SSDP als 'media-renderer';
InternetGatewayDevice / FRITZ!Box heisst jetzt type 'router'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:27:09 +01:00
duffyduckandClaude Opus 5.5 116a947a4a feat(satellite): FritzBox komplett steuern — describe/call/wlan ueber TR-064
Statt einzelner fest programmierter Befehle nutzt der Satellit die
Selbstbeschreibung der Box (tr64desc.xml + SCPD je Dienst, ohne Anmeldung
lesbar): was DIESE Firmware kann, steht dort — keine Tabellen pro Modell.

- fritzbox.describe: alle TR-064-Aktionen der Box, je mit [liest]/
  [aendert]/[GEFAEHRLICH]; filter grenzt ein (WLAN, Dect, Hosts …).
- fritzbox.call: beliebige Aktion (service kurz "WLANConfiguration:3",
  args ohne "New"-Praefix moeglich). Alles ausser Get… braucht
  confirmed=true, sonst needs_confirmation — ARIA muss vorher fragen.
- fritzbox.wlan: alle WLANs inkl. Gast (index/ssid/an), schalten mit
  index + enable (+ confirmed).
- FritzBox per Geraetename ("Fritzbox Keller") statt IP ansprechbar.
- Allowlist kennt Platzhalter "fritzbox.*" (Default + .env.example).
- Brain: Tool-Beschreibung mit Ablauf (describe → call) und Freigabe-Regel,
  fritzbox.* mit 60 s Timeout.

Getestet gegen eine nachgebaute TR-064-Box: describe/filter, lesen,
schreiben ohne/mit Freigabe (ohne kommt nichts an der Box an), WLAN
schalten, Namensaufloesung, Allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:11:58 +01:00
duffyduckandClaude Opus 5.5 0bf00f9fc4 feat(satellite): fritzbox.hosts mit LAN-Port, Verbindungsart und Tempo
ARIA konnte "an welchem Port der FritzBox Keller haengt X" nicht
beantworten: fritzbox.hosts warf alles ausser Name/IP/MAC/aktiv weg. Die
Host-Liste der Box (TR-064 X_AVM-DE_GetHostListPath) enthaelt je nach
FRITZ!OS auch InterfaceType, X_AVM-DE_Port, X_AVM-DE_Speed und
X_AVM-DE_Guest — die gehen jetzt als interface, lan_port, speed_mbit,
guest mit. params.find filtert nach Name/IP/MAC. Hinweis im Ergebnis:
lan_port ist die Buchse DIESER Box (hinter Switch/Repeater dessen Port).
Tool-Beschreibung im Brain nennt die neuen Felder.

Getestet mit nachgebauter AVM-Host-Liste; gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:06:21 +01:00
duffyduckandClaude Opus 5.5 c934866bba feat(satellite): gespeicherte Zugangsdaten anzeigen, Passwort per Auge, FritzBox-Benutzer ermitteln
Das Zugangsdaten-Formular war beim erneuten Oeffnen leer, obwohl der
Satellit alles gespeichert hatte.

Satellit:
- sat_creds_list mit ip liefert je Typ die Felder ohne Geheimnisse
  (Benutzer, Port, SNMP-Version/v3-Stufe) und has_<feld> fuer Passwoerter;
  mit reveal (nur zusammen mit ip) auch die Geheimnisse dieses Geraets.
- sat_creds_set behaelt ein gespeichertes Geheimnis, wenn das Feld nicht
  mitgeschickt wird — Benutzer aendern, ohne das Passwort neu zu tippen.

Diagnostic-Server: Antworten mit Klartext (revealed) gehen nur an den
Browser, der gefragt hat, nicht an alle offenen Tabs.

Formular: Benutzer/Port/Version stehen beim Oeffnen drin, Passwoerter als
"•••••• gespeichert"; 👁 holt sie im Klartext bzw. schaltet um. Leeres
Passwortfeld = gespeichertes behalten. FritzBox: "Benutzer ermitteln"
(nur bei als FritzBox erkannten Geraeten) liest login_sid.lua?version=2 —
die Box nennt ihre Benutzer ohne Anmeldung; eingetragen wird der zuletzt
angemeldete.

Getestet: Satelliten-Logik per Unit-Test, Formular in headless Chromium
(Anzeige, Speichern ohne Passwort, Auge, Benutzer ermitteln).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:48:44 +01:00
duffyduckandClaude Opus 5.5 3a3ef81835 fix(satellite): Scan-Auftrag waehrend laufendem Scan wartet bis zum Ende
Kam sat_discover, waehrend schon ein Scan lief (z.B. der erste nach dem
Start, ~25 s), wartete der Satellit nur 6 s und lieferte die bisherige
Liste — nach einem Neustart leer → "Keine Geraete gefunden". Jetzt wartet
er, bis der laufende Scan fertig ist (hoechstens SCAN_MAX_SEC).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:14:40 +01:00
duffyduckandClaude Opus 5.5 2a0c8b8192 fix(satellite): Thread-Leck im mDNS-Scan — ServiceBrowser werden beendet
Jeder ServiceBrowser ist ein eigener Thread; zc.close() beendet ihn nicht
(zeroconf 0.151.5, lokal nachgestellt: +1 Thread pro Browser und Scan,
mit cancel() keiner). Bei 14 Diensttypen und einem Scan alle 5 Minuten
hatte der Satellit HomeOffice nach ~5 Tagen 18.841 Threads = Thread-Limit
des Containers. Seit 30.10. 12:19 scheiterte jeder Scan mit "[Errno 11]
Resource temporarily unavailable", auf sat_discover kam keine Antwort mehr
(auch die RVS-Verbindung brach zeitweise mit Errno 11 ab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:10:45 +01:00
duffyduckandClaude Opus 5.5 aeabba10de fix(satellite): Scan haengt nicht mehr ewig und blockiert keine anderen Auftraege
Der Satellit HomeOffice war verbunden (Heartbeat lief), beantwortete
sat_discover aber nicht mehr — auch ein zweiter Scan blieb ohne Antwort.
Ursache: Nachrichten werden nacheinander abgearbeitet (async for … await
_handle), und der Scan lief im Loop. Haengt ein Schritt (mDNS/SSDP/ARP/SNMP/
ADB), steht der ganze Satellit — Tunnel und Befehle eingeschlossen.

- sat_discover laeuft als eigener Task, der Loop bleibt frei.
- Ein Scan ist auf SCAN_MAX_SEC (90 s) begrenzt; danach kommt die letzte
  bekannte Liste zurueck, und das Log nennt den haengenden Schritt.
- Diagnostic: "Scanne Netz …" endet nach 2 Minuten mit einem Hinweis statt
  fuer immer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:32 +01:00
duffyduckandClaude Opus 5.5 3edcc36a74 fix(diagnostic): Download-Fenster — Hinweis statt gelbem ℹ, Zahlen mit Komma
Das Info-Zeichen ℹ erschien je nach Schrift als nacktes "i" und gelb wie
eine Warnung. Jetzt grau mit "Hinweis:". Prozent, GB und MB/s mit Komma
wie im Rest der Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:53:55 +01:00
duffyduckandClaude Opus 5.5 126e33c4c0 fix(llm): Kontext-Auswahl zeigt wieder Maximum + GB — auch fuer Modelle auf der Platte
Seit "erst waehlen, dann Laden" zeigte die Kontext-Liste fuer ein noch
nicht geladenes Modell nur feste Stufen — ohne Maximum und ohne
Speicherbedarf. Jetzt:

- llm-adapter: disk.models[].profiles — pro heruntergeladener
  Quantisierung Maximum (context_length) und GB je Stufe aus dem Kopf der
  GGUF (gleiche Rechnung wie fuer die geladene Karte, Annahme: alles auf
  der Karte). Gesplittete Modelle ausgenommen.
- Diagnostic (Karte + Katalog-Spalte): Stufen bis zum Maximum, "(Maximum)"
  markiert, GB je Stufe, "⚠ passt nicht" bzw. bei MoE "⚠ nur mit
  Aufteilen". Nicht heruntergeladen: feste Stufen + Hinweis im Tooltip.
  Katalog rechnet mit der gewaehlten Ziel-Karte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:49:10 +01:00
duffyduckandClaude Opus 5.5 e3fe93e0e9 feat(llm): "Nur downloaden" im Katalog, Karten-Liste "nur heruntergeladene"
Katalog: "Laden" heisst jetzt "Download + Laden", daneben "Nur downloaden":
das Modell kommt auf die Platte der Box (aus der Ziel-Karten-Auswahl), in
keine Karte. Liegt es schon dort, ein Hinweis statt Download.

llm-adapter: llm_provision_model mit downloadOnly → _download_only:
Dateiliste, Plattenpruefung, paralleler Download (strict: ohne Fallback auf
llama.cpp, jeder Fehler kommt als Grund zurueck), keine Karte, keine
Registry. Download-Fenster: "Heruntergeladen — liegt auf der Box".

Karten-Modellwahl: Haken "nur heruntergeladene" in der Liste, Standard an.
Zeigt nur, was schon auf dieser Box liegt; "— leer —" und die aktuelle
Auswahl bleiben immer sichtbar. Meldet die Box keine Platte (alter
Adapter), ist der Haken aus und gesperrt.

Getestet: download-only gegen HuggingFace (tiny-llamas) und mit falschem
Repo; Haken/Suche/Knoepfe in headless Chromium mit der echten Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:44:00 +01:00
duffyduckandClaude Opus 5.5 93a3070e5c feat(diagnostic): Modell + Kontext erst waehlen, dann "Laden"
LLM-Karten: Modell-Auswahl und Kontext-Dropdown laden nicht mehr sofort.
Die Zeile zeigt die Auswahl, daneben "Laden" (bzw. "Leeren") und "✕" zum
Verwerfen; erst "Laden" fragt nach (MoE-Aufteilung, Ersetzen) und laedt
mit genau diesem Kontext — vorher lud man zweimal (Modell, dann neuer
Kontext). Fuer ein noch nicht geladenes Modell zeigt der Kontext feste
Stufen ohne GB, die Schaetzung kennt die Box erst nach dem ersten Laden.
Die Auswahl ueberlebt das Neuzeichnen durch worker_update.

Modell-Katalog: neue Spalte "Kontext" hinter der Ziel-Karte; Ziel und
Kontext bleiben beim Neuzeichnen erhalten. Neuzeichnen nach Fokusverlust
300 ms spaeter, damit ein Klick auf "Laden" nicht ins Leere geht.

Getestet in headless Chromium mit der echten Seite: waehlen → kein Senden,
Kontext waehlen, Neuzeichnen, Laden → genau ein provision mit dem Kontext,
Verwerfen, Katalog mit Kontext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:40:58 +01:00
duffyduckandClaude Opus 5.5 5783076e27 feat(llm): Tempo der Netzwerkkarte im Download-Fenster
Die Box meldet bei jedem Download-Fortschritt ihre Netzwerkkarten (echte
Karten, die "up" sind) mit Tempo. Der Container sieht nur sein eigenes
eth0, darum liest er das /sys des Rechners (neu: /sys:/hostsys:ro im
llm-adapter). Das Download-Fenster zeigt pro Download z.B.
"🔌 enp5s0 100 Mbit/s (max. ~12 MB/s — Kabel/Port prüfen)" — orange unter
1 Gbit/s. Anlass: ki-box hing an einem 100-Mbit-Port, 8 Verbindungen
brachten darum nichts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:33:18 +01:00
duffyduckandClaude Opus 5.5 c511a6b5ae feat(diagnostic): eigene Dialoge statt alert/confirm/prompt
Alle 43 confirm(), 7 prompt() und 71 alert() der Diagnostic laufen jetzt
ueber uiConfirm/uiPrompt/uiAlert — gleicher Stil wie der Aufteilen-Dialog:
erster Absatz als Ueberschrift, Rest als Erklaerung, Esc/Klick daneben =
Abbrechen, Enter = OK. Rueckfragen zu Loeschen/Entfernen/Zuruecksetzen
bekommen einen roten "Ja, ausfuehren"-Knopf. Nebenbei faellt die Browser-
Sperre "weitere Dialoge verhindern" weg, an der runDiskCleanup frueher hing.

Die 16 Funktionen, die noch nicht async waren, sind es jetzt (alles
onclick/onchange-Handler, kein Aufrufer nutzt einen Rueckgabewert).
Getestet in headless Chromium: OK/Abbrechen/Esc/Enter, Eingabe, Hinweis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:31:28 +01:00
duffyduckandClaude Opus 5.5 87f521ee61 feat(llm): Downloads parallel, abbrechbar; heruntergeladene Modelle loeschen
llm-adapter:
- Paralleler Download: llama.cpp zieht ueber EINE Verbindung (oft 1-50 MB/s,
  auch bei 1 Gbit). Die Box holt fehlende Dateien jetzt vorher selbst in
  64-MB-Stuecken ueber LLM_DOWNLOAD_CONNECTIONS (Standard 8) Verbindungen,
  prueft die sha256 und legt sie im HF-Cache-Layout ab, das llama.cpp selbst
  benutzt (blobs/<sha256>, snapshots/<commit>/<datei>, refs/main). Die Karte
  bleibt waehrenddessen beim alten Modell. Scheitert es, laedt llama.cpp wie
  bisher selbst. Fortschritt zaehlt bei sparse-Dateien nur geschriebene Bytes.
- Abbrechen: llm_remove_model mit cancel=true (kein neuer Typ → RVS-Allow-List
  unveraendert). Waehrend des Downloads: Stopp + halbe Datei weg, Karte
  unveraendert. Waehrend llama.cpp laedt: wie ein Fehlschlag → Karte zurueck.
  Laeuft nichts mehr, schliesst die Antwort den haengenden Eintrag.
- Loeschen: llm_remove_model mit deleteFiles=true entfernt das Repo aus dem
  Cache — nicht, solange es auf einer Karte liegt oder geladen wird.
- worker_hello meldet "disk": freier Platz + heruntergeladene Modelle je Repo.

Diagnostic: Abbrechen-Knopf je laufendem Download; im Katalog "auf ki-box ·
18,6 GB" mit Loeschen-Knopf und eine Zeile mit dem freien Platz je Box.

Getestet gegen HuggingFace (ggml-org/tiny-llamas, 4 Verbindungen, 2-MB-
Stuecke: Layout + Pruefsumme + Abbruch) und Loeschen inkl. Sperren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:21:04 +01:00
duffyduckandClaude Opus 5.5 739ab85deb feat(llm): Speicherplatz vor dem Download pruefen, klarer Aufteilen-Dialog
llm-adapter: Vor jeder Aenderung an Registry/Belegung rechnet die Box aus,
was vom Modell noch fehlt (fertige Blobs zaehlen nicht), und vergleicht es
mit dem freien Platz minus Puffer (LLM_DISK_RESERVE_GB, Standard 2). Reicht
es nicht, werden halbe Downloads, an denen seit 30 min niemand schreibt,
geloescht und neu gerechnet; reicht es dann immer noch nicht, bricht das
Laden mit klarer Meldung im Download-Fenster ab ("braucht ca. X GB, frei
sind Y GB"). Frueher brach llama.cpp mitten im Download ab, und der Grund
kam ueber llama-swap nicht an.

Diagnostic: Die MoE-Frage war ein confirm(), bei dem "Abbrechen" "ganz auf
die Karte laden" hiess. Jetzt ein Dialog mit drei Knoepfen: Aufteilen
(empfohlen) / Ganz auf die Karte / Abbrechen (laedt nichts). Esc und Klick
daneben brechen ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:12:42 +01:00
duffyduckandClaude Opus 5.5 1e5afe7f65 feat(diagnostic): Karten-Modellwahl als Liste mit Suchfeld oben drin
Statt Suchfeld neben einem normalen Dropdown (Tippen → Liste oeffnen →
passt nicht → zurueck ins Feld) oeffnet ein Klick jetzt eine Liste mit dem
Suchfeld oben. Tippen filtert sofort, Auswahl per Klick oder Pfeiltasten +
Enter, Esc / Klick daneben / Scrollen schliesst. Das versteckte <select>
haelt weiter Wert und onchange (onLlmCardSelect unveraendert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:03:09 +01:00
duffyduckandClaude Opus 5.5 e219382a51 feat(diagnostic): Suche im Modell-Katalog und in den Karten-Dropdowns
- Modell-Katalog: Suchfeld (erscheint, sobald die Liste geladen ist),
  Teilsuche ueber Name, Beschreibung, Repo und Quantisierung; mehrere
  Woerter muessen alle vorkommen.
- LLM-Karten: Suchfeld ueber jedem Modell-Dropdown blendet nicht passende
  Modelle aus; "— leer —" und das geladene Modell bleiben sichtbar.
- Fix: Ein offenes Karten-Dropdown klappte nach ein paar Sekunden zu, weil
  jedes worker_update die Liste neu zeichnete. Solange ein Dropdown oder
  Suchfeld darin den Fokus hat, wird das Neuzeichnen aufgeschoben und beim
  Verlassen nachgeholt (gilt auch fuer die Ziel-Karte im Katalog).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:56:03 +01:00
duffyduckandClaude Opus 5.5 8576d4676e feat(diagnostic): Bestenlisten — Never Code Alone (lokale Coder-Modelle) ergaenzt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:23:33 +01:00
duffyduckandClaude Opus 5.5 35d0ea8c41 fix(brain): Fehlermeldung der CLI landet nicht mehr als ARIAs Antwort im Verlauf
29.10., 06:40: Claudes Anmeldung war kurz abgelaufen; die CLI lieferte
"Failed to authenticate: OAuth session expired …" mit is_error=true als
Text. Das Brain hat das als ARIAs Antwort gespeichert (nur der
Sicherheitsfilter wurde bisher abgefangen) — dieselbe Klasse Fehler, die
schon einmal einen Chat vergiftet hat.

Jetzt: is_error ohne Sicherheitsfilter wird wie eine Ausnahme behandelt —
klar markierter [Fehler: …]-Eintrag statt einer scheinbaren Antwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:48:18 +01:00
duffyduckandClaude Opus 5.5 0f8debbf99 fix(proxy): ein Bericht mitten im Turn geht nicht mehr verloren
Claude Code liefert in result.result nur den LETZTEN Textblock eines Turns.
Am 28.10. hat ARIA in einem 2-Stunden-Turn ihren Bericht geschrieben, danach
noch auf Laeufe gewartet und mit einer kurzen Notiz geendet — beim Brain kam
nur die Notiz an ("…steht im Bericht oben"), der Bericht war weg.

- Nicht-Streaming: alle Textbloecke des Turns sammeln; laengere fruehere
  (>= 300 Zeichen, z.B. Berichte) kommen vor die letzte Antwort. Kurze
  Zwischensaetze bleiben weg, <voice> nur aus der letzten Antwort.
- Belegprotokoll speichert jetzt auch Texte (geschwaerzt, max. 20k je
  Block), damit so etwas wiederherstellbar ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:36:41 +01:00
duffyduckandClaude Opus 5.5 f8ad877881 fix(llm): fertige Antwort geht bei RVS-Abbruch nicht mehr verloren
28.10., 17:51:42: alle Clients verloren gleichzeitig die RVS-Verbindung
(RVS/Proxy kurz weg) — waehrend Gemma 31 s an einer Anfrage von Marina
rechnete. Nach 2 s war alles wieder verbunden, aber die fertige Antwort ging
ueber die tote Verbindung raus und wurde verworfen ("Send fehlgeschlagen
(llm_response)"); Marina wartete bis zu ihrer Frist (660 s) ins Leere.

llm-adapter: llm_response wird ueber die aktuelle Verbindung geschickt, ist
gerade keine offen, aufgehoben (5 min) und nach dem Wiederverbinden
nachgeschickt.

RVS: gezielte Antwort, deren Fragender nur neu verbunden ist (alte
Verbindung weg), geht bis 2 MB an den Raum statt verworfen zu werden — der
Fragende erkennt sie an der requestId. Dateien bleiben gezielt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:17:40 +01:00
duffyduckandClaude Opus 5.5 dd5d1fd2cb feat(llm): grosse MoE-Modelle auf kleinen Karten — Experten in den Arbeitsspeicher
Bei MoE-Modellen (z.B. Qwen3-Coder-30B-A3B: 18,6 GB, davon 16,35 GB
Experten) rechnet pro Wort nur ein kleiner Teil. llama.cpp kann die
Experten in den RAM legen (--cpu-moe / --n-cpu-moe N), der Rest bleibt auf
der Karte.

Adapter:
- Registry-Feld cpuMoe: 0 = Karte, -1 = alle Experten in den RAM,
  N = die der ersten N Schichten; -2 beim Laden = automatisch aufteilen.
- Automatisch: liest nur den Kopf der GGUF bei HuggingFace (HTTP-Range,
  Tensor-Verzeichnis), rechnet Experten je Schicht + KV-Cache gegen den
  VRAM der Karte und legt so viele Experten auf die Karte wie passen
  (12 GB/32k: 16 von 48 Schichten auf der Karte, 32 im RAM).
- Kontext-Auswahl: GB-Schaetzung fuer die Karte ohne ausgelagerte Experten;
  slot meldet moe, cpuMoe, ramMB. KV-Rechnung als _kv_bytes() geteilt.

Diagnostic: MoE-Modell zu gross fuer die Karte → Rueckfrage "Experten
automatisch aufteilen?"; pro Karte Auswahl Experten: Karte / automatisch /
Arbeitsspeicher. Kontextwechsel rechnet die Aufteilung neu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:24:41 +01:00
duffyduckandClaude Opus 5.5 26ad872c6c feat: Belege — jede ARIA-Antwort zeigt, was sie wirklich ausgefuehrt hat
Was ARIA selbst im Proxy ausfuehrt (Bash/ssh, Read, Edit …), war nur live
sichtbar — ob sie fuer "auf Prod nachgesehen" wirklich nachgesehen hat (und
wo), liess sich hinterher nicht pruefen. Das Befehlslog gab es nur fuer die
Host-Agenten.

Proxy (proxy-patches/routes.js): start/tool_use/tool_result/end zusaetzlich
dauerhaft nach /shared/logs/aria-tools/<tag>.jsonl (30 Tage), Passwoerter,
Tokens, API-Keys, private Schluessel und Zugangsdaten in URLs geschwaerzt.

Diagnostic: 🔍 an jeder ARIA-Bubble → alle Werkzeug-Aufrufe zwischen Stefans
Frage und dieser Antwort (Befehl + Ausgabe). Hat sie nichts ausgefuehrt,
sagt das Fenster es deutlich (Stand kommt dann aus Verlauf/Gedaechtnis).
GET /api/aria-proof?ts=&project=.

Deploy: proxy --force-recreate (Patch wirkt nur beim Start), diagnostic --build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:42:49 +01:00
duffyduckandClaude Opus 5.5 f479a0781d feat(brain): ARIA pflegt ihr Gedaechtnis selbst — Anleitung, Merkzeilen, Loeschen, ehrlichere Destillation
Anlass: Im Projekt Software Bude hat ARIA Test- (aria-wohnung) und
Prod-Schmiede (172.0.2.46) verwechselt, richtige Berichte fuer erfunden
erklaert und auf der Testumgebung ein Projekt angelegt. Ihr Wissen dazu lag
ungepinnt und veraltet ("ALTE Testinstanz") im kalten Gedaechtnis.

- Systemprompt: neuer Abschnitt "DEIN GEDAECHTNIS — so pflegst du es":
  von sich aus speichern, was nicht live nachsehbar ist; was NICHT rein
  gehoert (laufende Staende, eigene ungepruefte Berichte); Schubladen
  (tool = Infrastruktur, angepinnt); vor dem Speichern suchen, aktualisieren
  statt doppeln, Falsches loeschen; Erinnern != Wissen; jeder Stand-Bericht
  nennt Quelle (wo/womit/wann).
- Verzeichnis: Titel + Merkzeile + Datum statt nur Titel; Cold-Treffer mit Datum.
- memory_save: identity/rule/preference/tool/skill werden angepinnt wie
  beschrieben (vorher nur mit explizitem pinned=true — Infrastruktur landete
  kalt). memory_update kann die Schublade aendern. Neu: memory_delete.
- Destillat: Fakten nur aus dem, was Stefan sagt/entscheidet/bestaetigt;
  keine live nachsehbaren Staende (Ticket-Nummern, Status, Commit-IDs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:19:34 +01:00
duffyduckandClaude Opus 5.5 4d590df866 docs(diagnostic): Kontext-Info genauer — Zeichen statt Seiten, grosser Kontext != besser
32k als Zeichen-Spanne (Text vs. Code, ~2,3 Zeichen je Token bei HTML/JS)
statt pauschal 20–25 Seiten. Neu: in sehr langen Verlaeufen uebersehen
Modelle eher etwas (Mitte) und jede Anfrage wird langsamer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:17:57 +01:00
duffyduckandClaude Opus 5.5 b93b62eea9 feat(brain): Compacting fuer ARIAs lokalen Modus + Erklaerung in der Info-Box
Lokal bekam ARIA starr die letzten 8 Nachrichten — alles davor war fuer das
lokale Modell weg. Neu (local_compact.py):
- Fenster nach Tokens: so viele der letzten Nachrichten, wie in ~75 % des
  Kontextfensters der Karte passen (Systemprompt + Antwort abgezogen), max.
  LOCAL_MAX_TURNS (12). Kontextfenster vom Cluster (Bridge /internal/llm-models).
- Was herausfaellt, fasst das lokale Modell im Hintergrund zum laufenden
  Notizzettel zusammen (pro Projekt, /data/local_summaries.json); er geht als
  "Bisheriger Verlauf" in den lokalen Systemprompt. Destillat ins Gedaechtnis
  laeuft unveraendert weiter. Reset des Gespraechs loescht auch den Zettel.
- Bridge/local_llm: optionale laengere Frist fuer Hintergrund-Aufrufe
  (Compacting 180 s statt der 30 s fuer schnelle Antworten).

Diagnostic: Info-Box zum Kontext erklaert Kuerzen / Compacting / Gedaechtnis
und was ARIA davon nutzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:10:18 +01:00
duffyduckandClaude Opus 5.5 48fc88d430 fix(openai-wrapper): Kontext-Ueberlauf als OpenAI-400 context_length_exceeded
Bisher kam context_exceeded als 502 an — OpenAI-Clients (litellm/OpenHands)
hielten das fuer einen Serverfehler statt zu kuerzen/verdichten. Jetzt 400
mit code context_length_exceeded und dem OpenAI-ueblichen Text inkl. Zahlen.

Doku: Abschnitt "Den Verlauf verwaltet das Programm" (Kuerzen, Verdichten,
Gedaechtnis) fuer Drittprogramme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:04:09 +01:00
duffyduckandClaude Opus 5.5 c1b06aa26a feat(diagnostic): Bestenlisten — Ranglisten + HF-Trends direkt in den Katalog
Modell-Katalog → "🏆 Bestenlisten": Fenster mit laufend gepflegten
Ranglisten nach Kategorie (Allgemein, Programmieren, Bilder erzeugen,
Lokal/Community — Arena, Artificial Analysis, LiveBench, Aider, SWE-bench,
Vellum, r/LocalLLaMA, HF), darunter "Gerade im Trend auf HuggingFace" pro
Kategorie (Allgemein / Programmieren / Bilder verstehen): nur Repos mit
ladbarem GGUF, echte Groesse + Quantisierung, "passt / zu gross" gegen die
groesste LLM-Karte, standardmaessig nur bekannte Anbieter (Trend != gut).
"In Katalog" uebernimmt das Repo; geladen wird wie gewohnt im Katalog.

Server: GET /api/llm-discover (HF trendingScore, 30 min Cache),
POST /api/llm-catalog/add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:56:37 +01:00
duffyduckandClaude Opus 5.5 12f3cd3e55 feat(diagnostic): i-Button erklaert das Kontextfenster
Hinter jeder Token-Auswahl bei den LLM-Karten ein kleines i: oeffnet eine
Erklaerung — Arbeitsgedaechtnis pro Anfrage statt Sitzungsgedaechtnis, was
beim Ueberlauf passiert, Bild Schreibtisch/Aktenordner/Archiv, Speicherbedarf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:40:41 +01:00
duffyduckandClaude Opus 5.5 f7f9275b31 feat(diagnostic): Karten-Auslastung auch pro Box aus der Compute-Flotte
Neben "Auslastung" bei jeder Box ein "📊 Karten"-Button: dasselbe Fenster,
nur fuer diese Box (Titel zeigt den Namen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:37:10 +01:00
duffyduckandClaude Opus 5.5 294917fe36 feat(diagnostic): Auslastung aller Grafikkarten aller KI-Boxen auf einen Blick
LLM-Karten → "📊 Auslastung": Fenster mit jeder Box und jeder Karte, live
jede Sekunde — Rechenlast, Speicher (belegt/gesamt), Temperatur, Strom,
Verlauf der letzten 2 min und was auf der Karte laeuft (LLM-Modell mit
Kontext, Whisper, F5-TTS, Voxtral).

node_stats (alle GPU-Dienste, identische Kopie) meldet im Live-Stream jetzt
zusaetzlich die Werte PRO Karte (mit uuid). Pro Box streamt bevorzugt der
LLM-Adapter (sieht alle Karten mit echter Nummer); Boxen ohne ihn: jeder
GPU-Dienst seine Karte, Nummer aus worker.gpus. Streams werden erneuert,
solange das Fenster offen ist, und beim Schliessen beendet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:36:02 +01:00
duffyduckandClaude Opus 5.5 d3f36d8292 feat(llm): Kontext-Auswahl zeigt den Grafikspeicher und geht bis zum Modell-Maximum
Der Adapter liest den Kopf der GGUF-Datei (nur Metadaten): trainiertes
Maximum (context_length) und KV-Cache pro Token — bei Sliding-Window-Modellen
(Gemma) waechst nur der globale Teil mit dem Kontext. Daraus pro Stufe eine
Schaetzung Modell + mmproj + KV (f16) + 1,4 GB Aufschlag (gemessen: Gemma 4
12B @32k geschaetzt 8,8 GB, belegt 8,77 GB).

Diagnostic: Liste kommt von der Box (statt fest 4k..128k), bis zum Maximum
des Modells (auch krumme wie 40960), mit "ca. X GB" und "passt nicht",
wenn es die Karte sprengt. Alte Box ohne Angaben: feste Liste wie bisher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:28:32 +01:00
duffyduck bbeea24759 release(agent): bump to 0.0.1.8 2026-10-25 14:21:09 +01:00
duffyduckandClaude Opus 5.5 2a18280863 feat(host-agent): Linux-Release als Zip mit Dienst-Installer
Statt der nackten Binary haengt release_agent.sh jetzt
aria-host-agent-linux-x64-v<version>.zip an: Binary, install-service.sh,
aria-host-agent.service, .env.example und LIESMICH.txt (Start direkt oder
als systemd-Dienst). Ausfuehrbar-Rechte bleiben beim Entpacken erhalten.

install-service.sh startet den Dienst neu statt nur "enable --now" — so
laedt ein Update die neue Binary auch, wenn der Dienst schon laeuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:17:33 +01:00
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
68 changed files with 8637 additions and 634 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 20602
versionName "0.2.6.2"
// 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.2",
"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;
+39
View File
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
onTapStart: () => Promise<boolean>;
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
onTapStop: () => Promise<void>;
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
onTapCancel?: () => Promise<void>;
/** Button deaktivieren */
disabled?: boolean;
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
const VoiceButton: React.FC<VoiceButtonProps> = ({
onTapStart,
onTapStop,
onTapCancel,
disabled = false,
wakeWordActive = false,
}) => {
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [disabled, onTapStart, onTapStop]);
const handleCancel = useCallback(async () => {
if (!onTapCancel || tapBusy.current) return;
tapBusy.current = true;
try {
if (audioService.getRecordingState() === 'recording') await onTapCancel();
} finally {
tapBusy.current = false;
}
}, [onTapCancel]);
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const tenths = Math.floor((ms % 1000) / 100);
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
{wakeWordActive && !isRecording && (
<View style={styles.wakeWordDot} />
)}
{isRecording && onTapCancel && (
<TouchableOpacity
onPress={handleCancel}
accessibilityLabel="Aufnahme verwerfen"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.cancelButton}
>
<Text style={styles.cancelIcon}>🗑</Text>
</TouchableOpacity>
)}
<Animated.View
style={[
styles.buttonOuter,
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
backgroundColor: '#34C759',
zIndex: 10,
},
// Muelleimer links neben dem Aufnahmeknopf — nur waehrend der Aufnahme
cancelButton: {
position: 'absolute',
left: -46,
top: 14,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(60, 60, 70, 0.9)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
cancelIcon: {
fontSize: 18,
},
buttonOuter: {
width: 64,
height: 64,
+10
View File
@@ -2624,6 +2624,15 @@ const ChatScreen: React.FC = () => {
}
}, []);
// Muelleimer waehrend der Aufnahme: verwerfen. cancelStreamingRecording
// schickt stt_stream_end mit reason "cancel:*" — die STT-Bridge transkribiert
// dann nicht, die aria-bridge wirft einen spaeten Text weg, und das leere
// Endpoint raeumt die Sprechblase ab. Danach zurueck aufs Wake-Word.
const handleVoiceButtonCancel = useCallback(async (): Promise<void> => {
console.log('[Chat] Aufnahme verworfen (Muelleimer)');
await ariaStopRecording('user-discard');
}, [ariaStopRecording]);
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
setShowFileUpload(false);
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
onTapCancel={handleVoiceButtonCancel}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
+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 });
}
}
+251 -13
View File
@@ -33,6 +33,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod
import metrics
from local_llm import local_llm_chat
import local_compact
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
@@ -47,6 +48,11 @@ BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
_NO_MODEL = {"model": "", "requested": "", "reason": ""}
# Lokale Modelle bewusst waehlen: pro Chat ("local:<modell>" im Modell-Override)
# oder fuer eine Frage ("@lokal …" / "@lokal:<modell> …").
LOCAL_MODEL_PREFIX = "local:"
_AT_LOCAL_RE = re.compile(r"^\s*@lokal(?::([\w.\-]+))?(?=[\s:,]|$)[\s:,]*(.*)$", re.IGNORECASE | re.DOTALL)
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -1015,6 +1021,27 @@ META_TOOLS = [
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
"type": {"type": "string", "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
"description": "Schublade aendern (optional), z.B. fact → tool fuer Infrastruktur. tool/rule/preference/skill/identity werden dabei angepinnt."},
},
"required": ["id"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_delete",
"description": (
"Loesche einen Gedaechtnis-Eintrag, der FALSCH oder UEBERHOLT ist "
"(ID aus memory_search oder Cold-Memory). Ist er nur unvollstaendig, "
"lieber memory_update. identity-Eintraege kann nur Stefan loeschen."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Memory-ID (UUID)"},
"reason": {"type": "string", "description": "Kurz warum (landet im Log)"},
},
"required": ["id"],
},
@@ -1026,8 +1053,10 @@ META_TOOLS = [
"name": "memory_save",
"description": (
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
"Nutze das VON DIR AUS, sobald du etwas Nicht-Offensichtliches lernst "
"(siehe 'DEIN GEDAECHTNIS' im Prompt) — nicht erst auf 'merk dir das'. "
"VORHER memory_search: gibt es schon einen Eintrag, nimm memory_update. "
"Keine live nachsehbaren, sich aendernden Staende speichern. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
"File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
"Type-Wahl:\n"
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\n"
@@ -1071,7 +1100,7 @@ META_TOOLS = [
},
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
"pinned": {"type": "boolean", "description": "Weglassen = automatisch nach Schublade (identity/rule/preference/tool/skill angepinnt, fact nicht). Nur angeben, um davon abzuweichen."},
"attach_paths": {
"type": "array",
"items": {"type": "string"},
@@ -1268,8 +1297,51 @@ META_TOOLS = [
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
"hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete MIT "
"Verbindungsart (LAN/WLAN), lan_port (an welcher LAN-Buchse der Box) und "
"speed_mbit; 'find' filtert nach Name/IP/MAC (braucht "
"hinterlegten FritzBox-Login). FRITZ!BOX VOLLSTAENDIG STEUERN: "
"'fritzbox.describe' (params={'ip':'<box oder Name>','filter':'WLAN'}) listet, "
"was DIESE Box per TR-064 kann (je Aktion [liest]/[aendert]/[GEFAEHRLICH]); "
"'fritzbox.call' (params={'ip':..,'service':'WLANConfiguration:3',"
"'action':'GetInfo','args':{...}}) ruft jede davon auf; 'fritzbox.wlan' "
"zeigt alle WLANs inkl. Gast (index, ssid, an) und schaltet sie "
"(index + enable). Unbekannte Funktion (WPS, DECT, Anrufliste, Internet "
"sperren …)? Erst describe mit passendem filter, dann call. REGEL: Alles "
"was AENDERT (Set…, WPS, Neustart …) braucht confirmed=true — und das "
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
"zurueck: dann fragen, nicht raten. [GEFAEHRLICH] (Neustart, "
"Werkseinstellung, Portfreigabe, Passwort) besonders deutlich ankuendigen. "
"TASTE AN DER BOX: Manche Aktionen (z.B. Sicherung) muss man an der Box "
"bestaetigen — dann kommt 'muss an der FritzBox bestaetigt werden'. Frag "
"Stefan, ob er an der Box steht; nach seinem Ja erneut mit button_wait=true "
"aufrufen, dann hat er 2 Minuten fuer einen Tastendruck (sonst Timeout, "
"nichts passiert — einfach nachfragen und neu versuchen). 'fritzbox.backup' "
"(params={'ip':..,'button_wait':true}) sichert die Einstellungen — OHNE "
"'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das "
"Du nicht kennst und nicht kennen sollst); 'password' nur, wenn Stefan "
"ausdruecklich ein anderes will. SICHERUNGEN VERWALTEN: 'fritzbox.backups' "
"(params={'ip':..}) listet die Sicherungen der Box (datei, erstellt, modell, "
"firmware), neueste zuerst. 'fritzbox.backup_view' (params={'ip':..,'file':"
"'<datei>'}) zeigt die wichtigsten Einstellungen daraus (Kennwoerter bleiben "
"verschluesselt). 'fritzbox.restore' (params={'ip':..,'file':'<datei>',"
"'confirmed':true,'button_wait':true}) spielt eine Sicherung ein — GEFAEHRLICH: "
"Box uebernimmt alles und startet neu (1–3 min kein Netz); vorher Datum der "
"Sicherung nennen und Stefans ausdrueckliches Ja holen, ebenso ob er an der "
"Box steht. 'fritzbox.backup_delete' (params={'ip':..,'file':..,'confirmed':true}) "
"loescht eine Sicherung (nach Ja). 'fritzbox.backup_get' (params={'ip':..,"
"'file':..}) holt eine VORHANDENE Sicherung — sie liegt danach unter "
"/shared/uploads/ und kommt mit [FILE: <pfad>] in den Chat. ALLE BOXEN SICHERN: 'fritzbox.backup_all' "
"(params={} oder {'button_wait':true}) — pro Satellit einmal aufrufen (jeder "
"Satellit sichert seine Boxen mit hinterlegtem Login); nicht erreichbare oder "
"fehlerhafte werden uebersprungen, es geht mit der naechsten weiter — am Ende "
"die Liste je Box (✓/✗ + Grund) an Stefan. TASTENBESTAETIGUNG: "
"'fritzbox.second_factor' zeigt, ob sie an ist und ob sie sich per Schnittstelle "
"schalten laesst; 'fritzbox.second_factor_set' (params={'ip':..,'enable':false,"
"'confirmed':true,'button_wait':true}) schaltet sie — nur nach Stefans Ja; "
"geht es nicht, den Hinweis (Weg ueber die Oberflaeche) weitergeben. die Datei liegt danach unter /shared/uploads/ "
"und kommt mit [FILE: <pfad>] in den Chat. 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
@@ -2359,7 +2431,8 @@ class Agent:
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
# Wie viele der letzten Turns ans lokale Modell gehen, regelt jetzt
# local_compact (nach Tokens + LOCAL_MAX_TURNS, aeltere als Notizzettel).
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
@@ -2370,6 +2443,9 @@ class Agent:
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
# Schubladen, die angepinnt sind (immer im System-Prompt)
_PINNED_TYPES = {"identity", "rule", "preference", "tool", "skill"}
_SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar",
@@ -2441,6 +2517,61 @@ class Agent:
converse = flags["converse"]
return speak, converse
def _local_area_route(self, user_message: str, project_id: str):
"""(modell, frage, begruendung) wenn dieser Turn bewusst lokal laufen
soll, sonst None. modell "" = das gerade geladene lokale Modell."""
msg = router_mod._strip_leading_hint_blocks(user_message)
m = _AT_LOCAL_RE.match(msg)
if m:
return (m.group(1) or "").strip(), (m.group(2) or "").strip() or msg, "einmalig"
ov = context_model.get(project_id)
if ov.startswith(LOCAL_MODEL_PREFIX):
return ov[len(LOCAL_MODEL_PREFIX):], msg, context_model.REASON_PER_CHAT
return None
def _local_area_turn(self, question: str, project_id: str, model: str) -> tuple:
"""Ein ganzer Turn auf dem lokalen Modell — ohne Werkzeuge, ohne Claude.
Gibt (antwort, benutztes_modell). Fehler werden NICHT als ARIAs Antwort
in den Verlauf geschrieben (nur angezeigt)."""
sys_prompt = "\n".join([
IDENTITY_ANCHOR.strip(), "",
"## LOKALES MODELL",
"Du laeufst gerade als lokales Modell auf Stefans eigener Hardware — er hat "
"dieses Modell fuer diese Frage bzw. diesen Chat bewusst gewaehlt. Antworte "
"vollstaendig und sachlich auf Deutsch, als ARIA. Du hast hier KEINE "
"Werkzeuge (kein Internet, kein Gedaechtnis, keine Aktionen): behaupte nie, "
"etwas nachgesehen oder ausgefuehrt zu haben. Weisst du etwas nicht sicher, "
"sag das. Normaler Text, kein <voice>, kein [FILE:], keine Tool-Aufrufe.",
])
notes = local_compact.summary_block(project_id)
if notes:
sys_prompt += "\n\n" + notes
all_turns = self.conversation.window(project_id=project_id)
ctx = local_compact.model_ctx(model) if model else local_compact.DEFAULT_CTX * 4
budget = int(ctx * 0.6) - 2500 - local_compact.est_tokens(sys_prompt)
window = local_compact.fit_window(all_turns, max(budget, 512))
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
if question and len(messages) > 1 and messages[-1]["role"] == "user":
messages[-1]["content"] = question # ohne "@lokal" davor
res = local_llm_chat(messages, max_tokens=2000, temperature=0.6,
model=model or None, timeout=600)
used = res.get("model") or model or "lokales Modell"
if not res.get("ok"):
logger.warning("[lokal] %s nicht erreichbar: %s", model or "(geladenes)", res.get("error"))
return (f"⚠ Das lokale Modell {model or ''} hat nicht geantwortet: "
f"{res.get('error', 'unbekannt')}. Liegt es auf einer Karte? "
"(Diagnostic → LLM-Karten)").replace(" ", " "), used
try:
metrics.log_local_call(used, messages, res.get("content") or "", res.get("usage"))
except Exception:
pass
reply = re.sub(r"<think>[\s\S]*?</think>", "", res.get("content") or "")
reply = re.sub(r"</?voice>", "", reply).strip() or "[Das lokale Modell lieferte keine Antwort.]"
self.conversation.add("assistant", reply, project_id=project_id)
logger.info("[lokal] %s beantwortet (%s)", used, project_id or "Hauptchat")
return reply, used
def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config()
@@ -2476,9 +2607,21 @@ class Agent:
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools))
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
# wuerde das Prefill aufblaehen).
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
# Compacting (local_compact): aeltere Nachrichten als Notizzettel im
# Systemprompt, dazu so viele der letzten Nachrichten, wie ins
# Kontextfenster der Karte passen (hoechstens LOCAL_MAX_TURNS — Tempo).
notes = local_compact.summary_block(active_project_id)
if notes:
sys_prompt += "\n\n" + notes
all_turns = self.conversation.window(project_id=active_project_id)
ctx = local_compact.model_ctx(local_model)
budget = int(ctx * 0.75) - 500 - local_compact.est_tokens(sys_prompt)
window = local_compact.fit_window(all_turns, max(budget, 256))
try:
local_compact.maybe_compact(active_project_id, all_turns, window,
local_model, local_llm_chat)
except Exception as exc:
logger.debug("[compact] uebersprungen: %s", exc)
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
@@ -2604,6 +2747,13 @@ class Agent:
result = self.proxy.chat_full(messages, tools=tools, model=model,
project_id=project_id)
if not safeguard.looks_like_block(result.content, result.is_error):
if result.is_error:
# Die CLI meldet selbst einen Fehler (Anmeldung abgelaufen,
# Limit, …) — das ist KEINE Antwort von ARIA. Frueher landete
# "Failed to authenticate: OAuth session expired" als ihre
# Antwort im Verlauf. Jetzt: wie jede Ausnahme behandeln
# (klar markierter [Fehler: …]-Eintrag, Stefan sieht den Fehler).
raise RuntimeError(f"Claude meldet einen Fehler: {(result.content or '').strip()[:300]}")
return result, model
code = safeguard.detail_code(result.content)
@@ -2719,6 +2869,19 @@ class Agent:
if active_project_id:
projects_mod.touch_project(active_project_id)
# Lokales Modell bewusst gewaehlt — Claude ist fuer diesen Turn raus:
# * "@lokal …" / "@lokal:<modell> …" nur fuer diese eine Frage
# * Chat steht (🧠, pro Chat) auf "local:<modell>"
# Stefan entscheidet das selbst (z.B. ein Pentest-Projekt auf einem
# unzensierten Modell); nichts wird automatisch umgeleitet.
area = self._local_area_route(user_message, active_project_id)
if area is not None:
model, question, why = area
reply, used = self._local_area_turn(question, active_project_id, model)
converse = not _wants_end
return reply, "local", True, converse, False, {
"model": used, "requested": model or "lokal", "reason": why}
# Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
@@ -2791,6 +2954,14 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Bei einer Ablehnung: Stefan kann es bewusst an ein lokales Modell geben.
system_prompt += (
"\n\n## LOKALE MODELLE ALS AUSWEG\n"
"Lehnst du eine Anfrage ab (Richtlinien), sag Stefan kurz, dass er sie selbst "
"an ein lokales Modell auf seiner eigenen Hardware geben kann: mit "
"`@lokal <Frage>` fuer diese eine Frage, oder indem er den Chat oben (🧠) "
"auf ein Modell unter „Lokal“ stellt. Leite NICHT selbst weiter und gib "
"den Inhalt nicht selbst an ein Werkzeug — die Entscheidung trifft er.")
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
@@ -3178,13 +3349,22 @@ class Agent:
if not sat or not action:
return "FEHLER: satellite und action sind Pflicht."
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
# Kennwoerter im Klartext gehen nie ans Sprachmodell — die zeigt nur
# das Diagnostic (👁) in Stefans Browser.
if action in ("fritzbox.backup_password",):
return ("FEHLER: Kennwoerter zeige ich nicht an — Stefan sieht es im Diagnostic "
"(Satelliten → Geraet → 💾 → 👁 bei der Sicherung).")
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
# obwohl die Installation noch laeuft.
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
# Installation koennen zusammen mehrere Minuten dauern.
sat_timeout = (320.0 if action.startswith("adb.")
else 200.0 if action == "ssh.exec" else 20.0)
else 200.0 if action == "ssh.exec"
# button_wait: bis 2 min auf die Taste an der FritzBox
else 900.0 if action == "fritzbox.backup_all"
else 200.0 if action.startswith("fritzbox.") and params.get("button_wait")
else 60.0 if action.startswith("fritzbox.") else 20.0)
result = _post("/internal/satellite", {
"op": "command", "satellite": sat,
"device": arguments.get("device") or "", "action": action, "params": params,
@@ -3192,7 +3372,18 @@ class Agent:
}, sat_timeout + 20)
if result.get("ok"):
r = result.get("result")
if isinstance(r, dict) and r.get("file_b64"):
# Datei vom Satelliten (z.B. FritzBox-Sicherung) → /shared/uploads/
import base64
fname = re.sub(r"[^A-Za-z0-9._-]", "_", str(r.get("filename") or "satellit_datei"))
path = f"/shared/uploads/sat_{int(time.time())}_{fname}"
with open(path, "wb") as fh:
fh.write(base64.b64decode(r.pop("file_b64")))
r["im_chat_ablegbar"] = path
r["hinweis_chat"] = f"In den Chat legen mit [FILE: {path}]"
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
if result.get("needs_button") or result.get("needs_confirmation") or result.get("timeout"):
return f"FEHLER: {result.get('error')}"
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
@@ -4391,6 +4582,15 @@ class Agent:
existing.category = str(arguments["category"]).strip()
if "tags" in arguments and arguments["tags"] is not None:
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
if arguments.get("type"):
new_type = str(arguments["type"]).strip()
if new_type not in {"identity", "rule", "preference", "tool", "skill",
"fact", "conversation", "reminder"}:
return f"FEHLER: unbekannter type {new_type!r}."
if new_type != existing.type:
existing.type = new_type
if arguments.get("pinned") is None:
existing.pinned = new_type in self._PINNED_TYPES
if "pinned" in arguments and arguments["pinned"] is not None:
existing.pinned = bool(arguments["pinned"])
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
@@ -4418,6 +4618,29 @@ class Agent:
except Exception as e:
logger.exception("memory_update fehlgeschlagen")
return f"FEHLER: {e}"
if name == "memory_delete":
pid = (arguments.get("id") or "").strip()
reason = (arguments.get("reason") or "").strip()
if not pid:
return "FEHLER: id ist Pflicht."
existing = self.store.get(pid)
if not existing:
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
if existing.type == "identity":
return "FEHLER: identity-Eintraege loescht nur Stefan (Diagnostic)."
try:
self.store.delete(pid)
logger.info("[memory] geloescht: %r (%s) — Grund: %s", existing.title, pid[:8], reason or "-")
self._pending_events.append({
"type": "memory_saved", "action": "deleted",
"memory": {"id": pid, "type": existing.type, "title": existing.title,
"content_preview": (existing.content or "")[:140],
"category": existing.category, "pinned": existing.pinned},
})
return f"OK — Memory '{existing.title}' geloescht."
except Exception as e:
logger.exception("memory_delete fehlgeschlagen")
return f"FEHLER: {e}"
if name == "memory_save":
title = (arguments.get("title") or "").strip()
content = (arguments.get("content") or "").strip()
@@ -4431,7 +4654,13 @@ class Agent:
category = (arguments.get("category") or "").strip()
tags_in = arguments.get("tags") or []
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
pinned = bool(arguments.get("pinned", False))
# Schubladen identity/rule/preference/tool/skill sind laut
# Beschreibung ANGEPINNT — frueher nur, wenn pinned=true explizit
# mitkam (Default false), Infrastruktur landete so im kalten Teil.
if arguments.get("pinned") is None:
pinned = mem_type in self._PINNED_TYPES
else:
pinned = bool(arguments.get("pinned"))
attach_paths_in = arguments.get("attach_paths") or []
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
try:
@@ -4681,13 +4910,22 @@ class Agent:
# Konversation als Klartext bauen
transcript = "\n".join(
f"[{t.role.upper()}] {t.content}" for t in old_turns
f"[{'STEFAN' if t.role == 'user' else 'ARIA'}] {t.content}" for t in old_turns
)[:30000] # Cap auf 30k Zeichen damit der Prompt nicht explodiert
system = (
"Du extrahierst aus einer Konversation zwischen Stefan und ARIA die "
"wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, "
"keine flüchtigen Zustände. Antworte AUSSCHLIESSLICH mit gültigem JSON "
"keine flüchtigen Zustände.\n"
"QUELLE: Fakten kommen aus dem, was STEFAN sagt, entscheidet oder "
"bestätigt. ARIAs Nachrichten sind Berichte und können falsch sein — "
"übernimm daraus nur, was Stefan danach bestätigt hat.\n"
"NICHT aufnehmen: Systemzustände, die man live nachsehen kann und die "
"sich ändern (Projekt-/Ticket-Nummern im Fluss, Ticket-Status, Zeilen-"
"zahlen, Commit-IDs, wer gerade woran arbeitet, 'gerade nachgesehen'-"
"Befunde). Gut sind: Entscheidungen und ihr Grund, Stefans Vorlieben, "
"dauerhafte Regeln/Abläufe, wo etwas läuft (falls Stefan es bestätigt).\n"
"Antworte AUSSCHLIESSLICH mit gültigem JSON "
"im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", "
"\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. "
"Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array."
+186
View File
@@ -0,0 +1,186 @@
"""
Compacting fuer ARIAs lokalen Modus (Fast-Lane / localOnly).
Ein lokales Modell hat ein kleines Kontextfenster (z.B. 32k auf einer
12-GB-Karte). Frueher bekam es starr die letzten 8 Nachrichten — alles davor
war fuer das lokale Modell einfach weg. Jetzt:
* Fenster nach TOKENS statt fester Anzahl: so viele der letzten Nachrichten,
wie in ~75 % des Kontextfensters passen (Systemprompt + Antwortplatz
abgezogen), hoechstens LOCAL_MAX_TURNS (Tempo).
* Was aus dem Fenster faellt, fasst das lokale Modell im HINTERGRUND zu
einer laufenden Zusammenfassung zusammen ("Notizzettel"). Die liegt pro
Projekt in /data/local_summaries.json und geht bei jeder lokalen Anfrage
als "Bisheriger Verlauf" in den Systemprompt.
Unabhaengig davon laeuft weiter das Destillat (agent.distill_old_turns):
dauerhaft Wichtiges wandert als Fakt ins Gedaechtnis (Qdrant). Compacting =
Notizzettel fuer DIESES Gespraech, Destillat = Karteikarten fuers Archiv.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
import urllib.request
from pathlib import Path
logger = logging.getLogger(__name__)
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
SUMMARY_FILE = Path(os.environ.get("LOCAL_SUMMARY_FILE", "/data/local_summaries.json"))
LOCAL_MAX_TURNS = int(os.environ.get("LOCAL_MAX_TURNS", "12"))
DEFAULT_CTX = 8192
CHARS_PER_TOKEN = 2.8 # Deutsch/Code: eher vorsichtig schaetzen
COMPACT_MIN_TURNS = 4 # erst ab so vielen neu herausgefallenen Nachrichten
SUMMARY_MAX_TOKENS = 450
_lock = threading.Lock()
_running: set = set() # Projekte, fuer die gerade verdichtet wird
_ctx_cache: dict = {"ts": 0.0, "models": {}}
def est_tokens(text: str) -> int:
return int(len(text or "") / CHARS_PER_TOKEN) + 4
def model_ctx(model: str) -> int:
"""Kontextfenster des lokalen Modells laut Cluster (60 s gecacht)."""
now = time.time()
if now - _ctx_cache["ts"] > 60:
try:
with urllib.request.urlopen(f"{BRIDGE_URL}/internal/llm-models", timeout=3) as r:
data = json.loads(r.read().decode("utf-8", "ignore"))
_ctx_cache["models"] = {m.get("model"): int(m.get("ctx") or 0)
for m in data.get("models") or []}
_ctx_cache["ts"] = now
except Exception as exc:
logger.debug("[compact] Kontextfenster nicht abfragbar: %s", exc)
_ctx_cache["ts"] = now - 50 # in 10 s nochmal versuchen
return _ctx_cache["models"].get(model) or DEFAULT_CTX
def fit_window(turns: list, budget_tokens: int) -> list:
"""Die neuesten Nachrichten, die ins Budget passen (mind. die letzte)."""
out, used = [], 0
for t in reversed(turns[-LOCAL_MAX_TURNS:]):
need = est_tokens(t.content)
if out and used + need > budget_tokens:
break
out.append(t)
used += need
return list(reversed(out))
# ── Laufende Zusammenfassung pro Projekt ─────────────────────────────
def _load() -> dict:
try:
return json.loads(SUMMARY_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
def get_summary(project_id: str) -> dict:
"""{text, upto_ts} — upto_ts = Zeitstempel der juengsten verdichteten Nachricht."""
s = _load().get(project_id or "", {})
return {"text": s.get("text", ""), "upto_ts": s.get("upto_ts", "")}
def _save_summary(project_id: str, text: str, upto_ts: str) -> None:
with _lock:
data = _load()
data[project_id or ""] = {"text": text, "upto_ts": upto_ts,
"updated": time.strftime("%Y-%m-%dT%H:%M:%S")}
tmp = SUMMARY_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(SUMMARY_FILE)
def reset(project_id=None) -> None:
"""Zusammenfassung(en) verwerfen (z.B. beim Zuruecksetzen des Gespraechs)."""
with _lock:
if project_id is None:
try:
SUMMARY_FILE.unlink()
except FileNotFoundError:
pass
return
data = _load()
data.pop(project_id or "", None)
SUMMARY_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
def summary_block(project_id: str) -> str:
s = get_summary(project_id).get("text", "").strip()
if not s:
return ""
return ("## BISHERIGER VERLAUF (Zusammenfassung aelterer Nachrichten)\n"
"Das ist eine Zusammenfassung dessen, was vor den letzten Nachrichten "
"besprochen wurde. Nutze es als Hintergrund, zitiere es nicht.\n" + s)
def maybe_compact(project_id: str, all_turns: list, window: list, model: str,
chat_fn) -> None:
"""Startet im Hintergrund eine Verdichtung, wenn genug Nachrichten aus dem
Fenster gefallen sind, die noch nicht im Notizzettel stehen.
chat_fn = local_llm_chat (wird nur im Hintergrund-Thread aufgerufen)."""
if not window:
return
first_kept = window[0].ts
upto = get_summary(project_id)["upto_ts"]
dropped = [t for t in all_turns if t.ts < first_kept and t.ts > upto]
if len(dropped) < COMPACT_MIN_TURNS:
return
key = project_id or ""
with _lock:
if key in _running:
return
_running.add(key)
threading.Thread(target=_compact, args=(key, dropped, model, chat_fn),
daemon=True, name=f"compact-{key or 'main'}").start()
def _compact(project_id: str, dropped: list, model: str, chat_fn) -> None:
try:
prev = get_summary(project_id)["text"]
ctx = model_ctx(model)
# Was in die Verdichtungs-Anfrage passt: ~60 % des Fensters
room = int(ctx * 0.6 * CHARS_PER_TOKEN) - len(prev) - 2000
lines, used, last_ts = [], 0, ""
for t in dropped: # aelteste zuerst
who = "Stefan" if t.role == "user" else "ARIA"
line = f"{who}: {t.content.strip()}"
if used + len(line) > room:
line = line[: max(0, room - used)] + " …"
lines.append(line)
used += len(line)
last_ts = t.ts
if used >= room:
break # Rest beim naechsten Durchlauf
system = ("Du fuehrst einen laufenden Notizzettel ueber ein Gespraech zwischen "
"Stefan und seiner Assistentin ARIA. Schreibe auf Deutsch eine knappe "
"Zusammenfassung (hoechstens ~250 Woerter, Stichpunkte erlaubt): worum "
"es ging, was entschieden oder erledigt wurde, offene Punkte, wichtige "
"Namen/Zahlen. Kein Smalltalk, keine Einleitung, nur der Notizzettel.")
user = ((f"Bisheriger Notizzettel:\n{prev}\n\n" if prev else "")
+ "Diese Nachrichten kommen neu dazu:\n" + "\n".join(lines)
+ "\n\nSchreibe den aktualisierten Notizzettel.")
res = chat_fn([{"role": "system", "content": system}, {"role": "user", "content": user}],
max_tokens=SUMMARY_MAX_TOKENS, temperature=0.2, model=model, timeout=180)
text = (res.get("content") or "").strip() if res.get("ok") else ""
if not text:
logger.warning("[compact] Verdichtung fehlgeschlagen (%s) — Notizzettel bleibt",
res.get("error"))
return
_save_summary(project_id, text, last_ts)
logger.info("[compact] %d Nachrichten verdichtet (Projekt %r) → %d Zeichen Notizzettel",
len(dropped), project_id, len(text))
except Exception as exc:
logger.warning("[compact] Fehler: %s", exc)
finally:
with _lock:
_running.discard(project_id or "")
+4 -2
View File
@@ -30,7 +30,7 @@ LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC",
def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
model=None, timeout=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
@@ -44,13 +44,15 @@ def local_llm_chat(messages: list, *, max_tokens: int = 512,
req["tools"] = tools
if model:
req["model"] = model
if timeout:
req["timeout"] = timeout # Bridge-seitige Frist (sonst 30 s)
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
with urllib.request.urlopen(http_req, timeout=(timeout + 5) if timeout else LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
result = json.loads(resp.read().decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
+20 -1
View File
@@ -1190,6 +1190,11 @@ def conversation_reset():
"""Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert.
Destillierte facts bleiben in der DB."""
conversation().reset()
try:
import local_compact
local_compact.reset() # Notizzettel des lokalen Modus gehoert dazu
except Exception:
pass
return {"ok": True, "turns": 0}
@@ -1338,11 +1343,25 @@ def _context_model_rows() -> dict:
"reason": reason,
"blocked": sorted(blocked),
})
choices = _model_choices()
choices = _model_choices() + _local_model_choices()
names = {c["id"]: c["name"] for c in choices}
return {"default": default, "rows": rows, "choices": choices, "names": names}
def _local_model_choices() -> list:
"""Lokale Modelle, die gerade auf einer Karte liegen (ARIA-Cluster ueber
die Bridge) — als "local:<modell>" waehlbar pro Chat. Fail-soft: []."""
try:
import httpx as _httpx
base = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090").rstrip("/")
data = _httpx.get(f"{base}/internal/llm-models", timeout=4).json()
return [{"id": "local:" + m["model"], "name": "🖥 lokal: " + m["model"], "section": "local"}
for m in (data.get("models") or []) if m.get("model")]
except Exception as exc:
logger.info("[context-model] lokale Modelle nicht abrufbar: %s", exc)
return []
@app.get("/context-model/list")
def context_model_list():
return _context_model_rows()
+71 -5
View File
@@ -292,6 +292,70 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
return "\n".join(parts).strip()
MEMORY_CARE = """## DEIN GEDAECHTNIS — so pflegst du es
Zwischen zwei Nachrichten behaeltst du NICHTS. Was du spaeter wissen musst, steht
nur dann wieder vor dir, wenn es im Gedaechtnis liegt — angepinnt (immer im
Prompt), im Verzeichnis unten (Titel + Merkzeile) oder als Treffer der Suche.
Darum pflegst du es selbst, ohne dass Stefan "merk dir das" sagen muss.
### Wann speichern (von dir aus)
- Du lernst etwas, das man sonst muehsam neu herausfinden muesste: wo etwas
laeuft, welches System Test und welches Produktion ist, wie man hinkommt, wie
ein Ablauf funktioniert, was Stefan entschieden hat und WARUM.
- Stefan korrigiert dich oder bestaetigt einen nicht offensichtlichen Weg.
- Sofort, nicht "spaeter" — am Ende des Gespraechs bist du es nicht mehr.
### Was NICHT ins Gedaechtnis gehoert
- Was man jederzeit live nachsehen kann und sich laufend aendert: Ticket-Staende,
Projekt-/Ticket-Nummern im Fluss, Zeilenzahlen, "wer arbeitet gerade woran".
Das wird NACHGESEHEN, nicht erinnert.
- Deine eigenen Berichte oder Vermutungen, die du nicht in diesem Zug geprueft hast.
- Was ohnehin im Code, in Git oder in der Doku steht.
### Welche Schublade
- `tool` (angepinnt): Infrastruktur und Umgebungen — Hosts, Zugaenge, welche
Instanz Test und welche Produktion ist. Das muss IMMER vor dir liegen.
- `rule` / `preference` (angepinnt): harte Regeln, Stefans Arbeitsweise.
- `fact`: alles andere (nur per Suche sichtbar). Der Titel ist die Merkzeile im
Verzeichnis — schreib ihn so, dass du beim Lesen weisst, wann du nachschlagen
musst (z.B. "Schmiede: Prod 172.0.2.46, Test aria-wohnung" statt "Software Schmiede").
### Pflegen statt anhaeufen
- VOR dem Speichern mit `memory_search` pruefen, ob es schon einen Eintrag gibt.
Gibt es einen: `memory_update` statt eines zweiten Eintrags.
- Hat sich etwas geaendert (z.B. eine "alte" Kopie ist jetzt deine Testumgebung):
den Eintrag SOFORT berichtigen.
- Ist ein Eintrag falsch oder ueberholt: `memory_delete`.
### Erinnern ist nicht Wissen
- Ein Eintrag gibt den Stand von damals wieder (Datum steht dabei). Bevor du
Stefan einen Systemzustand als Tatsache nennst, pruefe ihn live.
- Jeder Stand-Bericht nennt seine Quelle: WO und WOMIT du nachgesehen hast und
wann ("Prod-Board 172.0.2.46, gerade eben"). Hast du in diesem Zug nicht
nachgesehen, sag das — und schau nach, statt aus dem Verlauf weiterzuschreiben.
- Widerspricht ein neuer Befund einem frueheren aus dem Verlauf: erst klaeren,
WO du jeweils nachgesehen hast, bevor du einen davon fuer falsch erklaerst."""
def build_memory_care_section() -> str:
return MEMORY_CARE
def _mem_date(p: MemoryPoint) -> str:
d = (p.updated_at or p.created_at or "")[:10]
return f" (Stand {d})" if d else ""
def _mem_hook(p: MemoryPoint, n: int = 110) -> str:
"""Erste Zeile/Satz des Inhalts als Merkzeile fuers Verzeichnis."""
c = " ".join((p.content or "").split())
if len(c) <= n:
return c
cut = c[:n]
return cut[: cut.rfind(" ")] + " …" if " " in cut else cut + " …"
def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
"""Baue 'Moeglicherweise relevant'-Block aus Search-Treffern."""
if not matches:
@@ -299,7 +363,7 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
lines = ["## Moeglicherweise relevant (aus Gedaechtnis)"]
for p in matches:
score = f" [score={p.score:.2f}]" if p.score is not None else ""
lines.append(f"- **{p.title}**{score}")
lines.append(f"- **{p.title}**{_mem_date(p)}{score}")
lines.append(f" {p.content.strip()}")
att_line = _attachments_line(p)
if att_line:
@@ -322,8 +386,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
lines = [
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"Diese Eintraege hast DU gespeichert — hier Titel und Merkzeile, nicht der "
"ganze Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
"",
@@ -332,7 +396,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
items = grouped[cat]
lines.append(f"### {cat}")
for p in items:
lines.append(f"- {p.title}")
hook = _mem_hook(p)
lines.append(f"- {p.title}{_mem_date(p)}" + (f" — {hook}" if hook and hook != p.title else ""))
lines.append("")
return "\n".join(lines).strip()
@@ -555,7 +620,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_memory_care_section(),
"", build_time_section(),
"", build_voice_flow_section()]
if skills:
parts.append("")
+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"]
+95 -54
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
@@ -3421,6 +3449,16 @@ class ARIABridge:
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
req_id = payload.get("requestId", "") or ""
# Verworfen (Muelleimer in der App)? Dann darf ein trotzdem noch
# kommender Text NICHT an ARIA — auch wenn eine alte STT-Bridge ihn
# noch transkribiert.
if req_id and str(payload.get("reason", "")).startswith("cancel"):
if not hasattr(self, "_stt_cancelled"):
self._stt_cancelled = {}
self._stt_cancelled[req_id] = time.time()
for rid, t in list(self._stt_cancelled.items()):
if time.time() - t > 120:
self._stt_cancelled.pop(rid, None)
if req_id:
try:
loop = asyncio.get_event_loop()
@@ -3445,6 +3483,9 @@ class ARIABridge:
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
payload.get("reason", ""))
return
if (payload.get("requestId") or "") in getattr(self, "_stt_cancelled", {}):
logger.info("[rvs] stt_endpoint einer verworfenen Aufnahme — ignoriert")
return
audio_request_id = payload.get("audioRequestId", "") or ""
voice = payload.get("voice", "") or ""
speed_raw = payload.get("speed")
@@ -3774,6 +3815,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 +3863,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
@@ -4233,7 +4284,7 @@ class ARIABridge:
async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
model=None, timeout: Optional[float] = None) -> dict:
"""Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
@@ -4242,55 +4293,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=timeout or 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],
@@ -4762,6 +4786,10 @@ class ARIABridge:
elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
elif method in ("GET", "POST") and path == "/internal/llm-models":
# Brain fragt: welche lokalen Modelle sind geladen + ihr
# Kontextfenster (fuer ARIAs Compacting im lokalen Modus).
await _send_response(writer, 200, {"ok": True, "models": self._llm_cluster.models()})
elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
@@ -4923,10 +4951,16 @@ class ARIABridge:
temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
_model = data.get("model") if isinstance(data.get("model"), str) else None
# Hintergrund-Arbeit (Compacting) darf laenger dauern als die
# 30 s fuer schnelle Antworten — gedeckelt auf 5 min.
try:
_timeout = min(300.0, float(data["timeout"])) if data.get("timeout") else None
except (TypeError, ValueError):
_timeout = None
result = await self._local_llm(
messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"),
tools=_tools, model=_model,
tools=_tools, model=_model, timeout=_timeout,
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
@@ -5234,6 +5268,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)})
+1855 -191
View File
File diff suppressed because it is too large Load Diff
+628 -22
View File
@@ -473,28 +473,445 @@ 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.
// "Von HuggingFace aktualisieren": mehrere Blickwinkel statt nur der
// meistgeladenen aller Zeiten (da gewinnen alte Modelle). Neu dazu:
// was gerade im Trend ist, Coder-Modelle und unzensierte Fassungen.
// Nur was eine Box laden kann (GGUF) und hoechstens LLM_CATALOG_MAX_GB.
const LLM_CATALOG_MAX_GB = Number(process.env.LLM_CATALOG_MAX_GB || 30);
const NON_LLM_RE = /image|audio|tts|speech|music|yue|whisper|diffusion|flux|sdxl|wan2|video|embed|rerank|ocr/i;
const HF_REFRESH_QUERIES = [
["beliebt", "search=GGUF&sort=downloads&direction=-1&limit=30"],
["im Trend", "filter=gguf&sort=trendingScore&direction=-1&limit=30"],
["Code", "search=coder&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=uncensored&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=heretic&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=abliterated&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
];
async function refreshLlmCatalogFromHF() {
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) throw new Error(`HF API ${r.status}`);
const list = await r.json();
const existing = loadLlmCatalog();
const byId = new Map(existing.map(m => [m.id, m]));
let added = 0;
for (const m of (Array.isArray(list) ? list : [])) {
const repo = m.id || m.modelId;
if (!repo || !/gguf/i.test(repo)) continue;
const fresh = new Map(); // repo → {cat, downloads}
for (const [cat, q] of HF_REFRESH_QUERIES) {
const list = await hfJson(`https://huggingface.co/api/models?${q}`);
for (const m of Array.isArray(list) ? list : []) {
const repo = m.id || m.modelId;
if (!repo || fresh.has(repo)) continue;
// Nur Sprachmodelle: Bild-, Audio-, Musik- und Sprach-Modelle kann die
// LLM-Box nicht als Chat-Modell laden.
const pt = String(m.pipeline_tag || "");
if (pt && !["text-generation", "image-text-to-text", "conversational"].includes(pt)) continue;
if (NON_LLM_RE.test(repo)) continue;
fresh.set(repo, { cat, downloads: m.downloads || 0 });
}
}
if (!fresh.size) throw new Error("HuggingFace hat nichts geliefert (erreichbar?)");
const known = new Set(existing.map(m => m.hfRepo));
for (const [repo, f] of fresh) {
if (known.has(repo)) continue;
const id = slugModelId(repo);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf" };
byId.set(id, entry); added++;
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
}
// 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 || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
// Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
// aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
return;
}
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
}));
}
// Werkzeug-Faehigkeit + Sicherheits-Scan nachtragen (wo noch keine Angabe),
// Beliebtheit bei jedem Abgleich frisch (aendert sich taeglich)
const today = new Date().toISOString().slice(0, 10);
const toEnrich = [...byId.values()].filter(e => e.hfRepo && (e.tools === undefined || e.safety === undefined
|| !e.popularity || e.popularity.at !== today));
for (let i = 0; i < toEnrich.length; i += 6) {
await Promise.all(toEnrich.slice(i, i + 6).map(async e => {
if (e.tools === undefined) e.tools = await hfToolSupport(e.hfRepo);
if (e.safety === undefined) e.safety = await hfRepoSafety(e.hfRepo);
if (!e.popularity || e.popularity.at !== today) e.popularity = await hfPopularity(e.hfRepo) || e.popularity;
}));
}
let added = 0;
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
// Aufraeumen: von HF uebernommen, vor > LLM_CATALOG_KEEP_DAYS Tagen, auf
// keiner Box (Platte/Karte), heute nicht mehr gelistet → raus. Kuratierte
// und von Hand hinzugefuegte bleiben immer.
const keepDays = Number(process.env.LLM_CATALOG_KEEP_DAYS || 30);
const onBox = new Set();
for (const w of workers.values()) {
for (const d of ((w.disk && w.disk.models) || [])) onBox.add(d.repo);
for (const sl of (w.slots || [])) if (sl.model) onBox.add(sl.model);
}
let removed = 0;
for (const [id, e] of [...byId]) {
const age = e.added_at ? (Date.now() - new Date(e.added_at).getTime()) / 864e5 : 0;
if (e.source === "hf" && !/Hand/.test(e.description || "") && age > keepDays
&& !fresh.has(e.hfRepo) && !onBox.has(e.hfRepo) && !onBox.has(e.id)) {
byId.delete(id); removed++;
}
}
const merged = Array.from(byId.values());
saveLlmCatalog(merged);
return { models: merged, added };
return { models: merged, added, removed };
}
// Einmal am Tag von selbst (LLM_CATALOG_REFRESH_HOURS, 0 = aus)
const LLM_CATALOG_REFRESH_HOURS = Number(process.env.LLM_CATALOG_REFRESH_HOURS ?? 24);
if (LLM_CATALOG_REFRESH_HOURS > 0) {
setInterval(() => {
refreshLlmCatalogFromHF()
.then(r => log("info", "llm", `LLM-Katalog automatisch aktualisiert: +${r.added}, -${r.removed}`))
.catch(err => log("warn", "llm", `LLM-Katalog automatisch: ${err.message}`));
}, LLM_CATALOG_REFRESH_HOURS * 3600 * 1000);
}
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
const ARIA_TOOL_LOG_DIR = "/shared/logs/aria-tools";
function ariaProof(toTs, project) {
// Beginn = Stefans letzte Nachricht davor im selben Kontext (sonst 30 min)
let fromTs = toTs - 30 * 60 * 1000;
try {
const lines = fs.readFileSync("/shared/config/chat_backup.jsonl", "utf-8").split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i]) continue;
let e; try { e = JSON.parse(lines[i]); } catch { continue; }
if (!e || !(e.ts < toTs) || e.role !== "user") continue;
if ((e.project_id || "") !== project) continue;
fromTs = Math.max(fromTs, e.ts - 2000);
break;
}
} catch (_) { }
const days = new Set([new Date(fromTs).toISOString().slice(0, 10), new Date(toTs).toISOString().slice(0, 10)]);
const reqs = new Map();
for (const d of days) {
let txt = "";
try { txt = fs.readFileSync(`${ARIA_TOOL_LOG_DIR}/${d}.jsonl`, "utf-8"); } catch { continue; }
for (const line of txt.split("\n")) {
if (!line) continue;
let ev; try { ev = JSON.parse(line); } catch { continue; }
if (!ev.requestId) continue;
const r = reqs.get(ev.requestId) || { requestId: ev.requestId, project: null, start: 0, end: 0, tools: [], byId: {} };
if (ev.kind === "start") { r.start = ev.ts; r.project = ev.projectId || ""; r.model = ev.model || ""; }
else if (ev.kind === "end") r.end = ev.ts;
else if (ev.kind === "tool_use") {
const t = { id: ev.id, name: ev.name, input: ev.input, inputTruncatedBytes: ev.inputTruncatedBytes || 0, ts: ev.ts };
r.tools.push(t); if (ev.id) r.byId[ev.id] = t;
} else if (ev.kind === "tool_result") {
const t = ev.id && r.byId[ev.id];
if (t) { t.result = ev.content; t.isError = !!ev.isError; t.resultTruncatedBytes = ev.truncatedBytes || 0; t.doneTs = ev.ts; }
}
reqs.set(ev.requestId, r);
}
}
const hit = [...reqs.values()]
.filter(r => r.start && r.project === project && r.start >= fromTs && r.start <= toTs + 5000)
.sort((a, b) => a.start - b.start)
.map(({ byId, ...r }) => r);
const logSince = (() => { try { return fs.readdirSync(ARIA_TOOL_LOG_DIR).filter(f => /\.jsonl$/.test(f)).sort()[0]?.slice(0, 10) || ""; } catch { return ""; } })();
return { from: fromTs, to: toTs, requests: hit, toolCount: hit.reduce((n, r) => n + r.tools.length, 0), logSince };
}
// ── Gerade im Trend: ladbare GGUF-Modelle von HuggingFace ───────────
// Fuer das Fenster "Bestenlisten" im Modell-Katalog. "Trend" ist nicht
// "das Beste" — viele Community-Mischungen. Darum standardmaessig nur
// bekannte Anbieter. Ergebnis 30 min gecacht (HF nicht dauernd fragen).
const TRUSTED_GGUF_AUTHORS = new Set(["unsloth", "bartowski", "lmstudio-community", "ggml-org", "qwen",
"google", "mistralai", "microsoft", "meta-llama", "deepseek-ai", "ibm-granite", "nvidia", "openai",
"allenai", "huggingfacetb", "thudm", "zai-org", "moonshotai", "minimaxai", "tencent", "baidu"]);
const DISCOVER_CATS = {
chat: { label: "Allgemein / Chat", q: "pipeline_tag=text-generation" },
code: { label: "Programmieren", q: "search=coder" },
vision: { label: "Bilder verstehen", q: "pipeline_tag=image-text-to-text" },
};
const _discoverCache = new Map(); // key → {ts, items}
async function discoverGguf(cat, trusted) {
const c = DISCOVER_CATS[cat] || DISCOVER_CATS.chat;
const key = `${cat}|${trusted ? 1 : 0}`;
const hit = _discoverCache.get(key);
if (hit && Date.now() - hit.ts < 30 * 60 * 1000) return hit.items;
const r = await fetch(`https://huggingface.co/api/models?library=gguf&sort=trendingScore&direction=-1&limit=100&${c.q}`,
{ headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) throw new Error(`HF API ${r.status}`);
let list = (await r.json()).filter(m => m && m.id);
if (trusted) list = list.filter(m => TRUSTED_GGUF_AUTHORS.has(String(m.id).split("/")[0].toLowerCase()));
list = list.slice(0, 18);
const items = [];
for (let i = 0; i < list.length; i += 6) {
await Promise.all(list.slice(i, i + 6).map(async m => {
const q = await hfRepoQuant(m.id, "Q4_K_M");
if (!q) return; // kein ladbares GGUF im Repo
items.push({ repo: m.id, author: String(m.id).split("/")[0], downloads: m.downloads || 0,
likes: m.likes || 0, trending: m.trendingScore || 0,
lastModified: m.lastModified || m.createdAt || "", quant: q.quant, sizeGB: q.sizeGB });
}));
}
items.sort((a, b) => b.trending - a.trending);
_discoverCache.set(key, { ts: Date.now(), items });
return items;
}
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
// Link oder "org/name" → "org/name" (huggingface.co/org/name/tree/main … geht auch)
function hfRepoFromInput(input) {
const t = String(input || "").trim().replace(/^https?:\/\/(www\.)?huggingface\.co\//i, "").replace(/[?#].*$/, "");
const m = t.match(/^([\w.-]+)\/([\w.-]+)/);
return m ? `${m[1]}/${m[2]}` : "";
}
// Repo ohne GGUF (z.B. nur safetensors): GGUF-Fassungen desselben Modells
// auf HuggingFace suchen — meist von mradermacher, bartowski, unsloth & Co.
async function hfGgufAlternatives(repo) {
const name = String(repo).split("/")[1] || "";
if (!name) return [];
try {
const r = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(name)}&filter=gguf&sort=downloads&direction=-1&limit=12`,
{ headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return [];
return (await r.json()).filter(m => m.id && m.id !== repo)
.map(m => ({ repo: m.id, downloads: m.downloads || 0, likes: m.likes || 0 })).slice(0, 8);
} catch { return []; }
}
// ── ℹ im Modell-Katalog: was kann das Modell, ist es zensiert? ──────
// HuggingFace liefert Grundmodell, Lizenz und Schlagworte, aber keine
// verstaendliche Beschreibung. Darum: kurze deutsche Texte je Modellfamilie
// (erkannt am Namen) + die Fakten von HF + Anfang der Modellkarte.
const MODEL_FAMILIES = [
["qwen3-coder", "Qwen3-Coder (Alibaba)", "Aufs Programmieren spezialisiert: Code schreiben, verstehen, umbauen, mit Werkzeugen arbeiten (Agenten). Die 30B-A3B-Fassung ist ein MoE-Modell: pro Wort rechnen nur ~3 Mrd. Parameter — schnell für seine Größe."],
["foundation-sec", "Foundation-Sec (Cisco Foundation AI)", "Auf IT-Sicherheit trainiert: Schwachstellen, Angriffstechniken (MITRE ATT&CK), Logs und Warnmeldungen auswerten. Die reine „Foundation-Sec-8B“ ist ein Basismodell (setzt nur Text fort) — zum Chatten die Instruct- oder Reasoning-Fassung nehmen."],
["vulnllm", "VulnLLM-R", "Sucht Sicherheitslücken in Quellcode und begründet Schritt für Schritt (Reasoning), warum eine Stelle verwundbar ist."],
["dolphin", "Dolphin (Cognitive Computations)", "Bewusst ohne Ablehnungen trainiert: macht, was man verlangt, und lässt sich stark über den Systemprompt steuern. Gut für Aufgaben, die große Anbieter verweigern — man ist aber selbst verantwortlich, was man damit tut."],
["hermes", "Hermes (Nous Research)", "Sehr gutes Befolgen von Anweisungen, Rollenspiel und Werkzeug-Aufrufe (Function Calling), wenige Ablehnungen. Hermes 4 kann zusätzlich „denken“ (Reasoning)."],
["lexi", "Lexi (Orenguteng)", "Unzensierter Llama-Ableger für allgemeinen Chat, beantwortet auch Fragen, die das Original ablehnt."],
["gemma", "Gemma (Google)", "Allround-Modell mit gutem Deutsch; neuere Fassungen verstehen auch Bilder. Eher vorsichtig, lehnt heikle Themen ab."],
["qwen", "Qwen (Alibaba)", "Starke Allround-Familie: Wissen, Logik, Mathe, Code, viele Sprachen inkl. Deutsch; neuere Fassungen mit Denkmodus. Standard-Fassungen haben Schutzfilter."],
["llama", "Llama (Meta)", "Bewährtes Allround-Modell und Basis für sehr viele Ableger; ordentliches Deutsch, solide Werkzeug-Aufrufe."],
["mistral", "Mistral (Mistral AI, Frankreich)", "Gut in europäischen Sprachen, effizient; Mistral Small 24B ist für seine Größe sehr stark."],
["deepseek", "DeepSeek", "Stark in Logik, Mathe und Code, oft mit ausführlichem Denkweg (Reasoning)."],
["phi", "Phi (Microsoft)", "Klein und schnell, erstaunlich gut in Logik für die Größe; eher Englisch."],
];
const UNCENSORED_RE = /uncensor|abliterat|heretic|obliterat|dolphin|lexi|unfiltered|nsfw|no.?refus/i;
async function hfJson(url) {
try {
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
return r.ok ? await r.json() : null;
} catch { return null; }
}
async function hfReadmeIntro(repo) {
try {
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return "";
let t = await r.text();
t = t.replace(/^---[\s\S]*?\n---\n/, ""); // YAML-Kopf
const paras = t.split(/\n\s*\n/).map(p => p.trim())
.filter(p => p && !/^(#|!\[|<|\||```|\[!\[|>|-{3,})/.test(p) && p.replace(/[^A-Za-z]/g, "").length > 60)
// Absaetze, die fast nur Links sind (Website:, Twitter: …), ueberspringen
.filter(p => p.replace(/https?:\/\/\S+/g, "").replace(/[^A-Za-z]/g, "").length > 80);
return paras.slice(0, 2).join("\n\n").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").slice(0, 900);
} catch { return ""; }
}
// Ganze Modellkarte als Text (ohne YAML/Bilder/HTML), fuer die Zusammenfassung
async function hfReadmeText(repo, max = 7000) {
try {
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return "";
return (await r.text()).replace(/^---[\s\S]*?\n---\n/, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").slice(0, max);
} catch { return ""; }
}
// Deutsche Kurzbeschreibung vom LOKALEN Modell aus der Modellkarte — einmal
// erzeugt, dann im Katalog gespeichert (summary_de). So hat jedes neue Modell
// eine, ohne dass jemand Texte pflegt.
async function llmModelSummary(repo) {
repo = hfRepoFromInput(repo);
const cat = loadLlmCatalog();
const entry = cat.find(m => m.hfRepo === repo);
if (entry && entry.summary_de) return { summary: entry.summary_de, by: entry.summary_by || "", cached: true };
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
let base = (meta.cardData || {}).base_model;
base = Array.isArray(base) ? base[0] : base || "";
const card = [await hfReadmeText(base) , await hfReadmeText(repo, 3000)].filter(Boolean).join("\n\n---\n\n");
if (!card.trim()) return { summary: "", error: "Keine Modellkarte auf HuggingFace." };
const tags = (meta.tags || []).filter(t => !/^(base_model|license|region|endpoints)/.test(t)).slice(0, 25).join(", ");
const res = await rvsRequest("llm_request", {
requestId: "modelinfo_" + Date.now(),
messages: [
{ role: "system", content: "Du fasst Modellkarten von KI-Sprachmodellen fuer einen Laien auf Deutsch zusammen. "
+ "Nur was in der Karte steht, nichts erfinden. 3 bis 5 kurze Saetze: wofuer das Modell gedacht ist, worin es "
+ "besonders gut ist, ob es Ablehnungen/Schutzfilter entfernt hat (uncensored/abliterated), und Besonderheiten "
+ "(Denkmodus, Werkzeuge, Sprachen, Groesse). Keine Ueberschrift, kein Markdown." },
{ role: "user", content: `Modell: ${repo}\nGrundmodell: ${base || "-"}\nSchlagworte: ${tags}\n\nModellkarte:\n${card}` },
],
max_tokens: 400, temperature: 0.2, model: "", targetInstance: "",
}, "llm_response", 180000);
const text = String(res.content || res.text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
if (!res.ok || !text) return { summary: "", error: res.error || "Das lokale Modell hat nicht geantwortet." };
const by = res.model || "lokales Modell";
if (entry) {
entry.summary_de = text; entry.summary_by = by; entry.summary_at = new Date().toISOString();
saveLlmCatalog(cat);
}
return { summary: text, by, cached: false };
}
async function llmModelInfo(repo) {
repo = hfRepoFromInput(repo);
if (!repo) throw new Error("ungueltiges Repo");
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
const card = meta.cardData || {};
let base = card.base_model;
base = Array.isArray(base) ? base[0] : base || "";
const baseMeta = base ? (await hfJson(`https://huggingface.co/api/models/${base}`) || {}) : {};
const tags = [...new Set([...(meta.tags || []), ...((card.tags) || [])])]
.filter(t => !/^(base_model|license|region|endpoints|autotrain|text-generation-inference|safetensors|transformers|gguf|conversational|imatrix)/.test(t));
const hay = [repo, base, ...tags].join(" ").toLowerCase();
const fams = [];
for (const [key, name, text] of MODEL_FAMILIES) {
// Ganzes Wort am Anfang ("phi" nicht in "dolphin" finden)
const re = new RegExp("(^|[^a-z0-9])" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
if (name && re.test(hay) && !fams.some(f => f.name === name)) fams.push({ name, text });
}
// Allgemeine Familien (Qwen/Llama …) nur, wenn keine speziellere passt
const special = fams.filter(f => !/^(Qwen|Llama|Mistral|Gemma) \(/.test(f.name));
const families = special.length ? [...special, ...fams.filter(f => !special.includes(f)).slice(0, 1)] : fams.slice(0, 1);
const unc = UNCENSORED_RE.exec(hay);
return {
repo, base, author: meta.author || repo.split("/")[0], license: card.license || "",
downloads: meta.downloads || 0, likes: meta.likes || 0,
created: meta.createdAt || "", updated: meta.lastModified || "",
tags: tags.slice(0, 20), families,
uncensored: !!unc,
tools: await hfToolSupport(repo),
safety: await hfRepoSafety(repo),
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
introFrom: base || repo,
};
}
// Kann das Modell Werkzeuge? Kennzeichen: die Chat-Vorlage im GGUF (HF liefert
// sie unter expand[]=gguf) kennt ein Werkzeug-Format, oder Schlagworte wie
// function-calling/tool-use. Zeigt nur, OB das Format da ist, nicht wie gut.
const TOOLS_TEMPLATE_RE = /\btools\b|tool_call|<tool|function_call/;
const TOOLS_TAG_RE = /function.?call|tool.?use|tool.?calling|agent/i;
async function hfToolSupport(repo) {
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=gguf&expand[]=tags`);
if (!d) return null;
const tpl = String(((d.gguf || {}).chat_template) || "");
if (TOOLS_TEMPLATE_RE.test(tpl)) return true;
if ((d.tags || []).some(t => TOOLS_TAG_RE.test(t))) return true;
return tpl ? false : null; // keine Vorlage = unbekannt
}
// ── Sicherheit eines Repos ─────────────────────────────────────────
// Die Box laedt NUR die GGUF-Datei (llama.cpp -hf / paralleler Download per
// sha256) — Skripte, Pickle o.ae. daneben werden nie geholt oder ausgefuehrt.
// Liegen sie trotzdem im Repo, ist das ein Warnsignal fuer den Anbieter.
// HuggingFace scannt jede Datei (securityFileStatus) — das Ergebnis zeigen wir.
const EXTRA_KNOWN_GGUF_AUTHORS = new Set(["mradermacher", "thebloke", "maziyarpanahi", "nousresearch",
"dphn", "cognitivecomputations", "fdtn-ai", "quantfactory", "lmstudio-ai"]);
function knownGgufAuthor(a) { // Funktion: TRUSTED_GGUF_AUTHORS steht weiter unten in der Datei
return TRUSTED_GGUF_AUTHORS.has(a) || EXTRA_KNOWN_GGUF_AUTHORS.has(a);
}
const RISKY_FILE_RE = /\.(py|sh|bash|bat|cmd|ps1|exe|dll|so|dylib|pkl|pickle|pt|pth|ckpt|joblib|npy|zip|tar|gz|7z|jar|js|vbs)$|(^|\/)pytorch_model[^/]*\.bin$/i;
// Beliebtheit: HF hat keine Sterne — nur Downloads (30 Tage / gesamt) und Likes.
async function hfPopularity(repo) {
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=downloads&expand[]=likes&expand[]=downloadsAllTime`);
if (!d) return null;
return { downloads: d.downloads || 0, downloadsAll: d.downloadsAllTime || 0, likes: d.likes || 0,
at: new Date().toISOString().slice(0, 10) };
}
async function hfRepoSafety(repo) {
const author = String(repo).split("/")[0].toLowerCase();
const out = { known: knownGgufAuthor(author), flagged: [], risky: [], scanned: 0 };
const tree = await hfJson(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1&expand=true`);
for (const f of Array.isArray(tree) ? tree : []) {
if (f.type !== "file") continue;
const st = f.securityFileStatus || {};
const subs = Object.values(st).filter(v => v && typeof v === "object").map(v => String(v.status || ""));
const all = [String(st.status || ""), ...subs].map(x => x.toLowerCase());
if (all.some(x => /unsafe|suspicious|malicious|caution|infected/.test(x))) out.flagged.push({ path: f.path, status: all.filter(Boolean).join(", ") });
else if (all.includes("safe")) out.scanned++;
if (RISKY_FILE_RE.test(f.path)) out.risky.push(f.path);
}
out.risky = out.risky.slice(0, 20);
return out;
}
async function addHfRepoToCatalog(repo, description = "") {
repo = hfRepoFromInput(repo);
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
const q = await hfRepoQuant(repo, "Q4_K_M");
if (!q) {
const err = new Error("In diesem Repo gibt es kein GGUF — das braucht die Box (llama.cpp).");
err.alternatives = await hfGgufAlternatives(repo);
throw err;
}
const cat = loadLlmCatalog();
let id = slugModelId(repo);
const same = cat.find(m => m.hfRepo === repo);
if (same) return { models: cat, id: same.id, existed: true };
while (cat.some(m => m.id === id)) id += "-2";
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
safety: await hfRepoSafety(repo), popularity: await hfPopularity(repo),
added_at: new Date().toISOString().slice(0, 10) });
saveLlmCatalog(cat);
return { models: cat, id, existed: false };
}
// ── File-Project-Manifest ───────────────────────────────────────────
@@ -601,6 +1018,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);
@@ -671,6 +1095,9 @@ function satelliteList() {
}));
}
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
const credsRevealWaiters = new Map();
function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() });
}
@@ -705,6 +1132,11 @@ 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 || [],
disk: w.disk || null, // LLM-Box: freier Platz + heruntergeladene Modelle
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
@@ -1148,6 +1580,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 +1664,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 || {} });
@@ -1233,7 +1674,15 @@ function connectRVS(forcePlain) {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
broadcast({ type: "sat_result", payload: msg.payload || {} });
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
const p = msg.payload || {};
const asker = credsRevealWaiters.get(p.requestId);
if (asker) {
credsRevealWaiters.delete(p.requestId);
if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
} else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
broadcast({ type: "sat_result", payload: p });
}
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie).
@@ -1246,7 +1695,16 @@ function connectRVS(forcePlain) {
location: p.location || "", devices: p.devices || [] });
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
// Credential-Store-Antworten eines Satelliten → an den Browser.
broadcast({ type: msg.type, payload: msg.payload || {} });
// Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der
// gefragt hat — nie an alle offenen Diagnostic-Tabs.
const p = msg.payload || {};
if (p.revealed) {
const asker = credsRevealWaiters.get(p.requestId);
credsRevealWaiters.delete(p.requestId);
if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p }));
} else {
broadcast({ type: msg.type, payload: p });
}
} else if (msg.type === "worker_hello") {
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
const p = msg.payload || {};
@@ -1260,6 +1718,12 @@ 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 || []),
disk: p.disk || prev.disk || null,
// 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 +1746,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 +1840,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 +1858,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 {}
});
@@ -1445,6 +1923,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
freshWs.on("error", () => {});
}
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
// eigene Verbindung, wartet auf expectType mit passender requestId.
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
return new Promise((resolve) => {
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
const proto = RVS_TLS === "true" ? "wss" : "ws";
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
ws2.on("message", (raw) => {
try {
const r = JSON.parse(raw.toString());
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
} catch {}
});
ws2.on("error", (e) => done({ ok: false, error: e.message }));
});
}
function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
@@ -2074,6 +2572,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");
@@ -2209,13 +2728,63 @@ const server = http.createServer((req, res) => {
refreshLlmCatalogFromHF()
.then(r => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added, removed: r.removed }));
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
});
} else if (req.url.startsWith("/api/aria-proof") && req.method === "GET") {
// Belege zu einer ARIA-Antwort: alle Werkzeug-Aufrufe (Bash/ssh, Read, …),
// die ARIA im Proxy zwischen Stefans Frage und dieser Antwort gemacht hat.
// Quelle: /shared/logs/aria-tools/<tag>.jsonl (proxy-patches/routes.js).
const u = new URL(req.url, "http://x");
const to = Number(u.searchParams.get("ts")) || 0;
const project = u.searchParams.get("project") || "";
res.writeHead(200, { "Content-Type": "application/json" });
if (!to) { res.end(JSON.stringify({ ok: false, error: "ts fehlt" })); return; }
try { res.end(JSON.stringify({ ok: true, ...ariaProof(to, project) })); }
catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); }
} else if (req.url.startsWith("/api/llm-discover") && req.method === "GET") {
const u = new URL(req.url, "http://x");
discoverGguf(u.searchParams.get("cat") || "chat", u.searchParams.get("trusted") !== "0")
.then(items => {
const inCat = new Set(loadLlmCatalog().map(m => m.hfRepo));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, items: items.map(i => ({ ...i, inCatalog: inCat.has(i.repo) })) }));
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
});
} else if (req.url.startsWith("/api/llm-model-info") && req.method === "GET") {
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
llmModelInfo(repo)
.then(info => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...info })); })
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
} else if (req.url.startsWith("/api/llm-model-summary") && req.method === "GET") {
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
llmModelSummary(repo)
.then(r => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: !!r.summary, ...r })); })
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
} else if (req.url === "/api/llm-catalog/add" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });
req.on("end", () => {
let repo = "", manual = false;
try { const b = JSON.parse(body || "{}"); repo = b.hfRepo; manual = !!b.manual; } catch {}
addHfRepoToCatalog(repo, manual ? "Von Hand hinzugefügt" : "")
.then(r => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, ...r }));
if (!r.existed) log("info", "llm", `Katalog: ${repo} aus den HF-Trends übernommen (${r.id})`);
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
});
});
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig()));
@@ -3073,6 +3642,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.
@@ -3091,6 +3667,10 @@ wss.on("connection", (ws) => {
params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN;
}
if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
credsRevealWaiters.set(String(msg.requestId), ws);
setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
}
sendToRVS_raw({
type: "sat_command",
payload: {
@@ -3144,8 +3724,13 @@ wss.on("connection", (ws) => {
requestId: "dn_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_list") {
const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
if (msg.reveal) {
credsRevealWaiters.set(requestId, ws);
setTimeout(() => credsRevealWaiters.delete(requestId), 30000);
}
sendToRVS_raw({ type: "sat_creds_list",
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_set") {
sendToRVS_raw({ type: "sat_creds_set",
@@ -3320,17 +3905,38 @@ 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(), downloadOnly: !!msg.downloadOnly });
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,
cpuMoe: msg.cpuMoe ?? 0, // MoE: 0 Karte, -1 alle Experten in den RAM, -2 automatisch verteilen
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
downloadOnly: !!msg.downloadOnly, // nur auf die Platte, in keine Karte
}, 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") {
// cancel = laufenden Download abbrechen, deleteFiles = Dateien von der Platte
sendToRVS_raw({ type: "llm_remove_model", payload: {
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`);
targetInstance: msg.targetInstance || "", key: msg.key, hfRepo: msg.hfRepo || "",
cancel: !!msg.cancel, deleteFiles: !!msg.deleteFiles }, timestamp: Date.now() });
log("info", "llm", `${msg.cancel ? "abbrechen" : msg.deleteFiles ? "Dateien loeschen" : "remove"} '${msg.key}' → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_test") {
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
const reqId = "diagtest_" + Date.now();
+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) ────
+384
View File
@@ -0,0 +1,384 @@
# 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,
Verlauf zu lang → **400 `context_length_exceeded`** mit dem OpenAI-üblichen Text
(„maximum context length is N tokens …") — daran erkennen OpenAI-Clients
(litellm, OpenHands …) den Überlauf und kürzen/verdichten selbst
- 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.
---
## Den Verlauf verwaltet das Programm
Die Box merkt sich **nichts** zwischen zwei Anfragen. Jede Anfrage muss alles
mitbringen, was das Modell sehen soll (Systemprompt, Werkzeuge, Verlauf). Das
Kontextfenster (`ctx`) ist die Obergrenze für **eine** Anfrage inklusive
Antwort. Wächst der Verlauf darüber hinaus, muss das Programm ihn kleiner
machen — drei übliche Wege, auch kombiniert:
| Weg | Was passiert | Gut für |
|---|---|---|
| **Kürzen** | älteste Nachrichten weglassen (Systemprompt behalten) | einfache Chats, Werkzeug-Schleifen |
| **Verdichten** („Compacting") | alte Teile vom Modell zusammenfassen lassen, Zusammenfassung statt Original mitsenden | lange Arbeitssitzungen |
| **Gedächtnis** (RAG) | Wichtiges als Fakten in einer Datenbank ablegen, pro Anfrage nur Passendes dazuholen | dauerhaftes Wissen über Sitzungen hinweg |
Wichtig dabei: Systemprompt **und** Werkzeug-Definitionen zählen mit, Platz
für die Antwort (`max_tokens`) einplanen, Token grob schätzen (Deutsch/Code:
eher 2,5–3 Zeichen pro Token) — und `context_exceeded` trotzdem abfangen:
kürzen, einmal neu senden.
## 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*
+8 -4
View File
@@ -142,15 +142,19 @@ 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`,
Assets: `aria-host-agent-linux-x64-v<v>.zip` (Binary + `install-service.sh` +
systemd-Unit + `.env.example` + `LIESMICH.txt` — entpacken, `.env` anlegen, dann
`./aria-host-agent` oder als Dienst `sudo ./install-service.sh ./.env`),
`aria-host-agent-android-agent-v<v>.apk`,
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
+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 108
versionName '0.0.1.8'
}
// 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.8"
HEARTBEAT_SEC = 25
+4 -1
View File
@@ -66,7 +66,10 @@ else
fi
systemctl daemon-reload
systemctl enable --now aria-host-agent
systemctl enable aria-host-agent
# restart statt "enable --now": bei einem Update laeuft der Dienst schon und
# muss die NEUE Binary laden
systemctl restart aria-host-agent
echo
echo "✓ Installiert & gestartet."
+85 -5
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"
@@ -75,6 +106,38 @@ LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
# Linux-Paket: Binary + Dienst-Installer + Unit + .env-Vorlage in EINER Zip —
# entpacken, .env anlegen, optional als Dienst installieren.
LINUX_PKG_NAME="aria-host-agent-linux-x64-v$VERSION"
LINUX_ZIP="$SCRIPT_DIR/dist/$LINUX_PKG_NAME.zip"
PKG_TMP="$(mktemp -d)"
mkdir -p "$PKG_TMP/$LINUX_PKG_NAME"
install -m 0755 "$LINUX_BIN" "$PKG_TMP/$LINUX_PKG_NAME/aria-host-agent"
install -m 0755 install-service.sh "$PKG_TMP/$LINUX_PKG_NAME/install-service.sh"
install -m 0644 aria-host-agent.service "$PKG_TMP/$LINUX_PKG_NAME/aria-host-agent.service"
install -m 0644 .env.example "$PKG_TMP/$LINUX_PKG_NAME/.env.example"
cat > "$PKG_TMP/$LINUX_PKG_NAME/LIESMICH.txt" <<EOF
ARIA Host-Agent $VERSION (Linux x86_64)
1) Zugangsdaten eintragen:
cp .env.example .env && nano .env (RVS_HOST, RVS_PORT, RVS_TOKEN, ...)
2a) Einfach starten (laeuft, solange das Terminal offen ist):
./aria-host-agent
2b) ODER als Dienst installieren (startet automatisch mit dem Rechner):
sudo ./install-service.sh ./.env
Danach: systemctl status aria-host-agent · journalctl -u aria-host-agent -f
Update: neue Zip entpacken, nochmal sudo ./install-service.sh /etc/aria-host-agent/.env
Screenshots brauchen eine grafische Sitzung — als Dienst gehen sie nur mit
DISPLAY/XAUTHORITY in der Unit (siehe aria-host-agent.service).
EOF
rm -f "$LINUX_ZIP"
( cd "$PKG_TMP" && zip -qr "$LINUX_ZIP" "$LINUX_PKG_NAME" )
rm -rf "$PKG_TMP"
echo -e " ${GREEN}✓${NC} Linux-Paket $LINUX_PKG_NAME.zip ($(du -h "$LINUX_ZIP" | cut -f1))"
( cd android && ./build.sh >/dev/null )
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
@@ -86,18 +149,20 @@ 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 ""
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
ASSETS=(
"$LINUX_BIN::aria-host-agent-linux-x64"
"$LINUX_ZIP::$LINUX_PKG_NAME.zip"
"$APK::aria-host-agent-android-$TAG.apk"
)
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
@@ -143,6 +208,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}"
+15 -2
View File
@@ -99,7 +99,20 @@ function _parseToolCalls(text) {
return { tool_calls: tcs, rest: restParts.join("").trim() };
}
export function cliResultToOpenai(result, requestId) {
// Laengere Texte, die Claude im Turn VOR der letzten Antwort geschrieben hat
// (z.B. ein Bericht, danach noch Warten/Werkzeuge, dann eine kurze
// Schlussnotiz). result.result enthaelt nur den LETZTEN Textblock — der
// Bericht ging so verloren (28.10., 2-Stunden-Turn). Kurze Zwischensaetze
// ("Ich schaue nach …") bleiben weg, Sprachausgabe nur aus der letzten Antwort.
const EARLIER_TEXT_MIN = 300;
function _mergeEarlier(finalText, earlier) {
const keep = (earlier || [])
.map(t => String(t || "").replace(/<voice>[\s\S]*?<\/voice>/g, "").replace(/\[\[[A-Z_]+\]\]/g, "").trim())
.filter(t => t.length >= EARLIER_TEXT_MIN && !finalText.includes(t));
return keep.length ? keep.join("\n\n") + "\n\n" + finalText : finalText;
}
export function cliResultToOpenai(result, requestId, earlierTexts = []) {
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
@@ -121,7 +134,7 @@ export function cliResultToOpenai(result, requestId) {
message.content = rest || null;
finishReason = "tool_calls";
} else {
message.content = rawText;
message.content = _mergeEarlier(rawText, earlierTexts);
}
return {
+59 -2
View File
@@ -118,7 +118,52 @@ function _emitToolEvent(toolName, projectId) {
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/
function _emitStreamEvent(requestId, kind, fields) {
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
const ev = { requestId, kind, ts: Date.now(), ...fields };
_postJson(STREAM_HOOK_URL, ev);
_persistToolEvent(ev);
}
// ── Werkzeug-Protokoll (Beleg) ────────────────────────────────
// Was ARIA SELBST im Proxy ausfuehrt (Bash/ssh, Read, Edit, …) war bisher
// nur live sichtbar. Jetzt zusaetzlich dauerhaft: eine JSONL-Datei pro Tag
// unter /shared/logs/aria-tools/. Die Diagnostic zeigt pro ARIA-Antwort die
// zugehoerigen Aufrufe ("Belege"). Nur start/tool_use/tool_result/end —
// keine Text-Deltas. Fail-open: ein Schreibfehler stoert ARIA nie.
const TOOL_LOG_DIR = process.env.ARIA_TOOL_LOG_DIR || "/shared/logs/aria-tools";
const TOOL_LOG_KINDS = new Set(["start", "tool_use", "tool_result", "text", "end"]);
const TOOL_LOG_KEEP_DAYS = 30;
let _toolLogCleaned = "";
// Offensichtliche Geheimnisse nicht dauerhaft ablegen (Passwoerter, Tokens,
// API-Keys, private Schluessel) — Ausgaben wie "cat .env" landen sonst im Log.
const SECRET_PATTERNS = [
[/(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g, "$1 *** $2"],
[/((?:pass(?:word|wd)?|token|secret|api[_-]?key|auth|credential)[\w-]*["']?\s*[=:]\s*["']?)[^\s"',;]{4,}/gi, "$1***"],
[/\b(hf_|ghp_|gho_|github_pat_|sk-|xox[bp]-|glpat-)[A-Za-z0-9_-]{8,}/g, "$1***"],
[/(https?:\/\/[^\s:@\/]+:)[^\s@\/]+@/g, "$1***@"],
];
function _redact(v) {
if (typeof v !== "string") return v;
for (const [re, rep] of SECRET_PATTERNS) v = v.replace(re, rep);
return v;
}
function _persistToolEvent(ev) {
if (!TOOL_LOG_KINDS.has(ev.kind)) return;
try {
ev = { ...ev, input: _redact(ev.input), content: _redact(ev.content),
text: typeof ev.text === "string" ? _redact(ev.text).slice(0, 20000) : ev.text };
const day = new Date(ev.ts).toISOString().slice(0, 10);
fs.mkdirSync(TOOL_LOG_DIR, { recursive: true });
fs.appendFile(`${TOOL_LOG_DIR}/${day}.jsonl`, JSON.stringify(ev) + "\n", () => {});
if (_toolLogCleaned !== day) { // einmal am Tag: alte Tage weg
_toolLogCleaned = day;
const cutoff = new Date(Date.now() - TOOL_LOG_KEEP_DAYS * 86400000).toISOString().slice(0, 10);
for (const f of fs.readdirSync(TOOL_LOG_DIR)) {
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) < cutoff) {
try { fs.unlinkSync(`${TOOL_LOG_DIR}/${f}`); } catch (_) { }
}
}
}
} catch (_) { /* fail-open */ }
}
function _truncate(str, max) {
@@ -442,6 +487,16 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
subprocess.on("result", (result) => {
finalResult = result;
});
// Alle Textbloecke des Turns sammeln — die letzte Antwort allein
// (result.result) verliert sonst einen frueher geschriebenen Bericht.
const turnTexts = [];
subprocess.on("assistant", (message) => {
try {
for (const b of message?.message?.content || []) {
if (b && b.type === "text" && b.text) turnTexts.push(b.text);
}
} catch (_) { }
});
subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message);
isComplete = true;
@@ -464,7 +519,9 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
return;
}
if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId));
const last = String(finalResult.result || "").trim();
const earlier = turnTexts.filter(t => t.trim() && t.trim() !== last);
res.json(cliResultToOpenai(finalResult, requestId, earlier));
}
else if (!res.headersSent) {
res.status(500).json({
+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)
+239 -10
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",
@@ -159,9 +188,12 @@ const TARGETED_REPLIES = new Set(TARGETED.values());
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000;
// Antworten bis zu dieser Groesse gehen an den Raum, wenn der Fragende nur
// neu verbunden ist (siehe Weiterleitung). Dateien sind groesser.
const REJOIN_BROADCAST_MAX = 2 * 1024 * 1024;
function pendingKey(replyType, requestId) {
return `${replyType}${requestId}`;
return `${replyType}\u0000${requestId}`;
}
/** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */
@@ -261,7 +293,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 +322,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 +411,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 +450,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 +479,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 +571,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 +590,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 +617,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;
}
@@ -514,7 +699,16 @@ function registerClient(ws, token) {
rememberRequester(ws, msg);
// Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum.
const targets = targetedRecipients(msg);
let targets = targetedRecipients(msg);
// Fragender inzwischen neu verbunden (RVS-/Proxy-Neustart waehrend die
// Antwort noch lief): seine alte Verbindung gibt es nicht mehr, die neue
// kennen wir nicht. Kleine Antworten (LLM-Antwort, Status) dann an den
// Raum — der Fragende erkennt sie an der requestId. Grosse (Dateien)
// bleiben verworfen, sonst gingen hunderte MB an alle.
if (targets && targets.length === 0 && raw.length <= REJOIN_BROADCAST_MAX) {
log(`${msg.type}: Fragender neu verbunden — ${(raw.length / 1024).toFixed(0)}KB an den Raum`);
targets = null;
}
if (targets) {
const data = raw.toString();
let sent = 0;
@@ -599,15 +793,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
+9 -1
View File
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
# fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login)
# fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten
# fritzbox.backup / backups / backup_view / restore / backup_delete
# FritzBox: Einstellungen sichern (Taste an der Box, 2 min),
# auflisten, lesbar ansehen, wieder einspielen, loeschen.
# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups).
# (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
# adb.connect ADB-Verbindung aufbauen (params: ip)
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
+902 -64
View File
File diff suppressed because it is too large Load Diff
+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."
+31 -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
@@ -162,6 +181,7 @@ services:
volumes:
- ./models:/models # generierte Config + Registry + Cache
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
- /sys:/hostsys:ro # nur lesen: Tempo der Netzwerkkarte (Download-Fenster)
environment:
- NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi)
- NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute
@@ -178,6 +198,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"))
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
+1443 -125
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
+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
+376
View File
@@ -0,0 +1,376 @@
"""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"
if code == "context_exceeded":
# Wie OpenAI: 400 + context_length_exceeded und die Zahlen im
# Text — daran erkennen OpenAI-Clients (litellm/OpenHands & Co.)
# einen Kontext-Ueberlauf und kuerzen/verdichten selbst.
n_ctx, n_req = res.get("ctx"), res.get("promptTokens")
msg = (f"This model's maximum context length is {n_ctx} tokens. However, your "
f"messages resulted in {n_req} tokens. Please reduce the length of the messages."
if n_ctx and n_req else (res.get("error") or "context length exceeded"))
return await reply(writer, 400, oai_error(msg, "context_length_exceeded"))
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())
+13 -3
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"))
@@ -323,6 +327,7 @@ class StreamSession:
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
noise_floor: float = 0.0
closed: bool = False
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
endpoint_sent: bool = False
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
@@ -392,10 +397,12 @@ class SessionManager:
sess.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> None:
def end_session(self, request_id: str, reason: str = "") -> None:
sess = self._sessions.get(request_id)
if sess is not None:
sess.closed = True
if str(reason).startswith("cancel"):
sess.discard = True # Muelleimer in der App: verwerfen
def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None)
@@ -616,6 +623,9 @@ class SessionManager:
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
if sess.discard:
reason = "cancelled"
_min_voiced = 10 ** 9 # verworfen: nie transkribieren, leerer Text
if sess.voiced_frames < _min_voiced:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
@@ -806,7 +816,7 @@ async def run_loop(sessions: SessionManager) -> None:
elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
sessions.end_session(payload.get("requestId", ""), payload.get("reason", ""))
elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
+16 -4
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"))
@@ -386,6 +390,7 @@ class StreamSession:
voice_rms_min: float = STREAM_VOICE_RMS_MIN
voice_rms_max: float = STREAM_VOICE_RMS_MAX
closed: bool = False # nach stream_end gesetzt
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
@@ -479,13 +484,16 @@ class SessionManager:
session.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> Optional[StreamSession]:
def end_session(self, request_id: str, reason: str = "") -> Optional[StreamSession]:
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
Final-Transcribe + Cleanup."""
Final-Transcribe + Cleanup. reason "cancel:*" (z.B. Muelleimer in der
App) = verwerfen: kein Transcribe, leerer Text — nichts geht an ARIA."""
session = self._sessions.get(request_id)
if session is None:
return None
session.closed = True
if str(reason).startswith("cancel"):
session.discard = True
return session
def drop(self, request_id: str) -> None:
@@ -803,6 +811,10 @@ class SessionManager:
if sess.partial_task is not None and not sess.partial_task.done():
sess.partial_task.cancel()
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
if sess.discard:
logger.info("Stream %s: verworfen (cancel) — kein Transcribe", sess.request_id[:8])
audio = audio[:0]
reason = "cancelled"
if audio.size == 0:
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
final_text = ""
@@ -1076,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
req_id[:8], payload.get("reason", ""))
await _debug_log(ws, "stream.end",
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
sessions.end_session(req_id)
sessions.end_session(req_id, payload.get("reason", ""))
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError: