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 4077d06b2d feat(rollen): echte Modellnamen als API-ID + Rollen als Tags/Alias
Modelle erscheinen jetzt mit ihrem ECHTEN Namen (= API-Name, den man in Zed/
OpenCode angibt) statt unter "coder"/"vision". Die Rolle wird ein zusaetzlicher,
sprechender Name (llama-swap-`aliases`) -> in der API funktionieren BEIDE.

- llamaswap.py: model_id_from_path (Repo-Ordner ohne -GGUF) + set_role_alias
  (Rolle als eindeutiger Alias, beim Setzen bei anderen Modellen entfernt) +
  ROLE_IDS (vision/coder/scout/reviewer/manager).
- models.py: register schreibt Key=Modellname, Rolle als Alias; /status liefert
  role + aliases + api_ids; neuer POST /set_role. Rueckwaertskompatibel: Legacy-
  Eintraege (coder/vision ohne Alias) behalten ihren Key als Rolle.
- cookbook.py: install-recipe/-model nutzen dasselbe Schema (Name + Rollen-Alias).
- models.js: Zeile zeigt echten Namen + Rollen-Tag + API-Name (klick=kopieren);
  neuer "Rolle"-Dialog mit 5 Schnellwahl-Rollen + freier Eingabe.
- cookbook.js: Profi-Download-Feld ist jetzt optionale Rolle (Name kommt vom
  Modell), Pflichtfeld-Pruefung entsprechend gelockert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:58:07 +02:00

247 lines
13 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";
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")); }
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-copy="${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(""));
}
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 onStatus(s) {
ALL = s?.models || [];
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) {
// 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>
<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-assign="${esc(m.name)}">Modell zuweisen</button>
<button class="ghost del" data-del="${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>${badge(m.state)}</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>
</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();
}));
}
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 };