Merge: SoC/SOTA-Refactor (Phasen 1–4)
Backend: Pricing/Draft-Pfad als Single Source of Truth, zentrales Logging, robuste gedrosselte Token-Erfassung, Stream-Service. Frontend: TanStack-Query-Daten-Layer + useDialog, geteilte Format-Utils, getypte API; DashboardView (849->40 Z.) und ModelsView (1681->46 Z.) in fokussierte Komponenten zerlegt. Alles per tsc + Build + Browser-Smoke-Test verifiziert (alle Views fehlerfrei). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
||||
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -15,6 +18,13 @@ from starlette.requests import Request
|
||||
from config import FRONTEND_DIST, VERSION
|
||||
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI(title="Mission Control 2.0", version=VERSION)
|
||||
|
||||
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
||||
|
||||
@@ -30,6 +30,10 @@ CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||
# Draft-Modell für Speculative Decoding (nur fast/coder, wenn vorhanden). Eine
|
||||
# Quelle der Wahrheit für llamaswap.register_model + migrate_config.
|
||||
SPEC_DRAFT_MODEL_PATH = os.environ.get(
|
||||
"MC_SPEC_DRAFT_MODEL", f"{MODELS_DIR.as_posix()}/drafts/qwen2.5-1.5b-instruct-q4_k_m.gguf")
|
||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
|
||||
from services.llamaswap import read_config, write_config
|
||||
from config import CONFIG_PATH
|
||||
from config import CONFIG_PATH, SPEC_DRAFT_MODEL_PATH
|
||||
|
||||
def migrate():
|
||||
print(f"Reading config from {CONFIG_PATH}...")
|
||||
@@ -15,9 +15,9 @@ def migrate():
|
||||
|
||||
cfg = read_config()
|
||||
models = cfg.get("models", {})
|
||||
|
||||
draft_path = "/srv/models/drafts/qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
||||
|
||||
|
||||
draft_path = SPEC_DRAFT_MODEL_PATH
|
||||
|
||||
for name, spec in models.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from config import LLAMA_SWAP_URL
|
||||
from services.gateway_stream import record_stream_chunk, record_usage
|
||||
from services.router_logic import FAST, FAST_NO_THINK, choose_model
|
||||
from services.token_stats import increment_tokens
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
@@ -37,41 +36,14 @@ async def _proxy(path: str, request: Request):
|
||||
async with httpx.AsyncClient(timeout=None) as c:
|
||||
async with c.stream("POST", url, json=body) as r:
|
||||
async for chunk in r.aiter_raw():
|
||||
try:
|
||||
chunk_str = chunk.decode("utf-8", errors="ignore")
|
||||
if '"usage":' in chunk_str:
|
||||
for line in chunk_str.splitlines():
|
||||
if line.startswith("data:"):
|
||||
data_str = line[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
data_json = json.loads(data_str)
|
||||
usage = data_json.get("usage")
|
||||
if usage:
|
||||
prompt = usage.get("prompt_tokens", 0)
|
||||
completion = usage.get("completion_tokens", 0)
|
||||
if prompt or completion:
|
||||
increment_tokens(prompt, completion, model=alias)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
record_stream_chunk(chunk, alias)
|
||||
yield chunk
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||
|
||||
async with httpx.AsyncClient(timeout=600) as c:
|
||||
r = await c.post(url, json=body)
|
||||
resp_json = r.json()
|
||||
try:
|
||||
usage = resp_json.get("usage")
|
||||
if usage:
|
||||
prompt = usage.get("prompt_tokens", 0)
|
||||
completion = usage.get("completion_tokens", 0)
|
||||
if prompt or completion:
|
||||
increment_tokens(prompt, completion, model=alias)
|
||||
except Exception:
|
||||
pass
|
||||
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
|
||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
||||
|
||||
|
||||
|
||||
+11
-54
@@ -5,6 +5,7 @@ Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
||||
zurückgegeben statt zu crashen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
@@ -15,8 +16,12 @@ from config import GATEWAY_URL, HERMES_API_URL, HERMES_WEBUI_URL, LLAMA_SWAP_URL
|
||||
from services import backup as backup_svc
|
||||
from services.agent import agent_status
|
||||
from services.gateway import gateway_reachable
|
||||
from services.llamaswap import engine_reachable
|
||||
from services.llamaswap import engine_reachable, list_models
|
||||
from services.pricing import compute_savings
|
||||
from services.system import system_status
|
||||
from services.token_stats import get_stats
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -90,64 +95,16 @@ def self_update() -> dict:
|
||||
return {"pull": pull, "reset": reset, "restart": restart_res}
|
||||
|
||||
|
||||
from services.token_stats import get_stats
|
||||
from services.llamaswap import list_models
|
||||
|
||||
@router.get("/system/token-stats")
|
||||
def token_stats() -> dict:
|
||||
stats = get_stats()
|
||||
p = stats.get("prompt_tokens", 0)
|
||||
c = stats.get("completion_tokens", 0)
|
||||
total = p + c
|
||||
|
||||
# Map model IDs and aliases to their respective roles for pricing resolution
|
||||
role_map = {}
|
||||
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
|
||||
role_map: dict[str, str | None] = {}
|
||||
try:
|
||||
for m in list_models():
|
||||
role_map[m["name"].lower()] = m.get("role")
|
||||
for alias in m.get("aliases", []):
|
||||
role_map[alias.lower()] = m.get("role")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Dynamic pricing tiers based on model class in June 2026
|
||||
PRICING = {
|
||||
"heavy": (15.0, 75.0),
|
||||
"coder": (3.0, 15.0),
|
||||
"hermes": (1.0, 5.0),
|
||||
"fast": (0.15, 0.60),
|
||||
"scout": (0.15, 0.60),
|
||||
"vision": (0.15, 0.60),
|
||||
"reasoning": (0.15, 0.60),
|
||||
}
|
||||
|
||||
modeled_p = 0
|
||||
modeled_c = 0
|
||||
saved_usd = 0.0
|
||||
|
||||
models_data = stats.get("models") or {}
|
||||
for m_name, m_tokens in models_data.items():
|
||||
mp = m_tokens.get("prompt", 0)
|
||||
mc = m_tokens.get("completion", 0)
|
||||
modeled_p += mp
|
||||
modeled_c += mc
|
||||
|
||||
role = role_map.get(m_name, m_name)
|
||||
rate_in, rate_out = PRICING.get(role, (0.15, 0.60))
|
||||
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
|
||||
|
||||
# Baseline/legacy tokens calculated at premium rates ($15.00 / $75.00)
|
||||
# to preserve historical savings value prior to model-specific logging
|
||||
baseline_p = max(0, p - modeled_p)
|
||||
baseline_c = max(0, c - modeled_c)
|
||||
saved_usd += (baseline_p * 15.0 + baseline_c * 75.0) / 1_000_000.0
|
||||
|
||||
saved_eur = saved_usd * 0.92 # 1 USD = 0.92 EUR
|
||||
|
||||
return {
|
||||
"prompt_tokens": p,
|
||||
"completion_tokens": c,
|
||||
"total_tokens": total,
|
||||
"saved_usd": round(saved_usd, 2),
|
||||
"saved_eur": round(saved_eur, 2)
|
||||
}
|
||||
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
|
||||
return compute_savings(get_stats(), role_map)
|
||||
|
||||
@@ -4,16 +4,20 @@ Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden
|
||||
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from config import HERMES_API_URL, HERMES_HOME, HERMES_WEBUI_URL, yaml
|
||||
from config import HERMES_API_URL, HERMES_HOME, HERMES_WEBUI_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reach(url: str, path: str = "") -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
return c.get(f"{url}{path}").status_code < 500
|
||||
except Exception:
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -31,7 +35,7 @@ def agent_status() -> dict:
|
||||
if isinstance(cfg, dict):
|
||||
brain_model = cfg.get("model", {}).get("model", "auto")
|
||||
except Exception:
|
||||
pass
|
||||
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
||||
|
||||
|
||||
return {
|
||||
@@ -64,6 +68,7 @@ def update_brain_model(new_model: str) -> bool:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
except Exception:
|
||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
||||
cfg = {}
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
@@ -85,8 +90,9 @@ def update_brain_model(new_model: str) -> bool:
|
||||
import services.maintenance as maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||
return False
|
||||
|
||||
@@ -14,11 +14,15 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
import logging
|
||||
|
||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||
from services.caps import capabilities
|
||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||
|
||||
|
||||
@@ -38,6 +42,7 @@ def _fetch_author_models(author: str) -> list:
|
||||
data = c.get(url).json()
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@@ -109,7 +114,7 @@ def refresh_discover(ram_gb: float) -> dict:
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, DISCOVER_CACHE_PATH)
|
||||
except Exception:
|
||||
pass # Cache ist nur Beschleunigung
|
||||
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
|
||||
return data
|
||||
|
||||
|
||||
@@ -118,7 +123,7 @@ def load_discover() -> dict | None:
|
||||
if DISCOVER_CACHE_PATH.exists():
|
||||
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -130,4 +135,5 @@ def safe_discover(ram_gb: float) -> dict | None:
|
||||
try:
|
||||
return refresh_discover(ram_gb)
|
||||
except Exception:
|
||||
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
|
||||
return cached
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Token-Erfassung für den Builtin-Gateway.
|
||||
|
||||
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
|
||||
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
|
||||
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from services.token_stats import increment_tokens
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_usage(usage: dict | None, model: str) -> None:
|
||||
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
|
||||
if not usage:
|
||||
return
|
||||
prompt = usage.get("prompt_tokens", 0)
|
||||
completion = usage.get("completion_tokens", 0)
|
||||
if prompt or completion:
|
||||
increment_tokens(prompt, completion, model=model)
|
||||
|
||||
|
||||
def record_stream_chunk(chunk: bytes, model: str) -> None:
|
||||
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
||||
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
||||
if b'"usage"' not in chunk:
|
||||
return
|
||||
text = chunk.decode("utf-8", errors="ignore")
|
||||
for line in text.splitlines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_str = line[5:].strip()
|
||||
if not data_str or data_str == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
record_usage(json.loads(data_str).get("usage"), model)
|
||||
except json.JSONDecodeError:
|
||||
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
|
||||
@@ -12,7 +12,7 @@ import re
|
||||
import httpx
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL
|
||||
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, SPEC_DRAFT_MODEL_PATH
|
||||
|
||||
# Kanonische Rollen (vereinheitlicht ggü. v1: kein manager/reviewer mehr).
|
||||
ROLE_IDS = {"vision", "coder", "reasoning", "agent", "scout"}
|
||||
@@ -188,9 +188,8 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
if role_lower in ("fast", "coder"):
|
||||
if "--parallel" not in cmd:
|
||||
cmd += " --parallel 2"
|
||||
draft_path = "/srv/models/drafts/qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
||||
if os.path.exists(draft_path) and "--spec-draft-model" not in cmd:
|
||||
cmd += f" --spec-draft-model {draft_path}"
|
||||
if os.path.exists(SPEC_DRAFT_MODEL_PATH) and "--spec-draft-model" not in cmd:
|
||||
cmd += f" --spec-draft-model {SPEC_DRAFT_MODEL_PATH}"
|
||||
|
||||
cfg.setdefault("models", {})[model_id] = {
|
||||
"cmd": LiteralScalarString(cmd + "\n"),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit).
|
||||
|
||||
Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer
|
||||
Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so
|
||||
eingesparte Summe. Wird vom System-Router dünn aufgerufen.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M).
|
||||
PRICING: dict[str, tuple[float, float]] = {
|
||||
"heavy": (15.0, 75.0),
|
||||
"coder": (3.0, 15.0),
|
||||
"hermes": (1.0, 5.0),
|
||||
"fast": (0.15, 0.60),
|
||||
"scout": (0.15, 0.60),
|
||||
"vision": (0.15, 0.60),
|
||||
"reasoning": (0.15, 0.60),
|
||||
}
|
||||
# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse).
|
||||
DEFAULT_RATE: tuple[float, float] = (0.15, 0.60)
|
||||
# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten,
|
||||
# damit historische Ersparnis erhalten bleibt.
|
||||
BASELINE_RATE: tuple[float, float] = PRICING["heavy"]
|
||||
USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92"))
|
||||
|
||||
|
||||
def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict:
|
||||
"""Aggregiert Tokens und berechnet die Cloud-Ersparnis.
|
||||
|
||||
role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung.
|
||||
"""
|
||||
prompt = stats.get("prompt_tokens", 0)
|
||||
completion = stats.get("completion_tokens", 0)
|
||||
|
||||
modeled_p = modeled_c = 0
|
||||
saved_usd = 0.0
|
||||
for m_name, m_tokens in (stats.get("models") or {}).items():
|
||||
mp = m_tokens.get("prompt", 0)
|
||||
mc = m_tokens.get("completion", 0)
|
||||
modeled_p += mp
|
||||
modeled_c += mc
|
||||
role = role_map.get(m_name, m_name)
|
||||
rate_in, rate_out = PRICING.get(role, DEFAULT_RATE)
|
||||
saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0
|
||||
|
||||
baseline_p = max(0, prompt - modeled_p)
|
||||
baseline_c = max(0, completion - modeled_c)
|
||||
saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0
|
||||
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": prompt + completion,
|
||||
"saved_usd": round(saved_usd, 2),
|
||||
"saved_eur": round(saved_usd * USD_TO_EUR, 2),
|
||||
"pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()},
|
||||
}
|
||||
@@ -1,56 +1,99 @@
|
||||
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
|
||||
|
||||
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
|
||||
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
|
||||
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
|
||||
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from config import HERMES_HOME
|
||||
|
||||
STATS_FILE = HERMES_HOME / "token_stats.json"
|
||||
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
|
||||
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
|
||||
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_stats: dict | None = None
|
||||
_dirty = False
|
||||
_last_flush = 0.0
|
||||
|
||||
|
||||
def _load_from_disk() -> dict:
|
||||
if not STATS_FILE.exists():
|
||||
return dict(_BASELINE)
|
||||
try:
|
||||
with open(STATS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("prompt_tokens", 0)
|
||||
data.setdefault("completion_tokens", 0)
|
||||
data.setdefault("models", {})
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
|
||||
return dict(_BASELINE)
|
||||
|
||||
|
||||
def _ensure_loaded() -> dict:
|
||||
global _stats
|
||||
if _stats is None:
|
||||
_stats = _load_from_disk()
|
||||
return _stats
|
||||
|
||||
|
||||
def _write(stats: dict) -> None:
|
||||
try:
|
||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATS_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(stats, f)
|
||||
tmp.replace(STATS_FILE)
|
||||
except OSError:
|
||||
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
if not STATS_FILE.exists():
|
||||
# Initialize stats with a nice baseline (e.g., representing previous usage)
|
||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
default_stats = {
|
||||
"prompt_tokens": 718400,
|
||||
"completion_tokens": 324200
|
||||
}
|
||||
try:
|
||||
with open(STATS_FILE, "w") as f:
|
||||
json.dump(default_stats, f)
|
||||
except Exception:
|
||||
return default_stats
|
||||
return default_stats
|
||||
|
||||
try:
|
||||
with open(STATS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
# Ensure keys exist
|
||||
if "prompt_tokens" not in data:
|
||||
data["prompt_tokens"] = 0
|
||||
if "completion_tokens" not in data:
|
||||
data["completion_tokens"] = 0
|
||||
if "models" not in data:
|
||||
data["models"] = {}
|
||||
return data
|
||||
except Exception:
|
||||
return {"prompt_tokens": 0, "completion_tokens": 0, "models": {}}
|
||||
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
|
||||
with _lock:
|
||||
return json.loads(json.dumps(_ensure_loaded()))
|
||||
|
||||
def save_stats(stats: dict):
|
||||
try:
|
||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATS_FILE, "w") as f:
|
||||
json.dump(stats, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def increment_tokens(prompt: int, completion: int, model: str = None):
|
||||
stats = get_stats()
|
||||
stats["prompt_tokens"] += prompt
|
||||
stats["completion_tokens"] += completion
|
||||
if model:
|
||||
model = model.lower()
|
||||
if "models" not in stats:
|
||||
stats["models"] = {}
|
||||
if model not in stats["models"]:
|
||||
stats["models"][model] = {"prompt": 0, "completion": 0}
|
||||
stats["models"][model]["prompt"] += prompt
|
||||
stats["models"][model]["completion"] += completion
|
||||
save_stats(stats)
|
||||
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
|
||||
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
|
||||
global _dirty, _last_flush
|
||||
with _lock:
|
||||
stats = _ensure_loaded()
|
||||
stats["prompt_tokens"] += prompt
|
||||
stats["completion_tokens"] += completion
|
||||
if model:
|
||||
m = stats.setdefault("models", {}).setdefault(
|
||||
model.lower(), {"prompt": 0, "completion": 0})
|
||||
m["prompt"] += prompt
|
||||
m["completion"] += completion
|
||||
_dirty = True
|
||||
now = time.monotonic()
|
||||
if now - _last_flush >= FLUSH_INTERVAL:
|
||||
_write(stats)
|
||||
_dirty = False
|
||||
_last_flush = now
|
||||
|
||||
|
||||
def flush() -> None:
|
||||
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
|
||||
global _dirty
|
||||
with _lock:
|
||||
if _dirty and _stats is not None:
|
||||
_write(_stats)
|
||||
_dirty = False
|
||||
|
||||
|
||||
atexit.register(flush)
|
||||
|
||||
-380
File diff suppressed because one or more lines are too long
+380
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
<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-CJm59bcL.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-paq9nNtl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+27
@@ -8,6 +8,7 @@
|
||||
"name": "mission-control-2-frontend",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
@@ -1810,6 +1811,32 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
@@ -18,9 +19,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/node": "^22.10.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
|
||||
+3
-16
@@ -11,30 +11,17 @@ import { AgentView } from "@/views/AgentView"
|
||||
import { GuideView } from "@/views/GuideView"
|
||||
import { Placeholder } from "@/views/Placeholder"
|
||||
import { SystemDrawer } from "@/components/SystemDrawer"
|
||||
import { api, type Health } from "@/lib/api"
|
||||
import { useHealth, useSystemStatus } from "@/lib/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<ViewId>("dashboard")
|
||||
const [health, setHealth] = useState<Health | null>(null)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
|
||||
const [sysStatus, setSysStatus] = useState<any | null>(null)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null))
|
||||
load()
|
||||
const t = setInterval(load, 10000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadSys = () => api<any>("/api/system/status").then(setSysStatus).catch(() => {})
|
||||
loadSys()
|
||||
const t = setInterval(loadSys, 20000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
const { data: health } = useHealth()
|
||||
const { data: sysStatus } = useSystemStatus(20_000)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add("dark")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from "react"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export interface CustomDialogProps {
|
||||
@@ -10,6 +11,7 @@ export interface CustomDialogProps {
|
||||
}
|
||||
|
||||
export function CustomDialog({ type, title, message, defaultValue, onConfirm, onCancel }: CustomDialogProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
@@ -26,15 +28,14 @@ export function CustomDialog({ type, title, message, defaultValue, onConfirm, on
|
||||
|
||||
{type === "prompt" && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
id="custom-dialog-input"
|
||||
defaultValue={defaultValue}
|
||||
className="w-full h-9 px-3 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const val = (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
|
||||
onConfirm(val)
|
||||
onConfirm(inputRef.current?.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -51,9 +52,7 @@ export function CustomDialog({ type, title, message, defaultValue, onConfirm, on
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const val = type === "prompt"
|
||||
? (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
|
||||
: undefined
|
||||
const val = type === "prompt" ? inputRef.current?.value : undefined
|
||||
onConfirm(val)
|
||||
}}
|
||||
className="h-8 px-4 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/95 rounded-lg transition-colors cursor-pointer"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from "react"
|
||||
import { Bot, ExternalLink, Cpu, Layers, X, Check } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
export function AgentStatusCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: agent } = useAgentStatus(3_000)
|
||||
const { data: modelsData } = useModels()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
||||
|
||||
const models = modelsData?.models ?? []
|
||||
|
||||
async function changeBrainModel(model: string) {
|
||||
try {
|
||||
await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) })
|
||||
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
|
||||
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
|
||||
</div>
|
||||
{agent?.webui_url && (
|
||||
<a
|
||||
href={resolveExternalUrl(agent.webui_url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
|
||||
agent.webui_reachable
|
||||
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
|
||||
: "border border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> Hermes öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">WebUI</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.webui_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.webui_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setShowBrainSelect(true)}
|
||||
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer group"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</span>
|
||||
<button className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-primary border border-primary/20 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-all cursor-pointer font-space">
|
||||
<Cpu className="h-3 w-3" /> Ändern
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-1 font-mono text-primary flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status…</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Gedächtnis & Stack-Tools via MCP gekoppelt.
|
||||
</div>
|
||||
|
||||
{agent && showBrainSelect && (
|
||||
<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 flex items-center gap-1.5">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span>Hermes-Gehirn konfigurieren</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowBrainSelect(false)}
|
||||
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 leading-relaxed">
|
||||
Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
|
||||
const isAlias = ["auto", "fast", "heavy"].includes(m)
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => changeBrainModel(m)}
|
||||
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",
|
||||
agent.brain_model === m || (!agent.brain_model && m === "auto")
|
||||
? "text-primary font-bold bg-primary/10 border-primary/30"
|
||||
: "text-foreground bg-background/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="font-semibold truncate max-w-[280px]">{m}</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
|
||||
</span>
|
||||
</div>
|
||||
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react"
|
||||
import { Brain, Plus } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useMemory, useQueryClient } from "@/lib/queries"
|
||||
|
||||
export function MemoryInputCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: memories = [] } = useMemory({ limit: 3 })
|
||||
const [memContent, setMemContent] = useState("")
|
||||
const [memCat, setMemCat] = useState("stable")
|
||||
const [savingMem, setSavingMem] = useState(false)
|
||||
|
||||
async function saveQuickMemory() {
|
||||
if (!memContent.trim() || savingMem) return
|
||||
setSavingMem(true)
|
||||
try {
|
||||
await api("/api/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
|
||||
})
|
||||
setMemContent("")
|
||||
qc.invalidateQueries({ queryKey: ["memory"] })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setSavingMem(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={memContent}
|
||||
onChange={(e) => setMemContent(e.target.value)}
|
||||
placeholder="Fakt / Regel im Pool speichern..."
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<select
|
||||
value={memCat}
|
||||
onChange={(e) => setMemCat(e.target.value)}
|
||||
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
|
||||
>
|
||||
<option value="stable">🔵 Fakt</option>
|
||||
<option value="instruction">📋 Regel</option>
|
||||
<option value="user">👤 User</option>
|
||||
<option value="versioned">🟡 Version</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={saveQuickMemory}
|
||||
disabled={!memContent.trim() || savingMem}
|
||||
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 space-y-1.5">
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
|
||||
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
|
||||
{memories.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
|
||||
) : (
|
||||
memories.map((m) => (
|
||||
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
|
||||
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
|
||||
{m.category}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
|
||||
{m.content}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Steht allen Clients per MCP zur Verfügung.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function RadialGauge({ value, label, detail }: { value: number; label: string; detail?: string }) {
|
||||
const radius = 24
|
||||
const circ = 2 * Math.PI * radius
|
||||
const offset = circ - (Math.min(value, 100) / 100) * circ
|
||||
|
||||
const strokeColor = value > 90
|
||||
? "stroke-red-500"
|
||||
: value > 75
|
||||
? "stroke-amber-500"
|
||||
: "stroke-primary"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 p-2 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<svg className="absolute inset-0 h-full w-full -rotate-90">
|
||||
<circle cx="32" cy="32" r={radius} className="stroke-muted fill-none" strokeWidth="4.5" />
|
||||
<circle cx="32" cy="32" r={radius} className={cn("fill-none transition-all duration-700 ease-out", strokeColor)} strokeWidth="4.5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-xs font-mono font-bold tracking-tight text-foreground">{Math.round(value)}%</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||
{detail && <span className="text-[10px] font-mono text-muted-foreground/80">{detail}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Layers } from "lucide-react"
|
||||
import { useModels } from "@/lib/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ROLES = ["fast", "heavy", "coder", "reasoning", "vision", "scout"]
|
||||
|
||||
export function RolesCard() {
|
||||
const { data } = useModels(3_000)
|
||||
const models = data?.models ?? []
|
||||
const running = data?.running ?? []
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Layers className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Rollen-Belegung</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{ROLES.map((role) => {
|
||||
const m = models.find((x) => x.role === role)
|
||||
const isRunning = m ? running.includes(m.name) : false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 rounded-xl border transition-all duration-300",
|
||||
isRunning
|
||||
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
|
||||
: m
|
||||
? "border-primary/20 bg-primary/5"
|
||||
: "border-border/30 bg-background/10 opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0",
|
||||
role === "fast" ? "bg-cyan-500/15 text-cyan-400 border-cyan-500/25" :
|
||||
role === "heavy" ? "bg-amber-500/15 text-amber-400 border-amber-500/25" :
|
||||
role === "coder" ? "bg-violet-500/15 text-violet-400 border-violet-500/25" :
|
||||
role === "reasoning" ? "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" :
|
||||
role === "vision" ? "bg-pink-500/15 text-pink-400 border-pink-500/25" :
|
||||
"bg-teal-500/15 text-teal-400 border-teal-500/25"
|
||||
)}>
|
||||
{role}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold truncate font-mono text-foreground">
|
||||
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
|
||||
</span>
|
||||
{m && (
|
||||
<div className="flex gap-1 items-center mt-0.5 flex-wrap">
|
||||
{m.prompt_cache && (
|
||||
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">
|
||||
PC
|
||||
</span>
|
||||
)}
|
||||
{m.spec_draft_model && (
|
||||
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
||||
SPEC
|
||||
</span>
|
||||
)}
|
||||
{m.parallel_slots > 1 && (
|
||||
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
||||
SLOTS: {m.parallel_slots}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{m ? (
|
||||
isRunning ? (
|
||||
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">
|
||||
bereit
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Laden erfolgt automatisch per Auto-Swap.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Cpu } from "lucide-react"
|
||||
import { useSystemStatus } from "@/lib/queries"
|
||||
import { gb } from "@/lib/format"
|
||||
import { RadialGauge } from "./RadialGauge"
|
||||
|
||||
export function SystemStatusCard() {
|
||||
const { data: sys } = useSystemStatus(3_000)
|
||||
|
||||
return (
|
||||
<div className="md:col-span-2 flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Cpu className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">System-Status</h2>
|
||||
</div>
|
||||
{sys ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
|
||||
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
|
||||
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
|
||||
<RadialGauge
|
||||
value={sys.gpu.busy_percent}
|
||||
label="GPU"
|
||||
detail={`${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB`}
|
||||
/>
|
||||
)}
|
||||
{sys.disk && (
|
||||
<RadialGauge value={sys.disk.percent} label="Disk" detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Systemdaten…</div>
|
||||
)}
|
||||
</div>
|
||||
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
|
||||
<div className="mt-4 flex gap-4 text-xs font-mono text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
|
||||
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Coins } from "lucide-react"
|
||||
import { useTokenStats } from "@/lib/queries"
|
||||
|
||||
export function TokenStatsCard() {
|
||||
const { data: tokenStats } = useTokenStats(3_000)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Coins className="h-4.5 w-4.5 text-primary animate-pulse" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Effizienz & Ersparnis</h2>
|
||||
</div>
|
||||
|
||||
{tokenStats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Geld gespart</div>
|
||||
<div className="text-base font-bold text-emerald-400 mt-0.5 tracking-tight font-space">
|
||||
{tokenStats.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} €
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
|
||||
({tokenStats.saved_usd.toLocaleString("en-US", { minimumFractionDigits: 2 })} $)
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Gesamt-Tokens</div>
|
||||
<div className="text-base font-bold text-primary mt-0.5 tracking-tight font-space">
|
||||
{tokenStats.total_tokens.toLocaleString("de-DE")}
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
|
||||
(Lokale Inferenz)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-border/20 pt-3 text-[10px] text-muted-foreground">
|
||||
<div className="flex justify-between items-center font-mono">
|
||||
<span>Input (Prompts):</span>
|
||||
<span className="font-semibold text-foreground">{tokenStats.prompt_tokens.toLocaleString("de-DE")} tkn</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center font-mono">
|
||||
<span>Output (Antworten):</span>
|
||||
<span className="font-semibold text-foreground">{tokenStats.completion_tokens.toLocaleString("de-DE")} tkn</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Statistiken…</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-[9px] text-muted-foreground/70 border-t border-border/30 pt-2.5 leading-normal">
|
||||
Berechnet im Vergleich zu Cloud-APIs von Juni 2026
|
||||
{tokenStats?.pricing?.heavy
|
||||
? ` (Ø ${tokenStats.pricing.heavy.in.toFixed(2).replace(".", ",")} $ / ${tokenStats.pricing.heavy.out.toFixed(2).replace(".", ",")} $ pro 1M tkn).`
|
||||
: "."}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useState } from "react"
|
||||
import { ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useUpdates, useJobs, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function UpdatesCard() {
|
||||
const qc = useQueryClient()
|
||||
const { data: updates } = useUpdates(3_000)
|
||||
const { data: jobs = [] } = useJobs(3_000)
|
||||
const { showConfirm, dialogElement } = useDialog()
|
||||
|
||||
const [msg, setMsg] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sudoPassword, setSudoPassword] = useState("")
|
||||
const [sudoLoading, setSudoLoading] = useState(false)
|
||||
const [sudoModal, setSudoModal] = useState<{
|
||||
open: boolean
|
||||
actionPath: string
|
||||
actionLabel: string
|
||||
payload?: any
|
||||
error?: string
|
||||
}>({ open: false, actionPath: "", actionLabel: "" })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.updates })
|
||||
qc.invalidateQueries({ queryKey: qk.jobs })
|
||||
qc.invalidateQueries({ queryKey: qk.models })
|
||||
}
|
||||
|
||||
async function postAction(path: string, label: string, payload?: any, password?: string) {
|
||||
setMsg(`${label} wird ausgeführt...`)
|
||||
setLoading(true)
|
||||
try {
|
||||
const body: any = { ...payload }
|
||||
if (password) body.sudo_password = password
|
||||
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (r.status === "password_required" || r.status === "incorrect_password") {
|
||||
setSudoModal({
|
||||
open: true,
|
||||
actionPath: path,
|
||||
actionLabel: label,
|
||||
payload,
|
||||
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
|
||||
})
|
||||
setMsg("")
|
||||
return
|
||||
}
|
||||
|
||||
if (r.job_id) setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
|
||||
else if (r.ok) setMsg(`${label} erfolgreich ausgeführt.`)
|
||||
else setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
setMsg(`Fehler bei ${label}: ${e.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSudoSubmit() {
|
||||
setSudoLoading(true)
|
||||
try {
|
||||
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
|
||||
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (r.status === "password_required" || r.status === "incorrect_password") {
|
||||
setSudoModal(prev => ({ ...prev, error: "Falsches Sudo-Passwort. Bitte erneut versuchen." }))
|
||||
return
|
||||
}
|
||||
|
||||
if (r.job_id) setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
|
||||
else if (r.ok) setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
|
||||
else setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
setMsg(`Fehler: ${e.message}`)
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
} finally {
|
||||
setSudoLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradeModel(repo: string, role: string) {
|
||||
setMsg(`Upgrade für ${repo} wird gestartet...`)
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
|
||||
})
|
||||
setMsg(`Upgrade-Download gestartet.`)
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
|
||||
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
{/* Sudo Password Dialog Modal */}
|
||||
{sudoModal.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
|
||||
<button
|
||||
onClick={() => { setSudoModal({ open: false, actionPath: "", actionLabel: "" }); setSudoPassword("") }}
|
||||
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-[10px] text-muted-foreground leading-normal">
|
||||
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={sudoPassword}
|
||||
onChange={(e) => setSudoPassword(e.target.value)}
|
||||
placeholder="Sudo-Passwort eingeben..."
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
{sudoModal.error && (
|
||||
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => { setSudoModal({ open: false, actionPath: "", actionLabel: "" }); setSudoPassword("") }}
|
||||
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSudoSubmit}
|
||||
disabled={!sudoPassword || sudoLoading}
|
||||
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{sudoLoading ? "Prüfe..." : "Ausführen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 border-b border-border/20 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates & Pflege</h2>
|
||||
</div>
|
||||
{updates?.last_check && (
|
||||
<span className="text-[9px] text-muted-foreground/80 font-mono">
|
||||
Zuletzt gesucht: {new Date(updates.last_check * 1000).toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit"
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updates ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
updates.os > 0
|
||||
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
|
||||
: "border-border/30 bg-background/25 text-muted-foreground"
|
||||
)}>
|
||||
<span>OS-Pakete</span>
|
||||
<span className="font-mono">{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
updates.engine > 0
|
||||
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
|
||||
: "border-border/30 bg-background/25 text-muted-foreground"
|
||||
)}>
|
||||
<span>Engine (llama.cpp)</span>
|
||||
<span className="font-mono">{updates.engine > 0 ? "Update verfügbar" : "aktuell"}</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
updates.models > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary font-semibold animate-pulse"
|
||||
: "border-border/30 bg-background/25 text-muted-foreground"
|
||||
)}>
|
||||
<span>Modell-Upgrades</span>
|
||||
<span className="font-mono">{updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
|
||||
<button
|
||||
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
|
||||
disabled={loading || !!activeOsJob}
|
||||
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{activeOsJob ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<span>OS Update</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
|
||||
disabled={loading || !!activeEngineJob}
|
||||
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{activeEngineJob ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Engine Update</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
|
||||
disabled={loading}
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Power className="h-3.5 w-3.5" />
|
||||
<span>Host Reboot</span>
|
||||
</button>
|
||||
|
||||
{updates.model_list.length > 0 && (
|
||||
<div className="space-y-1.5 border-t border-border/20 pt-3">
|
||||
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
|
||||
<div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
|
||||
{updates.model_list.map((m) => (
|
||||
<div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
|
||||
<span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
|
||||
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => upgradeModel(m.repo, m.role)}
|
||||
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
<Download className="h-2.5 w-2.5" /> Laden
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
|
||||
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
|
||||
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-border/30 pt-3 shrink-0">
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer"))}
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer shadow-md shadow-primary/10"
|
||||
>
|
||||
System-Zentrale öffnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { api } from "@/lib/api"
|
||||
import { useJobs, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtBytes, fmtEta } from "@/lib/format"
|
||||
|
||||
export function JobsBar() {
|
||||
const qc = useQueryClient()
|
||||
const { data: jobs = [] } = useJobs(2_000)
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
|
||||
async function cancelJob(jobId: string) {
|
||||
try {
|
||||
await api(`/api/jobs/${jobId}/cancel`, { method: "POST" })
|
||||
qc.invalidateQueries({ queryKey: qk.jobs })
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const active = jobs.filter((j) => j.state === "running" || j.state === "queued")
|
||||
const recent = jobs.filter((j) => j.state !== "running" && j.state !== "queued").slice(-3)
|
||||
|
||||
if (active.length === 0 && recent.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 shadow-lg shadow-black/10">
|
||||
<div className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">Aktive Downloads</div>
|
||||
|
||||
{active.map((j) => (
|
||||
<div key={j.id} className="space-y-1.5 p-3 rounded-xl bg-background/20 border border-border/40">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="font-semibold truncate max-w-[250px]">{j.label}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{j.progress ?? 0}% • {fmtBytes(j.done_bytes)}/{fmtBytes(j.total_bytes)}
|
||||
{j.eta_s ? ` • ETA ${fmtEta(j.eta_s)}` : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => cancelJob(j.id)}
|
||||
className="text-[10px] text-red-400 hover:text-red-300 font-semibold border border-red-500/25 bg-red-500/5 px-2 py-0.5 rounded transition-all cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${j.progress ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{recent.map((j) => (
|
||||
<div key={j.id} className="flex justify-between items-center text-xs text-muted-foreground px-1">
|
||||
<span className="truncate">{j.label}</span>
|
||||
<span className={cn(
|
||||
"font-semibold text-[10px] px-1.5 py-0.5 rounded uppercase font-mono",
|
||||
j.state === "done" ? "bg-emerald-500/10 text-emerald-400" : "bg-amber-500/10 text-amber-400"
|
||||
)}>
|
||||
{j.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type Fit } from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const ROLES = ["fast", "heavy", "coder", "reasoning", "agent", "vision", "scout"]
|
||||
|
||||
export function FitBadge({ fit }: { fit: Fit }) {
|
||||
const tone = {
|
||||
perfect: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/20",
|
||||
marginal: "bg-amber-500/15 text-amber-400 border border-amber-500/20",
|
||||
too_tight: "bg-red-500/15 text-red-400 border border-red-500/20",
|
||||
}[fit.level]
|
||||
return (
|
||||
<span className={cn("rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider font-mono", tone)}>
|
||||
{fit.text} • {fit.req_gb} GB RAM
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function getBrandInfo(name: string) {
|
||||
const low = name.toLowerCase()
|
||||
if (low.includes("qwen")) return { name: "Qwen", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", initial: "Q" }
|
||||
if (low.includes("gemma")) return { name: "Gemma", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", initial: "G" }
|
||||
if (low.includes("llama")) return { name: "Llama", color: "bg-red-500/20 text-red-300 border-red-500/30", initial: "🦙" }
|
||||
if (low.includes("mistral") || low.includes("mixtral")) return { name: "Mistral", color: "bg-orange-500/20 text-orange-300 border-orange-500/30", initial: "M" }
|
||||
if (low.includes("deepseek")) return { name: "DeepSeek", color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30", initial: "D" }
|
||||
if (low.includes("hermes") || low.includes("nous")) return { name: "Hermes", color: "bg-amber-500/20 text-amber-300 border-amber-500/30", initial: "H" }
|
||||
if (low.includes("phi")) return { name: "Phi", color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30", initial: "Φ" }
|
||||
return { name: "Other", color: "bg-slate-500/20 text-slate-300 border-slate-500/30", initial: "AI" }
|
||||
}
|
||||
@@ -130,6 +130,34 @@ export interface RoutingResp {
|
||||
gateway_reachable: boolean
|
||||
}
|
||||
|
||||
export interface GitInfo {
|
||||
hash: string
|
||||
date: string
|
||||
subject: string
|
||||
branch: string
|
||||
dirty: boolean
|
||||
path: string
|
||||
}
|
||||
|
||||
// Engine kann git-, binary- oder unbekannte Version sein — Felder je nach `type`.
|
||||
export interface ComponentVersion {
|
||||
type: "git" | "binary" | "unknown"
|
||||
hash?: string
|
||||
date?: string
|
||||
subject?: string
|
||||
branch?: string
|
||||
dirty?: boolean
|
||||
path?: string
|
||||
version_text?: string
|
||||
}
|
||||
|
||||
export interface Versions {
|
||||
mc2: GitInfo | null
|
||||
engine: ComponentVersion
|
||||
hermes_ui: GitInfo | null
|
||||
hermes_agent: GitInfo | null
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
cpu: { percent: number; cores: number | null }
|
||||
ram: { total: number; used: number; percent: number }
|
||||
@@ -142,6 +170,7 @@ export interface SystemStatus {
|
||||
} | null
|
||||
temp: { cpu?: number; gpu?: number } | null
|
||||
disk: { total: number; used: number; percent: number } | null
|
||||
versions?: Versions
|
||||
}
|
||||
|
||||
export interface ServicesResp {
|
||||
@@ -214,3 +243,17 @@ export interface Health {
|
||||
engine_reachable: boolean
|
||||
gateway_reachable: boolean
|
||||
}
|
||||
|
||||
export interface TokenStats {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
saved_usd: number
|
||||
saved_eur: number
|
||||
pricing?: Record<string, { in: number; out: number }>
|
||||
}
|
||||
|
||||
export interface ModelsResp {
|
||||
models: ModelInfo[]
|
||||
running?: string[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Zentrale Formatierungs-Helfer (vorher in einzelnen Views dupliziert).
|
||||
|
||||
/** Bytes → GB als String mit einer Nachkommastelle (z.B. "14.1"). */
|
||||
export function gb(b: number): string {
|
||||
return (b / 1024 ** 3).toFixed(1)
|
||||
}
|
||||
|
||||
/** Bytes → "1.2 GB" / "512 MB"; leer bei 0/undefined. */
|
||||
export function fmtBytes(b?: number): string {
|
||||
if (!b) return ""
|
||||
if (b > 1024 ** 3) return `${(b / 1024 ** 3).toFixed(1)} GB`
|
||||
return `${(b / 1024 ** 2).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
/** Bytes → "1.2 GB" / "512 MB"; "—" bei 0/undefined/null. */
|
||||
export function fmtSize(b?: number | null): string {
|
||||
if (!b) return "—"
|
||||
const g = b / 1024 ** 3
|
||||
return g >= 1 ? `${g.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
/** Sekunden → "3 min" / "45 s"; leer bei 0/undefined. */
|
||||
export function fmtEta(s?: number): string {
|
||||
if (!s) return ""
|
||||
const m = Math.floor(s / 60)
|
||||
return m > 0 ? `${m} min` : `${s} s`
|
||||
}
|
||||
|
||||
/** Kontextlänge → "32k"; "—" bei null. */
|
||||
export function fmtCtx(c: number | null): string {
|
||||
return c ? `${Math.round(c / 1024)}k` : "—"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
|
||||
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
|
||||
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
|
||||
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
api,
|
||||
type AgentStatus,
|
||||
type ConnectResp,
|
||||
type DiscoverResp,
|
||||
type Health,
|
||||
type Job,
|
||||
type Memory,
|
||||
type ModelsResp,
|
||||
type RoutingResp,
|
||||
type ServicesResp,
|
||||
type SystemStatus,
|
||||
type TokenStats,
|
||||
type UpdatesResp,
|
||||
} from "./api"
|
||||
|
||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||
export const qk = {
|
||||
health: ["health"] as const,
|
||||
systemStatus: ["system-status"] as const,
|
||||
services: ["services"] as const,
|
||||
models: ["models"] as const,
|
||||
routing: ["routing"] as const,
|
||||
jobs: ["jobs"] as const,
|
||||
tokenStats: ["token-stats"] as const,
|
||||
agentStatus: ["agent-status"] as const,
|
||||
updates: ["updates"] as const,
|
||||
discover: ["discover"] as const,
|
||||
connect: (params?: string) => ["connect", params ?? ""] as const,
|
||||
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
|
||||
}
|
||||
|
||||
export const useHealth = () =>
|
||||
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
|
||||
|
||||
export const useSystemStatus = (refetchInterval = 5_000) =>
|
||||
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
|
||||
|
||||
export const useServices = (refetchInterval = 3_000) =>
|
||||
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
|
||||
|
||||
export const useModels = (refetchInterval = 4_000) =>
|
||||
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
|
||||
|
||||
export const useRouting = (refetchInterval = 4_000) =>
|
||||
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
|
||||
|
||||
export const useJobs = (refetchInterval = 2_000) =>
|
||||
useQuery({
|
||||
queryKey: qk.jobs,
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
refetchInterval,
|
||||
select: (d) => d.jobs ?? [],
|
||||
})
|
||||
|
||||
export const useTokenStats = (refetchInterval = 3_000) =>
|
||||
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
|
||||
|
||||
export const useAgentStatus = (refetchInterval = 5_000) =>
|
||||
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
|
||||
|
||||
export const useUpdates = (refetchInterval?: number) =>
|
||||
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
|
||||
|
||||
export const useDiscover = () =>
|
||||
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
|
||||
|
||||
export const useConnect = (params?: string) =>
|
||||
useQuery({
|
||||
queryKey: qk.connect(params),
|
||||
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
|
||||
})
|
||||
|
||||
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
|
||||
useQuery({
|
||||
queryKey: qk.memory(opts?.q, opts?.category),
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams()
|
||||
if (opts?.q) p.set("q", opts.q)
|
||||
if (opts?.category) p.set("category", opts.category)
|
||||
return api<Memory[]>(`/api/memory?${p}`)
|
||||
},
|
||||
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
|
||||
})
|
||||
|
||||
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
|
||||
export function invalidate(qc: QueryClient, ...keys: readonly unknown[][]) {
|
||||
for (const key of keys) qc.invalidateQueries({ queryKey: key })
|
||||
}
|
||||
|
||||
export { useQueryClient }
|
||||
@@ -0,0 +1,55 @@
|
||||
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
|
||||
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
|
||||
//
|
||||
// Nutzung:
|
||||
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
|
||||
// ...
|
||||
// showConfirm("Titel", "Wirklich?", () => doIt())
|
||||
// return (<>{dialogElement}...</>)
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
|
||||
interface DialogState {
|
||||
type: "alert" | "confirm" | "prompt"
|
||||
title: string
|
||||
message: string
|
||||
defaultValue?: string
|
||||
onConfirm: (val?: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
const close = useCallback(() => setDialog(null), [])
|
||||
|
||||
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
|
||||
setDialog({
|
||||
type: "alert", title, message,
|
||||
onConfirm: () => { setDialog(null); onConfirm?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showConfirm = useCallback(
|
||||
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
|
||||
setDialog({
|
||||
type: "confirm", title, message,
|
||||
onConfirm: () => { setDialog(null); onConfirm() },
|
||||
onCancel: () => { setDialog(null); onCancel?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showPrompt = useCallback(
|
||||
(title: string, message: string, defaultValue: string,
|
||||
onConfirm: (val?: string) => void, onCancel?: () => void) => {
|
||||
setDialog({
|
||||
type: "prompt", title, message, defaultValue,
|
||||
onConfirm: (val) => { setDialog(null); onConfirm(val) },
|
||||
onCancel: () => { setDialog(null); onCancel?.() },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
|
||||
|
||||
return { showAlert, showConfirm, showPrompt, close, dialogElement }
|
||||
}
|
||||
+15
-1
@@ -1,10 +1,24 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import App from "./App"
|
||||
import "./index.css"
|
||||
|
||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react"
|
||||
import { useState, useRef, useCallback, useMemo } from "react"
|
||||
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield, Check, X } from "lucide-react"
|
||||
import { api, type AgentStatus } from "@/lib/api"
|
||||
import { api } from "@/lib/api"
|
||||
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
|
||||
|
||||
function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: boolean; detail?: string; icon: any; onClick?: () => void }) {
|
||||
@@ -49,26 +50,21 @@ function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: b
|
||||
}
|
||||
|
||||
export function AgentView() {
|
||||
const [s, setS] = useState<AgentStatus | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const { data: s, error: sErr } = useAgentStatus(5_000)
|
||||
const { data: modelsData } = useModels()
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const qc = useQueryClient()
|
||||
const error = sErr ? String(sErr) : ""
|
||||
|
||||
const availableModels = useMemo(() => {
|
||||
const names = (modelsData?.models ?? []).map(
|
||||
(m) => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
|
||||
return ["auto", "fast", "heavy", ...names]
|
||||
}, [modelsData])
|
||||
|
||||
// Graph UI state
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([])
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: "alert" | "confirm"
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
} | null>(null)
|
||||
|
||||
function showAlert(title: string, message: string, onConfirm?: () => void) {
|
||||
setDialog({ type: "alert", title, message, onConfirm })
|
||||
}
|
||||
|
||||
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
|
||||
@@ -99,19 +95,6 @@ export function AgentView() {
|
||||
return `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
|
||||
}
|
||||
|
||||
function loadModels() {
|
||||
api<{ models: { name: string }[] }>("/api/models")
|
||||
.then((res) => {
|
||||
const names = res.models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
|
||||
setAvailableModels(["auto", "fast", "heavy", ...names])
|
||||
})
|
||||
.catch((e) => console.error("Error loading models", e))
|
||||
}
|
||||
|
||||
async function changeBrainModel(model: string) {
|
||||
try {
|
||||
await api("/api/agent/brain", {
|
||||
@@ -119,20 +102,13 @@ export function AgentView() {
|
||||
body: JSON.stringify({ model })
|
||||
})
|
||||
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
|
||||
loadData()
|
||||
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadModels()
|
||||
const t = setInterval(loadData, 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Styles inside AgentView for dash flow animations */}
|
||||
@@ -471,15 +447,7 @@ export function AgentView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CustomDialog
|
||||
type={dialog.type}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
onConfirm={() => dialog.onConfirm && dialog.onConfirm()}
|
||||
onCancel={dialog.onCancel}
|
||||
/>
|
||||
)}
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { Check, Copy, Terminal, Info, Globe, FolderOpen } from "lucide-react"
|
||||
import { api, type ConnectResp } from "@/lib/api"
|
||||
import { useConnect } from "@/lib/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function ConnectView() {
|
||||
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
|
||||
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
|
||||
const [data, setData] = useState<ConnectResp | null>(null)
|
||||
const [active, setActive] = useState("cline")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
params.set("host", host)
|
||||
if (mcpPath) params.set("mcp_path", mcpPath)
|
||||
api<ConnectResp>(`/api/connect?${params}`)
|
||||
.then(setData)
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [host, mcpPath])
|
||||
const params = new URLSearchParams({ host })
|
||||
if (mcpPath) params.set("mcp_path", mcpPath)
|
||||
const { data, error: dataErr } = useConnect(params.toString())
|
||||
const error = dataErr ? String(dataErr) : ""
|
||||
|
||||
function saveHost(v: string) {
|
||||
setHost(v)
|
||||
|
||||
@@ -1,317 +1,13 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw, Check, Coins } from "lucide-react"
|
||||
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
|
||||
|
||||
function gb(b: number) {
|
||||
return (b / 1024 ** 3).toFixed(1)
|
||||
}
|
||||
|
||||
function RadialGauge({ value, label, detail }: { value: number; label: string; detail?: string }) {
|
||||
const radius = 24
|
||||
const circ = 2 * Math.PI * radius
|
||||
const offset = circ - (Math.min(value, 100) / 100) * circ
|
||||
|
||||
const strokeColor = value > 90
|
||||
? "stroke-red-500"
|
||||
: value > 75
|
||||
? "stroke-amber-500"
|
||||
: "stroke-primary"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 p-2 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<svg className="absolute inset-0 h-full w-full -rotate-90">
|
||||
<circle cx="32" cy="32" r={radius} className="stroke-muted fill-none" strokeWidth="4.5" />
|
||||
<circle cx="32" cy="32" r={radius} className={cn("fill-none transition-all duration-700 ease-out", strokeColor)} strokeWidth="4.5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-xs font-mono font-bold tracking-tight text-foreground">{Math.round(value)}%</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||
{detail && <span className="text-[10px] font-mono text-muted-foreground/80">{detail}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
|
||||
import { UpdatesCard } from "@/components/dashboard/UpdatesCard"
|
||||
import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
|
||||
import { RolesCard } from "@/components/dashboard/RolesCard"
|
||||
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
||||
import { TokenStatsCard } from "@/components/dashboard/TokenStatsCard"
|
||||
|
||||
export function DashboardView() {
|
||||
const [sys, setSys] = useState<SystemStatus | null>(null)
|
||||
const [agent, setAgent] = useState<AgentStatus | null>(null)
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [running, setRunning] = useState<string[]>([])
|
||||
const [memories, setMemories] = useState<Memory[]>([])
|
||||
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [tokenStats, setTokenStats] = useState<{
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
saved_usd: number
|
||||
saved_eur: number
|
||||
} | null>(null)
|
||||
|
||||
// Sudo & Action states
|
||||
const [msg, setMsg] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sudoPassword, setSudoPassword] = useState("")
|
||||
const [sudoLoading, setSudoLoading] = useState(false)
|
||||
const [sudoModal, setSudoModal] = useState<{
|
||||
open: boolean
|
||||
actionPath: string
|
||||
actionLabel: string
|
||||
payload?: any
|
||||
error?: string
|
||||
}>({ open: false, actionPath: "", actionLabel: "" })
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: "alert" | "confirm"
|
||||
title: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
} | null>(null)
|
||||
|
||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
||||
|
||||
async function changeBrainModel(model: string) {
|
||||
try {
|
||||
await api("/api/agent/brain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model })
|
||||
})
|
||||
setDialog({
|
||||
type: "alert",
|
||||
title: "Erfolgreich",
|
||||
message: `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`,
|
||||
onConfirm: () => setDialog(null)
|
||||
})
|
||||
loadData()
|
||||
setShowBrainSelect(false)
|
||||
} catch (e: any) {
|
||||
setDialog({
|
||||
type: "alert",
|
||||
title: "Fehler",
|
||||
message: `Fehler beim Wechseln des Gehirns: ${e.message}`,
|
||||
onConfirm: () => setDialog(null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirm(title: string, message: string, onConfirm: () => void) {
|
||||
setDialog({
|
||||
type: "confirm",
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => {
|
||||
setDialog(null)
|
||||
onConfirm()
|
||||
},
|
||||
onCancel: () => setDialog(null)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Quick Memory Form State
|
||||
const [memContent, setMemContent] = useState("")
|
||||
const [memCat, setMemCat] = useState("stable")
|
||||
const [savingMem, setSavingMem] = useState(false)
|
||||
|
||||
function loadData() {
|
||||
api<SystemStatus>("/api/system/status").then(setSys).catch(() => {})
|
||||
api<AgentStatus>("/api/agent/status").then(setAgent).catch(() => {})
|
||||
api<{ models: ModelInfo[]; running?: string[] }>("/api/models")
|
||||
.then((d) => {
|
||||
setModels(d.models || [])
|
||||
setRunning(d.running || [])
|
||||
})
|
||||
.catch(() => {})
|
||||
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
|
||||
api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {})
|
||||
api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs || [])).catch(() => {})
|
||||
api<any>("/api/system/token-stats").then(setTokenStats).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const t = setInterval(loadData, 3000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
async function postAction(path: string, label: string, payload?: any, password?: string) {
|
||||
setMsg(`${label} wird ausgeführt...`)
|
||||
setLoading(true)
|
||||
try {
|
||||
const body: any = { ...payload }
|
||||
if (password) {
|
||||
body.sudo_password = password
|
||||
}
|
||||
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (r.status === "password_required" || r.status === "incorrect_password") {
|
||||
setSudoModal({
|
||||
open: true,
|
||||
actionPath: path,
|
||||
actionLabel: label,
|
||||
payload,
|
||||
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
|
||||
})
|
||||
setMsg("")
|
||||
return
|
||||
}
|
||||
|
||||
if (r.job_id) {
|
||||
setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
|
||||
} else if (r.ok) {
|
||||
setMsg(`${label} erfolgreich ausgeführt.`)
|
||||
} else {
|
||||
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
|
||||
}
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
setMsg(`Fehler bei ${label}: ${e.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSudoSubmit() {
|
||||
setSudoLoading(true)
|
||||
try {
|
||||
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
|
||||
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (r.status === "password_required" || r.status === "incorrect_password") {
|
||||
setSudoModal(prev => ({
|
||||
...prev,
|
||||
error: "Falsches Sudo-Passwort. Bitte erneut versuchen."
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (r.job_id) {
|
||||
setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
|
||||
} else if (r.ok) {
|
||||
setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
|
||||
} else {
|
||||
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
|
||||
}
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
setMsg(`Fehler: ${e.message}`)
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
} finally {
|
||||
setSudoLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradeModel(repo: string, role: string) {
|
||||
setMsg(`Upgrade für ${repo} wird gestartet...`)
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
|
||||
})
|
||||
setMsg(`Upgrade-Download gestartet.`)
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickMemory() {
|
||||
if (!memContent.trim() || savingMem) return
|
||||
setSavingMem(true)
|
||||
try {
|
||||
await api("/api/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }),
|
||||
})
|
||||
setMemContent("")
|
||||
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setSavingMem(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Active updates check
|
||||
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
|
||||
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sudo Password Dialog Modal */}
|
||||
{sudoModal.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
}}
|
||||
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-[10px] text-muted-foreground leading-normal">
|
||||
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={sudoPassword}
|
||||
onChange={(e) => setSudoPassword(e.target.value)}
|
||||
placeholder="Sudo-Passwort eingeben..."
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
{sudoModal.error && (
|
||||
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
}}
|
||||
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSudoSubmit}
|
||||
disabled={!sudoPassword || sudoLoading}
|
||||
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{sudoLoading ? "Prüfe..." : "Ausführen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
|
||||
@@ -320,529 +16,19 @@ export function DashboardView() {
|
||||
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
|
||||
</div>
|
||||
|
||||
{/* Top Grid: System stats & Updates (3 Columns) */}
|
||||
{/* Top Grid: System stats & Updates */}
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{/* Card 1: System Status (2 columns wide) */}
|
||||
<div className="md:col-span-2 flex flex-col justify-between rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Cpu className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">System-Status</h2>
|
||||
</div>
|
||||
{sys ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
|
||||
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
|
||||
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
|
||||
<RadialGauge
|
||||
value={sys.gpu.busy_percent}
|
||||
label="GPU"
|
||||
detail={`${gb(sys.gpu.gtt_used)} / ${gb(sys.gpu.gtt_total)} GB`}
|
||||
/>
|
||||
)}
|
||||
{sys.disk && (
|
||||
<RadialGauge value={sys.disk.percent} label="Disk" detail={`${gb(sys.disk.used)} / ${gb(sys.disk.total)} GB`} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Systemdaten…</div>
|
||||
)}
|
||||
</div>
|
||||
{sys?.temp && (sys.temp.cpu || sys.temp.gpu) && (
|
||||
<div className="mt-4 flex gap-4 text-xs font-mono text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
{sys.temp.cpu != null && <span>CPU Temp: {sys.temp.cpu} °C</span>}
|
||||
{sys.temp.gpu != null && <span>GPU Temp: {sys.temp.gpu} °C</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card 2: Updates & Wartung (1 column wide) */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 border-b border-border/20 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates & Pflege</h2>
|
||||
</div>
|
||||
{updates?.last_check && (
|
||||
<span className="text-[9px] text-muted-foreground/80 font-mono">
|
||||
Zuletzt gesucht: {new Date(updates.last_check * 1000).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updates ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
updates.os > 0
|
||||
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
|
||||
: "border-border/30 bg-background/25 text-muted-foreground"
|
||||
)}>
|
||||
<span>OS-Pakete</span>
|
||||
<span className="font-mono">{updates.os > 0 ? `${updates.os} verfügbar` : "aktuell"}</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
updates.engine > 0
|
||||
? "border-amber-500/30 bg-amber-500/5 text-amber-400 font-semibold"
|
||||
: "border-border/30 bg-background/25 text-muted-foreground"
|
||||
)}>
|
||||
<span>Engine (llama.cpp)</span>
|
||||
<span className="font-mono">{updates.engine > 0 ? "Update verfügbar" : "aktuell"}</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
updates.models > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary font-semibold animate-pulse"
|
||||
: "border-border/30 bg-background/25 text-muted-foreground"
|
||||
)}>
|
||||
<span>Modell-Upgrades</span>
|
||||
<span className="font-mono">{updates.models > 0 ? `${updates.models} verfügbar` : "aktuell"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
|
||||
<button
|
||||
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
|
||||
disabled={loading || !!activeOsJob}
|
||||
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{activeOsJob ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<span>OS Update</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
|
||||
disabled={loading || !!activeEngineJob}
|
||||
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{activeEngineJob ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Engine Update</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { showConfirm("Host-System neu starten?", "Bist du sicher, dass du das Host-System neu starten willst?", () => postAction("/api/maintenance/reboot", "Reboot")) }}
|
||||
disabled={loading}
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Power className="h-3.5 w-3.5" />
|
||||
<span>Host Reboot</span>
|
||||
</button>
|
||||
|
||||
{/* Model Upgrades list */}
|
||||
{updates.model_list.length > 0 && (
|
||||
<div className="space-y-1.5 border-t border-border/20 pt-3">
|
||||
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
|
||||
<div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
|
||||
{updates.model_list.map((m) => (
|
||||
<div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
|
||||
<span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
|
||||
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => upgradeModel(m.repo, m.role)}
|
||||
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
<Download className="h-2.5 w-2.5" /> Laden
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
|
||||
)}
|
||||
|
||||
{/* Status messages display */}
|
||||
{msg && (
|
||||
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sudoers explanation text */}
|
||||
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
|
||||
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
|
||||
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-border/30 pt-3 shrink-0">
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer"))}
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer shadow-md shadow-primary/10"
|
||||
>
|
||||
System-Zentrale öffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SystemStatusCard />
|
||||
<UpdatesCard />
|
||||
</div>
|
||||
|
||||
{/* Bottom Grid: Agent, Roles, Memory & Token Stats (4 Columns on Desktop) */}
|
||||
{/* Bottom Grid: Agent, Roles, Memory & Token Stats */}
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{/* Card 3: Hermes Agent Status */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Hermes Agent</h2>
|
||||
</div>
|
||||
{agent?.webui_url && (
|
||||
<a
|
||||
href={resolveExternalUrl(agent.webui_url)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1 rounded-lg text-xs font-medium transition-all",
|
||||
agent.webui_reachable
|
||||
? "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/20"
|
||||
: "border border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" /> Hermes öffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{agent ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Gateway</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.gateway_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.gateway_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">WebUI</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={cn("h-2 w-2 rounded-full", agent.webui_reachable ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<span className="text-xs font-medium">{agent.webui_reachable ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setShowBrainSelect(true)}
|
||||
className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer group"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-semibold">Aktives Gehirn</span>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-primary border border-primary/20 bg-primary/10 hover:bg-primary/20 px-1.5 py-0.5 rounded transition-all cursor-pointer font-space"
|
||||
>
|
||||
<Cpu className="h-3 w-3" /> Ändern
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs font-medium mt-1 font-mono text-primary flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
{agent.brain_model ? `model: ${agent.brain_model}` : "model: auto"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Agenten-Status…</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Gedächtnis & Stack-Tools via MCP gekoppelt.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Slot-Belegung (Rollen & Modelle) */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Layers className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Rollen-Belegung</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{["fast", "heavy", "coder", "reasoning", "vision", "scout"].map((role) => {
|
||||
const m = models.find((x) => x.role === role)
|
||||
const isRunning = m ? running.includes(m.name) : false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 rounded-xl border transition-all duration-300",
|
||||
isRunning
|
||||
? "border-emerald-500/30 bg-emerald-500/5 shadow-sm shadow-emerald-500/5"
|
||||
: m
|
||||
? "border-primary/20 bg-primary/5"
|
||||
: "border-border/30 bg-background/10 opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn("text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded font-mono border tracking-wider shrink-0",
|
||||
role === "fast" ? "bg-cyan-500/15 text-cyan-400 border-cyan-500/25" :
|
||||
role === "heavy" ? "bg-amber-500/15 text-amber-400 border-amber-500/25" :
|
||||
role === "coder" ? "bg-violet-500/15 text-violet-400 border-violet-500/25" :
|
||||
role === "reasoning" ? "bg-emerald-500/15 text-emerald-400 border-emerald-500/25" :
|
||||
role === "vision" ? "bg-pink-500/15 text-pink-400 border-pink-500/25" :
|
||||
"bg-teal-500/15 text-teal-400 border-teal-500/25"
|
||||
)}>
|
||||
{role}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold truncate font-mono text-foreground">
|
||||
{m ? m.name.split("/").pop()?.replace(/\.gguf$/i, "") : "nicht zugewiesen"}
|
||||
</span>
|
||||
{m && (
|
||||
<div className="flex gap-1 items-center mt-0.5 flex-wrap">
|
||||
{m.prompt_cache && (
|
||||
<span className="text-[7px] leading-none font-mono font-bold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-1 py-0.5 rounded" title="Prompt Caching aktiv">
|
||||
PC
|
||||
</span>
|
||||
)}
|
||||
{m.spec_draft_model && (
|
||||
<span className="text-[7px] leading-none font-mono font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-1 py-0.5 rounded" title={`Speculative Decoding aktiv (Draft: ${m.spec_draft_model})`}>
|
||||
SPEC
|
||||
</span>
|
||||
)}
|
||||
{m.parallel_slots > 1 && (
|
||||
<span className="text-[7px] leading-none font-mono font-bold bg-violet-500/10 text-violet-400 border border-violet-500/20 px-1 py-0.5 rounded" title={`${m.parallel_slots} parallele Slots aktiv`}>
|
||||
SLOTS: {m.parallel_slots}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{m ? (
|
||||
isRunning ? (
|
||||
<span className="flex items-center gap-1 text-[8px] font-semibold text-emerald-400 uppercase tracking-wider font-mono">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> warm
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-semibold text-muted-foreground uppercase tracking-wider font-mono">
|
||||
bereit
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[8px] font-semibold text-muted-foreground/60 uppercase tracking-wider font-mono">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Laden erfolgt automatisch per Auto-Swap.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Quick Memory Input */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Gedächtnis</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={memContent}
|
||||
onChange={(e) => setMemContent(e.target.value)}
|
||||
placeholder="Fakt / Regel im Pool speichern..."
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-xl border border-border/50 bg-background/30 p-2 text-[10px] outline-none focus:ring-1 focus:ring-primary transition-all leading-normal"
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<select
|
||||
value={memCat}
|
||||
onChange={(e) => setMemCat(e.target.value)}
|
||||
className="h-7 rounded-lg border border-border/50 bg-background/50 px-2 text-[10px] outline-none cursor-pointer"
|
||||
>
|
||||
<option value="stable">🔵 Fakt</option>
|
||||
<option value="instruction">📋 Regel</option>
|
||||
<option value="user">👤 User</option>
|
||||
<option value="versioned">🟡 Version</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={saveQuickMemory}
|
||||
disabled={!memContent.trim() || savingMem}
|
||||
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-3 text-[10px] font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 space-y-1.5">
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">Zuletzt gespeichert:</div>
|
||||
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
|
||||
{memories.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground/75 py-1">Keine Einträge vorhanden.</div>
|
||||
) : (
|
||||
memories.map((m) => (
|
||||
<div key={m.id} className="text-[10px] bg-background/10 border border-border/30 rounded p-1.5 flex items-start gap-1.5">
|
||||
<span className="shrink-0 text-[8px] font-mono text-muted-foreground/80 px-1 py-0.5 rounded bg-muted/20">
|
||||
{m.category}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-muted-foreground hover:text-foreground transition-colors" title={m.content}>
|
||||
{m.content}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[10px] text-muted-foreground/80 border-t border-border/30 pt-3">
|
||||
Steht allen Clients per MCP zur Verfügung.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Effizienz & Ersparnis (Token-Stats & Ersparnis) */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Coins className="h-4.5 w-4.5 text-primary animate-pulse" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Effizienz & Ersparnis</h2>
|
||||
</div>
|
||||
|
||||
{tokenStats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Geld gespart</div>
|
||||
<div className="text-base font-bold text-emerald-400 mt-0.5 tracking-tight font-space">
|
||||
{tokenStats.saved_eur.toLocaleString("de-DE", { minimumFractionDigits: 2 })} €
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
|
||||
({tokenStats.saved_usd.toLocaleString("en-US", { minimumFractionDigits: 2 })} $)
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-background/20 rounded-xl border border-border/40 text-left">
|
||||
<div className="text-[8px] uppercase tracking-wider font-semibold text-muted-foreground/60">Gesamt-Tokens</div>
|
||||
<div className="text-base font-bold text-primary mt-0.5 tracking-tight font-space">
|
||||
{tokenStats.total_tokens.toLocaleString("de-DE")}
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground/80 mt-0.5 font-mono">
|
||||
(Lokale Inferenz)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-border/20 pt-3 text-[10px] text-muted-foreground">
|
||||
<div className="flex justify-between items-center font-mono">
|
||||
<span>Input (Prompts):</span>
|
||||
<span className="font-semibold text-foreground">{tokenStats.prompt_tokens.toLocaleString("de-DE")} tkn</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center font-mono">
|
||||
<span>Output (Antworten):</span>
|
||||
<span className="font-semibold text-foreground">{tokenStats.completion_tokens.toLocaleString("de-DE")} tkn</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-28 flex items-center justify-center text-xs text-muted-foreground">Lade Statistiken…</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 text-[9px] text-muted-foreground/70 border-t border-border/30 pt-2.5 leading-normal">
|
||||
Berechnet im Vergleich zu Cloud-APIs von Juni 2026 (Ø 15,00 $ / 75,00 $ pro 1M tkn).
|
||||
</div>
|
||||
</div>
|
||||
<AgentStatusCard />
|
||||
<RolesCard />
|
||||
<MemoryInputCard />
|
||||
<TokenStatsCard />
|
||||
</div>
|
||||
|
||||
{agent && showBrainSelect && (
|
||||
<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 flex items-center gap-1.5">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<span>Hermes-Gehirn konfigurieren</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowBrainSelect(false)}
|
||||
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 leading-relaxed">
|
||||
Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (<code className="text-primary font-semibold">auto</code> / <code className="text-primary font-semibold">fast</code> / <code className="text-primary font-semibold">heavy</code>):
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => {
|
||||
const isAlias = ["auto", "fast", "heavy"].includes(m);
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => changeBrainModel(m)}
|
||||
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",
|
||||
agent.brain_model === m || (!agent.brain_model && m === "auto")
|
||||
? "text-primary font-bold bg-primary/10 border-primary/30"
|
||||
: "text-foreground bg-background/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="font-semibold truncate max-w-[280px]">
|
||||
{m}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground mt-0.5">
|
||||
{isAlias ? "Gateway Routing Alias" : "Installiertes GGUF Modell"}
|
||||
</span>
|
||||
</div>
|
||||
{(agent.brain_model === m || (!agent.brain_model && m === "auto")) && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CustomDialog
|
||||
type={dialog.type}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
onConfirm={() => dialog.onConfirm()}
|
||||
onCancel={dialog.onCancel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
|
||||
import { api, type DedupeResult, type Memory } from "@/lib/api"
|
||||
import { api, type DedupeResult } from "@/lib/api"
|
||||
import { useMemory, useQueryClient } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
|
||||
|
||||
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
||||
@@ -26,73 +27,32 @@ const BORDER_CLASSES: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function MemoryView() {
|
||||
const [items, setItems] = useState<Memory[]>([])
|
||||
const [filter, setFilter] = useState("")
|
||||
const [q, setQ] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [category, setCategory] = useState("stable")
|
||||
const [error, setError] = useState("")
|
||||
const [deduping, setDeduping] = useState(false)
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: "alert" | "confirm"
|
||||
title: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
} | null>(null)
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
|
||||
const error = itemsErr ? String(itemsErr) : ""
|
||||
|
||||
function showAlert(title: string, message: string, onConfirm?: () => void) {
|
||||
setDialog({
|
||||
type: "alert",
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => {
|
||||
setDialog(null)
|
||||
if (onConfirm) onConfirm()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showConfirm(title: string, message: string, onConfirm: () => void) {
|
||||
setDialog({
|
||||
type: "confirm",
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => {
|
||||
setDialog(null)
|
||||
onConfirm()
|
||||
},
|
||||
onCancel: () => setDialog(null)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function load() {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set("q", q)
|
||||
if (filter) params.set("category", filter)
|
||||
api<Memory[]>(`/api/memory?${params}`)
|
||||
.then(setItems)
|
||||
.catch((e) => setError(String(e)))
|
||||
}
|
||||
|
||||
useEffect(load, [q, filter])
|
||||
const reloadMemory = () => qc.invalidateQueries({ queryKey: ["memory"] })
|
||||
|
||||
async function add() {
|
||||
if (!content.trim()) return
|
||||
await api("/api/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content, category, source: "ui" })
|
||||
await api("/api/memory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content, category, source: "ui" })
|
||||
})
|
||||
setContent("")
|
||||
load()
|
||||
reloadMemory()
|
||||
}
|
||||
|
||||
async function del(id: string) {
|
||||
await api(`/api/memory/${id}`, { method: "DELETE" })
|
||||
load()
|
||||
reloadMemory()
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
@@ -111,11 +71,11 @@ export function MemoryView() {
|
||||
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
|
||||
async () => {
|
||||
try {
|
||||
await api("/api/memory/dedupe", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apply: true })
|
||||
await api("/api/memory/dedupe", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apply: true })
|
||||
})
|
||||
load()
|
||||
reloadMemory()
|
||||
} catch (e: any) {
|
||||
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
|
||||
}
|
||||
@@ -285,15 +245,7 @@ export function MemoryView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dialog && (
|
||||
<CustomDialog
|
||||
type={dialog.type}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
onConfirm={dialog.onConfirm}
|
||||
onCancel={dialog.onCancel}
|
||||
/>
|
||||
)}
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
|
||||
import { api, type ServicesResp, type SystemStatus } from "@/lib/api"
|
||||
import { api } from "@/lib/api"
|
||||
import { useSystemStatus, useServices } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
import { CustomDialog } from "@/components/CustomDialog"
|
||||
import { gb } from "@/lib/format"
|
||||
|
||||
|
||||
function gb(b: number) {
|
||||
return (b / 1024 ** 3).toFixed(1)
|
||||
}
|
||||
|
||||
function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string; percent: number; detail?: string; icon: any }) {
|
||||
const barColor = percent > 90
|
||||
? "bg-red-500 shadow-md shadow-red-500/20"
|
||||
@@ -39,44 +37,13 @@ function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string;
|
||||
}
|
||||
|
||||
export function SystemView() {
|
||||
const [s, setS] = useState<SystemStatus | null>(null)
|
||||
const [svc, setSvc] = useState<ServicesResp | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const { data: s, error: sErr } = useSystemStatus(3_000)
|
||||
const { data: svc } = useServices(3_000)
|
||||
const { showAlert, dialogElement } = useDialog()
|
||||
const error = sErr ? String(sErr) : ""
|
||||
const [backupMsg, setBackupMsg] = useState("")
|
||||
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Custom Dialog State
|
||||
const [dialog, setDialog] = useState<{
|
||||
type: "alert" | "confirm"
|
||||
title: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
} | null>(null)
|
||||
|
||||
function showAlert(title: string, message: string, onConfirm?: () => void) {
|
||||
setDialog({
|
||||
type: "alert",
|
||||
title,
|
||||
message,
|
||||
onConfirm: () => {
|
||||
setDialog(null)
|
||||
if (onConfirm) onConfirm()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function load() {
|
||||
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
|
||||
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, 3000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
async function doBackup() {
|
||||
setBackupMsg("Backup snapshotted...")
|
||||
try {
|
||||
@@ -257,15 +224,7 @@ export function SystemView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CustomDialog
|
||||
type={dialog.type}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
onConfirm={dialog.onConfirm}
|
||||
onCancel={dialog.onCancel}
|
||||
/>
|
||||
)}
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react"
|
||||
import { Download, Search } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
|
||||
export function AddModel() {
|
||||
const [repo, setRepo] = useState("")
|
||||
const [quants, setQuants] = useState<string[]>([])
|
||||
const [quant, setQuant] = useState("Q4_K_M")
|
||||
const [msg, setMsg] = useState("")
|
||||
const [q, setQ] = useState("")
|
||||
const [results, setResults] = useState<{ repo: string; downloads: number }[]>([])
|
||||
|
||||
async function loadQuants(r?: string) {
|
||||
const rr = r ?? repo
|
||||
if (!rr.trim()) return
|
||||
setMsg("Analysiere HuggingFace Repository...")
|
||||
try {
|
||||
const d = await api<{ repo: string; quants: string[] }>(`/api/hf/quants?repo=${encodeURIComponent(rr)}`)
|
||||
setRepo(d.repo)
|
||||
setQuants(d.quants)
|
||||
if (d.quants.length) setQuant(d.quants.includes("Q4_K_M") ? "Q4_K_M" : d.quants[0])
|
||||
setMsg(d.quants.length ? "" : "Keine GGUF-Dateien in diesem Repository gefunden.")
|
||||
} catch (e) {
|
||||
setMsg(`Fehler: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!q.trim()) return
|
||||
setMsg("Durchsuche HuggingFace...")
|
||||
try {
|
||||
const d = await api<{ results: { repo: string; downloads: number }[] }>(`/api/hf/search?q=${encodeURIComponent(q)}`)
|
||||
setResults(d.results)
|
||||
setMsg(d.results.length ? "" : "Keine Ergebnisse gefunden.")
|
||||
} catch (e) {
|
||||
setMsg(`Suche fehlgeschlagen: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!repo.trim()) return
|
||||
setMsg("Download-Job wird initiiert...")
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, quant, jinja: true }),
|
||||
})
|
||||
setMsg(`Download gestartet: ${repo} (${quant}). Fortschritt wird oben angezeigt.`)
|
||||
} catch (e) {
|
||||
setMsg(`Download-Fehler: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">HF Download & Suche</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
placeholder="HF-URL oder org/repo (z.B. unsloth/Qwen2.5-Coder-7B-Instruct-GGUF)"
|
||||
className="flex-1 h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => loadQuants()}
|
||||
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
Quants laden
|
||||
</button>
|
||||
{quants.length > 0 && (
|
||||
<>
|
||||
<select
|
||||
value={quant}
|
||||
onChange={(e) => setQuant(e.target.value)}
|
||||
className="h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none text-foreground font-semibold"
|
||||
>
|
||||
{quants.map((qq) => <option key={qq} value={qq} className="bg-popover text-foreground">{qq}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={install}
|
||||
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Herunterladen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t border-border/20 pt-4">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
placeholder="HuggingFace durchsuchen (z.B. Llama-3.1)..."
|
||||
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background/40 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<button
|
||||
onClick={search}
|
||||
className="h-9 px-4 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="max-h-48 space-y-1.5 overflow-y-auto border border-border/40 bg-background/20 p-2 rounded-xl scrollbar-thin">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.repo}
|
||||
onClick={() => { setRepo(r.repo); setResults([]); setQ(""); loadQuants(r.repo) }}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-xs bg-background/10 border border-border/10 hover:border-primary/30 hover:bg-background/30 transition-all"
|
||||
>
|
||||
<span className="font-semibold truncate">{r.repo}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground flex items-center gap-1 shrink-0">
|
||||
<Download className="h-3 w-3" /> {r.downloads.toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div className="text-[10px] font-medium text-primary font-mono">{msg}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
import { useState } from "react"
|
||||
import { Download, Search, Star, Layers, Check, Bot, Eye, Code, Brain, Compass, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { useModels, useUpdates, useDiscover } from "@/lib/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtBytes } from "@/lib/format"
|
||||
import { FitBadge } from "@/components/models/ModelBadges"
|
||||
import { AddModel } from "./AddModel"
|
||||
|
||||
const ROLE_METADATA: Record<string, { title: string; desc: string; icon: any }> = {
|
||||
vision: {
|
||||
title: "Bilder & Vision",
|
||||
desc: "Verarbeitet Bilder, Screenshots und visuelle Diagramme in multimodalen Chats.",
|
||||
icon: Eye
|
||||
},
|
||||
coder: {
|
||||
title: "Coden & Entwicklung",
|
||||
desc: "Autovervollständigung, Refactoring und Codegenerierung direkt in deiner IDE.",
|
||||
icon: Code
|
||||
},
|
||||
reasoning: {
|
||||
title: "Logik & Nachdenken",
|
||||
desc: "Komplexe logische Gedankengänge, Mathematik und tiefgründiges Planen (Reasoning).",
|
||||
icon: Brain
|
||||
},
|
||||
agent: {
|
||||
title: "Autonomer Agent (Hermes)",
|
||||
desc: "Führt selbstständig Terminalbefehle aus und interagiert mit deinem Homelab.",
|
||||
icon: Bot
|
||||
},
|
||||
scout: {
|
||||
title: "Allrounder & Chat",
|
||||
desc: "Schnelle Antworten, Zusammenfassungen, Übersetzungen und alltägliche Fragen.",
|
||||
icon: Compass
|
||||
}
|
||||
}
|
||||
|
||||
export function Discover() {
|
||||
const { data, isLoading: loading, error: loadErr } = useDiscover()
|
||||
const { data: modelsResp } = useModels()
|
||||
const { data: updates } = useUpdates()
|
||||
const models = modelsResp?.models ?? []
|
||||
const error = loadErr ? String(loadErr) : ""
|
||||
const [installing, setInstalling] = useState<Record<string, string>>({})
|
||||
const [expandedAlternatives, setExpandedAlternatives] = useState<Record<string, boolean>>({})
|
||||
const [showExpert, setShowExpert] = useState(false)
|
||||
|
||||
async function install(repo: string, role: string, quant: string, toolCapable: boolean) {
|
||||
setInstalling((s) => ({ ...s, [repo]: "Starte..." }))
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, role, quant, jinja: toolCapable }),
|
||||
})
|
||||
setInstalling((s) => ({ ...s, [repo]: "Download läuft" }))
|
||||
} catch (e) {
|
||||
setInstalling((s) => ({ ...s, [repo]: "Fehler" }))
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-xs text-muted-foreground py-12 text-center">Analysiere Hardware und suche passende GGUF-Empfehlungen…</div>
|
||||
if (error || !data)
|
||||
return (
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">
|
||||
Empfehlungsdienst temporär nicht erreichbar ({error}).
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Informational Header */}
|
||||
<div className="text-[10px] font-mono text-muted-foreground bg-background/25 border border-border/40 p-4 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-3 shadow-sm">
|
||||
<div>
|
||||
Modell-Registry geladen für <span className="text-foreground font-bold">{data.sys_ram_gb} GB</span> System-RAM.
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="h-3.5 w-3.5 text-primary fill-primary/20" />
|
||||
<span>Empfehlungen sind automatisch auf deine Box-Hardware optimiert.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sockets/Slots Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{data.categories.map((cat) => {
|
||||
const meta = ROLE_METADATA[cat.role] || {
|
||||
title: cat.title || cat.role,
|
||||
desc: "Spezifisches Modell für diese Systemrolle.",
|
||||
icon: Layers
|
||||
}
|
||||
const IconComponent = meta.icon
|
||||
|
||||
// Check if a model is installed for this role
|
||||
const installedModel = models.find((m) => m.role === cat.role)
|
||||
|
||||
// Check if an upgrade is available for this role
|
||||
const hasUpgrade = updates?.model_list.find((u) => u.role === cat.role)
|
||||
|
||||
// Get the primary recommended model
|
||||
const recommendedModel = cat.models.find((m) => m.repo === cat.recommended) || cat.models[0]
|
||||
if (!recommendedModel) return null
|
||||
|
||||
const isInstallingRecommended = installing[recommendedModel.repo]
|
||||
const alternativeModels = cat.models.filter((m) => m.repo !== cat.recommended)
|
||||
const isExpanded = !!expandedAlternatives[cat.role]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cat.role}
|
||||
className={cn(
|
||||
"rounded-2xl border bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 flex flex-col justify-between gap-5 transition-all duration-300 hover:border-primary/40",
|
||||
installedModel ? "border-border/60" : "border-primary/20 shadow-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Socket Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-sm shrink-0">
|
||||
<IconComponent className="h-5.5 w-5.5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold tracking-tight text-foreground">{meta.title}</h3>
|
||||
<span className="text-[9px] font-mono text-muted-foreground uppercase bg-background/50 px-1.5 py-0.5 rounded border border-border/30 inline-block mt-0.5">
|
||||
Rolle: {cat.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badges */}
|
||||
{installedModel ? (
|
||||
<span className="flex items-center gap-1.5 text-[9px] font-bold text-emerald-400 uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Aktiviert
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-bold text-primary/70 uppercase tracking-wider bg-primary/10 border border-primary/20 px-2.5 py-1 rounded-lg">
|
||||
Frei
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Description */}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{meta.desc}
|
||||
</p>
|
||||
|
||||
{/* Current vs Recommended model card */}
|
||||
<div className="p-3.5 rounded-xl bg-background/35 border border-border/30 space-y-2.5">
|
||||
{installedModel ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60">Aktive GGUF-Belegung</div>
|
||||
<div className="text-xs font-mono font-bold text-foreground truncate" title={installedModel.name}>
|
||||
{installedModel.name.split("/").pop()}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2">
|
||||
<span>Größe: {fmtBytes(installedModel.size_bytes || 0)}</span>
|
||||
<span>•</span>
|
||||
<span>Quant: {installedModel.quant || "GGUF"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[9px] font-bold uppercase tracking-wider text-primary/80">Empfohlenes Modell</div>
|
||||
<div className="text-xs font-mono font-bold text-foreground truncate" title={recommendedModel.name}>
|
||||
{recommendedModel.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/80 flex items-center gap-2 flex-wrap">
|
||||
<span>Ersteller: {recommendedModel.author}</span>
|
||||
<span>•</span>
|
||||
<span>Quant: {recommendedModel.quant}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
<FitBadge fit={recommendedModel.fit} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Action Button */}
|
||||
<div className="pt-1">
|
||||
{installedModel ? (
|
||||
hasUpgrade ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[9px] font-semibold text-amber-400 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping shrink-0" />
|
||||
<span>Bessere Version in der Registry: {hasUpgrade.repo.split("/").pop()}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => install(hasUpgrade.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
||||
disabled={!!installing[hasUpgrade.repo]}
|
||||
className="h-8 w-full flex items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-black text-xs font-bold hover:bg-amber-400 disabled:opacity-50 transition-all cursor-pointer shadow-md shadow-amber-500/10"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{installing[hasUpgrade.repo] || "Auf neue Version aktualisieren"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-8 flex items-center justify-center gap-1.5 text-[10px] font-bold text-emerald-400 uppercase tracking-wide bg-emerald-500/5 border border-emerald-500/10 rounded-lg select-none">
|
||||
<Check className="h-4 w-4" /> Auf neuestem Stand
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => install(recommendedModel.repo, cat.role, recommendedModel.quant || "Q4_K_M", recommendedModel.caps.tools !== "no")}
|
||||
disabled={!!isInstallingRecommended}
|
||||
className={cn(
|
||||
"h-8 w-full flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer border",
|
||||
isInstallingRecommended
|
||||
? "border-primary/40 bg-primary/5 text-primary"
|
||||
: "bg-primary text-primary-foreground hover:opacity-90 shadow-md shadow-primary/10"
|
||||
)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{isInstallingRecommended || "Optimales Modell einsetzen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible alternatives list */}
|
||||
{alternativeModels.length > 0 && (
|
||||
<div className="border-t border-border/20 pt-3">
|
||||
<button
|
||||
onClick={() => setExpandedAlternatives((s) => ({ ...s, [cat.role]: !isExpanded }))}
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80 hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
<span>Alternative Empfehlungen anzeigen ({alternativeModels.length})</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2.5 space-y-2 max-h-36 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{alternativeModels.map((alt) => (
|
||||
<div key={alt.repo} className="p-2 rounded-lg bg-background/25 border border-border/20 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-mono font-bold text-foreground truncate" title={alt.name}>
|
||||
{alt.name}
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground flex items-center gap-1.5 mt-0.5">
|
||||
<span>Quant: {alt.quant}</span>
|
||||
<span>•</span>
|
||||
<span>{alt.fit.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => install(alt.repo, cat.role, alt.quant || "Q4_K_M", alt.caps.tools !== "no")}
|
||||
disabled={!!installing[alt.repo]}
|
||||
className="h-6 px-2.5 rounded bg-background/40 hover:bg-background/80 border border-border/40 text-[9px] font-bold text-foreground transition-all cursor-pointer shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{installing[alt.repo] || "Installieren"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Collapsible Custom Hugging Face Downloader */}
|
||||
<div className="border border-border/50 bg-card/20 rounded-2xl overflow-hidden shadow-md mt-4">
|
||||
<button
|
||||
onClick={() => setShowExpert(!showExpert)}
|
||||
className="w-full px-5 py-4 flex items-center justify-between text-xs font-bold uppercase tracking-wider text-muted-foreground hover:bg-card/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-primary" />
|
||||
<span>Eigenes Modell von Hugging Face laden (Erweiterte Ansicht)</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-primary hover:underline">
|
||||
{showExpert ? "Ausblenden ▲" : "Anzeigen ▼"}
|
||||
</span>
|
||||
</button>
|
||||
{showExpert && (
|
||||
<div className="p-5 border-t border-border/20 bg-card/10">
|
||||
<AddModel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user