v3 Phase B: alle Panels auf das Design-System + Beginner-UX

- cookbook.js: Fit-Ampel (gruen/gelb/rot) + Legende + Klartext-Urteile, sauberes Modal.
- server.js: heikle Aktionen mit confirmModal/promptModal (Klartext-Konsequenz),
  Konsole im neuen Stil, Begriffe uebersetzt.
- models.js: Tabelle re-skinnt (Capability-Tags statt Emoji, --blue raus),
  Entladen mit Bestaetigung, Konfig-Modal vereinheitlicht.
- jobs.js (Aktivitaet): Metrik-Kacheln + Klartext-Verlaeufe.
- guides.js: Kopf + Intro, Integrations-URL aus Browser-Host abgeleitet.
- index.html: Mountpunkte fuer Modelle-/Aktivitaets-Kopf.
- app.py: no-cache-Middleware fuer /static (UI-Aenderungen wirken sofort nach rsync,
  kein Stale-JS mehr).
- base.css: Sidebar bei schmalem Viewport icon-only (Label-Ueberlappung gefixt).

Verifiziert: alle 6 Panels mounten fehlerfrei (0 Konsolenfehler), Fit-Ampel rechnet live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-21 07:05:15 +02:00
parent 52b0a3bff5
commit 1aea0f558e
8 changed files with 482 additions and 751 deletions
+74 -107
View File
@@ -1,145 +1,116 @@
// models.js — "Modelle"-Ansicht: Schnelltest-Chat, Modell-Tabelle & Konfiguration.
// models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
import { api } from "../core/api.js";
import { $, badge, esc, toast, icon } from "../core/ui.js";
let ALL_MODELS = [];
import { $, badge, esc, toast, confirmModal } from "../core/ui.js";
let ALL = [];
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
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="Schreib was, um ein Modell zu wecken…"></textarea>
<button class="primary" id="chat-btn" title="Sendet eine Chat-Anfrage an das Modell (weckt es auf, falls es schläft)">Senden</button>
<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="hint mb-4">
💡 <b>Modelle werden automatisch geladen</b>, sobald eine Chat-Anfrage an sie gestellt wird. Du musst sie nicht manuell starten.
</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>Fähigkeiten</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
<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" style="display:none">Noch keine Modelle konfiguriert — zieh dir oben eins rein. 👇</div>
<!-- Config Modal -->
<div id="cfg-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:100; align-items:center; justify-content:center;">
<div class="card" style="width:100%; max-width:400px; position:relative">
<button id="cfg-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
<h2 style="margin-top:0">Modell konfigurieren</h2>
<p class="meta" id="cfg-model-name"></p>
<div class="mt-6">
<label>Context-Size (Tokens)</label>
<input id="cfg-ctx" type="number" class="tokin" value="8192">
<div class="meta text-xs mt-2">Höhere Werte erlauben längere Dokumente, brauchen aber mehr VRAM.</div>
</div>
<button class="primary mt-6" id="cfg-save" style="width:100%; padding:var(--sp-3)">Speichern</button>
</div>
<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)</label>
<input id="cfg-ctx" type="number" value="8192">
<div class="hint">Höhere Werte erlauben längere Texte, brauchen aber mehr Grafikspeicher.</div>
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
</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);
}
function onStatus(s) {
ALL_MODELS = s?.models || [];
const tb = $("#models");
if (!tb) return;
tb.innerHTML = "";
$("#models-empty").style.display = ALL_MODELS.length ? "none" : "block";
$("#m-count").textContent = ALL_MODELS.length ? ALL_MODELS.length + " konfiguriert" : "";
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");
const cur = sel.value;
sel.innerHTML = "";
for (const m of ALL_MODELS) {
const tr = document.createElement("tr");
let capsHtml = "";
if (m.meta && m.meta.caps) {
capsHtml = m.meta.caps.map(c => {
if (c === "Text") return `<span class="meta" title="Text" style="display:inline-block;width:18px">${icon("compass")}</span>`;
if (c === "Code") return `<strong style="color:var(--blue);display:inline-block;width:18px" title="Code">{ }</strong>`;
if (c === "Bild") return `<strong style="color:var(--purple);display:inline-block;width:18px" title="Vision">👁</strong>`;
return "";
}).join(" ");
}
let detailsHtml = "";
if (m.meta) {
const q = m.meta.quant || "?";
const c = m.meta.ctx ? (m.meta.ctx / 1024).toFixed(0) + "K" : "?";
const s = m.meta.size_bytes ? (m.meta.size_bytes / 1024 / 1024 / 1024).toFixed(1) + " GB" : "?";
detailsHtml = `<span class="meta" style="font-size:0.9em">${q} · ${c} · ${s}</span>`;
}
const perfHtml = m.state === "running" ? `<br><small class="meta" style="font-size:0.75em">n/a t/s</small>` : "";
const filenameHtml = m.meta?.filename ? `<br><small class="meta" style="font-size:0.8em">${esc(m.meta.filename)}</small>` : "";
tr.innerHTML = `<td class="mid" style="font-weight:500">${esc(m.name)}${filenameHtml}</td>
<td>${capsHtml}</td>
<td>${detailsHtml}</td>
<td>${badge(m.state)}${perfHtml}</td>
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
tb.innerHTML = ALL.map(m => {
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
return `<tr>
<td class="mid" style="font-weight:500">${esc(m.name)}${fn}</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">
<button class="ghost" data-cfg="${esc(m.name)}" title="Context-Size anpassen">Konfigurieren</button>
<button class="ghost" data-unload="${esc(m.name)}" title="Beendet das Modell sofort und gibt den VRAM wieder frei">Aus VRAM entfernen</button>
</td>`;
tb.appendChild(tr);
sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
}
<td style="text-align:right;white-space:nowrap">
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
</td></tr>`;
}).join("");
for (const m of ALL) 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-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"))));
}
function openConfig(alias) {
const m = ALL_MODELS.find(x => x.name === alias);
if (!m) return;
const m = ALL.find(x => x.name === alias); if (!m) return;
$("#cfg-model-name").textContent = m.name;
$("#cfg-ctx").value = m.meta?.ctx || 8192;
$("#cfg-modal").style.display = "flex";
}
async function saveConfig() {
const alias = $("#cfg-model-name").textContent;
const ctx = parseInt($("#cfg-ctx").value) || 8192;
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! Änderungen werden beim nächsten Modell-Start aktiv.");
$("#cfg-modal").style.display = "none";
refreshSoon();
} catch (e) {
toast(e.message, 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) {
try {
await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" });
toast("Entladen: " + m);
setTimeout(refreshSoon, 600);
} catch (e) { toast(e.message, true); }
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() {
@@ -147,14 +118,10 @@ async function sendChat() {
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 Swap 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();
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 };