diff --git a/llamaswap.py b/llamaswap.py index 264299c..9fb896a 100644 --- a/llamaswap.py +++ b/llamaswap.py @@ -7,11 +7,52 @@ Helfer rund um llama-swap und dessen config.yaml. """ import os +import re import httpx from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml +# Kanonische Rollen (Tags auf den Modellen). Bewusst eine kleine, feste Palette fuer die +# UI-Schnellwahl — frei waehlbare Rollen sind trotzdem erlaubt. +ROLE_IDS = {"vision", "coder", "scout", "reviewer", "manager"} + + +def model_id_from_path(model_path: str) -> str: + """Sprechende Modell-ID (= llama-swap-Modellname, den man in der API angibt) aus dem + GGUF-Pfad ableiten: der Repo-Ordnername ohne '-GGUF'. Fallback: Dateiname ohne + Quant-Suffix/.gguf. So steht in der Modell-Liste der echte Name statt 'coder'.""" + d = os.path.basename(os.path.dirname(model_path)) + name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_") + if not name: + fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I) + fn = re.sub(r"-\d+-of-\d+$", "", fn) + name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I) + return name or "modell" + + +def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None: + """Rolle als llama-swap-`aliases` auf ein Modell legen (damit der Rollenname in der API + ebenfalls funktioniert). Haelt den Alias EINDEUTIG: derselbe Rollen-Alias wird vorher + bei allen anderen Modellen entfernt. role=None/leer entfernt den Alias.""" + models = cfg.get("models") or {} + role = (role or "").strip().lower() + if role: + for mid, spec in models.items(): + if mid == model_id or not isinstance(spec, dict): + continue + al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role] + if al: + spec["aliases"] = al + else: + spec.pop("aliases", None) + spec = models.get(model_id) + if isinstance(spec, dict): + if role and role != model_id.lower(): + spec["aliases"] = [role] + else: + spec.pop("aliases", None) + def _swap_get(path: str): with httpx.Client(timeout=5.0) as c: diff --git a/routers/cookbook.py b/routers/cookbook.py index 6ebb72f..219c3e8 100644 --- a/routers/cookbook.py +++ b/routers/cookbook.py @@ -17,7 +17,7 @@ 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, USER_RECIPES_PATH, DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin) -from llamaswap import read_config, write_config +from llamaswap import read_config, write_config, model_id_from_path, set_role_alias from jobengine import start_job, JOBS, attach_download_progress from recipes import RECIPES, UPGRADES from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS @@ -255,7 +255,10 @@ def install_recipe(req: InstallRecipeReq): ctx = min(max_ctx_for(m["params_b"], m["quant"], ram_gb), 32768) path = str(target / file) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) - cfg["models"][m["role"]] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} + # Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias. + mid = model_id_from_path(path) + cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} + set_role_alias(cfg, mid, m["role"]) write_config(cfg) return {"job_ids": job_ids, "count": len(job_ids)} @@ -484,8 +487,11 @@ def install_model(req: InstallModelReq): attach_download_progress(jid, str(target), hf_file_size(req.repo, file)) cfg = read_config() ctx = min(max_ctx_for(req.params_b, req.quant, ram_gb), 32768) - cmd = CMD_TEMPLATE.replace("{model}", str(target / file)).replace("{ctx}", str(ctx)) - cfg["models"][req.role] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} + path = str(target / file) + cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) + mid = model_id_from_path(path) + cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL} + set_role_alias(cfg, mid, req.role) write_config(cfg) - return {"job_id": jid} + return {"job_id": jid, "model_id": mid} diff --git a/routers/models.py b/routers/models.py index 4259af4..068b61c 100644 --- a/routers/models.py +++ b/routers/models.py @@ -17,7 +17,8 @@ from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLA MODELS_DIR, TOKEN, hf_bin) from jobengine import JOBS, start_job, attach_download_progress from routers.cookbook import hf_file_size -from llamaswap import _swap_get, read_config, write_config +from llamaswap import (_swap_get, read_config, write_config, + model_id_from_path, set_role_alias, ROLE_IDS) from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb import re import os @@ -38,12 +39,18 @@ class DownloadReq(BaseModel): class RegisterReq(BaseModel): - alias: str + alias: str = "" # rueckwaertskompatibel: wird als Rolle interpretiert, wenn 'role' fehlt + role: str | None = None # Rollen-Tag (vision/coder/scout/reviewer/manager o.ae.) model_path: str ctx: int = 8192 ttl: int | None = None +class RoleReq(BaseModel): + alias: str # die Modell-ID (config-Key) + role: str = "" # leer = Rolle entfernen + + class ChatReq(BaseModel): model: str message: str @@ -92,17 +99,28 @@ def status(): if q_match: quant = q_match.group(1).upper() + # Rolle aus dem llama-swap-Alias ableiten; Legacy-Eintraege ohne Alias, deren Key + # selbst eine Rolle ist (coder/vision/...), behalten diese als Rolle. + aliases = spec.get("aliases") or [] + if isinstance(aliases, str): + aliases = [aliases] + aliases = [str(a) for a in aliases] + role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None) + caps = ["Text"] - if "coder" in name.lower() or (m_path and "code" in m_path.group(1).lower()): + if role == "coder" or "coder" in name.lower() or (m_path and "code" in m_path.group(1).lower()): caps = ["Code"] if "--mmproj" in cmd: caps.append("Bild") - + # "incomplete" = Rolle existiert in der config, hat aber kein Modell (-m) hinterlegt # (z.B. von provision.sh angelegter Platzhalter). Ehrlich kennzeichnen statt "bereit". incomplete = not (m_path and m_path.group(1)) configured[name] = { "name": name, + "role": role, + "aliases": aliases, + "api_ids": [name] + aliases, "ttl": spec.get("ttl", cfg.get("globalTTL", 0)), "cmd": cmd, "state": "idle", @@ -184,15 +202,31 @@ def register(req: RegisterReq): cfg = read_config() cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx)) cmd = _augment_vision(cmd, req.model_path) - cfg["models"][req.alias] = { + # Neues Schema: Schluessel = sprechender Modellname (steht so in der Modell-Liste und ist + # der API-Name), die Rolle kommt als llama-swap-Alias obendrauf (beide Namen funktionieren). + role = (req.role or req.alias or "").strip().lower() + model_id = model_id_from_path(req.model_path) + cfg["models"][model_id] = { "cmd": LiteralScalarString(cmd + "\n"), "ttl": req.ttl if req.ttl is not None else DEFAULT_TTL, } + set_role_alias(cfg, model_id, role) write_config(cfg) - return {"ok": True, "alias": req.alias, + return {"ok": True, "alias": model_id, "model_id": model_id, "role": role, "note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."} +@router.post("/set_role") +def set_role(req: RoleReq): + """Rollen-Tag eines Modells setzen/aendern (als eindeutiger llama-swap-Alias).""" + cfg = read_config() + if req.alias not in cfg.get("models", {}): + raise HTTPException(404, "Modell nicht gefunden.") + set_role_alias(cfg, req.alias, req.role.strip().lower() or None) + write_config(cfg) + return {"ok": True, "alias": req.alias, "role": req.role.strip().lower()} + + @router.post("/update_model") def update_model(req: UpdateReq): cfg = read_config() diff --git a/static/js/panels/cookbook.js b/static/js/panels/cookbook.js index 97fad19..ee5bc16 100644 --- a/static/js/panels/cookbook.js +++ b/static/js/panels/cookbook.js @@ -109,7 +109,7 @@ function mount() {
-
+
@@ -471,7 +471,7 @@ function openModalBase(title, repo) { async function openResult(i) { const m = currentResults[i]; if (!m) return; openModalBase(m.id.split("/").pop(), m.id); - $("#cb-m-alias").value = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9]/g, "-"); + $("#cb-m-alias").value = ""; $("#cb-m-files").style.display = "none"; $("#cb-m-loading").style.display = "block"; $("#cb-m-download").disabled = true; try { const ctx = parseInt($("#cb-m-ctx").value) || 8192; @@ -488,12 +488,12 @@ async function openResult(i) { async function doDownload() { 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; - if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true); + const role = $("#cb-m-alias").value.trim().toLowerCase(), ctx = parseInt($("#cb-m-ctx").value) || 8192; + if (!repo || !file) return toast("Bitte eine GGUF-Datei wählen.", true); const btn = $("#cb-m-download"); btn.disabled = true; btn.textContent = "Starte…"; try { const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file, hf_token: getHfToken() }) }); - await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path: res.expected_path, ctx }) }); + await api("/api/register", { method: "POST", body: JSON.stringify({ role, model_path: res.expected_path, ctx }) }); toast("Download gestartet — siehe Aktivität."); $("#cb-modal").style.display = "none"; document.querySelector(".nav-item[data-view='activity']")?.click(); diff --git a/static/js/panels/models.js b/static/js/panels/models.js index c7fcf3f..c5bb22f 100644 --- a/static/js/panels/models.js +++ b/static/js/panels/models.js @@ -4,10 +4,32 @@ import { api } from "../core/api.js"; import { $, badge, esc, toast, confirmModal, infoDot } from "../core/ui.js"; const CTX_HELP = "Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr (längere Dateien/Chats), braucht aber mehr Speicher und wird etwas langsamer."; +const ROLE_HELP = "Die Rolle ist ein zusätzlicher, sprechender Name (z.B. coder). Du kannst in deinen Tools entweder den echten Modellnamen ODER die Rolle angeben — beides führt zum selben Modell."; + +// Kanonische Rollen für die Schnellwahl (frei tippbar bleibt möglich). +const ROLES = [ + { id: "coder", label: "Coder", desc: "Programmieren & Code" }, + { id: "vision", label: "Vision", desc: "Bilder verstehen" }, + { id: "scout", label: "Scout", desc: "Schneller Allrounder" }, + { id: "reviewer", label: "Reviewer", desc: "Code/Texte prüfen" }, + { id: "manager", label: "Manager", desc: "Planen & koordinieren" }, +]; let ALL = []; function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); } +function roleTag(role) { + return role ? `${esc(role)}` : ""; +} +// Zeigt die API-Namen (echter Name + Rollen-Alias) mit Kopier-Knopf — das, was man im Tool angibt. +function apiIdLine(m) { + const ids = (m.api_ids && m.api_ids.length ? m.api_ids : [m.name]); + return `
+ API-Name: + ${ids.map(id => `${esc(id)}`).join('·')} +
`; +} + function capTags(caps) { if (!caps?.length) return "–"; return caps.map(c => @@ -65,12 +87,54 @@ function mount() {
Es läuft immer nur ein Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.
+ + + `; $("#chat-btn").addEventListener("click", sendChat); $("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none"); $("#cfg-modal").addEventListener("click", e => { if (e.target.id === "cfg-modal") $("#cfg-modal").style.display = "none"; }); $("#cfg-save").addEventListener("click", saveConfig); + $("#role-close").addEventListener("click", () => $("#role-modal").style.display = "none"); + $("#role-modal").addEventListener("click", e => { if (e.target.id === "role-modal") $("#role-modal").style.display = "none"; }); + $("#role-save").addEventListener("click", () => saveRole($("#role-custom").value.trim().toLowerCase())); + $("#role-clear").addEventListener("click", () => saveRole("")); +} + +let roleTarget = null; +function openRole(name) { + roleTarget = name; + const m = ALL.find(x => x.name === name); + $("#role-model-name").textContent = name; + $("#role-custom").value = ""; + $("#role-chips").innerHTML = ROLES.map(r => + ``).join(""); + $("#role-chips").querySelectorAll("[data-rolepick]").forEach(b => + b.addEventListener("click", () => saveRole(b.getAttribute("data-rolepick")))); + $("#role-modal").style.display = "flex"; +} +async function saveRole(role) { + if (!roleTarget) return; + try { + await api("/api/set_role", { method: "POST", body: JSON.stringify({ alias: roleTarget, role }) }); + toast(role ? `Rolle gesetzt: ${role}` : "Rolle entfernt."); + $("#role-modal").style.display = "none"; + refreshSoon(); + } catch (e) { toast(e.message, true); } } function onStatus(s) { @@ -96,12 +160,15 @@ function onStatus(s) { } const fn = m.meta?.filename ? `
${esc(m.meta.filename)}
` : ""; return ` - ${esc(m.name)}${fn} + +
${esc(m.name)}${roleTag(m.role)}
+ ${fn}${apiIdLine(m)} ${capTags(m.meta?.caps)} ${details(m.meta)} ${badge(m.state)} ${m.port ?? "auto"} + @@ -113,6 +180,10 @@ function onStatus(s) { tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload")))); tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg")))); tb.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteModel(b.getAttribute("data-del")))); + tb.querySelectorAll("[data-role]").forEach(b => b.addEventListener("click", () => openRole(b.getAttribute("data-role")))); + tb.querySelectorAll("[data-copy]").forEach(c => c.addEventListener("click", () => { + navigator.clipboard?.writeText(c.getAttribute("data-copy")); toast("Kopiert: " + c.getAttribute("data-copy")); + })); tb.querySelectorAll("[data-assign]").forEach(b => b.addEventListener("click", () => { toast("Lade im Cookbook ein Modell und setze den Alias auf: " + b.getAttribute("data-assign")); document.querySelector(".nav-item[data-view='cookbook']")?.click();