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
+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);
z-index:1000; justify-content:center; align-items:center; }
.modal-overlay.open { display:flex; }
@keyframes llmdlpulse { 0%,100% { opacity:.15; } 50% { opacity:.45; } }
.modal-box { background:#0D0D1A; border:1px solid #FFD60A44; border-radius:10px; width:90vw; max-width:860px;
max-height:90vh; display:flex; flex-direction:column; overflow:hidden; }
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 14px;
@@ -843,6 +844,7 @@
<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px;">
<h2 style="margin:0;">Modell-Katalog</h2>
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
</div>
<div class="card" style="max-width:1100px;">
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
@@ -1859,6 +1861,25 @@
</div>
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
<!-- Modell-Downloads auf LLM-Boxen: Fortschritt, dann „in die Karte geladen" -->
<div class="modal-overlay" id="llm-dl-modal" onclick="if(event.target===this)closeLlmDownloads()">
<div class="modal-box" style="max-width:720px;">
<div class="modal-header">
<h3>⬇ Modell-Downloads</h3>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="send({action:'clear_llm_jobs'})" title="Fertige und fehlgeschlagene aus der Liste nehmen" style="padding:3px 10px;font-size:11px;">Erledigte entfernen</button>
<button class="modal-close" onclick="closeLlmDownloads()">&times;</button>
</div>
</div>
<div id="llm-dl-list" style="padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;"></div>
<div style="padding:0 14px 12px;color:#555570;font-size:11px;line-height:1.5;">
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-box" style="max-width:640px;">
<div class="modal-header">
@@ -2313,6 +2334,8 @@
try { loadModelList(); } catch {}
// Sicherheitsfilter-Vorfaelle (Blockaden + Quarantaene)
try { loadSafeguard(); } catch {}
// Laufende Modell-Downloads (Badge am ⬇-Knopf)
send({ action: 'get_llm_jobs' });
// Lokales-LLM: erst Modell-Liste (Dropdown), dann Config (Auswahl setzen)
try { loadLocalModelList().then(() => loadLocalLlmConfig()); } catch {}
};
@@ -2574,6 +2597,13 @@
}
return;
}
if (msg.type === 'llm_provision_progress') { onLlmJob(msg.payload); return; }
if (msg.type === 'llm_jobs') {
_llmJobs = {};
for (const j of ((msg.payload || {}).jobs || [])) _llmJobs[_llmJobId(j)] = j;
renderLlmDownloads();
return;
}
if (msg.type === 'llm_provision_result') {
const p = msg.payload || {};
const st = document.getElementById('llm-catalog-status');
@@ -8942,6 +8972,105 @@
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Box</th><th ' + th + '></th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}
// ── Download-Fenster ──────────────────────────────────────────────
// Zustand kommt vom Diagnostic-Server (llm_provision_progress / llm_jobs);
// der kennt auch Downloads, die vor dem Oeffnen dieses Tabs gestartet sind.
let _llmJobs = {};
const _llmJobId = j => j.instanceId + '|' + j.key;
function _fmtBytes(b) {
if (!b && b !== 0) return '?';
if (b >= 1e9) return (b / 1e9).toFixed(2) + ' GB';
if (b >= 1e6) return (b / 1e6).toFixed(0) + ' MB';
return Math.round(b / 1e3) + ' KB';
}
function _fmtEta(s) {
if (s == null) return '';
if (s < 60) return s + ' s';
return Math.floor(s / 60) + ' min ' + String(s % 60).padStart(2, '0') + ' s';
}
function _boxName(instanceId) {
const w = (typeof workers !== 'undefined' ? workers : []).find(x => x.instanceId === instanceId);
return (w && w.node) || (instanceId || '').replace(/^llm@/, '');
}
function onLlmJob(job) {
if (!job || !job.key) return;
_llmJobs[_llmJobId(job)] = job;
renderLlmDownloads();
// 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) {
const m = _llmCatalog[i];
if (!m) return;
@@ -8951,6 +9080,7 @@
const st = document.getElementById('llm-catalog-status');
if (st) { st.textContent = `Lade '${m.id}' auf ${target}… (GGUF-Download kann dauern)`; st.style.color = '#FFD60A'; }
send({ action: 'llm_provision_model', targetInstance: target, key: m.id, hfRepo: m.hfRepo, quant: m.quant, ctx: m.ctx });
openLlmDownloads();
}
function runLlmTest() {
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 });
} else if (msg.type === "llm_provision_result") {
// Ergebnis eines Modell-Downloads/Aktivierens → an Browser (Katalog-Status).
broadcast({ type: "llm_provision_result", payload: msg.payload || {} });
const p = msg.payload || {};
if (p.key && p.instanceId && !p.removed) {
const job = llmJobUpdate(p.instanceId, p.key, p.ok ? {} : { phase: "error", error: p.error || "Fehler" });
if (job) broadcast({ type: "llm_provision_progress", payload: job });
}
broadcast({ type: "llm_provision_result", payload: p });
} else if (msg.type === "llm_provision_progress") {
// Download-Fortschritt einer Box (Prozent, Tempo, Restzeit, dann
// "laedt in die Karte" und "geladen"). Merken fuer spaeter geoeffnete Tabs.
const job = llmJobUpdate((msg.payload || {}).instanceId, (msg.payload || {}).key, msg.payload || {});
if (job) broadcast({ type: "llm_provision_progress", payload: job });
} else if (msg.type === "node_stats" || msg.type === "node_stats_history" || msg.type === "node_stats_reset_done") {
// Auslastungs-Monitor (Stage E): Box-Antworten an die Browser durchreichen.
broadcast({ type: msg.type, payload: msg.payload || {} });
@@ -2074,6 +2084,27 @@ setInterval(checkDiskSpace, 30000);
// ── HTTP Server + WebSocket fuer Browser ────────────────
// ── Modell-Downloads auf LLM-Boxen ──────────────────────────────────
// Letzter bekannter Stand pro (Box, Modell). Im Speicher reicht: das Fenster
// soll zeigen, was gerade laeuft und was eben fertig wurde, keine Historie.
const llmJobs = new Map();
function llmJobUpdate(instanceId, key, patch) {
if (!instanceId || !key) return null;
const id = `${instanceId}\u0000${key}`;
const job = { ...(llmJobs.get(id) || { instanceId, key, startedAt: Date.now() }), ...patch,
instanceId, key, updatedAt: Date.now() };
// Ein neuer Auftrag fuer dasselbe Modell faengt frisch an.
if (patch.phase === "queued") { delete job.error; delete job.percent; delete job.bytes; job.startedAt = Date.now(); }
llmJobs.set(id, job);
// Alte Abgeschlossene nicht ewig mitschleppen.
if (llmJobs.size > 30) {
const done = [...llmJobs.entries()].filter(([, j]) => j.phase === "ready" || j.phase === "error")
.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
while (llmJobs.size > 30 && done.length) llmJobs.delete(done.shift()[0]);
}
return job;
}
const htmlPath = path.join(__dirname, "index.html");
const mailHtmlPath = path.join(__dirname, "email.html");
@@ -3320,8 +3351,19 @@ wss.on("connection", (ws) => {
// Sessions- und Brain-File-Viewer entfernt — Sessions sind raus, Memory
// laeuft jetzt komplett ueber die Vector-DB im aria-brain (siehe Gehirn-Tab).
// restart_session kommt weiter rein, weil der Watchdog ihn manchmal triggert.
} else if (msg.action === "get_llm_jobs") {
ws.send(JSON.stringify({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } }));
} else if (msg.action === "clear_llm_jobs") {
// Nur Abgeschlossene wegraeumen — laufende Downloads bleiben sichtbar.
for (const [k, j] of llmJobs) if (j.phase === "ready" || j.phase === "error") llmJobs.delete(k);
broadcast({ type: "llm_jobs", payload: { jobs: [...llmJobs.values()] } });
} else if (msg.action === "llm_provision_model") {
// Modell auf eine bestimmte LLM-Box laden/aktivieren (Stage D).
// Sofort als "angefragt" fuehren: kommt danach nie etwas von der Box,
// sieht man das im Download-Fenster, statt dass einfach nichts passiert.
const queued = llmJobUpdate(msg.targetInstance || "", msg.key || "",
{ phase: "queued", requestedAt: Date.now() });
if (queued) broadcast({ type: "llm_provision_progress", payload: queued });
sendToRVS_raw({ type: "llm_provision_model", payload: {
targetInstance: msg.targetInstance || "",
key: msg.key, hfRepo: msg.hfRepo, quant: msg.quant, ctx: msg.ctx, ngl: msg.ngl,