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:
@@ -118,7 +118,7 @@ function mount() {
|
||||
<div id="cb-new-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<button id="cb-new-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3>Eigenes Setup erstellen</h3>
|
||||
<h3 id="cb-new-modal-title">Eigenes Setup erstellen</h3>
|
||||
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen — es erscheint danach mit Hardware-Ampel hier im Cookbook.</p>
|
||||
<label>Titel</label>
|
||||
<input id="cb-new-title" placeholder="z.B. Mein Schreib-Stack">
|
||||
@@ -270,8 +270,11 @@ function renderRecipes() {
|
||||
const best = r.id === RECOMMENDED;
|
||||
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`
|
||||
: r.user ? `<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>` : "";
|
||||
const del = r.user ? `<span class="cb-delr" data-delr="${esc(r.id)}" title="Setup löschen"
|
||||
style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1">×</span>` : "";
|
||||
const del = r.user ? `
|
||||
<span class="cb-editr" data-editr="${esc(r.id)}" title="Setup bearbeiten"
|
||||
style="position:absolute;top:10px;right:36px;color:var(--accent);cursor:pointer;font-size:14px;line-height:1">✎</span>
|
||||
<span class="cb-delr" data-delr="${esc(r.id)}" title="Setup löschen"
|
||||
style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1">×</span>` : "";
|
||||
return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}" style="position:relative">
|
||||
${del}${tag}
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -287,6 +290,8 @@ function renderRecipes() {
|
||||
b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe"))));
|
||||
$("#cb-recipes").querySelectorAll("[data-delr]").forEach(x =>
|
||||
x.addEventListener("click", e => { e.stopPropagation(); deleteRecipe(x.getAttribute("data-delr")); }));
|
||||
$("#cb-recipes").querySelectorAll("[data-editr]").forEach(x =>
|
||||
x.addEventListener("click", e => { e.stopPropagation(); openEditRecipe(x.getAttribute("data-editr")); }));
|
||||
}
|
||||
|
||||
// ---- Eigenes Setup erstellen/löschen ----
|
||||
@@ -301,10 +306,35 @@ function newModelRow() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let editRecipeId = null;
|
||||
|
||||
function openNewRecipe() {
|
||||
editRecipeId = null;
|
||||
$("#cb-new-title").value = "";
|
||||
$("#cb-new-desc").value = "";
|
||||
$("#cb-new-models").innerHTML = newModelRow();
|
||||
$("#cb-new-modal-title").textContent = "Eigenes Setup erstellen";
|
||||
$("#cb-new-save").textContent = "Setup speichern";
|
||||
$("#cb-new-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
function openEditRecipe(id) {
|
||||
const r = RECIPES.find(x => x.id === id); if (!r) return;
|
||||
editRecipeId = id;
|
||||
$("#cb-new-title").value = r.title;
|
||||
$("#cb-new-desc").value = r.desc || "";
|
||||
$("#cb-new-models").innerHTML = r.models.map(m => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = newModelRow();
|
||||
const el = row.firstElementChild;
|
||||
el.querySelector(".cb-nm-repo").value = m.repo;
|
||||
el.querySelector(".cb-nm-role").value = m.role;
|
||||
const quant = el.querySelector(".cb-nm-quant");
|
||||
if (quant) [...quant.options].forEach(o => { if (o.value === m.quant) o.selected = true; });
|
||||
return el.outerHTML;
|
||||
}).join("");
|
||||
$("#cb-new-modal-title").textContent = "Setup bearbeiten";
|
||||
$("#cb-new-save").textContent = "Änderungen speichern";
|
||||
$("#cb-new-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
@@ -319,13 +349,19 @@ async function saveNewRecipe() {
|
||||
if (!models.length) return toast("Mindestens ein Modell (Repo) angeben.", true);
|
||||
const btn = $("#cb-new-save"); btn.disabled = true; btn.textContent = "Speichere…";
|
||||
try {
|
||||
await api("/api/cookbook/user-recipe", { method: "POST",
|
||||
body: JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models }) });
|
||||
toast("Setup gespeichert.");
|
||||
const body = JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models });
|
||||
if (editRecipeId) {
|
||||
await api("/api/cookbook/user-recipe/" + encodeURIComponent(editRecipeId), { method: "PUT", body });
|
||||
toast("Setup aktualisiert.");
|
||||
} else {
|
||||
await api("/api/cookbook/user-recipe", { method: "POST", body });
|
||||
toast("Setup gespeichert.");
|
||||
}
|
||||
editRecipeId = null;
|
||||
$("#cb-new-modal").style.display = "none";
|
||||
loadRecipes();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = "Setup speichern";
|
||||
btn.disabled = false; btn.textContent = editRecipeId ? "Änderungen speichern" : "Setup speichern";
|
||||
}
|
||||
|
||||
async function deleteRecipe(id) {
|
||||
@@ -482,6 +518,11 @@ function showFit() {
|
||||
$("#cb-m-fit-text").innerHTML = `Bedarf: <b>~${f.fit.req_gb.toFixed(1)} GB</b> · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
|
||||
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
|
||||
const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec");
|
||||
if (opt) {
|
||||
// Kontext-Input auto auf optimal setzen, wenn noch auf Default (8192)
|
||||
const ctxInput = $("#cb-m-ctx");
|
||||
if (ctxInput && (ctxInput.value === "8192" || ctxInput.value === "")) ctxInput.value = opt;
|
||||
}
|
||||
if (recEl) recEl.innerHTML = opt
|
||||
? `Empfohlener Kontext für deine Hardware: <b>~${Math.round(opt / 1024)}k</b> — <a href="#" onclick="event.preventDefault();window.cbSetCtx(${opt})">übernehmen</a>` : "";
|
||||
const btn = $("#cb-m-download");
|
||||
|
||||
+45
-23
@@ -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 };
|
||||
|
||||
@@ -116,14 +116,21 @@ function capTag(caps) {
|
||||
return `<span class="tag text">Text</span>`;
|
||||
}
|
||||
function stackRow(m) {
|
||||
const downloading = m.state === "downloading";
|
||||
const on = RUNNING.has(m.state);
|
||||
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
|
||||
const status = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
|
||||
const dot = (m.state === "loading" || m.state === "starting" || downloading) ? "load" : on ? "on" : "";
|
||||
let status;
|
||||
if (downloading) {
|
||||
const pct = m.download_progress != null ? ` ${Math.round(m.download_progress)}%` : "";
|
||||
status = `↓ Download${pct}`;
|
||||
} else {
|
||||
status = on ? (m.state === "loading" ? "lädt…" : "geladen") : "bereit";
|
||||
}
|
||||
const file = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
|
||||
return `<div class="li"><span class="li-dot ${dot}"></span>
|
||||
<div class="li-main">
|
||||
<div class="flex items-center gap-2"><span class="li-id">${esc(m.name)}</span>${capTag(m.meta?.caps)}</div>${file}</div>
|
||||
<span class="li-meta" style="white-space:nowrap">${status}</span></div>`;
|
||||
<span class="li-meta" style="white-space:nowrap">${esc(status)}</span></div>`;
|
||||
}
|
||||
|
||||
async function renderNews() {
|
||||
|
||||
@@ -218,6 +218,7 @@ function onJobs(jobs) {
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (!await confirmModal({ title: "LLM-Engine aktualisieren?", body: "Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar." })) return;
|
||||
try { const r = await api("/api/update", { method: "POST" }); toast("LLM-Engine-Update läuft."); track(r.job_id); watchJob(r.job_id, "LLM-Engine-Update"); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user