fix(phase-a): Kontext-Cap, Download-State, Recipe-Edit, OS-Badge, Swap-Flash

A1: 32768-Kontext-Cap entfernt (install-recipe, install-model, register) →
    max_ctx_for() liefert nun bis zu 128k auf Strix Halo; behebt "context
    size exceeded" bei externen Tools.
A2: Download-State jetzt im Status-Endpoint sichtbar: Modelle zeigen
    "↓ Download X%" statt "bereit" während Job läuft (Backend + Frontend).
A3: PUT /api/cookbook/user-recipe/{id} + Edit-Button (✎) für eigene Setups.
    Download-Modal setzt Kontext-Input automatisch auf optimal.
A4: /api/updates liefert apt_cache_age_h; Badge zeigt Tooltip + ⚠ wenn >24h.
A5: Swap-Flash: Topbar-Text pulst kurz teal wenn Modell den State wechselt.
A6: LLM-Engine-Update fragt jetzt per confirmModal nach (Konsistenz).
A7: Event-Delegation statt per-render addEventListener in models.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 21:33:03 +02:00
parent 022c42dccb
commit 82d15d82db
10 changed files with 214 additions and 45 deletions
+45 -23
View File
@@ -3,6 +3,8 @@
import { api } from "../core/api.js";
import { $, badge, esc, toast, confirmModal, infoDot } from "../core/ui.js";
let JOBS_CACHE = [];
const CTX_HELP = "Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr (längere Dateien/Chats), braucht aber mehr Speicher und wird etwas langsamer.";
const ROLE_HELP = "Die Rolle ist ein zusätzlicher, sprechender Name (z.B. coder). Du kannst in deinen Tools entweder den echten Modellnamen ODER die Rolle angeben — beides führt zum selben Modell.";
@@ -18,6 +20,11 @@ const ROLES = [
let ALL = [];
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
// Badge mit Download-Fortschritt aus Backend-State (download_progress vom status endpoint)
function stateBadge(m) {
return badge(m.state, m.download_progress);
}
function roleTag(role) {
return role ? `<span class="tag" style="background:rgba(45,212,191,.14);color:var(--accent);border-color:rgba(45,212,191,.3)">${esc(role)}</span>` : "";
}
@@ -26,7 +33,7 @@ function apiIdLine(m) {
const ids = (m.api_ids && m.api_ids.length ? m.api_ids : [m.name]);
return `<div class="li-sub mono-sm" style="margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span class="text-mut">API-Name:</span>
${ids.map(id => `<code style="cursor:pointer" data-copy="${esc(id)}" title="Klicken zum Kopieren">${esc(id)}</code>`).join('<span class="text-mut">·</span>')}
${ids.map(id => `<code style="cursor:pointer" data-action="copy" data-name="${esc(id)}" title="Klicken zum Kopieren">${esc(id)}</code>`).join('<span class="text-mut">·</span>')}
</div>`;
}
@@ -113,6 +120,23 @@ function mount() {
$("#role-modal").addEventListener("click", e => { if (e.target.id === "role-modal") $("#role-modal").style.display = "none"; });
$("#role-save").addEventListener("click", () => saveRole($("#role-custom").value.trim().toLowerCase()));
$("#role-clear").addEventListener("click", () => saveRole(""));
// Event-Delegation: ein einziger Listener für alle Tabellen-Aktionen
$("#m-table").addEventListener("click", e => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const name = btn.getAttribute("data-name"), action = btn.getAttribute("data-action");
if (!name) return;
if (action === "cfg") openConfig(name);
else if (action === "role") openRole(name);
else if (action === "unload") unloadOne(name);
else if (action === "del") deleteModel(name);
else if (action === "copy") { navigator.clipboard?.writeText(name); toast("Kopiert: " + name); }
else if (action === "assign") {
toast("Lade im Cookbook ein Modell und setze den Alias auf: " + name);
document.querySelector(".nav-item[data-view='cookbook']")?.click();
}
});
}
let roleTarget = null;
@@ -137,8 +161,7 @@ async function saveRole(role) {
} catch (e) { toast(e.message, true); }
}
function onStatus(s) {
ALL = s?.models || [];
function renderTable() {
const tb = $("#models"); if (!tb) return;
$("#models-empty").style.display = ALL.length ? "none" : "flex";
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
@@ -146,7 +169,6 @@ function onStatus(s) {
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
tb.innerHTML = ALL.map(m => {
if (m.incomplete) {
// Leere Rolle (Platzhalter ohne Modell-Datei) — ehrlich kennzeichnen statt „bereit".
return `<tr style="opacity:.9">
<td class="mid" style="font-weight:500">${esc(m.name)}<div class="li-sub mono-sm" style="color:var(--warn)">kein Modell hinterlegt</div></td>
<td>—</td>
@@ -154,8 +176,8 @@ function onStatus(s) {
<td><span class="badge">leer</span></td>
<td class="port">—</td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" data-assign="${esc(m.name)}">Modell zuweisen</button>
<button class="ghost del" data-del="${esc(m.name)}">Löschen</button>
<button class="ghost" data-action="assign" data-name="${esc(m.name)}">Modell zuweisen</button>
<button class="ghost del" data-action="del" data-name="${esc(m.name)}">Löschen</button>
</td></tr>`;
}
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
@@ -165,29 +187,29 @@ function onStatus(s) {
${fn}${apiIdLine(m)}</td>
<td>${capTags(m.meta?.caps)}</td>
<td>${details(m.meta)}</td>
<td>${badge(m.state)}</td>
<td>${stateBadge(m)}</td>
<td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" data-role="${esc(m.name)}">Rolle</button>
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
<button class="ghost del" data-del="${esc(m.name)}">Löschen</button>
<button class="ghost" data-action="role" data-name="${esc(m.name)}">Rolle</button>
<button class="ghost" data-action="cfg" data-name="${esc(m.name)}">Konfigurieren</button>
<button class="ghost" data-action="unload" data-name="${esc(m.name)}">Entladen</button>
<button class="ghost del" data-action="del" data-name="${esc(m.name)}">Löschen</button>
</td></tr>`;
}).join("");
for (const m of ALL) if (!m.incomplete) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
if (cur) sel.value = cur;
}
tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))));
tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg"))));
tb.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteModel(b.getAttribute("data-del"))));
tb.querySelectorAll("[data-role]").forEach(b => b.addEventListener("click", () => openRole(b.getAttribute("data-role"))));
tb.querySelectorAll("[data-copy]").forEach(c => c.addEventListener("click", () => {
navigator.clipboard?.writeText(c.getAttribute("data-copy")); toast("Kopiert: " + c.getAttribute("data-copy"));
}));
tb.querySelectorAll("[data-assign]").forEach(b => b.addEventListener("click", () => {
toast("Lade im Cookbook ein Modell und setze den Alias auf: " + b.getAttribute("data-assign"));
document.querySelector(".nav-item[data-view='cookbook']")?.click();
}));
function onStatus(s) {
ALL = s?.models || [];
renderTable();
}
function onJobs(jobs) {
JOBS_CACHE = jobs || [];
// Nur neu rendern wenn sich der Download-Zustand geändert hat (minimiert DOM-Flicker)
const hasDownload = JOBS_CACHE.some(j => (j.state === "running" || j.state === "queued") && /^download\b/i.test(j.label));
if (hasDownload) renderTable();
}
async function deleteModel(m) {
@@ -243,4 +265,4 @@ async function sendChat() {
btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
}
export default { id: "models", mount, onStatus };
export default { id: "models", mount, onStatus, onJobs };