MC2 Final (D16 a/b/d): verständliche Updates + ehrliche Speicher-Zahlen
a) Update-Meldungen: generischer Release-Summarizer in Lucys Stimme mit
strukturiertem Aktions-Verdikt ("Musst du etwas tun? NEIN/JA") für
Hermes, Engine (llama.cpp) und llama-swap; Fangnetz-Hinweis verheiratet
Breaking-Change-Sorge mit dem Postcheck.
b) OS ehrlich: zurückgestellte Pakete (Phasen-Rollout / kept back) werden
ausgewiesen statt scheinbar zu hängen.
d) Ehrliche Speicher-Zahlen: KV-Cache aus echten GGUF-Architektur-Daten
(Layer × KV-Köpfe × head_dim) + KV-Quant aus dem cmd statt params-blinder
Schätzung — footprint_gb als eine Zahlensprache (Zentrale, Modell-Manager,
fits-Check auf warmset+largest). Auto-Rewarm-Nudge nach Config-Reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -75,15 +75,16 @@ def hermes_brain_info() -> dict:
|
|||||||
if m["name"] in persist and m["name"] != cur_name)
|
if m["name"] in persist and m["name"] != cur_name)
|
||||||
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
|
largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0)
|
||||||
gtt = gtt_budget_gb()
|
gtt = gtt_budget_gb()
|
||||||
# Brain muss immer resident sein → passt Brain + größtes on-demand zusammen?
|
# Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision)
|
||||||
# (fast/vision dürfen beim Laden eines großen Modells verdrängt werden.)
|
# resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset +
|
||||||
|
# größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen.
|
||||||
budget = {
|
budget = {
|
||||||
"gtt_gb": gtt,
|
"gtt_gb": gtt,
|
||||||
"brain_gb": round(brain_gb, 1),
|
"brain_gb": round(brain_gb, 1),
|
||||||
"warm_projected_gb": round(warm, 1),
|
"warm_projected_gb": round(warm, 1),
|
||||||
"largest_ondemand_gb": round(largest_od, 1),
|
"largest_ondemand_gb": round(largest_od, 1),
|
||||||
"fits": (brain_gb + largest_od) <= gtt,
|
"fits": (warm + largest_od) <= gtt,
|
||||||
"free_after_gb": round(gtt - brain_gb - largest_od, 1),
|
"free_after_gb": round(gtt - warm - largest_od, 1),
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
|
log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True)
|
||||||
|
|||||||
@@ -54,16 +54,46 @@ def params_of_model(model: dict) -> float:
|
|||||||
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
|
return max(float(caps.get("params_b") or 0), pb_size, 7.0)
|
||||||
|
|
||||||
|
|
||||||
|
_CTK_RE = re.compile(r"(?:--cache-type-k|(?<![\w-])-ctk)\s+(\S+)")
|
||||||
|
_CTV_RE = re.compile(r"(?:--cache-type-v|(?<![\w-])-ctv)\s+(\S+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_types(cmd: str) -> tuple[str | None, str | None]:
|
||||||
|
"""K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None)."""
|
||||||
|
ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None
|
||||||
|
cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None
|
||||||
|
return ck, cv
|
||||||
|
|
||||||
|
|
||||||
|
def _real_kv_gb(model: dict, ctx: int) -> float | None:
|
||||||
|
"""ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads ×
|
||||||
|
Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist
|
||||||
|
→ Aufrufer fällt auf die params-basierte Heuristik zurück."""
|
||||||
|
from services import gguf_meta
|
||||||
|
path = model.get("gguf_path")
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
meta = gguf_meta.arch_meta(path)
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
ck, cv = _cache_types(model.get("cmd") or "")
|
||||||
|
return gguf_meta.kv_cache_gb(meta, ctx, ck, cv)
|
||||||
|
|
||||||
|
|
||||||
def footprint_gb(model: dict) -> float:
|
def footprint_gb(model: dict) -> float:
|
||||||
"""Loaded-Footprint eines Modells = Gewichte + kalibrierter KV-Anteil (bei seinem
|
"""Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx).
|
||||||
aktuellen ctx)."""
|
KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) —
|
||||||
|
entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte
|
||||||
|
grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback."""
|
||||||
quant = model.get("quant") or "Q4_K_M"
|
quant = model.get("quant") or "Q4_K_M"
|
||||||
ctx = int(model.get("ctx") or 32768)
|
ctx = int(model.get("ctx") or 32768)
|
||||||
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||||
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||||
pb = params_of_model(model)
|
pb = params_of_model(model)
|
||||||
weights = max(pb * bpp, size_gb)
|
weights = max(pb * bpp, size_gb)
|
||||||
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
|
kv = _real_kv_gb(model, ctx)
|
||||||
|
if kv is None:
|
||||||
|
kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp
|
||||||
return weights + max(kv, 0.0)
|
return weights + max(kv, 0.0)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,9 +169,37 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
|
||||||
|
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
|
||||||
|
from services.fit import _NICE_CTX
|
||||||
|
if cap:
|
||||||
|
raw_ctx = min(raw_ctx, cap)
|
||||||
|
best = _NICE_CTX[0]
|
||||||
|
for c in _NICE_CTX:
|
||||||
|
if c <= raw_ctx:
|
||||||
|
best = c
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
def setup_aware_ctx_for_model(model: dict) -> dict:
|
def setup_aware_ctx_for_model(model: dict) -> dict:
|
||||||
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell (aus seiner Rolle,
|
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
|
||||||
Params & Quant). Für den 'Auto'-Button an der Modellkarte."""
|
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
|
||||||
return setup_aware_ctx(params_of_model(model),
|
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
|
||||||
model.get("quant") or "Q4_K_M",
|
from services import gguf_meta
|
||||||
role=model.get("role"))
|
quant = model.get("quant") or "Q4_K_M"
|
||||||
|
path = model.get("gguf_path")
|
||||||
|
meta = gguf_meta.arch_meta(path) if path else None
|
||||||
|
if not meta:
|
||||||
|
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
|
||||||
|
|
||||||
|
gtt = gtt_budget_gb()
|
||||||
|
r = reserved_gb(model.get("role"))
|
||||||
|
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
||||||
|
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||||
|
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||||
|
weights = max(params_of_model(model) * bpp, size_gb)
|
||||||
|
ck, cv = _cache_types(model.get("cmd") or "")
|
||||||
|
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
|
||||||
|
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
|
||||||
|
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
|
||||||
|
"budget_gb": round(budget, 1), "mode": r["mode"]}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ class _Reader:
|
|||||||
n = self.u64()
|
n = self.u64()
|
||||||
return self.read(n).decode("utf-8", "replace")
|
return self.read(n).decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def scalar(self, vtype: int):
|
||||||
|
"""Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar."""
|
||||||
|
fmt = _SCALAR_FMT.get(vtype)
|
||||||
|
if not fmt:
|
||||||
|
self.skip_value(vtype)
|
||||||
|
return None
|
||||||
|
return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0]
|
||||||
|
|
||||||
def skip_value(self, vtype: int) -> None:
|
def skip_value(self, vtype: int) -> None:
|
||||||
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
|
"""Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt
|
||||||
weiterzuschieben). Arrays werden elementweise konsumiert."""
|
weiterzuschieben). Arrays werden elementweise konsumiert."""
|
||||||
@@ -159,3 +167,100 @@ def compatible(target_path: str, draft_path: str) -> bool | None:
|
|||||||
if a is None or b is None:
|
if a is None or b is None:
|
||||||
return None
|
return None
|
||||||
return a == b
|
return a == b
|
||||||
|
|
||||||
|
|
||||||
|
# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ──────────────────────────────
|
||||||
|
# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern.
|
||||||
|
# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung
|
||||||
|
# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier.
|
||||||
|
# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …),
|
||||||
|
# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix.
|
||||||
|
_ARCH_WANT = (
|
||||||
|
".block_count", ".attention.head_count_kv", ".attention.head_count",
|
||||||
|
".attention.key_length", ".attention.value_length", ".embedding_length",
|
||||||
|
".context_length",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_arch_meta(path: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
r = _Reader(fh)
|
||||||
|
if r.read(4) != b"GGUF":
|
||||||
|
return None
|
||||||
|
r.u32() # version
|
||||||
|
r.u64() # tensor_count
|
||||||
|
kv_count = r.u64()
|
||||||
|
raw: dict = {}
|
||||||
|
arch = None
|
||||||
|
for _ in range(kv_count):
|
||||||
|
key = r.gstr()
|
||||||
|
vtype = r.u32()
|
||||||
|
if key == "general.architecture" and vtype == _T_STRING:
|
||||||
|
arch = r.gstr()
|
||||||
|
continue
|
||||||
|
if key == "tokenizer.ggml.tokens":
|
||||||
|
break # Arch-Metadaten stehen davor → fertig, Rest überspringen
|
||||||
|
suf = next((s for s in _ARCH_WANT if key.endswith(s)), None)
|
||||||
|
if suf is not None and vtype in _SCALAR_SIZE:
|
||||||
|
raw[suf] = r.scalar(vtype)
|
||||||
|
else:
|
||||||
|
r.skip_value(vtype)
|
||||||
|
n_layers = raw.get(".block_count")
|
||||||
|
n_head = raw.get(".attention.head_count")
|
||||||
|
n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA
|
||||||
|
n_embd = raw.get(".embedding_length")
|
||||||
|
hd_k = raw.get(".attention.key_length") \
|
||||||
|
or (int(n_embd / n_head) if (n_embd and n_head) else None)
|
||||||
|
hd_v = raw.get(".attention.value_length") or hd_k
|
||||||
|
if not (n_layers and n_head_kv and hd_k and hd_v):
|
||||||
|
return None # unvollständig → Aufrufer nutzt Heuristik-Fallback
|
||||||
|
return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv),
|
||||||
|
"head_dim_k": int(hd_k), "head_dim_v": int(hd_v),
|
||||||
|
"n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None}
|
||||||
|
except (OSError, EOFError, ValueError, struct.error):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=128)
|
||||||
|
def _arch_cached(path: str, mtime: float, size: int) -> dict | None:
|
||||||
|
return _read_arch_meta(path)
|
||||||
|
|
||||||
|
|
||||||
|
def arch_meta(path: str) -> dict | None:
|
||||||
|
"""Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size):
|
||||||
|
{arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar
|
||||||
|
oder unvollständig."""
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return _arch_cached(path, st.st_mtime, st.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants).
|
||||||
|
_KV_BPE = {
|
||||||
|
"f32": 4.0, "f16": 2.0, "bf16": 2.0,
|
||||||
|
"q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875,
|
||||||
|
"q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625,
|
||||||
|
}
|
||||||
|
_GIB = 1024 ** 3
|
||||||
|
|
||||||
|
|
||||||
|
def _bpe(cache_type: str | None) -> float:
|
||||||
|
return _KV_BPE.get((cache_type or "f16").lower(), 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float:
|
||||||
|
"""Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp:
|
||||||
|
je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V."""
|
||||||
|
per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx
|
||||||
|
k = per_tok * meta["head_dim_k"] * _bpe(ck)
|
||||||
|
v = per_tok * meta["head_dim_v"] * _bpe(cv)
|
||||||
|
return (k + v) / _GIB
|
||||||
|
|
||||||
|
|
||||||
|
def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float:
|
||||||
|
"""KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx)."""
|
||||||
|
return kv_cache_gb(meta, 1, ck, cv)
|
||||||
|
|||||||
@@ -197,6 +197,13 @@ def write_config(cfg: dict) -> None:
|
|||||||
with tmp.open("w", encoding="utf-8") as f:
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
r_yaml.dump(cfg, f)
|
r_yaml.dump(cfg, f)
|
||||||
os.replace(tmp, CONFIG_PATH)
|
os.replace(tmp, CONFIG_PATH)
|
||||||
|
# llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn.
|
||||||
|
# Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt.
|
||||||
|
try:
|
||||||
|
from services import warmer
|
||||||
|
warmer.nudge()
|
||||||
|
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
|
||||||
|
pass
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
|
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
|
||||||
|
|||||||
+115
-29
@@ -298,8 +298,42 @@ def updates() -> dict:
|
|||||||
|
|
||||||
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
|
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
|
||||||
|
|
||||||
|
def _os_held_back() -> list[dict]:
|
||||||
|
"""Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund.
|
||||||
|
Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen:
|
||||||
|
• Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'.
|
||||||
|
• zurückgehalten wegen neuer Abhängigkeiten → 'kept back'.
|
||||||
|
Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl,
|
||||||
|
wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen."""
|
||||||
|
held: list[dict] = []
|
||||||
|
sections = {
|
||||||
|
"The following upgrades have been deferred due to phasing:": "phasing",
|
||||||
|
"The following packages have been kept back:": "kept_back",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
|
||||||
|
capture_output=True, text=True, timeout=25)
|
||||||
|
reason: str | None = None
|
||||||
|
for line in (out.stdout or "").splitlines():
|
||||||
|
hdr = sections.get(line.strip())
|
||||||
|
if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln
|
||||||
|
reason = hdr
|
||||||
|
continue
|
||||||
|
if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts
|
||||||
|
for name in line.split():
|
||||||
|
held.append({"name": name, "reason": reason})
|
||||||
|
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
||||||
|
reason = None
|
||||||
|
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
|
||||||
|
pass
|
||||||
|
held.sort(key=lambda p: p["name"])
|
||||||
|
return held
|
||||||
|
|
||||||
|
|
||||||
def os_update_details() -> dict:
|
def os_update_details() -> dict:
|
||||||
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat)."""
|
"""Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche
|
||||||
|
Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back)."""
|
||||||
out_pkgs: list[dict] = []
|
out_pkgs: list[dict] = []
|
||||||
try:
|
try:
|
||||||
# LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift
|
# LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift
|
||||||
@@ -314,7 +348,8 @@ def os_update_details() -> dict:
|
|||||||
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
|
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
|
||||||
"current": m.group(3).strip()})
|
"current": m.group(3).strip()})
|
||||||
out_pkgs.sort(key=lambda p: p["name"])
|
out_pkgs.sort(key=lambda p: p["name"])
|
||||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs}
|
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
||||||
|
"held_back": _os_held_back()}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
||||||
|
|
||||||
@@ -334,6 +369,13 @@ def engine_update_details() -> dict:
|
|||||||
info["url"] = rel.get("html_url")
|
info["url"] = rel.get("html_url")
|
||||||
body = (rel.get("body") or "").strip()
|
body = (rel.get("body") or "").strip()
|
||||||
info["body"] = body[:2000] if body else None
|
info["body"] = body[:2000] if body else None
|
||||||
|
# Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call,
|
||||||
|
# wenn das Modal bei aktuellem Stand geöffnet wird).
|
||||||
|
if body and info["latest_build"] and info["installed_build"] \
|
||||||
|
and info["latest_build"] > info["installed_build"]:
|
||||||
|
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
||||||
|
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
||||||
|
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
@@ -354,31 +396,54 @@ def swap_update_details() -> dict:
|
|||||||
info["url"] = rel.get("html_url")
|
info["url"] = rel.get("html_url")
|
||||||
body = (rel.get("body") or "").strip()
|
body = (rel.get("body") or "").strip()
|
||||||
info["body"] = body[:2000] if body else None
|
info["body"] = body[:2000] if body else None
|
||||||
|
if body and info["latest_build"] and info["installed_build"] \
|
||||||
|
and info["latest_build"] > info["installed_build"]:
|
||||||
|
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
||||||
|
"verteilt und Sprachmodelle heiß nachlädt).")
|
||||||
|
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
# LLM-Zusammenfassung der anstehenden Hermes-Commits (die Box liest ihre Release-Notes selbst).
|
# LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes
|
||||||
# Gecacht auf den neuesten Commit-Hash — das Modal darf beliebig oft geöffnet werden.
|
# selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer
|
||||||
_relnotes_cache: dict = {"key": "", "summary": ""}
|
# (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …".
|
||||||
|
# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig
|
||||||
|
# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen.
|
||||||
|
_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}}
|
||||||
|
|
||||||
|
# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — <grund>".
|
||||||
|
# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut.
|
||||||
|
_ACTION_RX = re.compile(
|
||||||
|
r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _summarize_hermes_commits(commits: list[dict]) -> str:
|
def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
|
||||||
key = commits[0]["hash"] if commits else ""
|
"""Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt
|
||||||
|
ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`."""
|
||||||
|
empty = {"summary": "", "action_needed": None, "action_text": ""}
|
||||||
if not key:
|
if not key:
|
||||||
return ""
|
return empty
|
||||||
if _relnotes_cache["key"] == key and _relnotes_cache["summary"]:
|
cache = _relnotes_caches.setdefault(kind, {})
|
||||||
return _relnotes_cache["summary"]
|
if cache.get("key") == key and cache.get("data"):
|
||||||
|
return cache["data"]
|
||||||
from config import LLAMA_SWAP_URL
|
from config import LLAMA_SWAP_URL
|
||||||
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
|
|
||||||
prompt = (
|
prompt = (
|
||||||
"Du bist der Update-Berater einer lokalen AI-Box (Hermes-Agent auf Strix Halo; genutzt werden: "
|
"Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, "
|
||||||
"api_server/Gateway, memory-provider-Plugin 'mc2-memory', terminal-/web-Tools, approvals, cron). "
|
"fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n"
|
||||||
"Hier die Commit-Titel des anstehenden Hermes-Updates:\n\n" + subjects + "\n\n"
|
f"{context}\n\n"
|
||||||
"Fasse auf DEUTSCH in maximal 6 Stichpunkten zusammen: (1) Breaking Changes oder geänderte/"
|
"Anstehende Änderungen:\n" + changes + "\n\n"
|
||||||
"entfernte Config-Schlüssel (WICHTIGSTES zuerst, explizit warnen), (2) was unser Setup betrifft, "
|
"Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n"
|
||||||
"(3) welche neuen Features sich für uns lohnen. Keine Einleitung, nur die Punkte."
|
"Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n"
|
||||||
|
" • 'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das "
|
||||||
|
"Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der "
|
||||||
|
"Normalfall.\n"
|
||||||
|
" • 'AKTION: JA — <was er konkret tun/entscheiden muss>' — NUR wenn er wirklich selbst "
|
||||||
|
"handeln muss.\n"
|
||||||
|
"Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder "
|
||||||
|
"geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup "
|
||||||
|
"betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
|
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
|
||||||
@@ -389,21 +454,42 @@ def _summarize_hermes_commits(commits: list[dict]) -> str:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
resp = r.json()
|
resp = r.json()
|
||||||
choice = (resp.get("choices") or [{}])[0]
|
choice = (resp.get("choices") or [{}])[0]
|
||||||
summary = (choice.get("message") or {}).get("content") or ""
|
text = ((choice.get("message") or {}).get("content") or "").strip()
|
||||||
finish_reason = choice.get("finish_reason") or ""
|
finish_reason = choice.get("finish_reason") or ""
|
||||||
summary = summary.strip()
|
|
||||||
|
|
||||||
# finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
|
# finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten →
|
||||||
# letzten (unvollständigen) Stichpunkt entfernen.
|
# letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig.
|
||||||
# Bei "stop" oder None → Antwort ist vollständig → unverändert lassen.
|
if finish_reason == "length" and text:
|
||||||
if finish_reason == "length" and summary:
|
head, _, _tail = text.rpartition("\n")
|
||||||
lines = summary.rsplit("\n", 1)
|
text = head if head else ""
|
||||||
summary = lines[0] if len(lines) > 1 else ""
|
|
||||||
if summary:
|
# Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1,
|
||||||
_relnotes_cache.update(key=key, summary=summary)
|
# aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile).
|
||||||
return summary
|
action_needed: bool | None = None
|
||||||
|
action_text = ""
|
||||||
|
lines = text.splitlines()
|
||||||
|
for idx, ln in enumerate(lines):
|
||||||
|
if m := _ACTION_RX.match(ln):
|
||||||
|
action_needed = m.group(1).upper() == "JA"
|
||||||
|
action_text = (m.group(2) or "").strip()
|
||||||
|
text = "\n".join(lines[:idx] + lines[idx + 1:]).strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
data = {"summary": text, "action_needed": action_needed, "action_text": action_text}
|
||||||
|
if text:
|
||||||
|
cache.update(key=key, data=data)
|
||||||
|
return data
|
||||||
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
|
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
|
||||||
return f"(Zusammenfassung nicht verfügbar: {exc})"
|
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
||||||
|
"action_needed": None, "action_text": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_hermes_commits(commits: list[dict]) -> dict:
|
||||||
|
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
|
||||||
|
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
|
||||||
|
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
|
||||||
|
"terminal-/web-Tools, approvals, cron).")
|
||||||
|
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
||||||
|
|
||||||
|
|
||||||
def hermes_update_details() -> dict:
|
def hermes_update_details() -> dict:
|
||||||
@@ -430,7 +516,7 @@ def hermes_update_details() -> dict:
|
|||||||
info["commits"] = commits
|
info["commits"] = commits
|
||||||
info["behind"] = len(commits)
|
info["behind"] = len(commits)
|
||||||
if commits:
|
if commits:
|
||||||
info["summary"] = _summarize_hermes_commits(commits)
|
info.update(_summarize_hermes_commits(commits))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
info["error"] = str(exc)
|
info["error"] = str(exc)
|
||||||
return info
|
return info
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ log = logging.getLogger(__name__)
|
|||||||
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
|
ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0"
|
||||||
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
|
INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks
|
||||||
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
|
START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25"))
|
||||||
|
NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen
|
||||||
|
|
||||||
|
# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten).
|
||||||
|
# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die
|
||||||
|
# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL
|
||||||
|
# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt.
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
_wake: asyncio.Event | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def nudge() -> None:
|
||||||
|
"""Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op,
|
||||||
|
solange der Wächter (noch) nicht läuft."""
|
||||||
|
if _loop is not None and _wake is not None and not _loop.is_closed():
|
||||||
|
try:
|
||||||
|
_loop.call_soon_threadsafe(_wake.set)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _brain_model() -> str:
|
def _brain_model() -> str:
|
||||||
@@ -64,7 +82,11 @@ async def _warm(model: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def rewarm_loop() -> None:
|
async def rewarm_loop() -> None:
|
||||||
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch, wärmt bei Idle vor."""
|
"""Endlos-Schleife (Hintergrund-Task): prüft periodisch (und sofort nach einem Config-
|
||||||
|
Reload-Nudge), wärmt bei Idle vor."""
|
||||||
|
global _loop, _wake
|
||||||
|
_loop = asyncio.get_running_loop()
|
||||||
|
_wake = asyncio.Event()
|
||||||
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -74,4 +96,10 @@ async def rewarm_loop() -> None:
|
|||||||
await _warm(model)
|
await _warm(model)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
log.debug("rewarm: Tick fehlgeschlagen", exc_info=True)
|
||||||
await asyncio.sleep(INTERVAL)
|
# Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_wake.wait(), timeout=INTERVAL)
|
||||||
|
_wake.clear()
|
||||||
|
await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+112
-112
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BsGulpv8.js"></script>
|
<script type="module" crossorigin src="/assets/index-D0kUzkZ8.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ export function WarmSetManager() {
|
|||||||
{/* Speicher-Budget */}
|
{/* Speicher-Budget */}
|
||||||
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
<div className="rounded-xl border border-border/30 bg-background/20 p-3">
|
||||||
<div className="mb-1.5 flex items-center justify-between text-[11px]">
|
<div className="mb-1.5 flex items-center justify-between text-[11px]">
|
||||||
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Vorsichts-Schätzung (Obergrenze): Modellgewichte + Arbeitsspeicher, falls jedes Modell seinen vollen Kontext ausreizt. Real belegt ist meist deutlich weniger — der echte Ist-Wert steht oben im Speicher-Balken.">
|
<span className="flex items-center gap-1.5 font-semibold uppercase tracking-wider text-muted-foreground" title="Modellgewichte + KV-Cache, berechnet aus den echten Architektur-Daten jedes Modells (Layer × KV-Köpfe × Kontext) und seiner KV-Quantisierung. So viel legt llama.cpp beim Laden wirklich für den eingestellten Kontext an — kein Aufschlag mehr.">
|
||||||
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set (Obergrenze)
|
<HardDrive className="h-3.5 w-3.5" /> Reserviert fürs Set
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono font-bold text-foreground">
|
<span className="font-mono font-bold text-foreground">
|
||||||
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
|
{reservedGb.toFixed(1)}{totalGb > 0 ? ` / ${totalGb.toFixed(0)}` : ""} GB
|
||||||
@@ -144,9 +144,9 @@ export function WarmSetManager() {
|
|||||||
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
|
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-amber-400">
|
||||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Vorsichts-Schätzung: Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) könnte der
|
Zusammen mit dem größten Gelegenheits-Modell (~{bud.largest_ondemand_gb} GB) wird der Speicher knapp. Das Set
|
||||||
Speicher knapp werden. Das Set bleibt dabei immer geladen — wird es wirklich eng, schlägt das Laden des großen
|
bleibt dabei immer geladen — wird es wirklich eng, schlägt das Laden des großen Modells fehl (es wartet dann,
|
||||||
Modells fehl (es wartet dann, statt das Set zu verdrängen).
|
statt das Set zu verdrängen).
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowRight, Bot, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, Shuffle, X } from "lucide-react"
|
import { AlertTriangle, ArrowRight, Bot, CheckCircle2, Clock, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, ShieldCheck, Shuffle, X } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { Job, UpdateDetails } from "@/lib/api"
|
import type { Job, UpdateDetails } from "@/lib/api"
|
||||||
|
|
||||||
@@ -47,72 +47,135 @@ export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }:
|
|||||||
</div>
|
</div>
|
||||||
) : d?.error ? (
|
) : d?.error ? (
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3 text-red-400">{d.error}</div>
|
||||||
) : detail.kind === "os" ? (
|
|
||||||
(d?.count ?? 0) === 0 ? (
|
|
||||||
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{d!.packages!.map((p) => (
|
|
||||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
|
||||||
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
|
||||||
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
|
||||||
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 font-mono text-[11px]">
|
|
||||||
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
|
||||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
|
||||||
</div>
|
|
||||||
{(d?.name || d?.latest_tag) && (
|
|
||||||
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
|
||||||
)}
|
|
||||||
{d?.url && (
|
|
||||||
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
|
||||||
Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{d?.body && (
|
|
||||||
<pre className="whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
// hermes
|
<>
|
||||||
(d?.commits?.length ?? 0) === 0 ? (
|
{/* Aktions-Verdikt in Lucys Stimme — "Musst du etwas tun?" (engine/swap/hermes) */}
|
||||||
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
{d?.action_needed != null && (
|
||||||
) : (
|
<div className={cn("flex items-start gap-2 rounded-lg border p-3",
|
||||||
<>
|
d.action_needed ? "border-amber-500/40 bg-amber-500/10" : "border-emerald-500/40 bg-emerald-500/10")}>
|
||||||
{d?.summary && (
|
{d.action_needed
|
||||||
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
|
? <AlertTriangle className="h-4 w-4 text-amber-400 shrink-0 mt-0.5" />
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
|
: <CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0 mt-0.5" />}
|
||||||
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
|
<div className="min-w-0">
|
||||||
|
<div className={cn("text-xs font-semibold", d.action_needed ? "text-amber-300" : "text-emerald-300")}>
|
||||||
|
Musst du etwas tun? {d.action_needed ? "Ja" : "Nein — die Box regelt das (Fangnetz)"}
|
||||||
|
</div>
|
||||||
|
{d.action_needed && d.action_text && (
|
||||||
|
<div className="mt-0.5 text-[11px] text-foreground/90">{d.action_text}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
)}
|
||||||
<div className="space-y-1">
|
{/* Zusammenfassung der Box (Breaking Changes zuerst) */}
|
||||||
{d!.commits!.map((c) => (
|
{d?.summary && (
|
||||||
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
|
||||||
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
|
||||||
<div className="min-w-0">
|
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
|
||||||
<div className="text-[11px] truncate">{c.subject}</div>
|
</div>
|
||||||
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
)}
|
||||||
|
|
||||||
|
{detail.kind === "os" ? (
|
||||||
|
<>
|
||||||
|
{(d?.count ?? 0) === 0 ? (
|
||||||
|
<div className="text-muted-foreground">Keine Pakete zu aktualisieren — System ist aktuell.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground">{d!.count} Paket(e) werden aktualisiert:</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{d!.packages!.map((p) => (
|
||||||
|
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||||
|
<Package className="h-3 w-3 text-cyan-400 shrink-0" />{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 font-mono text-[10px] text-muted-foreground shrink-0">
|
||||||
|
<span>{p.current}</span><ArrowRight className="h-3 w-3" /><span className="text-emerald-400">{p.candidate}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Ehrlich: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) */}
|
||||||
|
{(d?.held_back?.length ?? 0) > 0 && (
|
||||||
|
<div className="space-y-1.5 rounded-lg border border-border/40 bg-background/20 p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
|
||||||
|
<Clock className="h-3.5 w-3.5" /> Vom Hersteller zurückgestellt — kein Handeln nötig
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
|
Diese Pakete gäbe es bereits, Ubuntu spielt sie aber gestaffelt aus (Phasen-Rollout)
|
||||||
|
bzw. hält sie kurz zurück. Sie kommen bei einem der nächsten automatischen Läufe von
|
||||||
|
selbst — das ist kein Fehler und nichts hängt fest.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{d!.held_back!.map((p) => (
|
||||||
|
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md border border-border/30 bg-background/30 px-2.5 py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 font-mono text-[11px] truncate">
|
||||||
|
<Package className="h-3 w-3 text-muted-foreground shrink-0" />{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground shrink-0">
|
||||||
|
{p.reason === "phasing" ? "Phasen-Rollout" : "vorerst zurückgehalten"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</>
|
||||||
|
) : detail.kind === "engine" || detail.kind === "swap" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[11px]">
|
||||||
|
<span className="rounded-md border border-border/40 bg-background/30 px-2 py-1">Build {d?.installed_build ?? "?"}</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 text-emerald-400">Build {d?.latest_build ?? "?"}</span>
|
||||||
|
</div>
|
||||||
|
{(d?.name || d?.latest_tag) && (
|
||||||
|
<div className="text-muted-foreground">Release: <span className="text-foreground">{d?.name}</span>{d?.latest_tag ? ` (${d.latest_tag})` : ""}</div>
|
||||||
|
)}
|
||||||
|
{d?.url && (
|
||||||
|
<a href={d.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-primary hover:underline">
|
||||||
|
Original-Release-Notes auf GitHub <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{d?.body && (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-[10px] text-muted-foreground hover:text-foreground">Original-Notizen (englisch) anzeigen</summary>
|
||||||
|
<pre className="mt-1.5 whitespace-pre-wrap rounded-lg border border-border/40 bg-background/30 p-3 text-[10px] leading-relaxed text-muted-foreground max-h-60 overflow-y-auto scrollbar-thin">{d.body}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// hermes
|
||||||
|
(d?.commits?.length ?? 0) === 0 ? (
|
||||||
|
<div className="text-muted-foreground">Keine neuen Commits — Hermes-Agent ist bereits aktuell.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-muted-foreground">{d!.behind} neue Commit(s) auf <span className="font-mono text-foreground">origin/{d!.branch}</span>:</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{d!.commits!.map((c) => (
|
||||||
|
<div key={c.hash} className="flex items-start gap-2 rounded-md border border-border/40 bg-background/30 px-2.5 py-1.5">
|
||||||
|
<GitCommit className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] truncate">{c.subject}</div>
|
||||||
|
<div className="font-mono text-[9px] text-muted-foreground">{c.hash} · {c.when}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fangnetz-Hinweis: verheiratet Breaking-Change-Sorge mit dem Postcheck (engine/swap/hermes) */}
|
||||||
|
{detail.kind !== "os" && !nothing && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border/40 bg-background/20 p-2.5">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||||
|
Vor dem Update sichert die Box automatisch den alten Stand. Danach prüft sie den ganzen
|
||||||
|
Stack per echter Anfrage — läuft etwas nicht, rollt sie von selbst zurück{detail.kind === "hermes" ? " und startet den Gateway neu" : ""}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground leading-normal">Vor dem Update wird automatisch ein Backup erstellt; danach startet der Hermes-Gateway neu.</p>
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -306,11 +306,16 @@ export interface UpdatesResp {
|
|||||||
export interface UpdateDetails {
|
export interface UpdateDetails {
|
||||||
kind: "os" | "engine" | "swap" | "hermes"
|
kind: "os" | "engine" | "swap" | "hermes"
|
||||||
error?: string
|
error?: string
|
||||||
// hermes: LLM-Zusammenfassung der anstehenden Commits (Breaking Changes zuerst)
|
// LLM-Zusammenfassung in Lucys Stimme (hermes/engine/swap; Breaking Changes zuerst)
|
||||||
summary?: string
|
summary?: string
|
||||||
|
// Aktions-Verdikt: muss der Besitzer selbst etwas tun? (null = kein Verdikt/keine Summary)
|
||||||
|
action_needed?: boolean | null
|
||||||
|
action_text?: string
|
||||||
// os
|
// os
|
||||||
count?: number
|
count?: number
|
||||||
packages?: { name: string; current: string; candidate: string }[]
|
packages?: { name: string; current: string; candidate: string }[]
|
||||||
|
// os: vom System zurückgestellte Pakete (Phasen-Rollout / kept back) — ehrlich statt "hängt"
|
||||||
|
held_back?: { name: string; reason: "phasing" | "kept_back" }[]
|
||||||
// engine
|
// engine
|
||||||
installed_build?: number | null
|
installed_build?: number | null
|
||||||
latest_build?: number | null
|
latest_build?: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user