82d15d82db
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>
269 lines
14 KiB
JavaScript
269 lines
14 KiB
JavaScript
// models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
|
||
|
||
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.";
|
||
|
||
// Kanonische Rollen für die Schnellwahl (frei tippbar bleibt möglich).
|
||
const ROLES = [
|
||
{ id: "coder", label: "Coder", desc: "Programmieren & Code" },
|
||
{ id: "vision", label: "Vision", desc: "Bilder verstehen" },
|
||
{ id: "scout", label: "Scout", desc: "Schneller Allrounder" },
|
||
{ id: "reviewer", label: "Reviewer", desc: "Code/Texte prüfen" },
|
||
{ id: "manager", label: "Manager", desc: "Planen & koordinieren" },
|
||
];
|
||
|
||
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>` : "";
|
||
}
|
||
// Zeigt die API-Namen (echter Name + Rollen-Alias) mit Kopier-Knopf — das, was man im Tool angibt.
|
||
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-action="copy" data-name="${esc(id)}" title="Klicken zum Kopieren">${esc(id)}</code>`).join('<span class="text-mut">·</span>')}
|
||
</div>`;
|
||
}
|
||
|
||
function capTags(caps) {
|
||
if (!caps?.length) return "–";
|
||
return caps.map(c =>
|
||
c === "Code" ? `<span class="tag code">Code</span>`
|
||
: c === "Bild" ? `<span class="tag img">Bild</span>`
|
||
: `<span class="tag text">Text</span>`).join(" ");
|
||
}
|
||
function details(meta) {
|
||
if (!meta) return "–";
|
||
const q = meta.quant || "?";
|
||
const c = meta.ctx ? Math.round(meta.ctx / 1024) + "K" : "?";
|
||
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + " GB" : "?";
|
||
return `<span class="mono-sm">${esc(q)} · ${c} · ${s}</span>`;
|
||
}
|
||
|
||
function mount() {
|
||
$("#m-head").innerHTML = `<div class="pagehead"><div>
|
||
<h1>Modelle</h1>
|
||
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
|
||
|
||
$("#m-chat").innerHTML = `
|
||
<div class="card-h"><h3>Schnelltest</h3></div>
|
||
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</div>
|
||
<label>Modell</label>
|
||
<select id="chat-model"></select>
|
||
<label>Nachricht</label>
|
||
<textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></textarea>
|
||
<button class="primary" id="chat-btn">Senden</button>
|
||
<div id="chat-reply" class="reply" style="display:none"></div>`;
|
||
|
||
$("#m-table").innerHTML = `
|
||
<div class="card-h"><h3>Modelle & Ports</h3><span class="meta" id="m-count"></span></div>
|
||
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
|
||
<table>
|
||
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
|
||
<tbody id="models"></tbody>
|
||
</table>
|
||
<div id="models-empty" class="empty-c" style="display:none">
|
||
<div class="e-t">Noch keine Modelle konfiguriert</div>
|
||
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
|
||
</div>
|
||
|
||
<div id="cfg-modal" class="modal-overlay" style="display:none">
|
||
<div class="modal-card">
|
||
<button id="cfg-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||
<h3>Modell konfigurieren</h3>
|
||
<p class="mono-sm" id="cfg-model-name" style="margin:-4px 0 16px"></p>
|
||
<label>Kontext-Größe (Tokens) ${infoDot(CTX_HELP)}</label>
|
||
<input id="cfg-ctx" type="number" value="8192">
|
||
<div class="tile" id="cfg-rec" style="display:none;margin:8px 0 10px;justify-content:space-between;align-items:center" >
|
||
<div style="flex:1"><div style="font-size:13px">Empfohlen für deine Hardware</div>
|
||
<div class="hint" id="cfg-rec-note" style="margin:4px 0 0"></div></div>
|
||
<button class="ghost" id="cfg-rec-apply">Optimal übernehmen</button>
|
||
</div>
|
||
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
|
||
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="role-modal" class="modal-overlay" style="display:none">
|
||
<div class="modal-card">
|
||
<button id="role-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||
<h3>Rolle festlegen ${infoDot(ROLE_HELP)}</h3>
|
||
<p class="mono-sm" id="role-model-name" style="margin:-4px 0 16px"></p>
|
||
<div class="hint" style="margin-bottom:10px">Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach <b>beide</b>: der echte Modellname und die Rolle.</div>
|
||
<div id="role-chips" class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px"></div>
|
||
<label>Eigene Rolle (optional)</label>
|
||
<input id="role-custom" placeholder="z.B. uebersetzer">
|
||
<div class="btn-row" style="margin-top:14px;display:flex;gap:8px">
|
||
<button class="ghost" id="role-clear" style="flex:1">Rolle entfernen</button>
|
||
<button class="primary" id="role-save" style="flex:2">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
$("#chat-btn").addEventListener("click", sendChat);
|
||
$("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none");
|
||
$("#cfg-modal").addEventListener("click", e => { if (e.target.id === "cfg-modal") $("#cfg-modal").style.display = "none"; });
|
||
$("#cfg-save").addEventListener("click", saveConfig);
|
||
$("#role-close").addEventListener("click", () => $("#role-modal").style.display = "none");
|
||
$("#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;
|
||
function openRole(name) {
|
||
roleTarget = name;
|
||
const m = ALL.find(x => x.name === name);
|
||
$("#role-model-name").textContent = name;
|
||
$("#role-custom").value = "";
|
||
$("#role-chips").innerHTML = ROLES.map(r =>
|
||
`<button class="${m?.role === r.id ? "primary" : "ghost"}" data-rolepick="${r.id}" title="${esc(r.desc)}" style="border-radius:999px">${esc(r.label)}</button>`).join("");
|
||
$("#role-chips").querySelectorAll("[data-rolepick]").forEach(b =>
|
||
b.addEventListener("click", () => saveRole(b.getAttribute("data-rolepick"))));
|
||
$("#role-modal").style.display = "flex";
|
||
}
|
||
async function saveRole(role) {
|
||
if (!roleTarget) return;
|
||
try {
|
||
await api("/api/set_role", { method: "POST", body: JSON.stringify({ alias: roleTarget, role }) });
|
||
toast(role ? `Rolle gesetzt: ${role}` : "Rolle entfernt.");
|
||
$("#role-modal").style.display = "none";
|
||
refreshSoon();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
function renderTable() {
|
||
const tb = $("#models"); if (!tb) return;
|
||
$("#models-empty").style.display = ALL.length ? "none" : "flex";
|
||
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
|
||
|
||
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
|
||
tb.innerHTML = ALL.map(m => {
|
||
if (m.incomplete) {
|
||
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>
|
||
<td><span class="mono-sm text-mut">leere Rolle</span></td>
|
||
<td><span class="badge">leer</span></td>
|
||
<td class="port">—</td>
|
||
<td style="text-align:right;white-space:nowrap">
|
||
<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>` : "";
|
||
return `<tr>
|
||
<td class="mid">
|
||
<div style="font-weight:500;display:flex;align-items:center;gap:7px;flex-wrap:wrap">${esc(m.name)}${roleTag(m.role)}</div>
|
||
${fn}${apiIdLine(m)}</td>
|
||
<td>${capTags(m.meta?.caps)}</td>
|
||
<td>${details(m.meta)}</td>
|
||
<td>${stateBadge(m)}</td>
|
||
<td class="port">${m.port ?? "auto"}</td>
|
||
<td style="text-align:right;white-space:nowrap">
|
||
<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;
|
||
}
|
||
|
||
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) {
|
||
const ok = await confirmModal({
|
||
title: `„${m}" wirklich löschen?`,
|
||
body: "Das Modell wird aus der Konfiguration ausgetragen und seine Dateien werden von der Festplatte gelöscht, um Speicher freizugeben. <b>Das lässt sich nicht rückgängig machen</b> — später brauchst du dafür einen neuen Download.",
|
||
confirmLabel: "Endgültig löschen", danger: true });
|
||
if (!ok) return;
|
||
try {
|
||
const r = await api("/api/delete_model", { method: "POST", body: JSON.stringify({ alias: m }) });
|
||
toast(r.note || ("Gelöscht: " + m));
|
||
setTimeout(refreshSoon, 600);
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
function openConfig(alias) {
|
||
const m = ALL.find(x => x.name === alias); if (!m) return;
|
||
$("#cfg-model-name").textContent = m.name;
|
||
$("#cfg-ctx").value = m.meta?.ctx || 8192;
|
||
const opt = m.meta?.optimal_ctx, rec = $("#cfg-rec");
|
||
if (opt) {
|
||
const cur = m.meta?.ctx || 0, peak = m.meta?.peak_ram_gb, peakOpt = m.meta?.peak_ram_optimal_gb;
|
||
$("#cfg-rec-note").innerHTML =
|
||
`Optimal ~${Math.round(opt / 1024)}k → ~${peakOpt} GB Spitzenbedarf.<br>` +
|
||
`Aktuell ~${Math.round(cur / 1024)}k → ~${peak} GB.`;
|
||
$("#cfg-rec-apply").onclick = () => { $("#cfg-ctx").value = opt; };
|
||
rec.style.display = "flex";
|
||
} else { rec.style.display = "none"; }
|
||
$("#cfg-modal").style.display = "flex";
|
||
}
|
||
async function saveConfig() {
|
||
const alias = $("#cfg-model-name").textContent, ctx = parseInt($("#cfg-ctx").value) || 8192;
|
||
$("#cfg-save").disabled = true;
|
||
try { await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) }); toast("Gespeichert — aktiv beim nächsten Modell-Start."); $("#cfg-modal").style.display = "none"; refreshSoon(); }
|
||
catch (e) { toast(e.message, true); }
|
||
$("#cfg-save").disabled = false;
|
||
}
|
||
|
||
async function unloadOne(m) {
|
||
if (!await confirmModal({ title: `„${m}" entladen?`, body: "Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.", confirmLabel: "Entladen" })) return;
|
||
try { await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" }); toast("Entladen: " + m); setTimeout(refreshSoon, 600); }
|
||
catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
async function sendChat() {
|
||
const model = $("#chat-model").value, message = $("#chat-msg").value.trim();
|
||
if (!model) return toast("Kein Modell vorhanden.", true);
|
||
if (!message) return;
|
||
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
|
||
const box = $("#chat-reply"); box.style.display = "block"; box.textContent = "(wecke Modell, kann beim Laden kurz dauern…)";
|
||
try { const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) }); box.textContent = r.reply; }
|
||
catch (e) { box.textContent = "Fehler: " + e.message; }
|
||
btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
|
||
}
|
||
|
||
export default { id: "models", mount, onStatus, onJobs };
|