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>
This commit is contained in:
2026-10-31 15:21:04 +01:00
co-authored by Claude Opus 5.5
parent 739ab85deb
commit 87f521ee61
3 changed files with 304 additions and 13 deletions
+31 -4
View File
@@ -2731,7 +2731,8 @@
const p = msg.payload || {};
const st = document.getElementById('llm-catalog-status');
if (st) {
if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
if (p.deleted) { st.textContent = `✓ '${p.key}' von der Box gelöscht` + (p.freedBytes ? ` (${_fmtBytes(p.freedBytes)} frei geworden)` : ''); st.style.color = '#8888AA'; }
else if (p.removed) { st.textContent = `✓ '${p.key}' entfernt`; st.style.color = '#8888AA'; }
else if (p.unloaded) { st.textContent = `✓ Karte ${p.gpu} geleert (${p.key})`; st.style.color = '#8888AA'; }
else if (p.ok) { st.textContent = `✓ '${p.key}' geladen/aktiv auf ${p.instanceId || '?'}`; st.style.color = '#3FFF3F'; }
else { st.textContent = `✗ '${p.key}': ${p.error || 'Fehler'}`; st.style.color = '#FF6B6B'; }
@@ -9301,7 +9302,14 @@
const sl = (b.slots || []).find(x => x.model === m.id);
return escapeHtml(b.node) + (sl ? ' · Karte ' + escapeHtml(sl.gpu) : '');
};
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` : '<span style="color:#6a6a88;">nicht geladen</span>';
// Liegt nur auf der Platte (keine Karte)? → Groesse + Loeschen
const stored = boxes.filter(b => !have.includes(b))
.map(b => ({ b, d: ((b.disk && b.disk.models) || []).find(x => x.repo === m.hfRepo) })).filter(x => x.d);
const storedTxt = stored.map(({ b, d }) => `<div style="color:#8888AA;">💾 auf ${escapeHtml(b.node)} · ${_fmtBytes(d.bytes)}
<button class="btn secondary" onclick="deleteLlmFiles('${escapeHtmlAttr(b.instanceId)}', ${i})" title="Heruntergeladene Datei von der Box löschen (gibt Platz frei)"
style="padding:1px 6px;font-size:10px;margin-left:4px;">Löschen</button></div>`).join('');
const haveTxt = have.length ? `<span style="color:#3FFF3F;">✓ ${have.map(where).join(', ')}</span>` + storedTxt
: (storedTxt || '<span style="color:#6a6a88;">nicht geladen</span>');
const vram = m.sizeGB ? '~' + m.sizeGB + ' GB' : '—';
const vramStyle = m.sizeGB >= 12 ? 'color:#FFB020;' : 'color:#AAB;';
// Ziel = Karte. Vorauswahl: eine leere Karte, sonst die erste.
@@ -9323,7 +9331,9 @@
'<td ' + td + '"><button class="btn secondary" ' + (boxes.length ? '' : 'disabled') + ' onclick="provisionModel(' + i + ')" style="padding:3px 10px;font-size:11px;">Laden</button></td>' +
'</tr>';
}).join('');
box.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
const diskLine = boxes.filter(b => b.disk).map(b => `${escapeHtml(b.node)}: ${_fmtBytes(b.disk.freeBytes)} frei`).join(' · ');
box.innerHTML = (diskLine ? '<div style="color:#6a6a88;font-size:11px;margin:0 0 6px;">💾 Speicherplatz — ' + diskLine + '</div>' : '') +
'<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:760px;">' +
'<thead><tr style="color:#8888AA;border-bottom:1px solid #2A2A3E;">' +
'<th ' + th + '>Modell</th><th ' + th + '>Kann gut / Beschreibung</th><th ' + th + '>VRAM</th>' +
'<th ' + th + '>Status</th><th ' + th + '>Ziel-Karte</th><th ' + th + '></th>' +
@@ -9411,7 +9421,9 @@
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>
<span style="color:#8888AA;font-size:11px;">→ ${box_}
${['ready', 'error'].includes(j.phase) ? '' : `<button class="btn secondary" onclick="cancelLlmJob('${escapeHtmlAttr(j.instanceId)}','${escapeHtmlAttr(j.key)}')"
style="padding:1px 8px;font-size:10px;margin-left:8px;">Abbrechen</button>`}</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>` : ''}
@@ -9862,6 +9874,21 @@
});
}
function deleteLlmFiles(instanceId, i) {
const m = _llmCatalog[i];
if (!m) return;
const b = onlineLlmBoxes().find(w => w.instanceId === instanceId);
const d = ((b && b.disk && b.disk.models) || []).find(x => x.repo === m.hfRepo);
if (!confirm(`${m.id} von ${(b && b.node) || instanceId} löschen?\n\n`
+ `Die heruntergeladene Datei (${d ? _fmtBytes(d.bytes) : '?'}, Repo ${m.hfRepo}) wird von der Box entfernt. `
+ `Wer es wieder will, muss es neu herunterladen.`)) return;
send({ action: 'llm_remove_model', targetInstance: instanceId, key: m.id, hfRepo: m.hfRepo, deleteFiles: true });
}
function cancelLlmJob(instanceId, key) {
if (!confirm(`Laden von ${key} abbrechen?\n\nDer halbe Download wird gelöscht, die Karte bleibt bzw. wird wie vorher.`)) return;
send({ action: 'llm_remove_model', targetInstance: instanceId, key, cancel: true });
}
async function provisionModel(i) {
const m = _llmCatalog[i];
if (!m) return;