Compare commits

...
54 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
33 changed files with 4523 additions and 378 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20601 versionCode 20602
versionName "0.2.6.1" versionName "0.2.6.2"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.6.1", "version": "0.2.6.2",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+39
View File
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
onTapStart: () => Promise<boolean>; onTapStart: () => Promise<boolean>;
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */ /** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
onTapStop: () => Promise<void>; onTapStop: () => Promise<void>;
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
onTapCancel?: () => Promise<void>;
/** Button deaktivieren */ /** Button deaktivieren */
disabled?: boolean; disabled?: boolean;
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */ /** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
const VoiceButton: React.FC<VoiceButtonProps> = ({ const VoiceButton: React.FC<VoiceButtonProps> = ({
onTapStart, onTapStart,
onTapStop, onTapStop,
onTapCancel,
disabled = false, disabled = false,
wakeWordActive = false, wakeWordActive = false,
}) => { }) => {
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
} }
}, [disabled, onTapStart, onTapStop]); }, [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 formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000);
const tenths = Math.floor((ms % 1000) / 100); const tenths = Math.floor((ms % 1000) / 100);
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
{wakeWordActive && !isRecording && ( {wakeWordActive && !isRecording && (
<View style={styles.wakeWordDot} /> <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 <Animated.View
style={[ style={[
styles.buttonOuter, styles.buttonOuter,
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
backgroundColor: '#34C759', backgroundColor: '#34C759',
zIndex: 10, 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: { buttonOuter: {
width: 64, width: 64,
height: 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 // Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => { const handleFileSelected = useCallback(async (file: FileData) => {
setShowFileUpload(false); setShowFileUpload(false);
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
<VoiceButton <VoiceButton
onTapStart={handleVoiceButtonStart} onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop} onTapStop={handleVoiceButtonStop}
onTapCancel={handleVoiceButtonCancel}
disabled={connectionState !== 'connected'} disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive} wakeWordActive={wakeWordActive}
/> />
+251 -13
View File
@@ -33,6 +33,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod import router as router_mod
import metrics import metrics
from local_llm import local_llm_chat from local_llm import local_llm_chat
import local_compact
import skills as skills_mod import skills as skills_mod
import triggers as triggers_mod import triggers as triggers_mod
import watcher as watcher_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 # Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten. # (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
_NO_MODEL = {"model": "", "requested": "", "reason": ""} _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 (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/") SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start # 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)"}, "category": {"type": "string", "description": "Neue Kategorie (optional)"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"}, "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"], "required": ["id"],
}, },
@@ -1026,8 +1053,10 @@ META_TOOLS = [
"name": "memory_save", "name": "memory_save",
"description": ( "description": (
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). " "Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges " "Nutze das VON DIR AUS, sobald du etwas Nicht-Offensichtliches lernst "
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes " "(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" "File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
"Type-Wahl:\n" "Type-Wahl:\n"
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\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'"}, "category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"}, "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": { "attach_paths": {
"type": "array", "type": "array",
"items": {"type": "string"}, "items": {"type": "string"},
@@ -1268,8 +1297,51 @@ META_TOOLS = [
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' " "existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige " "(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) " "SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht " "— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete MIT "
"hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) " "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) " "— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + " "und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort " "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). # /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude. # 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 _LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen # 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 # mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der # AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer). # 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 = ( _SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found", "fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar", "konnte nicht", "keine verbindung", "nicht verfuegbar",
@@ -2441,6 +2517,61 @@ class Agent:
converse = flags["converse"] converse = flags["converse"]
return speak, 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, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config() cfg = router_mod.load_config()
@@ -2476,9 +2607,21 @@ class Agent:
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR, sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools)) has_tools=bool(tools))
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster # Compacting (local_compact): aeltere Nachrichten als Notizzettel im
# wuerde das Prefill aufblaehen). # Systemprompt, dazu so viele der letzten Nachrichten, wie ins
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:] # 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": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window] 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, result = self.proxy.chat_full(messages, tools=tools, model=model,
project_id=project_id) project_id=project_id)
if not safeguard.looks_like_block(result.content, result.is_error): 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 return result, model
code = safeguard.detail_code(result.content) code = safeguard.detail_code(result.content)
@@ -2719,6 +2869,19 @@ class Agent:
if active_project_id: if active_project_id:
projects_mod.touch_project(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 # Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher # /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der # ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
@@ -2791,6 +2954,14 @@ class Agent:
oauth_callback_host=oauth_host, oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port, oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls) 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 # Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt- # echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter # Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
@@ -3178,13 +3349,22 @@ class Agent:
if not sat or not action: if not sat or not action:
return "FEHLER: satellite und action sind Pflicht." return "FEHLER: satellite und action sind Pflicht."
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {} 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 # Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout", # WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
# obwohl die Installation noch laeuft. # obwohl die Installation noch laeuft.
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download + # adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
# Installation koennen zusammen mehrere Minuten dauern. # Installation koennen zusammen mehrere Minuten dauern.
sat_timeout = (320.0 if action.startswith("adb.") 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", { result = _post("/internal/satellite", {
"op": "command", "satellite": sat, "op": "command", "satellite": sat,
"device": arguments.get("device") or "", "action": action, "params": params, "device": arguments.get("device") or "", "action": action, "params": params,
@@ -3192,7 +3372,18 @@ class Agent:
}, sat_timeout + 20) }, sat_timeout + 20)
if result.get("ok"): if result.get("ok"):
r = result.get("result") 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." 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'}" return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
except Exception as exc: except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}" return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
@@ -4391,6 +4582,15 @@ class Agent:
existing.category = str(arguments["category"]).strip() existing.category = str(arguments["category"]).strip()
if "tags" in arguments and arguments["tags"] is not None: 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()] 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: if "pinned" in arguments and arguments["pinned"] is not None:
existing.pinned = bool(arguments["pinned"]) existing.pinned = bool(arguments["pinned"])
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat() existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
@@ -4418,6 +4618,29 @@ class Agent:
except Exception as e: except Exception as e:
logger.exception("memory_update fehlgeschlagen") logger.exception("memory_update fehlgeschlagen")
return f"FEHLER: {e}" 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": if name == "memory_save":
title = (arguments.get("title") or "").strip() title = (arguments.get("title") or "").strip()
content = (arguments.get("content") or "").strip() content = (arguments.get("content") or "").strip()
@@ -4431,7 +4654,13 @@ class Agent:
category = (arguments.get("category") or "").strip() category = (arguments.get("category") or "").strip()
tags_in = arguments.get("tags") or [] 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 [] 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_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 [] attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
try: try:
@@ -4681,13 +4910,22 @@ class Agent:
# Konversation als Klartext bauen # Konversation als Klartext bauen
transcript = "\n".join( 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 )[:30000] # Cap auf 30k Zeichen damit der Prompt nicht explodiert
system = ( system = (
"Du extrahierst aus einer Konversation zwischen Stefan und ARIA die " "Du extrahierst aus einer Konversation zwischen Stefan und ARIA die "
"wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, " "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\", " "im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", "
"\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. " "\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. "
"Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array." "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, def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None, temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict: model=None, timeout=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...]. """Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten. 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 req["tools"] = tools
if model: if model:
req["model"] = model req["model"] = model
if timeout:
req["timeout"] = timeout # Bridge-seitige Frist (sonst 30 s)
try: try:
body = json.dumps(req).encode("utf-8") body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request( http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST", f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
headers={"Content-Type": "application/json"}, 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")) result = json.loads(resp.read().decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
try: try:
+20 -1
View File
@@ -1190,6 +1190,11 @@ def conversation_reset():
"""Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert. """Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert.
Destillierte facts bleiben in der DB.""" Destillierte facts bleiben in der DB."""
conversation().reset() conversation().reset()
try:
import local_compact
local_compact.reset() # Notizzettel des lokalen Modus gehoert dazu
except Exception:
pass
return {"ok": True, "turns": 0} return {"ok": True, "turns": 0}
@@ -1338,11 +1343,25 @@ def _context_model_rows() -> dict:
"reason": reason, "reason": reason,
"blocked": sorted(blocked), "blocked": sorted(blocked),
}) })
choices = _model_choices() choices = _model_choices() + _local_model_choices()
names = {c["id"]: c["name"] for c in choices} names = {c["id"]: c["name"] for c in choices}
return {"default": default, "rows": rows, "choices": choices, "names": names} 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") @app.get("/context-model/list")
def context_model_list(): def context_model_list():
return _context_model_rows() 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() 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: def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
"""Baue 'Moeglicherweise relevant'-Block aus Search-Treffern.""" """Baue 'Moeglicherweise relevant'-Block aus Search-Treffern."""
if not matches: if not matches:
@@ -299,7 +363,7 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
lines = ["## Moeglicherweise relevant (aus Gedaechtnis)"] lines = ["## Moeglicherweise relevant (aus Gedaechtnis)"]
for p in matches: for p in matches:
score = f" [score={p.score:.2f}]" if p.score is not None else "" 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()}") lines.append(f" {p.content.strip()}")
att_line = _attachments_line(p) att_line = _attachments_line(p)
if att_line: if att_line:
@@ -322,8 +386,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
lines = [ lines = [
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)", "## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der " "Diese Eintraege hast DU gespeichert — hier Titel und Merkzeile, nicht der "
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` " "ganze Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** " "(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.", "(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] items = grouped[cat]
lines.append(f"### {cat}") lines.append(f"### {cat}")
for p in items: 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("") lines.append("")
return "\n".join(lines).strip() return "\n".join(lines).strip()
@@ -555,7 +620,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth.""" """Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die # Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt. # 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()] "", build_voice_flow_section()]
if skills: if skills:
parts.append("") parts.append("")
+26 -3
View File
@@ -3449,6 +3449,16 @@ class ARIABridge:
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup # stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
# als Leak-Schutz, falls gar kein Endpoint mehr kommt. # als Leak-Schutz, falls gar kein Endpoint mehr kommt.
req_id = payload.get("requestId", "") or "" 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: if req_id:
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@@ -3473,6 +3483,9 @@ class ARIABridge:
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)", logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
payload.get("reason", "")) payload.get("reason", ""))
return 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 "" audio_request_id = payload.get("audioRequestId", "") or ""
voice = payload.get("voice", "") or "" voice = payload.get("voice", "") or ""
speed_raw = payload.get("speed") speed_raw = payload.get("speed")
@@ -4271,7 +4284,7 @@ class ARIABridge:
async def _local_llm(self, messages: list, max_tokens: int = 512, async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None, temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict: model=None, timeout: Optional[float] = None) -> dict:
"""Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf """Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck. llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}.""" Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
@@ -4286,7 +4299,7 @@ class ARIABridge:
res = await self._llm_cluster.route({ res = await self._llm_cluster.route({
"model": model or "", "messages": messages, "max_tokens": max_tokens, "model": model or "", "messages": messages, "max_tokens": max_tokens,
"temperature": temperature, "stop": stop, "tools": tools, "temperature": temperature, "stop": stop, "tools": tools,
}, timeout=self._LLM_TIMEOUT_S) }, timeout=timeout or self._LLM_TIMEOUT_S)
if not res.get("ok"): if not res.get("ok"):
err = res.get("error") or "llm-adapter Fehler" err = res.get("error") or "llm-adapter Fehler"
if res.get("errorCode") == "model_unavailable": if res.get("errorCode") == "model_unavailable":
@@ -4773,6 +4786,10 @@ class ARIABridge:
elif method == "POST" and path == "/internal/satellite-list": elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities. # Brain fragt: welche Satelliten/Netze sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()}) await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
elif method in ("GET", "POST") and path == "/internal/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": elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte). # Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()}) await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
@@ -4934,10 +4951,16 @@ class ARIABridge:
temperature = 0.7 temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None _tools = data.get("tools") if isinstance(data.get("tools"), list) else None
_model = data.get("model") if isinstance(data.get("model"), str) 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( result = await self._local_llm(
messages=messages, max_tokens=max_tokens, messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"), temperature=temperature, stop=data.get("stop"),
tools=_tools, model=_model, tools=_tools, model=_model, timeout=_timeout,
) )
status = 200 if result.get("ok") else 502 status = 200 if result.get("ok") else 502
await _send_response(writer, status, result) await _send_response(writer, status, result)
+1466 -220
View File
File diff suppressed because it is too large Load Diff
+488 -23
View File
@@ -517,22 +517,46 @@ async function hfRepoQuant(repo, wanted = "Q4_K_M") {
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes // Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse // HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
// eingetragen — auch bei schon vorhandenen HF-Eintraegen. // 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() { 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 existing = loadLlmCatalog();
const byId = new Map(existing.map(m => [m.id, m])); const byId = new Map(existing.map(m => [m.id, m]));
let added = 0; const fresh = new Map(); // repo → {cat, downloads}
for (const m of (Array.isArray(list) ? list : [])) { for (const [cat, q] of HF_REFRESH_QUERIES) {
const repo = m.id || m.modelId; const list = await hfJson(`https://huggingface.co/api/models?${q}`);
if (!repo || !/gguf/i.test(repo)) continue; 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); const id = slugModelId(repo);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0, byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true }; description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
byId.set(id, entry); uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
} }
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben // Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt. // gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
@@ -540,18 +564,354 @@ async function refreshLlmCatalogFromHF() {
for (let i = 0; i < toCheck.length; i += 6) { for (let i = 0; i < toCheck.length; i += 6) {
await Promise.all(toCheck.slice(i, i + 6).map(async e => { await Promise.all(toCheck.slice(i, i + 6).map(async e => {
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M"); const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
if (!q) { if (!q || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
// Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren. // Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
if (e._new) byId.delete(e.id); else e.unusable = true; // aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
return; return;
} }
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable; 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; } } 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()); const merged = Array.from(byId.values());
saveLlmCatalog(merged); 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 ─────────────────────────────────────────── // ── File-Project-Manifest ───────────────────────────────────────────
@@ -735,6 +1095,9 @@ function satelliteList() {
})); }));
} }
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
const credsRevealWaiters = new Map();
function broadcastSatellites() { function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() }); broadcast({ type: "sat_update", satellites: satelliteList() });
} }
@@ -773,6 +1136,7 @@ function workerList() {
// aber diese Feldliste liess sie nie zum Browser durch ("meldet keine Karten"). // aber diese Feldliste liess sie nie zum Browser durch ("meldet keine Karten").
slots: Array.isArray(w.slots) ? w.slots : null, slots: Array.isArray(w.slots) ? w.slots : null,
knownModels: w.knownModels || [], knownModels: w.knownModels || [],
disk: w.disk || null, // LLM-Box: freier Platz + heruntergeladene Modelle
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS, online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
})); }));
} }
@@ -1310,7 +1674,15 @@ function connectRVS(forcePlain) {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} }); broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") { } else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout). // 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") { } else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list) // Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie). // der Bridge → an den Browser (Agent-Historie).
@@ -1323,7 +1695,16 @@ function connectRVS(forcePlain) {
location: p.location || "", devices: p.devices || [] }); location: p.location || "", devices: p.devices || [] });
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") { } else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
// Credential-Store-Antworten eines Satelliten → an den Browser. // 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") { } else if (msg.type === "worker_hello") {
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet. // Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
const p = msg.payload || {}; const p = msg.payload || {};
@@ -1342,6 +1723,7 @@ function connectRVS(forcePlain) {
// in eine Karte laden koennte. // in eine Karte laden koennte.
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null), slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []), 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 // 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 // Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt). // die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
@@ -1541,6 +1923,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
freshWs.on("error", () => {}); 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) { function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return; if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj); const payload = JSON.stringify(msgObj);
@@ -2326,13 +2728,63 @@ const server = http.createServer((req, res) => {
refreshLlmCatalogFromHF() refreshLlmCatalogFromHF()
.then(r => { .then(r => {
res.writeHead(200, { "Content-Type": "application/json" }); 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`); log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
}) })
.catch(err => { .catch(err => {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() })); 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") { } else if (req.url === "/api/local-llm-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig())); res.end(JSON.stringify(readLocalLlmConfig()));
@@ -3215,6 +3667,10 @@ wss.on("connection", (ws) => {
params.rvs_tls = RVS_TLS === "true"; params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN; 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({ sendToRVS_raw({
type: "sat_command", type: "sat_command",
payload: { payload: {
@@ -3268,8 +3724,13 @@ wss.on("connection", (ws) => {
requestId: "dn_" + Date.now() }, requestId: "dn_" + Date.now() },
timestamp: Date.now() }); timestamp: Date.now() });
} else if (msg.action === "sat_creds_list") { } 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", 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() }); timestamp: Date.now() });
} else if (msg.action === "sat_creds_set") { } else if (msg.action === "sat_creds_set") {
sendToRVS_raw({ type: "sat_creds_set", sendToRVS_raw({ type: "sat_creds_set",
@@ -3455,12 +3916,14 @@ wss.on("connection", (ws) => {
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box, // Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
// sieht man das im Download-Fenster, statt dass einfach nichts passiert. // sieht man das im Download-Fenster, statt dass einfach nichts passiert.
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "", const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
{ phase: "queued", requestedAt: Date.now() }); { phase: "queued", requestedAt: Date.now(), downloadOnly: !!msg.downloadOnly });
if (queued) broadcast({ type: "llm_provision_progress", payload: queued }); if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
sendToRVS_raw({ type: "llm_provision_model", payload: { sendToRVS_raw({ type: "llm_provision_model", payload: {
targetInstance: msg.targetInstance || "", targetInstance: msg.targetInstance || "",
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl, 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 gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
downloadOnly: !!msg.downloadOnly, // nur auf die Platte, in keine Karte
}, timestamp: Date.now() }); }, timestamp: Date.now() });
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`); log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_unload_model") { } else if (msg.action === "llm_unload_model") {
@@ -3469,9 +3932,11 @@ wss.on("connection", (ws) => {
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() }); targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`); log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_remove_model") { } else if (msg.action === "llm_remove_model") {
// cancel = laufenden Download abbrechen, deleteFiles = Dateien von der Platte
sendToRVS_raw({ type: "llm_remove_model", payload: { sendToRVS_raw({ type: "llm_remove_model", payload: {
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() }); targetInstance: msg.targetInstance || "", key: msg.key, hfRepo: msg.hfRepo || "",
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`); 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") { } else if (msg.action === "llm_test") {
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response). // Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
const reqId = "diagtest_" + Date.now(); const reqId = "diagtest_" + Date.now();
+23 -1
View File
@@ -296,7 +296,10 @@ steht und welche Modelle es gibt. Im Programm eintragen:
- `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem - `POST /v1/chat/completions` — auch `stream: true` (kommt derzeit in einem
Rutsch) und `tools` Rutsch) und `tools`
- Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell → - Fehler als HTTP-Fehler mit OpenAI-Fehlerobjekt: nicht geladenes Modell →
404 `model_not_found`, Cluster nicht erreichbar → 503, Zeitüberschreitung → 504 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 - 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 - **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 Container. Dann das Docker-Netz `aria-llm` anhängen und
@@ -333,6 +336,25 @@ 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 ## Was Programme nicht senden
`llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der `llm_provision_model`, `llm_unload_model` und `llm_remove_model` sind der
+10 -1
View File
@@ -18,7 +18,16 @@ WORKDIR /work
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe # nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht). # 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 \ && unzip -q /tmp/nssm.zip -d /tmp \
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \ && cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
&& rm -rf /tmp/nssm* && rm -rf /tmp/nssm*
+4 -1
View File
@@ -151,7 +151,10 @@ Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
SKIP_WINDOWS=1 ./release_agent.sh # ohne Windows (schneller) 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 `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 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 Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent' applicationId 'de.hackersoft.ariaagent'
minSdk 26 minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34 targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 106 versionCode 108
versionName '0.0.1.6' versionName '0.0.1.8'
} }
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit // Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
+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)) 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). # Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.1.6" AGENT_VERSION = "0.0.1.8"
HEARTBEAT_SEC = 25 HEARTBEAT_SEC = 25
+4 -1
View File
@@ -66,7 +66,10 @@ else
fi fi
systemctl daemon-reload 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
echo "✓ Installiert & gestartet." echo "✓ Installiert & gestartet."
+37 -3
View File
@@ -106,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; } [ -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))" 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 ) ( cd android && ./build.sh >/dev/null )
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk" APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; } [ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
@@ -117,18 +149,20 @@ if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}" echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
else else
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)" 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" echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
else else
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}" 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
fi fi
echo "" echo ""
# Asset-Liste aufbauen: "lokaler_pfad::asset-name" # Asset-Liste aufbauen: "lokaler_pfad::asset-name"
ASSETS=( ASSETS=(
"$LINUX_BIN::aria-host-agent-linux-x64" "$LINUX_ZIP::$LINUX_PKG_NAME.zip"
"$APK::aria-host-agent-android-$TAG.apk" "$APK::aria-host-agent-android-$TAG.apk"
) )
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS # Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
+15 -2
View File
@@ -99,7 +99,20 @@ function _parseToolCalls(text) {
return { tool_calls: tcs, rest: restParts.join("").trim() }; 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 // modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version // der Request wirklich lief — bei einem Alias also die aufgeloeste Version
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call // ("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; message.content = rest || null;
finishReason = "tool_calls"; finishReason = "tool_calls";
} else { } else {
message.content = rawText; message.content = _mergeEarlier(rawText, earlierTexts);
} }
return { return {
+59 -2
View File
@@ -118,7 +118,52 @@ function _emitToolEvent(toolName, projectId) {
* kind: "start" | "text" | "tool_use" | "tool_result" | "end" * kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/ */
function _emitStreamEvent(requestId, kind, fields) { 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) { function _truncate(str, max) {
@@ -442,6 +487,16 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
subprocess.on("result", (result) => { subprocess.on("result", (result) => {
finalResult = 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) => { subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message); console.error("[NonStreaming] Error:", error.message);
isComplete = true; isComplete = true;
@@ -464,7 +519,9 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
return; return;
} }
if (finalResult) { 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) { else if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
+13 -1
View File
@@ -188,6 +188,9 @@ const TARGETED_REPLIES = new Set(TARGETED.values());
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort. // Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
const pendingReplies = new Map(); const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000; 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) { function pendingKey(replyType, requestId) {
return `${replyType}\u0000${requestId}`; return `${replyType}\u0000${requestId}`;
@@ -696,7 +699,16 @@ function registerClient(ws, token) {
rememberRequester(ws, msg); rememberRequester(ws, msg);
// Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum. // 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) { if (targets) {
const data = raw.toString(); const data = raw.toString();
let sent = 0; let sent = 0;
+9 -1
View File
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip) # snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login) # fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (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') # 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.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
# adb.connect ADB-Verbindung aufbauen (params: ip) # adb.connect ADB-Verbindung aufbauen (params: ip)
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
# Mikro) — spart das Zusammenklicken mit der Fernbedienung # Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion # ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab. # 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) ────────────────────────────── # ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren. # 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
+1
View File
@@ -181,6 +181,7 @@ services:
volumes: volumes:
- ./models:/models # generierte Config + Registry + Cache - ./models:/models # generierte Config + Registry + Cache
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml) - ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
- /sys:/hostsys:ro # nur lesen: Tempo der Netzwerkkarte (Download-Fenster)
environment: environment:
- NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi) - NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi)
- NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute - NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0 gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)} 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: async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi") txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar" return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC: while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu() g = await self._query_gpu()
smi = await self._nvidia_smi_text() smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", { await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name, "instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g, "nvidiaSmi": smi, "cards": cards, **g,
}) })
await asyncio.sleep(1) await asyncio.sleep(1)
except asyncio.CancelledError: except asyncio.CancelledError:
+743 -19
View File
@@ -28,7 +28,11 @@ import json
import logging import logging
import os import os
import re import re
import hashlib
import queue
import shlex import shlex
import shutil
import threading
import time import time
import httpx import httpx
@@ -226,6 +230,31 @@ def _base_config() -> dict:
return {} return {}
def _cpu_moe_flag(v) -> str:
"""MoE-Modelle: Experten in den Arbeitsspeicher. -1 = alle (--cpu-moe),
N > 0 = die der ersten N Schichten (--n-cpu-moe N), 0/leer = alles auf die Karte.
Der Rest (Attention, gemeinsame Teile, KV-Cache) bleibt auf der Karte — pro
Wort rechnet ein MoE nur wenige Experten, darum bleibt es brauchbar schnell."""
try:
n = int(v or 0)
except (TypeError, ValueError):
return ""
if n < 0:
return " --cpu-moe"
return f" --n-cpu-moe {n}" if n > 0 else ""
def _model_cpu_moe(key: str) -> int:
for e in _load_registry():
if (e.get("key") or "").strip() == key:
try:
return int(e.get("cpuMoe") or 0)
except (TypeError, ValueError):
return 0
m = re.search(r"--n-cpu-moe\s+(\d+)", _model_cmd(key) or "")
return int(m.group(1)) if m else (-1 if "--cpu-moe" in (_model_cmd(key) or "") else 0)
def _model_cmd(key: str) -> str: def _model_cmd(key: str) -> str:
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die """llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.).""" Diagnostic geladen) oder aus dem Basis-Template (qwen3-8b & Co.)."""
@@ -234,7 +263,8 @@ def _model_cmd(key: str) -> str:
quant = (e.get("quant") or "Q4_K_M").strip() quant = (e.get("quant") or "Q4_K_M").strip()
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n" return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
f"-hf {e['hfRepo'].strip()}:{quant}\n" f"-hf {e['hfRepo'].strip()}:{quant}\n"
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja") f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja"
+ _cpu_moe_flag(e.get("cpuMoe")))
spec = (_base_config().get("models") or {}).get(key) or {} spec = (_base_config().get("models") or {}).get(key) or {}
return str(spec.get("cmd") or "") return str(spec.get("cmd") or "")
@@ -306,15 +336,49 @@ async def _reload_llama() -> None:
await asyncio.sleep(3) # Poll-Intervall + Puffer, bevor jemand fragt await asyncio.sleep(3) # Poll-Intervall + Puffer, bevor jemand fragt
# Antworten, die nicht verloren gehen duerfen: Bricht die RVS-Verbindung ab,
# waehrend eine Anfrage noch rechnet (z.B. RVS-/Proxy-Neustart), wuerde die
# fertige Antwort sonst ueber die tote Verbindung geschickt und verworfen — der
# Fragende (Schmiede, ARIA) wartet dann bis zu seiner Frist ins Leere (am
# 28.10. 11 Minuten). Jetzt: ueber die NEUE Verbindung senden, notfalls bis zum
# Wiederverbinden aufheben.
_RELIABLE_TYPES = {"llm_response"}
_OUTBOX_TTL_S = 300
_outbox: list = [] # [(ts, mtype, payload)]
_current_ws = None # die aktuell offene RVS-Verbindung
async def _send(ws, mtype: str, payload: dict) -> None: async def _send(ws, mtype: str, payload: dict) -> None:
try: data = json.dumps({"type": mtype, "payload": payload, "timestamp": int(time.time() * 1000)})
await ws.send(json.dumps({ targets = [ws] if ws is _current_ws or _current_ws is None else [ws, _current_ws]
"type": mtype, last_err = None
"payload": payload, for t in targets:
"timestamp": int(time.time() * 1000), try:
})) await t.send(data)
except Exception as e: return
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e) except Exception as e:
last_err = e
if mtype in _RELIABLE_TYPES:
_outbox.append((time.time(), mtype, payload))
logger.warning("Send fehlgeschlagen (%s): %s — aufgehoben, geht nach dem Wiederverbinden raus",
mtype, last_err)
else:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, last_err)
async def _flush_outbox(ws) -> None:
"""Nach dem (Wieder-)Verbinden: aufgehobene Antworten nachschicken."""
now = time.time()
keep = [(t, m, p) for (t, m, p) in _outbox if now - t <= _OUTBOX_TTL_S]
_outbox.clear()
for t, m, p in keep:
try:
await ws.send(json.dumps({"type": m, "payload": p, "timestamp": int(time.time() * 1000)}))
logger.info("Aufgehobene Antwort nachgeschickt (%s, %.0f s alt)", m, now - t)
except Exception as e:
_outbox.append((t, m, p))
logger.warning("Nachschicken fehlgeschlagen (%s): %s", m, e)
return
def _sanitize_messages(messages: list) -> list: def _sanitize_messages(messages: list) -> list:
@@ -517,6 +581,237 @@ def _model_ctx(key: str) -> int:
return int(m.group(1)) if m else 0 return int(m.group(1)) if m else 0
# ── Kontextfenster: was das Modell kann + was es an Grafikspeicher kostet ──
#
# Aus dem Kopf der GGUF-Datei (nur Metadaten, die Gewichte werden nicht
# gelesen): trainiertes Maximum (context_length) und die Groesse des KV-Caches
# pro Token. Modelle mit Sliding-Window-Attention (Gemma) halten in den
# "lokalen" Schichten nur ein festes Fenster — nur die globalen Schichten
# wachsen mit dem Kontext. Annahme: KV-Cache in f16 (llama.cpp-Standard).
_GGUF_T = {0: "B", 1: "b", 2: "H", 3: "h", 4: "I", 5: "i", 6: "f", 7: "?", 10: "Q", 11: "q", 12: "d"}
_gguf_cache: dict = {} # path → (mtime, meta)
CTX_STEPS = [4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576]
# Fester Aufschlag (CUDA-Kontext + Rechenpuffer von llama.cpp) — gemessen auf
# einer RTX 3060 mit Gemma 4 12B bei 32k: ~1,4 GB ueber Modell + KV-Cache.
VRAM_OVERHEAD_MB = 1400
UBATCH = 512
def _gguf_meta(path: str, fsize: int = 0) -> dict:
"""Metadaten-Block einer GGUF-Datei (gecacht). Lange String-Listen (der
Tokenizer) werden uebersprungen, nicht gespeichert. fsize: wahre Groesse,
wenn path nur der Anfang der Datei ist (Kopf per HTTP-Range geholt)."""
try:
mt = os.path.getmtime(path)
except OSError:
return {}
hit = _gguf_cache.get(path)
if hit and hit[0] == mt and not fsize:
return hit[1]
import struct
meta: dict = {}
def rd(f, fmt):
return struct.unpack("<" + fmt, f.read(struct.calcsize("<" + fmt)))[0]
def rstr(f):
return f.read(rd(f, "Q")).decode("utf-8", "replace")
def rval(f, t):
if t == 8:
return rstr(f)
if t == 9:
et, n = rd(f, "I"), rd(f, "Q")
if et == 8:
for _ in range(n):
f.seek(rd(f, "Q"), 1)
return None
if et == 9:
raise ValueError("verschachtelte Arrays")
fmt = _GGUF_T[et]
if n > 4096:
f.seek(struct.calcsize("<" + fmt) * n, 1)
return None
return [rd(f, fmt) for _ in range(n)]
return rd(f, _GGUF_T[t])
try:
with open(path, "rb") as f:
if f.read(4) != b"GGUF":
return {}
rd(f, "I")
n_tensors = rd(f, "Q")
for _ in range(rd(f, "Q")):
k = rstr(f)
v = rval(f, rd(f, "I"))
if v is not None:
meta[k] = v
# Tensor-Verzeichnis: Groesse der MoE-Experten (…_exps) je Schicht —
# fuer die Schaetzung "was bleibt auf der Karte, was geht in den RAM"
infos = []
for _ in range(n_tensors):
name = rstr(f)
nd = rd(f, "I")
for _ in range(nd):
rd(f, "Q")
rd(f, "I")
infos.append((rd(f, "Q"), name))
align = int(meta.get("general.alignment") or 32)
data_start = (f.tell() + align - 1) // align * align
fsize = fsize or os.path.getsize(path)
infos.sort()
exp_by_layer: dict = {}
for i, (off, name) in enumerate(infos):
nxt = infos[i + 1][0] if i + 1 < len(infos) else fsize - data_start
if "_exps" in name:
mm = re.match(r"blk\.(\d+)\.", name)
lay = int(mm.group(1)) if mm else -1
exp_by_layer[lay] = exp_by_layer.get(lay, 0) + max(0, nxt - off)
meta["_expert_bytes_by_layer"] = exp_by_layer
except Exception as e:
logger.debug("GGUF %s nicht lesbar: %s", path, e)
meta = {}
_gguf_cache[path] = (mt, meta)
return meta
def _kv_bytes(meta: dict, ctx: int) -> int:
"""KV-Cache (f16) fuer ctx Tokens laut GGUF-Metadaten — Sliding-Window-
Schichten (Gemma) halten nur ihr Fenster."""
arch = meta.get("general.architecture")
if not arch:
return 0
g = lambda k, d=None: meta.get(f"{arch}.{k}", d)
n_layer = int(g("block_count", 0) or 0)
if not n_layer:
return 0
def per_layer(v, d=0):
return list(v) if isinstance(v, list) else [v if v is not None else d] * n_layer
kv_heads = per_layer(g("attention.head_count_kv", g("attention.head_count", 0)))
k_len = int(g("attention.key_length", 0) or 0)
v_len = int(g("attention.value_length", 0) or 0)
if not (k_len and v_len):
emb, heads = int(g("embedding_length", 0) or 0), int(g("attention.head_count", 0) or 0)
k_len = v_len = emb // heads if heads else 0
k_swa = int(g("attention.key_length_swa", k_len) or k_len)
v_swa = int(g("attention.value_length_swa", v_len) or v_len)
window = int(g("attention.sliding_window", 0) or 0)
pattern = g("attention.sliding_window_pattern")
swa = per_layer(pattern, False) if isinstance(pattern, list) and window else [False] * n_layer
per_tok = sum(int(kv_heads[i]) * (k_len + v_len) * 2 for i in range(n_layer) if not swa[i])
swa_tok = sum(int(kv_heads[i]) * (k_swa + v_swa) * 2 for i in range(n_layer) if swa[i])
return per_tok * ctx + swa_tok * min(ctx, window + UBATCH)
async def _auto_cpu_moe(repo: str, file: dict, vram_mb: int, ctx: int) -> int:
"""Wie viele Schichten mit Experten muessen in den RAM, damit der Rest auf
die Karte passt? Holt nur den Kopf der GGUF (Metadaten + Tensor-Verzeichnis,
wenige MB per HTTP-Range) — die Datei selbst laedt erst llama.cpp.
0 = passt komplett, -1 = alle Experten in den RAM (oder unbekannt)."""
import tempfile
url = f"https://huggingface.co/{repo}/resolve/main/{file['path']}"
headers = {"Range": "bytes=0-41943039"}
if HF_TOKEN:
headers["Authorization"] = f"Bearer {HF_TOKEN}"
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as c:
r = await c.get(url, headers=headers)
r.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".gguf", delete=False) as tf:
tf.write(r.content)
tmp = tf.name
try:
meta = _gguf_meta(tmp, fsize=int(file.get("size") or 0))
finally:
_gguf_cache.pop(tmp, None)
os.unlink(tmp)
except Exception as e:
logger.warning("MoE-Aufteilung fuer %s nicht berechenbar (%s) — alle Experten in den RAM", repo, e)
return -1
exp = meta.get("_expert_bytes_by_layer") or {}
if not exp:
return 0 # kein MoE
total_mb = int(file.get("size") or 0) / 1048576
exp_mb = sum(exp.values()) / 1048576
budget = vram_mb * 0.92 - VRAM_OVERHEAD_MB - _kv_bytes(meta, ctx) / 1048576 - (total_mb - exp_mb)
per_layer = exp_mb / len(exp)
on_gpu = max(0, int(budget // per_layer)) if per_layer else 0
n = max(0, len(exp) - on_gpu)
logger.info("MoE %s: %d von %d Schichten mit Experten in den RAM (Karte %d MB, ctx %d)",
repo, n, len(exp), vram_mb, ctx)
return -1 if n >= len(exp) else n
def _model_files(key: str) -> tuple:
"""(Gewichte.gguf, mmproj.gguf|None) eines Modells im HF-Cache unter /models
— aus "-hf org/repo:QUANT" bzw. "-m pfad" im llama-server-Aufruf."""
import glob
cmd = _model_cmd(key) or ""
m = re.search(r"(?:^|\s)-m\s+(\S+)", cmd)
if m and os.path.isfile(m.group(1)):
return m.group(1), None
m = re.search(r"(?:^|\s)-hf\s+([^\s:]+)(?::(\S+))?", cmd)
if not m:
return None, None
repo, quant = m.group(1), (m.group(2) or "").lower()
base = os.path.join(MODELS_DIR, "models--" + repo.replace("/", "--"), "snapshots")
files = [p for p in glob.glob(os.path.join(base, "*", "*.gguf")) if os.path.isfile(p)]
weights = [p for p in files if "mmproj" not in os.path.basename(p).lower()]
if quant:
weights = [p for p in weights if quant in os.path.basename(p).lower()] or weights
mmproj = [p for p in files if "mmproj" in os.path.basename(p).lower()]
return (weights[0] if weights else None), (mmproj[0] if mmproj else None)
def _ctx_profile(key: str) -> dict:
"""{ctxMax, options: [{ctx, estMB}]} fuer die Kontext-Auswahl der Diagnostic.
Leer, wenn die Modelldatei (noch) nicht da oder nicht lesbar ist."""
weights, mmproj = _model_files(key)
if not weights:
return {}
return _profile_of_files(weights, mmproj, lambda: _model_cpu_moe(key))
def _profile_of_files(weights: str, mmproj, cpu_moe_of=lambda: 0) -> dict:
"""Wie _ctx_profile, aber fuer beliebige Dateien — auch heruntergeladene
Modelle, die (noch) auf keiner Karte liegen. cpu_moe_of: Aufteilung der
Experten (0 = Schaetzung "alles auf der Karte")."""
meta = _gguf_meta(weights)
arch = meta.get("general.architecture")
if not arch:
return {}
g = lambda k, d=None: meta.get(f"{arch}.{k}", d)
n_layer = int(g("block_count", 0) or 0)
if not n_layer:
return {}
files_mb = (os.path.getsize(weights) + (os.path.getsize(mmproj) if mmproj else 0)) / 1048576
ctx_max = int(g("context_length", 0) or 0)
exp_by_layer = meta.get("_expert_bytes_by_layer") or {}
moe = bool(exp_by_layer) or int(g("expert_count", 0) or 0) > 0
cpu_moe = cpu_moe_of() if moe else 0
if cpu_moe:
layers = sorted(exp_by_layer) if cpu_moe < 0 else sorted(exp_by_layer)[:cpu_moe]
ram_mb = sum(exp_by_layer[l] for l in layers) / 1048576
else:
ram_mb = 0.0
gpu_files_mb = files_mb - ram_mb
steps = [c for c in CTX_STEPS if not ctx_max or c <= ctx_max]
if ctx_max and ctx_max not in steps:
steps.append(ctx_max) # krummes Maximum (z.B. 40960) mit anbieten
opts = []
for c in steps:
kv_mb = _kv_bytes(meta, c) / 1048576
opts.append({"ctx": c, "estMB": int(gpu_files_mb + kv_mb + VRAM_OVERHEAD_MB)})
out = {"ctxMax": ctx_max, "options": opts, "moe": moe, "cpuMoe": cpu_moe}
if moe:
out["expertsMB"] = int(sum(exp_by_layer.values()) / 1048576)
out["ramMB"] = int(ram_mb)
return out
async def _vram_used() -> dict: async def _vram_used() -> dict:
"""{index: belegte MB} — frisch bei jedem Aufruf (aendert sich laufend).""" """{index: belegte MB} — frisch bei jedem Aufruf (aendert sich laufend)."""
try: try:
@@ -555,7 +850,8 @@ async def _slots_info() -> list:
on_cpu = bool(m and state == "ready" and u is not None and u < ON_CPU_BELOW_MB) on_cpu = bool(m and state == "ready" and u is not None and u < ON_CPU_BELOW_MB)
out.append({"gpu": card, "gpuName": g.get("name", ""), "vramMB": g.get("vramMB", 0), out.append({"gpu": card, "gpuName": g.get("name", ""), "vramMB": g.get("vramMB", 0),
"vramUsedMB": u if u is not None else -1, "onCpu": on_cpu, "vramUsedMB": u if u is not None else -1, "onCpu": on_cpu,
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0}) "model": m, "state": state, "ctx": _model_ctx(m) if m else 0,
**(_ctx_profile(m) if m else {})})
return out return out
@@ -593,6 +889,7 @@ async def _announce(ws) -> None:
"models": models, "models": models,
"slots": info, # pro LLM-Karte: Modell + Zustand (Diagnostic) "slots": info, # pro LLM-Karte: Modell + Zustand (Diagnostic)
"knownModels": _known_models(), # was in eine Karte geladen werden KOENNTE "knownModels": _known_models(), # was in eine Karte geladen werden KOENNTE
"disk": await asyncio.to_thread(_disk_info), # frei + heruntergeladene Modelle
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird # OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env # NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt). # ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
@@ -841,9 +1138,12 @@ def _scan_download(oids: set) -> tuple:
inprog = fn.endswith(_INPROGRESS) inprog = fn.endswith(_INPROGRESS)
base = fn[:-len(_INPROGRESS)] if inprog else fn base = fn[:-len(_INPROGRESS)] if inprog else fn
try: try:
size = os.path.getsize(os.path.join(root, fn)) st = os.stat(os.path.join(root, fn))
except OSError: except OSError:
continue continue
# Der eigene Parallel-Download legt die Datei in voller Groesse an
# (sparse) — dann zaehlt nur, was wirklich geschrieben ist.
size = min(st.st_size, st.st_blocks * 512) if inprog else st.st_size
if base in oids: if base in oids:
done += size done += size
found += 1 found += 1
@@ -856,6 +1156,264 @@ def _scan_download(oids: set) -> tuple:
return done, active, found return done, active, found
# Freier Platz vor dem Download: llama.cpp bricht bei voller Platte mitten
# im Download ab, und der Grund kommt ueber llama-swap nicht an. Vorher
# rechnen: was fehlt noch (fertige Blobs zaehlen nicht) gegen den freien
# Platz minus Puffer. Halbe Downloads, an denen seit STALE_PARTIAL_S niemand
# mehr schreibt, sind Reste abgebrochener Versuche — die duerfen weg.
DISK_RESERVE_BYTES = int(float(os.getenv("LLM_DISK_RESERVE_GB", "2")) * 1024**3)
STALE_PARTIAL_S = 30 * 60
def _blob_state(oids: set) -> tuple:
"""({oid: Bytes fertig}, [(pfad, bytes, mtime)] aller halben Downloads)."""
done, partial = {}, []
for root, _dirs, fns in os.walk(MODELS_DIR):
for fn in fns:
path = os.path.join(root, fn)
try:
st = os.stat(path)
except OSError:
continue
if fn.endswith(_INPROGRESS):
partial.append((path, st.st_size, st.st_mtime))
elif fn in oids:
done[fn] = st.st_size
return done, partial
def _check_disk(files: list) -> tuple:
"""(ok, Meldung). Ohne HF-Dateiliste (files leer) wird nicht geprueft."""
if not files:
return True, ""
done, partial = _blob_state({f["oid"] for f in files if f.get("oid")})
need = sum(f["size"] for f in files if f.get("oid") not in done)
if not need:
return True, ""
free = shutil.disk_usage(MODELS_DIR).free
note = ""
if need + DISK_RESERVE_BYTES > free:
stale = [(p, b) for p, b, mt in partial if time.time() - mt > STALE_PARTIAL_S]
for path, _b in stale:
try:
os.remove(path)
except OSError:
pass
if stale:
freed = sum(b for _p, b in stale)
note = f"Reste abgebrochener Downloads geloescht ({len(stale)} Datei(en), {freed / 1024**3:.1f} GB)"
logger.info("Speicherplatz: %s", note)
free = shutil.disk_usage(MODELS_DIR).free
if need + DISK_RESERVE_BYTES > free:
gb = lambda n: f"{n / 1024**3:.1f}".replace(".", ",")
return False, (f"Zu wenig Speicherplatz auf {NODE_NAME}: das Modell braucht ca. {gb(need)} GB, "
f"frei sind {gb(free)} GB (dazu {gb(DISK_RESERVE_BYTES)} GB Puffer). "
f"Nicht mehr gebrauchte Modelle im Katalog entfernen oder Platz auf der Box schaffen."
+ (f" ({note})" if note else ""))
return True, note
# Paralleler Download: llama.cpp zieht eine Datei ueber EINE Verbindung, das
# bleibt oft bei 10-50 MB/s haengen, auch bei 1-Gbit-Leitung. Wir holen die
# Datei vorher selbst in Stuecken ueber mehrere Verbindungen — direkt ins
# HF-Cache-Layout, das llama.cpp selbst anlegt (blobs/<sha256>, snapshots/
# <commit>/<datei> → Symlink, refs/main). Danach findet llama.cpp sie als
# "liegt schon da". Klappt es nicht, laedt llama.cpp wie bisher selbst.
DOWNLOAD_CONNECTIONS = int(os.getenv("LLM_DOWNLOAD_CONNECTIONS", "8"))
DOWNLOAD_PIECE = 64 * 1024**2
_provisions: dict = {} # key → {"cancel": threading.Event, "task", "phase"}
def _repo_dir(repo: str) -> str:
return os.path.join(MODELS_DIR, "models--" + repo.replace("/", "--"))
async def _hf_commit(repo: str) -> str:
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(f"https://huggingface.co/api/models/{repo}", headers=headers)
r.raise_for_status()
return str(r.json().get("sha") or "")
except Exception as e:
logger.warning("HF-Commit fuer %s nicht abrufbar (%s)", repo, e)
return ""
def _link_snapshot(repo: str, commit: str, f: dict) -> None:
rdir = _repo_dir(repo)
link = os.path.join(rdir, "snapshots", commit, f["path"])
os.makedirs(os.path.dirname(link), exist_ok=True)
target = os.path.relpath(os.path.join(rdir, "blobs", f["oid"]), os.path.dirname(link))
if os.path.islink(link) or os.path.exists(link):
os.remove(link)
os.symlink(target, link)
os.makedirs(os.path.join(rdir, "refs"), exist_ok=True)
with open(os.path.join(rdir, "refs", "main"), "w") as fh:
fh.write(commit)
def _download_parallel(repo: str, commit: str, f: dict, cancel: threading.Event) -> str:
"""Eine Datei in Stuecken ueber mehrere Verbindungen. "" = fertig, sonst Grund."""
blobs = os.path.join(_repo_dir(repo), "blobs")
os.makedirs(blobs, exist_ok=True)
final = os.path.join(blobs, f["oid"])
part = final + _INPROGRESS
if os.path.exists(final):
return ""
if os.path.exists(part) and time.time() - os.path.getmtime(part) < 120:
return "es laeuft schon ein Download dieser Datei"
size = f["size"]
url = f"https://huggingface.co/{repo}/resolve/{commit}/{f['path']}"
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
with open(part, "wb") as fh:
fh.truncate(size) # sparse: belegt erst beim Schreiben
pieces: queue.Queue = queue.Queue()
for start in range(0, size, DOWNLOAD_PIECE):
pieces.put((start, min(size, start + DOWNLOAD_PIECE) - 1))
errors: list = []
def worker() -> None:
fd = os.open(part, os.O_WRONLY)
try:
with httpx.Client(timeout=httpx.Timeout(30, read=60), follow_redirects=True,
headers=headers) as c:
while not cancel.is_set() and not errors:
try:
start, end = pieces.get_nowait()
except queue.Empty:
return
pos = start
for attempt in range(5):
try:
with c.stream("GET", url, headers={"Range": f"bytes={pos}-{end}"}) as r:
if r.status_code != 206:
raise RuntimeError(f"HTTP {r.status_code} statt Teil-Download")
for chunk in r.iter_bytes(1024 * 1024):
if cancel.is_set():
return
os.pwrite(fd, chunk, pos)
pos += len(chunk)
if pos != end + 1:
raise RuntimeError("Stueck unvollstaendig")
break
except Exception as e:
if attempt == 4 or cancel.is_set():
errors.append(str(e)[:200])
return
time.sleep(2 * (attempt + 1)) # weiter ab pos
finally:
os.close(fd)
threads = [threading.Thread(target=worker, daemon=True) for _ in range(max(1, DOWNLOAD_CONNECTIONS))]
for t in threads:
t.start()
for t in threads:
t.join()
if cancel.is_set() or errors:
try:
os.remove(part)
except OSError:
pass
return "abgebrochen" if cancel.is_set() else errors[0]
h = hashlib.sha256()
with open(part, "rb") as fh:
while chunk := fh.read(8 * 1024 * 1024):
if cancel.is_set():
os.remove(part)
return "abgebrochen"
h.update(chunk)
if h.hexdigest() != f["oid"]:
os.remove(part)
return "Pruefsumme stimmt nicht — Datei verworfen"
os.replace(part, final)
return ""
async def _prefetch(ws, key: str, repo: str, files: list, card: str, cancel: threading.Event,
strict: bool = False) -> str:
"""Laedt fehlende Dateien parallel vor. "" = alles da (oder Fallback auf
llama.cpp), "abgebrochen" = Benutzer hat abgebrochen. strict (nur
herunterladen): kein Fallback — jeder Fehler kommt als Grund zurueck."""
files = [f for f in files if f.get("oid") and f.get("size")]
if not files:
return "keine Dateiliste von HuggingFace" if strict else ""
if DOWNLOAD_CONNECTIONS < 2 and not strict:
return ""
missing = [f for f in files if not os.path.exists(os.path.join(_repo_dir(repo), "blobs", f["oid"]))]
if not missing:
return ""
commit = await _hf_commit(repo)
if not commit:
return "HuggingFace nicht erreichbar" if strict else ""
stop = asyncio.Event()
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files), files=len(files), gpu=card,
note=f"Lade mit {DOWNLOAD_CONNECTIONS} Verbindungen")
watcher = asyncio.create_task(_watch_download(ws, key, files, stop, final_phase=False))
err = ""
try:
for f in missing:
err = await asyncio.to_thread(_download_parallel, repo, commit, f, cancel)
if err:
break
if not err:
for f in files:
await asyncio.to_thread(_link_snapshot, repo, commit, f)
finally:
stop.set()
try:
await watcher
except Exception:
pass
if err and err != "abgebrochen" and not strict:
logger.warning("Paralleler Download von %s fehlgeschlagen (%s) — llama.cpp laedt selbst", repo, err)
return ""
return err
def _disk_profiles(repo_dir: str) -> list:
"""Kontext-Profil (Maximum + GB je Stufe, Annahme: alles auf der Karte) je
heruntergeladener Quantisierung — damit die Diagnostic auch fuer Modelle,
die auf keiner Karte liegen, zeigen kann, was sie koennen und kosten."""
import glob
files = [p for p in glob.glob(os.path.join(repo_dir, "snapshots", "*", "**", "*.gguf"), recursive=True)
if os.path.isfile(p)]
mmproj = next((p for p in files if "mmproj" in os.path.basename(p).lower()), None)
out = []
for w in files:
if not _is_main_gguf(w) or _SPLIT_RE.search(w):
continue # gesplittete Modelle: Groesse waere nur die des ersten Teils
prof = _profile_of_files(w, mmproj)
if prof:
out.append({"quant": _quant_of(w), **{k: prof[k] for k in ("ctxMax", "options", "moe") if k in prof},
**({"expertsMB": prof["expertsMB"]} if "expertsMB" in prof else {})})
return out
def _disk_info() -> dict:
"""Freier Platz + fertig heruntergeladene Modelle (je Repo) fuer die Diagnostic."""
out = {"freeBytes": 0, "models": []}
try:
out["freeBytes"] = shutil.disk_usage(MODELS_DIR).free
for name in sorted(os.listdir(MODELS_DIR)):
if not name.startswith("models--"):
continue
blobs = os.path.join(MODELS_DIR, name, "blobs")
size = 0
for fn in (os.listdir(blobs) if os.path.isdir(blobs) else []):
if not fn.endswith(_INPROGRESS):
try:
size += os.path.getsize(os.path.join(blobs, fn))
except OSError:
pass
if size:
out["models"].append({"repo": name[len("models--"):].replace("--", "/", 1), "bytes": size,
"profiles": _disk_profiles(os.path.join(MODELS_DIR, name))})
except OSError as e:
logger.info("Plattenbelegung nicht lesbar: %s", e)
return out
async def _gpu_info(card: str) -> dict: async def _gpu_info(card: str) -> dict:
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen').""" """Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
if not card: if not card:
@@ -873,13 +1431,46 @@ async def _gpu_info(card: str) -> dict:
return {"gpu": card} return {"gpu": card}
# Tempo der Netzwerkkarte(n) der Box — der Container sieht nur sein eigenes
# eth0, darum liest er das /sys des Rechners (docker-compose: /sys:/hostsys:ro).
# Nur echte Karten (mit device-Link), die "up" sind. Fehlt der Mount: [].
HOST_SYS = os.getenv("HOST_SYS", "/hostsys")
_link_cache: dict = {"ts": 0.0, "links": []}
def _host_links() -> list:
if time.time() - _link_cache["ts"] < 60:
return _link_cache["links"]
links = []
base = os.path.join(HOST_SYS, "class", "net")
try:
for name in sorted(os.listdir(base)):
d = os.path.join(base, name)
try:
if not os.path.exists(os.path.join(d, "device")):
continue
with open(os.path.join(d, "operstate")) as fh:
if fh.read().strip() != "up":
continue
with open(os.path.join(d, "speed")) as fh:
mbit = int(fh.read().strip())
except (OSError, ValueError):
continue
if mbit > 0:
links.append({"iface": name, "mbit": mbit})
except OSError:
pass
_link_cache.update(ts=time.time(), links=links)
return links
async def _emit_progress(ws, key: str, phase: str, **extra) -> None: async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
await _send(ws, "llm_provision_progress", await _send(ws, "llm_provision_progress",
{"instanceId": INSTANCE_ID, "node": NODE_NAME, "key": key, {"instanceId": INSTANCE_ID, "node": NODE_NAME, "key": key,
"phase": phase, "ts": int(time.time() * 1000), **extra}) "phase": phase, "ts": int(time.time() * 1000), "links": _host_links(), **extra})
async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> None: async def _watch_download(ws, key: str, files: list, stop: asyncio.Event, final_phase: bool = True) -> None:
"""Meldet jede Sekunde den Download-Stand, bis der Warmup fertig ist. """Meldet jede Sekunde den Download-Stand, bis der Warmup fertig ist.
Ist alles da, wechselt die Phase auf 'load' (llama.cpp laedt in die Karte).""" Ist alles da, wechselt die Phase auf 'load' (llama.cpp laedt in die Karte)."""
oids = {f["oid"] for f in files if f.get("oid")} oids = {f["oid"] for f in files if f.get("oid")}
@@ -906,7 +1497,7 @@ async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> Non
# es lag schon im Cache. Im Notfallmodus (keine HF-Liste) zaehlt # es lag schon im Cache. Im Notfallmodus (keine HF-Liste) zaehlt
# nur: es lief ein Download, und jetzt nicht mehr. # nur: es lief ein Download, und jetzt nicht mehr.
all_there = bool(oids) and found >= len(oids) all_there = bool(oids) and found >= len(oids)
if phase != "load" and (all_there or downloaded): if final_phase and phase != "load" and (all_there or downloaded):
phase = "load" phase = "load"
await _emit_progress(ws, key, "load", bytes=total or b, total=total, await _emit_progress(ws, key, "load", bytes=total or b, total=total,
percent=100, cached=not downloaded) percent=100, cached=not downloaded)
@@ -922,12 +1513,56 @@ async def _provision_result(ws, key: str, ok: bool, **extra) -> None:
async def _handle_provision(ws, payload: dict) -> None: async def _handle_provision(ws, payload: dict) -> None:
key = (payload.get("key") or "").strip()
if key in _provisions:
return await _provision_result(ws, key, False, gpu=str(payload.get("gpu") or ""),
error="Fuer dieses Modell laeuft schon ein Auftrag.")
_provisions[key] = {"cancel": threading.Event(), "task": asyncio.current_task(), "phase": "prep"}
try:
await _do_provision(ws, payload, _provisions[key])
finally:
_provisions.pop(key, None)
async def _download_only(ws, key: str, repo: str, wanted: str, job: dict) -> None:
"""Nur auf die Platte der Box laden — keine Karte, keine Registry. Spaeter
"Laden" findet die Datei dann als "liegt schon da"."""
async def fail(msg: str) -> None:
await _emit_progress(ws, key, "error", error=msg, downloadOnly=True)
await _provision_result(ws, key, False, error=msg, downloadOnly=True)
if not repo:
return await fail("hfRepo fehlt")
files = await _hf_repo_files(repo)
if files is None:
return await fail(f"{repo}: nicht gefunden oder HuggingFace nicht erreichbar")
quant = _pick_quant(files, wanted) if files else ""
if not quant:
return await fail(f"In {repo} gibt es keine ladbare GGUF-Datei.")
qfiles = _files_for_quant(files, quant)
disk_ok, disk_note = await asyncio.to_thread(_check_disk, qfiles)
if not disk_ok:
return await fail(disk_note)
job["phase"] = "download"
err = await _prefetch(ws, key, repo, qfiles, "", job["cancel"], strict=True)
if err or job["cancel"].is_set():
return await fail("Abgebrochen" if (err == "abgebrochen" or job["cancel"].is_set()) else err)
await _announce(ws)
total = sum(f["size"] for f in qfiles)
await _emit_progress(ws, key, "ready", percent=100, bytes=total, total=total, downloadOnly=True, quant=quant,
note="; ".join(x for x in (disk_note, f"{quant} liegt auf der Box — in keine Karte geladen") if x))
await _provision_result(ws, key, True, downloadOnly=True)
logger.info("Nur heruntergeladen: %s (%s, %s)", key, repo, quant)
async def _do_provision(ws, payload: dict, job: dict) -> None:
"""Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt, """Laedt ein Modell in eine LLM-Karte (nur aus der Diagnostic). Ersetzt,
was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per
--watch-config + Preload) → Warmup, der wartet bis es bereit ist; daneben --watch-config + Preload) → Warmup, der wartet bis es bereit ist; daneben
misst _watch_download den Download.""" misst _watch_download den Download."""
key = (payload.get("key") or "").strip() key = (payload.get("key") or "").strip()
repo = (payload.get("hfRepo") or "").strip() repo = (payload.get("hfRepo") or "").strip()
if payload.get("downloadOnly"):
return await _download_only(ws, key, repo, (payload.get("quant") or "Q4_K_M").strip(), job)
cards = _llm_cards() cards = _llm_cards()
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip() card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
if not card and len(cards) == 1: if not card and len(cards) == 1:
@@ -973,10 +1608,38 @@ async def _handle_provision(ws, payload: dict) -> None:
if quant != wanted.upper(): if quant != wanted.upper():
quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}" quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
logger.info("Karte %s: %s", card, quant_note) logger.info("Karte %s: %s", card, quant_note)
# Vor jeder Aenderung an Registry/Belegung: passt es auf die Platte?
disk_ok, disk_note = await asyncio.to_thread(_check_disk, _files_for_quant(repo_files, quant))
if not disk_ok:
await _emit_progress(ws, key, "error", gpu=card, error=disk_note)
return await _provision_result(ws, key, False, gpu=card, error=disk_note)
if disk_note:
quant_note = "; ".join(x for x in (quant_note, disk_note) if x)
# Vor dem Umstellen der Karte: die alte bleibt nutzbar, solange geladen wird.
job["phase"] = "download"
pf = await _prefetch(ws, key, repo, _files_for_quant(repo_files, quant), card, job["cancel"])
if pf == "abgebrochen" or job["cancel"].is_set():
msg = "Abgebrochen — Karte unveraendert"
await _emit_progress(ws, key, "error", gpu=card, error=msg)
return await _provision_result(ws, key, False, gpu=card, error=msg)
ctx_req = int(payload.get("ctx") or 8192)
try:
cpu_moe = int(payload.get("cpuMoe") or 0)
except (TypeError, ValueError):
cpu_moe = 0
if cpu_moe == -2:
# "automatisch verteilen": so viele Experten auf die Karte wie passen
qfiles = _files_for_quant(repo_files, quant) if repo_files else []
vram = int(((await _gpu_names()).get(card) or {}).get("vramMB") or 0)
if len(qfiles) == 1 and vram:
cpu_moe = await _auto_cpu_moe(repo, qfiles[0], vram, ctx_req)
else:
cpu_moe = -1 # gesplittete Datei / Karte unbekannt: alle in den RAM
entry = { entry = {
"key": key, "hfRepo": repo, "quant": quant, "key": key, "hfRepo": repo, "quant": quant,
"ctx": int(payload.get("ctx") or 8192), "ctx": ctx_req,
"ngl": int(payload.get("ngl") or 99), "ngl": int(payload.get("ngl") or 99),
"cpuMoe": cpu_moe,
} }
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry]) _save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
elif not _model_cmd(key): elif not _model_cmd(key):
@@ -999,9 +1662,16 @@ async def _handle_provision(ws, payload: dict) -> None:
t0 = time.time() t0 = time.time()
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen # Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
# LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud. # LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud.
res = await _call_llama([{"role": "user", "content": "hi"}], job["phase"] = "llama"
max_tokens=1, temperature=0.0, stop=None, model=key, try:
timeout=WARMUP_TIMEOUT_S) if job["cancel"].is_set():
raise asyncio.CancelledError
res = await _call_llama([{"role": "user", "content": "hi"}],
max_tokens=1, temperature=0.0, stop=None, model=key,
timeout=WARMUP_TIMEOUT_S)
except asyncio.CancelledError:
# Abbrechen-Knopf: wie ein Fehlschlag behandeln → Karte zuruecksetzen
res = {"ok": False, "error": "Abgebrochen"}
dt = time.time() - t0 dt = time.time() - t0
stop.set() stop.set()
try: try:
@@ -1042,6 +1712,13 @@ async def _handle_provision(ws, payload: dict) -> None:
_save_registry(reg) _save_registry(reg)
_generate_config() _generate_config()
await _reload_llama() await _reload_llama()
if job["cancel"].is_set():
# llama.cpp ist gestoppt — seinen halben Download nicht liegen lassen
for f in files:
try:
os.remove(os.path.join(_repo_dir(repo), "blobs", f["oid"] + _INPROGRESS))
except OSError:
pass
await _announce(ws) await _announce(ws)
note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer" note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer"
await _emit_llm_status(ws, "error", key, error=err) await _emit_llm_status(ws, "error", key, error=err)
@@ -1068,10 +1745,24 @@ async def _handle_unload(ws, payload: dict) -> None:
async def _handle_remove(ws, payload: dict) -> None: async def _handle_remove(ws, payload: dict) -> None:
"""Modell aus der Registry entfernen (und von seiner Karte, falls belegt). """Modell aus der Registry entfernen (und von seiner Karte, falls belegt).
Das GGUF bleibt im Cache.""" Das GGUF bleibt im Cache — ausser deleteFiles. cancel = laufenden
Download/Ladevorgang abbrechen (gleicher Nachrichtentyp, damit die
RVS-Allow-List nicht erweitert werden muss)."""
key = (payload.get("key") or "").strip() key = (payload.get("key") or "").strip()
if not key: if not key:
return return
if payload.get("cancel"):
job = _provisions.get(key)
if not job:
# Nichts laeuft (mehr) — haengenden Eintrag im Download-Fenster schliessen
return await _provision_result(ws, key, False, error="Abgebrochen (lief nicht mehr)")
job["cancel"].set()
if job["phase"] == "llama" and job.get("task"):
job["task"].cancel()
logger.info("Abbruch angefordert: %s (Phase %s)", key, job["phase"])
return
if payload.get("deleteFiles"):
return await _delete_model_files(ws, key, (payload.get("hfRepo") or "").strip())
_save_registry([e for e in _load_registry() if e.get("key") != key]) _save_registry([e for e in _load_registry() if e.get("key") != key])
slots = _load_slots() slots = _load_slots()
for c in [c for c, m in slots.items() if m == key]: for c in [c for c, m in slots.items() if m == key]:
@@ -1084,6 +1775,35 @@ async def _handle_remove(ws, payload: dict) -> None:
logger.info("removed model %s", key) logger.info("removed model %s", key)
async def _delete_model_files(ws, key: str, repo: str) -> None:
"""Heruntergeladene Dateien eines Repos von der Platte loeschen. Nicht,
solange es auf einer Karte liegt oder gerade geladen wird."""
reg = _load_registry()
repo = repo or next((e.get("hfRepo") for e in reg if e.get("key") == key), "") or ""
if not re.fullmatch(r"\w[\w.-]*/\w[\w.-]*", repo):
return await _provision_result(ws, key, False, error=f"Unbekanntes Repo '{repo}' — nichts geloescht")
repo_of = {e.get("key"): e.get("hfRepo") for e in reg}
busy = [c for c, m in _load_slots().items() if m and repo_of.get(m) == repo]
if busy:
return await _provision_result(ws, key, False, error=(
f"Liegt noch auf Karte {', '.join(busy)} — erst die Karte leeren oder ein anderes Modell laden."))
if any(repo_of.get(k) == repo for k in _provisions):
return await _provision_result(ws, key, False, error="Wird gerade geladen — erst abbrechen.")
path = _repo_dir(repo)
freed = sum(m["bytes"] for m in _disk_info()["models"] if m["repo"] == repo)
try:
await asyncio.to_thread(shutil.rmtree, path)
except FileNotFoundError:
pass
except OSError as e:
return await _provision_result(ws, key, False, error=f"Loeschen fehlgeschlagen: {e}")
_save_registry([e for e in reg if e.get("hfRepo") != repo])
_generate_config()
await _announce(ws)
await _provision_result(ws, key, True, removed=True, deleted=True, freedBytes=freed)
logger.info("Dateien von %s geloescht (%.1f GB)", repo, freed / 1024**3)
async def _handle_models_request(ws, payload: dict) -> None: async def _handle_models_request(ws, payload: dict) -> None:
"""Erkundung fuer Clients: welche Modelle kann ich bei DIESER Box fragen? """Erkundung fuer Clients: welche Modelle kann ich bei DIESER Box fragen?
Jede Box antwortet einzeln (RVS ist ein Broadcast-Raum) — der Client Jede Box antwortet einzeln (RVS ist ein Broadcast-Raum) — der Client
@@ -1099,6 +1819,7 @@ async def _handle_models_request(ws, payload: dict) -> None:
async def _run() -> None: async def _run() -> None:
global _current_ws
if not RVS_HOST: if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch") logger.error("RVS_HOST nicht gesetzt — Abbruch")
return return
@@ -1136,6 +1857,9 @@ async def _run() -> None:
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL) f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
async with websockets.connect(url, **connect_kwargs) as ws: async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden — llm-adapter online") logger.info("RVS verbunden — llm-adapter online")
_current_ws = ws
if _outbox:
await _flush_outbox(ws)
retry_s = 2 retry_s = 2
tls_fallback_tried = False tls_fallback_tried = False
ping_task = asyncio.create_task(_worker_register(ws)) ping_task = asyncio.create_task(_worker_register(ws))
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0 gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)} 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: async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi") txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar" return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC: while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu() g = await self._query_gpu()
smi = await self._nvidia_smi_text() smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", { await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name, "instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g, "nvidiaSmi": smi, "cards": cards, **g,
}) })
await asyncio.sleep(1) await asyncio.sleep(1)
except asyncio.CancelledError: except asyncio.CancelledError:
+9
View File
@@ -228,6 +228,15 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) ->
return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error")) return await reply(writer, 503, oai_error(_unreachable(e), "cluster_unreachable", "server_error"))
if not res.get("ok"): if not res.get("ok"):
code = res.get("errorCode") or "model_error" 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) status = {"bad_request": 400, "model_unavailable": 404, "timeout": 504}.get(code, 502)
return await reply(writer, status, oai_error( return await reply(writer, status, oai_error(
res.get("error") or "Fehler", "model_not_found" if code == "model_unavailable" else code, res.get("error") or "Fehler", "model_not_found" if code == "model_unavailable" else code,
+8 -2
View File
@@ -327,6 +327,7 @@ class StreamSession:
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
noise_floor: float = 0.0 noise_floor: float = 0.0
closed: bool = False closed: bool = False
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
endpoint_sent: bool = False endpoint_sent: bool = False
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt # Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein # keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
@@ -396,10 +397,12 @@ class SessionManager:
sess.last_chunk_at = time.time() sess.last_chunk_at = time.time()
return True 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) sess = self._sessions.get(request_id)
if sess is not None: if sess is not None:
sess.closed = True sess.closed = True
if str(reason).startswith("cancel"):
sess.discard = True # Muelleimer in der App: verwerfen
def drop(self, request_id: str) -> None: def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None) self._sessions.pop(request_id, None)
@@ -620,6 +623,9 @@ class SessionManager:
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort # Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht. # ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1 _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: if sess.voiced_frames < _min_voiced:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint", logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason) sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
@@ -810,7 +816,7 @@ async def run_loop(sessions: SessionManager) -> None:
elif mtype == "stt_audio_chunk": elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload) sessions.feed_chunk(payload)
elif mtype == "stt_stream_end": elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", "")) sessions.end_session(payload.get("requestId", ""), payload.get("reason", ""))
elif mtype == "stt_transcribe_blob": elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live- # One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt # 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 gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)} 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: async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi") txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar" return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC: while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu() g = await self._query_gpu()
smi = await self._nvidia_smi_text() smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", { await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name, "instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g, "nvidiaSmi": smi, "cards": cards, **g,
}) })
await asyncio.sleep(1) await asyncio.sleep(1)
except asyncio.CancelledError: except asyncio.CancelledError:
+11 -3
View File
@@ -390,6 +390,7 @@ class StreamSession:
voice_rms_min: float = STREAM_VOICE_RMS_MIN voice_rms_min: float = STREAM_VOICE_RMS_MIN
voice_rms_max: float = STREAM_VOICE_RMS_MAX voice_rms_max: float = STREAM_VOICE_RMS_MAX
closed: bool = False # nach stream_end gesetzt 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 endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten # Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit # ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
@@ -483,13 +484,16 @@ class SessionManager:
session.last_chunk_at = time.time() session.last_chunk_at = time.time()
return True 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 """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) session = self._sessions.get(request_id)
if session is None: if session is None:
return None return None
session.closed = True session.closed = True
if str(reason).startswith("cancel"):
session.discard = True
return session return session
def drop(self, request_id: str) -> None: def drop(self, request_id: str) -> None:
@@ -807,6 +811,10 @@ class SessionManager:
if sess.partial_task is not None and not sess.partial_task.done(): if sess.partial_task is not None and not sess.partial_task.done():
sess.partial_task.cancel() sess.partial_task.cancel()
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer)) 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: if audio.size == 0:
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8]) logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
final_text = "" final_text = ""
@@ -1080,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
req_id[:8], payload.get("reason", "")) req_id[:8], payload.get("reason", ""))
await _debug_log(ws, "stream.end", await _debug_log(ws, "stream.end",
f"received id={req_id[:12]} reason={payload.get('reason', '')}") 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": elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "") 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 gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)} 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: async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi") txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar" return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC: while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu() g = await self._query_gpu()
smi = await self._nvidia_smi_text() smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", { await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name, "instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g, "nvidiaSmi": smi, "cards": cards, **g,
}) })
await asyncio.sleep(1) await asyncio.sleep(1)
except asyncio.CancelledError: except asyncio.CancelledError: