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:
Hitonabi
2026-06-22 16:13:55 +02:00
parent 5761556d84
commit 81f6861df8
3 changed files with 174 additions and 10 deletions
+3
View File
@@ -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
View File
@@ -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)
+82 -4
View File
@@ -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) {