feat(rollen): echte Modellnamen als API-ID + Rollen als Tags/Alias
Modelle erscheinen jetzt mit ihrem ECHTEN Namen (= API-Name, den man in Zed/ OpenCode angibt) statt unter "coder"/"vision". Die Rolle wird ein zusaetzlicher, sprechender Name (llama-swap-`aliases`) -> in der API funktionieren BEIDE. - llamaswap.py: model_id_from_path (Repo-Ordner ohne -GGUF) + set_role_alias (Rolle als eindeutiger Alias, beim Setzen bei anderen Modellen entfernt) + ROLE_IDS (vision/coder/scout/reviewer/manager). - models.py: register schreibt Key=Modellname, Rolle als Alias; /status liefert role + aliases + api_ids; neuer POST /set_role. Rueckwaertskompatibel: Legacy- Eintraege (coder/vision ohne Alias) behalten ihren Key als Rolle. - cookbook.py: install-recipe/-model nutzen dasselbe Schema (Name + Rollen-Alias). - models.js: Zeile zeigt echten Namen + Rollen-Tag + API-Name (klick=kopieren); neuer "Rolle"-Dialog mit 5 Schnellwahl-Rollen + freier Eingabe. - cookbook.js: Profi-Download-Feld ist jetzt optionale Rolle (Name kommt vom Modell), Pflichtfeld-Pruefung entsprechend gelockert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+11
-5
@@ -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}
|
||||
|
||||
|
||||
+40
-6
@@ -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()
|
||||
|
||||
@@ -109,7 +109,7 @@ function mount() {
|
||||
<select id="cb-m-files"></select>
|
||||
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace…</div>
|
||||
<div class="row" style="margin-top:4px">
|
||||
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
|
||||
<div><label>Rolle (optional)</label><input id="cb-m-alias" placeholder="z.B. coder, vision, scout"></div>
|
||||
<div><label>Kontext-Größe ${infoDot(CTX_HELP)}</label><input id="cb-m-ctx" type="number" value="8192"></div>
|
||||
</div>
|
||||
<div class="hint" id="cb-m-ctx-rec" style="margin:-6px 0 14px"></div>
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? `<span class="tag" style="background:rgba(45,212,191,.14);color:var(--accent);border-color:rgba(45,212,191,.3)">${esc(role)}</span>` : "";
|
||||
}
|
||||
// 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 `<div class="li-sub mono-sm" style="margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<span class="text-mut">API-Name:</span>
|
||||
${ids.map(id => `<code style="cursor:pointer" data-copy="${esc(id)}" title="Klicken zum Kopieren">${esc(id)}</code>`).join('<span class="text-mut">·</span>')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function capTags(caps) {
|
||||
if (!caps?.length) return "–";
|
||||
return caps.map(c =>
|
||||
@@ -65,12 +87,54 @@ function mount() {
|
||||
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
|
||||
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="role-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-card">
|
||||
<button id="role-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
|
||||
<h3>Rolle festlegen ${infoDot(ROLE_HELP)}</h3>
|
||||
<p class="mono-sm" id="role-model-name" style="margin:-4px 0 16px"></p>
|
||||
<div class="hint" style="margin-bottom:10px">Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach <b>beide</b>: der echte Modellname und die Rolle.</div>
|
||||
<div id="role-chips" class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<label>Eigene Rolle (optional)</label>
|
||||
<input id="role-custom" placeholder="z.B. uebersetzer">
|
||||
<div class="btn-row" style="margin-top:14px;display:flex;gap:8px">
|
||||
<button class="ghost" id="role-clear" style="flex:1">Rolle entfernen</button>
|
||||
<button class="primary" id="role-save" style="flex:2">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
$("#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 =>
|
||||
`<button class="${m?.role === r.id ? "primary" : "ghost"}" data-rolepick="${r.id}" title="${esc(r.desc)}" style="border-radius:999px">${esc(r.label)}</button>`).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 ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
|
||||
return `<tr>
|
||||
<td class="mid" style="font-weight:500">${esc(m.name)}${fn}</td>
|
||||
<td class="mid">
|
||||
<div style="font-weight:500;display:flex;align-items:center;gap:7px;flex-wrap:wrap">${esc(m.name)}${roleTag(m.role)}</div>
|
||||
${fn}${apiIdLine(m)}</td>
|
||||
<td>${capTags(m.meta?.caps)}</td>
|
||||
<td>${details(m.meta)}</td>
|
||||
<td>${badge(m.state)}</td>
|
||||
<td class="port">${m.port ?? "auto"}</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="ghost" data-role="${esc(m.name)}">Rolle</button>
|
||||
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
|
||||
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
|
||||
<button class="ghost del" data-del="${esc(m.name)}">Löschen</button>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user