diff --git a/routers/models.py b/routers/models.py index 18ef8f9..4259af4 100644 --- a/routers/models.py +++ b/routers/models.py @@ -98,11 +98,15 @@ def status(): 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, "ttl": spec.get("ttl", cfg.get("globalTTL", 0)), "cmd": cmd, "state": "idle", + "incomplete": incomplete, "port": None, "meta": { "ctx": ctx, @@ -157,12 +161,29 @@ def download(req: DownloadReq): return {"job_id": job_id, "expected_path": str(target / req.file)} +def _augment_vision(cmd: str, model_path: str) -> str: + """Liegt im selben Ordner ein mmproj-Projektor, --mmproj + --jinja ergaenzen — + Vision-Modelle brauchen das in llama.cpp (siehe CLAUDE.md). No-op, wenn schon gesetzt + oder kein Projektor da.""" + if "--mmproj" in cmd: + return cmd + try: + d = os.path.dirname(model_path) + for f in sorted(os.listdir(d)): + if "mmproj" in f.lower() and f.lower().endswith(".gguf"): + return cmd.rstrip() + f" --mmproj {os.path.join(d, f)} --jinja" + except OSError: + pass + return cmd + + @router.post("/register") def register(req: RegisterReq): # Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt # erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist. 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] = { "cmd": LiteralScalarString(cmd + "\n"), "ttl": req.ttl if req.ttl is not None else DEFAULT_TTL, diff --git a/static/js/panels/models.js b/static/js/panels/models.js index fe7543b..c7fcf3f 100644 --- a/static/js/panels/models.js +++ b/static/js/panels/models.js @@ -81,6 +81,19 @@ function onStatus(s) { const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = ""; tb.innerHTML = ALL.map(m => { + if (m.incomplete) { + // Leere Rolle (Platzhalter ohne Modell-Datei) — ehrlich kennzeichnen statt „bereit". + return ` + ${esc(m.name)}
kein Modell hinterlegt
+ — + leere Rolle + leer + — + + + + `; + } const fn = m.meta?.filename ? `
${esc(m.meta.filename)}
` : ""; return ` ${esc(m.name)}${fn} @@ -94,12 +107,16 @@ function onStatus(s) { `; }).join(""); - for (const m of ALL) sel.insertAdjacentHTML("beforeend", ``); + for (const m of ALL) if (!m.incomplete) sel.insertAdjacentHTML("beforeend", ``); if (cur) sel.value = cur; 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-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(); + })); } async function deleteModel(m) {