Simplify Athena runtime and document current architecture
This commit is contained in:
@@ -7,15 +7,15 @@ ARG HF_HUB_VERSION=1.28.0
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3.12-venv && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
python -m venv --system-site-packages /opt/flux-venv && \
|
||||
/opt/flux-venv/bin/pip install --no-cache-dir \
|
||||
python -m venv --system-site-packages /opt/image-venv && \
|
||||
/opt/image-venv/bin/pip install --no-cache-dir \
|
||||
"diffusers==${DIFFUSERS_VERSION}" \
|
||||
"transformers==${TRANSFORMERS_VERSION}" \
|
||||
"accelerate==${ACCELERATE_VERSION}" \
|
||||
"huggingface-hub==${HF_HUB_VERSION}" \
|
||||
sentencepiece protobuf safetensors pillow && \
|
||||
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin flux
|
||||
useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin image-worker
|
||||
|
||||
COPY flux_worker.py /app/flux_worker.py
|
||||
COPY image_worker.py /app/image_worker.py
|
||||
USER 10002:10002
|
||||
ENTRYPOINT ["/opt/flux-venv/bin/python", "/app/flux_worker.py"]
|
||||
ENTRYPOINT ["/opt/image-venv/bin/python", "/app/image_worker.py"]
|
||||
+17
-16
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Private FLUX.2 Klein Distilled worker used only during a GPU hot swap."""
|
||||
"""Private Z-Image-Turbo worker used only during a GPU hot swap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
HOST = os.environ.get("WORKER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("WORKER_PORT", "8086"))
|
||||
TOKEN = os.environ.get("WORKER_TOKEN", "").strip()
|
||||
MODEL_DIR = os.environ.get("FLUX_MODEL_DIR", "/models/FLUX.2-klein-4B")
|
||||
MODEL_DIR = os.environ.get("Z_IMAGE_MODEL_DIR", "/models/Z-Image-Turbo")
|
||||
OUTPUT_DIR = Path(os.environ.get("IMAGE_DIR", "/data/images")).resolve()
|
||||
PIPE = None
|
||||
LOAD_SECONDS = 0.0
|
||||
@@ -33,14 +33,15 @@ def load_pipeline() -> None:
|
||||
if PIPE is not None:
|
||||
return
|
||||
import torch
|
||||
from diffusers import DiffusionPipeline
|
||||
from diffusers import ZImagePipeline
|
||||
started = time.monotonic()
|
||||
PIPE = DiffusionPipeline.from_pretrained(
|
||||
MODEL_DIR, torch_dtype=torch.bfloat16)
|
||||
# The full pipeline leaves too little activation headroom on a 16 GiB
|
||||
# RTX 5080. Model CPU offload keeps each active component on CUDA while
|
||||
# parking inactive components in system RAM between the four steps.
|
||||
PIPE.enable_model_cpu_offload()
|
||||
PIPE = ZImagePipeline.from_pretrained(
|
||||
MODEL_DIR, torch_dtype=torch.bfloat16, low_cpu_mem_usage=False)
|
||||
# The Qwen text encoder and the DiT do not fit together in the usable
|
||||
# 16 GiB of the RTX 5080. Sequential offload keeps only the active
|
||||
# submodule on CUDA. This is slower than a fully resident pipeline, but
|
||||
# deterministic and leaves the RTX 3060 available for XTTS.
|
||||
PIPE.enable_sequential_cpu_offload()
|
||||
if hasattr(PIPE, "enable_vae_slicing"):
|
||||
PIPE.enable_vae_slicing()
|
||||
if hasattr(PIPE, "enable_vae_tiling"):
|
||||
@@ -61,16 +62,16 @@ def generate(data: dict) -> dict:
|
||||
if (width, height) not in {(1024, 1024), (1536, 1024), (1024, 1536),
|
||||
(1920, 1088), (1088, 1920)}:
|
||||
raise ValueError("unsupported image size")
|
||||
steps = int(data.get("steps", 4))
|
||||
guidance = float(data.get("guidance", 1.0))
|
||||
if steps != 4 or guidance != 1.0:
|
||||
raise ValueError("distilled FLUX.2 Klein requires steps=4 and guidance=1.0")
|
||||
steps = int(data.get("steps", 9))
|
||||
guidance = float(data.get("guidance", 0.0))
|
||||
if steps != 9 or guidance != 0.0:
|
||||
raise ValueError("Z-Image-Turbo requires steps=9 and guidance=0.0")
|
||||
seed = data.get("seed")
|
||||
generator = None if seed is None else torch.Generator(device="cuda").manual_seed(int(seed))
|
||||
load_pipeline()
|
||||
started = time.monotonic()
|
||||
image = PIPE(prompt=prompt, height=height, width=width,
|
||||
num_inference_steps=4, guidance_scale=1.0,
|
||||
num_inference_steps=9, guidance_scale=0.0,
|
||||
generator=generator).images[0]
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
output = OUTPUT_DIR / filename
|
||||
@@ -83,7 +84,7 @@ def generate(data: dict) -> dict:
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
# Never log request bodies/prompts.
|
||||
print(f"[flux-worker] {self.client_address[0]} {fmt % args}", flush=True)
|
||||
print(f"[z-image-worker] {self.client_address[0]} {fmt % args}", flush=True)
|
||||
|
||||
def reply(self, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
@@ -112,7 +113,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise ValueError("invalid request size")
|
||||
self.reply(200, generate(json.loads(self.rfile.read(length))))
|
||||
except Exception as exc:
|
||||
print(f"[flux-worker] generation failed: "
|
||||
print(f"[z-image-worker] generation failed: "
|
||||
f"{type(exc).__name__}: {str(exc)[:1000]}", flush=True)
|
||||
self.reply(400, {"status": "error", "message": str(exc)})
|
||||
|
||||
@@ -24,7 +24,7 @@ ALLOWED = tuple(x.strip() for x in os.environ.get(
|
||||
"ALLOWED_PROFILES", "fast,medium,large,ultra,uncensored,experimental").split(",") if x.strip())
|
||||
LABEL_KEY = "com.mike-ai.llama-profile"
|
||||
IMAGE_LABEL_KEY = "com.mike-ai.image-worker"
|
||||
IMAGE_WORKER = os.environ.get("IMAGE_WORKER", "flux")
|
||||
IMAGE_WORKER = os.environ.get("IMAGE_WORKER", "image")
|
||||
LOCK = threading.Lock()
|
||||
log = logging.getLogger("profile-controller")
|
||||
|
||||
@@ -98,7 +98,7 @@ def set_image_worker(running: bool) -> dict:
|
||||
with LOCK:
|
||||
item = image_container()
|
||||
if running:
|
||||
# A FLUX worker may never overlap a llama profile on the 5080.
|
||||
# The image worker may never overlap a llama profile on the 5080.
|
||||
for profile_item in containers().values():
|
||||
stop_container(profile_item)
|
||||
if item.get("State") != "running":
|
||||
|
||||
@@ -64,7 +64,7 @@ wg_ipv4=$(ip -4 -o address show dev wg0 | awk 'NR == 1 { split($4, address, "/")
|
||||
# The VPN is Athena's normal application network. Nothing below is published
|
||||
# on the physical university interface: every listener is bound inside this
|
||||
# namespace to the Fritzbox-assigned WireGuard address. Clients on the home
|
||||
# VPN may use OpenWebUI, the router and every useful MCP directly.
|
||||
# VPN clients may use the router, speech services and Athena operator directly.
|
||||
proxy_pids=""
|
||||
start_proxy() {
|
||||
listen_port=$1
|
||||
@@ -74,29 +74,10 @@ start_proxy() {
|
||||
}
|
||||
|
||||
start_proxy 22 172.30.10.1:22
|
||||
start_proxy 8080 open-webui:8080
|
||||
start_proxy 8081 router:8081
|
||||
start_proxy 8085 tts-gateway:8085
|
||||
start_proxy 8091 piper:8085
|
||||
start_proxy 8092 xtts:80
|
||||
start_proxy 9119 hermes:9119
|
||||
start_proxy 8642 hermes:8642
|
||||
|
||||
# MCP endpoints. Optional services keep their listener even while stopped and
|
||||
# begin working automatically as soon as their container is started.
|
||||
start_proxy 8202 mcp-athena-operator:8000
|
||||
# Portable general web MCP for Pi, Hermes and other clients. OpenWebUI uses
|
||||
# its native broad search by default; both paths are site-agnostic.
|
||||
start_proxy 8203 tinysearch:8000
|
||||
start_proxy 8204 mcp-github:8000
|
||||
start_proxy 8205 mcp-homeassistant:8000
|
||||
start_proxy 8206 mcp-arr:8000
|
||||
start_proxy 8207 mcp-navidrome:3000
|
||||
start_proxy 8208 mcp-unraid-ssh:8000
|
||||
|
||||
# Search backends are also directly available for diagnostics and alternative
|
||||
# clients. Normal chat clients should prefer the MCP endpoint on 8203.
|
||||
start_proxy 8210 searxng:8080
|
||||
start_proxy 8211 tinysearch:8000
|
||||
|
||||
wait $(printf '%s\n' "$proxy_pids" | awk '{print $2}')
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY app.py /app/app.py
|
||||
|
||||
RUN useradd --uid 10020 --create-home --shell /usr/sbin/nologin dashboard
|
||||
|
||||
USER 10020:10020
|
||||
EXPOSE 8099
|
||||
|
||||
CMD ["python", "/app/app.py"]
|
||||
@@ -0,0 +1,775 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("DASHBOARD_PORT", "8099"))
|
||||
ROUTER_URL = os.getenv("ROUTER_URL", "http://router:8081").rstrip("/")
|
||||
ROUTER_API_KEY = os.getenv("ROUTER_API_KEY", "")
|
||||
HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc"))
|
||||
HOST_DATA = os.getenv("HOST_DATA", "/host/data")
|
||||
HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models"))
|
||||
STARTED = time.time()
|
||||
HISTORY_DB = Path(os.getenv("DASHBOARD_HISTORY_DB", "/var/lib/llama-dashboard/history.sqlite3"))
|
||||
HISTORY_INTERVAL = max(5, int(os.getenv("DASHBOARD_HISTORY_INTERVAL", "15")))
|
||||
DETAIL_RETENTION_DAYS = max(1, int(os.getenv("DASHBOARD_DETAIL_RETENTION_DAYS", "21")))
|
||||
|
||||
|
||||
def _number(value: str) -> int | float | None:
|
||||
value = value.strip()
|
||||
if not value or value.lower() in {"n/a", "[n/a]", "not supported"}:
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
return int(number) if number.is_integer() else number
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
class CpuSampler:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._previous: tuple[int, int] | None = None
|
||||
self._previous_net: tuple[float, int, int] | None = None
|
||||
|
||||
def sample(self) -> dict[str, Any]:
|
||||
stat = _read_text(HOST_PROC / "stat").splitlines()
|
||||
cpu_line = next((line for line in stat if line.startswith("cpu ")), "")
|
||||
values = [int(item) for item in cpu_line.split()[1:] if item.isdigit()]
|
||||
total = sum(values)
|
||||
idle = sum(values[3:5]) if len(values) >= 5 else 0
|
||||
with self._lock:
|
||||
usage = None
|
||||
if self._previous and total > self._previous[0]:
|
||||
delta_total = total - self._previous[0]
|
||||
delta_idle = idle - self._previous[1]
|
||||
usage = round(100 * (1 - delta_idle / delta_total), 1)
|
||||
self._previous = (total, idle)
|
||||
|
||||
mem: dict[str, int] = {}
|
||||
for line in _read_text(HOST_PROC / "meminfo").splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, raw = line.split(":", 1)
|
||||
try:
|
||||
mem[key] = int(raw.strip().split()[0]) * 1024
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
total_mem = mem.get("MemTotal", 0)
|
||||
available = mem.get("MemAvailable", 0)
|
||||
used_mem = max(0, total_mem - available)
|
||||
load = _read_text(HOST_PROC / "loadavg").split()
|
||||
uptime_raw = _read_text(HOST_PROC / "uptime").split()
|
||||
uptime = float(uptime_raw[0]) if uptime_raw else None
|
||||
cpu_count = sum(1 for line in stat if line.startswith("cpu") and len(line) > 3 and line[3].isdigit())
|
||||
|
||||
disk: dict[str, Any] = {}
|
||||
try:
|
||||
usage_disk = shutil.disk_usage(HOST_DATA)
|
||||
disk = {"total": usage_disk.total, "used": usage_disk.used, "free": usage_disk.free}
|
||||
except OSError:
|
||||
pass
|
||||
rx_bytes = 0
|
||||
tx_bytes = 0
|
||||
interfaces = 0
|
||||
for line in _read_text(HOST_PROC / "net/dev").splitlines()[2:]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
name, values_raw = line.split(":", 1)
|
||||
if name.strip() == "lo":
|
||||
continue
|
||||
values_net = values_raw.split()
|
||||
if len(values_net) < 9:
|
||||
continue
|
||||
try:
|
||||
rx_bytes += int(values_net[0])
|
||||
tx_bytes += int(values_net[8])
|
||||
interfaces += 1
|
||||
except ValueError:
|
||||
continue
|
||||
now = time.monotonic()
|
||||
rx_rate = None
|
||||
tx_rate = None
|
||||
with self._lock:
|
||||
if self._previous_net and now > self._previous_net[0]:
|
||||
elapsed = now - self._previous_net[0]
|
||||
rx_rate = max(0, rx_bytes - self._previous_net[1]) / elapsed
|
||||
tx_rate = max(0, tx_bytes - self._previous_net[2]) / elapsed
|
||||
self._previous_net = (now, rx_bytes, tx_bytes)
|
||||
return {
|
||||
"usage_percent": usage,
|
||||
"logical_cpus": cpu_count,
|
||||
"load": [float(item) for item in load[:3]] if len(load) >= 3 else [],
|
||||
"memory": {"total": total_mem, "used": used_mem, "available": available},
|
||||
"disk_data": disk,
|
||||
"network": {
|
||||
"interfaces": interfaces,
|
||||
"rx_bytes": rx_bytes,
|
||||
"tx_bytes": tx_bytes,
|
||||
"rx_bytes_per_second": round(rx_rate, 1) if rx_rate is not None else None,
|
||||
"tx_bytes_per_second": round(tx_rate, 1) if tx_rate is not None else None,
|
||||
},
|
||||
"host_uptime_seconds": uptime,
|
||||
}
|
||||
|
||||
|
||||
CPU = CpuSampler()
|
||||
|
||||
|
||||
GPU_FIELDS = [
|
||||
"index", "name", "uuid", "utilization.gpu", "utilization.memory",
|
||||
"memory.total", "memory.used", "memory.free", "temperature.gpu",
|
||||
"power.draw", "power.limit", "clocks.current.graphics",
|
||||
"clocks.current.memory", "fan.speed", "pstate",
|
||||
]
|
||||
|
||||
|
||||
def gpu_status() -> tuple[list[dict[str, Any]], str | None]:
|
||||
command = [
|
||||
"nvidia-smi",
|
||||
f"--query-gpu={','.join(GPU_FIELDS)}",
|
||||
"--format=csv,noheader,nounits",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=4, check=True)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return [], str(exc)
|
||||
cards: list[dict[str, Any]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
values = [value.strip() for value in line.split(",")]
|
||||
if len(values) != len(GPU_FIELDS):
|
||||
continue
|
||||
raw = dict(zip(GPU_FIELDS, values))
|
||||
cards.append({
|
||||
"index": _number(raw["index"]),
|
||||
"name": raw["name"],
|
||||
"uuid": raw["uuid"],
|
||||
"gpu_percent": _number(raw["utilization.gpu"]),
|
||||
"memory_controller_percent": _number(raw["utilization.memory"]),
|
||||
"memory_total_mib": _number(raw["memory.total"]),
|
||||
"memory_used_mib": _number(raw["memory.used"]),
|
||||
"memory_free_mib": _number(raw["memory.free"]),
|
||||
"temperature_c": _number(raw["temperature.gpu"]),
|
||||
"power_w": _number(raw["power.draw"]),
|
||||
"power_limit_w": _number(raw["power.limit"]),
|
||||
"graphics_clock_mhz": _number(raw["clocks.current.graphics"]),
|
||||
"memory_clock_mhz": _number(raw["clocks.current.memory"]),
|
||||
"fan_percent": _number(raw["fan.speed"]),
|
||||
"pstate": raw["pstate"],
|
||||
})
|
||||
return cards, None
|
||||
|
||||
|
||||
def gpu_processes() -> list[dict[str, Any]]:
|
||||
command = [
|
||||
"nvidia-smi",
|
||||
"--query-compute-apps=gpu_uuid,pid,process_name,used_memory",
|
||||
"--format=csv,noheader,nounits",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=4, check=True)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
processes = []
|
||||
for line in result.stdout.splitlines():
|
||||
values = [value.strip() for value in line.split(",", 3)]
|
||||
if len(values) == 4:
|
||||
processes.append({
|
||||
"gpu_uuid": values[0], "pid": _number(values[1]),
|
||||
"name": values[2], "memory_mib": _number(values[3]),
|
||||
})
|
||||
return processes
|
||||
|
||||
|
||||
def llama_runtime() -> dict[str, Any]:
|
||||
"""Read the running llama.cpp command line from the host procfs."""
|
||||
options = {
|
||||
"--model": "model_path",
|
||||
"--alias": "alias",
|
||||
"--ctx-size": "context_size",
|
||||
"--batch-size": "batch_size",
|
||||
"--ubatch-size": "ubatch_size",
|
||||
"--parallel": "parallel",
|
||||
"--threads": "threads",
|
||||
"--threads-batch": "threads_batch",
|
||||
"--device": "device",
|
||||
"--tensor-split": "tensor_split",
|
||||
"--cache-type-k": "cache_k",
|
||||
"--cache-type-v": "cache_v",
|
||||
"--reasoning-budget": "reasoning_budget",
|
||||
"--spec-draft-n-max": "mtp_draft_tokens",
|
||||
}
|
||||
flags = {
|
||||
"--flash-attn": "flash_attention",
|
||||
"--cache-prompt": "prompt_cache",
|
||||
"--mmproj-offload": "vision_offload",
|
||||
}
|
||||
try:
|
||||
entries = list(HOST_PROC.iterdir())
|
||||
except OSError:
|
||||
return {}
|
||||
for entry in entries:
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
raw = _read_text(entry / "cmdline")
|
||||
if not raw:
|
||||
continue
|
||||
args = [item for item in raw.split("\0") if item]
|
||||
if "--model" not in args or not any("llama" in item.lower() or item.endswith("/server") for item in args[:2]):
|
||||
continue
|
||||
result: dict[str, Any] = {"pid": int(entry.name), "executable": args[0]}
|
||||
for index, arg in enumerate(args):
|
||||
if arg in options and index + 1 < len(args):
|
||||
value: Any = args[index + 1]
|
||||
if value.isdigit():
|
||||
value = int(value)
|
||||
result[options[arg]] = value
|
||||
if arg in flags:
|
||||
value = True
|
||||
if index + 1 < len(args) and args[index + 1].lower() in {"on", "off", "true", "false"}:
|
||||
value = args[index + 1].lower() in {"on", "true"}
|
||||
result[flags[arg]] = value
|
||||
if result.get("model_path"):
|
||||
result["model_file"] = Path(str(result["model_path"])).name
|
||||
return result
|
||||
return {}
|
||||
|
||||
|
||||
def router_status() -> tuple[dict[str, Any], str | None]:
|
||||
headers = {"Accept": "application/json"}
|
||||
if ROUTER_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {ROUTER_API_KEY}"
|
||||
request = urllib.request.Request(f"{ROUTER_URL}/status", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=4) as response:
|
||||
return json.load(response), None
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
return {}, str(exc)
|
||||
|
||||
|
||||
_MODEL_LOCK = threading.Lock()
|
||||
_MODEL_AT = 0.0
|
||||
_MODEL_CACHE: tuple[list[dict[str, Any]], dict[str, Any]] = ([], {"count": 0, "total_size": 0})
|
||||
|
||||
|
||||
def model_inventory() -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
global _MODEL_AT, _MODEL_CACHE
|
||||
now = time.monotonic()
|
||||
with _MODEL_LOCK:
|
||||
if now - _MODEL_AT < 30:
|
||||
return _MODEL_CACHE
|
||||
files: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
try:
|
||||
candidates = sorted(HOST_MODELS.rglob("*.gguf"))
|
||||
except OSError:
|
||||
candidates = []
|
||||
for path in candidates[:100]:
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total += stat.st_size
|
||||
files.append({
|
||||
"name": path.name,
|
||||
"relative_path": str(path.relative_to(HOST_MODELS)),
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
})
|
||||
result = (files, {"count": len(files), "total_size": total})
|
||||
with _MODEL_LOCK:
|
||||
_MODEL_CACHE = result
|
||||
_MODEL_AT = now
|
||||
return result
|
||||
|
||||
|
||||
class EventTracker:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._last: dict[str, Any] = {}
|
||||
self._events: list[dict[str, Any]] = []
|
||||
|
||||
def update(self, router: dict[str, Any], router_error: str | None) -> list[dict[str, Any]]:
|
||||
state = {
|
||||
"profile": router.get("current_profile"),
|
||||
"model": (router.get("upstream") or {}).get("model"),
|
||||
"available": (router.get("qwen") or {}).get("available"),
|
||||
"switching": router.get("switching"),
|
||||
"image_phase": (router.get("image") or {}).get("phase"),
|
||||
"image_model": (router.get("image") or {}).get("model"),
|
||||
"image_loaded": (router.get("image") or {}).get("model_loaded"),
|
||||
"router_error": bool(router_error),
|
||||
}
|
||||
labels = {
|
||||
"profile": "Profil",
|
||||
"model": "Modell",
|
||||
"available": "Inferenz bereit",
|
||||
"switching": "Profilwechsel",
|
||||
"image_phase": "Bildgenerierung",
|
||||
"image_model": "Bildmodell",
|
||||
"image_loaded": "Bildmodell geladen",
|
||||
"router_error": "Routerfehler",
|
||||
}
|
||||
with self._lock:
|
||||
if self._last:
|
||||
for key, value in state.items():
|
||||
old = self._last.get(key)
|
||||
if value != old:
|
||||
self._events.insert(0, {
|
||||
"timestamp": time.time(),
|
||||
"name": labels[key],
|
||||
"from": old,
|
||||
"to": value,
|
||||
})
|
||||
self._last = state
|
||||
self._events = self._events[:20]
|
||||
return list(self._events)
|
||||
|
||||
|
||||
EVENTS = EventTracker()
|
||||
|
||||
_COLLECT_LOCK = threading.Lock()
|
||||
_COLLECT_AT = 0.0
|
||||
_COLLECT_CACHE: dict[str, Any] = {}
|
||||
|
||||
|
||||
def collect() -> dict[str, Any]:
|
||||
global _COLLECT_AT, _COLLECT_CACHE
|
||||
now = time.monotonic()
|
||||
with _COLLECT_LOCK:
|
||||
if now - _COLLECT_AT < 0.75 and _COLLECT_CACHE:
|
||||
return _COLLECT_CACHE
|
||||
gpus, gpu_error = gpu_status()
|
||||
router, router_error = router_status()
|
||||
models, model_summary = model_inventory()
|
||||
result = {
|
||||
"timestamp": time.time(),
|
||||
"dashboard_uptime_seconds": round(time.time() - STARTED, 1),
|
||||
"cpu": CPU.sample(),
|
||||
"gpus": gpus,
|
||||
"gpu_processes": gpu_processes(),
|
||||
"llama_runtime": llama_runtime(),
|
||||
"router": router,
|
||||
"models": models,
|
||||
"model_summary": model_summary,
|
||||
"events": EVENTS.update(router, router_error),
|
||||
"errors": {"gpu": gpu_error, "router": router_error},
|
||||
}
|
||||
with _COLLECT_LOCK:
|
||||
_COLLECT_CACHE = result
|
||||
_COLLECT_AT = now
|
||||
return result
|
||||
|
||||
|
||||
class HistoryStore:
|
||||
"""Small persistent telemetry store; never stores prompts or responses."""
|
||||
|
||||
TOKEN_KEYS = ("prompt_tokens_total", "prompt_tokens_cached_total", "tokens_predicted_total")
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._db = sqlite3.connect(path, check_same_thread=False)
|
||||
self._db.row_factory = sqlite3.Row
|
||||
self._lock = threading.Lock()
|
||||
with self._db:
|
||||
self._db.executescript("""
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS samples_raw (
|
||||
ts INTEGER NOT NULL, gpu_index INTEGER NOT NULL,
|
||||
gpu_util REAL, memory_used_mib REAL, temperature_c REAL, power_w REAL,
|
||||
profile TEXT, model TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_samples_raw_ts ON samples_raw(ts);
|
||||
CREATE TABLE IF NOT EXISTS samples_hourly (
|
||||
hour_ts INTEGER NOT NULL, gpu_index INTEGER NOT NULL,
|
||||
gpu_util_sum REAL, gpu_util_max REAL, memory_used_sum REAL, memory_used_max REAL,
|
||||
temperature_sum REAL, temperature_max REAL, power_sum REAL, samples INTEGER NOT NULL,
|
||||
PRIMARY KEY(hour_ts, gpu_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS token_totals (
|
||||
id INTEGER PRIMARY KEY CHECK(id=1), prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cached_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO token_totals(id) VALUES(1);
|
||||
CREATE TABLE IF NOT EXISTS token_hourly (
|
||||
hour_ts INTEGER NOT NULL, profile TEXT NOT NULL, model TEXT NOT NULL,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0, cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(hour_ts, profile, model)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_events (
|
||||
ts INTEGER NOT NULL, previous_profile TEXT, profile TEXT,
|
||||
previous_model TEXT, model TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_events_ts ON model_events(ts);
|
||||
""")
|
||||
|
||||
def _meta(self, key: str) -> str | None:
|
||||
row = self._db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def _set_meta(self, key: str, value: Any) -> None:
|
||||
self._db.execute(
|
||||
"INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
def record(self, snapshot: dict[str, Any]) -> None:
|
||||
ts = int(snapshot.get("timestamp") or time.time())
|
||||
router = snapshot.get("router") or {}
|
||||
image = router.get("image") or {}
|
||||
image_active = image.get("phase") not in (None, "idle")
|
||||
profile = str("image" if image_active else
|
||||
(router.get("current_profile") or "unknown"))
|
||||
model = str((image.get("model") if image_active else
|
||||
(router.get("upstream") or {}).get("model")) or "unknown")
|
||||
metrics = ((router.get("llama_telemetry") or {}).get("metrics") or {})
|
||||
runtime = snapshot.get("llama_runtime") or {}
|
||||
runtime_id = f"{runtime.get('pid', 'none')}:{runtime.get('model_file') or model}"
|
||||
hour = ts - ts % 3600
|
||||
|
||||
with self._lock, self._db:
|
||||
for gpu in snapshot.get("gpus") or []:
|
||||
if gpu.get("index") is None:
|
||||
continue
|
||||
self._db.execute(
|
||||
"INSERT INTO samples_raw VALUES(?,?,?,?,?,?,?,?)",
|
||||
(ts, int(gpu["index"]), gpu.get("gpu_percent"), gpu.get("memory_used_mib"),
|
||||
gpu.get("temperature_c"), gpu.get("power_w"), profile, model),
|
||||
)
|
||||
|
||||
previous_runtime = self._meta("counter_runtime")
|
||||
deltas: list[int] = []
|
||||
for key in self.TOKEN_KEYS:
|
||||
current = max(0, int(float(metrics.get(key) or 0)))
|
||||
previous = int(self._meta(f"counter_{key}") or 0)
|
||||
delta = current - previous if previous_runtime == runtime_id and current >= previous else current
|
||||
deltas.append(max(0, delta))
|
||||
self._set_meta(f"counter_{key}", current)
|
||||
self._set_meta("counter_runtime", runtime_id)
|
||||
if any(deltas):
|
||||
self._db.execute(
|
||||
"UPDATE token_totals SET prompt_tokens=prompt_tokens+?, cached_tokens=cached_tokens+?, output_tokens=output_tokens+? WHERE id=1",
|
||||
deltas,
|
||||
)
|
||||
self._db.execute(
|
||||
"""INSERT INTO token_hourly VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(hour_ts,profile,model) DO UPDATE SET
|
||||
prompt_tokens=prompt_tokens+excluded.prompt_tokens,
|
||||
cached_tokens=cached_tokens+excluded.cached_tokens,
|
||||
output_tokens=output_tokens+excluded.output_tokens""",
|
||||
(hour, profile, model, *deltas),
|
||||
)
|
||||
|
||||
previous_profile = self._meta("last_profile")
|
||||
previous_model = self._meta("last_model")
|
||||
if previous_profile is not None and (profile != previous_profile or model != previous_model):
|
||||
self._db.execute(
|
||||
"INSERT INTO model_events VALUES(?,?,?,?,?)",
|
||||
(ts, previous_profile, profile, previous_model, model),
|
||||
)
|
||||
self._set_meta("last_profile", profile)
|
||||
self._set_meta("last_model", model)
|
||||
|
||||
def compact(self) -> None:
|
||||
cutoff = int(time.time()) - DETAIL_RETENTION_DAYS * 86400
|
||||
with self._lock, self._db:
|
||||
self._db.execute("""
|
||||
INSERT INTO samples_hourly
|
||||
SELECT ts-ts%3600, gpu_index, SUM(gpu_util), MAX(gpu_util),
|
||||
SUM(memory_used_mib), MAX(memory_used_mib), SUM(temperature_c),
|
||||
MAX(temperature_c), SUM(power_w), COUNT(*)
|
||||
FROM samples_raw WHERE ts < ? GROUP BY ts-ts%3600, gpu_index
|
||||
ON CONFLICT(hour_ts,gpu_index) DO UPDATE SET
|
||||
gpu_util_sum=gpu_util_sum+excluded.gpu_util_sum,
|
||||
gpu_util_max=MAX(gpu_util_max,excluded.gpu_util_max),
|
||||
memory_used_sum=memory_used_sum+excluded.memory_used_sum,
|
||||
memory_used_max=MAX(memory_used_max,excluded.memory_used_max),
|
||||
temperature_sum=temperature_sum+excluded.temperature_sum,
|
||||
temperature_max=MAX(temperature_max,excluded.temperature_max),
|
||||
power_sum=power_sum+excluded.power_sum,
|
||||
samples=samples+excluded.samples
|
||||
""", (cutoff,))
|
||||
self._db.execute("DELETE FROM samples_raw WHERE ts < ?", (cutoff,))
|
||||
|
||||
def query(self, range_name: str) -> dict[str, Any]:
|
||||
ranges = {
|
||||
"1h": (3600, 60), "24h": (86400, 300), "7d": (7 * 86400, 1800),
|
||||
"21d": (21 * 86400, 3600), "all": (0, 3600),
|
||||
}
|
||||
seconds, bucket = ranges.get(range_name, ranges["24h"])
|
||||
now = int(time.time())
|
||||
start = 0 if seconds == 0 else now - seconds
|
||||
detail_cutoff = now - DETAIL_RETENTION_DAYS * 86400
|
||||
with self._lock:
|
||||
points: list[dict[str, Any]] = []
|
||||
if start < detail_cutoff:
|
||||
for row in self._db.execute("""
|
||||
SELECT hour_ts ts,gpu_index,gpu_util_sum/samples gpu_util,gpu_util_max,
|
||||
memory_used_sum/samples memory_used_mib,memory_used_max,
|
||||
temperature_sum/samples temperature_c,temperature_max,
|
||||
power_sum/samples power_w
|
||||
FROM samples_hourly WHERE hour_ts>=? ORDER BY hour_ts,gpu_index
|
||||
""", (start,)):
|
||||
points.append(dict(row))
|
||||
raw_start = max(start, detail_cutoff)
|
||||
for row in self._db.execute(f"""
|
||||
SELECT (ts/{bucket})*{bucket} ts,gpu_index,AVG(gpu_util) gpu_util,MAX(gpu_util) gpu_util_max,
|
||||
AVG(memory_used_mib) memory_used_mib,MAX(memory_used_mib) memory_used_max,
|
||||
AVG(temperature_c) temperature_c,MAX(temperature_c) temperature_max,
|
||||
AVG(power_w) power_w
|
||||
FROM samples_raw WHERE ts>=? GROUP BY (ts/{bucket}),gpu_index ORDER BY ts,gpu_index
|
||||
""", (raw_start,)):
|
||||
points.append(dict(row))
|
||||
totals = dict(self._db.execute("SELECT * FROM token_totals WHERE id=1").fetchone())
|
||||
range_tokens = dict(self._db.execute(
|
||||
"SELECT COALESCE(SUM(prompt_tokens),0) prompt_tokens, COALESCE(SUM(cached_tokens),0) cached_tokens, COALESCE(SUM(output_tokens),0) output_tokens FROM token_hourly WHERE hour_ts>=?",
|
||||
(start,),
|
||||
).fetchone())
|
||||
profile_usage = [dict(row) for row in self._db.execute("""
|
||||
SELECT profile,model,SUM(prompt_tokens) prompt_tokens,
|
||||
SUM(cached_tokens) cached_tokens,SUM(output_tokens) output_tokens
|
||||
FROM token_hourly WHERE hour_ts>=? GROUP BY profile,model
|
||||
ORDER BY SUM(prompt_tokens+cached_tokens+output_tokens) DESC
|
||||
""", (start,))]
|
||||
events = [dict(row) for row in self._db.execute(
|
||||
"SELECT * FROM model_events WHERE ts>=? ORDER BY ts DESC LIMIT 50", (start,)
|
||||
)]
|
||||
raw_info = dict(self._db.execute(
|
||||
"SELECT COUNT(*) rows, MIN(ts) oldest, MAX(ts) newest FROM samples_raw"
|
||||
).fetchone())
|
||||
points.sort(key=lambda item: (item["ts"], item["gpu_index"]))
|
||||
return {
|
||||
"range": range_name if range_name in ranges else "24h",
|
||||
"detail_retention_days": DETAIL_RETENTION_DAYS,
|
||||
"sample_interval_seconds": HISTORY_INTERVAL,
|
||||
"points": points,
|
||||
"token_totals": totals,
|
||||
"range_tokens": range_tokens,
|
||||
"profile_usage": profile_usage,
|
||||
"model_events": events,
|
||||
"storage": raw_info,
|
||||
}
|
||||
|
||||
|
||||
HISTORY = HistoryStore(HISTORY_DB)
|
||||
|
||||
|
||||
def history_collector() -> None:
|
||||
next_compaction = 0.0
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
HISTORY.record(collect())
|
||||
if time.time() >= next_compaction:
|
||||
HISTORY.compact()
|
||||
next_compaction = time.time() + 3600
|
||||
except Exception as exc:
|
||||
print(f"history collector: {exc}", flush=True)
|
||||
time.sleep(max(1, HISTORY_INTERVAL - (time.monotonic() - started)))
|
||||
|
||||
|
||||
HTML = r'''<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Athena · llama.cpp Dashboard</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#070b11;--panel:#0d141e;--panel2:#111b28;--line:#213044;--text:#e7eef7;--muted:#8fa1b5;--cyan:#45d7ff;--green:#66e3a4;--amber:#ffc65c;--red:#ff6b7a}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% -10%,#183049 0,transparent 35%),var(--bg);font:14px/1.45 Inter,ui-sans-serif,system-ui;color:var(--text)}main{max-width:1450px;margin:auto;padding:24px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:20px}.eyebrow{color:var(--cyan);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:11px}h1{margin:3px 0 0;font-size:30px}.live{display:flex;align-items:center;gap:8px;color:var(--muted)}.dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 14px var(--green)}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}.card{background:linear-gradient(145deg,rgba(17,27,40,.96),rgba(10,16,24,.96));border:1px solid var(--line);border-radius:14px;padding:17px;min-width:0}.span3{grid-column:span 3}.span4{grid-column:span 4}.span6{grid-column:span 6}.span12{grid-column:span 12}.label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.value{font-size:26px;font-weight:750;margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sub{color:var(--muted);margin-top:4px}.bar-label{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;margin-top:13px}.bar{height:9px;background:#070b11;border-radius:99px;overflow:hidden;margin-top:5px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--green));transition:width .5s}.fill.gpu-load{background:linear-gradient(90deg,#8b7cff,var(--cyan))}.gpu-title{display:flex;justify-content:space-between;align-items:center;gap:12px}.badge{border:1px solid var(--line);background:#07101a;color:var(--cyan);padding:4px 8px;border-radius:99px;font-size:11px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:16px}.metric b{display:block;font-size:18px}.metric span{color:var(--muted);font-size:11px}.status{color:var(--green)}.status.bad{color:var(--red)}table{border-collapse:collapse;width:100%;margin-top:10px}th,td{text-align:left;padding:8px;border-bottom:1px solid var(--line)}th{color:var(--muted);font-weight:500}.error{color:var(--red);white-space:pre-wrap}.footer{color:var(--muted);font-size:12px;text-align:right;margin-top:14px}@media(max-width:900px){.span3,.span4,.span6{grid-column:span 12}.metrics{grid-template-columns:repeat(2,1fr)}.top{align-items:flex-start;flex-direction:column}}
|
||||
.amber{color:var(--amber)}.section-title{grid-column:span 12;margin:10px 2px -3px;color:var(--cyan);font-size:12px;font-weight:750;letter-spacing:.13em;text-transform:uppercase}.slot-list{display:grid;gap:12px;margin-top:13px}.slot{border:1px solid var(--line);border-radius:11px;padding:13px;background:#09111b}.slot-head,.row{display:flex;justify-content:space-between;align-items:center;gap:12px}.slot-head b{font-size:16px}.profiles{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;margin-top:13px}.profile-item{border:1px solid var(--line);border-radius:10px;padding:10px}.profile-item.active{border-color:var(--cyan);box-shadow:inset 0 0 0 1px #45d7ff33}.profile-item b{display:block}.profile-item span{color:var(--muted);font-size:11px}.scroll{max-height:330px;overflow:auto}.scroll th{position:sticky;top:0;background:var(--panel)}.event{padding:8px 0;border-bottom:1px solid var(--line)}.event:last-child{border:0}.event time{color:var(--muted);font-size:11px;margin-right:8px}@media(max-width:900px){.profiles{grid-template-columns:1fr}}
|
||||
.span4 .metrics{grid-template-columns:repeat(2,1fr)}
|
||||
.history-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0 14px}.history-controls button{border:1px solid var(--line);background:#09111b;color:var(--muted);padding:6px 10px;border-radius:8px;cursor:pointer}.history-controls button.active{color:var(--cyan);border-color:var(--cyan)}.chart-legend{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 10px}.chart-legend button{border:1px solid var(--series);background:#09111b;color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.chart-legend button::before{content:'';display:inline-block;width:10px;height:3px;background:var(--series);margin:0 7px 3px 0}.chart-legend button.off{opacity:.4;text-decoration:line-through}.chart{width:100%;height:250px;display:block}.token-total{font-size:24px;font-weight:750;margin-top:5px}.history-note{color:var(--muted);font-size:11px;margin-top:8px}
|
||||
.usage-list{display:grid;gap:13px;margin-top:8px}.usage-head{display:flex;justify-content:space-between;gap:14px;align-items:baseline}.usage-head b{font-size:16px}.usage-head span{color:var(--muted)}.usage-meta{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px;margin-top:5px}
|
||||
</style></head><body><main>
|
||||
<div class="top"><div><div class="eyebrow">Mike AI · Live Telemetry</div><h1>Athena llama.cpp Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div>
|
||||
<section class="grid">
|
||||
<article class="card span3"><div class="label">Aktives Profil</div><div class="value" id="profile">–</div><div class="sub" id="profileSub">Router wird abgefragt</div></article>
|
||||
<article class="card span3"><div class="label">Modell</div><div class="value" id="model">–</div><div class="sub" id="modelSub">–</div></article>
|
||||
<article class="card span3"><div class="label">CPU</div><div class="value" id="cpu">–</div><div class="bar"><div class="fill" id="cpuBar"></div></div><div class="sub" id="load">–</div></article>
|
||||
<article class="card span3"><div class="label">System-RAM</div><div class="value" id="ram">–</div><div class="bar"><div class="fill" id="ramBar"></div></div><div class="sub" id="ramSub">–</div></article>
|
||||
<div id="gpuCards" class="span12 grid"></div>
|
||||
<div class="section-title">Inferenz · Live</div>
|
||||
<article class="card span3"><div class="label">Generierung</div><div class="value" id="generationRate">–</div><div class="sub" id="generationSub">Token pro Sekunde</div></article>
|
||||
<article class="card span3"><div class="label">Prompt-Einlesen</div><div class="value" id="promptRate">–</div><div class="sub" id="promptSub">Token pro Sekunde</div></article>
|
||||
<article class="card span3"><div class="label">Prompt-Cache</div><div class="value" id="cacheHit">–</div><div class="bar"><div class="fill" id="cacheBar"></div></div><div class="sub" id="cacheSub">–</div></article>
|
||||
<article class="card span3"><div class="label">Anfragen</div><div class="value" id="requestState">–</div><div class="sub" id="requestSub">–</div></article>
|
||||
<article class="card span12"><div class="label">Slots und Kontextfenster</div><div class="slot-list" id="slotCards"><div class="sub">Telemetrie wird geladen …</div></div></article>
|
||||
<article class="card span4"><div class="label">MTP / Speculative Decoding</div><div class="value" id="mtpAcceptance">–</div><div class="bar"><div class="fill gpu-load" id="mtpBar"></div></div><div class="metrics"><div class="metric"><b id="mtpDrafted">–</b><span>Draft-Token</span></div><div class="metric"><b id="mtpAccepted">–</b><span>akzeptiert</span></div><div class="metric"><b id="mtpSteps">–</b><span>Prüfschritte</span></div><div class="metric"><b id="mtpDepth">–</b><span>Draft-Tiefe</span></div></div></article>
|
||||
<article class="card span4"><div class="label">Tokenzähler seit Modellstart</div><div class="metrics" id="counters"></div></article>
|
||||
<article class="card span4"><div class="label">Modell-Eigenschaften</div><div class="metrics" id="modelDetails"></div></article>
|
||||
<div class="section-title">Langzeitstatistik</div>
|
||||
<article class="card span4"><div class="label">Eingabe-Tokens gesamt</div><div class="token-total" id="historyInput">–</div><div class="sub" id="historyInputSub">neu + Prompt-Cache</div></article>
|
||||
<article class="card span4"><div class="label">Ausgabe-Tokens gesamt</div><div class="token-total" id="historyOutput">–</div><div class="sub" id="historyOutputSub">seit Beginn der Aufzeichnung</div></article>
|
||||
<article class="card span4"><div class="label">Historie</div><div class="token-total" id="historyStorage">–</div><div class="sub">21 Tage detailliert, danach Stundenwerte</div></article>
|
||||
<article class="card span12"><div class="row"><div><div class="label">GPU-Verlauf</div><div class="sub">Auslastung und Temperatur beider Karten</div></div><div class="history-controls" id="historyRanges"><button data-range="1h">1 h</button><button data-range="24h" class="active">24 h</button><button data-range="7d">7 Tage</button><button data-range="21d">21 Tage</button><button data-range="all">Gesamt</button></div></div><div class="chart-legend" id="gpuLegend"></div><canvas class="chart" id="gpuHistoryChart"></canvas><div class="history-note" id="historyNote">Historie wird geladen …</div></article>
|
||||
<article class="card span12"><div class="row"><div><div class="label">Nutzung nach Profil und Modell</div><div class="sub">Prozentanteil im oben gewählten Zeitraum</div></div><div class="history-controls" id="usageModes"><button data-mode="total" class="active">Gesamte Tokenarbeit</button><button data-mode="output">Nur Ausgabe</button></div></div><div class="usage-list" id="profileUsage"><div class="sub">Nutzungsverteilung wird geladen …</div></div></article>
|
||||
<article class="card span12"><div class="label">Dauerhafte Modellwechsel</div><table><thead><tr><th>Zeit</th><th>Profil</th><th>Modell</th></tr></thead><tbody id="historyEvents"><tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr></tbody></table></article>
|
||||
<div class="section-title">Router · Profile · Dienste</div>
|
||||
<article class="card span6"><div class="label">Inferenz</div><div class="value status" id="availability">–</div><div class="metrics"><div class="metric"><b id="activeChats">–</b><span>aktive Anfragen</span></div><div class="metric"><b id="routerUptime">–</b><span>Router-Uptime</span></div><div class="metric"><b id="switching">–</b><span>Profilwechsel</span></div><div class="metric"><b id="dataDisk">–</b><span>/data belegt</span></div></div></article>
|
||||
<article class="card span6"><div class="label">Verfügbare Profile</div><div class="profiles" id="profiles"></div></article>
|
||||
<article class="card span6"><div class="label">Zusatzdienste</div><div class="metrics" id="services"></div></article>
|
||||
<article class="card span6"><div class="label">Netzwerk und Host</div><div class="metrics" id="hostMetrics"></div></article>
|
||||
<div class="section-title">Hardware · Dateien · Laufzeit</div>
|
||||
<article class="card span6"><div class="label">GPU-Prozesse</div><table><thead><tr><th>GPU</th><th>Prozess</th><th>PID</th><th>VRAM</th></tr></thead><tbody id="processes"><tr><td colspan="4">–</td></tr></tbody></table></article>
|
||||
<article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article>
|
||||
<article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article>
|
||||
<article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article>
|
||||
<article class="card span12 error" id="errors" hidden></article>
|
||||
</section><div class="footer">Aktualisierung jede Sekunde · Nur lesende Telemetrie</div>
|
||||
</main><script>
|
||||
const $=id=>document.getElementById(id); const pct=n=>n==null?'–':`${n.toFixed?.(1)??n}%`; const gib=b=>b?`${(b/1073741824).toFixed(1)} GiB`:'–'; const dur=s=>{if(s==null)return'–';let d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?`${d}d ${h}h`:`${h}h ${m}m`};
|
||||
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0));return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${g.index}</div><div class="value">${g.name}</div></div><span class="badge">${g.pstate||'–'}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB</b><span>VRAM</span></div><div class="metric"><b>${g.temperature_c??'–'} °C</b><span>Temperatur</span></div><div class="metric"><b>${g.power_w??'–'} / ${g.power_limit_w??'–'} W</b><span>Leistung</span></div><div class="metric"><b>${g.graphics_clock_mhz??'–'} MHz</b><span>Grafiktakt</span></div><div class="metric"><b>${g.memory_clock_mhz??'–'} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${pct(g.memory_controller_percent)}</b><span>Memory Controller</span></div><div class="metric"><b>${pct(g.fan_percent)}</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${load.toFixed(1)} %</span></div><div class="bar"><div class="fill gpu-load" style="width:${load}%"></div></div><div class="bar-label"><span>VRAM-Belegung</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${Math.min(100,p)}%"></div></div><div class="sub">${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei</div></article>`}
|
||||
const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":"Bildmodell wird geladen","generating":"Bild wird generiert","unloading-image":"Bildmodell wird entladen","restoring-qwen":"Qwen wird wiederhergestellt"}[p]||p||'bereit');
|
||||
async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000);
|
||||
</script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''
|
||||
|
||||
|
||||
FULL_JS = r'''
|
||||
const deNum=n=>n==null?'–':Number(n).toLocaleString('de-DE',{maximumFractionDigits:1});
|
||||
const bytesRate=n=>n==null?'–':n>=1048576?`${(n/1048576).toFixed(1)} MiB/s`:`${(n/1024).toFixed(1)} KiB/s`;
|
||||
const metric=(value,label)=>`<div class="metric"><b>${value??'–'}</b><span>${label}</span></div>`;
|
||||
const boolLabel=v=>v===true?'ja':v===false?'nein':'–';
|
||||
function slotHtml(s){
|
||||
let used=s.context_used||0,total=s.n_ctx||0,p=total?Math.min(100,used/total*100):0;
|
||||
let state=s.processing?'arbeitet':'frei';
|
||||
return `<div class="slot"><div class="slot-head"><b>Slot ${s.id??'–'} · ${state}</b><span class="badge">${s.speculative?'MTP aktiv':'Standard'}</span></div><div class="bar-label"><span>${deNum(used)} / ${deNum(total)} Token</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${p}%"></div></div><div class="metrics">${metric(deNum(s.prompt_tokens),'Prompt')}${metric(deNum(s.prompt_cached),'Cache-Token')}${metric(deNum(s.decoded_tokens),'generiert')}${metric(deNum(s.remaining_generation),'Ausgabe übrig')}${metric(s.task_id??'–','Task-ID')}${metric(s.temperature??'–','Temperatur')}${metric(boolLabel(s.stream),'Streaming')}${metric(deNum(s.max_tokens),'Ausgabelimit')}</div></div>`;
|
||||
}
|
||||
async function refreshFull(){
|
||||
try{
|
||||
let response=await fetch('/api/status',{cache:'no-store'}); if(!response.ok) return;
|
||||
let d=await response.json(),rt=d.router||{},lt=rt.llama_telemetry||{},met=lt.metrics||{},props=lt.props||{},lr=d.llama_runtime||{},cpu=d.cpu||{},net=cpu.network||{};
|
||||
let gen=met.predicted_tokens_seconds,prompt=met.prompt_tokens_seconds;
|
||||
$('generationRate').textContent=gen==null?'–':`${deNum(gen)} tok/s`;
|
||||
let genAvg=met.tokens_predicted_seconds_total?met.tokens_predicted_total/met.tokens_predicted_seconds_total:null;
|
||||
$('generationSub').textContent=genAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(genAvg)} tok/s`;
|
||||
$('promptRate').textContent=prompt==null?'–':`${deNum(prompt)} tok/s`;
|
||||
let promptAvg=met.prompt_seconds_total?met.prompt_tokens_total/met.prompt_seconds_total:null;
|
||||
$('promptSub').textContent=promptAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(promptAvg)} tok/s`;
|
||||
let cached=Number(met.prompt_tokens_cached_total||0),processed=Number(met.prompt_tokens_total||0),hit=(cached+processed)?cached/(cached+processed)*100:null;
|
||||
$('cacheHit').textContent=hit==null?'–':`${hit.toFixed(1)} %`;$('cacheBar').style.width=`${hit||0}%`;
|
||||
$('cacheSub').textContent=hit==null?'Keine Cache-Metrik':`${deNum(cached)} wiederverwendet · ${deNum(processed)} neu`;
|
||||
let running=Number(met.requests_processing??(rt.qwen||{}).active_chats??0),waiting=Number(met.requests_deferred||0);
|
||||
$('requestState').textContent=`${running} aktiv · ${waiting} wartet`;
|
||||
$('requestSub').textContent=`${(lt.slots||[]).filter(s=>!s.processing).length} freie Slots`;
|
||||
$('slotCards').innerHTML=(lt.slots||[]).map(slotHtml).join('')||'<div class="sub">Slot-Telemetrie momentan nicht verfügbar</div>';
|
||||
let drafted=Number(met.spec_decode_num_draft_tokens_total||0),accepted=Number(met.spec_decode_num_accepted_tokens_total||0),acceptance=drafted?accepted/drafted*100:null;
|
||||
$('mtpAcceptance').textContent=acceptance==null?'–':`${acceptance.toFixed(1)} % akzeptiert`;$('mtpBar').style.width=`${acceptance||0}%`;
|
||||
$('mtpDrafted').textContent=deNum(drafted);$('mtpAccepted').textContent=deNum(accepted);$('mtpSteps').textContent=deNum(met.spec_decode_num_drafts_total);$('mtpDepth').textContent=lr.mtp_draft_tokens??'–';
|
||||
$('counters').innerHTML=metric(deNum(met.prompt_tokens_total),'Prompt neu')+metric(deNum(met.prompt_tokens_cached_total),'Prompt aus Cache')+metric(deNum(met.tokens_predicted_total),'generierte Token')+metric(deNum(met.n_decode_total),'Decode-Aufrufe')+metric(deNum(met.n_tokens_max),'größte Sequenz')+metric(deNum(met.n_busy_slots_per_decode),'Slots je Decode');
|
||||
let modalities=Object.entries(props.modalities||{}).filter(([,v])=>v).map(([k])=>k).join(', ')||'–';
|
||||
$('modelDetails').innerHTML=metric(props.model_ftype||'–','Quantisierung')+metric(props.total_slots??lr.parallel??'–','Slots')+metric(modalities,'Modalitäten')+metric(deNum(props.default_context||lr.context_size),'Kontext')+metric(props.model_alias||lr.alias||'–','Alias')+metric(lr.reasoning_budget??'–','Reasoning-Budget');
|
||||
$('profiles').innerHTML=Object.entries(rt.profiles||{}).map(([name,ctx])=>`<div class="profile-item ${name===rt.current_profile?'active':''}"><b>${name}</b><span>${Number(ctx).toLocaleString('de-DE')} Token${name===rt.current_profile?' · aktiv':''}</span></div>`).join('')||'<div class="sub">Keine Profile gemeldet</div>';
|
||||
let tts=rt.tts||{},stt=rt.stt||{},img=rt.image||{};
|
||||
$('services').innerHTML=metric(tts.ready?'bereit':'nicht bereit',`TTS · ${tts.engine||'–'}`)+metric(tts.speaker||'–','Stimme')+metric(stt.reachable?'bereit':'aus','STT')+metric(img.worker||'–','Bild-Worker')+metric(img.model||'–','Bildmodell')+metric(img.phase==='idle'?'inaktiv':imagePhaseLabel(img.phase),'Bildstatus')+metric(img.model_loaded?'geladen':'entladen','Modellzustand')+metric(img.last_seconds==null?'–':`${deNum(img.last_seconds)} s`,'letztes Bild');
|
||||
$('hostMetrics').innerHTML=metric(bytesRate(net.rx_bytes_per_second),'Netzwerk empfangen')+metric(bytesRate(net.tx_bytes_per_second),'Netzwerk gesendet')+metric(dur(cpu.host_uptime_seconds),'Host-Uptime')+metric(dur(d.dashboard_uptime_seconds),'Dashboard-Uptime')+metric((cpu.load||[]).join(' / ')||'–','Load 1/5/15')+metric(net.interfaces??'–','Interfaces');
|
||||
let summary=d.model_summary||{};$('modelSummary').textContent=`${summary.count??0} Dateien · ${gib(summary.total_size||0)} gesamt`;
|
||||
$('modelFiles').innerHTML=(d.models||[]).map(f=>`<tr><td>${f.name}</td><td>${f.relative_path}</td><td>${gib(f.size)}</td><td>${new Date(f.modified*1000).toLocaleString('de-DE')}</td></tr>`).join('')||'<tr><td colspan="4">Keine GGUF-Dateien im eingebundenen Modellordner</td></tr>';
|
||||
$('events').innerHTML=(d.events||[]).map(e=>`<div class="event"><time>${new Date(e.timestamp*1000).toLocaleTimeString('de-DE')}</time><b>${e.name}</b>: ${String(e.from??'–')} → ${String(e.to??'–')}</div>`).join('')||'<div class="sub">Noch keine Zustandsänderung</div>';
|
||||
}catch(_){/* Die bestehende Verbindungsanzeige meldet Fehler bereits sichtbar. */}
|
||||
}
|
||||
refreshFull();setInterval(refreshFull,1000);
|
||||
'''
|
||||
|
||||
|
||||
HISTORY_JS = r'''
|
||||
let historyRange='24h',usageMode='total',lastProfileUsage=[];
|
||||
const historyColors=['#45d7ff','#ffb454','#66e3a4','#c39bff'];
|
||||
const hiddenHistorySeries=new Set();let lastHistoryPoints=[];
|
||||
function drawHistory(points){
|
||||
lastHistoryPoints=points;
|
||||
const canvas=$('gpuHistoryChart'),rect=canvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1;
|
||||
canvas.width=Math.max(1,Math.floor(rect.width*ratio));canvas.height=Math.max(1,Math.floor(rect.height*ratio));
|
||||
const x=canvas.getContext('2d');x.scale(ratio,ratio);const w=rect.width,h=rect.height,pad={l:42,r:44,t:16,b:28};
|
||||
x.clearRect(0,0,w,h);x.strokeStyle='#213044';x.fillStyle='#8fa1b5';x.font='11px system-ui';x.lineWidth=1;
|
||||
for(let i=0;i<=4;i++){let y=pad.t+(h-pad.t-pad.b)*i/4;x.beginPath();x.moveTo(pad.l,y);x.lineTo(w-pad.r,y);x.stroke();x.fillText(`${100-i*25}%`,4,y+4);x.fillText(`${100-i*25}°`,w-pad.r+7,y+4)}
|
||||
if(!points.length){x.fillText('Noch keine historischen Messwerte',pad.l+10,h/2);return}
|
||||
const min=Math.min(...points.map(p=>p.ts)),max=Math.max(...points.map(p=>p.ts));
|
||||
const px=t=>pad.l+(t-min)/Math.max(1,max-min)*(w-pad.l-pad.r), py=v=>pad.t+(100-Math.max(0,Math.min(100,v)))/100*(h-pad.t-pad.b);
|
||||
const span=max-min,ticks=5;
|
||||
for(let i=0;i<ticks;i++){
|
||||
const ts=min+span*i/(ticks-1),xx=px(ts),date=new Date(ts*1000);
|
||||
const label=span<=2*86400
|
||||
?date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
|
||||
:span<=45*86400
|
||||
?date.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
|
||||
:date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
|
||||
x.strokeStyle='#172538';x.beginPath();x.moveTo(xx,pad.t);x.lineTo(xx,h-pad.b);x.stroke();
|
||||
x.fillStyle='#8fa1b5';x.textAlign=i===0?'left':i===ticks-1?'right':'center';x.fillText(label,xx,h-7);
|
||||
}
|
||||
x.textAlign='start';
|
||||
const ids=[...new Set(points.map(p=>p.gpu_index))].sort();
|
||||
$('gpuLegend').innerHTML=ids.flatMap((id,idx)=>[['load',`GPU ${id} Auslastung`,historyColors[idx*2%historyColors.length]],['temp',`GPU ${id} Temperatur`,historyColors[(idx*2+1)%historyColors.length]]].map(([kind,label,color])=>{let key=`${id}:${kind}`;return `<button data-series="${key}" class="${hiddenHistorySeries.has(key)?'off':''}" style="--series:${color}" aria-pressed="${hiddenHistorySeries.has(key)?'false':'true'}">${label}</button>`})).join('');
|
||||
ids.forEach((id,idx)=>{let rows=points.filter(p=>p.gpu_index===id),load=historyColors[idx*2%historyColors.length],temp=historyColors[(idx*2+1)%historyColors.length];
|
||||
[[load,'gpu_util','load'],[temp,'temperature_c','temp']].forEach(([color,key,kind])=>{if(hiddenHistorySeries.has(`${id}:${kind}`))return;x.beginPath();x.strokeStyle=color;x.lineWidth=2;let first=true;rows.forEach(p=>{if(p[key]==null)return;let xx=px(p.ts),yy=py(Number(p[key]));first?(x.moveTo(xx,yy),first=false):x.lineTo(xx,yy)});x.stroke()})});
|
||||
}
|
||||
async function refreshHistory(){try{let r=await fetch(`/api/history?range=${historyRange}`,{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),t=d.token_totals||{},input=Number(t.prompt_tokens||0)+Number(t.cached_tokens||0);$('historyInput').textContent=deNum(input);$('historyInputSub').textContent=`${deNum(t.prompt_tokens)} neu · ${deNum(t.cached_tokens)} aus Cache`;$('historyOutput').textContent=deNum(t.output_tokens||0);$('historyStorage').textContent=`${deNum((d.storage||{}).rows||0)} Messpunkte`;drawHistory(d.points||[]);renderProfileUsage(d.profile_usage||[]);$('historyNote').textContent=`Bereich ${d.range} · Messung alle ${d.sample_interval_seconds}s · Detaildaten ${d.detail_retention_days} Tage`;$('historyEvents').innerHTML=(d.model_events||[]).slice(0,15).map(e=>`<tr><td>${new Date(e.ts*1000).toLocaleString('de-DE')}</td><td>${e.previous_profile||'–'} → ${e.profile||'–'}</td><td>${e.previous_model||'–'} → ${e.model||'–'}</td></tr>`).join('')||'<tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr>'}catch(e){$('historyNote').textContent=`Historie nicht verfügbar: ${e.message}`}}
|
||||
function renderProfileUsage(rows){lastProfileUsage=rows;let value=r=>usageMode==='output'?Number(r.output_tokens||0):Number(r.prompt_tokens||0)+Number(r.cached_tokens||0)+Number(r.output_tokens||0),sum=rows.reduce((n,r)=>n+value(r),0);$('profileUsage').innerHTML=rows.map((r,i)=>{let v=value(r),p=sum?v/sum*100:0,input=Number(r.prompt_tokens||0)+Number(r.cached_tokens||0);return `<div class="usage-row"><div class="usage-head"><b>${r.profile||'unbekannt'} <span>· ${r.model||'–'}</span></b><strong>${p.toFixed(1)} %</strong></div><div class="bar"><div class="fill" style="width:${p}%;background:${historyColors[i%historyColors.length]}"></div></div><div class="usage-meta"><span>${deNum(input)} Eingabe · ${deNum(r.output_tokens||0)} Ausgabe</span><span>${deNum(v)} gewertet</span></div></div>`}).join('')||'<div class="sub">In diesem Zeitraum wurden noch keine Token aufgezeichnet.</div>'}
|
||||
$('usageModes').addEventListener('click',e=>{let b=e.target.closest('button[data-mode]');if(!b)return;usageMode=b.dataset.mode;document.querySelectorAll('#usageModes button').forEach(x=>x.classList.toggle('active',x===b));renderProfileUsage(lastProfileUsage)});
|
||||
$('historyRanges').addEventListener('click',e=>{let b=e.target.closest('button[data-range]');if(!b)return;historyRange=b.dataset.range;document.querySelectorAll('#historyRanges button').forEach(x=>x.classList.toggle('active',x===b));refreshHistory()});
|
||||
$('gpuLegend').addEventListener('click',e=>{let b=e.target.closest('button[data-series]');if(!b)return;let key=b.dataset.series;hiddenHistorySeries.has(key)?hiddenHistorySeries.delete(key):hiddenHistorySeries.add(key);drawHistory(lastHistoryPoints)});
|
||||
window.addEventListener('resize',()=>refreshHistory());refreshHistory();setInterval(refreshHistory,15000);
|
||||
'''
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AthenaDashboard/1.0"
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path == "/":
|
||||
self._send(200, HTML.encode(), "text/html; charset=utf-8")
|
||||
elif path == "/full.js":
|
||||
self._send(200, FULL_JS.encode(), "text/javascript; charset=utf-8")
|
||||
elif path == "/history.js":
|
||||
self._send(200, HISTORY_JS.encode(), "text/javascript; charset=utf-8")
|
||||
elif path == "/health":
|
||||
self._send(200, b'{"status":"ok"}', "application/json")
|
||||
elif path == "/api/status":
|
||||
body = json.dumps(collect(), ensure_ascii=False, separators=(",", ":")).encode()
|
||||
self._send(200, body, "application/json; charset=utf-8")
|
||||
elif path == "/api/history":
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||
range_name = query.get("range", ["24h"])[0]
|
||||
body = json.dumps(HISTORY.query(range_name), ensure_ascii=False, separators=(",", ":")).encode()
|
||||
self._send(200, body, "application/json; charset=utf-8")
|
||||
else:
|
||||
self._send(404, b'{"error":"not found"}', "application/json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=history_collector, name="history-collector", daemon=True).start()
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM python:3.13-slim AS builder
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /uvx /bin/
|
||||
RUN uv pip install --system --break-system-packages "arr-mcp[mcp]==1.0.1"
|
||||
|
||||
FROM python:3.13-slim
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
RUN groupadd --system --gid 10001 mcp \
|
||||
&& useradd --system --uid 10001 --gid 10001 --no-create-home mcp
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["arr-mcp"]
|
||||
CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000", "--auth-type", "none"]
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM ghcr.io/github/github-mcp-server@sha256:1817b57d43916532dc002bdc5f344d639bd9fb54a9148d42168458f7c3280567 AS github
|
||||
|
||||
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
|
||||
|
||||
ARG MCP_PROXY_VERSION=0.12.0
|
||||
RUN pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp==1.29.0"
|
||||
|
||||
# GitHub publishes a minimal image containing only the official Go binary.
|
||||
# mcp-proxy contributes transport conversion only; GitHub API behavior and
|
||||
# every exposed tool remain implemented by GitHub's official MCP server.
|
||||
COPY --from=github /server/github-mcp-server /usr/local/bin/github-mcp-server
|
||||
|
||||
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
EXPOSE 8000
|
||||
# This is the same OpenWebUI-compatible stateless transport used by Athena's
|
||||
# other Python/stdio MCP adapters. The official GitHub binary remains the only
|
||||
# component implementing GitHub operations.
|
||||
# mcp-proxy intentionally starts stdio children with a minimal environment.
|
||||
# Explicit pass-through is required so the GitHub subprocess receives the PAT
|
||||
# already injected into this container by Docker. The value is never placed on
|
||||
# the command line, image, logs or Open WebUI connection record.
|
||||
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--pass-environment", "--"]
|
||||
CMD ["/usr/local/bin/github-mcp-server", "stdio", "--read-only", "--tools", "search_repositories,get_file_contents,search_code"]
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM nginx:1.29-alpine
|
||||
COPY platform/mcp/homeassistant.conf.template /etc/nginx/templates/homeassistant.conf.template
|
||||
COPY platform/mcp/ha-relay-entrypoint.sh /usr/local/bin/ha-relay-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/ha-relay-entrypoint \
|
||||
&& mkdir -p /tmp/client_temp /tmp/proxy_temp \
|
||||
&& chown -R nginx:nginx /tmp/client_temp /tmp/proxy_temp
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/usr/local/bin/ha-relay-entrypoint"]
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM ghcr.io/blakeem/navidrome-mcp:2.2.0@sha256:047f911a5a8f7cc8f185bb4d6e7ca6c435542edefff4694a00c2f718ab0ee7f5
|
||||
|
||||
# llama.cpp's tool-schema converter requires every JSON-Schema regex to be
|
||||
# fully anchored. Upstream 2.2.0 omits the trailing `$` on exactly two radio
|
||||
# URL fields. Fail the build if upstream changes instead of patching blindly.
|
||||
USER root
|
||||
RUN node -e 'const fs=require("node:fs"); const p="/app/dist/tools/handlers/radio-handlers.js"; let s=fs.readFileSync(p,"utf8"); const a="pattern: '\''^https?://.+'\''"; const b="pattern: '\''^https?://.+$'\''"; const n=s.split(a).length-1; if(n!==2) throw new Error(`expected 2 schema patterns, found ${n}`); fs.writeFileSync(p,s.split(a).join(b));'
|
||||
USER node
|
||||
@@ -1,15 +0,0 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG MCP_PROXY_VERSION=0.12.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp>=1.17,<2" \
|
||||
&& useradd --system --uid 10001 --create-home --home-dir /app mcp
|
||||
|
||||
RUN touch /app/unraid_mcp.py && chown 10001:10001 /app/unraid_mcp.py
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
|
||||
CMD ["python", "/app/unraid_mcp.py"]
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG MCP_PROXY_VERSION=0.12.0
|
||||
ARG YT_DLP_VERSION=2026.7.4
|
||||
RUN pip install --no-cache-dir \
|
||||
"mcp-proxy==${MCP_PROXY_VERSION}" \
|
||||
"mcp>=1.17,<2" \
|
||||
"yt-dlp==${YT_DLP_VERSION}"
|
||||
|
||||
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
|
||||
COPY web-search/web_search_mcp.py /app/web_search_mcp.py
|
||||
RUN chown -R 10001:10001 /app
|
||||
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
|
||||
CMD ["python", "/app/web_search_mcp.py"]
|
||||
@@ -1,12 +0,0 @@
|
||||
services:
|
||||
mcp-github:
|
||||
# Temporary, operator-controlled maintenance mode. It deliberately omits
|
||||
# delete, merge, repository creation, issue mutation and workflow tools.
|
||||
command:
|
||||
- /usr/local/bin/github-mcp-server
|
||||
- stdio
|
||||
- --tools
|
||||
- search_repositories,get_repository_tree,get_file_contents,search_code,list_branches,create_branch,create_or_update_file,push_files,create_pull_request
|
||||
environment:
|
||||
GITHUB_READ_ONLY: "0"
|
||||
GITHUB_TOOLS: search_repositories,get_repository_tree,get_file_contents,search_code,list_branches,create_branch,create_or_update_file,push_files,create_pull_request
|
||||
+3
-193
@@ -12,147 +12,8 @@ x-tool-common: &tool-common
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
mcp-web:
|
||||
<<: *tool-common
|
||||
# Historical site-specific facade. Kept only for rollback while the
|
||||
# default portable endpoint points directly at TinySearch's broad MCP.
|
||||
profiles: [legacy-web]
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: mcp/Dockerfile.web
|
||||
image: mike-ai/mcp-web:local
|
||||
container_name: mike-ai-mcp-web
|
||||
# The relay fetches and validates public result pages itself. It therefore
|
||||
# needs both the private tool network and the explicitly separated egress
|
||||
# network; keeping it on `tools` only makes search discovery work while
|
||||
# every page fetch fails.
|
||||
networks: [tools, tools-egress]
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
environment:
|
||||
TINYSEARCH_MCP_URL: http://tinysearch:8000/mcp
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
WEB_SEARCH_BUDGET_MAX_RELATED: "3"
|
||||
YOUTUBE_TIMEOUT: "45"
|
||||
depends_on:
|
||||
tinysearch:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1', 8000), 2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
searxng:
|
||||
<<: *tool-common
|
||||
image: searxng/searxng@sha256:e45d5894bfaa0bf8773b9f283795ae57f1c15ddb29c8cecb70b3665b0ce9ec60
|
||||
container_name: mike-ai-tools-searxng
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
volumes:
|
||||
- ${SEARXNG_SETTINGS_FILE:-../web-search/searxng-settings.example.yml}:/etc/searxng/settings.yml:ro
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
tinysearch:
|
||||
<<: *tool-common
|
||||
# TinySearch v0.6.1. This release fixes the crawler behavior observed with
|
||||
# v0.5.1 and adds the current search -> scrape_urls workflow.
|
||||
image: marcellm01/tinysearch@sha256:7a7d0585f5000f462e699e42b97409715826a9e2edcd09a166afa93a4b7cba31
|
||||
container_name: mike-ai-tools-tinysearch
|
||||
dns: ["${AI_DNS:-1.1.1.1}"]
|
||||
# Crawl4AI keeps transient browser/session state here. The container stays
|
||||
# read-only; only this disposable runtime directory (and /tmp from the
|
||||
# common hardening block) is writable.
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||
- /home/tinysearch/.crawl4ai:rw,nosuid,nodev,size=256m,mode=1777
|
||||
shm_size: 1gb
|
||||
volumes:
|
||||
- tinysearch-models:/data/models
|
||||
- ../web-search/tinysearch_config.json:/config/tinysearch_config.json:ro
|
||||
environment:
|
||||
MCP_TRANSPORT: streamable-http
|
||||
MCP_HOST: 0.0.0.0
|
||||
MCP_PORT: "8000"
|
||||
TINYSEARCH_CONFIG_PATH: /config/tinysearch_config.json
|
||||
TINYSEARCH_SEARCH_BACKEND: searxng
|
||||
SEARXNG_URL: http://searxng:8080/search
|
||||
depends_on: [searxng]
|
||||
cap_add: [SETUID, SETGID, CHOWN]
|
||||
networks: [tools, tools-egress]
|
||||
# The image's built-in `tinysearch doctor` also requires a writable
|
||||
# configuration directory, although normal server operation does not.
|
||||
# Check the service socket instead so read-only hardening remains intact.
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1', 8000), 2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
mcp-homeassistant:
|
||||
<<: *tool-common
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: platform/mcp/Dockerfile.homeassistant-relay
|
||||
image: mike-ai/mcp-homeassistant-relay:local
|
||||
container_name: mike-ai-mcp-homeassistant
|
||||
profiles: [homeassistant]
|
||||
# Keep the public TLS hostname for SNI/certificate validation, but route it
|
||||
# to the private reverse proxy through WireGuard. Public DNS may otherwise
|
||||
# resolve to the Fritzbox WAN address, which is unreachable/hairpinned from
|
||||
# Athena's remote-site containers.
|
||||
extra_hosts:
|
||||
- "ha.casaderoll.de:${HOME_LAN_PROXY_IP:-192.168.1.2}"
|
||||
volumes:
|
||||
- ${HA_ENV_FILE:-/etc/mike-ai/homeassistant-admin-mcp.env}:/run/secrets/homeassistant.env:ro
|
||||
cap_add: [CHOWN, SETUID, SETGID]
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-arr:
|
||||
<<: *tool-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.arr
|
||||
image: mike-ai/mcp-arr:1.0.1-patched
|
||||
container_name: mike-ai-mcp-arr
|
||||
profiles: [arr]
|
||||
env_file:
|
||||
- ${ARR_ENV_FILE:-/etc/mike-ai/arr-mcp.env}
|
||||
volumes:
|
||||
# The local fork adds bounded read-only Sonarr pseudo-actions. Keep the
|
||||
# patch explicit until upstream publishes a self-contained 2.x image.
|
||||
- ${ARR_SONARR_PATCH:-./patches/mcp_sonarr.py}:/usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_sonarr.py:ro
|
||||
# Upstream's generic "Execute any Radarr API action" text gives small
|
||||
# models no routing boundary. This overlay changes guidance only.
|
||||
- ${ARR_RADARR_PATCH:-./patches/mcp_radarr.py}:/usr/local/lib/python3.13/site-packages/arr_mcp/mcp/mcp_radarr.py:ro
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
mcp-navidrome:
|
||||
<<: *tool-common
|
||||
# Version and amd64 manifest are pinned. The image contains no mpv, so it
|
||||
# cannot play audio on the headless AI host and does not expose playback
|
||||
# controls. It talks to Navidrome only through its authenticated API.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.navidrome
|
||||
image: mike-ai/mcp-navidrome:2.2.0-schemafix1
|
||||
container_name: mike-ai-mcp-navidrome
|
||||
profiles: [navidrome]
|
||||
env_file:
|
||||
- ${NAVIDROME_MCP_ENV_FILE:-/etc/mike-ai/navidrome-mcp.env}
|
||||
environment:
|
||||
MCP_TRANSPORT: http
|
||||
MCP_HTTP_EXPOSE: "true"
|
||||
MCP_HTTP_PORT: "3000"
|
||||
# OpenWebUI uses the Docker name; Pi/Hermes may reach the same endpoint
|
||||
# directly through Athena's WireGuard address and VPN port 8207.
|
||||
MCP_HTTP_ALLOWED_HOSTS: "mike-ai-mcp-navidrome:3000,mike-ai-mcp-navidrome,${VPN_SERVICE_IP:-192.168.1.212}:8207,${VPN_SERVICE_IP:-192.168.1.212}"
|
||||
WEBUI_ENABLED: "false"
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,nodev,size=64m
|
||||
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
# Einziger MCP auf Athena: die schmale Fassade zum root-eigenen Operator.
|
||||
# Alle portablen Fach-MCPs laufen als eigene Container auf Unraid.
|
||||
mcp-athena-operator:
|
||||
<<: *tool-common
|
||||
build:
|
||||
@@ -163,61 +24,10 @@ services:
|
||||
environment:
|
||||
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
||||
volumes:
|
||||
# The unprivileged MCP facade sees only the root-owned executor socket.
|
||||
# Docker, source, models, Git credentials and host paths remain on the
|
||||
# executor side and are reachable only through structured operations.
|
||||
- /run/mike-ai-operator:/operator:ro
|
||||
networks: [tools]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
test: [CMD, python, -c, "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
mcp-github:
|
||||
<<: *tool-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.github
|
||||
image: mike-ai/mcp-github:github-v1.10.1-mcp-proxy-v0.12.0
|
||||
container_name: mike-ai-mcp-github
|
||||
profiles: [github]
|
||||
env_file:
|
||||
- ${GITHUB_MCP_ENV_FILE:-/etc/mike-ai/github-mcp.env}
|
||||
environment:
|
||||
# These server-side limits remain authoritative even if a client asks
|
||||
# for broader toolsets. The token itself must also remain read-only.
|
||||
GITHUB_TOOLS: search_repositories,get_file_contents,search_code
|
||||
GITHUB_READ_ONLY: "1"
|
||||
networks: [tools, tools-egress]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
mcp-unraid-ssh:
|
||||
<<: *tool-common
|
||||
profiles: [extended]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.unraid-ssh
|
||||
image: mike-ai/mcp-unraid-ssh:local
|
||||
container_name: mike-ai-mcp-unraid-ssh
|
||||
environment:
|
||||
UNRAID_MCP_CONFIG: /run/config/unraid-mcp.json
|
||||
volumes:
|
||||
- ${UNRAID_MCP_SOURCE:-/opt/mike-ai/unraid-agent/unraid_mcp.py}:/app/unraid_mcp.py:ro
|
||||
- ${UNRAID_MCP_CONFIG:-/etc/mike-ai/unraid-mcp.json}:/run/config/unraid-mcp.json:ro
|
||||
- ${UNRAID_SSH_KEY:-/etc/mike-ai/keys/unraid_root}:/etc/mike-ai/keys/unraid_root:ro
|
||||
- ${UNRAID_KNOWN_HOSTS:-/etc/mike-ai/ssh/known_hosts_unraid_ai}:/etc/mike-ai/ssh/known_hosts_unraid_ai:ro
|
||||
- unraid-audit:/var/log/mike-ai
|
||||
networks: [tools, tools-egress]
|
||||
|
||||
volumes:
|
||||
tinysearch-models:
|
||||
name: mike-ai-tools_tinysearch-models
|
||||
external: true
|
||||
unraid-audit:
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
MODE=${1:-}
|
||||
CONFIRM=${2:-}
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
PROJECT=mike-ai-tools
|
||||
BASE=(docker compose -p "$PROJECT" --profile github -f "$ROOT_DIR/compose.yaml")
|
||||
MAINTENANCE=(-f "$ROOT_DIR/compose.github-maintenance.yaml")
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
|
||||
case "$MODE" in
|
||||
read)
|
||||
"${BASE[@]}" up -d --no-deps --force-recreate mcp-github
|
||||
;;
|
||||
maintenance)
|
||||
[[ $CONFIRM == --confirm ]] || die \
|
||||
"Wartungsmodus nur mit: $0 maintenance --confirm"
|
||||
"${BASE[@]}" "${MAINTENANCE[@]}" up -d --no-deps --force-recreate mcp-github
|
||||
;;
|
||||
status)
|
||||
command_line=$(docker inspect -f '{{json .Config.Cmd}}' mike-ai-mcp-github 2>/dev/null || true)
|
||||
if [[ $command_line == *create_branch* ]]; then
|
||||
printf 'GitHub MCP: WARTUNGSMODUS (begrenzter Schreibzugriff)\n'
|
||||
elif [[ $command_line == *--read-only* ]]; then
|
||||
printf 'GitHub MCP: NUR LESEN\n'
|
||||
else
|
||||
die "GitHub-MCP-Modus ist nicht eindeutig; Konfiguration prüfen."
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Aufruf: $0 {status|read|maintenance --confirm}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Open WebUI caches MCP capabilities. A short backend restart makes the new
|
||||
# allowlist deterministic for all profiles without touching model services.
|
||||
docker restart mike-ai-open-webui >/dev/null
|
||||
for _ in $(seq 1 30); do
|
||||
[[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-mcp-github 2>/dev/null || true) == healthy ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-mcp-github 2>/dev/null || true) == healthy ]] || \
|
||||
die "GitHub MCP wurde nicht gesund. Zurücksetzen mit: $0 read"
|
||||
|
||||
"$0" status
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
config=/run/secrets/homeassistant.env
|
||||
if [ ! -r "$config" ]; then
|
||||
echo "Home Assistant secret file is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
set -a
|
||||
. "$config"
|
||||
set +a
|
||||
: "${HASS_URL:?HASS_URL is required}"
|
||||
: "${HASS_TOKEN:?HASS_TOKEN is required}"
|
||||
|
||||
upstream=${HASS_URL%/}
|
||||
escaped_token=$(printf '%s' "$HASS_TOKEN" | sed 's/[&/]/\\&/g')
|
||||
escaped_upstream=$(printf '%s' "$upstream" | sed 's/[&/]/\\&/g')
|
||||
sed -e "s/__HASS_TOKEN__/$escaped_token/g" \
|
||||
-e "s/__HASS_UPSTREAM__/$escaped_upstream/g" \
|
||||
/etc/nginx/templates/homeassistant.conf.template \
|
||||
> /tmp/nginx.conf
|
||||
unset HASS_TOKEN
|
||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||
@@ -1,30 +0,0 @@
|
||||
worker_processes 1;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
events { worker_connections 128; }
|
||||
|
||||
http {
|
||||
access_log /dev/stdout;
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
|
||||
server {
|
||||
listen 8000;
|
||||
location /mcp {
|
||||
proxy_pass __HASS_UPSTREAM__/api/hass_mcp;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name $proxy_host;
|
||||
proxy_set_header Authorization "Bearer __HASS_TOKEN__";
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
source_file=$repo_dir/platform/mcp/patches/hass_mcp/yaml_config.py
|
||||
ha_config_dir=${1:-}
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root auf dem Home-Assistant-Host ausführen."
|
||||
[[ -n $ha_config_dir ]] || die "Aufruf: $0 /pfad/zum/home-assistant-config"
|
||||
[[ -s $source_file ]] || die "Patchdatei fehlt: $source_file"
|
||||
|
||||
target=$ha_config_dir/custom_components/hass_mcp/tools/yaml_config.py
|
||||
[[ -s $target ]] || die "Native hass_mcp-Installation nicht gefunden: $target"
|
||||
|
||||
backup_dir=$ha_config_dir/.hass_mcp_patch_backups
|
||||
mkdir -p "$backup_dir"
|
||||
chmod 700 "$backup_dir"
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
backup=$backup_dir/yaml_config.py.before-guard-$stamp
|
||||
cp -p "$target" "$backup"
|
||||
chmod 600 "$backup"
|
||||
install -m 0644 "$source_file" "$target"
|
||||
|
||||
printf 'YAML Guard installiert. Sicherung: %s\n' "$backup"
|
||||
printf 'Home Assistant muss jetzt kontrolliert neu gestartet werden.\n'
|
||||
@@ -16,14 +16,10 @@ docker network inspect mike-ai-tools >/dev/null 2>&1 || \
|
||||
docker network inspect mike-ai-tools-egress >/dev/null 2>&1 || \
|
||||
docker network create --subnet 172.30.50.0/24 mike-ai-tools-egress >/dev/null
|
||||
|
||||
# One administrative MCP exposes ATHENA.md plus the bounded host operator.
|
||||
# Einziger MCP auf Athena: ATHENA.md plus der begrenzte Host-Operator.
|
||||
"$MCP_DIR/../operator/install-operator.sh"
|
||||
|
||||
services=(mcp-athena-operator)
|
||||
# Portable Fach-MCPs laufen zentral im MCPHub auf Unraid. Ihre Compose-Blöcke
|
||||
# bleiben vorläufig als explizite Rollback-Profile erhalten, werden bei einer
|
||||
# normalen Athena-Installation aber weder gebaut noch gestartet.
|
||||
echo "ARR, GitHub, Home Assistant und Navidrome werden über MCPHub auf Unraid bereitgestellt."
|
||||
"${COMPOSE[@]}" up -d --build "${services[@]}"
|
||||
echo "Portable Fach-MCPs laufen als eigene Container auf Unraid."
|
||||
"${COMPOSE[@]}" up -d --build mcp-athena-operator
|
||||
|
||||
"${COMPOSE[@]}" ps
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
"""Guarded YAML access for Home Assistant configuration files.
|
||||
|
||||
The language model is untrusted. Reads are bounded and redact likely inline
|
||||
credentials. Every mutation is previewed first, bound to the exact current file
|
||||
hash, backed up, written atomically, checked by Home Assistant and rolled back
|
||||
when validation or reload fails. ``secrets.yaml`` is never addressable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from ..identity import user_context
|
||||
from ..protocol import ToolError, internal_error
|
||||
from ..registry import LIMIT_FIELD, OFFSET_FIELD, paginate, schema, tool
|
||||
|
||||
# kind -> (filename, parsed structure, reload service domain)
|
||||
_KINDS: dict[str, tuple[str, str, str | None]] = {
|
||||
"automation": ("automations.yaml", "list", "automation"),
|
||||
"script": ("scripts.yaml", "dict", "script"),
|
||||
"scene": ("scenes.yaml", "list", "scene"),
|
||||
# configuration.yaml is intentionally raw-read/replace only. Treating its
|
||||
# top-level keys as CRUD records would be dangerously misleading.
|
||||
"configuration": ("configuration.yaml", "dict", None),
|
||||
}
|
||||
_STRUCTURED_KINDS = frozenset({"automation", "script", "scene"})
|
||||
_OPS = (
|
||||
"list",
|
||||
"get",
|
||||
"read_source",
|
||||
"find_source",
|
||||
"find_commented_blocks",
|
||||
"list_backups",
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"replace_source_text",
|
||||
"restore_backup",
|
||||
"reload",
|
||||
)
|
||||
_MUTATING_OPS = frozenset({"create", "update", "delete", "replace_source_text", "restore_backup"})
|
||||
_TICKET_TTL_SECONDS = 600
|
||||
_MAX_SOURCE_BYTES = 2 * 1024 * 1024
|
||||
_MAX_REPLACEMENT_CHARS = 50_000
|
||||
_PENDING: dict[str, dict[str, Any]] = {}
|
||||
_SENSITIVE_LINE = re.compile(
|
||||
r"(?i)^(?P<prefix>\s*[^#\n]*(?:password|passwd|token|secret|api[_-]?key|authorization)[^:]*:\s*).*$"
|
||||
)
|
||||
_SECRET_REFERENCE = re.compile(r"!secret\s+[^\s#]+", re.IGNORECASE)
|
||||
|
||||
|
||||
@tool(
|
||||
name="ha_yaml_config",
|
||||
description=(
|
||||
"Safely inspect and edit Home Assistant YAML. Structured CRUD is limited to "
|
||||
"automations.yaml, scripts.yaml and scenes.yaml. Raw source operations also "
|
||||
"allow configuration.yaml so commented-out blocks can be found and reviewed. "
|
||||
"secrets.yaml and arbitrary paths are impossible. Use find_commented_blocks once "
|
||||
"to inventory fully commented YAML entries; use read_source/find_source only for "
|
||||
"other comments or exact YAML text. Every mutation first returns a diff/preview "
|
||||
"and one-time approval_ticket; only repeat the exact unchanged call with "
|
||||
"confirm=true after explicit user approval. Writes create a backup, are atomic, "
|
||||
"run Home Assistant config validation, roll back on failure, and reload the "
|
||||
"affected domain when supported. Never claim a preview changed Home Assistant."
|
||||
),
|
||||
input_schema=schema(
|
||||
properties={
|
||||
"kind": {"type": "string", "enum": list(_KINDS)},
|
||||
"op": {"type": "string", "enum": list(_OPS)},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Entry id for structured get/create/update/delete.",
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
"description": "Complete entry config for structured create/update.",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"description": "Case-insensitive literal text for find_source.",
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1,
|
||||
"description": "First 1-based line returned by read_source.",
|
||||
},
|
||||
"max_lines": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 400,
|
||||
"default": 120,
|
||||
"description": "Bounded source lines returned by read_source/find_source.",
|
||||
},
|
||||
"old_text": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": _MAX_REPLACEMENT_CHARS,
|
||||
"description": "Exact unique YAML source text to replace.",
|
||||
},
|
||||
"new_text": {
|
||||
"type": "string",
|
||||
"maxLength": _MAX_REPLACEMENT_CHARS,
|
||||
"description": "Replacement YAML source text; may be empty to remove a block.",
|
||||
},
|
||||
"backup_id": {
|
||||
"type": "string",
|
||||
"pattern": r"^[a-z0-9_.-]+$",
|
||||
"description": "Opaque filename returned by list_backups.",
|
||||
},
|
||||
"confirm": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "True only after the user approved the exact preview.",
|
||||
},
|
||||
"approval_ticket": {
|
||||
"type": "string",
|
||||
"description": "One-time ticket from the unchanged mutation preview.",
|
||||
},
|
||||
"limit": LIMIT_FIELD,
|
||||
"offset": OFFSET_FIELD,
|
||||
},
|
||||
required=["kind", "op"],
|
||||
),
|
||||
read_only=False,
|
||||
idempotent=False,
|
||||
requires_admin=True,
|
||||
write_ops=["create", "update", "replace_source_text", "reload"],
|
||||
destructive_ops=["delete", "restore_backup"],
|
||||
admin_ops=["list", "get", "read_source", "find_source", "find_commented_blocks", "list_backups"],
|
||||
)
|
||||
async def ha_yaml_config(
|
||||
hass: HomeAssistant,
|
||||
kind: str,
|
||||
op: str,
|
||||
id: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
query: str | None = None,
|
||||
start_line: int = 1,
|
||||
max_lines: int = 120,
|
||||
old_text: str | None = None,
|
||||
new_text: str | None = None,
|
||||
backup_id: str | None = None,
|
||||
confirm: bool = False,
|
||||
approval_ticket: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in _KINDS:
|
||||
raise ToolError(f"unknown kind '{kind}'")
|
||||
if op not in _OPS:
|
||||
raise ToolError(f"unknown op '{op}'")
|
||||
|
||||
filename, structure, reload_domain = _KINDS[kind]
|
||||
path = Path(hass.config.path(filename))
|
||||
|
||||
if op in {"list", "get", "create", "update", "delete", "reload"} and kind not in _STRUCTURED_KINDS:
|
||||
raise ToolError(
|
||||
"configuration.yaml supports only read_source, find_source, list_backups, "
|
||||
"replace_source_text and restore_backup"
|
||||
)
|
||||
|
||||
if op == "read_source":
|
||||
return await _read_source(hass, path, start_line, max_lines)
|
||||
if op == "find_source":
|
||||
if not query:
|
||||
raise ToolError("op=find_source requires query")
|
||||
return await _find_source(hass, path, query, max_lines)
|
||||
if op == "find_commented_blocks":
|
||||
if kind not in {"automation", "scene"}:
|
||||
raise ToolError("find_commented_blocks supports automation and scene list files")
|
||||
return await _find_commented_blocks(hass, path, max_lines)
|
||||
if op == "list_backups":
|
||||
return await _list_backups(hass, filename, limit, offset)
|
||||
if op == "replace_source_text":
|
||||
if old_text is None or new_text is None:
|
||||
raise ToolError("op=replace_source_text requires old_text and new_text")
|
||||
_reject_sensitive_replacement(old_text, new_text)
|
||||
current = await _read_text(hass, path)
|
||||
if current.count(old_text) != 1:
|
||||
raise ToolError(
|
||||
f"old_text must occur exactly once in {filename}; found {current.count(old_text)} occurrences"
|
||||
)
|
||||
proposed = current.replace(old_text, new_text, 1)
|
||||
await _validate_yaml_text(hass, proposed, structure, filename)
|
||||
change = _change_record(kind, op, current, {"old_text": old_text, "new_text": new_text})
|
||||
if not confirm:
|
||||
return _preview(change, _source_diff(filename, current, proposed))
|
||||
_consume_ticket(change, approval_ticket)
|
||||
return await _commit_text(hass, path, filename, structure, reload_domain, current, proposed)
|
||||
if op == "restore_backup":
|
||||
if not backup_id:
|
||||
raise ToolError("op=restore_backup requires backup_id from list_backups")
|
||||
current = await _read_text(hass, path)
|
||||
restored = await _read_backup(hass, filename, backup_id)
|
||||
await _validate_yaml_text(hass, restored, structure, filename)
|
||||
change = _change_record(kind, op, current, {"backup_id": backup_id})
|
||||
if not confirm:
|
||||
return _preview(change, _source_diff(filename, current, restored), extra={"backup_id": backup_id})
|
||||
_consume_ticket(change, approval_ticket)
|
||||
return await _commit_text(hass, path, filename, structure, reload_domain, current, restored)
|
||||
|
||||
data = await _load(hass, path, structure)
|
||||
if op == "list":
|
||||
return paginate(_to_list(data, structure), limit, offset)
|
||||
if op == "get":
|
||||
if not id:
|
||||
raise ToolError("op=get requires id")
|
||||
item = _find(data, structure, id)
|
||||
if item is None:
|
||||
raise ToolError(f"{kind} '{id}' not found in {filename}")
|
||||
return item
|
||||
if op == "reload":
|
||||
await _reload(hass, reload_domain)
|
||||
return {"reloaded": reload_domain, "changed_file": False}
|
||||
|
||||
current_text = await _read_text(hass, path)
|
||||
proposed_data = _copy_data(data)
|
||||
result: dict[str, Any]
|
||||
if op == "create":
|
||||
if not config:
|
||||
raise ToolError("op=create requires config")
|
||||
# The generated id must be deterministic so the exact preview can be
|
||||
# confirmed in a second call without silently proposing another entry.
|
||||
generated_id = f"mcp_{hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()[:16]}"
|
||||
new_id = id or config.get("id") or generated_id
|
||||
if _find(proposed_data, structure, new_id) is not None:
|
||||
raise ToolError(f"{kind} '{new_id}' already exists")
|
||||
if structure == "list":
|
||||
proposed_data.append({"id": new_id, **{k: v for k, v in config.items() if k != "id"}})
|
||||
else:
|
||||
proposed_data[new_id] = config
|
||||
result = {
|
||||
"operation": "create",
|
||||
"id": new_id,
|
||||
"proposed_entry": _find(proposed_data, structure, new_id),
|
||||
}
|
||||
elif op == "update":
|
||||
if not id or not config:
|
||||
raise ToolError("op=update requires id and config")
|
||||
before = _find(proposed_data, structure, id)
|
||||
if before is None or not _replace(proposed_data, structure, id, config):
|
||||
raise ToolError(f"{kind} '{id}' not found")
|
||||
result = {"operation": "update", "id": id, "current_entry": before, "proposed_entry": _find(proposed_data, structure, id)}
|
||||
elif op == "delete":
|
||||
if not id:
|
||||
raise ToolError("op=delete requires id")
|
||||
before = _find(proposed_data, structure, id)
|
||||
if before is None or not _remove(proposed_data, structure, id):
|
||||
raise ToolError(f"{kind} '{id}' not found")
|
||||
result = {"operation": "delete", "id": id, "current_entry": before}
|
||||
else:
|
||||
raise ToolError(f"unsupported op '{op}'")
|
||||
|
||||
change = _change_record(kind, op, current_text, {"id": id, "config": config, "result": result})
|
||||
if not confirm:
|
||||
return _preview(change, extra=result)
|
||||
_consume_ticket(change, approval_ticket)
|
||||
committed = await _commit_data(hass, path, filename, structure, reload_domain, current_text, proposed_data)
|
||||
return {**result, **committed}
|
||||
|
||||
|
||||
def _copy_data(data: Any) -> Any:
|
||||
return copy.deepcopy(data)
|
||||
|
||||
|
||||
def _fingerprint(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _change_record(kind: str, op: str, current: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": kind,
|
||||
"op": op,
|
||||
"current_sha256": _fingerprint(current),
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
|
||||
def _new_ticket(change: dict[str, Any]) -> str:
|
||||
now = time.time()
|
||||
for key, value in list(_PENDING.items()):
|
||||
if value["expires_at"] <= now:
|
||||
_PENDING.pop(key, None)
|
||||
ticket = secrets.token_urlsafe(18)
|
||||
_PENDING[ticket] = {
|
||||
"fingerprint": _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":"))),
|
||||
"expires_at": now + _TICKET_TTL_SECONDS,
|
||||
}
|
||||
return ticket
|
||||
|
||||
|
||||
def _consume_ticket(change: dict[str, Any], ticket: str | None) -> None:
|
||||
record = _PENDING.pop(ticket, None) if ticket else None
|
||||
expected = _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":")))
|
||||
if not record or record["expires_at"] <= time.time() or record["fingerprint"] != expected:
|
||||
raise ToolError(
|
||||
"approval_ticket is missing, expired, already used, or does not match the exact "
|
||||
"change/current file. Run the same operation without confirm, show the preview, "
|
||||
"then repeat unchanged with confirm=true only after explicit user approval."
|
||||
)
|
||||
|
||||
|
||||
def _preview(change: dict[str, Any], diff: list[str] | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"changed": False,
|
||||
"confirmation_required": True,
|
||||
"approval_ticket": _new_ticket(change),
|
||||
"ticket_expires_in_seconds": _TICKET_TTL_SECONDS,
|
||||
"current_sha256": change["current_sha256"],
|
||||
**(extra or {}),
|
||||
**({"diff": diff, "diff_truncated": len(diff) >= 120} if diff is not None else {}),
|
||||
"model_instruction": (
|
||||
"This is a preview only. Show it to the user and stop. Do not claim anything was "
|
||||
"changed. After explicit approval repeat the exact call with confirm=true and approval_ticket."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _redact_line(line: str) -> str:
|
||||
match = _SENSITIVE_LINE.match(line)
|
||||
if match:
|
||||
return f"{match.group('prefix')}<redacted>"
|
||||
return _SECRET_REFERENCE.sub("!secret <redacted-reference>", line)
|
||||
|
||||
|
||||
def _reject_sensitive_replacement(*values: str) -> None:
|
||||
for value in values:
|
||||
if any(_SENSITIVE_LINE.match(line) for line in value.splitlines()) or _SECRET_REFERENCE.search(value):
|
||||
raise ToolError(
|
||||
"Raw replacement containing credential-like keys or !secret references is refused. "
|
||||
"Edit that material locally outside the LLM context."
|
||||
)
|
||||
|
||||
|
||||
async def _read_text(hass: HomeAssistant, path: Path) -> str:
|
||||
def _read() -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
if path.stat().st_size > _MAX_SOURCE_BYTES:
|
||||
raise ToolError(f"{path.name} exceeds the {_MAX_SOURCE_BYTES} byte safety limit")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
return await hass.async_add_executor_job(_read)
|
||||
|
||||
|
||||
async def _read_source(hass: HomeAssistant, path: Path, start_line: int, max_lines: int) -> dict[str, Any]:
|
||||
text = await _read_text(hass, path)
|
||||
lines = text.splitlines()
|
||||
start = max(1, start_line)
|
||||
count = max(1, min(max_lines, 400))
|
||||
selected = lines[start - 1 : start - 1 + count]
|
||||
return {
|
||||
"file": path.name,
|
||||
"sha256": _fingerprint(text),
|
||||
"total_lines": len(lines),
|
||||
"start_line": start,
|
||||
"returned_lines": len(selected),
|
||||
"has_more": start - 1 + len(selected) < len(lines),
|
||||
"lines": [{"line": start + index, "text": _redact_line(line)} for index, line in enumerate(selected)],
|
||||
"redaction_note": "Credential-like values and !secret reference names are redacted.",
|
||||
}
|
||||
|
||||
|
||||
async def _find_source(hass: HomeAssistant, path: Path, query: str, max_lines: int) -> dict[str, Any]:
|
||||
text = await _read_text(hass, path)
|
||||
lines = text.splitlines()
|
||||
hits = [index for index, line in enumerate(lines) if query.casefold() in line.casefold()]
|
||||
cap = max(1, min(max_lines, 400))
|
||||
selected = hits[:cap]
|
||||
return {
|
||||
"file": path.name,
|
||||
"sha256": _fingerprint(text),
|
||||
"authoritative_match_count": len(hits),
|
||||
"returned_count": len(selected),
|
||||
"has_more": len(hits) > len(selected),
|
||||
"matches": [{"line": index + 1, "text": _redact_line(lines[index])} for index in selected],
|
||||
"redaction_note": "Credential-like values and !secret reference names are redacted.",
|
||||
}
|
||||
|
||||
|
||||
def _extract_commented_blocks(text: str, max_lines: int) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Return top-level YAML list entries whose every source line is commented.
|
||||
|
||||
This intentionally recognizes only the conservative ``# - id:`` form used
|
||||
by Home Assistant's automations/scenes editor. Ordinary prose comments,
|
||||
partially disabled entries and nested comments are not treated as entries.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
start_pattern = re.compile(r"^\s*#\s*-\s+id\s*:\s*(.*?)\s*$", re.IGNORECASE)
|
||||
alias_pattern = re.compile(r"^\s*#\s+alias\s*:\s*(.*?)\s*$", re.IGNORECASE)
|
||||
blocks: list[dict[str, Any]] = []
|
||||
consumed = 0
|
||||
index = 0
|
||||
truncated = False
|
||||
|
||||
def clean_scalar(value: str) -> str:
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
while index < len(lines):
|
||||
match = start_pattern.match(lines[index])
|
||||
if not match:
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
block_lines = [lines[index]]
|
||||
index += 1
|
||||
while index < len(lines):
|
||||
if start_pattern.match(lines[index]):
|
||||
break
|
||||
if not lines[index].strip() or not re.match(r"^\s*#", lines[index]):
|
||||
break
|
||||
block_lines.append(lines[index])
|
||||
index += 1
|
||||
if consumed + len(block_lines) > max_lines:
|
||||
truncated = True
|
||||
break
|
||||
alias = None
|
||||
for line in block_lines:
|
||||
alias_match = alias_pattern.match(line)
|
||||
if alias_match:
|
||||
alias = clean_scalar(alias_match.group(1))
|
||||
break
|
||||
blocks.append(
|
||||
{
|
||||
"start_line": start + 1,
|
||||
"end_line": start + len(block_lines),
|
||||
"id": clean_scalar(match.group(1)),
|
||||
"alias": alias,
|
||||
"source": [
|
||||
{"line": start + offset + 1, "text": _redact_line(line)}
|
||||
for offset, line in enumerate(block_lines)
|
||||
],
|
||||
}
|
||||
)
|
||||
consumed += len(block_lines)
|
||||
return blocks, truncated
|
||||
|
||||
|
||||
async def _find_commented_blocks(
|
||||
hass: HomeAssistant, path: Path, max_lines: int
|
||||
) -> dict[str, Any]:
|
||||
text = await _read_text(hass, path)
|
||||
cap = max(1, min(max_lines, 400))
|
||||
blocks, truncated = _extract_commented_blocks(text, cap)
|
||||
return {
|
||||
"file": path.name,
|
||||
"sha256": _fingerprint(text),
|
||||
"authoritative_block_count": len(blocks) if not truncated else None,
|
||||
"returned_block_count": len(blocks),
|
||||
"returned_source_lines": sum(len(block["source"]) for block in blocks),
|
||||
"has_more": truncated,
|
||||
"blocks": blocks,
|
||||
"recognition_rule": "Only fully commented top-level '# - id:' YAML list entries are returned.",
|
||||
"redaction_note": "Credential-like values and !secret reference names are redacted.",
|
||||
}
|
||||
|
||||
|
||||
def _source_diff(filename: str, before: str, after: str) -> list[str]:
|
||||
return list(
|
||||
difflib.unified_diff(
|
||||
before.splitlines(),
|
||||
after.splitlines(),
|
||||
fromfile=f"{filename}:before",
|
||||
tofile=f"{filename}:after",
|
||||
lineterm="",
|
||||
n=3,
|
||||
)
|
||||
)[:120]
|
||||
|
||||
|
||||
def _backup_dir(hass: HomeAssistant) -> Path:
|
||||
return Path(hass.config.path(".hass_mcp_backups", "yaml"))
|
||||
|
||||
|
||||
async def _create_backup(hass: HomeAssistant, filename: str, content: str) -> str:
|
||||
backup_id = f"{filename}.{time.strftime('%Y%m%d-%H%M%S')}.{_fingerprint(content)[:10]}.bak"
|
||||
directory = _backup_dir(hass)
|
||||
|
||||
def _write() -> None:
|
||||
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target = directory / backup_id
|
||||
target.write_text(content, encoding="utf-8")
|
||||
target.chmod(0o600)
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
return backup_id
|
||||
|
||||
|
||||
async def _list_backups(hass: HomeAssistant, filename: str, limit: int, offset: int) -> dict[str, Any]:
|
||||
directory = _backup_dir(hass)
|
||||
|
||||
def _list() -> list[dict[str, Any]]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
rows = []
|
||||
for path in directory.glob(f"{filename}.*.bak"):
|
||||
stat = path.stat()
|
||||
rows.append({"backup_id": path.name, "size": stat.st_size, "created_unix": int(stat.st_mtime)})
|
||||
return sorted(rows, key=lambda row: row["created_unix"], reverse=True)
|
||||
|
||||
return paginate(await hass.async_add_executor_job(_list), limit, offset)
|
||||
|
||||
|
||||
async def _read_backup(hass: HomeAssistant, filename: str, backup_id: str) -> str:
|
||||
if Path(backup_id).name != backup_id or not backup_id.startswith(f"{filename}.") or not backup_id.endswith(".bak"):
|
||||
raise ToolError("backup_id is not valid for this YAML kind")
|
||||
path = _backup_dir(hass) / backup_id
|
||||
|
||||
def _read() -> str:
|
||||
if not path.is_file():
|
||||
raise ToolError("backup_id not found")
|
||||
if path.stat().st_size > _MAX_SOURCE_BYTES:
|
||||
raise ToolError("backup exceeds safety limit")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
return await hass.async_add_executor_job(_read)
|
||||
|
||||
|
||||
async def _validate_yaml_text(hass: HomeAssistant, content: str, structure: str, filename: str) -> Any:
|
||||
from homeassistant.util.yaml import parse_yaml
|
||||
|
||||
def _parse() -> Any:
|
||||
parsed = parse_yaml(content) if content.strip() else ([] if structure == "list" else {})
|
||||
if structure == "list" and not isinstance(parsed, list):
|
||||
raise ToolError(f"{filename} must be a YAML list, got {type(parsed).__name__}")
|
||||
if structure == "dict" and not isinstance(parsed, dict):
|
||||
raise ToolError(f"{filename} must be a YAML mapping, got {type(parsed).__name__}")
|
||||
return parsed
|
||||
|
||||
return await hass.async_add_executor_job(_parse)
|
||||
|
||||
|
||||
async def _check_full_config(hass: HomeAssistant) -> dict[str, Any]:
|
||||
try:
|
||||
from homeassistant.components.config.core import async_check_ha_config_file
|
||||
except ImportError:
|
||||
from homeassistant.config import async_check_ha_config_file
|
||||
|
||||
result = await async_check_ha_config_file(hass)
|
||||
if result is None:
|
||||
return {"valid": True}
|
||||
if isinstance(result, str):
|
||||
return {"valid": not bool(result), "error": result or None}
|
||||
errors = getattr(result, "errors", None)
|
||||
if errors:
|
||||
return {"valid": False, "error": str(errors)}
|
||||
return {"valid": True, "result": str(result)}
|
||||
|
||||
|
||||
async def _atomic_write(hass: HomeAssistant, path: Path, content: str) -> None:
|
||||
def _write() -> None:
|
||||
temporary = path.with_name(f".{path.name}.hass-mcp-{secrets.token_hex(6)}.tmp")
|
||||
try:
|
||||
with temporary.open("w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
|
||||
|
||||
async def _commit_text(
|
||||
hass: HomeAssistant,
|
||||
path: Path,
|
||||
filename: str,
|
||||
structure: str,
|
||||
reload_domain: str | None,
|
||||
before: str,
|
||||
proposed: str,
|
||||
) -> dict[str, Any]:
|
||||
current = await _read_text(hass, path)
|
||||
if _fingerprint(current) != _fingerprint(before):
|
||||
raise ToolError("YAML file changed after preview; refusing stale write and requiring a new preview")
|
||||
await _validate_yaml_text(hass, proposed, structure, filename)
|
||||
backup_id = await _create_backup(hass, filename, before)
|
||||
await _atomic_write(hass, path, proposed)
|
||||
validation = await _check_full_config(hass)
|
||||
if not validation["valid"]:
|
||||
await _atomic_write(hass, path, before)
|
||||
raise ToolError(f"Home Assistant config validation failed; original restored from {backup_id}: {validation.get('error')}")
|
||||
try:
|
||||
if reload_domain:
|
||||
await _reload(hass, reload_domain)
|
||||
except Exception:
|
||||
await _atomic_write(hass, path, before)
|
||||
if reload_domain:
|
||||
try:
|
||||
await _reload(hass, reload_domain)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
readback = await _read_text(hass, path)
|
||||
return {
|
||||
"changed": readback == proposed,
|
||||
"file": filename,
|
||||
"backup_id": backup_id,
|
||||
"full_config_valid": True,
|
||||
"reloaded": reload_domain,
|
||||
"restart_required": reload_domain is None,
|
||||
"new_sha256": _fingerprint(readback),
|
||||
"exact_readback_match": readback == proposed,
|
||||
}
|
||||
|
||||
|
||||
async def _commit_data(
|
||||
hass: HomeAssistant,
|
||||
path: Path,
|
||||
filename: str,
|
||||
structure: str,
|
||||
reload_domain: str | None,
|
||||
before: str,
|
||||
data: Any,
|
||||
) -> dict[str, Any]:
|
||||
from homeassistant.util.yaml import save_yaml
|
||||
|
||||
def _render() -> str:
|
||||
temporary = path.with_name(f".{path.name}.hass-mcp-render-{secrets.token_hex(6)}.tmp")
|
||||
try:
|
||||
save_yaml(str(temporary), data)
|
||||
return temporary.read_text(encoding="utf-8")
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
proposed = await hass.async_add_executor_job(_render)
|
||||
return await _commit_text(hass, path, filename, structure, reload_domain, before, proposed)
|
||||
|
||||
|
||||
async def _load(hass: HomeAssistant, path: Path, structure: str) -> Any:
|
||||
return await _validate_yaml_text(hass, await _read_text(hass, path), structure, path.name)
|
||||
|
||||
|
||||
async def _reload(hass: HomeAssistant, domain: str | None) -> None:
|
||||
if not domain:
|
||||
return
|
||||
try:
|
||||
await hass.services.async_call(domain, "reload", {}, blocking=True, context=user_context())
|
||||
except Exception as error:
|
||||
raise internal_error(f"{domain}.reload failed", error) from error
|
||||
|
||||
|
||||
def _derive_entity_id(domain: str, structure: str, new_id: str, config: dict[str, Any]) -> str:
|
||||
slug = slugify(new_id) if structure == "dict" else slugify(config.get("alias") or new_id)
|
||||
return f"{domain}.{slug}"
|
||||
|
||||
|
||||
def _to_list(data: Any, structure: str) -> list[dict[str, Any]]:
|
||||
if structure == "list":
|
||||
return list(data)
|
||||
return [{"id": key, **value} for key, value in data.items()]
|
||||
|
||||
|
||||
def _find(data: Any, structure: str, id: str) -> dict[str, Any] | None:
|
||||
if structure == "list":
|
||||
for entry in data:
|
||||
if entry.get("id") == id or entry.get("alias") == id:
|
||||
return entry
|
||||
return None
|
||||
return {"id": id, **data[id]} if id in data else None
|
||||
|
||||
|
||||
def _replace(data: Any, structure: str, id: str, new: dict[str, Any]) -> bool:
|
||||
if structure == "list":
|
||||
for index, entry in enumerate(data):
|
||||
if entry.get("id") == id:
|
||||
data[index] = {"id": id, **{key: value for key, value in new.items() if key != "id"}}
|
||||
return True
|
||||
return False
|
||||
if id in data:
|
||||
data[id] = new
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _remove(data: Any, structure: str, id: str) -> bool:
|
||||
if structure == "list":
|
||||
for index, entry in enumerate(data):
|
||||
if entry.get("id") == id:
|
||||
del data[index]
|
||||
return True
|
||||
return False
|
||||
if id in data:
|
||||
del data[id]
|
||||
return True
|
||||
return False
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Small, explicit, read-only Radarr tools built on the upstream API client."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from arr_mcp.auth import get_radarr_client
|
||||
|
||||
|
||||
async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any:
|
||||
"""Call one known upstream API method without exposing dynamic dispatch."""
|
||||
return await asyncio.to_thread(getattr(client, action), **(kwargs or {}))
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict") and callable(value.dict):
|
||||
return value.dict()
|
||||
return value
|
||||
|
||||
|
||||
def _movies(value: Any) -> list[dict[str, Any]]:
|
||||
value = _plain(value)
|
||||
if isinstance(value, dict) and "result" in value:
|
||||
value = value["result"]
|
||||
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _compact_movie(movie: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: movie[key]
|
||||
for key in ("id", "title", "originalTitle", "year", "status", "monitored", "hasFile", "path", "tmdbId")
|
||||
if movie.get(key) is not None
|
||||
}
|
||||
|
||||
|
||||
def _compact_release(item: dict[str, Any]) -> dict[str, Any]:
|
||||
quality = item.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
result = {
|
||||
key: item[key]
|
||||
for key in ("guid", "title", "indexer", "indexerId", "size", "age", "seeders", "leechers", "protocol", "downloadAllowed", "releaseGroup")
|
||||
if item.get(key) is not None
|
||||
}
|
||||
if quality_name:
|
||||
result["quality"] = quality_name
|
||||
if isinstance(item.get("rejections"), list) and item["rejections"]:
|
||||
result["rejections"] = [str(reason)[:180] for reason in item["rejections"][:5]]
|
||||
return result
|
||||
|
||||
|
||||
def _codec_aliases(value: str) -> set[str]:
|
||||
aliases = {
|
||||
"h264": {"h264", "x264", "avc"},
|
||||
"x264": {"h264", "x264", "avc"},
|
||||
"avc": {"h264", "x264", "avc"},
|
||||
"h265": {"h265", "x265", "hevc"},
|
||||
"x265": {"h265", "x265", "hevc"},
|
||||
"hevc": {"h265", "x265", "hevc"},
|
||||
}
|
||||
requested: set[str] = set()
|
||||
for item in value.split(","):
|
||||
key = item.strip().casefold()
|
||||
if key:
|
||||
requested.update(aliases.get(key, {key}))
|
||||
return requested
|
||||
|
||||
|
||||
def _compact_inventory(
|
||||
movies: list[dict[str, Any]], *, codecs: str = "", query: str = "",
|
||||
offset: int = 0, limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
wanted = _codec_aliases(codecs)
|
||||
needle = query.strip().casefold()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for movie in movies:
|
||||
movie_file = movie.get("movieFile") or {}
|
||||
if not movie.get("hasFile") or not movie_file:
|
||||
continue
|
||||
media = movie_file.get("mediaInfo") or {}
|
||||
codec = str(media.get("videoCodec") or "unknown")
|
||||
if wanted and codec.casefold() not in wanted:
|
||||
continue
|
||||
title = str(movie.get("title") or "")
|
||||
if needle and needle not in title.casefold():
|
||||
continue
|
||||
quality = movie_file.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
size = int(movie_file.get("size") or 0)
|
||||
rows.append({
|
||||
"radarrId": movie.get("id"),
|
||||
"title": title,
|
||||
"year": movie.get("year"),
|
||||
"movieFileId": movie_file.get("id"),
|
||||
"relativePath": movie_file.get("relativePath"),
|
||||
"sizeBytes": size,
|
||||
"sizeGiB": round(size / 1073741824, 2),
|
||||
"quality": quality_name,
|
||||
"resolution": media.get("resolution"),
|
||||
"videoCodec": codec,
|
||||
"videoBitDepth": media.get("videoBitDepth"),
|
||||
"audioCodec": media.get("audioCodec"),
|
||||
"audioLanguages": media.get("audioLanguages"),
|
||||
"subtitles": media.get("subtitles"),
|
||||
})
|
||||
rows.sort(key=lambda row: (str(row["title"]).casefold(), row.get("year") or 0))
|
||||
total = len(rows)
|
||||
start = max(0, int(offset))
|
||||
count = min(500, max(1, int(limit)))
|
||||
selected = rows[start:start + count]
|
||||
return {
|
||||
"totalMatched": total,
|
||||
"offset": start,
|
||||
"returned": len(selected),
|
||||
"hasMore": start + len(selected) < total,
|
||||
"movies": selected,
|
||||
}
|
||||
|
||||
|
||||
def register_radarr_tools(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_find_movie(
|
||||
query: str = Field(description="Movie title or title fragment."),
|
||||
limit: int = Field(default=10, ge=1, le=25),
|
||||
) -> Any:
|
||||
"""Find a movie already managed by Radarr. READ ONLY. Never starts a search or download."""
|
||||
needle = query.strip().casefold()
|
||||
if len(needle) < 2:
|
||||
raise ValueError("query must contain at least two characters")
|
||||
movies = _movies(await _call(get_radarr_client(), "get_movie"))
|
||||
matches = [
|
||||
_compact_movie(movie)
|
||||
for movie in movies
|
||||
if needle in " ".join(str(movie.get(key, "")) for key in ("title", "originalTitle", "sortTitle")).casefold()
|
||||
]
|
||||
return {"query": query, "match_count": len(matches), "matches": matches[:limit], "truncated": len(matches) > limit}
|
||||
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_movie_codec_inventory(
|
||||
video_codecs: str = Field(
|
||||
default="",
|
||||
description="Optional comma-separated filter, e.g. h264, x264, h265, x265 or hevc.",
|
||||
),
|
||||
query: str = Field(default="", description="Optional case-insensitive title fragment."),
|
||||
offset: int = Field(default=0, ge=0),
|
||||
limit: int = Field(default=200, ge=1, le=500),
|
||||
) -> Any:
|
||||
"""Compact authoritative Radarr movie-file inventory. Use for codec, resolution, language and size questions instead of get_movie, raw API requests or filesystem scans. Results are valid bounded JSON without alternate titles, images, overviews or ratings."""
|
||||
client = get_radarr_client()
|
||||
response = _plain(await _call(client, "get_movie"))
|
||||
movies = _movies(response)
|
||||
if not movies and response not in ([], {"result": []}):
|
||||
raise RuntimeError("Radarr get_movie returned an unexpected response")
|
||||
return _compact_inventory(
|
||||
movies, codecs=video_codecs, query=query, offset=offset, limit=limit,
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"radarr"})
|
||||
async def radarr_search_releases(
|
||||
movie_id: int = Field(ge=1, description="Exact Radarr movie id returned by radarr_find_movie."),
|
||||
release_group: str = Field(default="", description="Optional release-group filter."),
|
||||
limit: int = Field(default=50, ge=1, le=100),
|
||||
) -> Any:
|
||||
"""Search Radarr's configured indexers for one movie. READ ONLY: never grabs or downloads a release."""
|
||||
raw = _plain(await _call(get_radarr_client(), "get_release", {"movieId": movie_id}))
|
||||
if isinstance(raw, dict) and "result" in raw:
|
||||
raw = raw["result"]
|
||||
releases = [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
|
||||
needle = release_group.strip().casefold()
|
||||
if needle:
|
||||
releases = [
|
||||
item for item in releases
|
||||
if needle in (str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))).casefold()
|
||||
]
|
||||
compact = [_compact_release(item) for item in releases[:limit]]
|
||||
return {
|
||||
"movie_id": movie_id,
|
||||
"release_group_filter": release_group or None,
|
||||
"total": len(releases),
|
||||
"returned": len(compact),
|
||||
"truncated": len(releases) > limit,
|
||||
"results": compact,
|
||||
"download_started": False,
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
"""Sonarr condensed action-routed MCP tool.
|
||||
|
||||
CONCEPT:ECO-4.82 — gitlab-style organized per-service tool surface.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from arr_mcp.auth import get_sonarr_client
|
||||
|
||||
|
||||
MAX_COLLECTION_ITEMS = 50
|
||||
APPROVAL_TTL_SECONDS = 600
|
||||
_APPROVALS: dict[str, tuple[float, str]] = {}
|
||||
|
||||
|
||||
async def _call(client: Any, action: str, kwargs: dict[str, Any] | None = None) -> Any:
|
||||
"""Call one known upstream API method without exposing dynamic dispatch."""
|
||||
method = getattr(client, action)
|
||||
return await asyncio.to_thread(method, **(kwargs or {}))
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict") and callable(value.dict):
|
||||
return value.dict()
|
||||
if isinstance(value, list):
|
||||
return [_plain(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _plain(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _unwrap(value: Any) -> Any:
|
||||
value = _plain(value)
|
||||
if isinstance(value, dict) and set(value) == {"result"}:
|
||||
return value["result"]
|
||||
return value
|
||||
|
||||
|
||||
def _pick(item: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
return {field: item[field] for field in fields if item.get(field) is not None}
|
||||
|
||||
|
||||
def _compact_series(item: dict[str, Any], include_seasons: bool = False) -> dict[str, Any]:
|
||||
result = _pick(
|
||||
item,
|
||||
("id", "title", "sortTitle", "year", "status", "monitored", "path", "tvdbId"),
|
||||
)
|
||||
statistics = item.get("statistics") or {}
|
||||
if isinstance(statistics, dict):
|
||||
result["statistics"] = _pick(
|
||||
statistics,
|
||||
("seasonCount", "episodeFileCount", "episodeCount", "totalEpisodeCount", "sizeOnDisk", "percentOfEpisodes"),
|
||||
)
|
||||
if include_seasons:
|
||||
result["seasons"] = [
|
||||
{
|
||||
**_pick(season, ("seasonNumber", "monitored")),
|
||||
"statistics": _pick(
|
||||
season.get("statistics") or {},
|
||||
("episodeFileCount", "episodeCount", "totalEpisodeCount", "sizeOnDisk", "percentOfEpisodes"),
|
||||
),
|
||||
}
|
||||
for season in item.get("seasons", [])
|
||||
if isinstance(season, dict)
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _compact_episode(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return _pick(
|
||||
item,
|
||||
("id", "seriesId", "seasonNumber", "episodeNumber", "title", "airDate", "airDateUtc", "monitored", "hasFile", "episodeFileId"),
|
||||
)
|
||||
|
||||
|
||||
def _compact_file(item: dict[str, Any]) -> dict[str, Any]:
|
||||
quality = item.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
result = _pick(
|
||||
item,
|
||||
("id", "seriesId", "seasonNumber", "relativePath", "path", "size", "dateAdded", "releaseGroup"),
|
||||
)
|
||||
if quality_name:
|
||||
result["quality"] = quality_name
|
||||
return result
|
||||
|
||||
|
||||
def _compact_release(item: dict[str, Any]) -> dict[str, Any]:
|
||||
quality = item.get("quality") or {}
|
||||
quality_name = (quality.get("quality") or {}).get("name") if isinstance(quality, dict) else None
|
||||
result = _pick(
|
||||
item,
|
||||
(
|
||||
"guid", "title", "indexer", "indexerId", "size", "age", "ageHours",
|
||||
"seeders", "leechers", "protocol", "downloadAllowed", "releaseWeight",
|
||||
"releaseGroup", "seasonNumber", "fullSeason",
|
||||
),
|
||||
)
|
||||
if quality_name:
|
||||
result["quality"] = quality_name
|
||||
rejections = item.get("rejections")
|
||||
if isinstance(rejections, list) and rejections:
|
||||
result["rejections"] = [str(reason)[:180] for reason in rejections[:5]]
|
||||
return result
|
||||
|
||||
|
||||
def _bounded(items: list[Any], compact) -> dict[str, Any]:
|
||||
total = len(items)
|
||||
return {
|
||||
"total": total,
|
||||
"returned": min(total, MAX_COLLECTION_ITEMS),
|
||||
"truncated": total > MAX_COLLECTION_ITEMS,
|
||||
"items": [compact(item) for item in items[:MAX_COLLECTION_ITEMS] if isinstance(item, dict)],
|
||||
"next_step": (
|
||||
"Use find_series or narrower Sonarr parameters; do not repeat the same broad request."
|
||||
if total > MAX_COLLECTION_ITEMS else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _compact_result(action: str, value: Any) -> Any:
|
||||
value = _unwrap(value)
|
||||
if isinstance(value, list):
|
||||
if action in {"get_series", "get_series_lookup", "lookup_series"}:
|
||||
return _bounded(value, _compact_series)
|
||||
if action in {"get_episode", "get_calendar", "get_wanted_missing", "get_wanted_cutoff"}:
|
||||
return _bounded(value, _compact_episode)
|
||||
if action == "get_episodefile":
|
||||
return _bounded(value, _compact_file)
|
||||
if action == "get_release":
|
||||
return _bounded(value, _compact_release)
|
||||
return _bounded(value, lambda item: item)
|
||||
if isinstance(value, dict) and action in {"get_series_id"}:
|
||||
return _compact_series(value, include_seasons=True)
|
||||
if isinstance(value, dict) and action in {"get_episode_id"}:
|
||||
return _compact_episode(value)
|
||||
if isinstance(value, dict) and action in {"get_episodefile_id"}:
|
||||
return _compact_file(value)
|
||||
return value
|
||||
|
||||
|
||||
async def _find_series(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
query = str(kwargs.get("query", "")).strip()
|
||||
if len(query) < 2:
|
||||
raise ValueError("query must contain at least two characters")
|
||||
limit = max(1, min(int(kwargs.get("limit", 8)), 15))
|
||||
raw = _unwrap(await _call(client, "get_series"))
|
||||
words = [word for word in re.findall(r"[a-z0-9]+", query.casefold()) if len(word) > 1]
|
||||
matches = []
|
||||
for item in raw if isinstance(raw, list) else []:
|
||||
haystack = " ".join(
|
||||
str(item.get(field, "")) for field in ("title", "sortTitle", "originalTitle", "alternateTitles")
|
||||
).casefold()
|
||||
if all(word in haystack for word in words):
|
||||
matches.append(_compact_series(item, include_seasons=False))
|
||||
return {
|
||||
"query": query,
|
||||
"matches": matches[:limit],
|
||||
"match_count": len(matches),
|
||||
"truncated": len(matches) > limit,
|
||||
"task_complete": True,
|
||||
"instruction": "Use the returned series id for details. Do not call get_series for discovery.",
|
||||
}
|
||||
|
||||
|
||||
async def _season_summary(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
series_id = int(kwargs["series_id"])
|
||||
season_number = int(kwargs["season_number"])
|
||||
series = _unwrap(await _call(client, "get_series_id", {"id": series_id}))
|
||||
episodes = _unwrap(
|
||||
await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number})
|
||||
)
|
||||
files = _unwrap(
|
||||
await _call(client, "get_episodefile", {"seriesId": series_id})
|
||||
)
|
||||
selected_episodes = [
|
||||
_compact_episode(item) for item in episodes
|
||||
if isinstance(item, dict) and item.get("seasonNumber") == season_number
|
||||
] if isinstance(episodes, list) else []
|
||||
selected_files = [
|
||||
_compact_file(item) for item in files
|
||||
if isinstance(item, dict) and item.get("seasonNumber") == season_number
|
||||
] if isinstance(files, list) else []
|
||||
groups = sorted({str(item.get("releaseGroup")) for item in selected_files if item.get("releaseGroup")})
|
||||
return {
|
||||
"series": _compact_series(series) if isinstance(series, dict) else {"id": series_id},
|
||||
"season_number": season_number,
|
||||
"episode_count": len(selected_episodes),
|
||||
"file_count": len(selected_files),
|
||||
"release_groups": groups,
|
||||
"episodes": selected_episodes[:30],
|
||||
"files": selected_files[:30],
|
||||
"task_complete": True,
|
||||
"instruction": "This is the complete compact season answer. Do not repeat broad series or episode queries.",
|
||||
}
|
||||
|
||||
|
||||
async def _search_releases(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
series_id = kwargs.get("series_id")
|
||||
episode_id = kwargs.get("episode_id")
|
||||
season_number = kwargs.get("season_number")
|
||||
release_group = str(kwargs.get("release_group", "")).strip()
|
||||
season_pack_only = kwargs.get("season_pack_only") is True
|
||||
if series_id is None and episode_id is None:
|
||||
raise ValueError("search_releases requires series_id or episode_id")
|
||||
query: dict[str, Any] = {}
|
||||
if series_id is not None:
|
||||
query["seriesId"] = int(series_id)
|
||||
if episode_id is not None:
|
||||
query["episodeId"] = int(episode_id)
|
||||
if season_number is not None:
|
||||
query["seasonNumber"] = int(season_number)
|
||||
raw = await _call(client, "get_release", query)
|
||||
raw = _unwrap(raw)
|
||||
if release_group and isinstance(raw, list):
|
||||
needle = release_group.casefold()
|
||||
raw = [
|
||||
item for item in raw
|
||||
if isinstance(item, dict)
|
||||
and needle in (
|
||||
str(item.get("releaseGroup", "")) + " " + str(item.get("title", ""))
|
||||
).casefold()
|
||||
]
|
||||
if season_pack_only:
|
||||
if season_number is None:
|
||||
raise ValueError("season_pack_only=true requires season_number")
|
||||
season_token = rf"(?:^|[. _-])S0*{int(season_number)}(?:[. _-]|$)"
|
||||
episode_token = rf"S0*{int(season_number)}E\d+"
|
||||
raw = [
|
||||
item for item in raw
|
||||
if isinstance(item, dict)
|
||||
and (
|
||||
item.get("fullSeason") is True
|
||||
or (
|
||||
re.search(season_token, str(item.get("title", "")), re.IGNORECASE)
|
||||
and not re.search(episode_token, str(item.get("title", "")), re.IGNORECASE)
|
||||
)
|
||||
)
|
||||
] if isinstance(raw, list) else raw
|
||||
compact = _compact_result("get_release", raw)
|
||||
return {
|
||||
"task_complete": True,
|
||||
"search_scope": {
|
||||
"series_id": series_id,
|
||||
"episode_id": episode_id,
|
||||
"season_number": season_number,
|
||||
"release_group_filter": release_group or None,
|
||||
"season_pack_only": season_pack_only,
|
||||
},
|
||||
"monitoring_changed": False,
|
||||
"download_started": False,
|
||||
"results": compact,
|
||||
"instruction": (
|
||||
"These are Sonarr indexer results. Do not use web search to replace them. "
|
||||
"If the user requests one specific release, release group, or complete season pack, "
|
||||
"NEVER substitute an automatic episode search: preview that exact result with "
|
||||
"preview_release_grab, then wait for explicit approval before grab_release."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _episode_numbers(value: Any) -> list[int]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list) or len(value) > 100:
|
||||
raise ValueError("episode_numbers must be a JSON list with at most 100 entries")
|
||||
numbers = sorted({int(item) for item in value})
|
||||
if any(item < 0 or item > 9999 for item in numbers):
|
||||
raise ValueError("episode_numbers contains an invalid episode number")
|
||||
return numbers
|
||||
|
||||
|
||||
async def _resolve_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
series_id = int(kwargs["series_id"])
|
||||
season_number = int(kwargs["season_number"])
|
||||
requested_numbers = _episode_numbers(kwargs.get("episode_numbers"))
|
||||
if series_id < 1 or season_number < 0:
|
||||
raise ValueError("series_id and season_number must be non-negative identifiers")
|
||||
|
||||
series = _unwrap(await _call(client, "get_series_id", {"id": series_id}))
|
||||
episodes = _unwrap(
|
||||
await _call(client, "get_episode", {"seriesId": series_id, "seasonNumber": season_number})
|
||||
)
|
||||
candidates = [
|
||||
item for item in episodes
|
||||
if isinstance(item, dict)
|
||||
and int(item.get("seasonNumber", -1)) == season_number
|
||||
and (not requested_numbers or int(item.get("episodeNumber", -1)) in requested_numbers)
|
||||
] if isinstance(episodes, list) else []
|
||||
if requested_numbers:
|
||||
found_numbers = {int(item.get("episodeNumber", -1)) for item in candidates}
|
||||
missing_metadata = sorted(set(requested_numbers) - found_numbers)
|
||||
if missing_metadata:
|
||||
raise ValueError(f"Sonarr has no episode metadata for episode numbers: {missing_metadata}")
|
||||
missing = [item for item in candidates if not bool(item.get("hasFile"))]
|
||||
if not candidates:
|
||||
raise ValueError("No Sonarr episodes match the requested scope")
|
||||
if len(missing) > 100:
|
||||
raise ValueError("Refusing to search more than 100 missing episodes at once")
|
||||
|
||||
compact = [_compact_episode(item) for item in missing]
|
||||
scope = {
|
||||
"series_id": series_id,
|
||||
"series_title": str(series.get("title", "")) if isinstance(series, dict) else "",
|
||||
"season_number": season_number,
|
||||
"requested_episode_numbers": requested_numbers,
|
||||
"missing_episode_ids": [int(item["id"]) for item in missing],
|
||||
"missing_episode_numbers": [int(item["episodeNumber"]) for item in missing],
|
||||
}
|
||||
fingerprint = json.dumps(scope, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return {
|
||||
"scope": scope,
|
||||
"fingerprint": fingerprint,
|
||||
"selected_episode_count": len(candidates),
|
||||
"already_present_count": len(candidates) - len(missing),
|
||||
"unmonitored_missing_count": sum(not bool(item.get("monitored")) for item in missing),
|
||||
"missing_episodes": compact,
|
||||
}
|
||||
|
||||
|
||||
async def _preview_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
resolved = await _resolve_episode_search(client, kwargs)
|
||||
ticket = secrets.token_urlsafe(24)
|
||||
now = time.monotonic()
|
||||
for old_ticket, (expires, _) in list(_APPROVALS.items()):
|
||||
if expires <= now:
|
||||
_APPROVALS.pop(old_ticket, None)
|
||||
_APPROVALS[ticket] = (now + APPROVAL_TTL_SECONDS, resolved["fingerprint"])
|
||||
return {
|
||||
"action": "preview-only",
|
||||
**{key: value for key, value in resolved.items() if key != "fingerprint"},
|
||||
"monitoring_changed": False,
|
||||
"download_started": False,
|
||||
"approval_ticket": ticket,
|
||||
"approval_expires_in_seconds": APPROVAL_TTL_SECONDS,
|
||||
"next_step": (
|
||||
"Review series, season and episode list. Only after explicit approval call "
|
||||
"start_episode_search with exactly the same scope, confirm=true and this ticket."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _start_episode_search(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
if kwargs.get("confirm") is not True:
|
||||
raise PermissionError("confirm=true is required after reviewing preview_episode_search")
|
||||
ticket = str(kwargs.get("approval_ticket", ""))
|
||||
if not ticket:
|
||||
raise PermissionError("approval_ticket is required")
|
||||
resolved = await _resolve_episode_search(client, kwargs)
|
||||
approval = _APPROVALS.pop(ticket, None)
|
||||
if approval is None or approval[0] <= time.monotonic():
|
||||
raise PermissionError("Approval ticket is missing, expired or already used")
|
||||
if not secrets.compare_digest(approval[1], resolved["fingerprint"]):
|
||||
raise PermissionError("Approval ticket does not match this exact episode search")
|
||||
episode_ids = resolved["scope"]["missing_episode_ids"]
|
||||
if not episode_ids:
|
||||
return {
|
||||
"ok": True,
|
||||
"command_started": False,
|
||||
"reason": "All selected episodes already have files",
|
||||
"scope": resolved["scope"],
|
||||
}
|
||||
command = _unwrap(
|
||||
await _call(client, "post_command", {"data": {"name": "EpisodeSearch", "episodeIds": episode_ids}})
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"command_started": True,
|
||||
"sonarr_command": _pick(command, ("id", "name", "status", "queued", "startedOn")) if isinstance(command, dict) else command,
|
||||
"scope": resolved["scope"],
|
||||
"monitoring_changed": False,
|
||||
"download_may_start_immediately": True,
|
||||
"instruction": (
|
||||
"Sonarr is now searching its configured indexers and may immediately grab/download "
|
||||
"the best acceptable release for every approved episode. EpisodeSearch is NOT a "
|
||||
"read-only manual-search preview and is NOT limited by the monitored flag. Check the "
|
||||
"queue before claiming that nothing was downloaded."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _release_query(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
series_id = int(kwargs["series_id"])
|
||||
if series_id < 1:
|
||||
raise ValueError("series_id must be a positive Sonarr identifier")
|
||||
query: dict[str, Any] = {"seriesId": series_id}
|
||||
if kwargs.get("season_number") is not None:
|
||||
season_number = int(kwargs["season_number"])
|
||||
if season_number < 0:
|
||||
raise ValueError("season_number must be non-negative")
|
||||
query["seasonNumber"] = season_number
|
||||
if kwargs.get("episode_id") is not None:
|
||||
episode_id = int(kwargs["episode_id"])
|
||||
if episode_id < 1:
|
||||
raise ValueError("episode_id must be a positive Sonarr identifier")
|
||||
query["episodeId"] = episode_id
|
||||
return query
|
||||
|
||||
|
||||
async def _resolve_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
guid = str(kwargs.get("guid", "")).strip()
|
||||
if not guid:
|
||||
raise ValueError(
|
||||
"guid is required; copy it from the exact search_releases result the user selected"
|
||||
)
|
||||
query = _release_query(kwargs)
|
||||
raw = _unwrap(
|
||||
await _call(client, "get_release", query)
|
||||
)
|
||||
matches = [
|
||||
item for item in raw if isinstance(item, dict) and str(item.get("guid", "")) == guid
|
||||
] if isinstance(raw, list) else []
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
"The exact release GUID is no longer present in Sonarr's current indexer results; "
|
||||
"run search_releases again and do not guess or substitute another release"
|
||||
)
|
||||
release = matches[0]
|
||||
compact = _compact_release(release)
|
||||
rejections = compact.get("rejections") or []
|
||||
blocked_without_force = release.get("downloadAllowed") is False or bool(rejections)
|
||||
force = kwargs.get("force") is True
|
||||
|
||||
existing_file_count = None
|
||||
season_number = query.get("seasonNumber")
|
||||
if season_number is not None:
|
||||
episodes = _unwrap(
|
||||
await _call(client, "get_episode", {"seriesId": query["seriesId"], "seasonNumber": season_number})
|
||||
)
|
||||
if isinstance(episodes, list):
|
||||
existing_file_count = sum(
|
||||
bool(item.get("hasFile")) for item in episodes if isinstance(item, dict)
|
||||
)
|
||||
|
||||
stable_release = _pick(
|
||||
release,
|
||||
(
|
||||
"guid", "title", "indexer", "indexerId", "size", "protocol",
|
||||
"downloadAllowed", "releaseGroup", "seasonNumber", "fullSeason",
|
||||
),
|
||||
)
|
||||
raw_rejections = release.get("rejections")
|
||||
stable_release["rejections"] = (
|
||||
[str(reason) for reason in raw_rejections]
|
||||
if isinstance(raw_rejections, list)
|
||||
else []
|
||||
)
|
||||
scope = {
|
||||
"series_id": query["seriesId"],
|
||||
"season_number": query.get("seasonNumber"),
|
||||
"episode_id": query.get("episodeId"),
|
||||
"guid": guid,
|
||||
"force": force,
|
||||
}
|
||||
fingerprint = json.dumps(
|
||||
{"scope": scope, "release": stable_release},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return {
|
||||
"scope": scope,
|
||||
"fingerprint": fingerprint,
|
||||
"release": compact,
|
||||
"raw_release": release,
|
||||
"existing_episode_files_in_season": existing_file_count,
|
||||
"blocked_without_force": blocked_without_force,
|
||||
}
|
||||
|
||||
|
||||
async def _preview_release_grab(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
resolved = await _resolve_release_grab(client, kwargs)
|
||||
ticket = secrets.token_urlsafe(24)
|
||||
now = time.monotonic()
|
||||
for old_ticket, (expires, _) in list(_APPROVALS.items()):
|
||||
if expires <= now:
|
||||
_APPROVALS.pop(old_ticket, None)
|
||||
force_required = resolved["blocked_without_force"] and not resolved["scope"]["force"]
|
||||
if not force_required:
|
||||
_APPROVALS[ticket] = (now + APPROVAL_TTL_SECONDS, resolved["fingerprint"])
|
||||
return {
|
||||
"action": "preview-only",
|
||||
"scope": resolved["scope"],
|
||||
"release": resolved["release"],
|
||||
"existing_episode_files_in_season": resolved["existing_episode_files_in_season"],
|
||||
"monitoring_changed": False,
|
||||
"download_started": False,
|
||||
"existing_files_deleted": False,
|
||||
"replacement_guaranteed": False,
|
||||
"warning": (
|
||||
"Grabbing a season pack does not itself delete or guarantee replacement of existing "
|
||||
"episode files. Sonarr applies its import, quality-profile and upgrade rules after download."
|
||||
),
|
||||
"force_required": force_required,
|
||||
"approval_ticket": None if force_required else ticket,
|
||||
"approval_expires_in_seconds": None if force_required else APPROVAL_TTL_SECONDS,
|
||||
"next_step": (
|
||||
"This result has Sonarr rejections or downloadAllowed=false. Explain the rejections and "
|
||||
"only after the user explicitly accepts them call preview_release_grab again with force=true."
|
||||
if force_required else
|
||||
"Show the exact title, size, indexer, rejections and overwrite warning. Only after explicit "
|
||||
"approval call grab_release with exactly the same scope, confirm=true and this ticket."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _grab_release(client: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
if kwargs.get("confirm") is not True:
|
||||
raise PermissionError("confirm=true is required after reviewing preview_release_grab")
|
||||
ticket = str(kwargs.get("approval_ticket", ""))
|
||||
if not ticket:
|
||||
raise PermissionError("approval_ticket is required")
|
||||
resolved = await _resolve_release_grab(client, kwargs)
|
||||
if resolved["blocked_without_force"] and not resolved["scope"]["force"]:
|
||||
raise PermissionError(
|
||||
"This release has Sonarr rejections or downloadAllowed=false; an explicitly approved "
|
||||
"force=true preview is required"
|
||||
)
|
||||
approval = _APPROVALS.pop(ticket, None)
|
||||
if approval is None or approval[0] <= time.monotonic():
|
||||
raise PermissionError("Approval ticket is missing, expired or already used")
|
||||
if not secrets.compare_digest(approval[1], resolved["fingerprint"]):
|
||||
raise PermissionError("Approval ticket does not match this exact release grab")
|
||||
result = _unwrap(
|
||||
await _call(client, "post_release", {"data": resolved["raw_release"]})
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"download_started": True,
|
||||
"selected_release": resolved["release"],
|
||||
"sonarr_result": _compact_release(result) if isinstance(result, dict) else result,
|
||||
"monitoring_changed": False,
|
||||
"existing_files_deleted": False,
|
||||
"replacement_guaranteed": False,
|
||||
"instruction": (
|
||||
"The exact approved release was sent to Sonarr's configured download client. "
|
||||
"Do not claim that an existing episode was overwritten; verify queue/import history later."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def register_sonarr_tools(mcp: FastMCP) -> None:
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_find_series(
|
||||
query: str = Field(description="Series title or title fragment, for example Mord ist ihr Hobby."),
|
||||
limit: int = Field(default=8, ge=1, le=15),
|
||||
) -> Any:
|
||||
"""Find a Sonarr series by name. READ ONLY. Never changes monitoring and never starts a search or download."""
|
||||
client = get_sonarr_client()
|
||||
return await _find_series(client, {"query": query, "limit": limit})
|
||||
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_get_season_summary(
|
||||
series_id: int = Field(description="Exact Sonarr series id returned by sonarr_find_series."),
|
||||
season_number: int = Field(ge=0, description="Season number."),
|
||||
) -> Any:
|
||||
"""Return episodes and existing files for one Sonarr season. READ ONLY. File names do not prove audio language."""
|
||||
return await _season_summary(
|
||||
get_sonarr_client(),
|
||||
{"series_id": series_id, "season_number": season_number},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_search_releases(
|
||||
series_id: int = Field(description="Exact Sonarr series id."),
|
||||
season_number: int | None = Field(default=None, ge=0),
|
||||
episode_id: int | None = Field(default=None, ge=1),
|
||||
release_group: str = Field(default="", description="Optional release-group filter, for example FuN."),
|
||||
season_pack_only: bool = Field(default=False, description="Only complete season packs. Requires season_number."),
|
||||
) -> Any:
|
||||
"""Search Sonarr's configured indexers and return compact matching releases. READ ONLY: does not alter monitoring, start automatic search, grab, or download anything."""
|
||||
return await _search_releases(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"season_number": season_number,
|
||||
"episode_id": episode_id,
|
||||
"release_group": release_group,
|
||||
"season_pack_only": season_pack_only,
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr"})
|
||||
async def sonarr_system_status() -> Any:
|
||||
"""Return compact Sonarr version and runtime status. READ ONLY."""
|
||||
return _compact_result("get_system_status", await _call(get_sonarr_client(), "get_system_status"))
|
||||
|
||||
if os.environ.get("ARR_MCP_WRITE", "").strip().lower() not in ("1", "true", "yes", "on"):
|
||||
return
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_preview_release_grab(
|
||||
series_id: int = Field(description="Exact Sonarr series id."),
|
||||
guid: str = Field(description="Exact GUID returned by sonarr_search_releases."),
|
||||
season_number: int | None = Field(default=None, ge=0),
|
||||
episode_id: int | None = Field(default=None, ge=1),
|
||||
force: bool = Field(default=False),
|
||||
) -> Any:
|
||||
"""Preview one exact release grab and issue a short-lived approval ticket. Does not download anything."""
|
||||
return await _preview_release_grab(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"guid": guid,
|
||||
"season_number": season_number,
|
||||
"episode_id": episode_id,
|
||||
"force": force,
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_grab_release(
|
||||
series_id: int = Field(description="Same series id used for the preview."),
|
||||
guid: str = Field(description="Same exact release GUID used for the preview."),
|
||||
approval_ticket: str = Field(description="Ticket returned by sonarr_preview_release_grab."),
|
||||
confirm: bool = Field(description="Must be true after explicit user approval."),
|
||||
season_number: int | None = Field(default=None, ge=0),
|
||||
episode_id: int | None = Field(default=None, ge=1),
|
||||
force: bool = Field(default=False),
|
||||
) -> Any:
|
||||
"""Grab exactly one previously previewed release. WRITE: can immediately start a download."""
|
||||
return await _grab_release(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"guid": guid,
|
||||
"approval_ticket": approval_ticket,
|
||||
"confirm": confirm,
|
||||
"season_number": season_number,
|
||||
"episode_id": episode_id,
|
||||
"force": force,
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_preview_episode_search(
|
||||
series_id: int = Field(description="Exact Sonarr series id."),
|
||||
season_number: int = Field(ge=0),
|
||||
episode_numbers: list[int] | None = Field(default=None, description="Optional episode numbers; omit for all missing episodes in the season."),
|
||||
) -> Any:
|
||||
"""Preview an automatic Sonarr episode search. Does not change monitoring or download anything."""
|
||||
return await _preview_episode_search(
|
||||
get_sonarr_client(),
|
||||
{"series_id": series_id, "season_number": season_number, "episode_numbers": episode_numbers},
|
||||
)
|
||||
|
||||
@mcp.tool(tags={"sonarr", "write"})
|
||||
async def sonarr_start_episode_search(
|
||||
series_id: int = Field(description="Same series id used for the preview."),
|
||||
season_number: int = Field(ge=0),
|
||||
approval_ticket: str = Field(description="Ticket returned by sonarr_preview_episode_search."),
|
||||
confirm: bool = Field(description="Must be true after explicit user approval."),
|
||||
episode_numbers: list[int] | None = Field(default=None),
|
||||
) -> Any:
|
||||
"""Start a previously previewed automatic episode search. WRITE: may immediately download releases."""
|
||||
return await _start_episode_search(
|
||||
get_sonarr_client(),
|
||||
{
|
||||
"series_id": series_id,
|
||||
"season_number": season_number,
|
||||
"episode_numbers": episode_numbers,
|
||||
"approval_ticket": approval_ticket,
|
||||
"confirm": confirm,
|
||||
},
|
||||
)
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Hermes and OpenWebUI MCP registrations from one JSON registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
|
||||
BEGIN = "# BEGIN MANAGED MCP SERVERS"
|
||||
END = "# END MANAGED MCP SERVERS"
|
||||
CLIENT_TOKEN = ""
|
||||
|
||||
|
||||
def env_file(path: str) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
source = pathlib.Path(path)
|
||||
if not source.is_file():
|
||||
return values
|
||||
for raw in source.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def enabled(item: dict) -> bool:
|
||||
required = item.get("required_file")
|
||||
if required and not pathlib.Path(required).is_file():
|
||||
return False
|
||||
source = item.get("env_file")
|
||||
if source:
|
||||
values = env_file(source)
|
||||
url_ready = bool(item.get("url")) or bool(values.get(item.get("url_env", "")))
|
||||
key_ready = (not item.get("key_env")
|
||||
or bool(values.get(item["key_env"]))
|
||||
or (item.get("key_env") == "MCPHUB_BEARER_TOKEN"
|
||||
and bool(CLIENT_TOKEN)))
|
||||
return url_ready and key_ready
|
||||
return True
|
||||
|
||||
|
||||
def resolved(item: dict) -> tuple[str, str]:
|
||||
if item.get("env_file"):
|
||||
values = env_file(item["env_file"])
|
||||
url = item.get("url") or values[item["url_env"]]
|
||||
key = values.get(item.get("key_env", ""), "")
|
||||
if not key and item.get("key_env") == "MCPHUB_BEARER_TOKEN":
|
||||
key = CLIENT_TOKEN
|
||||
return url, key
|
||||
return item["url"], ""
|
||||
|
||||
|
||||
def active(registry: pathlib.Path, client: str) -> list[dict]:
|
||||
document = json.loads(registry.read_text(encoding="utf-8"))
|
||||
if document.get("version") != 1 or not isinstance(document.get("servers"), list):
|
||||
raise SystemExit("Unsupported MCP registry schema")
|
||||
return [item for item in document["servers"] if client in item.get("clients", []) and enabled(item)]
|
||||
|
||||
|
||||
def yaml_quote(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def hermes_block(items: list[dict]) -> str:
|
||||
lines = [BEGIN, "mcp_servers:"]
|
||||
for item in items:
|
||||
url, key = resolved(item)
|
||||
lines.extend([
|
||||
f" {item.get('hermes_id', item['id'])}:",
|
||||
f" url: {yaml_quote(url)}",
|
||||
])
|
||||
if key:
|
||||
lines.extend([" headers:", f" Authorization: {yaml_quote('Bearer ' + key)}"])
|
||||
if "tool_include" in item:
|
||||
lines.append(" tools:")
|
||||
lines.append(" include:")
|
||||
for tool in item["tool_include"]:
|
||||
lines.append(f" - {yaml_quote(str(tool))}")
|
||||
elif "tool_exclude" in item:
|
||||
lines.append(" tools:")
|
||||
lines.append(" exclude:")
|
||||
for tool in item["tool_exclude"]:
|
||||
lines.append(f" - {yaml_quote(str(tool))}")
|
||||
lines.extend([
|
||||
f" timeout: {int(item.get('timeout', 300))}",
|
||||
" connect_timeout: 30",
|
||||
" supports_parallel_tool_calls: false",
|
||||
])
|
||||
lines.append(END)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def update_hermes(path: pathlib.Path, block: str) -> None:
|
||||
if not path.is_file():
|
||||
return
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if BEGIN in text and END in text:
|
||||
prefix, rest = text.split(BEGIN, 1)
|
||||
_, suffix = rest.split(END, 1)
|
||||
text = prefix.rstrip() + "\n\n" + block + suffix.lstrip("\n")
|
||||
else:
|
||||
marker = "\nmcp_servers:"
|
||||
if marker in text:
|
||||
text = text.split(marker, 1)[0].rstrip() + "\n\n" + block
|
||||
else:
|
||||
text = text.rstrip() + "\n\n" + block
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def openwebui_connection(item: dict) -> dict:
|
||||
url, key = resolved(item)
|
||||
config = {"enable": True, "access_grants": []}
|
||||
if item.get("functions"):
|
||||
config["function_name_filter_list"] = item["functions"]
|
||||
elif item.get("tool_include"):
|
||||
config["function_name_filter_list"] = ",".join(item["tool_include"])
|
||||
return {
|
||||
"url": url, "path": "", "type": "mcp",
|
||||
"auth_type": item.get("auth_type", "none"), "headers": None,
|
||||
"key": key, "config": config,
|
||||
"info": {"id": item["id"], "name": item["name"], "description": item["description"]},
|
||||
}
|
||||
|
||||
|
||||
def update_openwebui(db: pathlib.Path, items: list[dict]) -> None:
|
||||
con = sqlite3.connect(db)
|
||||
now = int(time.time())
|
||||
row = con.execute("select value from config where key=?", ("tool_server.connections",)).fetchone()
|
||||
old = json.loads(row[0]) if row else []
|
||||
if not isinstance(old, list):
|
||||
raise SystemExit("Unexpected OpenWebUI tool_server.connections format")
|
||||
managed_ids = {
|
||||
"athena-platform", "athena-operator-local", "web-general-local", "github-local",
|
||||
"homeassistant-local", "arr-local", "navidrome-local", "mua",
|
||||
"mua-readonly-local", "athena-terminal-local", "unraid-readonly-local", "web-local",
|
||||
}
|
||||
keep = [entry for entry in old if str((entry.get("info") or {}).get("id", "")) not in managed_ids]
|
||||
keep.extend(openwebui_connection(item) for item in items)
|
||||
with con:
|
||||
con.execute(
|
||||
"""insert into config (key,value,updated_at) values (?,?,?)
|
||||
on conflict(key) do update set value=excluded.value,updated_at=excluded.updated_at""",
|
||||
("tool_server.connections", json.dumps(keep, ensure_ascii=False), now),
|
||||
)
|
||||
con.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global CLIENT_TOKEN
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--registry", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--hermes", type=pathlib.Path, action="append", default=[])
|
||||
parser.add_argument("--openwebui-db", type=pathlib.Path)
|
||||
parser.add_argument("--mcphub-token-file", type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
if args.mcphub_token_file:
|
||||
CLIENT_TOKEN = args.mcphub_token_file.read_text(encoding="utf-8").strip()
|
||||
if not CLIENT_TOKEN:
|
||||
raise SystemExit("MCPHub token file is empty")
|
||||
if args.hermes:
|
||||
block = hermes_block(active(args.registry, "hermes"))
|
||||
for path in args.hermes:
|
||||
update_hermes(path, block)
|
||||
if args.openwebui_db:
|
||||
update_openwebui(args.openwebui_db, active(args.registry, "openwebui"))
|
||||
print("MCP_CLIENT_SYNC_OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui}
|
||||
ENV_FILE=${NAVIDROME_MCP_ENV_FILE:-/etc/mike-ai/navidrome-mcp.env}
|
||||
|
||||
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
|
||||
[[ -s $ENV_FILE ]] || die "Navidrome-Secret-Datei fehlt."
|
||||
[[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-mcp-navidrome 2>/dev/null || true) == healthy ]] || \
|
||||
die "Navidrome-MCP ist nicht gesund."
|
||||
[[ $(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || true) == healthy ]] || \
|
||||
die "OpenWebUI ist nicht gesund."
|
||||
|
||||
expect_lastfm=false
|
||||
grep -q '^LASTFM_API_KEY=..' "$ENV_FILE" && expect_lastfm=true
|
||||
|
||||
result=$(docker exec -i -e EXPECT_LASTFM="$expect_lastfm" "$CONTAINER" python - <<'PY'
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
EXPECTED_LASTFM = {
|
||||
"get_similar_artists", "get_similar_tracks", "get_artist_info",
|
||||
"get_top_tracks_by_artist", "get_trending_music", "get_artist_albums",
|
||||
"get_album_info",
|
||||
}
|
||||
PLAYBACK = {"play_songs", "pause", "set_volume"}
|
||||
|
||||
def find_unanchored(value, path=""):
|
||||
bad = []
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
here = f"{path}.{key}" if path else key
|
||||
if key == "pattern" and (
|
||||
not isinstance(child, str)
|
||||
or not child.startswith("^")
|
||||
or not child.endswith("$")
|
||||
):
|
||||
bad.append(here)
|
||||
bad.extend(find_unanchored(child, here))
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
bad.extend(find_unanchored(child, f"{path}[{index}]"))
|
||||
return bad
|
||||
|
||||
async def verify():
|
||||
async with streamablehttp_client(
|
||||
"http://mike-ai-mcp-navidrome:3000/mcp"
|
||||
) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
result = await session.list_tools()
|
||||
names = {tool.name for tool in result.tools}
|
||||
bad = []
|
||||
for tool in result.tools:
|
||||
bad.extend(find_unanchored(tool.inputSchema, tool.name))
|
||||
if bad:
|
||||
raise SystemExit("Unverankerte JSON-Schema-Patterns: " + ", ".join(bad))
|
||||
if PLAYBACK & names:
|
||||
raise SystemExit("Playback-Werkzeuge sind auf dem Headless-Host aktiv.")
|
||||
expect_lastfm = os.environ.get("EXPECT_LASTFM") == "true"
|
||||
if expect_lastfm and not EXPECTED_LASTFM <= names:
|
||||
raise SystemExit("Last.fm-Werkzeugkatalog ist unvollständig.")
|
||||
if not expect_lastfm and EXPECTED_LASTFM & names:
|
||||
raise SystemExit("Last.fm-Werkzeuge sind ohne konfigurierten Schlüssel aktiv.")
|
||||
if expect_lastfm:
|
||||
# Public metadata only. Do not print the returned chart data.
|
||||
response = await session.call_tool(
|
||||
"get_trending_music", {"type": "artists", "limit": 1}
|
||||
)
|
||||
if response.isError:
|
||||
raise SystemExit("Öffentliche Last.fm-Testabfrage ist fehlgeschlagen.")
|
||||
|
||||
con = sqlite3.connect("/app/backend/data/webui.db")
|
||||
row = con.execute(
|
||||
"select value from config where key=?", ("tool_server.connections",)
|
||||
).fetchone()
|
||||
connections = json.loads(row[0]) if row else []
|
||||
ids = {
|
||||
str((connection.get("info") or {}).get("id", ""))
|
||||
for connection in connections if isinstance(connection, dict)
|
||||
}
|
||||
if "navidrome-local" not in ids:
|
||||
raise SystemExit("OpenWebUI-Verbindung navidrome-local fehlt.")
|
||||
print(f"NAVIDROME_ACCEPTANCE_OK tools={len(names)} lastfm={str(expect_lastfm).lower()}")
|
||||
|
||||
asyncio.run(verify())
|
||||
PY
|
||||
)
|
||||
[[ $result == NAVIDROME_ACCEPTANCE_OK\ * ]] || \
|
||||
die "Navidrome-Abnahme lieferte keinen gültigen Erfolgsmarker."
|
||||
printf '%s\n' "$result"
|
||||
@@ -26,11 +26,11 @@ models:
|
||||
file: ggml-large-v3-turbo.bin
|
||||
target: /opt/mike-ai/models/whisper/ggml-large-v3-turbo.bin
|
||||
sha256: "REPLACE_AFTER_VERIFICATION"
|
||||
flux:
|
||||
image:
|
||||
role: image-generation
|
||||
source: black-forest-labs/FLUX.2-klein-4B
|
||||
target: /data/models/FLUX.2-klein-4B
|
||||
revision: "303481f0390afb112393f9d77e8f0be72fcefeb7"
|
||||
source: Tongyi-MAI/Z-Image-Turbo
|
||||
target: /data/models/Z-Image-Turbo
|
||||
revision: "f332072aa78be7aecdf3ee76d5c247082da564a6"
|
||||
xtts:
|
||||
role: text-to-speech
|
||||
source: coqui/XTTS-v2
|
||||
|
||||
Reference in New Issue
Block a user