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:
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+76
-76
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user