Compare commits

..
34 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5.5 2a0c8b8192 fix(satellite): Thread-Leck im mDNS-Scan — ServiceBrowser werden beendet
Jeder ServiceBrowser ist ein eigener Thread; zc.close() beendet ihn nicht
(zeroconf 0.151.5, lokal nachgestellt: +1 Thread pro Browser und Scan,
mit cancel() keiner). Bei 14 Diensttypen und einem Scan alle 5 Minuten
hatte der Satellit HomeOffice nach ~5 Tagen 18.841 Threads = Thread-Limit
des Containers. Seit 30.10. 12:19 scheiterte jeder Scan mit "[Errno 11]
Resource temporarily unavailable", auf sat_discover kam keine Antwort mehr
(auch die RVS-Verbindung brach zeitweise mit Errno 11 ab).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:59:16 +01:00
29 changed files with 2625 additions and 289 deletions
+4 -3
View File
@@ -672,7 +672,8 @@ cd android
### Release auf Gitea veroeffentlichen
```bash
./release.sh 1.2.0
./release.sh # Version automatisch +1 (0.2.6.1 → 0.2.6.2, 0.2.6.9 → 0.2.7.0)
./release.sh 1.2.0 # oder Version selbst setzen
```
Das Script macht alles in einem Schritt:
@@ -706,7 +707,7 @@ die VM voll laeuft:
Die App prueft beim Start ob eine neuere Version auf dem RVS liegt.
Der Update-Flow:
1. `./release.sh 0.0.3.0` → APK wird auf RVS kopiert (via scp)
1. `./release.sh` (Version automatisch +1) → APK wird auf RVS kopiert (via scp)
2. Alternativ: `git pull` auf dem RVS-Server → APK in `rvs/updates/`
3. App sendet `update_check` mit aktueller Version
4. RVS vergleicht → sendet `update_available`
@@ -1238,7 +1239,7 @@ cd host-agent/android && ./build.sh # Docker (Android-SDK + Gradle)
# -> dist/aria-android-agent.apk
# Release mit allen Agent-Artefakten (Linux/Windows/Android) als Gitea-Assets:
cd host-agent && ./release_agent.sh 0.0.1.4
cd host-agent && ./release_agent.sh # Version automatisch +1
```
Der Signaturschluessel liegt fest in `host-agent/android/aria-agent.keystore`
+104 -10
View File
@@ -33,6 +33,7 @@ from proxy_client import ProxyClient, Message as ProxyMessage
import router as router_mod
import metrics
from local_llm import local_llm_chat
import local_compact
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
@@ -1015,6 +1016,27 @@ META_TOOLS = [
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
"type": {"type": "string", "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"],
"description": "Schublade aendern (optional), z.B. fact → tool fuer Infrastruktur. tool/rule/preference/skill/identity werden dabei angepinnt."},
},
"required": ["id"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_delete",
"description": (
"Loesche einen Gedaechtnis-Eintrag, der FALSCH oder UEBERHOLT ist "
"(ID aus memory_search oder Cold-Memory). Ist er nur unvollstaendig, "
"lieber memory_update. identity-Eintraege kann nur Stefan loeschen."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Memory-ID (UUID)"},
"reason": {"type": "string", "description": "Kurz warum (landet im Log)"},
},
"required": ["id"],
},
@@ -1026,8 +1048,10 @@ META_TOOLS = [
"name": "memory_save",
"description": (
"Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). "
"Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges "
"festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
"Nutze das VON DIR AUS, sobald du etwas Nicht-Offensichtliches lernst "
"(siehe 'DEIN GEDAECHTNIS' im Prompt) — nicht erst auf 'merk dir das'. "
"VORHER memory_search: gibt es schon einen Eintrag, nimm memory_update. "
"Keine live nachsehbaren, sich aendernden Staende speichern. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes "
"File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n"
"Type-Wahl:\n"
"- identity: ARIAs Selbstbild / Wesensart (PINNED)\n"
@@ -1071,7 +1095,7 @@ META_TOOLS = [
},
"category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"},
"pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."},
"pinned": {"type": "boolean", "description": "Weglassen = automatisch nach Schublade (identity/rule/preference/tool/skill angepinnt, fact nicht). Nur angeben, um davon abzuweichen."},
"attach_paths": {
"type": "array",
"items": {"type": "string"},
@@ -2359,7 +2383,8 @@ class Agent:
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
# Wie viele der letzten Turns ans lokale Modell gehen, regelt jetzt
# local_compact (nach Tokens + LOCAL_MAX_TURNS, aeltere als Notizzettel).
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Weiche Fehler-Marker im Tool-Ausgabetext. Viele Action-Skills geben einen
@@ -2370,6 +2395,9 @@ class Agent:
# mehrstufig weiterdenkt. Info-Tools (web_search/memory_search) sind bewusst
# AUSGENOMMEN: deren Inhalt darf das Wort "Fehler" tragen ohne dass der
# Tool-Call scheiterte. Eskalieren ist immer sicher (nur langsamer).
# Schubladen, die angepinnt sind (immer im System-Prompt)
_PINNED_TYPES = {"identity", "rule", "preference", "tool", "skill"}
_SOFT_FAIL_MARKERS = (
"fehler", "fehlgeschlagen", "nicht gefunden", "not found",
"konnte nicht", "keine verbindung", "nicht verfuegbar",
@@ -2476,9 +2504,21 @@ class Agent:
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
has_tools=bool(tools))
# Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
# wuerde das Prefill aufblaehen).
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
# Compacting (local_compact): aeltere Nachrichten als Notizzettel im
# Systemprompt, dazu so viele der letzten Nachrichten, wie ins
# Kontextfenster der Karte passen (hoechstens LOCAL_MAX_TURNS — Tempo).
notes = local_compact.summary_block(active_project_id)
if notes:
sys_prompt += "\n\n" + notes
all_turns = self.conversation.window(project_id=active_project_id)
ctx = local_compact.model_ctx(local_model)
budget = int(ctx * 0.75) - 500 - local_compact.est_tokens(sys_prompt)
window = local_compact.fit_window(all_turns, max(budget, 256))
try:
local_compact.maybe_compact(active_project_id, all_turns, window,
local_model, local_llm_chat)
except Exception as exc:
logger.debug("[compact] uebersprungen: %s", exc)
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
@@ -2604,6 +2644,13 @@ class Agent:
result = self.proxy.chat_full(messages, tools=tools, model=model,
project_id=project_id)
if not safeguard.looks_like_block(result.content, result.is_error):
if result.is_error:
# Die CLI meldet selbst einen Fehler (Anmeldung abgelaufen,
# Limit, …) — das ist KEINE Antwort von ARIA. Frueher landete
# "Failed to authenticate: OAuth session expired" als ihre
# Antwort im Verlauf. Jetzt: wie jede Ausnahme behandeln
# (klar markierter [Fehler: …]-Eintrag, Stefan sieht den Fehler).
raise RuntimeError(f"Claude meldet einen Fehler: {(result.content or '').strip()[:300]}")
return result, model
code = safeguard.detail_code(result.content)
@@ -4391,6 +4438,15 @@ class Agent:
existing.category = str(arguments["category"]).strip()
if "tags" in arguments and arguments["tags"] is not None:
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
if arguments.get("type"):
new_type = str(arguments["type"]).strip()
if new_type not in {"identity", "rule", "preference", "tool", "skill",
"fact", "conversation", "reminder"}:
return f"FEHLER: unbekannter type {new_type!r}."
if new_type != existing.type:
existing.type = new_type
if arguments.get("pinned") is None:
existing.pinned = new_type in self._PINNED_TYPES
if "pinned" in arguments and arguments["pinned"] is not None:
existing.pinned = bool(arguments["pinned"])
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
@@ -4418,6 +4474,29 @@ class Agent:
except Exception as e:
logger.exception("memory_update fehlgeschlagen")
return f"FEHLER: {e}"
if name == "memory_delete":
pid = (arguments.get("id") or "").strip()
reason = (arguments.get("reason") or "").strip()
if not pid:
return "FEHLER: id ist Pflicht."
existing = self.store.get(pid)
if not existing:
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
if existing.type == "identity":
return "FEHLER: identity-Eintraege loescht nur Stefan (Diagnostic)."
try:
self.store.delete(pid)
logger.info("[memory] geloescht: %r (%s) — Grund: %s", existing.title, pid[:8], reason or "-")
self._pending_events.append({
"type": "memory_saved", "action": "deleted",
"memory": {"id": pid, "type": existing.type, "title": existing.title,
"content_preview": (existing.content or "")[:140],
"category": existing.category, "pinned": existing.pinned},
})
return f"OK — Memory '{existing.title}' geloescht."
except Exception as e:
logger.exception("memory_delete fehlgeschlagen")
return f"FEHLER: {e}"
if name == "memory_save":
title = (arguments.get("title") or "").strip()
content = (arguments.get("content") or "").strip()
@@ -4431,7 +4510,13 @@ class Agent:
category = (arguments.get("category") or "").strip()
tags_in = arguments.get("tags") or []
tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else []
pinned = bool(arguments.get("pinned", False))
# Schubladen identity/rule/preference/tool/skill sind laut
# Beschreibung ANGEPINNT — frueher nur, wenn pinned=true explizit
# mitkam (Default false), Infrastruktur landete so im kalten Teil.
if arguments.get("pinned") is None:
pinned = mem_type in self._PINNED_TYPES
else:
pinned = bool(arguments.get("pinned"))
attach_paths_in = arguments.get("attach_paths") or []
attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else []
try:
@@ -4681,13 +4766,22 @@ class Agent:
# Konversation als Klartext bauen
transcript = "\n".join(
f"[{t.role.upper()}] {t.content}" for t in old_turns
f"[{'STEFAN' if t.role == 'user' else 'ARIA'}] {t.content}" for t in old_turns
)[:30000] # Cap auf 30k Zeichen damit der Prompt nicht explodiert
system = (
"Du extrahierst aus einer Konversation zwischen Stefan und ARIA die "
"wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, "
"keine flüchtigen Zustände. Antworte AUSSCHLIESSLICH mit gültigem JSON "
"keine flüchtigen Zustände.\n"
"QUELLE: Fakten kommen aus dem, was STEFAN sagt, entscheidet oder "
"bestätigt. ARIAs Nachrichten sind Berichte und können falsch sein — "
"übernimm daraus nur, was Stefan danach bestätigt hat.\n"
"NICHT aufnehmen: Systemzustände, die man live nachsehen kann und die "
"sich ändern (Projekt-/Ticket-Nummern im Fluss, Ticket-Status, Zeilen-"
"zahlen, Commit-IDs, wer gerade woran arbeitet, 'gerade nachgesehen'-"
"Befunde). Gut sind: Entscheidungen und ihr Grund, Stefans Vorlieben, "
"dauerhafte Regeln/Abläufe, wo etwas läuft (falls Stefan es bestätigt).\n"
"Antworte AUSSCHLIESSLICH mit gültigem JSON "
"im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", "
"\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. "
"Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array."
+186
View File
@@ -0,0 +1,186 @@
"""
Compacting fuer ARIAs lokalen Modus (Fast-Lane / localOnly).
Ein lokales Modell hat ein kleines Kontextfenster (z.B. 32k auf einer
12-GB-Karte). Frueher bekam es starr die letzten 8 Nachrichten — alles davor
war fuer das lokale Modell einfach weg. Jetzt:
* Fenster nach TOKENS statt fester Anzahl: so viele der letzten Nachrichten,
wie in ~75 % des Kontextfensters passen (Systemprompt + Antwortplatz
abgezogen), hoechstens LOCAL_MAX_TURNS (Tempo).
* Was aus dem Fenster faellt, fasst das lokale Modell im HINTERGRUND zu
einer laufenden Zusammenfassung zusammen ("Notizzettel"). Die liegt pro
Projekt in /data/local_summaries.json und geht bei jeder lokalen Anfrage
als "Bisheriger Verlauf" in den Systemprompt.
Unabhaengig davon laeuft weiter das Destillat (agent.distill_old_turns):
dauerhaft Wichtiges wandert als Fakt ins Gedaechtnis (Qdrant). Compacting =
Notizzettel fuer DIESES Gespraech, Destillat = Karteikarten fuers Archiv.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
import urllib.request
from pathlib import Path
logger = logging.getLogger(__name__)
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
SUMMARY_FILE = Path(os.environ.get("LOCAL_SUMMARY_FILE", "/data/local_summaries.json"))
LOCAL_MAX_TURNS = int(os.environ.get("LOCAL_MAX_TURNS", "12"))
DEFAULT_CTX = 8192
CHARS_PER_TOKEN = 2.8 # Deutsch/Code: eher vorsichtig schaetzen
COMPACT_MIN_TURNS = 4 # erst ab so vielen neu herausgefallenen Nachrichten
SUMMARY_MAX_TOKENS = 450
_lock = threading.Lock()
_running: set = set() # Projekte, fuer die gerade verdichtet wird
_ctx_cache: dict = {"ts": 0.0, "models": {}}
def est_tokens(text: str) -> int:
return int(len(text or "") / CHARS_PER_TOKEN) + 4
def model_ctx(model: str) -> int:
"""Kontextfenster des lokalen Modells laut Cluster (60 s gecacht)."""
now = time.time()
if now - _ctx_cache["ts"] > 60:
try:
with urllib.request.urlopen(f"{BRIDGE_URL}/internal/llm-models", timeout=3) as r:
data = json.loads(r.read().decode("utf-8", "ignore"))
_ctx_cache["models"] = {m.get("model"): int(m.get("ctx") or 0)
for m in data.get("models") or []}
_ctx_cache["ts"] = now
except Exception as exc:
logger.debug("[compact] Kontextfenster nicht abfragbar: %s", exc)
_ctx_cache["ts"] = now - 50 # in 10 s nochmal versuchen
return _ctx_cache["models"].get(model) or DEFAULT_CTX
def fit_window(turns: list, budget_tokens: int) -> list:
"""Die neuesten Nachrichten, die ins Budget passen (mind. die letzte)."""
out, used = [], 0
for t in reversed(turns[-LOCAL_MAX_TURNS:]):
need = est_tokens(t.content)
if out and used + need > budget_tokens:
break
out.append(t)
used += need
return list(reversed(out))
# ── Laufende Zusammenfassung pro Projekt ─────────────────────────────
def _load() -> dict:
try:
return json.loads(SUMMARY_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
def get_summary(project_id: str) -> dict:
"""{text, upto_ts} — upto_ts = Zeitstempel der juengsten verdichteten Nachricht."""
s = _load().get(project_id or "", {})
return {"text": s.get("text", ""), "upto_ts": s.get("upto_ts", "")}
def _save_summary(project_id: str, text: str, upto_ts: str) -> None:
with _lock:
data = _load()
data[project_id or ""] = {"text": text, "upto_ts": upto_ts,
"updated": time.strftime("%Y-%m-%dT%H:%M:%S")}
tmp = SUMMARY_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(SUMMARY_FILE)
def reset(project_id=None) -> None:
"""Zusammenfassung(en) verwerfen (z.B. beim Zuruecksetzen des Gespraechs)."""
with _lock:
if project_id is None:
try:
SUMMARY_FILE.unlink()
except FileNotFoundError:
pass
return
data = _load()
data.pop(project_id or "", None)
SUMMARY_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
def summary_block(project_id: str) -> str:
s = get_summary(project_id).get("text", "").strip()
if not s:
return ""
return ("## BISHERIGER VERLAUF (Zusammenfassung aelterer Nachrichten)\n"
"Das ist eine Zusammenfassung dessen, was vor den letzten Nachrichten "
"besprochen wurde. Nutze es als Hintergrund, zitiere es nicht.\n" + s)
def maybe_compact(project_id: str, all_turns: list, window: list, model: str,
chat_fn) -> None:
"""Startet im Hintergrund eine Verdichtung, wenn genug Nachrichten aus dem
Fenster gefallen sind, die noch nicht im Notizzettel stehen.
chat_fn = local_llm_chat (wird nur im Hintergrund-Thread aufgerufen)."""
if not window:
return
first_kept = window[0].ts
upto = get_summary(project_id)["upto_ts"]
dropped = [t for t in all_turns if t.ts < first_kept and t.ts > upto]
if len(dropped) < COMPACT_MIN_TURNS:
return
key = project_id or ""
with _lock:
if key in _running:
return
_running.add(key)
threading.Thread(target=_compact, args=(key, dropped, model, chat_fn),
daemon=True, name=f"compact-{key or 'main'}").start()
def _compact(project_id: str, dropped: list, model: str, chat_fn) -> None:
try:
prev = get_summary(project_id)["text"]
ctx = model_ctx(model)
# Was in die Verdichtungs-Anfrage passt: ~60 % des Fensters
room = int(ctx * 0.6 * CHARS_PER_TOKEN) - len(prev) - 2000
lines, used, last_ts = [], 0, ""
for t in dropped: # aelteste zuerst
who = "Stefan" if t.role == "user" else "ARIA"
line = f"{who}: {t.content.strip()}"
if used + len(line) > room:
line = line[: max(0, room - used)] + " …"
lines.append(line)
used += len(line)
last_ts = t.ts
if used >= room:
break # Rest beim naechsten Durchlauf
system = ("Du fuehrst einen laufenden Notizzettel ueber ein Gespraech zwischen "
"Stefan und seiner Assistentin ARIA. Schreibe auf Deutsch eine knappe "
"Zusammenfassung (hoechstens ~250 Woerter, Stichpunkte erlaubt): worum "
"es ging, was entschieden oder erledigt wurde, offene Punkte, wichtige "
"Namen/Zahlen. Kein Smalltalk, keine Einleitung, nur der Notizzettel.")
user = ((f"Bisheriger Notizzettel:\n{prev}\n\n" if prev else "")
+ "Diese Nachrichten kommen neu dazu:\n" + "\n".join(lines)
+ "\n\nSchreibe den aktualisierten Notizzettel.")
res = chat_fn([{"role": "system", "content": system}, {"role": "user", "content": user}],
max_tokens=SUMMARY_MAX_TOKENS, temperature=0.2, model=model, timeout=180)
text = (res.get("content") or "").strip() if res.get("ok") else ""
if not text:
logger.warning("[compact] Verdichtung fehlgeschlagen (%s) — Notizzettel bleibt",
res.get("error"))
return
_save_summary(project_id, text, last_ts)
logger.info("[compact] %d Nachrichten verdichtet (Projekt %r) → %d Zeichen Notizzettel",
len(dropped), project_id, len(text))
except Exception as exc:
logger.warning("[compact] Fehler: %s", exc)
finally:
with _lock:
_running.discard(project_id or "")
+4 -2
View File
@@ -30,7 +30,7 @@ LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC",
def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
model=None, timeout=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
model (B0.5): welches Modell llama-swap laden soll. tools (B1b): optionale
OpenAI-Tool-Defs; das Ergebnis kann dann result['tool_calls'] enthalten.
@@ -44,13 +44,15 @@ def local_llm_chat(messages: list, *, max_tokens: int = 512,
req["tools"] = tools
if model:
req["model"] = model
if timeout:
req["timeout"] = timeout # Bridge-seitige Frist (sonst 30 s)
try:
body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request(
f"{BRIDGE_URL}/internal/local-llm", data=body, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(http_req, timeout=LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
with urllib.request.urlopen(http_req, timeout=(timeout + 5) if timeout else LOCAL_LLM_HTTP_TIMEOUT_SEC) as resp:
result = json.loads(resp.read().decode("utf-8", "ignore"))
except urllib.error.HTTPError as exc:
try:
+5
View File
@@ -1190,6 +1190,11 @@ def conversation_reset():
"""Hardes Reset — der Rolling-Window-Verlauf wird komplett geleert.
Destillierte facts bleiben in der DB."""
conversation().reset()
try:
import local_compact
local_compact.reset() # Notizzettel des lokalen Modus gehoert dazu
except Exception:
pass
return {"ok": True, "turns": 0}
+71 -5
View File
@@ -292,6 +292,70 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
return "\n".join(parts).strip()
MEMORY_CARE = """## DEIN GEDAECHTNIS — so pflegst du es
Zwischen zwei Nachrichten behaeltst du NICHTS. Was du spaeter wissen musst, steht
nur dann wieder vor dir, wenn es im Gedaechtnis liegt — angepinnt (immer im
Prompt), im Verzeichnis unten (Titel + Merkzeile) oder als Treffer der Suche.
Darum pflegst du es selbst, ohne dass Stefan "merk dir das" sagen muss.
### Wann speichern (von dir aus)
- Du lernst etwas, das man sonst muehsam neu herausfinden muesste: wo etwas
laeuft, welches System Test und welches Produktion ist, wie man hinkommt, wie
ein Ablauf funktioniert, was Stefan entschieden hat und WARUM.
- Stefan korrigiert dich oder bestaetigt einen nicht offensichtlichen Weg.
- Sofort, nicht "spaeter" — am Ende des Gespraechs bist du es nicht mehr.
### Was NICHT ins Gedaechtnis gehoert
- Was man jederzeit live nachsehen kann und sich laufend aendert: Ticket-Staende,
Projekt-/Ticket-Nummern im Fluss, Zeilenzahlen, "wer arbeitet gerade woran".
Das wird NACHGESEHEN, nicht erinnert.
- Deine eigenen Berichte oder Vermutungen, die du nicht in diesem Zug geprueft hast.
- Was ohnehin im Code, in Git oder in der Doku steht.
### Welche Schublade
- `tool` (angepinnt): Infrastruktur und Umgebungen — Hosts, Zugaenge, welche
Instanz Test und welche Produktion ist. Das muss IMMER vor dir liegen.
- `rule` / `preference` (angepinnt): harte Regeln, Stefans Arbeitsweise.
- `fact`: alles andere (nur per Suche sichtbar). Der Titel ist die Merkzeile im
Verzeichnis — schreib ihn so, dass du beim Lesen weisst, wann du nachschlagen
musst (z.B. "Schmiede: Prod 172.0.2.46, Test aria-wohnung" statt "Software Schmiede").
### Pflegen statt anhaeufen
- VOR dem Speichern mit `memory_search` pruefen, ob es schon einen Eintrag gibt.
Gibt es einen: `memory_update` statt eines zweiten Eintrags.
- Hat sich etwas geaendert (z.B. eine "alte" Kopie ist jetzt deine Testumgebung):
den Eintrag SOFORT berichtigen.
- Ist ein Eintrag falsch oder ueberholt: `memory_delete`.
### Erinnern ist nicht Wissen
- Ein Eintrag gibt den Stand von damals wieder (Datum steht dabei). Bevor du
Stefan einen Systemzustand als Tatsache nennst, pruefe ihn live.
- Jeder Stand-Bericht nennt seine Quelle: WO und WOMIT du nachgesehen hast und
wann ("Prod-Board 172.0.2.46, gerade eben"). Hast du in diesem Zug nicht
nachgesehen, sag das — und schau nach, statt aus dem Verlauf weiterzuschreiben.
- Widerspricht ein neuer Befund einem frueheren aus dem Verlauf: erst klaeren,
WO du jeweils nachgesehen hast, bevor du einen davon fuer falsch erklaerst."""
def build_memory_care_section() -> str:
return MEMORY_CARE
def _mem_date(p: MemoryPoint) -> str:
d = (p.updated_at or p.created_at or "")[:10]
return f" (Stand {d})" if d else ""
def _mem_hook(p: MemoryPoint, n: int = 110) -> str:
"""Erste Zeile/Satz des Inhalts als Merkzeile fuers Verzeichnis."""
c = " ".join((p.content or "").split())
if len(c) <= n:
return c
cut = c[:n]
return cut[: cut.rfind(" ")] + " …" if " " in cut else cut + " …"
def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
"""Baue 'Moeglicherweise relevant'-Block aus Search-Treffern."""
if not matches:
@@ -299,7 +363,7 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
lines = ["## Moeglicherweise relevant (aus Gedaechtnis)"]
for p in matches:
score = f" [score={p.score:.2f}]" if p.score is not None else ""
lines.append(f"- **{p.title}**{score}")
lines.append(f"- **{p.title}**{_mem_date(p)}{score}")
lines.append(f" {p.content.strip()}")
att_line = _attachments_line(p)
if att_line:
@@ -322,8 +386,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
lines = [
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"Diese Eintraege hast DU gespeichert — hier Titel und Merkzeile, nicht der "
"ganze Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
"",
@@ -332,7 +396,8 @@ def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
items = grouped[cat]
lines.append(f"### {cat}")
for p in items:
lines.append(f"- {p.title}")
hook = _mem_hook(p)
lines.append(f"- {p.title}{_mem_date(p)}" + (f" — {hook}" if hook and hook != p.title else ""))
lines.append("")
return "\n".join(lines).strip()
@@ -555,7 +620,8 @@ def build_system_prompt(
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers + FLUX + OAuth."""
# Identitaets-Anker IMMER zuerst — vor allen Memories/Sektionen, damit die
# ARIA-Rolle auch in Projekten mit injection-artigem Inhalt (Pentest) haelt.
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_time_section(),
parts = [IDENTITY_ANCHOR, "", build_hot_memory_section(pinned), "", build_memory_care_section(),
"", build_time_section(),
"", build_voice_flow_section()]
if skills:
parts.append("")
+13 -3
View File
@@ -4271,7 +4271,7 @@ class ARIABridge:
async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None, tools=None,
model=None) -> dict:
model=None, timeout: Optional[float] = None) -> dict:
"""Schickt einen llm_request an den llm-adapter (AI-Box), wartet auf
llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
@@ -4286,7 +4286,7 @@ class ARIABridge:
res = await self._llm_cluster.route({
"model": model or "", "messages": messages, "max_tokens": max_tokens,
"temperature": temperature, "stop": stop, "tools": tools,
}, timeout=self._LLM_TIMEOUT_S)
}, timeout=timeout or self._LLM_TIMEOUT_S)
if not res.get("ok"):
err = res.get("error") or "llm-adapter Fehler"
if res.get("errorCode") == "model_unavailable":
@@ -4773,6 +4773,10 @@ class ARIABridge:
elif method == "POST" and path == "/internal/satellite-list":
# Brain fragt: welche Satelliten/Netze sind online + Capabilities.
await _send_response(writer, 200, {"ok": True, "satellites": self._satellite_list()})
elif method in ("GET", "POST") and path == "/internal/llm-models":
# Brain fragt: welche lokalen Modelle sind geladen + ihr
# Kontextfenster (fuer ARIAs Compacting im lokalen Modus).
await _send_response(writer, 200, {"ok": True, "models": self._llm_cluster.models()})
elif method in ("GET", "POST") and path == "/internal/worker-list":
# Diagnostic/Brain fragt: welche Compute-Worker sind online (Flotte).
await _send_response(writer, 200, {"ok": True, "workers": self._worker_list()})
@@ -4934,10 +4938,16 @@ class ARIABridge:
temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
_model = data.get("model") if isinstance(data.get("model"), str) else None
# Hintergrund-Arbeit (Compacting) darf laenger dauern als die
# 30 s fuer schnelle Antworten — gedeckelt auf 5 min.
try:
_timeout = min(300.0, float(data["timeout"])) if data.get("timeout") else None
except (TypeError, ValueError):
_timeout = None
result = await self._local_llm(
messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"),
tools=_tools, model=_model,
tools=_tools, model=_model, timeout=_timeout,
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
+931 -192
View File
File diff suppressed because it is too large Load Diff
+150 -3
View File
@@ -554,6 +554,107 @@ async function refreshLlmCatalogFromHF() {
return { models: merged, added };
}
// ── 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")
async function addHfRepoToCatalog(repo) {
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo");
const q = await hfRepoQuant(repo, "Q4_K_M");
if (!q) throw new Error("kein ladbares GGUF-Modell in diesem Repo");
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: "Aus den HuggingFace-Trends übernommen", source: "hf" });
saveLlmCatalog(cat);
return { models: cat, id, existed: false };
}
// ── File-Project-Manifest ───────────────────────────────────────────
// Jeder Eintrag map[absoluter_pfad] = project_id (leer = Hauptchat).
// Wird vom files-list-Endpoint + files-set-project gepflegt.
@@ -773,6 +874,7 @@ function workerList() {
// aber diese Feldliste liess sie nie zum Browser durch ("meldet keine Karten").
slots: Array.isArray(w.slots) ? w.slots : null,
knownModels: w.knownModels || [],
disk: w.disk || null, // LLM-Box: freier Platz + heruntergeladene Modelle
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
@@ -1342,6 +1444,7 @@ function connectRVS(forcePlain) {
// in eine Karte laden koennte.
slots: Array.isArray(p.slots) ? p.slots : (prev.slots || null),
knownModels: Array.isArray(p.knownModels) ? p.knownModels : (prev.knownModels || []),
disk: p.disk || prev.disk || null,
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
@@ -2333,6 +2436,46 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, models: loadLlmCatalog() }));
});
} else if (req.url.startsWith("/api/aria-proof") && req.method === "GET") {
// Belege zu einer ARIA-Antwort: alle Werkzeug-Aufrufe (Bash/ssh, Read, …),
// die ARIA im Proxy zwischen Stefans Frage und dieser Antwort gemacht hat.
// Quelle: /shared/logs/aria-tools/<tag>.jsonl (proxy-patches/routes.js).
const u = new URL(req.url, "http://x");
const to = Number(u.searchParams.get("ts")) || 0;
const project = u.searchParams.get("project") || "";
res.writeHead(200, { "Content-Type": "application/json" });
if (!to) { res.end(JSON.stringify({ ok: false, error: "ts fehlt" })); return; }
try { res.end(JSON.stringify({ ok: true, ...ariaProof(to, project) })); }
catch (e) { res.end(JSON.stringify({ ok: false, error: e.message })); }
} else if (req.url.startsWith("/api/llm-discover") && req.method === "GET") {
const u = new URL(req.url, "http://x");
discoverGguf(u.searchParams.get("cat") || "chat", u.searchParams.get("trusted") !== "0")
.then(items => {
const inCat = new Set(loadLlmCatalog().map(m => m.hfRepo));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, items: items.map(i => ({ ...i, inCatalog: inCat.has(i.repo) })) }));
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
});
} else if (req.url === "/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 = "";
try { repo = JSON.parse(body || "{}").hfRepo; } catch {}
addHfRepoToCatalog(repo)
.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 }));
});
});
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readLocalLlmConfig()));
@@ -3455,12 +3598,14 @@ wss.on("connection", (ws) => {
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
{ phase: "queued", requestedAt: Date.now() });
{ phase: "queued", requestedAt: Date.now(), downloadOnly: !!msg.downloadOnly });
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
sendToRVS_raw({ type: "llm_provision_model", payload: {
targetInstance: msg.targetInstance || "",
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
cpuMoe: msg.cpuMoe ?? 0, // MoE: 0 Karte, -1 alle Experten in den RAM, -2 automatisch verteilen
gpu: msg.gpu ?? "", // Ziel-Karte auf der Box
downloadOnly: !!msg.downloadOnly, // nur auf die Platte, in keine Karte
}, timestamp: Date.now() });
log("info", "llm", `provision '${msg.key}' (${msg.hfRepo}) → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_unload_model") {
@@ -3469,9 +3614,11 @@ wss.on("connection", (ws) => {
targetInstance: msg.targetInstance || "", gpu: String(msg.gpu ?? "") }, timestamp: Date.now() });
log("info", "llm", `Karte ${msg.gpu} leeren → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_remove_model") {
// cancel = laufenden Download abbrechen, deleteFiles = Dateien von der Platte
sendToRVS_raw({ type: "llm_remove_model", payload: {
targetInstance: msg.targetInstance || "", key: msg.key }, timestamp: Date.now() });
log("info", "llm", `remove '${msg.key}' → ${msg.targetInstance || "?"}`);
targetInstance: msg.targetInstance || "", key: msg.key, hfRepo: msg.hfRepo || "",
cancel: !!msg.cancel, deleteFiles: !!msg.deleteFiles }, timestamp: Date.now() });
log("info", "llm", `${msg.cancel ? "abbrechen" : msg.deleteFiles ? "Dateien loeschen" : "remove"} '${msg.key}' → ${msg.targetInstance || "?"}`);
} else if (msg.action === "llm_test") {
// Test-Chat: kurze Nachricht direkt ans lokale LLM (llm_request/llm_response).
const reqId = "diagtest_" + Date.now();
+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
Rutsch) und `tools`
- 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
- **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
@@ -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
`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
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
# nssm.cc faellt oefter aus (503) — dann Ersatz aus dem Internet Archive.
# Beide liefern dieselbe Datei; die Pruefsumme stellt das sicher.
ARG NSSM_SHA256=727d1e42275c605e0f04aba98095c38a8e1e46def453cdffce42869428aa6743
RUN for url in https://nssm.cc/release/nssm-2.24.zip \
"https://web.archive.org/web/2024id_/https://nssm.cc/release/nssm-2.24.zip"; do \
curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors "$url" -o /tmp/nssm.zip \
&& echo "$NSSM_SHA256 /tmp/nssm.zip" | sha256sum -c - && break; \
echo "nssm: $url fehlgeschlagen, versuche naechste Quelle" >&2; rm -f /tmp/nssm.zip; \
done \
&& test -f /tmp/nssm.zip \
&& unzip -q /tmp/nssm.zip -d /tmp \
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
&& rm -rf /tmp/nssm*
+8 -4
View File
@@ -142,15 +142,19 @@ Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
## Release (Binaries als Gitea-Assets)
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
`release_agent.sh [version]` baut alles Docker-Baubare und hängt es als
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
```bash
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
./release_agent.sh # Version automatisch +1 (0.0.1.6 → 0.0.1.7)
./release_agent.sh 0.2.0 # Version selbst setzen; Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh # ohne Windows (schneller)
```
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
Assets: `aria-host-agent-linux-x64-v<v>.zip` (Binary + `install-service.sh` +
systemd-Unit + `.env.example` + `LIESMICH.txt` — entpacken, `.env` anlegen, dann
`./aria-host-agent` oder als Dienst `sudo ./install-service.sh ./.env`),
`aria-host-agent-android-agent-v<v>.apk`,
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
+1 -1
View File
@@ -246,7 +246,7 @@ es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh
```bash
cd host-agent
./release_agent.sh 0.2.0
./release_agent.sh # Version automatisch +1 (oder: ./release_agent.sh 0.2.0)
```
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 105
versionName '0.0.1.5'
versionCode 108
versionName '0.0.1.8'
}
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
+1 -1
View File
@@ -162,7 +162,7 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.1.5"
AGENT_VERSION = "0.0.1.8"
HEARTBEAT_SEC = 25
+4 -1
View File
@@ -66,7 +66,10 @@ else
fi
systemctl daemon-reload
systemctl enable --now aria-host-agent
systemctl enable aria-host-agent
# restart statt "enable --now": bei einem Update laeuft der Dienst schon und
# muss die NEUE Binary laden
systemctl restart aria-host-agent
echo
echo "✓ Installiert & gestartet."
+70 -5
View File
@@ -5,7 +5,8 @@
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
#
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
# Verwendung: ./release_agent.sh (Version automatisch +1)
# ./release_agent.sh 0.1.0.0 (Version selbst setzen)
#
# Artefakte:
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
@@ -31,8 +32,38 @@ ROOT_DIR="$(cd .. && pwd)"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# ── Parameter ────────────────────────────────────────────────────────
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
# Ohne Angabe: letzte Stelle automatisch hochzaehlen. Mit Angabe: genau diese.
# Naechste Version: hoechste aus Datei-Version und letztem Git-Tag, dann wie
# ein Kilometerzaehler +1: hintere Stellen laufen 0..9 und uebertragen nach
# vorne (0.2.6.9 → 0.2.7.0, 0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach
# weiter (9.9.9.9 → 10.0.0.0). Aufruf: next_version <aktuelle> <tag-prefix>
next_version() {
local cur="$1" prefix="$2" tagv best
tagv=$(git tag -l "${prefix}*" | sed "s/^${prefix}//" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
best=$(printf '%s\n%s\n' "$cur" "$tagv" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
[ -z "$best" ] && best="0.0.0.0"
awk -F. -v OFS=. '{
for (i = NF; i >= 1; i--) {
if (i == 1 || $i < 9) { $i = $i + 1; break }
$i = 0 # 9 → 0, Uebertrag nach vorne
}
print
}' <<< "$best"
}
CURRENT=$(grep -m1 "versionName" android/app/build.gradle | sed -E "s/.*versionName '([^']*)'.*/\1/")
VERSION=${1:-$(next_version "$CURRENT" "agent-v")}
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
echo -e "${RED}Ungueltige Version: $VERSION (z.B. 0.0.1.7)${NC}"; exit 1
fi
TAG="agent-v$VERSION"
if git rev-parse "$TAG" &>/dev/null; then
echo -e "${RED}Tag $TAG gibt es schon — andere Version angeben.${NC}"; exit 1
fi
echo -e "${CYAN}Agent-Version: $CURRENT → ${GREEN}$VERSION${NC}"
read -p "Release $VERSION bauen? [J/n] " OK
[[ "$OK" =~ ^[nN] ]] && { echo "Abgebrochen."; exit 0; }
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
@@ -75,6 +106,38 @@ LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
# Linux-Paket: Binary + Dienst-Installer + Unit + .env-Vorlage in EINER Zip —
# entpacken, .env anlegen, optional als Dienst installieren.
LINUX_PKG_NAME="aria-host-agent-linux-x64-v$VERSION"
LINUX_ZIP="$SCRIPT_DIR/dist/$LINUX_PKG_NAME.zip"
PKG_TMP="$(mktemp -d)"
mkdir -p "$PKG_TMP/$LINUX_PKG_NAME"
install -m 0755 "$LINUX_BIN" "$PKG_TMP/$LINUX_PKG_NAME/aria-host-agent"
install -m 0755 install-service.sh "$PKG_TMP/$LINUX_PKG_NAME/install-service.sh"
install -m 0644 aria-host-agent.service "$PKG_TMP/$LINUX_PKG_NAME/aria-host-agent.service"
install -m 0644 .env.example "$PKG_TMP/$LINUX_PKG_NAME/.env.example"
cat > "$PKG_TMP/$LINUX_PKG_NAME/LIESMICH.txt" <<EOF
ARIA Host-Agent $VERSION (Linux x86_64)
1) Zugangsdaten eintragen:
cp .env.example .env && nano .env (RVS_HOST, RVS_PORT, RVS_TOKEN, ...)
2a) Einfach starten (laeuft, solange das Terminal offen ist):
./aria-host-agent
2b) ODER als Dienst installieren (startet automatisch mit dem Rechner):
sudo ./install-service.sh ./.env
Danach: systemctl status aria-host-agent · journalctl -u aria-host-agent -f
Update: neue Zip entpacken, nochmal sudo ./install-service.sh /etc/aria-host-agent/.env
Screenshots brauchen eine grafische Sitzung — als Dienst gehen sie nur mit
DISPLAY/XAUTHORITY in der Unit (siehe aria-host-agent.service).
EOF
rm -f "$LINUX_ZIP"
( cd "$PKG_TMP" && zip -qr "$LINUX_ZIP" "$LINUX_PKG_NAME" )
rm -rf "$PKG_TMP"
echo -e " ${GREEN}✓${NC} Linux-Paket $LINUX_PKG_NAME.zip ($(du -h "$LINUX_ZIP" | cut -f1))"
( cd android && ./build.sh >/dev/null )
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
@@ -86,18 +149,20 @@ if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
else
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
WIN_LOG="$SCRIPT_DIR/dist/build-win.log"
if ./build-win.sh "$VERSION" >"$WIN_LOG" 2>&1; then
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
else
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
echo -e " ${YELLOW}Letzte Zeilen (ganzes Log: $WIN_LOG):${NC}"
tail -n 8 "$WIN_LOG" | sed 's/^/ /'
fi
fi
echo ""
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
ASSETS=(
"$LINUX_BIN::aria-host-agent-linux-x64"
"$LINUX_ZIP::$LINUX_PKG_NAME.zip"
"$APK::aria-host-agent-android-$TAG.apk"
)
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
+15 -2
View File
@@ -99,7 +99,20 @@ function _parseToolCalls(text) {
return { tool_calls: tcs, rest: restParts.join("").trim() };
}
export function cliResultToOpenai(result, requestId) {
// Laengere Texte, die Claude im Turn VOR der letzten Antwort geschrieben hat
// (z.B. ein Bericht, danach noch Warten/Werkzeuge, dann eine kurze
// Schlussnotiz). result.result enthaelt nur den LETZTEN Textblock — der
// Bericht ging so verloren (28.10., 2-Stunden-Turn). Kurze Zwischensaetze
// ("Ich schaue nach …") bleiben weg, Sprachausgabe nur aus der letzten Antwort.
const EARLIER_TEXT_MIN = 300;
function _mergeEarlier(finalText, earlier) {
const keep = (earlier || [])
.map(t => String(t || "").replace(/<voice>[\s\S]*?<\/voice>/g, "").replace(/\[\[[A-Z_]+\]\]/g, "").trim())
.filter(t => t.length >= EARLIER_TEXT_MIN && !finalText.includes(t));
return keep.length ? keep.join("\n\n") + "\n\n" + finalText : finalText;
}
export function cliResultToOpenai(result, requestId, earlierTexts = []) {
// modelUsage ist die Wahrheit: die CLI tragt dort den Namen ein, unter dem
// der Request wirklich lief — bei einem Alias also die aufgeloeste Version
// ("opus" -> "claude-opus-5-5"). Leer bleibt es, wenn gar kein Call
@@ -121,7 +134,7 @@ export function cliResultToOpenai(result, requestId) {
message.content = rest || null;
finishReason = "tool_calls";
} else {
message.content = rawText;
message.content = _mergeEarlier(rawText, earlierTexts);
}
return {
+59 -2
View File
@@ -118,7 +118,52 @@ function _emitToolEvent(toolName, projectId) {
* kind: "start" | "text" | "tool_use" | "tool_result" | "end"
*/
function _emitStreamEvent(requestId, kind, fields) {
_postJson(STREAM_HOOK_URL, { requestId, kind, ts: Date.now(), ...fields });
const ev = { requestId, kind, ts: Date.now(), ...fields };
_postJson(STREAM_HOOK_URL, ev);
_persistToolEvent(ev);
}
// ── Werkzeug-Protokoll (Beleg) ────────────────────────────────
// Was ARIA SELBST im Proxy ausfuehrt (Bash/ssh, Read, Edit, …) war bisher
// nur live sichtbar. Jetzt zusaetzlich dauerhaft: eine JSONL-Datei pro Tag
// unter /shared/logs/aria-tools/. Die Diagnostic zeigt pro ARIA-Antwort die
// zugehoerigen Aufrufe ("Belege"). Nur start/tool_use/tool_result/end —
// keine Text-Deltas. Fail-open: ein Schreibfehler stoert ARIA nie.
const TOOL_LOG_DIR = process.env.ARIA_TOOL_LOG_DIR || "/shared/logs/aria-tools";
const TOOL_LOG_KINDS = new Set(["start", "tool_use", "tool_result", "text", "end"]);
const TOOL_LOG_KEEP_DAYS = 30;
let _toolLogCleaned = "";
// Offensichtliche Geheimnisse nicht dauerhaft ablegen (Passwoerter, Tokens,
// API-Keys, private Schluessel) — Ausgaben wie "cat .env" landen sonst im Log.
const SECRET_PATTERNS = [
[/(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g, "$1 *** $2"],
[/((?:pass(?:word|wd)?|token|secret|api[_-]?key|auth|credential)[\w-]*["']?\s*[=:]\s*["']?)[^\s"',;]{4,}/gi, "$1***"],
[/\b(hf_|ghp_|gho_|github_pat_|sk-|xox[bp]-|glpat-)[A-Za-z0-9_-]{8,}/g, "$1***"],
[/(https?:\/\/[^\s:@\/]+:)[^\s@\/]+@/g, "$1***@"],
];
function _redact(v) {
if (typeof v !== "string") return v;
for (const [re, rep] of SECRET_PATTERNS) v = v.replace(re, rep);
return v;
}
function _persistToolEvent(ev) {
if (!TOOL_LOG_KINDS.has(ev.kind)) return;
try {
ev = { ...ev, input: _redact(ev.input), content: _redact(ev.content),
text: typeof ev.text === "string" ? _redact(ev.text).slice(0, 20000) : ev.text };
const day = new Date(ev.ts).toISOString().slice(0, 10);
fs.mkdirSync(TOOL_LOG_DIR, { recursive: true });
fs.appendFile(`${TOOL_LOG_DIR}/${day}.jsonl`, JSON.stringify(ev) + "\n", () => {});
if (_toolLogCleaned !== day) { // einmal am Tag: alte Tage weg
_toolLogCleaned = day;
const cutoff = new Date(Date.now() - TOOL_LOG_KEEP_DAYS * 86400000).toISOString().slice(0, 10);
for (const f of fs.readdirSync(TOOL_LOG_DIR)) {
if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) < cutoff) {
try { fs.unlinkSync(`${TOOL_LOG_DIR}/${f}`); } catch (_) { }
}
}
}
} catch (_) { /* fail-open */ }
}
function _truncate(str, max) {
@@ -442,6 +487,16 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
subprocess.on("result", (result) => {
finalResult = result;
});
// Alle Textbloecke des Turns sammeln — die letzte Antwort allein
// (result.result) verliert sonst einen frueher geschriebenen Bericht.
const turnTexts = [];
subprocess.on("assistant", (message) => {
try {
for (const b of message?.message?.content || []) {
if (b && b.type === "text" && b.text) turnTexts.push(b.text);
}
} catch (_) { }
});
subprocess.on("error", (error) => {
console.error("[NonStreaming] Error:", error.message);
isComplete = true;
@@ -464,7 +519,9 @@ async function handleNonStreamingResponse(res, subprocess, cliInput, requestId)
return;
}
if (finalResult) {
res.json(cliResultToOpenai(finalResult, requestId));
const last = String(finalResult.result || "").trim();
const earlier = turnTexts.filter(t => t.trim() && t.trim() !== last);
res.json(cliResultToOpenai(finalResult, requestId, earlier));
}
else if (!res.headersSent) {
res.status(500).json({
+33 -2
View File
@@ -2,7 +2,8 @@
# ════════════════════════════════════════════════
# ARIA — Release Script
# Baut APK, taggt, erstellt Gitea Release
# Verwendung: ./release.sh 1.2.0
# Verwendung: ./release.sh (Version automatisch +1)
# ./release.sh 0.3.0.0 (Version selbst setzen)
# Gitea-Kennwort wird interaktiv abgefragt.
# ════════════════════════════════════════════════
@@ -16,8 +17,38 @@ CYAN='\033[0;36m'
NC='\033[0m'
# ── Parameter ─────────────────────────────────
VERSION=${1:?"Usage: ./release.sh <version> (z.B. 1.2.0)"}
# Ohne Angabe: letzte Stelle automatisch hochzaehlen. Mit Angabe: genau diese.
# Naechste Version: hoechste aus Datei-Version und letztem Git-Tag, dann wie
# ein Kilometerzaehler +1: hintere Stellen laufen 0..9 und uebertragen nach
# vorne (0.2.6.9 → 0.2.7.0, 0.2.9.9 → 0.3.0.0), die vorderste zaehlt einfach
# weiter (9.9.9.9 → 10.0.0.0). Aufruf: next_version <aktuelle> <tag-prefix>
next_version() {
local cur="$1" prefix="$2" tagv best
tagv=$(git tag -l "${prefix}*" | sed "s/^${prefix}//" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
best=$(printf '%s\n%s\n' "$cur" "$tagv" | grep -E '^[0-9]+(\.[0-9]+)+$' | sort -V | tail -1)
[ -z "$best" ] && best="0.0.0.0"
awk -F. -v OFS=. '{
for (i = NF; i >= 1; i--) {
if (i == 1 || $i < 9) { $i = $i + 1; break }
$i = 0 # 9 → 0, Uebertrag nach vorne
}
print
}' <<< "$best"
}
CURRENT=$(grep -m1 '"version"' android/package.json | sed -E 's/.*"version": *"([^"]*)".*/\1/')
VERSION=${1:-$(next_version "$CURRENT" "v")}
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
echo -e "${RED}Ungueltige Version: $VERSION (z.B. 0.2.6.2)${NC}"; exit 1
fi
TAG="v$VERSION"
if git rev-parse "$TAG" &>/dev/null; then
echo -e "${RED}Tag $TAG gibt es schon — andere Version angeben.${NC}"; exit 1
fi
echo -e "${CYAN}App-Version: $CURRENT → ${GREEN}$VERSION${NC}"
read -p "Release $VERSION bauen? [J/n] " OK
[[ "$OK" =~ ^[nN] ]] && { echo "Abgebrochen."; exit 0; }
# ── Gitea-Konfiguration ──────────────────────
# Aus .env lesen falls vorhanden (GITEA_URL, GITEA_REPO, GITEA_USER)
+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.
const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000;
// Antworten bis zu dieser Groesse gehen an den Raum, wenn der Fragende nur
// neu verbunden ist (siehe Weiterleitung). Dateien sind groesser.
const REJOIN_BROADCAST_MAX = 2 * 1024 * 1024;
function pendingKey(replyType, requestId) {
return `${replyType}\u0000${requestId}`;
@@ -696,7 +699,16 @@ function registerClient(ws, token) {
rememberRequester(ws, msg);
// Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum.
const targets = targetedRecipients(msg);
let targets = targetedRecipients(msg);
// Fragender inzwischen neu verbunden (RVS-/Proxy-Neustart waehrend die
// Antwort noch lief): seine alte Verbindung gibt es nicht mehr, die neue
// kennen wir nicht. Kleine Antworten (LLM-Antwort, Status) dann an den
// Raum — der Fragende erkennt sie an der requestId. Grosse (Dateien)
// bleiben verworfen, sonst gingen hunderte MB an alle.
if (targets && targets.length === 0 && raw.length <= REJOIN_BROADCAST_MAX) {
log(`${msg.type}: Fragender neu verbunden — ${(raw.length / 1024).toFixed(0)}KB an den Raum`);
targets = null;
}
if (targets) {
const data = raw.toString();
let sent = 0;
+57 -25
View File
@@ -181,6 +181,9 @@ APK_MAX_BYTES = int(os.environ.get("APK_MAX_BYTES", str(64 * 1024 * 1024)) or st
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
# Obergrenze fuer einen ganzen Scan. Haengt ein Schritt (mDNS, SNMP, ADB …),
# kommt nach dieser Zeit die letzte bekannte Liste zurueck statt gar nichts.
SCAN_MAX_SEC = float(os.environ.get("SCAN_MAX_SEC", "90") or "90")
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
# http.get/http.post: Body-Ausschnitt. Default grosszuegig (ganze Statusseiten
@@ -466,18 +469,28 @@ def _discover_mdns(timeout: float) -> list[dict]:
pass
zc = None
browsers = []
try:
zc = Zeroconf()
listener = _Listener()
for t in MDNS_TYPES:
try:
ServiceBrowser(zc, t, listener)
browsers.append(ServiceBrowser(zc, t, listener))
except Exception:
pass
time.sleep(timeout)
except Exception as exc:
logger.warning("mDNS-Sweep-Fehler: %s", exc)
finally:
# Jeder ServiceBrowser ist ein eigener Thread, den zc.close() NICHT
# beendet — ohne cancel() blieben pro Scan 14 Threads liegen. Nach
# ~5 Tagen war das Thread-Limit des Containers voll (18.841), danach
# scheiterte jeder Scan mit "[Errno 11] Resource temporarily unavailable".
for b in browsers:
try:
b.cancel()
except Exception:
pass
try:
if zc:
zc.close()
@@ -1797,6 +1810,7 @@ class Satellite:
self._devices: list[dict] = []
self._devices_ts: float = 0.0
self._scanning = False
self._scan_stage = ""
# Offene Port-Tunnel: tunnelId -> {"writer", "task", "last", "ziel"}
self._tunnels: dict[str, dict] = {}
@@ -1812,26 +1826,40 @@ class Satellite:
break
return self._devices
self._scanning = True
self._scan_stage = "start"
try:
loop = asyncio.get_event_loop()
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
arp = await loop.run_in_executor(None, _discover_arp)
_self = [_self_device()] if LIST_SELF else []
_self = [d for d in _self if d]
self._devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
await self._enrich_adb(self._devices)
self._devices_ts = time.time()
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
n_adb = sum(1 for d in self._devices if d.get("adbCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb)
await asyncio.wait_for(self._scan_inner(), SCAN_MAX_SEC)
except asyncio.TimeoutError:
logger.warning("[scan] nach %.0fs abgebrochen — haengt bei: %s. Liefere die letzte Liste (%d Geraete).",
SCAN_MAX_SEC, self._scan_stage, len(self._devices))
finally:
self._scanning = False
return self._devices
async def _scan_inner(self) -> None:
"""Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC."""
loop = asyncio.get_event_loop()
self._scan_stage = "mdns"
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
self._scan_stage = "ssdp"
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
self._scan_stage = "arp"
arp = await loop.run_in_executor(None, _discover_arp)
_self = [_self_device()] if LIST_SELF else []
_self = [d for d in _self if d]
devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
if SNMP_DISCOVERY:
self._scan_stage = "snmp"
await self._enrich_snmp(devices)
self._scan_stage = "adb"
await self._enrich_adb(devices)
self._devices = devices
self._devices_ts = time.time()
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
n_adb = sum(1 for d in self._devices if d.get("adbCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb)
async def _enrich_snmp(self, devices: list) -> None:
"""Fragt jedes Geraet mit IP parallel per SNMP-System-Group ab und haengt
die Infos an. Verbessert Name/Typ, wenn bisher nur eine IP bekannt war."""
@@ -2020,6 +2048,16 @@ class Satellite:
return True
return target in (SATELLITE_ID.lower(), SATELLITE_LOCATION.lower())
async def _discover_reply(self, payload: dict) -> None:
devices = await self._scan(force=bool(payload.get("force")))
await self._send({
"type": "sat_devices",
"payload": {"requestId": payload.get("requestId", ""), "satellite": SATELLITE_ID,
"location": SATELLITE_LOCATION, "devices": devices,
"net": NET},
"timestamp": int(time.time() * 1000),
})
async def _handle(self, raw: str) -> None:
try:
msg = json.loads(raw)
@@ -2031,15 +2069,9 @@ class Satellite:
if mtype == "sat_discover":
if not self._for_me(payload):
return
req_id = payload.get("requestId", "")
devices = await self._scan(force=bool(payload.get("force")))
await self._send({
"type": "sat_devices",
"payload": {"requestId": req_id, "satellite": SATELLITE_ID,
"location": SATELLITE_LOCATION, "devices": devices,
"net": NET},
"timestamp": int(time.time() * 1000),
})
# Eigener Task: ein langer Scan darf den Nachrichten-Loop (Tunnel,
# Befehle, weitere Scans) nicht anhalten.
asyncio.create_task(self._discover_reply(payload))
elif mtype == "sat_tunnel_open":
if not self._for_me(payload):
+1
View File
@@ -181,6 +181,7 @@ services:
volumes:
- ./models:/models # generierte Config + Registry + Cache
- ./llama-swap:/llamaswap:ro # Basis-Template (config.yaml)
- /sys:/hostsys:ro # nur lesen: Tempo der Netzwerkkarte (Download-Fenster)
environment:
- NVIDIA_VISIBLE_DEVICES=all # alle Karten sichtbar (nur nvidia-smi)
- NVIDIA_DRIVER_CAPABILITIES=utility # utility = nvidia-smi, KEIN VRAM/Compute
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
+743 -19
View File
@@ -28,7 +28,11 @@ import json
import logging
import os
import re
import hashlib
import queue
import shlex
import shutil
import threading
import time
import httpx
@@ -226,6 +230,31 @@ def _base_config() -> dict:
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:
"""llama-server-Aufruf fuer ein Modell: aus der Registry (ueber die
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()
return (f"llama-server --port ${{PORT}} --host 127.0.0.1\n"
f"-hf {e['hfRepo'].strip()}:{quant}\n"
f"-ngl {int(e.get('ngl') or 99)} -c {int(e.get('ctx') or 8192)} --jinja")
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 {}
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
# 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:
try:
await ws.send(json.dumps({
"type": mtype,
"payload": payload,
"timestamp": int(time.time() * 1000),
}))
except Exception as e:
logger.warning("Send fehlgeschlagen (%s): %s", mtype, e)
data = json.dumps({"type": mtype, "payload": payload, "timestamp": int(time.time() * 1000)})
targets = [ws] if ws is _current_ws or _current_ws is None else [ws, _current_ws]
last_err = None
for t in targets:
try:
await t.send(data)
return
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:
@@ -517,6 +581,237 @@ def _model_ctx(key: str) -> int:
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:
"""{index: belegte MB} — frisch bei jedem Aufruf (aendert sich laufend)."""
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)
out.append({"gpu": card, "gpuName": g.get("name", ""), "vramMB": g.get("vramMB", 0),
"vramUsedMB": u if u is not None else -1, "onCpu": on_cpu,
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0})
"model": m, "state": state, "ctx": _model_ctx(m) if m else 0,
**(_ctx_profile(m) if m else {})})
return out
@@ -593,6 +889,7 @@ async def _announce(ws) -> None:
"models": models,
"slots": info, # pro LLM-Karte: Modell + Zustand (Diagnostic)
"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
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
@@ -841,9 +1138,12 @@ def _scan_download(oids: set) -> tuple:
inprog = fn.endswith(_INPROGRESS)
base = fn[:-len(_INPROGRESS)] if inprog else fn
try:
size = os.path.getsize(os.path.join(root, fn))
st = os.stat(os.path.join(root, fn))
except OSError:
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:
done += size
found += 1
@@ -856,6 +1156,264 @@ def _scan_download(oids: set) -> tuple:
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:
"""Name + VRAM einer Karte nach dem Laden (fuer 'in Karte N geladen')."""
if not card:
@@ -873,13 +1431,46 @@ async def _gpu_info(card: str) -> dict:
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:
await _send(ws, "llm_provision_progress",
{"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.
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")}
@@ -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
# nur: es lief ein Download, und jetzt nicht mehr.
all_there = bool(oids) and found >= len(oids)
if phase != "load" and (all_there or downloaded):
if final_phase and phase != "load" and (all_there or downloaded):
phase = "load"
await _emit_progress(ws, key, "load", bytes=total or b, total=total,
percent=100, cached=not downloaded)
@@ -922,12 +1513,56 @@ async def _provision_result(ws, key: str, ok: bool, **extra) -> 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,
was dort lag. Ablauf: Registry + Belegung → Config (llama-swap laedt per
--watch-config + Preload) → Warmup, der wartet bis es bereit ist; daneben
misst _watch_download den Download."""
key = (payload.get("key") or "").strip()
repo = (payload.get("hfRepo") or "").strip()
if payload.get("downloadOnly"):
return await _download_only(ws, key, repo, (payload.get("quant") or "Q4_K_M").strip(), job)
cards = _llm_cards()
card = str(payload.get("gpu") if payload.get("gpu") is not None else "").strip()
if not card and len(cards) == 1:
@@ -973,10 +1608,38 @@ async def _handle_provision(ws, payload: dict) -> None:
if quant != wanted.upper():
quant_note = f"{wanted} gibt es in {repo} nicht — nehme {quant}"
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 = {
"key": key, "hfRepo": repo, "quant": quant,
"ctx": int(payload.get("ctx") or 8192),
"ctx": ctx_req,
"ngl": int(payload.get("ngl") or 99),
"cpuMoe": cpu_moe,
}
_save_registry([e for e in _load_registry() if e.get("key") != key] + [entry])
elif not _model_cmd(key):
@@ -999,9 +1662,16 @@ async def _handle_provision(ws, payload: dict) -> None:
t0 = time.time()
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
# LLM_TIMEOUT_SEC meldete das frueher "Fehler", waehrend llama-swap weiterlud.
res = await _call_llama([{"role": "user", "content": "hi"}],
max_tokens=1, temperature=0.0, stop=None, model=key,
timeout=WARMUP_TIMEOUT_S)
job["phase"] = "llama"
try:
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
stop.set()
try:
@@ -1042,6 +1712,13 @@ async def _handle_provision(ws, payload: dict) -> None:
_save_registry(reg)
_generate_config()
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)
note = f" — Karte {card} wieder auf {previous}" if previous else f" — Karte {card} wieder leer"
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:
"""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()
if not key:
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])
slots = _load_slots()
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)
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:
"""Erkundung fuer Clients: welche Modelle kann ich bei DIESER Box fragen?
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:
global _current_ws
if not RVS_HOST:
logger.error("RVS_HOST nicht gesetzt — Abbruch")
return
@@ -1136,6 +1857,9 @@ async def _run() -> None:
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden — llm-adapter online")
_current_ws = ws
if _outbox:
await _flush_outbox(ws)
retry_s = 2
tls_fallback_tried = False
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
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
+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"))
if not res.get("ok"):
code = res.get("errorCode") or "model_error"
if code == "context_exceeded":
# Wie OpenAI: 400 + context_length_exceeded und die Zahlen im
# Text — daran erkennen OpenAI-Clients (litellm/OpenHands & Co.)
# einen Kontext-Ueberlauf und kuerzen/verdichten selbst.
n_ctx, n_req = res.get("ctx"), res.get("promptTokens")
msg = (f"This model's maximum context length is {n_ctx} tokens. However, your "
f"messages resulted in {n_req} tokens. Please reduce the length of the messages."
if n_ctx and n_req else (res.get("error") or "context length exceeded"))
return await reply(writer, 400, oai_error(msg, "context_length_exceeded"))
status = {"bad_request": 400, "model_unavailable": 404, "timeout": 504}.get(code, 502)
return await reply(writer, status, oai_error(
res.get("error") or "Fehler", "model_not_found" if code == "model_unavailable" else code,
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError:
+27 -1
View File
@@ -96,6 +96,31 @@ class NodeStats:
gpu = round(sum(utils) / len(utils), 1) if utils else 0.0
return {"gpu": gpu, "memUsed": int(used), "memTotal": int(total)}
async def _query_cards(self) -> list:
"""Pro Karte: Index, Name, Last, Speicher, Temperatur, Strom.
uuid, damit die Diagnostic dieselbe Karte erkennt, wenn mehrere
Dienste einer Box sie melden."""
out = await _run_cmd(
"nvidia-smi",
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,"
"temperature.gpu,power.draw,power.limit",
"--format=csv,noheader,nounits")
cards = []
for line in out.strip().splitlines():
p = [x.strip() for x in line.split(",")]
if len(p) < 9:
continue
def num(v):
try:
return float(v)
except ValueError:
return None
cards.append({"index": p[0], "uuid": p[1], "name": p[2], "util": num(p[3]),
"memUsed": num(p[4]), "memTotal": num(p[5]), "temp": num(p[6]),
"power": num(p[7]), "powerLimit": num(p[8])})
return cards
async def _nvidia_smi_text(self) -> str:
txt = await _run_cmd("nvidia-smi")
return txt or "nvidia-smi nicht verfuegbar"
@@ -128,9 +153,10 @@ class NodeStats:
while time.time() - t0 < STREAM_MAX_SEC:
g = await self._query_gpu()
smi = await self._nvidia_smi_text()
cards = await self._query_cards()
await send(ws, "node_stats", {
"instanceId": self.instance_id, "node": self.node_name,
"nvidiaSmi": smi, **g,
"nvidiaSmi": smi, "cards": cards, **g,
})
await asyncio.sleep(1)
except asyncio.CancelledError: