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("/")
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"))
# 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.
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
# 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 json
import os
import re
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@@ -12,7 +14,7 @@ from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth
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 jobengine import start_job, JOBS, attach_download_progress
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}
# ---------------------------------------------------------------------------
# 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):
repo_id: str
ctx: int = 8192
@@ -41,6 +67,21 @@ class InstallModelReq(BaseModel):
quant: str = "Q4_K_M"
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:
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
# 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."""
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
out = []
for r in RECIPES:
for r in all_recipes():
models, worst = [], "perfect"
for m in r["models"]:
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)})
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
worst = fit["level"]
out.append({**r, "models": models, "fit_level": worst})
# „Beste Wahl": das reichste Setup, das komplett auf die Hardware passt.
fitting = [r for r in out if r["fit_level"] != "too_tight"]
out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))})
# „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt.
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
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")
def install_recipe(req: InstallRecipeReq):
"""Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
ü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:
raise HTTPException(404, "Setup nicht gefunden.")
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).
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) {