diff --git a/config.py b/config.py index b298137..5c4cd4c 100644 --- a/config.py +++ b/config.py @@ -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). diff --git a/routers/cookbook.py b/routers/cookbook.py index df2395a..258261e 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -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) diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index ae331cd..f820390 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -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() {

Wofür möchtest du es nutzen?

- deine Hardware
+ deine Hardware +
Lade Setups…
@@ -76,6 +77,23 @@ function mount() { + +