This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mission-control/static/js/panels/models.js
T
Hitonabi 82d15d82db 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>
2026-06-22 21:33:03 +02:00

269 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 &amp; 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 };