// 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 ? `${esc(role)} ` : "";
}
// 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 `
API-Name:
${ids.map(id => `${esc(id)}`).join('· ')}
`;
}
function capTags(caps) {
if (!caps?.length) return "–";
return caps.map(c =>
c === "Code" ? `Code `
: c === "Bild" ? `Bild `
: `Text `).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 `${esc(q)} · ${c} · ${s} `;
}
function mount() {
$("#m-head").innerHTML = `
Modelle
Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.
`;
$("#m-chat").innerHTML = `
Schnelltest
Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.
Modell
Nachricht
Senden
`;
$("#m-table").innerHTML = `
Modelle & Ports
Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.
Modell Kann Details Status Port Aktionen
Noch keine Modelle konfiguriert
Hol dir unter „Cookbook" ein passendes Modell.
Schließen
Modell konfigurieren
Kontext-Größe (Tokens) ${infoDot(CTX_HELP)}
Empfohlen für deine Hardware
Optimal übernehmen
Es läuft immer nur ein Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.
Speichern
Schließen
Rolle festlegen ${infoDot(ROLE_HELP)}
Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach beide : der echte Modellname und die Rolle.
Eigene Rolle (optional)
Rolle entfernen
Speichern
`;
$("#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 =>
`${esc(r.label)} `).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 `
${esc(m.name)}kein Modell hinterlegt
—
leere Rolle
leer
—
Modell zuweisen
Löschen
`;
}
const fn = m.meta?.filename ? `${esc(m.meta.filename)}
` : "";
return `
${esc(m.name)}${roleTag(m.role)}
${fn}${apiIdLine(m)}
${capTags(m.meta?.caps)}
${details(m.meta)}
${stateBadge(m)}
${m.port ?? "auto"}
Rolle
Konfigurieren
Entladen
Löschen
`;
}).join("");
for (const m of ALL) if (!m.incomplete) sel.insertAdjacentHTML("beforeend", `${esc(m.name)} `);
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. Das lässt sich nicht rückgängig machen — 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. ` +
`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 };