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.
|
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -15,6 +18,13 @@ from starlette.requests import Request
|
|||||||
from config import FRONTEND_DIST, VERSION
|
from config import FRONTEND_DIST, VERSION
|
||||||
from routers import agent, connect, gateway_proxy, health, maintenance, memory, models, routing, system
|
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)
|
app = FastAPI(title="Mission Control 2.0", version=VERSION)
|
||||||
|
|
||||||
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
# 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:
|
if "{model}" not in CMD_TEMPLATE:
|
||||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
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).
|
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
sys.path.append(str(Path(__file__).resolve().parent))
|
sys.path.append(str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
from services.llamaswap import read_config, write_config
|
from services.llamaswap import read_config, write_config
|
||||||
from config import CONFIG_PATH
|
from config import CONFIG_PATH, SPEC_DRAFT_MODEL_PATH
|
||||||
|
|
||||||
def migrate():
|
def migrate():
|
||||||
print(f"Reading config from {CONFIG_PATH}...")
|
print(f"Reading config from {CONFIG_PATH}...")
|
||||||
@@ -15,9 +15,9 @@ def migrate():
|
|||||||
|
|
||||||
cfg = read_config()
|
cfg = read_config()
|
||||||
models = cfg.get("models", {})
|
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():
|
for name, spec in models.items():
|
||||||
if not isinstance(spec, dict):
|
if not isinstance(spec, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import json
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from config import LLAMA_SWAP_URL
|
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.router_logic import FAST, FAST_NO_THINK, choose_model
|
||||||
from services.token_stats import increment_tokens
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
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 httpx.AsyncClient(timeout=None) as c:
|
||||||
async with c.stream("POST", url, json=body) as r:
|
async with c.stream("POST", url, json=body) as r:
|
||||||
async for chunk in r.aiter_raw():
|
async for chunk in r.aiter_raw():
|
||||||
try:
|
record_stream_chunk(chunk, alias)
|
||||||
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
|
|
||||||
yield chunk
|
yield chunk
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=600) as c:
|
async with httpx.AsyncClient(timeout=600) as c:
|
||||||
r = await c.post(url, json=body)
|
r = await c.post(url, json=body)
|
||||||
resp_json = r.json()
|
resp_json = r.json()
|
||||||
try:
|
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
|
||||||
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
|
|
||||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
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.
|
zurückgegeben statt zu crashen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
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 import backup as backup_svc
|
||||||
from services.agent import agent_status
|
from services.agent import agent_status
|
||||||
from services.gateway import gateway_reachable
|
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.system import system_status
|
||||||
|
from services.token_stats import get_stats
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -90,64 +95,16 @@ def self_update() -> dict:
|
|||||||
return {"pull": pull, "reset": reset, "restart": restart_res}
|
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")
|
@router.get("/system/token-stats")
|
||||||
def token_stats() -> dict:
|
def token_stats() -> dict:
|
||||||
stats = get_stats()
|
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||||
p = stats.get("prompt_tokens", 0)
|
# Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen.
|
||||||
c = stats.get("completion_tokens", 0)
|
role_map: dict[str, str | None] = {}
|
||||||
total = p + c
|
|
||||||
|
|
||||||
# Map model IDs and aliases to their respective roles for pricing resolution
|
|
||||||
role_map = {}
|
|
||||||
try:
|
try:
|
||||||
for m in list_models():
|
for m in list_models():
|
||||||
role_map[m["name"].lower()] = m.get("role")
|
role_map[m["name"].lower()] = m.get("role")
|
||||||
for alias in m.get("aliases", []):
|
for alias in m.get("aliases", []):
|
||||||
role_map[alias.lower()] = m.get("role")
|
role_map[alias.lower()] = m.get("role")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True)
|
||||||
|
return compute_savings(get_stats(), role_map)
|
||||||
# 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden
|
|||||||
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import httpx
|
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:
|
def _reach(url: str, path: str = "") -> bool:
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=3.0) as c:
|
with httpx.Client(timeout=3.0) as c:
|
||||||
return c.get(f"{url}{path}").status_code < 500
|
return c.get(f"{url}{path}").status_code < 500
|
||||||
except Exception:
|
except httpx.HTTPError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ def agent_status() -> dict:
|
|||||||
if isinstance(cfg, dict):
|
if isinstance(cfg, dict):
|
||||||
brain_model = cfg.get("model", {}).get("model", "auto")
|
brain_model = cfg.get("model", {}).get("model", "auto")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -64,6 +68,7 @@ def update_brain_model(new_model: str) -> bool:
|
|||||||
with config_path.open("r", encoding="utf-8") as f:
|
with config_path.open("r", encoding="utf-8") as f:
|
||||||
cfg = r_yaml.load(f) or {}
|
cfg = r_yaml.load(f) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
||||||
cfg = {}
|
cfg = {}
|
||||||
|
|
||||||
if not isinstance(cfg, dict):
|
if not isinstance(cfg, dict):
|
||||||
@@ -85,8 +90,9 @@ def update_brain_model(new_model: str) -> bool:
|
|||||||
import services.maintenance as maintenance
|
import services.maintenance as maintenance
|
||||||
maintenance.restart_service("hermes-gateway")
|
maintenance.restart_service("hermes-gateway")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ import time
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||||
from services.caps import capabilities
|
from services.caps import capabilities
|
||||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||||
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
_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()
|
data = c.get(url).json()
|
||||||
return data if isinstance(data, list) else []
|
return data if isinstance(data, list) else []
|
||||||
except Exception:
|
except Exception:
|
||||||
|
log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True)
|
||||||
return []
|
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")
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
os.replace(tmp, DISCOVER_CACHE_PATH)
|
os.replace(tmp, DISCOVER_CACHE_PATH)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Cache ist nur Beschleunigung
|
log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -118,7 +123,7 @@ def load_discover() -> dict | None:
|
|||||||
if DISCOVER_CACHE_PATH.exists():
|
if DISCOVER_CACHE_PATH.exists():
|
||||||
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
|
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -130,4 +135,5 @@ def safe_discover(ram_gb: float) -> dict | None:
|
|||||||
try:
|
try:
|
||||||
return refresh_discover(ram_gb)
|
return refresh_discover(ram_gb)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True)
|
||||||
return cached
|
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
|
import httpx
|
||||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
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).
|
# Kanonische Rollen (vereinheitlicht ggü. v1: kein manager/reviewer mehr).
|
||||||
ROLE_IDS = {"vision", "coder", "reasoning", "agent", "scout"}
|
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 role_lower in ("fast", "coder"):
|
||||||
if "--parallel" not in cmd:
|
if "--parallel" not in cmd:
|
||||||
cmd += " --parallel 2"
|
cmd += " --parallel 2"
|
||||||
draft_path = "/srv/models/drafts/qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
if os.path.exists(SPEC_DRAFT_MODEL_PATH) and "--spec-draft-model" not in cmd:
|
||||||
if os.path.exists(draft_path) and "--spec-draft-model" not in cmd:
|
cmd += f" --spec-draft-model {SPEC_DRAFT_MODEL_PATH}"
|
||||||
cmd += f" --spec-draft-model {draft_path}"
|
|
||||||
|
|
||||||
cfg.setdefault("models", {})[model_id] = {
|
cfg.setdefault("models", {})[model_id] = {
|
||||||
"cmd": LiteralScalarString(cmd + "\n"),
|
"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 json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import HERMES_HOME
|
from config import HERMES_HOME
|
||||||
|
|
||||||
STATS_FILE = HERMES_HOME / "token_stats.json"
|
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:
|
def get_stats() -> dict:
|
||||||
if not STATS_FILE.exists():
|
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
|
||||||
# Initialize stats with a nice baseline (e.g., representing previous usage)
|
with _lock:
|
||||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
return json.loads(json.dumps(_ensure_loaded()))
|
||||||
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": {}}
|
|
||||||
|
|
||||||
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):
|
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
|
||||||
stats = get_stats()
|
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
|
||||||
stats["prompt_tokens"] += prompt
|
global _dirty, _last_flush
|
||||||
stats["completion_tokens"] += completion
|
with _lock:
|
||||||
if model:
|
stats = _ensure_loaded()
|
||||||
model = model.lower()
|
stats["prompt_tokens"] += prompt
|
||||||
if "models" not in stats:
|
stats["completion_tokens"] += completion
|
||||||
stats["models"] = {}
|
if model:
|
||||||
if model not in stats["models"]:
|
m = stats.setdefault("models", {}).setdefault(
|
||||||
stats["models"][model] = {"prompt": 0, "completion": 0}
|
model.lower(), {"prompt": 0, "completion": 0})
|
||||||
stats["models"][model]["prompt"] += prompt
|
m["prompt"] += prompt
|
||||||
stats["models"][model]["completion"] += completion
|
m["completion"] += completion
|
||||||
save_stats(stats)
|
_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="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+27
@@ -8,6 +8,7 @@
|
|||||||
"name": "mission-control-2-frontend",
|
"name": "mission-control-2-frontend",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
@@ -1810,6 +1811,32 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/node": "^22.10.1",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
|||||||
+3
-16
@@ -11,30 +11,17 @@ import { AgentView } from "@/views/AgentView"
|
|||||||
import { GuideView } from "@/views/GuideView"
|
import { GuideView } from "@/views/GuideView"
|
||||||
import { Placeholder } from "@/views/Placeholder"
|
import { Placeholder } from "@/views/Placeholder"
|
||||||
import { SystemDrawer } from "@/components/SystemDrawer"
|
import { SystemDrawer } from "@/components/SystemDrawer"
|
||||||
import { api, type Health } from "@/lib/api"
|
import { useHealth, useSystemStatus } from "@/lib/queries"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<ViewId>("dashboard")
|
const [view, setView] = useState<ViewId>("dashboard")
|
||||||
const [health, setHealth] = useState<Health | null>(null)
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
|
||||||
const [sysStatus, setSysStatus] = useState<any | null>(null)
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
|
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: health } = useHealth()
|
||||||
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null))
|
const { data: sysStatus } = useSystemStatus(20_000)
|
||||||
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)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add("dark")
|
document.documentElement.classList.add("dark")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from "react"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
export interface CustomDialogProps {
|
export interface CustomDialogProps {
|
||||||
@@ -10,6 +11,7 @@ export interface CustomDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CustomDialog({ type, title, message, defaultValue, onConfirm, onCancel }: CustomDialogProps) {
|
export function CustomDialog({ type, title, message, defaultValue, onConfirm, onCancel }: CustomDialogProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[99] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
|
<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">
|
<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" && (
|
{type === "prompt" && (
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
id="custom-dialog-input"
|
|
||||||
defaultValue={defaultValue}
|
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"
|
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
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const val = (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
|
onConfirm(inputRef.current?.value)
|
||||||
onConfirm(val)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -51,9 +52,7 @@ export function CustomDialog({ type, title, message, defaultValue, onConfirm, on
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const val = type === "prompt"
|
const val = type === "prompt" ? inputRef.current?.value : undefined
|
||||||
? (document.getElementById("custom-dialog-input") as HTMLInputElement)?.value
|
|
||||||
: undefined
|
|
||||||
onConfirm(val)
|
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"
|
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
|
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 {
|
export interface SystemStatus {
|
||||||
cpu: { percent: number; cores: number | null }
|
cpu: { percent: number; cores: number | null }
|
||||||
ram: { total: number; used: number; percent: number }
|
ram: { total: number; used: number; percent: number }
|
||||||
@@ -142,6 +170,7 @@ export interface SystemStatus {
|
|||||||
} | null
|
} | null
|
||||||
temp: { cpu?: number; gpu?: number } | null
|
temp: { cpu?: number; gpu?: number } | null
|
||||||
disk: { total: number; used: number; percent: number } | null
|
disk: { total: number; used: number; percent: number } | null
|
||||||
|
versions?: Versions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServicesResp {
|
export interface ServicesResp {
|
||||||
@@ -214,3 +243,17 @@ export interface Health {
|
|||||||
engine_reachable: boolean
|
engine_reachable: boolean
|
||||||
gateway_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 React from "react"
|
||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
import "./index.css"
|
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(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</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 { 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 { 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 }) {
|
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() {
|
export function AgentView() {
|
||||||
const [s, setS] = useState<AgentStatus | null>(null)
|
const { data: s, error: sErr } = useAgentStatus(5_000)
|
||||||
const [error, setError] = useState("")
|
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
|
// Graph UI state
|
||||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
||||||
const [showBrainSelect, setShowBrainSelect] = useState(false)
|
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
|
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
|
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}`
|
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) {
|
async function changeBrainModel(model: string) {
|
||||||
try {
|
try {
|
||||||
await api("/api/agent/brain", {
|
await api("/api/agent/brain", {
|
||||||
@@ -119,20 +102,13 @@ export function AgentView() {
|
|||||||
body: JSON.stringify({ model })
|
body: JSON.stringify({ model })
|
||||||
})
|
})
|
||||||
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
|
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
|
||||||
loadData()
|
qc.invalidateQueries({ queryKey: qk.agentStatus })
|
||||||
setShowBrainSelect(false)
|
setShowBrainSelect(false)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
|
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData()
|
|
||||||
loadModels()
|
|
||||||
const t = setInterval(loadData, 5000)
|
|
||||||
return () => clearInterval(t)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Styles inside AgentView for dash flow animations */}
|
{/* Styles inside AgentView for dash flow animations */}
|
||||||
@@ -471,15 +447,7 @@ export function AgentView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dialog && (
|
{dialogElement}
|
||||||
<CustomDialog
|
|
||||||
type={dialog.type}
|
|
||||||
title={dialog.title}
|
|
||||||
message={dialog.message}
|
|
||||||
onConfirm={() => dialog.onConfirm && dialog.onConfirm()}
|
|
||||||
onCancel={dialog.onCancel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 { 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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function ConnectView() {
|
export function ConnectView() {
|
||||||
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
|
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
|
||||||
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
|
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
|
||||||
const [data, setData] = useState<ConnectResp | null>(null)
|
|
||||||
const [active, setActive] = useState("cline")
|
const [active, setActive] = useState("cline")
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [error, setError] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
const params = new URLSearchParams({ host })
|
||||||
const params = new URLSearchParams()
|
if (mcpPath) params.set("mcp_path", mcpPath)
|
||||||
params.set("host", host)
|
const { data, error: dataErr } = useConnect(params.toString())
|
||||||
if (mcpPath) params.set("mcp_path", mcpPath)
|
const error = dataErr ? String(dataErr) : ""
|
||||||
api<ConnectResp>(`/api/connect?${params}`)
|
|
||||||
.then(setData)
|
|
||||||
.catch((e) => setError(String(e)))
|
|
||||||
}, [host, mcpPath])
|
|
||||||
|
|
||||||
function saveHost(v: string) {
|
function saveHost(v: string) {
|
||||||
setHost(v)
|
setHost(v)
|
||||||
|
|||||||
@@ -1,317 +1,13 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
|
||||||
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw, Check, Coins } from "lucide-react"
|
import { UpdatesCard } from "@/components/dashboard/UpdatesCard"
|
||||||
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
|
import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
|
||||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
import { RolesCard } from "@/components/dashboard/RolesCard"
|
||||||
import { CustomDialog } from "@/components/CustomDialog"
|
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
||||||
|
import { TokenStatsCard } from "@/components/dashboard/TokenStatsCard"
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardView() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Title */}
|
||||||
<div>
|
<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">
|
<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>
|
<p className="text-sm text-muted-foreground">Aktueller Status von System, Modellen und Agent.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Grid: System stats & Updates (3 Columns) */}
|
{/* Top Grid: System stats & Updates */}
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
{/* Card 1: System Status (2 columns wide) */}
|
<SystemStatusCard />
|
||||||
<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">
|
<UpdatesCard />
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{/* Card 3: Hermes Agent Status */}
|
<AgentStatusCard />
|
||||||
<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">
|
<RolesCard />
|
||||||
<div>
|
<MemoryInputCard />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<TokenStatsCard />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</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 { 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 { cn } from "@/lib/utils"
|
||||||
import { CustomDialog } from "@/components/CustomDialog"
|
|
||||||
|
|
||||||
|
|
||||||
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
|
||||||
@@ -26,73 +27,32 @@ const BORDER_CLASSES: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MemoryView() {
|
export function MemoryView() {
|
||||||
const [items, setItems] = useState<Memory[]>([])
|
|
||||||
const [filter, setFilter] = useState("")
|
const [filter, setFilter] = useState("")
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [content, setContent] = useState("")
|
const [content, setContent] = useState("")
|
||||||
const [category, setCategory] = useState("stable")
|
const [category, setCategory] = useState("stable")
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [deduping, setDeduping] = useState(false)
|
const [deduping, setDeduping] = useState(false)
|
||||||
|
|
||||||
// Custom Dialog State
|
const qc = useQueryClient()
|
||||||
const [dialog, setDialog] = useState<{
|
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||||
type: "alert" | "confirm"
|
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
|
||||||
title: string
|
const error = itemsErr ? String(itemsErr) : ""
|
||||||
message: string
|
|
||||||
onConfirm: () => void
|
|
||||||
onCancel?: () => void
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
function showAlert(title: string, message: string, onConfirm?: () => void) {
|
const reloadMemory = () => qc.invalidateQueries({ queryKey: ["memory"] })
|
||||||
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])
|
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
if (!content.trim()) return
|
if (!content.trim()) return
|
||||||
await api("/api/memory", {
|
await api("/api/memory", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ content, category, source: "ui" })
|
body: JSON.stringify({ content, category, source: "ui" })
|
||||||
})
|
})
|
||||||
setContent("")
|
setContent("")
|
||||||
load()
|
reloadMemory()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(id: string) {
|
async function del(id: string) {
|
||||||
await api(`/api/memory/${id}`, { method: "DELETE" })
|
await api(`/api/memory/${id}`, { method: "DELETE" })
|
||||||
load()
|
reloadMemory()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
@@ -111,11 +71,11 @@ export function MemoryView() {
|
|||||||
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
|
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
await api("/api/memory/dedupe", {
|
await api("/api/memory/dedupe", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ apply: true })
|
body: JSON.stringify({ apply: true })
|
||||||
})
|
})
|
||||||
load()
|
reloadMemory()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
|
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
|
||||||
}
|
}
|
||||||
@@ -285,15 +245,7 @@ export function MemoryView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dialog && (
|
{dialogElement}
|
||||||
<CustomDialog
|
|
||||||
type={dialog.type}
|
|
||||||
title={dialog.title}
|
|
||||||
message={dialog.message}
|
|
||||||
onConfirm={dialog.onConfirm}
|
|
||||||
onCancel={dialog.onCancel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 { 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 { 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 }) {
|
function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string; percent: number; detail?: string; icon: any }) {
|
||||||
const barColor = percent > 90
|
const barColor = percent > 90
|
||||||
? "bg-red-500 shadow-md shadow-red-500/20"
|
? "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() {
|
export function SystemView() {
|
||||||
const [s, setS] = useState<SystemStatus | null>(null)
|
const { data: s, error: sErr } = useSystemStatus(3_000)
|
||||||
const [svc, setSvc] = useState<ServicesResp | null>(null)
|
const { data: svc } = useServices(3_000)
|
||||||
const [error, setError] = useState("")
|
const { showAlert, dialogElement } = useDialog()
|
||||||
|
const error = sErr ? String(sErr) : ""
|
||||||
const [backupMsg, setBackupMsg] = useState("")
|
const [backupMsg, setBackupMsg] = useState("")
|
||||||
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
|
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() {
|
async function doBackup() {
|
||||||
setBackupMsg("Backup snapshotted...")
|
setBackupMsg("Backup snapshotted...")
|
||||||
try {
|
try {
|
||||||
@@ -257,15 +224,7 @@ export function SystemView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dialog && (
|
{dialogElement}
|
||||||
<CustomDialog
|
|
||||||
type={dialog.type}
|
|
||||||
title={dialog.title}
|
|
||||||
message={dialog.message}
|
|
||||||
onConfirm={dialog.onConfirm}
|
|
||||||
onCancel={dialog.onCancel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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