feat(llm): Download-Fortschritt beim Modell-Laden — und "Laden" kommt endlich an

Eigentlicher Fehler zuerst: llm_provision_model/_result und llm_remove_model
standen nie in der RVS-Allow-List. Der RVS verwirft unbekannte Typen mit einem
stillen return — jeder Klick auf "Laden" im Modell-Katalog ist seit Stage D
(19.09.) an dieser Liste gestorben, die Box hat nie davon erfahren. Laden
liessen sich nur die Modelle aus der Basis-Config der Box.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 15:33:54 +02:00
co-authored by Claude Opus 5.5
parent d684bbb5f0
commit 864237ff2c
6 changed files with 342 additions and 7 deletions
+1
View File
@@ -544,6 +544,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat. - `GET /api/chat-backup?lines=N` — letzte N Zeilen aus `chat_backup.jsonl` (Default 200, max 5000) als geparstes JSON. Hilfreich um nachzuvollziehen was ARIA tatsaechlich gemacht hat.
- `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs). - `GET /api/agent-stream?lines=N` — gleiche Mechanik fuer den persistierten Live-Stream (Tool-Calls + Inputs + Outputs).
- **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke` - **OAuth-Callback-Pipeline**: Caddy davor terminiert TLS via Let's Encrypt, RVS hat einen HTTP-Listener auf demselben Port wie der WebSocket. Provider (Spotify/Dropbox/Discord/...) redirecten den User an `https://{RVS_HOST}/oauth/callback/{service}` → RVS broadcastet als `oauth_callback`-WS-Message → aria-bridge forwarded an Brain → Brain matched `state`, tauscht `code` gegen Token, persistiert in `/shared/config/oauth_tokens.json`. Token-Refresh laeuft automatisch. ARIA hat fuenf Brain-Tools: **`oauth_register_provider`** (legt URLs eines neuen Providers wie Dropbox/Discord/Notion/... on-demand in `oauth_apps.json` an — Credentials bleiben Stefans Job), `oauth_authorize`, `oauth_get_token`, **`oauth_call`**, `oauth_revoke`
- **Lokale Modelle laden mit Fortschritt** (Modell-Katalog → „Laden", Knopf **⬇ Downloads**): echte Prozentanzeige mit Tempo und Restzeit, danach „laedt in die Karte" und am Ende „✓ in Karte N (Name) geladen" samt belegtem VRAM. llama.cpp zieht das GGUF per `-hf` selbst und meldet nichts; der llm-adapter fragt deshalb vorab bei Hugging Face, welche Dateien mit welcher Groesse und sha256 kommen, und misst genau diese im HF-Cache-Layout (`/models/models--org--repo/blobs/<sha256>.downloadInProgress`, nach Abschluss ohne Endung) — klappt auch bei gesplitteten Modellen und parallelen Downloads; liegt es schon im Cache, geht es direkt ans Laden. Der Warmup wartet jetzt bis zu `LLM_WARMUP_TIMEOUT_S` (1900 s) statt 60 s — vorher meldete ein mehrere GB grosses Modell „Fehler", waehrend llama-swap weiterlud. Ein Auftrag, auf den die Box nicht antwortet, erscheint nach 30 s als Warnung statt still zu verschwinden. Pro LLM-Box laeuft **ein** Modell auf ihrer LLM-Karte (`LLM_GPU`), ein anderes zu laden wechselt dorthin. *Wichtig:* `llm_provision_*` fehlte bis hierhin in der RVS-Allow-List — „Laden" hat seit Stage D nie eine Box erreicht
- **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung) - **Sprachmodell**: die Auswahlliste kommt aus **Anthropics eigenem Modell-Katalog** (`downloads.claude.ai/model-catalog/v1/catalog.json`, Surface `cc`) — dieselbe Quelle, aus der sich Claude Code bedient. Oeffentlich, ohne API-Key. Oben die **Tier-Aliase** (opus/sonnet/haiku/fable), die automatisch der neuesten Version folgen; darunter die **exakten Versionen**, aeltere als solche markiert. „↻ Aktualisieren" holt frisch, sonst 6h-Cache mit Datei-Fallback fuer offline. **🔬 Testen** schickt eine echte Mini-Anfrage durch den ganzen Weg und zeigt, welches Modell tatsaechlich geantwortet hat (aus `modelUsage.canonicalModel` der CLI — das Modell selbst zu fragen waere unzuverlaessig). Vier Ergebnisse: Tier aufgeloest (gruen), exakte Version (gruen), **nicht erkannt** (gelb — der Proxy haette still `opus` eingesetzt), ungueltig (rot, mit CLI-Begruendung)
- **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert - **ARIA E-Mail** (Einstellungen → Integrationen): ARIA liest und sendet Mails ueber IMAP/SMTP (je SSL/TLS, STARTTLS oder unverschluesselt, Zertifikatspruefung abschaltbar fuer eigene Server) — Text, HTML, **Anhaenge** und **eingebettete Bilder** (`<img src="cid:…">`), Antworten mit `In-Reply-To`, Ordner anlegen (auch Unterordner) und Mails verschieben. Tools: `email_status`, `email_send`, `email_list`, `email_read`, `email_folders`, `email_folder_create`, `email_move`. Code in [`aria-brain/aria_mail.py`](aria-brain/aria_mail.py). **Whitelist** ist eine harte Grenze im Sendepfad, kein Prompt-Hinweis: alles, was nicht drinsteht, ist gesperrt, leer = ARIA sendet nirgendwohin; geprueft wird jeder Empfaenger (An/Cc/Bcc), ein fremder blockiert die ganze Mail. Eine Schreibweise, damit kein Eintrag mehrdeutig ist: `stefan@hacker-net.de` (genau die), `*@hacker-net.de` (die Domain, NICHT `evilhacker-net.de`, keine Subdomains), `*@*.hacker-net.de` (Subdomains), `*@*` (alles); Kurzformen wie `*hacker-net.de` werden beim Speichern mit Korrekturvorschlag abgelehnt. **Zugangsdaten sieht ARIA nie**: Konfiguration und Passwort liegen im Brain unter `/data` (in ARIAs Container nicht eingehaengt — `/shared` dagegen schon, darum NICHT dort), keine Schnittstelle gibt das Passwort zurueck (nur „gesetzt"), der Brain-Export laesst `email_secret.json` und `email_admin.key` aus. Konfiguration lesen/aendern nur mit Admin-Schluessel (`X-Aria-Admin`, liegt in `/data`), den die Diagnostic anhaengt — Anfragen aus ARIAs Container weist sie ab, sonst koennte eine eingehende Mail ARIA dazu bringen, per curl die Whitelist zu oeffnen. *Grenze:* ARIAs Container hat einen SSH-Schluessel auf die VM; legt sie es gezielt darauf an, kommt sie als root an jede Datei — das hier verhindert, dass Zugangsdaten von selbst irgendwo auftauchen, keinen bewussten Einbruch. Eingehende Mails sind fuer ARIA als **Daten, nicht Anweisungen** markiert. Kopie jeder gesendeten Mail landet in „Gesendet" (fehlt der Ordner, wird er angelegt). **📬 Postfach oeffnen** zeigt ARIAs ganzes Postfach in eigenem Fenster, Unterordner eingerueckt — nur fuer dich zum Reinschauen, markiert nichts als gelesen (sonst wuesste ARIA nicht mehr, was neu ist); Mail-HTML laeuft in einem Rahmen ohne Skripte und ohne Nachladen von aussen (kein Tracking-Pixel), eingebettete Bilder werden mitgeliefert
- **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude" - **Modell pro Chat**: jeder Chat (Hauptchat und jedes Projekt) kann sein eigenes Modell haben, ohne die Einstellung fuer alle umzustellen. Reihenfolge: **Einstellung** (`brainModel`) < **pro Chat** (`/shared/config/context_models.json`, gesetzt per 🧠-Auswahl neben „+ Projekt") < **Ausweich** (Sicherheitsfilter, s.u.). Angezeigt wird immer, was der **naechste Turn wirklich nutzt**: in den Kontext-Karten, in der Auswahl (orange „↪ läuft auf …" plus **„↩ zurück zu …"**, wenn gerade ausgewichen wird) und an jeder ARIA-Bubble — dort das Modell, das tatsaechlich geantwortet hat (aus `modelUsage` der CLI, also die aufgeloeste Version statt des Alias). Als Grundeinstellung zaehlt das Modell, mit dem das Brain **laeuft**, nicht der Dateiwert — ein ausstehender Neustart nach Aenderung von `brainModel` faellt so auf. In der **App** dasselbe hinter der Quell-Anzeige (Einstellungen, Default aus): Modell-Chip in der Kopfzeile, Tippen oeffnet die Auswahl fuer diesen Chat, die Bubbles zeigen das Modell statt nur „Claude"
+130
View File
@@ -103,6 +103,7 @@
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85); .modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.85);
z-index:1000; justify-content:center; align-items:center; } z-index:1000; justify-content:center; align-items:center; }
.modal-overlay.open { display:flex; } .modal-overlay.open { display:flex; }
@keyframes llmdlpulse { 0%,100% { opacity:.15; } 50% { opacity:.45; } }
.modal-box { background:#0D0D1A; border:1px solid #FFD60A44; border-radius:10px; width:90vw; max-width:860px; .modal-box { background:#0D0D1A; border:1px solid #FFD60A44; border-radius:10px; width:90vw; max-width:860px;
max-height:90vh; display:flex; flex-direction:column; overflow:hidden; } max-height:90vh; display:flex; flex-direction:column; overflow:hidden; }
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; .modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px;
@@ -843,6 +844,7 @@
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;"> <div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
<h2 style="margin:0;">Modell-Katalog</h2> <h2 style="margin:0;">Modell-Katalog</h2>
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button> <button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
</div> </div>
<div class="card" style="max-width:1100px;"> <div class="card" style="max-width:1100px;">
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;"> <p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
@@ -1859,6 +1861,25 @@
</div> </div>
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt --> <!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
<!-- Modell-Downloads auf LLM-Boxen: Fortschritt, dann „in die Karte geladen" -->
<div class="modal-overlay" id="llm-dl-modal" onclick="if(event.target===this)closeLlmDownloads()">
<div class="modal-box" style="max-width:720px;">
<div class="modal-header">
<h3>⬇ Modell-Downloads</h3>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="send({action:'clear_llm_jobs'})" title="Fertige und fehlgeschlagene aus der Liste nehmen" style="padding:3px 10px;font-size:11px;">Erledigte entfernen</button>
<button class="modal-close" onclick="closeLlmDownloads()">&times;</button>
</div>
</div>
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
Pro LLM-Box läuft immer <strong style="color:#8888AA;">ein</strong> Modell auf ihrer LLM-Karte
(<code>LLM_GPU</code> in der .env der Box). Laden eines anderen wechselt dorthin — das vorige
wird entladen, bleibt aber heruntergeladen.
</div>
</div>
</div>
<div class="modal-overlay" id="info-modal"> <div class="modal-overlay" id="info-modal">
<div class="modal-box" style="max-width:640px;"> <div class="modal-box" style="max-width:640px;">
<div class="modal-header"> <div class="modal-header">
@@ -2313,6 +2334,8 @@
try { loadModelList(); } catch {} try { loadModelList(); } catch {}
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene) // Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
try { loadSafeguard(); } catch {} try { loadSafeguard(); } catch {}
// Laufende Modell-Downloads (Badge am ⬇-Knopf)
send({ action: 'get_llm_jobs' });
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen) // Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {} try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
}; };
@@ -2574,6 +2597,13 @@
} }
return; return;
} }
if (msg.type === 'llm_provision_progress') { onLlmJob(msg.payload); return; }
if (msg.type === 'llm_jobs') {
_llmJobs = {};
for (const j of ((msg.payload || {}).jobs || [])) _llmJobs[_llmJobId(j)] = j;
renderLlmDownloads();
return;
}
if (msg.type === 'llm_provision_result') { if (msg.type === 'llm_provision_result') {
const p = msg.payload || {}; const p = msg.payload || {};
const st = document.getElementById('llm-catalog-status'); const st = document.getElementById('llm-catalog-status');
@@ -8942,6 +8972,105 @@
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' + '<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>'; '</tr></thead><tbody>' + rows + '</tbody></table>';
} }
// ── Download-Fenster ──────────────────────────────────────────────
// Zustand kommt vom Diagnostic-Server (llm_provision_progress / llm_jobs);
// der kennt auch Downloads, die vor dem Oeffnen dieses Tabs gestartet sind.
let _llmJobs = {};
const _llmJobId = j => j.instanceId + '|' + j.key;
function _fmtBytes(b) {
if (!b && b !== 0) return '?';
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(0) + ' MB';
return Math.round(b / 1e3) + ' KB';
}
function _fmtEta(s) {
if (s == null) return '';
if (s < 60) return s + ' s';
return Math.floor(s / 60) + ' min ' + String(s % 60).padStart(2, '0') + ' s';
}
function _boxName(instanceId) {
const w = (typeof workers !== 'undefined' ? workers : []).find(x => x.instanceId === instanceId);
return (w && w.node) || (instanceId || '').replace(/^llm@/, '');
}
function onLlmJob(job) {
if (!job || !job.key) return;
_llmJobs[_llmJobId(job)] = job;
renderLlmDownloads();
// Fertig geladen → Katalog-Status ("✓ ki-box") nachziehen.
if (job.phase === 'ready' && typeof requestWorkers === 'function') requestWorkers();
}
function renderLlmDownloads() {
const jobs = Object.values(_llmJobs).sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0));
const running = jobs.filter(j => !['ready', 'error'].includes(j.phase)).length;
const badge = document.getElementById('llm-dl-badge');
if (badge) { badge.textContent = running; badge.style.display = running ? 'inline-block' : 'none'; }
const box = document.getElementById('llm-dl-list');
if (!box) return;
if (!jobs.length) {
box.innerHTML = '<div style="color:#6a6a88;text-align:center;padding:20px;">Keine Downloads. „Laden" im Modell-Katalog startet einen.</div>';
return;
}
box.innerHTML = jobs.map(j => {
const box_ = escapeHtml(_boxName(j.instanceId));
let pct = 0, color = '#0096FF', line = '', sub = '';
if (j.phase === 'queued') {
// Kommt hier nie mehr was, erreicht der Auftrag die Box nicht.
const age = (Date.now() - (j.requestedAt || j.startedAt || Date.now())) / 1000;
line = 'Angefragt — warte auf die Box …';
if (age > 30) { line = '⚠ Die Box antwortet nicht.'; color = '#FF9F0A';
sub = 'Ist sie online? Nach einem Update von RVS und Box erneut versuchen.'; }
} else if (j.phase === 'start') {
line = 'Box hat den Auftrag — prüfe, ob das Modell schon da ist …';
} else if (j.phase === 'download') {
pct = j.percent != null ? j.percent : 0;
line = j.percent != null
? `${pct.toFixed(1)} % · ${_fmtBytes(j.bytes)} / ${_fmtBytes(j.total)}`
: `${_fmtBytes(j.bytes)} heruntergeladen (Größe unbekannt)`;
sub = (j.speedBps ? (j.speedBps / 1e6).toFixed(1) + ' MB/s' : '') + (j.etaSeconds != null ? ' · noch ' + _fmtEta(j.etaSeconds) : '');
} else if (j.phase === 'load') {
pct = 100; color = '#FFD60A';
line = j.cached ? 'Liegt schon auf der Box — lädt in die Karte …' : '100 % heruntergeladen — lädt jetzt in die Karte …';
sub = 'Das Modell wandert in den Grafikspeicher; das dauert je nach Größe ein paar Sekunden bis Minuten.';
} else if (j.phase === 'ready') {
pct = 100; color = '#34C759';
const card = j.gpu != null && j.gpu !== '' ? `Karte ${escapeHtml(String(j.gpu))}${j.gpuName ? ' (' + escapeHtml(j.gpuName) + ')' : ''}` : 'die Karte';
line = `✓ In ${card} geladen — bereit`;
const vram = j.vramTotalMB ? ` · VRAM ${(j.vramUsedMB / 1024).toFixed(1)} / ${(j.vramTotalMB / 1024).toFixed(1)} GB belegt` : '';
sub = (j.loadSeconds != null ? `Dauer ${_fmtEta(Math.round(j.loadSeconds))}` : '') + vram;
} else if (j.phase === 'error') {
color = '#FF6B6B'; pct = j.percent || 0;
line = '✗ ' + escapeHtml(j.error || 'Fehler');
}
const bar = `<div style="height:8px;background:#1E1E2E;border-radius:4px;overflow:hidden;margin:6px 0 4px;">
<div style="height:100%;width:${Math.max(0, Math.min(100, pct))}%;background:${color};transition:width .6s;
${j.phase === 'queued' || j.phase === 'start' ? 'opacity:.35;width:100%;animation:llmdlpulse 1.2s ease-in-out infinite;' : ''}"></div></div>`;
return `<div style="background:#12121E;border:1px solid #2A2A3E;border-radius:8px;padding:10px 12px;">
<div style="display:flex;justify-content:space-between;gap:10px;">
<strong style="color:#E0E0F0;">${escapeHtml(j.key)}</strong>
<span style="color:#8888AA;font-size:11px;">→ ${box_}</span>
</div>${bar}
<div style="color:${color === '#0096FF' ? '#E0E0F0' : color};font-size:12px;">${line}</div>
${sub ? `<div style="color:#6a6a88;font-size:11px;margin-top:2px;">${sub}</div>` : ''}
</div>`;
}).join('');
}
function openLlmDownloads() {
send({ action: 'get_llm_jobs' });
renderLlmDownloads();
document.getElementById('llm-dl-modal').classList.add('open');
}
function closeLlmDownloads() {
document.getElementById('llm-dl-modal').classList.remove('open');
}
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
setInterval(() => {
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
}, 5000);
function provisionModel(i) { function provisionModel(i) {
const m = _llmCatalog[i]; const m = _llmCatalog[i];
if (!m) return; if (!m) return;
@@ -8951,6 +9080,7 @@
const st = document.getElementById('llm-catalog-status'); const st = document.getElementById('llm-catalog-status');
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; } if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx }); send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
openLlmDownloads();
} }
function runLlmTest() { function runLlmTest() {
const inp = document.getElementById('llm-test-input'); const inp = document.getElementById('llm-test-input');
+43 -1
View File
@@ -1372,7 +1372,17 @@ function connectRVS(forcePlain) {
broadcast({ type: "service_status", payload: msg.payload }); broadcast({ type: "service_status", payload: msg.payload });
} else if (msg.type === "llm_provision_result") { } else if (msg.type === "llm_provision_result") {
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status). // Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
broadcast({ type: "llm_provision_result", payload: msg.payload || {} }); const p = msg.payload || {};
if (p.key && p.instanceId && !p.removed) {
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
if (job) broadcast({ type: "llm_provision_progress", payload: job });
}
broadcast({ type: "llm_provision_result", payload: p });
} else if (msg.type === "llm_provision_progress") {
// Download-Fortschritt einer Box (Prozent, Tempo, Restzeit, dann
// "laedt in die Karte" und "geladen"). Merken fuer spaeter geoeffnete Tabs.
const job = llmJobUpdate((msg.payload || {}).instanceId, (msg.payload || {}).key, msg.payload || {});
if (job) broadcast({ type: "llm_provision_progress", payload: job });
} else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") { } else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") {
// Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen. // Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen.
broadcast({ type: msg.type, payload: msg.payload || {} }); broadcast({ type: msg.type, payload: msg.payload || {} });
@@ -2074,6 +2084,27 @@ setInterval(checkDiskSpace, 30000);
// ── HTTP Server + WebSocket fuer Browser ──────────────── // ── HTTP Server + WebSocket fuer Browser ────────────────
// ── Modell-Downloads auf LLM-Boxen ──────────────────────────────────
// Letzter bekannter Stand pro (Box, Modell). Im Speicher reicht: das Fenster
// soll zeigen, was gerade laeuft und was eben fertig wurde, keine Historie.
const llmJobs = new Map();
function llmJobUpdate(instanceId, key, patch) {
if (!instanceId || !key) return null;
const id = `${instanceId}\u0000${key}`;
const job = { ...(llmJobs.get(id) || { instanceId, key, startedAt: Date.now() }), ...patch,
instanceId, key, updatedAt: Date.now() };
// Ein neuer Auftrag fuer dasselbe Modell faengt frisch an.
if (patch.phase === "queued") { delete job.error; delete job.percent; delete job.bytes; job.startedAt = Date.now(); }
llmJobs.set(id, job);
// Alte Abgeschlossene nicht ewig mitschleppen.
if (llmJobs.size > 30) {
const done = [...llmJobs.entries()].filter(([, j]) => j.phase === "ready" || j.phase === "error")
.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
while (llmJobs.size > 30 && done.length) llmJobs.delete(done.shift()[0]);
}
return job;
}
const htmlPath = path.join(__dirname, "index.html"); const htmlPath = path.join(__dirname, "index.html");
const mailHtmlPath = path.join(__dirname, "email.html"); const mailHtmlPath = path.join(__dirname, "email.html");
@@ -3320,8 +3351,19 @@ wss.on("connection", (ws) => {
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory // Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab). // laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
// restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert. // restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
} else if (msg.action === "get_llm_jobs") {
ws.send(JSON.stringify({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } }));
} else if (msg.action === "clear_llm_jobs") {
// Nur Abgeschlossene wegraeumen — laufende Downloads bleiben sichtbar.
for (const [k, j] of llmJobs) if (j.phase === "ready" || j.phase === "error") llmJobs.delete(k);
broadcast({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } });
} else if (msg.action === "llm_provision_model") { } else if (msg.action === "llm_provision_model") {
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D). // Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
{ phase: "queued", requestedAt: Date.now() });
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
sendToRVS_raw({ type: "llm_provision_model", payload: { sendToRVS_raw({ type: "llm_provision_model", payload: {
targetInstance: msg.targetInstance || "", targetInstance: msg.targetInstance || "",
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl, key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,
+6 -1
View File
@@ -124,6 +124,11 @@ const ALLOWED_TYPES = new Set([
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt // OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
// immer aus der .env der Box — er ist ein Docker-Port-Mapping). // immer aus der .env der Box — er ist ein Docker-Port-Mapping).
"llm_openai_config", "llm_openai_config_result", "llm_openai_config", "llm_openai_config_result",
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
"llm_provision_model", "llm_provision_result", "llm_provision_progress",
"llm_remove_model",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop", "node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history", "node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done", "node_stats_reset", "node_stats_reset_done",
@@ -161,7 +166,7 @@ const pendingReplies = new Map();
const PENDING_TTL_MS = 180_000; const PENDING_TTL_MS = 180_000;
function pendingKey(replyType, requestId) { function pendingKey(replyType, requestId) {
return `${replyType}${requestId}`; return `${replyType}\u0000${requestId}`;
} }
/** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */ /** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */
+5
View File
@@ -178,6 +178,11 @@ services:
- LLM_REGISTRY=/models/aria_models.json - LLM_REGISTRY=/models/aria_models.json
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig. # Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600} - LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
# Auf welche Karte llama-swap gepinnt ist — fuer die Meldung „in Karte N
# geladen" samt VRAM nach dem Laden. Dieselbe Variable wie bei llama-swap.
- LLM_GPU=${LLM_GPU:-0}
# Fuer gesperrte (gated) HF-Repos: die Dateiliste fuer den Fortschritt.
- HF_TOKEN=${HF_TOKEN:-}
restart: unless-stopped restart: unless-stopped
# ─── OpenHands (optional, Standalone) ──────────────── # ─── OpenHands (optional, Standalone) ────────────────
+157 -5
View File
@@ -238,7 +238,7 @@ async def _send(ws, mtype: str, payload: dict) -> None:
async def _call_llama(messages: list, *, max_tokens: int, temperature: float, async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop, tools=None, model=None) -> dict: stop, tools=None, model=None, timeout: float | None = None) -> dict:
"""Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt """Ruft llama.cpp/llama-swap /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, tool_calls, error} zurueck — wirft nie. {ok, content, tool_calls, error} zurueck — wirft nie.
@@ -263,7 +263,7 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
# weiter. Qwen3 unterdrueckt damit den <think>-Block. # weiter. Qwen3 unterdrueckt damit den <think>-Block.
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
try: try:
async with httpx.AsyncClient(timeout=LLM_TIMEOUT_SEC) as client: async with httpx.AsyncClient(timeout=timeout or LLM_TIMEOUT_SEC) as client:
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body) r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
@@ -454,6 +454,141 @@ async def _handle_openai_config(ws, payload: dict) -> None:
await _announce(ws) await _announce(ws)
# ── Download-Fortschritt beim Modell-Laden ─────────────────────────────
# llama.cpp zieht das GGUF per -hf selbst und meldet keinen Fortschritt. Wir
# messen von aussen: Hugging Face sagt vorab, welche Dateien kommen (Name,
# Groesse, sha256). llama.cpp legt sie im HF-Cache-Layout ab —
# /models/models--org--repo/blobs/<sha256>.downloadInProgress (waechst)
# /models/models--org--repo/blobs/<sha256> (fertig)
# Genau diese Dateien werden gemessen. Das klappt auch bei gesplitteten
# Modellen und bei zwei Downloads gleichzeitig. Fallback fuer aeltere
# llama.cpp-Layouts: alle *.downloadInProgress unter /models.
MODELS_DIR = os.getenv("LLAMA_CACHE_DIR", "/models")
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
LLM_GPU = os.getenv("LLM_GPU", "").strip() # Karte, auf die llama-swap gepinnt ist
WARMUP_TIMEOUT_S = float(os.getenv("LLM_WARMUP_TIMEOUT_S", "1900")) # > healthCheckTimeout
_INPROGRESS = ".downloadInProgress"
_SPLIT_RE = re.compile(r"-(\d{5})-of-(\d{5})\.gguf$", re.IGNORECASE)
async def _hf_expected_files(repo: str, quant: str) -> list:
"""Welche GGUF-Dateien zieht 'repo:quant'? [{path, oid, size}]. Fail-soft: []."""
url = f"https://huggingface.co/api/models/{repo}/tree/main?recursive=1"
headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url, headers=headers)
r.raise_for_status()
entries = r.json()
except Exception as e:
logger.warning("HF-Dateiliste fuer %s nicht abrufbar (%s) — Fortschritt ohne Gesamtgroesse", repo, e)
return []
q = (quant or "").lower()
files = []
for e in entries if isinstance(entries, list) else []:
path = e.get("path") or ""
low = path.lower()
if e.get("type") != "file" or not low.endswith(".gguf") or "mmproj" in low:
continue
if q and q not in low:
continue
lfs = e.get("lfs") or {}
files.append({"path": path, "oid": lfs.get("oid") or e.get("oid") or "",
"size": int(lfs.get("size") or e.get("size") or 0)})
if not files:
return []
# Gesplittetes Modell → alle Teile; sonst die eine passende Datei (bei
# mehreren Treffern die mit dem kuerzesten Namen, z.B. ohne "-imat").
split = [f for f in files if _SPLIT_RE.search(f["path"])]
if split:
return split
return [min(files, key=lambda f: len(f["path"]))]
def _scan_download(oids: set) -> tuple:
"""(bytes_da, laeuft_noch, gefunden) fuer die erwarteten Blobs."""
done, active, found = 0, False, 0
fallback, fallback_active = 0, False
for root, _dirs, fns in os.walk(MODELS_DIR):
for fn in fns:
inprog = fn.endswith(_INPROGRESS)
base = fn[:-len(_INPROGRESS)] if inprog else fn
try:
size = os.path.getsize(os.path.join(root, fn))
except OSError:
continue
if base in oids:
done += size
found += 1
active |= inprog
elif inprog:
fallback += size
fallback_active = True
if not found and fallback_active:
return fallback, True, 0
return done, active, found
async def _gpu_info() -> dict:
"""Name + VRAM der LLM-Karte nach dem Laden (fuer 'in Karte N geladen')."""
if not LLM_GPU:
return {}
try:
proc = await asyncio.create_subprocess_exec(
"nvidia-smi", f"--id={LLM_GPU}",
"--query-gpu=index,name,memory.used,memory.total", "--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
out, _ = await asyncio.wait_for(proc.communicate(), 10)
idx, name, used, total = [x.strip() for x in out.decode().strip().split(",")[:4]]
return {"gpu": idx, "gpuName": name, "vramUsedMB": int(used), "vramTotalMB": int(total)}
except Exception as e:
logger.info("GPU-Info nicht verfuegbar: %s", e)
return {"gpu": LLM_GPU}
async def _emit_progress(ws, key: str, phase: str, **extra) -> None:
await _send(ws, "llm_provision_progress",
{"instanceId": INSTANCE_ID, "node": NODE_NAME, "key": key,
"phase": phase, "ts": int(time.time() * 1000), **extra})
async def _watch_download(ws, key: str, files: list, stop: asyncio.Event) -> None:
"""Meldet jede Sekunde den Download-Stand, bis der Warmup fertig ist.
Ist alles da, wechselt die Phase auf 'load' (llama.cpp laedt in die Karte)."""
oids = {f["oid"] for f in files if f.get("oid")}
total = sum(f["size"] for f in files)
phase = ""
downloaded = False # gab es einen echten Download? sonst: lag schon im Cache
last_b, last_t, speed = None, time.time(), 0.0
while not stop.is_set():
b, active, found = await asyncio.to_thread(_scan_download, oids)
now = time.time()
if last_b is not None and now > last_t and b >= last_b:
inst = (b - last_b) / (now - last_t)
speed = inst if speed == 0 else 0.7 * speed + 0.3 * inst
last_b, last_t = b, now
if active:
downloaded = True
phase = "download"
pct = round(min(100.0, 100.0 * b / total), 1) if total else None
eta = int(max(0, total - b) / speed) if total and speed > 1 else None
await _emit_progress(ws, key, "download", bytes=b, total=total, percent=pct,
speedBps=int(speed), etaSeconds=eta)
else:
# Nichts laeuft mehr: entweder fertig geladen (alle Blobs da) oder
# es lag schon im Cache. Im Notfallmodus (keine HF-Liste) zaehlt
# nur: es lief ein Download, und jetzt nicht mehr.
all_there = bool(oids) and found >= len(oids)
if phase != "load" and (all_there or downloaded):
phase = "load"
await _emit_progress(ws, key, "load", bytes=total or b, total=total,
percent=100, cached=not downloaded)
try:
await asyncio.wait_for(stop.wait(), 1.0)
except asyncio.TimeoutError:
pass
async def _handle_provision(ws, payload: dict) -> None: async def _handle_provision(ws, payload: dict) -> None:
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup """Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste.""" (zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
@@ -475,19 +610,36 @@ async def _handle_provision(ws, payload: dict) -> None:
_generate_config() _generate_config()
await _reload_llama() await _reload_llama()
await _announce(ws) # Bridge/Diagnostic lernen das neue Modell await _announce(ws) # Bridge/Diagnostic lernen das neue Modell
# Warmup: Mini-Request → llama-swap laedt/zieht das Modell (Fortschritt via # Warmup: Mini-Request → llama-swap zieht das GGUF (falls noch nicht da)
# service_status loading→ready, freshlyDownloaded). # und laedt es in die Karte. Daneben misst _watch_download den Download.
files = await _hf_expected_files(repo, entry["quant"])
await _emit_progress(ws, key, "start", total=sum(f["size"] for f in files),
files=len(files), gpu=LLM_GPU)
await _emit_llm_status(ws, "loading", key) await _emit_llm_status(ws, "loading", key)
stop = asyncio.Event()
watcher = asyncio.create_task(_watch_download(ws, key, files, stop))
t0 = time.time() t0 = time.time()
# Langer Timeout: ein GGUF mit mehreren GB braucht Minuten. Mit dem normalen
# LLM_TIMEOUT_SEC (60 s) meldete das hier "Fehler", waehrend llama-swap im
# Hintergrund munter weiterlud.
res = await _call_llama([{"role": "user", "content": "hi"}], res = await _call_llama([{"role": "user", "content": "hi"}],
max_tokens=1, temperature=0.0, stop=None, model=key) max_tokens=1, temperature=0.0, stop=None, model=key,
timeout=WARMUP_TIMEOUT_S)
dt = time.time() - t0 dt = time.time() - t0
stop.set()
try:
await watcher
except Exception:
pass
if res.get("ok"): if res.get("ok"):
_ready_models.add(key) _ready_models.add(key)
await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1), await _emit_llm_status(ws, "ready", key, loadSeconds=round(dt, 1),
freshlyDownloaded=dt > 25) freshlyDownloaded=dt > 25)
await _emit_progress(ws, key, "ready", percent=100, loadSeconds=round(dt, 1),
**(await _gpu_info()))
else: else:
await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160]) await _emit_llm_status(ws, "error", key, error=(res.get("error") or "")[:160])
await _emit_progress(ws, key, "error", error=(res.get("error") or "")[:200])
await _send(ws, "llm_provision_result", await _send(ws, "llm_provision_result",
{"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False), {"instanceId": INSTANCE_ID, "key": key, "ok": res.get("ok", False),
"error": res.get("error"), "elapsedMs": int(dt * 1000)}) "error": res.get("error"), "elapsedMs": int(dt * 1000)})