v4 Schritt 2: Cookbook 2.0 — Use-Case-Setups (Stacks)
- recipes.py: 6 kuratierte Use-Case-Stacks (Coding, Vision, Chat, LongDoc, Agents, Fast). - cookbook.py: /recipes (Stack-Fit pro Modell + Gesamturteil) + /install-recipe (download + register aller Modelle mit optimalem, gedeckeltem Kontext). - cookbook.js: Use-Case-Karten als Haupteinstieg + Setup-Modal + 'Komplettes Setup installieren'; Roh-Suche in 'Profi-Modus' (collapsible). - models.py /register: exists()-Check entfernt (fixt frischen Download-Install). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+81
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Kuratierte Use-Case-"Setups" (Stacks) fuers Cookbook 2.0.
|
||||||
|
|
||||||
|
Idee: Der Nutzer waehlt *wofuer* er es braucht — nicht *welches Modell*. Jedes Setup ist eine
|
||||||
|
Kombination aus Rollen (z.B. Haupt-Coder + schneller Coder + Vision). Hardware-Fit + optimaler
|
||||||
|
Kontext werden zur Laufzeit aus der echten RAM-Groesse berechnet (hw_math), nicht hier hartkodiert.
|
||||||
|
|
||||||
|
Daten, kein Code — bewusst getrennt (KISS/SoC). Repo/Datei sind unsloth-GGUFs auf HuggingFace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
RECIPES = [
|
||||||
|
{
|
||||||
|
"id": "coding", "title": "Coden & Programmieren", "icon": "code",
|
||||||
|
"desc": "Ein starker Haupt-Coder, ein flinker für Autovervollständigung und Vision für Screenshots.",
|
||||||
|
"models": [
|
||||||
|
{"role": "coder", "name": "Qwen2.5 Coder 32B", "repo": "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf", "params_b": 32, "quant": "Q4_K_M",
|
||||||
|
"why": "Hauptmodell — versteht große Codebasen und komplexe Aufgaben."},
|
||||||
|
{"role": "coder-fast", "name": "Qwen2.5 Coder 7B", "repo": "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M",
|
||||||
|
"why": "Schnelle Autovervollständigung & einfache Edits."},
|
||||||
|
{"role": "vision", "name": "Llama 3.2 Vision 11B", "repo": "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF",
|
||||||
|
"file": "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf", "params_b": 11, "quant": "Q4_K_M",
|
||||||
|
"why": "Liest Screenshots & Fehlerbilder für die Analyse."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vision", "title": "Bilder verstehen", "icon": "eye",
|
||||||
|
"desc": "Ein multimodales Modell, das Bilder und Text gemeinsam versteht.",
|
||||||
|
"models": [
|
||||||
|
{"role": "vision", "name": "Llama 3.2 Vision 11B", "repo": "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF",
|
||||||
|
"file": "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf", "params_b": 11, "quant": "Q4_K_M",
|
||||||
|
"why": "Beschreibt Bilder, liest Diagramme, analysiert Screenshots."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chat", "title": "Allrounder & Chat", "icon": "compass",
|
||||||
|
"desc": "Ein vielseitiges Modell für alltägliche Fragen, Texte und Brainstorming.",
|
||||||
|
"models": [
|
||||||
|
{"role": "scout", "name": "Qwen2.5 7B", "repo": "unsloth/Qwen2.5-7B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M",
|
||||||
|
"why": "Schneller, kluger Allrounder — guter Standard."},
|
||||||
|
{"role": "scout-pro", "name": "Qwen2.5 14B", "repo": "unsloth/Qwen2.5-14B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-14B-Instruct-Q4_K_M.gguf", "params_b": 14, "quant": "Q4_K_M",
|
||||||
|
"why": "Mehr Tiefe, wenn die Antworten besser sein sollen."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "longdoc", "title": "Lange Dokumente", "icon": "file",
|
||||||
|
"desc": "Großes Modell mit großem Kontext — für lange Texte, Verträge, Bücher.",
|
||||||
|
"models": [
|
||||||
|
{"role": "reader", "name": "Qwen2.5 14B", "repo": "unsloth/Qwen2.5-14B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-14B-Instruct-Q4_K_M.gguf", "params_b": 14, "quant": "Q4_K_M",
|
||||||
|
"why": "Solide Qualität bei großem Kontextfenster (auto-optimiert)."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agents", "title": "Agenten & Tool-Use", "icon": "layers",
|
||||||
|
"desc": "Modelle, die gut mit Werkzeugen/Funktionen umgehen — für autonome Agenten.",
|
||||||
|
"models": [
|
||||||
|
{"role": "agent", "name": "Qwen2.5 32B", "repo": "unsloth/Qwen2.5-32B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-32B-Instruct-Q4_K_M.gguf", "params_b": 32, "quant": "Q4_K_M",
|
||||||
|
"why": "Stark im Function-Calling und mehrstufigem Denken."},
|
||||||
|
{"role": "agent-fast", "name": "Qwen2.5 7B", "repo": "unsloth/Qwen2.5-7B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M",
|
||||||
|
"why": "Schneller Helfer für einfache Tool-Schritte."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fast", "title": "Schnell & sparsam", "icon": "bolt",
|
||||||
|
"desc": "Kleine, flinke Modelle — ideal bei wenig Speicher oder für schnelle Antworten.",
|
||||||
|
"models": [
|
||||||
|
{"role": "mini", "name": "Qwen2.5 3B", "repo": "unsloth/Qwen2.5-3B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-3B-Instruct-Q4_K_M.gguf", "params_b": 3, "quant": "Q4_K_M",
|
||||||
|
"why": "Winzig & sehr schnell für einfache Aufgaben."},
|
||||||
|
{"role": "coder-fast", "name": "Qwen2.5 Coder 7B", "repo": "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF",
|
||||||
|
"file": "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf", "params_b": 7, "quant": "Q4_K_M",
|
||||||
|
"why": "Flinker Coder, falls es doch mal Code sein soll."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -8,11 +8,19 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
|
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
|
||||||
|
from llamaswap import read_config, write_config
|
||||||
|
from jobengine import start_job, JOBS
|
||||||
|
from recipes import RECIPES
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
|
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
|
||||||
|
|
||||||
|
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||||
|
|
||||||
class AnalyzeRequest(BaseModel):
|
class AnalyzeRequest(BaseModel):
|
||||||
repo_id: str
|
repo_id: str
|
||||||
ctx: int = 8192
|
ctx: int = 8192
|
||||||
@@ -22,6 +30,9 @@ class EvaluateRequest(BaseModel):
|
|||||||
quant: str
|
quant: str
|
||||||
ctx: int
|
ctx: int
|
||||||
|
|
||||||
|
class InstallRecipeReq(BaseModel):
|
||||||
|
recipe_id: str
|
||||||
|
|
||||||
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
|
||||||
@@ -100,3 +111,47 @@ def evaluate_single(req: EvaluateRequest):
|
|||||||
fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
|
fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
|
||||||
return fit
|
return fit
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recipes")
|
||||||
|
def recipes():
|
||||||
|
"""Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN
|
||||||
|
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:
|
||||||
|
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})
|
||||||
|
return {"recipes": out, "sys_ram_gb": round(ram_gb, 1)}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(404, "Setup nicht gefunden.")
|
||||||
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
||||||
|
cfg = read_config()
|
||||||
|
job_ids = []
|
||||||
|
for m in recipe["models"]:
|
||||||
|
target = MODELS_DIR / m["repo"].split("/")[-1]
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
args = ["hf", "download", m["repo"], m["file"], "--local-dir", str(target)]
|
||||||
|
jid = start_job(args, f"download {m['name']}", env={"HF_XET_HIGH_PERFORMANCE": "1"})
|
||||||
|
JOBS[jid]["result_path"] = str(target / m["file"])
|
||||||
|
job_ids.append(jid)
|
||||||
|
# Eintrag jetzt schon schreiben — optimaler Kontext, aber gedeckelt fuer schnellen Erststart.
|
||||||
|
ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768)
|
||||||
|
path = str(target / m["file"])
|
||||||
|
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
|
||||||
|
cfg["models"][m["role"]] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
|
||||||
|
write_config(cfg)
|
||||||
|
return {"job_ids": job_ids, "count": len(recipe["models"])}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -145,8 +145,8 @@ def download(req: DownloadReq):
|
|||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
def register(req: RegisterReq):
|
def register(req: RegisterReq):
|
||||||
if not Path(req.model_path).exists():
|
# Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt
|
||||||
raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}")
|
# erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist.
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx))
|
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx))
|
||||||
cfg["models"][req.alias] = {
|
cfg["models"][req.alias] = {
|
||||||
|
|||||||
+113
-102
@@ -1,20 +1,9 @@
|
|||||||
// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile.
|
// cookbook.js — Cookbook 2.0 (v4): Use-Case-Setups („Wofür?") + Profi-Suche.
|
||||||
// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register.
|
// Backend: /api/cookbook/{recipes,install-recipe,analyze,evaluate} (hw_math).
|
||||||
|
|
||||||
import { api } from "../core/api.js";
|
import { api } from "../core/api.js";
|
||||||
import { $, esc, icon, toast } from "../core/ui.js";
|
import { $, esc, icon, toast } from "../core/ui.js";
|
||||||
|
|
||||||
const CURATED = [
|
|
||||||
{ name: "Qwen 2.5 Coder 7B", repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
|
|
||||||
desc: "Schnell und locker auf deiner Kiste — ideal zum Programmieren.", params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast" },
|
|
||||||
{ name: "Qwen 2.5 Coder 32B", repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF", file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
|
|
||||||
desc: "Mehr Können, etwas langsamer — der starke Allround-Coder.", params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder" },
|
|
||||||
{ name: "Llama 3.2 Vision 11B", repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
|
|
||||||
desc: "Versteht Bilder und Text — gut für multimodale Aufgaben.", params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision" },
|
|
||||||
{ name: "Qwen 2.5 7B", repo: "unsloth/Qwen2.5-7B-Instruct-GGUF", file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
|
||||||
desc: "Hervorragender Allrounder — schnell und vielseitig.", params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
|
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
|
||||||
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
|
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
|
||||||
@@ -24,64 +13,79 @@ let lastSys = null;
|
|||||||
let currentResults = [];
|
let currentResults = [];
|
||||||
let currentAnalysis = null;
|
let currentAnalysis = null;
|
||||||
let activeFilter = "";
|
let activeFilter = "";
|
||||||
|
let RECIPES = [];
|
||||||
|
|
||||||
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
|
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
|
||||||
|
const fitWord = lvl => lvl === "perfect" ? "Passt locker" : lvl === "marginal" ? "Wird knapp" : "Zu groß";
|
||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
const c = $(".view[data-view='cookbook']");
|
const c = $(".view[data-view='cookbook']");
|
||||||
c.innerHTML = `
|
c.innerHTML = `
|
||||||
<div class="pagehead"><div>
|
<div class="pagehead"><div>
|
||||||
<h1>Modell-Cookbook</h1>
|
<h1>Modell-Cookbook</h1>
|
||||||
<div class="sub">Finde ein KI-Modell, das auf deinen Mini-PC passt — den Hardware-Check rechnen wir für dich aus.</div>
|
<div class="sub">Wofür willst du es nutzen? Wir empfehlen das passende Setup für deine Hardware.</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<div class="flex gap-3" style="margin:4px 0 6px">
|
<div class="card-h" style="align-items:center"><h3>Wofür möchtest du es nutzen?</h3>
|
||||||
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1">
|
<span class="chip" id="cb-hw">deine Hardware</span></div>
|
||||||
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
|
<div class="grid grid-3" id="cb-recipes">
|
||||||
</div>
|
<div class="empty" style="grid-column:1/-1;text-align:center">Lade Setups…</div>
|
||||||
<div class="flex gap-2 items-center" style="flex-wrap:wrap">
|
|
||||||
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap;flex:1"></div>
|
|
||||||
<select id="cb-sort" style="width:auto;margin:0;padding:7px 10px;font-family:var(--sans)">
|
|
||||||
<option value="downloads">Beliebteste Downloads</option>
|
|
||||||
<option value="likes">Am beliebtesten (Likes)</option>
|
|
||||||
<option value="lastModified">Zuletzt aktualisiert</option>
|
|
||||||
<option value="trendingScore">Im Trend</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-h" style="margin-top:18px;align-items:center">
|
<details class="guide-acc card" style="padding:0;overflow:hidden;margin-top:8px">
|
||||||
<h3 id="cb-section-title">Empfohlen für deine Hardware</h3>
|
<summary>Profi-Modus: HuggingFace direkt durchsuchen</summary>
|
||||||
<span class="chip" id="cb-hw">deine Hardware</span>
|
<div class="acc-body">
|
||||||
|
<div class="flex gap-3" style="margin-bottom:8px">
|
||||||
|
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"…" style="margin:0;flex:1">
|
||||||
|
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center" style="flex-wrap:wrap">
|
||||||
|
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap;flex:1"></div>
|
||||||
|
<select id="cb-sort" style="width:auto;margin:0;padding:7px 10px;font-family:var(--sans)">
|
||||||
|
<option value="downloads">Beliebteste Downloads</option>
|
||||||
|
<option value="likes">Am beliebtesten (Likes)</option>
|
||||||
|
<option value="lastModified">Zuletzt aktualisiert</option>
|
||||||
|
<option value="trendingScore">Im Trend</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="legend" style="margin:14px 0">
|
||||||
|
<span><i style="background:var(--on)"></i>passt locker</span>
|
||||||
|
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
|
||||||
|
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-3" id="cb-grid"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div id="cb-recipe-modal" class="modal-overlay" style="display:none">
|
||||||
|
<div class="modal-card" style="max-width:580px">
|
||||||
|
<button id="cb-r-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||||
|
<h3 id="cb-r-title">Setup</h3>
|
||||||
|
<p class="text-mut text-sm" id="cb-r-desc" style="margin:-4px 0 16px"></p>
|
||||||
|
<div id="cb-r-models"></div>
|
||||||
|
<button class="primary" id="cb-r-install" style="width:100%;margin-top:16px">Komplettes Setup installieren</button>
|
||||||
|
<div class="hint" style="margin-top:8px">Lädt alle Modelle herunter und pflegt sie mit optimalem Kontext ein. Fortschritt in der Aktivität.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend mb-4">
|
|
||||||
<span><i style="background:var(--on)"></i>passt locker</span>
|
|
||||||
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
|
|
||||||
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-3" id="cb-grid"></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>
|
||||||
<h3 id="cb-m-title">Modell</h3>
|
<h3 id="cb-m-title">Modell</h3>
|
||||||
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
|
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
|
||||||
|
|
||||||
<label>Quantisierung (GGUF-Datei) wählen</label>
|
<label>Quantisierung (GGUF-Datei) wählen</label>
|
||||||
<select id="cb-m-files"></select>
|
<select id="cb-m-files"></select>
|
||||||
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
|
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
|
||||||
|
|
||||||
<div class="row" style="margin-top:4px">
|
<div class="row" style="margin-top:4px">
|
||||||
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
|
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
|
||||||
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
|
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" id="cb-m-ctx-rec" style="margin:-6px 0 14px"></div>
|
<div class="hint" id="cb-m-ctx-rec" style="margin:-6px 0 14px"></div>
|
||||||
|
|
||||||
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
|
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
|
||||||
<div><div style="font-size:13px">Ressourcen-Check</div>
|
<div><div style="font-size:13px">Ressourcen-Check</div>
|
||||||
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
|
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne…</div></div>
|
||||||
<span id="cb-m-fit-badge"></span>
|
<span id="cb-m-fit-badge"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen & Einpflegen</button>
|
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen & Einpflegen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -96,75 +100,99 @@ function mount() {
|
|||||||
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
$("#cb-m-files").addEventListener("change", updateLiveFit);
|
||||||
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
|
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
|
||||||
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-recipe-modal").addEventListener("click", e => { if (e.target.id === "cb-recipe-modal") $("#cb-recipe-modal").style.display = "none"; });
|
||||||
|
|
||||||
renderHwChip();
|
renderHwChip();
|
||||||
renderCurated();
|
loadRecipes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Use-Case-Setups ----
|
||||||
|
async function loadRecipes() {
|
||||||
|
try {
|
||||||
|
const d = await api("/api/cookbook/recipes");
|
||||||
|
RECIPES = d.recipes || [];
|
||||||
|
renderRecipes();
|
||||||
|
} catch (e) {
|
||||||
|
$("#cb-recipes").innerHTML = `<div class="alert err" style="grid-column:1/-1">Setups nicht ladbar: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecipes() {
|
||||||
|
$("#cb-recipes").innerHTML = RECIPES.map(r => `
|
||||||
|
<button class="card-btn" data-recipe="${esc(r.id)}">
|
||||||
|
<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>
|
||||||
|
<span class="fit-badge ${fitCls(r.fit_level)}">${fitWord(r.fit_level)}</span>
|
||||||
|
</div>
|
||||||
|
<p>${esc(r.desc)}</p>
|
||||||
|
<div class="text-xs text-mut">${r.models.length} Modell${r.models.length > 1 ? "e" : ""} im Setup</div>
|
||||||
|
</button>`).join("");
|
||||||
|
$("#cb-recipes").querySelectorAll("[data-recipe]").forEach(b =>
|
||||||
|
b.addEventListener("click", () => openRecipe(b.getAttribute("data-recipe"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecipe(id) {
|
||||||
|
const r = RECIPES.find(x => x.id === id); if (!r) return;
|
||||||
|
$("#cb-r-title").textContent = r.title;
|
||||||
|
$("#cb-r-desc").textContent = r.desc;
|
||||||
|
$("#cb-r-models").innerHTML = `<div class="list">${r.models.map(m => `
|
||||||
|
<div class="li" style="align-items:flex-start">
|
||||||
|
<div class="li-main">
|
||||||
|
<div class="flex items-center gap-2" style="flex-wrap:wrap">
|
||||||
|
<span class="li-id">${esc(m.name)}</span><span class="tag text">${esc(m.role)}</span></div>
|
||||||
|
<div class="li-sub">${esc(m.why)}</div>
|
||||||
|
<div class="li-sub mono-sm">~${m.fit.req_gb.toFixed(1)} GB · ~${Math.round(m.fit.tps)} Tok/s · optimal ~${Math.round(m.optimal_ctx / 1024)}k Kontext</div>
|
||||||
|
</div>
|
||||||
|
<span class="fit-badge ${fitCls(m.fit.level)}">${esc(m.fit.text)}</span>
|
||||||
|
</div>`).join("")}</div>`;
|
||||||
|
const btn = $("#cb-r-install");
|
||||||
|
btn.className = r.fit_level === "too_tight" ? "primary warn" : "primary";
|
||||||
|
btn.textContent = r.fit_level === "too_tight" ? "Trotzdem installieren (zu groß)" : "Komplettes Setup installieren";
|
||||||
|
btn.onclick = () => installRecipe(r.id);
|
||||||
|
$("#cb-recipe-modal").style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRecipe(id) {
|
||||||
|
const btn = $("#cb-r-install"); btn.disabled = true; btn.textContent = "Starte…";
|
||||||
|
try {
|
||||||
|
const r = await api("/api/cookbook/install-recipe", { method: "POST", body: JSON.stringify({ recipe_id: id }) });
|
||||||
|
toast(`Setup wird installiert (${r.count} Modelle) — siehe Aktivität.`);
|
||||||
|
$("#cb-recipe-modal").style.display = "none";
|
||||||
|
document.querySelector(".nav-item[data-view='activity']")?.click();
|
||||||
|
} catch (e) { toast("Fehler: " + e.message, true); }
|
||||||
|
btn.disabled = false; btn.textContent = "Komplettes Setup installieren";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hardware-Chip / Filter ----
|
||||||
function renderFilters() {
|
function renderFilters() {
|
||||||
$("#cb-filters").innerHTML = FILTERS.map(f =>
|
$("#cb-filters").innerHTML = FILTERS.map(f =>
|
||||||
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`
|
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`).join("");
|
||||||
).join("");
|
|
||||||
$("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
|
$("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
|
||||||
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
|
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHwChip() {
|
function renderHwChip() {
|
||||||
const el = $("#cb-hw"); if (!el) return;
|
const el = $("#cb-hw"); if (!el) return;
|
||||||
if (lastSys?.ram?.total) {
|
if (lastSys?.ram?.total) el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
|
||||||
el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Karten ----
|
// ---- Profi-Suche (HuggingFace) ----
|
||||||
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
|
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
|
||||||
|
|
||||||
function curatedCard(m, i, fit) {
|
|
||||||
return `<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-cur="${i}">
|
|
||||||
<div class="flex justify-between items-center"><h3 style="margin:0;font-size:15px;font-weight:500">${esc(m.name)}</h3>
|
|
||||||
<span class="fit-badge ${fitCls(fit.level)}">${esc(fit.text)}</span></div>
|
|
||||||
<div class="text-sm text-mut" style="margin:12px 0;flex:1;line-height:1.5">${esc(m.desc)}</div>
|
|
||||||
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
|
|
||||||
<span class="mono-sm">${metricLine(fit)}</span><span class="mono-sm">${esc(m.quant)}</span></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderCurated() {
|
|
||||||
$("#cb-section-title").textContent = "Empfohlen für deine Hardware";
|
|
||||||
const grid = $("#cb-grid"); if (!grid) return;
|
|
||||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Berechne Hardware-Fit…</div>`;
|
|
||||||
try {
|
|
||||||
let html = "";
|
|
||||||
for (let i = 0; i < CURATED.length; i++) {
|
|
||||||
const m = CURATED[i];
|
|
||||||
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
|
|
||||||
html += curatedCard(m, i, fit);
|
|
||||||
}
|
|
||||||
grid.innerHTML = html;
|
|
||||||
grid.querySelectorAll("[data-cur]").forEach(el => el.addEventListener("click", () => openCurated(+el.getAttribute("data-cur"))));
|
|
||||||
} catch (e) {
|
|
||||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Konnte Empfehlungen nicht laden: ${esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
let q = $("#cb-search").value.trim();
|
let q = $("#cb-search").value.trim();
|
||||||
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
|
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
|
||||||
if (!q) return renderCurated();
|
if (!q) { $("#cb-grid").innerHTML = ""; return; }
|
||||||
|
|
||||||
const sort = $("#cb-sort")?.value || "downloads";
|
const sort = $("#cb-sort")?.value || "downloads";
|
||||||
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
|
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
|
||||||
$("#cb-section-title").textContent = "Suchergebnisse: " + q;
|
$("#cb-grid").innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
|
||||||
const grid = $("#cb-grid");
|
|
||||||
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
|
|
||||||
try {
|
try {
|
||||||
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`;
|
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`;
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
currentResults = await r.json();
|
currentResults = await r.json();
|
||||||
renderResults(currentResults);
|
renderResults(currentResults);
|
||||||
} catch (e) {
|
} catch (e) { $("#cb-grid").innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`; }
|
||||||
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
btn.disabled = false; btn.textContent = "Suchen";
|
btn.disabled = false; btn.textContent = "Suchen";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,12 +224,9 @@ async function fetchFitForCard(i, repo_id) {
|
|||||||
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
|
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
|
||||||
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
|
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
|
||||||
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
|
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
|
||||||
} catch {
|
} catch { const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; } }
|
||||||
const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Modal ----
|
|
||||||
function showFit() {
|
function showFit() {
|
||||||
const file = $("#cb-m-files").value;
|
const file = $("#cb-m-files").value;
|
||||||
const f = currentAnalysis?.files.find(x => x.filename === file);
|
const f = currentAnalysis?.files.find(x => x.filename === file);
|
||||||
@@ -211,8 +236,7 @@ function showFit() {
|
|||||||
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
|
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
|
||||||
const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec");
|
const opt = f.optimal_ctx, recEl = $("#cb-m-ctx-rec");
|
||||||
if (recEl) recEl.innerHTML = opt
|
if (recEl) recEl.innerHTML = opt
|
||||||
? `Empfohlener Kontext für deine Hardware: <b>~${Math.round(opt / 1024)}k</b> — <a href="#" onclick="event.preventDefault();window.cbSetCtx(${opt})">übernehmen</a>`
|
? `Empfohlener Kontext für deine Hardware: <b>~${Math.round(opt / 1024)}k</b> — <a href="#" onclick="event.preventDefault();window.cbSetCtx(${opt})">übernehmen</a>` : "";
|
||||||
: "";
|
|
||||||
const btn = $("#cb-m-download");
|
const btn = $("#cb-m-download");
|
||||||
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
|
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
|
||||||
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; }
|
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; }
|
||||||
@@ -253,19 +277,6 @@ async function openResult(i) {
|
|||||||
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
|
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCurated(i) {
|
|
||||||
const m = CURATED[i]; if (!m) return;
|
|
||||||
openModalBase(m.name, m.repo);
|
|
||||||
$("#cb-m-files").style.display = "block"; $("#cb-m-loading").style.display = "none";
|
|
||||||
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
|
|
||||||
$("#cb-m-alias").value = m.alias; $("#cb-m-ctx").value = m.ctx; $("#cb-m-download").disabled = false;
|
|
||||||
try {
|
|
||||||
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
|
|
||||||
currentAnalysis = { repo: m.repo, params_b: m.params_b, files: [{ filename: m.file, quant: m.quant, fit, optimal_ctx: fit.optimal_ctx }] };
|
|
||||||
showFit();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDownload() {
|
async function doDownload() {
|
||||||
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value;
|
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value;
|
||||||
const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
|
||||||
|
|||||||
Reference in New Issue
Block a user