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:
@@ -17,6 +17,9 @@ from ruamel.yaml import YAML
|
|||||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||||
|
# Eigene Cookbook-Setups (vom Nutzer angelegt). Bewusst NICHT im App-Verzeichnis, sonst
|
||||||
|
# wuerde ein Deploy (rsync) sie ueberschreiben -> persistent neben den Modellen ablegen.
|
||||||
|
USER_RECIPES_PATH = Path(os.environ.get("MC_USER_RECIPES", str(MODELS_DIR / "mission-control-recipes.json")))
|
||||||
# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird.
|
# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird.
|
||||||
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
|
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
|
||||||
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
|
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
|
||||||
|
|||||||
+89
-6
@@ -3,6 +3,8 @@ Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechn
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -12,7 +14,7 @@ from ruamel.yaml.scalarstring import LiteralScalarString
|
|||||||
|
|
||||||
from auth import auth
|
from auth import auth
|
||||||
from hw_math import evaluate_fit, max_ctx_for
|
from hw_math import evaluate_fit, max_ctx_for
|
||||||
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, hf_bin
|
from config import MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH, hf_bin
|
||||||
from llamaswap import read_config, write_config
|
from llamaswap import read_config, write_config
|
||||||
from jobengine import start_job, JOBS, attach_download_progress
|
from jobengine import start_job, JOBS, attach_download_progress
|
||||||
from recipes import RECIPES, UPGRADES
|
from recipes import RECIPES, UPGRADES
|
||||||
@@ -21,6 +23,30 @@ router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
|
|||||||
|
|
||||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eigene Setups (vom Nutzer) — persistente JSON-Datei, gleiches Schema wie RECIPES.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def load_user_recipes() -> list:
|
||||||
|
try:
|
||||||
|
if USER_RECIPES_PATH.exists():
|
||||||
|
return json.loads(USER_RECIPES_PATH.read_text(encoding="utf-8")) or []
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_user_recipes(items: list) -> None:
|
||||||
|
USER_RECIPES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = USER_RECIPES_PATH.with_name(USER_RECIPES_PATH.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, USER_RECIPES_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def all_recipes() -> list:
|
||||||
|
"""Eingebaute + eigene Setups (eigene tragen 'user': True)."""
|
||||||
|
return list(RECIPES) + load_user_recipes()
|
||||||
|
|
||||||
class AnalyzeRequest(BaseModel):
|
class AnalyzeRequest(BaseModel):
|
||||||
repo_id: str
|
repo_id: str
|
||||||
ctx: int = 8192
|
ctx: int = 8192
|
||||||
@@ -41,6 +67,21 @@ class InstallModelReq(BaseModel):
|
|||||||
quant: str = "Q4_K_M"
|
quant: str = "Q4_K_M"
|
||||||
hf_token: str | None = None
|
hf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRecipeModelReq(BaseModel):
|
||||||
|
repo: str
|
||||||
|
role: str
|
||||||
|
quant: str = "Q4_K_M"
|
||||||
|
name: str | None = None
|
||||||
|
why: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRecipeReq(BaseModel):
|
||||||
|
title: str
|
||||||
|
desc: str = ""
|
||||||
|
icon: str = "box"
|
||||||
|
models: list[UserRecipeModelReq]
|
||||||
|
|
||||||
def extract_params_b(repo_id: str) -> float:
|
def extract_params_b(repo_id: str) -> float:
|
||||||
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
|
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
|
||||||
# z.B. Qwen2.5-Coder-32B -> 32
|
# z.B. Qwen2.5-Coder-32B -> 32
|
||||||
@@ -126,26 +167,68 @@ def recipes():
|
|||||||
Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit."""
|
Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit."""
|
||||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||||
out = []
|
out = []
|
||||||
for r in RECIPES:
|
for r in all_recipes():
|
||||||
models, worst = [], "perfect"
|
models, worst = [], "perfect"
|
||||||
for m in r["models"]:
|
for m in r["models"]:
|
||||||
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb)
|
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb)
|
||||||
models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
|
models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
|
||||||
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
|
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
|
||||||
worst = fit["level"]
|
worst = fit["level"]
|
||||||
out.append({**r, "models": models, "fit_level": worst})
|
out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))})
|
||||||
# „Beste Wahl": das reichste Setup, das komplett auf die Hardware passt.
|
# „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt.
|
||||||
fitting = [r for r in out if r["fit_level"] != "too_tight"]
|
fitting = [r for r in out if not r["user"] and r["fit_level"] != "too_tight"]
|
||||||
rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None
|
rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None
|
||||||
return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None}
|
return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user-recipe")
|
||||||
|
def create_user_recipe(req: UserRecipeReq):
|
||||||
|
"""Eigenes Cookbook-Setup anlegen. params_b wird aus dem Repo-Namen abgeleitet, damit der
|
||||||
|
Nutzer nur Repo + Rolle + Quant angeben muss; Fit/Kontext kommen wie immer zur Laufzeit."""
|
||||||
|
if not req.title.strip():
|
||||||
|
raise HTTPException(400, "Bitte einen Titel angeben.")
|
||||||
|
models = []
|
||||||
|
for m in req.models:
|
||||||
|
if not m.repo.strip():
|
||||||
|
continue
|
||||||
|
models.append({
|
||||||
|
"role": (m.role or "modell").strip(),
|
||||||
|
"name": (m.name or m.repo.split("/")[-1]).strip(),
|
||||||
|
"repo": m.repo.strip(),
|
||||||
|
"params_b": extract_params_b(m.repo),
|
||||||
|
"quant": (m.quant or "Q4_K_M").strip(),
|
||||||
|
"why": (m.why or "").strip(),
|
||||||
|
})
|
||||||
|
if not models:
|
||||||
|
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
|
||||||
|
items = load_user_recipes()
|
||||||
|
base = "user-" + (re.sub(r"[^a-z0-9]+", "-", req.title.lower()).strip("-") or "setup")
|
||||||
|
rid, n = base, 2
|
||||||
|
existing = {r.get("id") for r in items} | {r["id"] for r in RECIPES}
|
||||||
|
while rid in existing:
|
||||||
|
rid, n = f"{base}-{n}", n + 1
|
||||||
|
items.append({"id": rid, "title": req.title.strip(), "icon": (req.icon or "box").strip(),
|
||||||
|
"desc": req.desc.strip(), "models": models, "user": True})
|
||||||
|
save_user_recipes(items)
|
||||||
|
return {"ok": True, "id": rid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user-recipe/{recipe_id}")
|
||||||
|
def delete_user_recipe(recipe_id: str):
|
||||||
|
items = load_user_recipes()
|
||||||
|
kept = [r for r in items if r.get("id") != recipe_id]
|
||||||
|
if len(kept) == len(items):
|
||||||
|
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
|
||||||
|
save_user_recipes(kept)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/install-recipe")
|
@router.post("/install-recipe")
|
||||||
def install_recipe(req: InstallRecipeReq):
|
def install_recipe(req: InstallRecipeReq):
|
||||||
"""Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
|
"""Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
|
||||||
optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
|
optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
|
||||||
übernimmt es, sobald die Datei da ist."""
|
übernimmt es, sobald die Datei da ist."""
|
||||||
recipe = next((r for r in RECIPES if r["id"] == req.recipe_id), None)
|
recipe = next((r for r in all_recipes() if r["id"] == req.recipe_id), None)
|
||||||
if not recipe:
|
if not recipe:
|
||||||
raise HTTPException(404, "Setup nicht gefunden.")
|
raise HTTPException(404, "Setup nicht gefunden.")
|
||||||
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math).
|
// Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math).
|
||||||
|
|
||||||
import { api, getHfToken } from "../core/api.js";
|
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 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.";
|
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></div>
|
||||||
|
|
||||||
<div class="card-h" style="align-items:center"><h3>Wofür möchtest du es nutzen?</h3>
|
<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="grid grid-3" id="cb-recipes">
|
||||||
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
|
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +77,23 @@ function mount() {
|
|||||||
</div>
|
</div>
|
||||||
</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 id="cb-modal" class="modal-overlay" style="display:none">
|
||||||
<div class="modal-card" style="max-width:560px">
|
<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>
|
<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(); };
|
window.cbSetCtx = v => { $("#cb-m-ctx").value = v; reanalyzeCtx(); };
|
||||||
$("#cb-r-close").addEventListener("click", () => $("#cb-recipe-modal").style.display = "none");
|
$("#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-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();
|
renderHwChip();
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
@@ -130,8 +154,12 @@ async function loadRecipes() {
|
|||||||
function renderRecipes() {
|
function renderRecipes() {
|
||||||
$("#cb-recipes").innerHTML = RECIPES.map(r => {
|
$("#cb-recipes").innerHTML = RECIPES.map(r => {
|
||||||
const best = r.id === RECOMMENDED;
|
const best = r.id === RECOMMENDED;
|
||||||
return `<button class="card-btn${best ? " cb-best" : ""}" data-recipe="${esc(r.id)}">
|
const tag = best ? `<div class="cb-best-tag">★ Beste Wahl für dein System</div>`
|
||||||
${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">
|
<div class="flex justify-between items-center">
|
||||||
<span class="flex items-center gap-2"><span class="text-accent">${icon(r.icon)}</span>
|
<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>
|
<h3 style="margin:0;font-size:15px">${esc(r.title)}</h3></span>
|
||||||
@@ -143,6 +171,56 @@ function renderRecipes() {
|
|||||||
}).join("");
|
}).join("");
|
||||||
$("#cb-recipes").querySelectorAll("[data-recipe]").forEach(b =>
|
$("#cb-recipes").querySelectorAll("[data-recipe]").forEach(b =>
|
||||||
b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe"))));
|
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) {
|
function openRecipe(id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user