Feat: Rollen-Empfehlung - bestes installiertes Modell je Rolle (Auto-Pick)

Analog zum Auto-ctx-Button: das Rollen-Zuweisungs-Modal empfiehlt jetzt, welches
INSTALLIERTE Modell am besten auf die Rolle passt - capability-getrieben (Vision/Coder/
Tools/MoE aus services.caps) + setup-bewusster Fit (services.budget, gleiche Mathematik
wie Install-Automatik & Auto-ctx).

- services/roles.py: recommend_for_role() rankt installierte Modelle (Eignung + Fit + Tempo
  + Wissen); harte Anforderungen (Vision braucht Vision, Hirn braucht Tools) schliessen aus.
- GET /api/roles/{role}/recommend
- Cockpit-Modal: »Auto: <Modell>«-Button im Header, »Empfohlen«-Badge, Sortierung nach Score,
  pro Zeile Fit + Begruendung (~t/s); ungeeignete gedimmt mit Klartext-Grund.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 14:37:37 +02:00
parent 1e011714dd
commit 763e634dfd
8 changed files with 281 additions and 108 deletions
+8
View File
@@ -151,6 +151,14 @@ class RoleReq(BaseModel):
role: str | None = None
@router.get("/roles/{role}/recommend")
def recommend_role(role: str) -> dict:
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
from services import roles
return roles.recommend_for_role(role)
@router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict:
if not llamaswap.set_role(model_id, body.role):
+109
View File
@@ -0,0 +1,109 @@
"""
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
"""
import psutil
from services import budget, llamaswap
from services.fit import evaluate_fit
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
def _suitability(role: str, caps: dict, params: float, name: str) -> float:
"""0..1 — wie gut passt die Capability eines Modells zur Rolle. Harte Anforderungen
(Vision braucht Vision) geben 0 bei Nichterfüllung; weiche Präferenzen skalieren."""
role = (role or "").lower()
low = (name or "").lower()
vision = bool(caps.get("vision"))
coder = bool(caps.get("coder"))
tools = caps.get("tools") != "no"
moe = bool(caps.get("moe"))
if role == "vision":
return 1.0 if vision else 0.0 # harte Anforderung
if role == "coder":
return 1.0 if coder else 0.45 # Coder bevorzugt, andere notfalls
if role == "hermes":
# Agent-Hirn: natives Tool-Calling Pflicht; Hermes-Familie am robustesten.
if "hermes" in low:
return 1.0
return 0.85 if tools else 0.15
if role == "fast":
# schnelles Alltags-Hirn: klein/MoE bevorzugt (niedrige aktive Params = Tempo).
return 1.0 if (moe or params <= 40) else 0.5
if role == "heavy":
# schweres Reasoning: Wissen = Gesamt-Params (groß bevorzugt).
return min(params / 70.0, 1.0)
if role == "scout":
# Multimodal-Allrounder: Vision ein Plus, sonst solide Basis.
return 0.9 if vision else 0.7
return 0.5
def _reason(role: str, caps: dict, fit: dict, suit: float, fits: bool, incomplete: bool) -> str:
if incomplete:
return "Download unvollständig"
if role == "vision" and not caps.get("vision"):
return "keine Vision-Fähigkeit"
if role == "hermes" and caps.get("tools") == "no":
return "kein natives Tool-Calling"
if not fits:
return "passt nicht ins Budget (OOM)"
bits = []
if role == "vision":
bits.append("Vision ✓")
if role == "coder" and caps.get("coder"):
bits.append("Coder ✓")
if role == "hermes":
bits.append("Tools ✓" if caps.get("tools") != "no" else "ohne Tools")
if caps.get("moe"):
bits.append("MoE")
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
return " · ".join(bits)
def recommend_for_role(role: str) -> dict:
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
role = (role or "").strip().lower()
ram = _ram_gb()
out = []
for m in llamaswap.list_models():
caps = m.get("capabilities") or {}
params = budget.params_of_model(m)
quant = m.get("quant") or "Q4_K_M"
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
incomplete = bool(m.get("incomplete"))
fits = (fit["level"] != "too_tight") and not incomplete
suit = _suitability(role, caps, params, m["name"])
suitable = suit >= 0.5 and fits
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
score = (2.0 * suit) + fit_term \
+ min((fit["tps"] or 0) / 80.0, 1.0) * 0.5 \
+ min(params / 120.0, 1.0) * 0.5
if not fits:
score -= 5.0
out.append({
"name": m["name"], "current_role": m.get("role"),
"params_b": round(params, 1), "quant": quant,
"fit": fit, "suitable": suitable, "incomplete": incomplete,
"score": round(score, 3),
"reason": _reason(role, caps, fit, suit, fits, incomplete),
})
out.sort(key=lambda x: -x["score"])
rec = next((o["name"] for o in out if o["suitable"]), None)
for o in out:
o["recommended"] = (o["name"] == rec)
return {"role": role, "recommended": rec, "models": out}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-eaeyLEVq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CqYu-pXY.css">
<script type="module" crossorigin src="/assets/index-Pcp4609T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cp70TbqQ.css">
</head>
<body>
<div id="root"></div>
+19
View File
@@ -73,6 +73,25 @@ export interface Fit {
tps: number
}
export interface RoleRecModel {
name: string
current_role: string | null
params_b: number
quant: string
fit: Fit
suitable: boolean
incomplete: boolean
score: number
reason: string
recommended: boolean
}
export interface RoleRecResp {
role: string
recommended: string | null
models: RoleRecModel[]
}
export interface FitResp {
params_b: number
fit: Fit
+61 -24
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Copy, Zap, Bot } from "lucide-react"
import { api, type ModelInfo } from "@/lib/api"
import { api, type ModelInfo, type RoleRecResp } from "@/lib/api"
import { useModels, useRouting, useConnect, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { CapsChips } from "@/components/CapsChips"
@@ -29,6 +29,7 @@ export function Cockpit() {
// UI state
const [activeClient, setActiveClient] = useState<"roocode" | "cursor" | "opencode" | "zed" | "continue" | null>(null)
const [activeRoleForAssign, setActiveRoleForAssign] = useState<string | null>(null)
const [roleRec, setRoleRec] = useState<RoleRecResp | null>(null)
const [specModel, setSpecModel] = useState<ModelInfo | null>(null)
const [showBrainSwitch, setShowBrainSwitch] = useState(false)
const [copied, setCopied] = useState(false)
@@ -131,6 +132,15 @@ export function Cockpit() {
}
}
// Rollen-Modal öffnen + setup-bewusste Empfehlung holen (bestes installiertes Modell).
function openRoleAssign(role: string) {
setActiveRoleForAssign(role)
setRoleRec(null)
api<RoleRecResp>(`/api/roles/${encodeURIComponent(role)}/recommend`)
.then((d) => setRoleRec(d))
.catch(() => { /* Empfehlung optional — Modal funktioniert auch ohne */ })
}
async function handleSetCtx(name: string, cur: number | null) {
// Setup-bewussten Optimalwert holen (Rolle/Params/Quant + aktuelles Setup).
let auto: { ctx: number; gtt_gb: number; reserved_gb: number; budget_gb: number; mode: string } | null = null
@@ -518,7 +528,7 @@ export function Cockpit() {
: "border-dashed border-border/40 bg-background/20"
)}
style={{ left: "90%", top: yPositions[indexMap] }}
onClick={() => setActiveRoleForAssign(role)}
onClick={() => openRoleAssign(role)}
>
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{role}</span>
@@ -660,7 +670,7 @@ export function Cockpit() {
return (
<div
key={role}
onClick={() => setActiveRoleForAssign(role)}
onClick={() => openRoleAssign(role)}
className={cn(
"rounded-xl border p-3 flex flex-col justify-between gap-2.5 transition-all cursor-pointer select-none text-left bg-background/25 hover:border-primary/45 hover:bg-background/40 group min-h-[90px]",
isWarm
@@ -1125,7 +1135,16 @@ export function Cockpit() {
</div>
{/* Role Assignment Modal */}
{activeRoleForAssign && (
{activeRoleForAssign && (() => {
const rec = roleRec && roleRec.role === activeRoleForAssign ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
@@ -1139,45 +1158,63 @@ export function Cockpit() {
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell aus deiner Bibliothek für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
Wähle ein Modell für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
</p>
{rec?.recommended && (
<button
onClick={() => assign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
<button
onClick={() => {
handleRoleChange(activeRoleForAssign, "")
setActiveRoleForAssign(null)
}}
onClick={() => assign("")}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
{models.map((m) => (
{ordered.map((m) => {
const r = recByName[m.name]
const isCur = m.role === activeRoleForAssign
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => {
handleRoleChange(activeRoleForAssign, m.name)
setActiveRoleForAssign(null)
}}
onClick={() => assign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border border-border/30",
m.role === activeRoleForAssign
? "text-primary font-bold bg-primary/10 border-primary/30"
: "text-foreground bg-background/20"
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left">
<span className="truncate max-w-[280px] font-semibold">{m.name.split("/").pop()?.replace(".gguf", "")}</span>
<span className="text-[9px] text-muted-foreground mt-0.5">{fmtSize(m.size_bytes)} · {m.quant}</span>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{m.role === activeRoleForAssign && <Check className="h-4 w-4 shrink-0 text-primary" />}
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
))}
)
})}
</div>
</div>
</div>
)}
)
})()}
{specModel && (
<SpecDraftModal