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