feat: eigene Cookbook-Setups erstellen & loeschen
- Nutzer koennen im Cookbook eigene Use-Case-Setups anlegen (Titel, Beschreibung, beliebige Modelle: Repo + Rolle + Quant). Groesse wird aus dem Repo-Namen abgeleitet; Fit-Ampel + optimaler Kontext kommen wie bei kuratierten Setups zur Laufzeit aus hw_math. - Persistenz: JSON-Datei (MC_USER_RECIPES, Default /srv/models/...) -> ueberlebt Deploys (liegt bewusst NICHT im rsync-Ziel). - /api/cookbook/recipes merged eingebaute + eigene Setups; install-recipe findet beide. Neue Endpunkte POST/DELETE /api/cookbook/user-recipe. - UI: "+ Eigenes Setup", Modal mit dynamischen Modell-Zeilen; eigene Karten mit "Dein Setup"-Tag + Loeschen. "Beste Wahl" bleibt auf kuratierte beschraenkt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math).
|
||||
|
||||
import { api, getHfToken } from "../core/api.js";
|
||||
import { $, esc, icon, toast, infoDot } from "../core/ui.js";
|
||||
import { $, esc, icon, toast, infoDot, confirmModal } from "../core/ui.js";
|
||||
|
||||
const CTX_HELP = "Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr, braucht aber mehr Speicher und wird etwas langsamer.";
|
||||
const GGUF_HELP = "GGUF ist das lokal lauffähige Dateiformat. Unsere Engine lädt nur GGUF — Repos ohne GGUF-Datei kann sie nicht nutzen.";
|
||||
@@ -33,7 +33,8 @@ function mount() {
|
||||
</div></div>
|
||||
|
||||
<div class="card-h" style="align-items:center"><h3>Wofür möchtest du es nutzen?</h3>
|
||||
<span class="chip" id="cb-hw">deine Hardware</span></div>
|
||||
<span class="chip" id="cb-hw" style="margin-left:auto">deine Hardware</span>
|
||||
<button class="ghost" id="cb-new-open" style="margin-left:10px">+ Eigenes Setup</button></div>
|
||||
<div class="grid grid-3" id="cb-recipes">
|
||||
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
|
||||
</div>
|
||||
@@ -76,6 +77,23 @@ function mount() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cb-new-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<button id="cb-new-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3>Eigenes Setup erstellen</h3>
|
||||
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen — es erscheint danach mit Hardware-Ampel hier im Cookbook.</p>
|
||||
<label>Titel</label>
|
||||
<input id="cb-new-title" placeholder="z.B. Mein Schreib-Stack">
|
||||
<label>Kurzbeschreibung (optional)</label>
|
||||
<input id="cb-new-desc" placeholder="Wofür ist dieses Setup gut?">
|
||||
<label style="margin-top:10px">Modelle</label>
|
||||
<div id="cb-new-models"></div>
|
||||
<button class="ghost" id="cb-new-add" style="margin-top:8px">+ Modell hinzufügen</button>
|
||||
<div class="hint" style="margin-top:10px">Die <b>Repo-ID</b> findest du über die <b>Profi-Suche</b> weiter unten (z.B. <code>unsloth/Qwen3-8B-GGUF</code>). Die Modellgröße erkennen wir automatisch aus dem Namen.</div>
|
||||
<button class="primary" id="cb-new-save" style="width:100%;margin-top:14px">Setup speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cb-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
@@ -110,6 +128,12 @@ function mount() {
|
||||
window.cbSetCtx = v => { $("#cb-m-ctx").value = v; reanalyzeCtx(); };
|
||||
$("#cb-r-close").addEventListener("click", () => $("#cb-recipe-modal").style.display = "none");
|
||||
$("#cb-recipe-modal").addEventListener("click", e => { if (e.target.id === "cb-recipe-modal") $("#cb-recipe-modal").style.display = "none"; });
|
||||
$("#cb-new-open").addEventListener("click", openNewRecipe);
|
||||
$("#cb-new-close").addEventListener("click", () => $("#cb-new-modal").style.display = "none");
|
||||
$("#cb-new-modal").addEventListener("click", e => { if (e.target.id === "cb-new-modal") $("#cb-new-modal").style.display = "none"; });
|
||||
$("#cb-new-add").addEventListener("click", () => $("#cb-new-models").insertAdjacentHTML("beforeend", newModelRow()));
|
||||
$("#cb-new-models").addEventListener("click", e => { const d = e.target.closest(".cb-nm-del"); if (d) d.closest(".cb-nm-row").remove(); });
|
||||
$("#cb-new-save").addEventListener("click", saveNewRecipe);
|
||||
|
||||
renderHwChip();
|
||||
loadRecipes();
|
||||
@@ -130,8 +154,12 @@ async function loadRecipes() {
|
||||
function renderRecipes() {
|
||||
$("#cb-recipes").innerHTML = RECIPES.map(r => {
|
||||
const best = r.id === RECOMMENDED;
|
||||
return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}">
|
||||
${best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>` : ""}
|
||||
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`
|
||||
: r.user ? `<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>` : "";
|
||||
const del = r.user ? `<span class="cb-delr" data-delr="${esc(r.id)}" title="Setup löschen"
|
||||
style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1">×</span>` : "";
|
||||
return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}" style="position:relative">
|
||||
${del}${tag}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="flex items-center gap-2"><span class="text-accent">${icon(r.icon)}</span>
|
||||
<h3 style="margin:0;font-size:15px">${esc(r.title)}</h3></span>
|
||||
@@ -143,6 +171,56 @@ function renderRecipes() {
|
||||
}).join("");
|
||||
$("#cb-recipes").querySelectorAll("[data-recipe]").forEach(b =>
|
||||
b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe"))));
|
||||
$("#cb-recipes").querySelectorAll("[data-delr]").forEach(x =>
|
||||
x.addEventListener("click", e => { e.stopPropagation(); deleteRecipe(x.getAttribute("data-delr")); }));
|
||||
}
|
||||
|
||||
// ---- Eigenes Setup erstellen/löschen ----
|
||||
function newModelRow() {
|
||||
return `<div class="cb-nm-row flex gap-2" style="margin-bottom:8px;align-items:center">
|
||||
<input class="cb-nm-repo" placeholder="Repo, z.B. unsloth/Qwen3-8B-GGUF" style="flex:2;margin:0">
|
||||
<input class="cb-nm-role" placeholder="Rolle (z.B. coder)" style="flex:1;margin:0">
|
||||
<select class="cb-nm-quant" style="width:auto;margin:0;font-family:var(--sans)">
|
||||
<option>Q4_K_M</option><option>Q5_K_M</option><option>Q6_K</option><option>Q8_0</option><option>Q3_K_M</option>
|
||||
</select>
|
||||
<button class="ghost del cb-nm-del" title="Zeile entfernen" style="margin:0;padding:6px 10px">×</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openNewRecipe() {
|
||||
$("#cb-new-title").value = "";
|
||||
$("#cb-new-desc").value = "";
|
||||
$("#cb-new-models").innerHTML = newModelRow();
|
||||
$("#cb-new-modal").style.display = "flex";
|
||||
}
|
||||
|
||||
async function saveNewRecipe() {
|
||||
const title = $("#cb-new-title").value.trim();
|
||||
if (!title) return toast("Bitte einen Titel angeben.", true);
|
||||
const models = [...$("#cb-new-models").querySelectorAll(".cb-nm-row")].map(row => ({
|
||||
repo: row.querySelector(".cb-nm-repo").value.trim(),
|
||||
role: row.querySelector(".cb-nm-role").value.trim() || "modell",
|
||||
quant: row.querySelector(".cb-nm-quant").value,
|
||||
})).filter(m => m.repo);
|
||||
if (!models.length) return toast("Mindestens ein Modell (Repo) angeben.", true);
|
||||
const btn = $("#cb-new-save"); btn.disabled = true; btn.textContent = "Speichere…";
|
||||
try {
|
||||
await api("/api/cookbook/user-recipe", { method: "POST",
|
||||
body: JSON.stringify({ title, desc: $("#cb-new-desc").value.trim(), models }) });
|
||||
toast("Setup gespeichert.");
|
||||
$("#cb-new-modal").style.display = "none";
|
||||
loadRecipes();
|
||||
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||
btn.disabled = false; btn.textContent = "Setup speichern";
|
||||
}
|
||||
|
||||
async function deleteRecipe(id) {
|
||||
const r = RECIPES.find(x => x.id === id);
|
||||
if (!await confirmModal({ title: `„${r ? r.title : id}" löschen?`,
|
||||
body: "Dein eigenes Setup wird aus dem Cookbook entfernt. Bereits installierte Modelle bleiben unangetastet.",
|
||||
confirmLabel: "Löschen", danger: true })) return;
|
||||
try { await api("/api/cookbook/user-recipe/" + encodeURIComponent(id), { method: "DELETE" }); toast("Setup gelöscht."); loadRecipes(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
function openRecipe(id) {
|
||||
|
||||
Reference in New Issue
Block a user