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
+66 -29
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,59 +1135,86 @@ 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">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary">
Rolle '{activeRoleForAssign}' konfigurieren
</h3>
<button
<button
onClick={() => setActiveRoleForAssign(null)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-xs text-muted-foreground">
Wähle ein Modell aus deiner Bibliothek für die Rolle <strong className="text-foreground">{activeRoleForAssign}</strong>:
</p>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
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)
}}
<button
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