Feat: Discover zukunftssicher (Recency-Score) + Agent-Hirn (Hermes) im Modell-Manager

- discover.rank_runnable: Score aus Fit + Recency (lastModified, Halbwertszeit ~9 Mon)
  + Capability (params, log) + Popularity (downloads, log) -> neuere Generationen bevorzugt.
- Agent-Hirn: neuer GET /api/agent/brain (aktuelles hermes-Modell + bestes NousResearch-
  Hermes-Update, versions-aware via Hermes-X.Y-Parsing). Cockpit zeigt "Agent-Hirn (Hermes)"-Karte
  mit aktuellem Brain + Aktualisieren-Button, wenn NousResearch eine neuere Generation hat
  (z.B. Hermes-4-14B -> Hermes-4.3-36B). Update installiert mit Rolle hermes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-27 02:44:27 +02:00
parent 8bb4e11f31
commit 510de69250
11 changed files with 606 additions and 411 deletions
+7 -1
View File
@@ -3,7 +3,7 @@
from fastapi import APIRouter
from pydantic import BaseModel
from services.agent import agent_status, update_brain_model
from services.agent import agent_status, hermes_brain_info, update_brain_model
router = APIRouter(prefix="/api")
@@ -17,6 +17,12 @@ def status() -> dict:
return agent_status()
@router.get("/agent/brain")
def brain_info() -> dict:
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
return hermes_brain_info()
@router.post("/agent/brain")
def set_brain_model(body: BrainReq) -> dict:
ok = update_brain_model(body.model)
+64
View File
@@ -6,14 +6,78 @@ Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
import logging
import os
import re
import httpx
import psutil
from config import ANYTHINGLLM_URL, HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL
log = logging.getLogger(__name__)
def _hermes_version(name: str) -> float | None:
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
low = (name or "").lower()
if "hermes" not in low:
return None
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
return float(m.group(1)) if m else None
def hermes_brain_info() -> dict:
"""Aktuelles Agent-Hirn (hermes-Rolle) + bestes verfügbares NousResearch-Hermes-Modell,
das auf diese Hardware passt. Für den Modell-Manager: Brain sichtbar + updatebar,
sobald NousResearch eine neuere Hermes-Generation veröffentlicht."""
from services import discover, llamaswap
from services.fit import evaluate_fit, extract_params_b
models = llamaswap.list_models()
cur = next((m for m in models if m.get("role") == "hermes"), None)
cur_ver = _hermes_version(cur["name"]) if cur else None
cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None
current = None
if cur:
current = {"name": cur["name"], "filename": cur.get("filename"),
"params_b": cur_params, "quant": cur.get("quant"),
"size_bytes": cur.get("size_bytes"), "version": cur_ver,
"gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")}
ram = psutil.virtual_memory().total / (1024 ** 3)
best = None
try:
cands = []
for r in discover._fetch_author_models("NousResearch"):
rid = r.get("id", "")
if "hermes" not in rid.lower():
continue
pb = extract_params_b(rid)
fit = evaluate_fit(pb, "Q4_K_M", 8192, ram, name=rid)
if fit["level"] == "too_tight":
continue
cands.append({"repo": rid, "name": rid.split("/")[-1],
"version": _hermes_version(rid) or 0.0, "params_b": pb,
"downloads": int(r.get("downloads") or 0), "fit": fit})
# neueste Hermes-Version zuerst, dann größer/fähiger, dann beliebter
cands.sort(key=lambda c: (c["version"], c["params_b"], c["downloads"]), reverse=True)
best = cands[0] if cands else None
except Exception:
log.debug("hermes_brain_info: HF-Abfrage fehlgeschlagen", exc_info=True)
update = False
if best is not None:
if cur_ver is None:
update = True
elif best["version"] > cur_ver:
update = True
elif best["version"] == cur_ver and best["params_b"] > (cur_params or 0) * 1.05:
update = True
# gleiche Datei schon installiert? dann kein Update
if current and best["repo"].split("/")[-1].lower() in (current["name"] or "").lower():
update = False
return {"current": current, "recommended": best, "update_available": update}
def _reach(url: str, path: str = "") -> bool:
try:
with httpx.Client(timeout=3.0) as c:
+34 -10
View File
@@ -9,8 +9,10 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import math
import os
import time
from datetime import datetime
import httpx
@@ -46,19 +48,41 @@ def _fetch_author_models(author: str) -> list:
return []
def _age_days(last_modified, now_ts: float) -> float:
"""Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre."""
if not last_modified:
return 540.0
try:
dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00"))
return max((now_ts - dt.timestamp()) / 86400.0, 0.0)
except Exception:
return 540.0
def _score(m: dict, now_ts: float) -> float:
"""Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert:
- Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst),
- Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified),
- Capability: mehr Parameter (log-skaliert),
- Popularity: Downloads (log-skaliert).
So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor
Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen."""
fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0
recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0)
cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0
pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0
return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop
def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware:
1) nur was komfortabel passt (perfect vor marginal, too_tight fliegt raus),
2) das FÄHIGSTE zuerst — mehr Parameter = mehr Können (bei MoE bleibt es dank
aktiver-Param-Schätzung schnell),
3) bei Gleichstand das meistgeladene.
So bevorzugt die 128-GB-Box große (MoE-)Modelle statt kleiner Populär-Modelle —
und „Modelle finden" schlägt nie ein Downgrade vor (z.B. 35B-A3B → 4B)."""
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware.
Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability
+ Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden"
schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance)."""
now_ts = time.time()
return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: (_FIT_ORDER[m["fit"]["level"]],
-float(m.get("params_b") or 0.0),
-int(m.get("downloads") or 0)),
key=lambda m: -_score(m, now_ts),
)
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
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-oijhNb41.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cx3ZKkHL.css">
<script type="module" crossorigin src="/assets/index-BPfAqjxb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bgft9fxe.css">
</head>
<body>
<div id="root"></div>
+26
View File
@@ -294,3 +294,29 @@ export interface ModelsResp {
models: ModelInfo[]
running?: string[]
}
export interface HermesBrainModel {
name: string
filename?: string
params_b: number | null
quant?: string
size_bytes?: number | null
version?: number | null
gguf_path?: string
incomplete?: boolean
}
export interface HermesBrainCandidate {
repo: string
name: string
version: number
params_b: number
downloads: number
fit: Fit
}
export interface HermesBrainResp {
current: HermesBrainModel | null
recommended: HermesBrainCandidate | null
update_available: boolean
}
+6
View File
@@ -9,6 +9,7 @@ import {
type ConnectResp,
type DiscoverResp,
type DraftsResp,
type HermesBrainResp,
type Health,
type Job,
type Memory,
@@ -30,6 +31,7 @@ export const qk = {
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
hermesBrain: ["hermes-brain"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
drafts: (target?: string) => ["drafts", target ?? ""] as const,
@@ -66,6 +68,10 @@ export const useTokenStats = (refetchInterval = 3_000) =>
export const useAgentStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
// Agent-Hirn (Hermes) + bestes NousResearch-Update. Selten pollen (HF-Abfrage).
export const useHermesBrain = (refetchInterval = 60_000) =>
useQuery({ queryKey: qk.hermesBrain, queryFn: () => api<HermesBrainResp>("/api/agent/brain"), refetchInterval })
export const useUpdates = (refetchInterval?: number) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
+71 -2
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Copy, Zap } from "lucide-react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Copy, Zap, Bot } from "lucide-react"
import { api, type ModelInfo } from "@/lib/api"
import { useModels, useRouting, useConnect, useUpdates, useQueryClient, qk } from "@/lib/queries"
import { useModels, useRouting, useConnect, useUpdates, useHermesBrain, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
@@ -15,6 +15,7 @@ export function Cockpit() {
const { data: routing } = useRouting(4_000)
const { data: connectData } = useConnect()
const { data: updates } = useUpdates(4_000)
const { data: brain } = useHermesBrain()
const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
@@ -175,6 +176,25 @@ export function Cockpit() {
}
}
async function handleBrainUpdate(repo: string) {
showConfirm(
"Agent-Hirn aktualisieren?",
`Neues Hermes-Modell '${repo.split("/").pop()}' herunterladen und als Agent-Hirn (Rolle hermes) setzen? Hermes nutzt danach automatisch das neue Modell.`,
async () => {
try {
await api("/api/models/install", {
method: "POST",
body: JSON.stringify({ repo, role: "hermes", quant: "Q4_K_M", jinja: true }),
})
showAlert("Download gestartet", "Das neue Agent-Hirn wird geladen. Fortschritt oben.")
reload()
} catch (e: any) {
showAlert("Fehler", `Update fehlgeschlagen: ${e.message || e}`)
}
}
)
}
async function copySnippet(snippet?: string) {
if (!snippet) return
await navigator.clipboard.writeText(snippet)
@@ -628,6 +648,55 @@ export function Cockpit() {
</div>
</div>
{/* ZONE B.6: Agent-Hirn (Hermes) — sichtbar + updatebar (NousResearch) */}
{brain?.current && (
<div className="rounded-2xl border border-indigo-500/30 bg-indigo-500/5 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Bot className="h-4.5 w-4.5 text-indigo-400" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Agent-Hirn (Hermes)</span>
{brain.current.version != null && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-indigo-500/15 text-indigo-300 border border-indigo-500/25">v{brain.current.version}</span>
)}
</div>
<span className="text-[10px] font-mono text-muted-foreground hidden sm:block">Modell, das der Hermes-Agent als Gehirn nutzt</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl bg-background/30 border border-border/30 p-3.5">
<div className="min-w-0">
<div className="text-xs font-mono font-bold text-foreground truncate" title={brain.current.name}>
{brain.current.name.split("/").pop()}
</div>
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 mt-0.5">
<span>{brain.current.params_b ? `${brain.current.params_b}B` : "—"}</span>
<span></span><span>{brain.current.quant || "GGUF"}</span>
<span></span><span>{fmtSize(brain.current.size_bytes || 0)}</span>
</div>
</div>
{brain.update_available && brain.recommended ? (
<button
onClick={() => handleBrainUpdate(brain.recommended!.repo)}
className="h-8 px-3 shrink-0 flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-[10px] font-bold uppercase hover:bg-amber-400 transition-all cursor-pointer shadow-md shadow-amber-500/10"
>
<Download className="h-3.5 w-3.5" /> Aktualisieren
</button>
) : (
<span className="h-8 px-3 shrink-0 flex items-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/15 rounded-lg select-none">
<Check className="h-4 w-4" /> Neueste Generation
</span>
)}
</div>
{brain.update_available && brain.recommended && (
<div className="text-[10px] text-amber-400/90 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
Neuere Generation verfügbar: <span className="font-mono font-bold">{brain.recommended.name.replace(/-GGUF$/i, "")}</span>
(v{brain.recommended.version}, {brain.recommended.params_b}B) von NousResearch.
</div>
)}
</div>
)}
{/* ZONE C: Library list cards & Upgrade Radar */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-1">