Merge lucy-v2: Komplett-Review 02.07.2026 (Voice-Speed, Turn-Detection, Update-Playbook, Lucy-Ausgliederung)

Konflikte zugunsten lucy-v2 aufgeloest (Superset; mains UI-Rework war dort dupliziert),
dist wird nach dem Merge frisch gebaut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-02 15:54:58 +02:00
58 changed files with 3020 additions and 2246 deletions
+9
View File
@@ -14,6 +14,15 @@
"9000" "9000"
], ],
"port": 9000 "port": 9000
},
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
"autoPort": false,
"port": 5180
} }
] ]
} }
+5
View File
@@ -15,3 +15,8 @@ frontend/dist/avatar.vrm
# Env / local # Env / local
*.env *.env
.DS_Store .DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
+1 -1
View File
@@ -32,7 +32,7 @@ MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765"
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse). # macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = ( _DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} " "llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa 1 --no-mmap" "-c {ctx} -ngl 999 -fa on --no-mmap"
) )
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE) CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE: if "{model}" not in CMD_TEMPLATE:
+51 -21
View File
@@ -57,7 +57,9 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
for u in image_urls: for u in image_urls:
content.append({"type": "image_url", "image_url": {"url": u}}) content.append({"type": "image_url", "image_url": {"url": u}})
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=5.0)) as client: # 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
"messages": [{"role": "user", "content": content}], "messages": [{"role": "user", "content": content}],
@@ -135,6 +137,25 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
raise HTTPException(502, f"STT fehlgeschlagen: {exc}") raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
@router.post("/voice/turn")
async def voice_turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
with Timer("turn"):
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
r.raise_for_status()
return r.json()
except httpx.HTTPError as exc:
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
log.warning("Turn-Check fehlgeschlagen: %s", exc)
return {"complete": True, "probability": 1.0, "engine": "fallback"}
@router.post("/voice/reference") @router.post("/voice/reference")
async def voice_set_reference(audio: UploadFile = File(...)) -> dict: async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
@@ -193,25 +214,6 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
if not HERMES_API_KEY: if not HERMES_API_KEY:
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
user_text = body.text
imgs = [u for u in (body.images or []) if u]
if imgs:
# Bildschirm-Sicht: erst das Vision-Modell die Monitore beschreiben lassen, dann die Beschreibung
# als TEXT-Kontext an Hermes (Lucy antwortet mit vollem Hirn/Gedächtnis, sieht via besserem VL-Modell).
with Timer("vision"):
desc = await _describe_images(imgs, body.text)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages.append({"role": "user", "content": user_text})
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucy-Hirn ist Thinking-Modell -> fuer die gesprochene Assistentin Thinking AUS (sonst 11k
# Reasoning-Token vor der kurzen Antwort, gemessen ~30s TTFB). Muster wie gateway_proxy/mem0.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
headers = { headers = {
"Authorization": f"Bearer {HERMES_API_KEY}", "Authorization": f"Bearer {HERMES_API_KEY}",
"X-Hermes-Session-Id": body.session_id, "X-Hermes-Session-Id": body.session_id,
@@ -222,6 +224,29 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
async def gen(): async def gen():
t0 = time.perf_counter() t0 = time.perf_counter()
first = True first = True
first_content = True
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
user_text = body.text
imgs = [u for u in (body.images or []) if u]
if imgs:
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
with Timer("vision"):
desc = await _describe_images(imgs, body.text)
if desc:
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
messages = []
if body.system:
messages.append({"role": "system", "content": body.system})
messages.append({"role": "user", "content": user_text})
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
payload["chat_template_kwargs"] = {"enable_thinking": False}
try: try:
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
async with client.stream( async with client.stream(
@@ -232,9 +257,14 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
return return
async for chunk in r.aiter_raw(): async for chunk in r.aiter_raw():
if first: # Time-To-First-Byte des Hermes-Streams (gefühlte Lucy-Latenz) if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0) record_stage("chat_ttfb", (time.perf_counter() - t0) * 1000.0)
first = False first = False
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + LLM-TTFT) —
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
if first_content and b'"content"' in chunk:
record_stage("chat_first_content", (time.perf_counter() - t0) * 1000.0)
first_content = False
yield chunk yield chunk
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
+3 -1
View File
@@ -221,7 +221,9 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
# KV-Cache-Reuse über Turns (Prompt-Cache wiederverwenden) — hilft allen Chat-Modellen # KV-Cache-Reuse über Turns (Prompt-Cache wiederverwenden) — hilft allen Chat-Modellen
# (Agent-Hirn, Coding, Multi-Turn). Spiegelt die auf der Box bewährten Flags wider, damit # (Agent-Hirn, Coding, Multi-Turn). Spiegelt die auf der Box bewährten Flags wider, damit
# neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix). # neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix).
if "--cache-reuse" not in cmd: # NICHT bei Vision-Modellen: --cache-reuse + --mmproj bricht llama-server (live verifiziert,
# deshalb fahren vision/scout auf der Box ohne cache-reuse).
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
cmd += " --cache-reuse 256 -cram 16384" cmd += " --cache-reuse 256 -cram 16384"
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage # IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6). # (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
+44 -2
View File
@@ -359,8 +359,44 @@ def swap_update_details() -> dict:
return info return info
# LLM-Zusammenfassung der anstehenden Hermes-Commits (die Box liest ihre Release-Notes selbst).
# Gecacht auf den neuesten Commit-Hash — das Modal darf beliebig oft geöffnet werden.
_relnotes_cache: dict = {"key": "", "summary": ""}
def _summarize_hermes_commits(commits: list[dict]) -> str:
key = commits[0]["hash"] if commits else ""
if not key:
return ""
if _relnotes_cache["key"] == key and _relnotes_cache["summary"]:
return _relnotes_cache["summary"]
from config import LLAMA_SWAP_URL
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
prompt = (
"Du bist der Update-Berater einer lokalen AI-Box (Hermes-Agent auf Strix Halo; genutzt werden: "
"api_server/Gateway, memory-provider-Plugin 'mc2-memory', terminal-/web-Tools, approvals, cron). "
"Hier die Commit-Titel des anstehenden Hermes-Updates:\n\n" + subjects + "\n\n"
"Fasse auf DEUTSCH in maximal 6 Stichpunkten zusammen: (1) Breaking Changes oder geänderte/"
"entfernte Config-Schlüssel (WICHTIGSTES zuerst, explizit warnen), (2) was unser Setup betrifft, "
"(3) welche neuen Features sich für uns lohnen. Keine Einleitung, nur die Punkte."
)
try:
r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={
"model": "fast", "max_tokens": 380, "temperature": 0.2,
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": prompt}],
})
r.raise_for_status()
summary = ((r.json().get("choices") or [{}])[0].get("message", {}).get("content") or "").strip()
if summary:
_relnotes_cache.update(key=key, summary=summary)
return summary
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
return f"(Zusammenfassung nicht verfügbar: {exc})"
def hermes_update_details() -> dict: def hermes_update_details() -> dict:
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>).""" """Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []} info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
git = system.find_hermes_agent_git() git = system.find_hermes_agent_git()
if not git or not git.get("path"): if not git or not git.get("path"):
@@ -382,6 +418,8 @@ def hermes_update_details() -> dict:
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]}) commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
info["commits"] = commits info["commits"] = commits
info["behind"] = len(commits) info["behind"] = len(commits)
if commits:
info["summary"] = _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
@@ -549,10 +587,14 @@ def hermes_update_job() -> dict:
backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh") backup = os.path.join(_REPO_ROOT, "deploy", "backup.sh")
postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh") postcheck = os.path.join(_REPO_ROOT, "deploy", "hermes-postcheck.sh")
# Backup → update → Gateway-Neustart → Gehirn-Check (Job wird rot, wenn Mem0/Plugin kaputt). # Backup → update → Gateway-Neustart → Gehirn-Check (Job wird rot, wenn Mem0/Plugin kaputt).
# Backup → update → doctor (Config/Deps-Diagnose der NEUEN Version) → Gateway-Neustart →
# Gehirn-Check (erweitert um Config-Drift-Scan, Tool- und Voice-Smoke — Lehre aus v0.18:
# 'approvals.mode auto' wurde still ungültig und legte alle Tools in pending_approval).
cmd = (f"bash {backup} || true; " cmd = (f"bash {backup} || true; "
f"cd {path} && {py} -m hermes_cli.main update --yes " f"cd {path} && {py} -m hermes_cli.main update --yes "
f"&& {py} -m hermes_cli.main doctor "
f"&& systemctl --user restart hermes-gateway " f"&& systemctl --user restart hermes-gateway "
f"&& sleep 4 && bash {postcheck}") f"&& sleep 6 && bash {postcheck}")
def on_done(): def on_done():
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen _comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
-31
View File
@@ -1,31 +0,0 @@
"""Spricht mit dem Hermes Agent Daemon auf der AI Box."""
import httpx
TIMEOUT = 120
def ask_agent(text: str, screenshot_b64: str | None, settings: dict) -> tuple[str, list]:
"""Schickt Nachricht an den Daemon, gibt (response, tool_calls) zurück."""
daemon_url = settings.get("agent_daemon_url", "http://192.168.178.151:8765")
try:
with httpx.Client(timeout=TIMEOUT) as c:
resp = c.post(f"{daemon_url}/chat", json={
"message": text,
"screenshot": screenshot_b64,
})
resp.raise_for_status()
data = resp.json()
return data.get("response", ""), data.get("tool_calls", [])
except httpx.TimeoutException:
return "Antwort hat zu lange gedauert.", []
except Exception as e:
return f"Agent nicht erreichbar: {e}", []
def get_agent_status(settings: dict) -> dict:
daemon_url = settings.get("agent_daemon_url", "http://192.168.178.151:8765")
try:
with httpx.Client(timeout=5) as c:
return c.get(f"{daemon_url}/status").json()
except Exception:
return {"alive": False}
-47
View File
@@ -1,47 +0,0 @@
import httpx
SYSTEM_PROMPT = (
"Du bist Hermes, ein hilfreicher KI-Assistent. "
"Antworte kurz und präzise, da deine Antwort vorgelesen wird. "
"Maximal 3-4 Sätze, kein Markdown."
)
REQUEST_TIMEOUT = 30
def ask(text: str, screenshot_b64: str | None, settings: dict) -> str:
"""Schickt Text (+ optionalen Screenshot) an MC2 und gibt die Antwort zurück."""
use_vision = screenshot_b64 is not None
model = settings.get("vision_model", "Qwen3-VL-2B-Instruct") if use_vision \
else settings.get("chat_model", "Hermes-4-14B")
base_url = settings.get("mc2_url", "http://192.168.178.151:9001/v1")
max_tokens = int(settings.get("max_response_tokens", 200))
if use_vision:
user_content = [
{"type": "text", "text": text},
{"type": "image_url", "image_url": {
"url": f"data:image/jpeg;base64,{screenshot_b64}"
}},
]
else:
user_content = text
payload = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
"max_tokens": max_tokens,
"stream": False,
}
try:
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
response = client.post(f"{base_url}/chat/completions", json=payload)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"].strip()
except httpx.TimeoutException:
return "Entschuldigung, die Antwort hat zu lange gedauert."
except Exception as e:
return f"Verbindungsfehler: {e}"
-91
View File
@@ -1,91 +0,0 @@
"""
Audio-Input: Aufnahme + Whisper STT.
Kein Wake-Word-Loop — Aufnahme startet direkt auf Hotkey-Signal.
"""
import os
import tempfile
import threading
import numpy as np
import sounddevice as sd
import scipy.io.wavfile as wav
from faster_whisper import WhisperModel
from pathlib import Path
SAMPLE_RATE = 16000
CHUNK = 1024
ENERGY_THRESHOLD = 0.008
_model: WhisperModel | None = None
_model_size: str = ""
_recording = False
_frames: list[np.ndarray] = []
_stream: sd.InputStream | None = None
_lock = threading.Lock()
def load_model(model_size: str):
global _model, _model_size
if _model is None or _model_size != model_size:
model_dir = Path.home() / ".hermes-voice" / "whisper-models"
model_dir.mkdir(parents=True, exist_ok=True)
_model = WhisperModel(
model_size,
device="cpu",
compute_type="int8",
download_root=str(model_dir),
)
_model_size = model_size
def start_recording():
global _recording, _frames, _stream
with _lock:
_recording = True
_frames = []
def callback(indata, frames, time, status):
if _recording:
_frames.append(indata[:, 0].copy())
_stream = sd.InputStream(
samplerate=SAMPLE_RATE,
channels=1,
dtype="float32",
blocksize=CHUNK,
callback=callback,
)
_stream.start()
def stop_recording() -> np.ndarray:
global _recording, _stream
with _lock:
_recording = False
if _stream:
_stream.stop()
_stream.close()
_stream = None
if not _frames:
return np.array([], dtype=np.float32)
return np.concatenate(_frames)
def transcribe(audio: np.ndarray, model_size: str, language: str) -> str:
if len(audio) < SAMPLE_RATE * 0.3:
return ""
load_model(model_size)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp = f.name
try:
wav.write(tmp, SAMPLE_RATE, (audio * 32767).astype(np.int16))
lang = language if language != "auto" else None
segments, _ = _model.transcribe(
tmp,
language=lang,
beam_size=3,
vad_filter=True,
)
return " ".join(s.text for s in segments).strip()
finally:
os.unlink(tmp)
-70
View File
@@ -1,70 +0,0 @@
import asyncio
import os
import tempfile
import threading
import time
import pygame
import edge_tts
pygame.mixer.init()
_stop_event = threading.Event()
_speak_lock = threading.Lock()
def stop_speaking():
_stop_event.set()
pygame.mixer.music.stop()
def speak(text: str, voice: str = "de-DE-SeraphinaMultilingualNeural", rate: str = "+0%"):
"""Text → Edge TTS → Lautsprecher. Blockiert bis fertig oder unterbrochen."""
with _speak_lock:
_stop_event.clear()
tmp_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
tmp_path = f.name
# Edge TTS in eigenem Event-Loop (Thread-sicher)
loop = asyncio.new_event_loop()
try:
communicate = edge_tts.Communicate(text, voice, rate=rate)
loop.run_until_complete(communicate.save(tmp_path))
finally:
loop.close()
if _stop_event.is_set():
return
pygame.mixer.music.load(tmp_path)
pygame.mixer.music.play()
# Polling ohne pygame.time.wait (blockiert Event-Loop nicht)
while pygame.mixer.music.get_busy():
if _stop_event.is_set():
pygame.mixer.music.stop()
break
time.sleep(0.05)
except Exception:
pass
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError:
pass
def play_ding():
"""Kurzer Aktivierungs-Ton (440 Hz, 120ms)."""
import numpy as np
sample_rate = 44100
duration = 0.12
t = np.linspace(0, duration, int(sample_rate * duration), False)
wave = (np.sin(2 * np.pi * 440 * t) * 0.3 * 32767).astype(np.int16)
stereo = np.column_stack([wave, wave])
sound = pygame.sndarray.make_sound(stereo)
sound.play()
time.sleep(duration + 0.02)
-46
View File
@@ -1,46 +0,0 @@
@echo off
cd /d "%~dp0"
echo ========================================
echo Hermes Voice Client -- Build (.exe)
echo ========================================
echo.
:: Venv pruefen
if not exist ".venv\Scripts\activate.bat" (
echo [FEHLER] Bitte erst setup.bat ausfuehren.
pause
exit /b 1
)
:: PyInstaller installieren falls noetig
.venv\Scripts\pip install pyinstaller -q
echo [1/2] Baue HermesVoice.exe ...
.venv\Scripts\pyinstaller ^
--onefile ^
--windowed ^
--name HermesVoice ^
--add-data "assets;assets" ^
--hidden-import "customtkinter" ^
--hidden-import "pynput.keyboard._win32" ^
--hidden-import "pynput.mouse._win32" ^
--collect-all "customtkinter" ^
main.py
echo.
if exist "dist\HermesVoice.exe" (
echo [2/2] Fertig!
echo.
echo dist\HermesVoice.exe ^(%.0f MB^)
echo.
echo Die .exe benoetigt beim ersten Start Internet fuer:
echo - Whisper-Modell-Download ^(~150MB fuer "small"^)
echo - Edge TTS ^(online^)
echo.
for %%I in ("dist\HermesVoice.exe") do echo Groesse: %%~zI Bytes
) else (
echo [FEHLER] Build fehlgeschlagen. Siehe build-Log oben.
)
echo ========================================
pause
-62
View File
@@ -1,62 +0,0 @@
# Hermes Voice Client — Konfiguration
# Alle Einstellungen hier anpassen, kein Code-Edit nötig.
# ── AI-Box ──────────────────────────────────────────────────────────────
MC2_BASE_URL = "http://192.168.178.151:9001/v1"
# Modell für reine Text-Anfragen (kein Screenshot)
CHAT_MODEL = "Hermes-4-14B"
# Modell wenn ein Screenshot mitgeschickt wird
VISION_MODEL = "Qwen3-VL-2B-Instruct"
# ── Wake Word ────────────────────────────────────────────────────────────
# Einfach den gewünschten Trigger-Text hier eintragen — kein Account, kein Download.
# Whisper transkribiert Sprache und prüft ob eines der Wörter enthalten ist.
# Mehrere Varianten möglich: ["hey hermes", "hermes", "hey jarvis"]
WAKE_WORDS = ["hey hermes", "hermes"]
# Whisper-Modell für die schnelle Wake-Detection (tiny = 39MB, sehr schnell)
WAKE_WHISPER_MODEL = "tiny"
# ── Spracheingabe ────────────────────────────────────────────────────────
# faster-whisper Modellgröße: "tiny", "base", "small", "medium"
# "small" läuft gut auf CPU (~244 MB), "base" ist schneller aber ungenauer
WHISPER_MODEL_SIZE = "small"
WHISPER_LANGUAGE = "de" # "de" für Deutsch, "en" für Englisch, None = auto
# Sekunden Stille bis Aufnahme endet
SILENCE_TIMEOUT = 1.5
# Maximale Aufnahmedauer in Sekunden (Sicherheitsnetz)
MAX_RECORD_SECONDS = 20
# ── Sprachausgabe ────────────────────────────────────────────────────────
# Edge TTS Stimmen: https://speech.microsoft.com/portal/voicegallery
# Deutsch: "de-DE-KillianNeural", "de-DE-SeraphinaMultilingualNeural"
# Englisch: "en-US-AndrewNeural", "en-US-AriaNeural"
TTS_VOICE = "de-DE-KillianNeural"
TTS_RATE = "+0%" # Geschwindigkeit: "+10%" schneller, "-10%" langsamer
TTS_PITCH = "+0Hz" # Tonhöhe
# ── Screen Capture ───────────────────────────────────────────────────────
# Screenshot bei jeder Anfrage mitschicken?
SCREENSHOT_ON_QUERY = True
# Monitor-Index (0 = alle, 1 = primär, 2 = zweiter Monitor)
SCREENSHOT_MONITOR = 1
# Auflösung für Screenshot (kleinere = schnellere Übertragung)
SCREENSHOT_WIDTH = 1280
SCREENSHOT_HEIGHT = 720
# ── Agent-Verhalten ──────────────────────────────────────────────────────
MAX_RESPONSE_TOKENS = 200 # kurze gesprochene Antworten
REQUEST_TIMEOUT = 30 # Sekunden bis Timeout
SYSTEM_PROMPT = """Du bist Hermes, ein KI-Assistent der direkt in den lokalen AI-Homelab integriert ist.
Du hörst die Stimme des Nutzers und siehst seinen Bildschirm.
Regeln für Antworten:
- Kurz und präzise (24 Sätze maximum)
- Kein Markdown, keine Aufzählungen, keine Codeblöcke — du wirst gesprochen
- Wenn du einen offensichtlichen Fehler auf dem Bildschirm siehst, weise kurz darauf hin
- Antworte auf Deutsch wenn der Nutzer Deutsch spricht, auf Englisch wenn Englisch
- Sei direkt und hilfreich, kein unnötiges Smalltalk"""
-39
View File
@@ -1,39 +0,0 @@
import json
from pathlib import Path
CONFIG_DIR = Path.home() / ".hermes-voice"
SETTINGS_FILE = CONFIG_DIR / "settings.json"
DEFAULTS = {
"mc2_url": "http://192.168.178.151:9001/v1",
"chat_model": "Hermes-4-14B",
"vision_model": "Qwen3-VL-2B-Instruct",
"hotkey": "ctrl_r",
"tts_voice": "de-DE-SeraphinaMultilingualNeural",
"tts_rate": "+0%",
"whisper_model": "small",
"language": "de",
"screenshot_enabled": True,
"screenshot_monitor": 1,
"max_response_tokens": 200,
"silence_timeout": 1.5,
}
def load() -> dict:
CONFIG_DIR.mkdir(exist_ok=True)
if SETTINGS_FILE.exists():
try:
saved = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
return {**DEFAULTS, **saved}
except Exception:
pass
return dict(DEFAULTS)
def save(settings: dict):
CONFIG_DIR.mkdir(exist_ok=True)
SETTINGS_FILE.write_text(
json.dumps(settings, indent=2, ensure_ascii=False),
encoding="utf-8",
)
-103
View File
@@ -1,103 +0,0 @@
"""
Globaler Push-to-Talk Hotkey via pynput.
Funktioniert auch wenn das Fenster minimiert/im Hintergrund ist.
"""
import threading
from pynput import keyboard
# Mapping: config-String → pynput Key/KeyCode
_SPECIAL = {
"ctrl_r": keyboard.Key.ctrl_r,
"ctrl_l": keyboard.Key.ctrl_l,
"alt_r": keyboard.Key.alt_r,
"alt_l": keyboard.Key.alt_l,
"shift_r": keyboard.Key.shift_r,
"shift_l": keyboard.Key.shift_l,
"f13": keyboard.Key.f13,
"f14": keyboard.Key.f14,
"f15": keyboard.Key.f15,
"f16": keyboard.Key.f16,
"caps_lock": keyboard.Key.caps_lock,
"scroll_lock": keyboard.Key.scroll_lock,
"pause": keyboard.Key.pause,
}
DISPLAY_NAMES = {
"ctrl_r": "Rechte Strg",
"ctrl_l": "Linke Strg",
"alt_r": "Rechte Alt",
"alt_l": "Linke Alt",
"shift_r": "Rechte Shift",
"shift_l": "Linke Shift",
"f13": "F13", "f14": "F14", "f15": "F15", "f16": "F16",
"caps_lock": "Caps Lock",
"scroll_lock": "Scroll Lock",
"pause": "Pause",
}
def key_to_str(key) -> str:
"""Konvertiert pynput Key → config-String."""
for name, k in _SPECIAL.items():
if key == k:
return name
if hasattr(key, "char") and key.char:
return key.char.lower()
return str(key).replace("Key.", "")
def str_to_display(key_str: str) -> str:
return DISPLAY_NAMES.get(key_str, key_str.upper())
class HotkeyListener:
def __init__(self, key_str: str, on_press_cb, on_release_cb):
self._key_str = key_str
self._on_press = on_press_cb
self._on_release = on_release_cb
self._pressed = False
self._listener: keyboard.Listener | None = None
def _target_key(self):
return _SPECIAL.get(self._key_str) or keyboard.KeyCode.from_char(self._key_str)
def _on_press_raw(self, key):
if not self._pressed and key == self._target_key():
self._pressed = True
self._on_press()
def _on_release_raw(self, key):
if self._pressed and key == self._target_key():
self._pressed = False
self._on_release()
def start(self):
self._listener = keyboard.Listener(
on_press=self._on_press_raw,
on_release=self._on_release_raw,
)
self._listener.start()
def stop(self):
if self._listener:
self._listener.stop()
def update_key(self, key_str: str):
self._key_str = key_str
self._pressed = False
class KeyCapturer:
"""Einmalig den nächsten Tastendruck abfangen für Hotkey-Konfiguration."""
def __init__(self, callback):
self._callback = callback
self._listener: keyboard.Listener | None = None
def start(self):
def on_press(key):
key_str = key_to_str(key)
self._listener.stop()
self._callback(key_str)
self._listener = keyboard.Listener(on_press=on_press)
self._listener.start()
-325
View File
@@ -1,325 +0,0 @@
"""
Hermes Voice Client — GUI
Push-to-Talk: Hotkey halten → sprechen → loslassen → Hermes antwortet
"""
import queue
import threading
import sys
import customtkinter as ctk
from PIL import Image, ImageDraw
import config_manager as cfg
from audio_input import start_recording, stop_recording, transcribe
from audio_output import speak, stop_speaking, play_ding
from screen_capture import capture_screen
from agent_client import ask_agent, get_agent_status
from hotkey_listener import HotkeyListener, KeyCapturer, str_to_display
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
# ── UI-Event-Queue (thread-safe) ─────────────────────────────────────────
_ui_queue: queue.Queue = queue.Queue()
def ui_event(event: str, data=None):
_ui_queue.put((event, data))
# ── Hauptfenster ─────────────────────────────────────────────────────────
class HermesApp(ctk.CTk):
def __init__(self):
super().__init__()
self.settings = cfg.load()
self._hotkey: HotkeyListener | None = None
self._worker: threading.Thread | None = None
self.title("Hermes Voice")
self.geometry("400x520")
self.resizable(False, False)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self._build_ui()
self._start_hotkey()
self.after(100, self._poll_queue)
# ── Layout ───────────────────────────────────────────────────────────
def _build_ui(self):
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
# Header
header = ctk.CTkFrame(self, height=50, corner_radius=0)
header.grid(row=0, column=0, sticky="ew")
header.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(header, text="⚡ Hermes Voice",
font=ctk.CTkFont(size=16, weight="bold")).grid(
row=0, column=0, padx=16, pady=12, sticky="w")
ctk.CTkButton(header, text="", width=36, height=36,
command=self._open_settings).grid(
row=0, column=1, padx=8, pady=8)
# Status
status_frame = ctk.CTkFrame(self, corner_radius=12)
status_frame.grid(row=1, column=0, padx=16, pady=(16, 8), sticky="ew")
status_frame.grid_columnconfigure(0, weight=1)
self._status_dot = ctk.CTkLabel(status_frame, text="",
font=ctk.CTkFont(size=32),
text_color="#22c55e")
self._status_dot.grid(row=0, column=0, pady=(12, 4))
self._status_label = ctk.CTkLabel(status_frame, text="Bereit",
font=ctk.CTkFont(size=14))
self._status_label.grid(row=1, column=0, pady=(0, 4))
self._hotkey_label = ctk.CTkLabel(
status_frame,
text=f"[ {str_to_display(self.settings['hotkey'])} ] gedrückt halten",
font=ctk.CTkFont(size=11),
text_color="gray",
)
self._hotkey_label.grid(row=2, column=0, pady=(0, 12))
# Conversation log
log_frame = ctk.CTkFrame(self, corner_radius=12)
log_frame.grid(row=2, column=0, padx=16, pady=8, sticky="nsew")
log_frame.grid_columnconfigure(0, weight=1)
log_frame.grid_rowconfigure(1, weight=1)
ctk.CTkLabel(log_frame, text="Letzte Konversation",
font=ctk.CTkFont(size=11), text_color="gray").grid(
row=0, column=0, padx=12, pady=(8, 0), sticky="w")
self._log = ctk.CTkTextbox(log_frame, font=ctk.CTkFont(size=12),
state="disabled", wrap="word")
self._log.grid(row=1, column=0, padx=8, pady=(4, 8), sticky="nsew")
# Footer
footer = ctk.CTkFrame(self, height=40, corner_radius=0)
footer.grid(row=3, column=0, sticky="ew")
footer.grid_columnconfigure(0, weight=1)
self._screenshot_var = ctk.BooleanVar(
value=self.settings["screenshot_enabled"])
ctk.CTkCheckBox(footer, text="Screenshot mitsenden",
variable=self._screenshot_var,
command=self._toggle_screenshot).grid(
row=0, column=0, padx=16, pady=8, sticky="w")
ctk.CTkButton(footer, text="Stop", width=60, height=28,
fg_color="#dc2626", hover_color="#b91c1c",
command=stop_speaking).grid(
row=0, column=1, padx=8, pady=8)
# ── Hotkey ───────────────────────────────────────────────────────────
def _start_hotkey(self):
if self._hotkey:
self._hotkey.stop()
self._hotkey = HotkeyListener(
self.settings["hotkey"],
on_press_cb=self._hotkey_pressed,
on_release_cb=self._hotkey_released,
)
self._hotkey.start()
def _hotkey_pressed(self):
ui_event("status", ("recording", "🔴 Ich höre…", "#ef4444"))
play_ding()
start_recording()
def _hotkey_released(self):
audio = stop_recording()
ui_event("status", ("thinking", "💭 Denke…", "#3b82f6"))
self._worker = threading.Thread(
target=self._process, args=(audio,), daemon=True)
self._worker.start()
def _process(self, audio):
text = transcribe(audio,
self.settings["whisper_model"],
self.settings["language"])
if not text:
ui_event("status", ("idle", "Bereit", "#22c55e"))
return
ui_event("log_user", text)
screenshot = (capture_screen()
if self.settings["screenshot_enabled"] else None)
ui_event("status", ("thinking", "💭 Denke…", "#3b82f6"))
response, tool_calls = ask_agent(text, screenshot, self.settings)
for tc in tool_calls:
ui_event("log_tool", tc)
ui_event("log_hermes", response)
ui_event("status", ("speaking", "🔊 Spricht…", "#a855f7"))
speak(response, self.settings["tts_voice"], self.settings["tts_rate"])
ui_event("status", ("idle", "Bereit", "#22c55e"))
# ── Queue-Polling ────────────────────────────────────────────────────
def _poll_queue(self):
while not _ui_queue.empty():
event, data = _ui_queue.get_nowait()
if event == "status":
_, label, color = data
self._status_label.configure(text=label)
self._status_dot.configure(text_color=color)
elif event == "log_user":
self._append_log(f"Du: {data}")
elif event == "log_hermes":
self._append_log(f"Hermes: {data}\n")
self.after(80, self._poll_queue)
def _append_log(self, text: str):
self._log.configure(state="normal")
self._log.insert("end", text + "\n")
self._log.see("end")
self._log.configure(state="disabled")
# ── Settings Dialog ──────────────────────────────────────────────────
def _open_settings(self):
SettingsWindow(self)
def _toggle_screenshot(self):
self.settings["screenshot_enabled"] = self._screenshot_var.get()
cfg.save(self.settings)
def apply_settings(self, new_settings: dict):
self.settings = new_settings
cfg.save(new_settings)
self._hotkey.update_key(new_settings["hotkey"])
self._hotkey_label.configure(
text=f"[ {str_to_display(new_settings['hotkey'])} ] gedrückt halten")
self._screenshot_var.set(new_settings["screenshot_enabled"])
def _on_close(self):
if self._hotkey:
self._hotkey.stop()
stop_speaking()
self.destroy()
# ── Settings Window ───────────────────────────────────────────────────────
class SettingsWindow(ctk.CTkToplevel):
def __init__(self, parent: HermesApp):
super().__init__(parent)
self._parent = parent
self.title("Einstellungen")
self.geometry("420x520")
self.resizable(False, False)
self.grab_set()
self._s = dict(parent.settings)
self._capturing = False
self._build()
def _build(self):
self.grid_columnconfigure(1, weight=1)
row = 0
def label(text):
nonlocal row
ctk.CTkLabel(self, text=text, anchor="w").grid(
row=row, column=0, padx=16, pady=6, sticky="w")
def entry(key, width=240):
nonlocal row
var = ctk.StringVar(value=str(self._s.get(key, "")))
e = ctk.CTkEntry(self, textvariable=var, width=width)
e.grid(row=row, column=1, padx=16, pady=6, sticky="ew")
row += 1
return var
def dropdown(key, options, width=200):
nonlocal row
var = ctk.StringVar(value=str(self._s.get(key, options[0])))
dd = ctk.CTkOptionMenu(self, values=options, variable=var, width=width)
dd.grid(row=row, column=1, padx=16, pady=6, sticky="w")
row += 1
return var
def toggle(key):
nonlocal row
var = ctk.BooleanVar(value=bool(self._s.get(key, False)))
sw = ctk.CTkSwitch(self, text="", variable=var)
sw.grid(row=row, column=1, padx=16, pady=6, sticky="w")
row += 1
return var
label("MC2 URL")
self._url = entry("mc2_url")
label("Hotkey")
self._hotkey_frame = ctk.CTkFrame(self, fg_color="transparent")
self._hotkey_frame.grid(row=row, column=1, padx=16, pady=6, sticky="w")
self._hotkey_display = ctk.CTkLabel(
self._hotkey_frame,
text=str_to_display(self._s.get("hotkey", "ctrl_r")),
width=100,
)
self._hotkey_display.grid(row=0, column=0, padx=(0, 8))
ctk.CTkButton(self._hotkey_frame, text="Aufnehmen", width=90,
command=self._capture_hotkey).grid(row=0, column=1)
row += 1
label("TTS Stimme")
self._voice = dropdown("tts_voice", [
"de-DE-SeraphinaMultilingualNeural",
"de-DE-AmalaNeural",
"de-DE-KatjaNeural",
"de-DE-KillianNeural",
"de-DE-ConradNeural",
"en-US-AriaNeural",
"en-US-JennyNeural",
])
label("Whisper Modell")
self._whisper = dropdown("whisper_model",
["tiny", "base", "small", "medium"])
label("Sprache")
self._lang = dropdown("language",
["de", "en", "auto"])
label("Screenshot")
self._screenshot = toggle("screenshot_enabled")
label("Monitor")
self._monitor = dropdown("screenshot_monitor", ["1", "2", "3"])
label("Max. Tokens")
self._tokens = entry("max_response_tokens", width=100)
row += 1
ctk.CTkButton(self, text="Speichern", command=self._save).grid(
row=row, column=0, columnspan=2, pady=16, padx=16, sticky="ew")
def _capture_hotkey(self):
self._hotkey_display.configure(text="Taste drücken…")
def on_key(key_str):
self._s["hotkey"] = key_str
self.after(0, lambda: self._hotkey_display.configure(
text=str_to_display(key_str)))
KeyCapturer(on_key).start()
def _save(self):
self._s["mc2_url"] = self._url.get().strip()
self._s["tts_voice"] = self._voice.get()
self._s["whisper_model"] = self._whisper.get()
self._s["language"] = self._lang.get()
self._s["screenshot_enabled"] = self._screenshot.get()
self._s["screenshot_monitor"] = int(self._monitor.get())
try:
self._s["max_response_tokens"] = int(self._tokens.get())
except ValueError:
pass
self._parent.apply_settings(self._s)
self.destroy()
# ── Entry Point ───────────────────────────────────────────────────────────
if __name__ == "__main__":
app = HermesApp()
app.mainloop()
-29
View File
@@ -1,29 +0,0 @@
# Hermes Voice Client — Dependencies
# Installation: pip install -r requirements.txt
# Speech-to-Text (lokal auf CPU)
faster-whisper>=1.0.0
# Audio I/O
sounddevice>=0.4.6
numpy>=1.24.0
scipy>=1.11.0
# Text-to-Speech
edge-tts>=6.1.9
# Screen Capture
mss>=9.0.1
Pillow>=10.0.0
# HTTP Client
httpx>=0.27.0
# Audio Playback
pygame>=2.5.0
# GUI
customtkinter>=5.2.0
# Globaler Hotkey
pynput>=1.7.6
-19
View File
@@ -1,19 +0,0 @@
import base64
import io
import mss
from PIL import Image
from config import SCREENSHOT_MONITOR, SCREENSHOT_WIDTH, SCREENSHOT_HEIGHT
def capture_screen() -> str:
"""Screenshot des primären Monitors als base64-JPEG."""
with mss.mss() as sct:
monitor = sct.monitors[SCREENSHOT_MONITOR]
raw = sct.grab(monitor)
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
img = img.resize((SCREENSHOT_WIDTH, SCREENSHOT_HEIGHT), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=80)
return base64.b64encode(buf.getvalue()).decode("utf-8")
-44
View File
@@ -1,44 +0,0 @@
@echo off
:: Immer aus dem eigenen Verzeichnis laufen
cd /d "%~dp0"
echo ========================================
echo Hermes Voice Client -- Setup
echo ========================================
echo.
:: Python pruefen
python --version >nul 2>&1
if errorlevel 1 (
echo [FEHLER] Python nicht gefunden. Bitte Python 3.11+ installieren.
pause
exit /b 1
)
:: Venv anlegen falls nicht vorhanden
if not exist ".venv" (
echo [1/3] Erstelle virtuelle Umgebung...
python -m venv .venv
)
:: Dependencies installieren
echo [2/3] Installiere Dependencies...
.venv\Scripts\pip install --upgrade pip -q
.venv\Scripts\pip install -r requirements.txt
:: Kurz-Check
echo [3/3] Pruefe Konfiguration...
.venv\Scripts\python -c "import customtkinter; import pynput; print('GUI + Hotkey OK')"
echo.
echo ========================================
echo Setup abgeschlossen!
echo.
echo Naechste Schritte:
echo 1. start.bat ausfuehren
echo 2. Einstellungen (Zahnrad) pruefen:
echo - MC2 URL (Standard: 192.168.178.151:9001)
echo - Hotkey (Standard: Rechte Strg)
echo Optional: build.bat fuer .exe-Kompilierung
echo ========================================
pause
-4
View File
@@ -1,4 +0,0 @@
@echo off
:: Hermes Voice Client starten (kein Konsolenfenster im Hintergrund)
cd /d "%~dp0"
start "" .venv\Scripts\pythonw.exe main.py
+38
View File
@@ -38,6 +38,44 @@ else
echo "FAIL · mc2-memory-Plugin lädt nicht (MemoryProvider-ABC geändert?)"; fail=1 echo "FAIL · mc2-memory-Plugin lädt nicht (MemoryProvider-ABC geändert?)"; fail=1
fi fi
# --- Config-Drift-Wächter (Lehre aus v0.18, 02.07.2026): Hermes fällt bei unbekannten ---
# --- Config-Werten STILL auf Defaults zurück (approvals.mode 'auto' -> manual -> alle ---
# --- Tools hingen in pending_approval). Solche Warnungen müssen den Job ROT machen. ---
if journalctl --user -u hermes-gateway --since "-3 minutes" --no-pager -o cat 2>/dev/null \
| grep -iE "Unknown [a-z._]+ '|deprecated .*(setting|option|config)|defaulting to|invalid config" \
| grep -v "check_fn" | head -5 | tee /tmp/hermes-config-warnings.txt | grep -q .; then
echo "FAIL · Config-Warnungen nach Update (siehe oben) — Config an neue Version anpassen!"; fail=1
else
echo "PASS · keine Config-Drift-Warnungen im Gateway-Log"
fi
# --- Tool-Smoke: ein harmloser terminal-Befehl durch den echten Agenten. Fängt kaputte ---
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
if [ -n "$API_KEY" ]; then
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis"
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
else
echo "FAIL · Tool-Smoke ohne verwertbares Ergebnis: $(echo "$TOOL_RESP" | head -c 200)"; fail=1
fi
else
echo "SKIP · Tool-Smoke (kein API_SERVER_KEY in $HERMES/.env gefunden)"
fi
# --- Voice-Smoke: Lucys Sprech-Pfad antwortet zügig (first byte des SSE-Streams). ---
if curl -sf -m 30 -N -X POST "$MC_URL/api/voice/chat" -H "Content-Type: application/json" \
-d '{"text":"Sag nur ok.","session_id":"postcheck-voice-smoke"}' 2>/dev/null | head -c 50 | grep -q "data:"; then
echo "PASS · Voice-Smoke (Lucys /api/voice/chat streamt)"
else
echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht"; fail=1
fi
if [ "$fail" -eq 0 ]; then if [ "$fail" -eq 0 ]; then
echo "=== GEHIRN OK ✓ ===" echo "=== GEHIRN OK ✓ ==="
else else
+110
View File
@@ -0,0 +1,110 @@
# Auto-generiert von provision.sh - per Mission Control erweiterbar
healthCheckTimeout: 300
globalTTL: 0
models:
Qwen3.6-35B-A3B:
# parallel 2 + c 131072: 2 Slots à 65k (Slot 2 = Mem0-Lern-Extraktion, blockiert Voice-Turns
# nicht mehr); KV Q8_0 macht das speicherneutral zu vorher (65k f16). Bench 2026-07-02:
# Q8_0 kostet 0 t/s, MTP n-max 3 = +26% vs. ohne Spec.
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --parallel 2 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-mtp --spec-draft-n-max 3
ttl: 0
aliases:
- hermes
- fast
# context = nutzbarer Kontext PRO REQUEST (c geteilt durch parallel-Slots).
capabilities:
in: [text]
out: [text]
tools: true
context: 65536
Qwen3.5-122B-A10B:
cmd: |
llama-server -m /srv/models/Qwen3.5-122B-A10B-GGUF/Q4_K_M/Qwen3.5-122B-A10B-Q4_K_M-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- heavy
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-Coder-Next:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-Next-GGUF/Qwen3-Coder-Next-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
aliases:
- coder
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
Qwen3-VL-8B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-VL-8B-Instruct-GGUF/Qwen3VL-8B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-8B-Instruct-GGUF/mmproj-Qwen3VL-8B-Instruct-F16.gguf --jinja
ttl: 300
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
context: 32768
Qwen3-Embedding-0.6B:
cmd: |
llama-server -m /srv/models/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf --host 127.0.0.1 --port ${PORT} --embedding --pooling last -ngl 999 -fa on --no-mmap -c 8192
ttl: 0
aliases:
- embed
Qwen3-Coder-30B-A3B-Instruct:
cmd: |
llama-server -m /srv/models/Qwen3-Coder-30B-A3B-Instruct-GGUF/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 300
aliases:
- coder-lite
capabilities:
in: [text]
out: [text]
tools: true
context: 131072
gpt-oss-120b:
# heavy-Kandidat (Bench 02.07.: tg 54-55 t/s vs. Qwen3.5-122B 23,5 t/s = 2,3x, 60 statt
# 73 GB). Noch OHNE heavy-Alias — erst Qualitaets-Check (deutsch/Reasoning), dann Entscheid.
cmd: |
llama-server -m /srv/models/gpt-oss-120b-GGUF/gpt-oss-120b-mxfp4-00001-of-00003.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --jinja --cache-reuse 256 -cram 16384
ttl: 600
capabilities:
in: [text]
out: [text]
tools: true
context: 32768
Qwen3-VL-30B-A3B-Instruct:
# vision-Upgrade-Kandidat (Bench 02.07.: tg 90-92 t/s, MoE 3B aktiv, stark auf GUI-Benchmarks).
# Noch OHNE vision-Alias — erst Screenshot-Qualitaets-Check, dann Entscheid (brains-Budget!).
cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --no-mmap --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 300
capabilities:
in: [text, image]
out: [text]
context: 32768
GLM-4.6V-Flash:
cmd: |
llama-server -m /srv/models/GLM-4.6V-Flash-GGUF/GLM-4.6V-Flash-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --no-mmap --mmproj /srv/models/GLM-4.6V-Flash-GGUF/mmproj-BF16.gguf --jinja
ttl: 300
aliases:
- scout
capabilities:
in: [text, image]
out: [text]
tools: true
context: 131072
groups:
brains:
swap: false
persist: true
members:
- Qwen3-Embedding-0.6B
- Qwen3-VL-8B-Instruct
- Qwen3.6-35B-A3B
+75
View File
@@ -0,0 +1,75 @@
# Kickoff: Voll-Audit MC2 (Front+Backend) + Box-SSH + Optimierungsplan
> Für eine **frische Claude-Code-Session mit echtem Terminal** (direkter SSH-Zugang zur Box).
> Aufgabe vom Nutzer: front/backend komplett scannen (IST-Zustand), via SSH auf die Box gehen
> (ausdrücklich erlaubt), und am Ende einen **sauberen, strukturierten Plan** liefern, wie es
> weitergeht — inkl. Optimierungen für ALLE LLM-Rollen und einem **neuen `scout`-Modell**.
> Erst auditieren/planen — keine destruktiven Änderungen ohne Plan-Freigabe.
## Zugänge
- **Repo (Dev-PC):** `F:\Coding Stuff\mission-control-2` (Windows). Auf der Box: `~/mission-control-v2`.
- **Box:** `ssh hitonabi@192.168.178.151` (key-based, funktioniert non-interaktiv). Hostname
`tobisniceaiarbeitstier`, AMD Ryzen AI MAX+ 395 (Strix Halo), 122 GB RAM, GTT ~124 GB, Vulkan/RADV.
- **Wichtige Box-Pfade:** `/etc/llama-swap/config.yaml` (Engine, `-watch-config`),
`~/.hermes/config.yaml` (Hermes-Agent = Lucys Hirn), `/srv/models/` (GGUFs),
`/opt/llamacpp-vulkan/llama-server` (Engine v9843).
- **Dienste (systemd --user):** `hermes-gateway` (:8642), `hermes-webui` (:8787), llama-swap (:8080),
MC2-Gateway (:9001). Live-Logs: `journalctl --user -u hermes-gateway -f`.
## Architektur (verifiziert 30.06.2026)
- **llama-swap** (:8080) lädt GGUFs; Rollen = llama-swap **`aliases`**; Warm-Bleiben über
`groups: { brains: { swap:false } }` + `ttl`.
- **MC2-Gateway** (:9001/v1, FastAPI) mit virtuellen Lanes `chat`(→fast/heavy) & `coding`(→coder_lite/coder).
Code: `backend/services/router_logic.py`, `routing_policy.py`, `roles.py`, `llamaswap.py`, `budget.py`,
`fit.py`; Router `backend/routers/{models,routing,voice}.py`. Frontend: `frontend/src/views/ModelsView.tsx`,
`components/models/*`, `SystemDrawer.tsx`.
- **Hermes-Agent** (:8642) = Lucys Hirn (`~/.hermes/config.yaml: model.default`), Delegation `heavy`.
- **Lucy-Client** (`client/lucy-desktop`, Electron) spawnt lokal pocket-tts (:8130); Persona+Umlaut-Logik
im Client (`renderer/src/config.ts`, `lib/voice/useVoiceAgent.ts`).
- **Features sind eigene Rollen** (hirn-unabhängig): Sehen=`Qwen3-VL-8B`, Hören=Whisper(`stt`),
Sprechen=pocket-tts, Embedding=`Qwen3-Embedding-0.6B`(ttl 0, immer warm), Gedächtnis=Mem0/MCP.
## Modelle / Rollen (Stand jetzt)
| Rolle (alias) | Modell | aktiv | ctt/ttl | Notiz |
|---|---|---|---|---|
| fast | Qwen3.6-35B-A3B | 3B | ttl 0, --parallel 2, mmproj | chat-Lane Default |
| heavy | Qwen3.5-122B-A10B | 10B | ttl 600 | Delegation-Ziel |
| coder | Qwen3-Coder-Next | — | ttl 600, spec draft-simple (Qwen3-0.6B) | |
| coder_lite | Qwen3-Coder-30B-A3B | 3B | ttl 300 | coding-Lane Default |
| vision | Qwen3-VL-8B-Instruct | — | ttl 300 | Screen-Beschreibung |
| (embedding) | Qwen3-Embedding-0.6B | — | ttl 0 | immer warm |
| **brain (NEU, Lucy)** | **gemma-4-26B-A4B-it** | 4B | ttl 0, **noch Alias `scout`** | Hermes default seit 30.06. |
## Bereits gemacht (30.06.)
- Lucys Hirn `fast`**`gemma-4-26B-A4B-it`** (`~/.hermes/config.yaml model.default`, Backup
`~/.hermes/config.yaml.gemma-swap.bak`, hermes-gateway neu gestartet, verifiziert).
- Umlaut-Fix: Client-Stammliste erweitert (`useVoiceAgent.ts`) **und** Server-Netz `_fix_umlauts`
(`client/lucy-tts/pocket_server.py`). Hermes-System-Prompt fordert echte Umlaute (Client-Persona).
- pocket-tts getunt: A1 safetensors-Voice-Cache, A2 Referenz-Cleaning, B3 FP-Gate skip kurze Audios,
FAST_FIRST (kurzer 1. Chunk, TTFB ~1,3s), seriell (WORKERS=0) als Default. Details: `docs/LUCY_TTS_PLAN.md`.
## Offene Probleme / Aufgaben
1. **Brain-Rolle fehlt als Konzept.** gemma läuft als Alias `scout`, ist NICHT in `brains: swap:false`
→ unter Speicherdruck evakuierbar trotz ttl 0. Detail-Brief: **`docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md`**
(Backend+Frontend-Umbau: erstklassige `brain`-Rolle, auto-warm/ko-resident, Hermes-Sync).
2. **`scout`-Rolle jetzt vakant** (gemma war scout, ist jetzt Hirn). **Neues scout-Modell suchen:**
multimodaler Allrounder, klein/MoE (schnell, KO-Residenz-freundlich), Tools wünschenswert. Tagesaktuell
recherchieren + Empfehlung.
3. **MTP-Speculative-Decoding für gemma** (1,52× Durchsatz, 0 Qualitätsverlust): Engine kann `draft-mtp`
✓; MTP-Drafter `gemma-4-26B-A4B-it-assistant` (~0,4B) muss nach `/srv/models/`, gemma-cmd ergänzen
(`-md <assistant.gguf> --spec-type draft-mtp --spec-draft-n-max/-n-min`; **alte** `--draft-max/-min`
sind entfernt!). MC2-UI/Backend kennt MTP-Drafter nicht (zeigte „Vocab ?") → erweitern. (Im Brain-Brief.)
4. **Optimierung ALLER Rollen prüfen:** je Modell ctx/quant/ttl/Warm-Gruppe/Parallelität/Spec-Decoding
(MTP für Gemma; Draft-Eignung für Qwen-Modelle) gegen das GTT-/RAM-Budget (~124 GB) und reale t/s
auf Strix Halo (bandbreitenlimitiert → MoE bevorzugt; dichte Modelle langsam). KO-Residenz-Set
sinnvoll definieren (was muss gleichzeitig warm sein für Lucy + IDE-Coding?).
## Deliverable
Ein **strukturierter Plan** (z.B. `docs/OPTIMIZATION_PLAN.md`): IST-Zustand → konkrete Maßnahmen je Rolle
(mit Begründung/Zahlen) → Reihenfolge/Risiko/Revert → offene Entscheidungen. Erst Plan, dann Umsetzung
nach Freigabe. Alles reversibel (Config-Backups vor jeder Änderung).
## Leitplanken
- Features (Sehen/Hören/Sprechen/Embedding/Gedächtnis) + IDE-Lanes dürfen NICHT brechen.
- Vor jeder Box-Config-Änderung Backup; llama-swap reloadt per `-watch-config`.
- Keine destruktiven Aktionen ohne Plan-Freigabe des Nutzers.
+106
View File
@@ -0,0 +1,106 @@
# Claude-Code-Auftrag: „Brain"-Rolle für Lucy (Front- + Backend-Umbau)
> Ziel: Lucys Hirn (aktuell `gemma-4-26B-A4B-it`) als **erstklassige, dauer-warme Rolle** im MC2-Stack
> verankern — statt es als `scout` zu führen, das nach `ttl` entladen wird. Features (Sehen/Hören/
> Sprechen/Embedding) dürfen NICHT verloren gehen, KO-Residenz + Budget müssen sauber bleiben.
## Kontext / Architektur (Ist-Zustand, verifiziert 30.06.)
- **Engine:** llama-swap (config: `/etc/llama-swap/config.yaml` auf der Box, läuft mit `-watch-config`).
Rollen werden als llama-swap **`aliases`** gesetzt; Warm-Bleiben über `groups:` (eine Gruppe
`brains: { swap: false }`) + `ttl`. Installierte Modelle: `Qwen3.6-35B-A3B` (fast, ttl 0),
`Qwen3.5-122B-A10B` (heavy, ttl 600), `Qwen3-Coder-Next` (coder, ttl 600),
`Qwen3-Coder-30B-A3B-Instruct` (coder_lite, ttl 300), `Qwen3-VL-8B-Instruct` (vision, ttl 300),
`Qwen3-Embedding-0.6B` (ttl 0 = immer warm), `gemma-4-26B-A4B-it` (ttl 180).
- **Gateway:** MC2 builtin auf `:9001/v1` mit virtuellen Lanes `chat` (→ fast/heavy) und
`coding` (→ coder_lite/coder). Code: `backend/services/router_logic.py`, `routing_policy.py`.
**Lucy nutzt die Lanes NICHT** — sie ist der Hermes-Agent.
- **Lucy-Hirn:** Hermes-Agent (`:8642`), Brain = `~/.hermes/config.yaml``model.default`
(jetzt `gemma-4-26B-A4B-it`), Delegation = `heavy`. Persona/Prompt kommt aus dem **Client**
(`client/lucy-desktop/src/renderer/src/config.ts`), nicht aus der Box-Config.
- **Hardware:** Strix Halo (Ryzen AI Max+ 395), 122 GB, GTT ~124 GB, bandbreitenlimitiert →
MoE mit wenig aktiven Params ist schnell, dichte Modelle langsam.
## Root-Cause des Bugs
1. Kanonische Rollen `ROLE_IDS = {fast, heavy, coder, vision, scout}`**keine `brain`/Agent-Rolle**.
(Kommentar im Code: „kein agent/reasoning mehr".)
2. Es gibt keine Verknüpfung „Hirn-Modell ⇒ muss warm bleiben". gemma trägt den Alias `scout`,
ist NICHT in der `brains`-Gruppe, `ttl 180` → entlädt im Leerlauf → Lucy lädt kalt nach (~510 s).
3. `roles.py` referenziert noch eine `hermes`-Rolle, die in `ROLE_IDS` nicht existiert → Inkonsistenz.
## Aufgabe
### Backend (`backend/`)
1. **Neue erstklassige Rolle `brain` einführen** (oder `hermes` reaktivieren) und in ALLEN
Quellen synchronisieren (heute dupliziert!):
- `services/llamaswap.py``ROLE_IDS`
- `services/sources.py``ROLE_IDS` + Titel/Icon (analog `scout: {title, icon}`)
- `services/maintenance.py` (Modell-Upgrade-Rollenliste)
- `services/roles.py``_capability_suit`/`_pref`/`_reason` für `brain` (Tools Pflicht,
mittlere Größe + MoE bevorzugt, niedrige Latenz). `hermes`-Altlast bereinigen.
2. **Rolle `brain` ⇒ automatisch warm + ko-resident.** Beim Zuweisen der `brain`-Rolle
(`POST /api/models/{model_id}/role`, `routers/models.py`):
- Modell via `set_group("brains", members=[...], swap=False, persist=True)` in die Warm-Gruppe
aufnehmen (bestehender Helper in `llamaswap.py`).
- `ttl: 0` setzen (oder hoch), vorheriges Brain-Modell aus `brains` entfernen + ttl entspannen.
- Embedding (`Qwen3-Embedding-0.6B`, ttl 0) MUSS warm bleiben — nicht verdrängen.
3. **Single Source of Truth für „Lucys Hirn":** beim Setzen der `brain`-Rolle auch Hermes'
`~/.hermes/config.yaml``model.default` auf das Modell setzen + `systemctl --user restart
hermes-gateway` (mit Backup der config). So bleibt MC2-UI-Auswahl ↔ Hermes-Brain konsistent.
4. **Budget/KO-Residenz-Safety:** Brain(warm) + Embedding(warm) + on-demand Vision + Coder müssen
in GTT (~124 GB) passen. `services/budget.py`/`fit.py` nutzen, bei OOM warnen (nicht hart laden).
5. **Lanes/IDEs unangetastet lassen:** chat/coding-Routing (`router_logic.py`) bleibt wie es ist;
`fast` bleibt für die chat-Lane warm.
### Frontend (`frontend/src/`)
1. **Rollen-Taxonomie** um `brain` erweitern (Badges/Titel/Icon) — Duplikat zur Backend-Liste
finden & angleichen (`components/models/*`, `views/ModelsView.tsx`, evtl. `ModelBadges.ROLES`).
gemma darf NICHT mehr als `scout` erscheinen.
2. **Rollen-Zuweisungs-Modal:** `brain` auswählbar; Warm-/KO-Residenz-Status anzeigen
(ist das Brain in `brains`? warm? ttl?). Empfehlung via bestehendem `/api/roles/{role}/recommend`.
3. **Warm-Set / GTT-Budget sichtbar machen** (Dashboard/Models): was ist gerade ko-resident,
passt es ins Budget? (nutzt `/api/models` `running` + budget-Infos).
4. Optional: „Lucy-Hirn"-Selector, der die `brain`-Rolle setzt (treibt Backend #2 + #3).
### Zusatz: MTP-Speculative-Decoding für Gemma (Durchsatz für agentische Arbeit)
Lucy ist ein **voller Agent** (Tools/MCP/PC-Control/Vision/Delegation) — Durchsatz zählt, nicht nur
TTFB. Gemma 4 bringt **MTP** (Multi-Token-Prediction) mit → 1,52× schneller bei null Qualitätsverlust.
- **Engine kann es bereits:** `llama-server` v9843 listet `--spec-type … draft-mtp …`. ✓
- **Vocab-kompatibler „Draft" = Gemmas eigener MTP-Kopf** `gemma-4-26B-A4B-it-assistant` (~0,4B,
identischer Tokenizer per Konstruktion). Quelle z.B. `unsloth/gemma-4-26B-A4B-it-GGUF` (enthält den
MTP-Drafter, PR ggml-org/llama.cpp#23398). Muss nach `/srv/models/...` geladen werden (noch nicht da).
- **Flag-Änderung beachten:** `--draft-max/--draft-min` sind ENTFERNT → `--spec-draft-n-max` /
`--spec-draft-n-min`. Drafter laden via `-md <assistant.gguf> --spec-type draft-mtp`. Exakte Flags
des Builds mit `llama-server --help` gegenprüfen.
- **MC2-Bug:** die Spec-Draft-Auswahl (UI + Backend) kennt **nur klassische Drafts** (vergleicht stur
`n_vocab`/`pre`) und bot fälschlich nur den Qwen-Draft an („Vocab ?"). **Erweitern:** MTP-Drafter
(`gemma4_assistant`-Arch) als gültigen, vocab-kompatiblen Draft erkennen, den passenden `-assistant`-
GGUF anbieten, und beim Aktivieren `-md … --spec-type draft-mtp --spec-draft-n-max/-n-min` in die
llama-swap-cmd schreiben. Co-Residenz: Drafter ~0,4B → vernachlässigbar.
## Akzeptanzkriterien
- [ ] `gemma-4-26B-A4B-it` wird in der UI als **`brain`** geführt (nicht `scout`).
- [ ] Es bleibt **warm**: in `brains: swap:false`, `ttl 0`; überlebt > alter ttl Leerlauf
(Verifikation: `curl :8080/running` nach >5 Min Idle zeigt das Modell weiterhin `ready`).
- [ ] Lucy antwortet ohne Kalt-Nachladen nach Leerlauf.
- [ ] Vision (`Qwen3-VL-8B`), Embedding (`Qwen3-Embedding-0.6B`), Coder bleiben funktionsfähig &
ko-resident; **kein OOM**; IDE-Lanes (chat/coding) unverändert.
- [ ] Brain-Wechsel in der UI aktualisiert llama-swap (Warm-Gruppe) UND Hermes `model.default`
(+ Restart), reversibel (Backup).
## Sofort-Hotfix (unabhängig vom Umbau — macht Lucy JETZT warm)
Auf der Box, bis der saubere Umbau steht:
```bash
# gemma als immer-warm + in die brains-Gruppe (Backup zuerst!)
cp /etc/llama-swap/config.yaml /etc/llama-swap/config.yaml.bak
# gemma ttl 180 -> 0 und groups.brains.members um gemma-4-26B-A4B-it ergänzen
# (manuell editieren oder via MC2 set_group), dann:
# llama-swap reloadt per -watch-config automatisch.
```
## Wichtige Dateien (Einstieg)
- Backend: `services/llamaswap.py` (Rollen=aliases, `set_role_alias`, `set_group`, `register_model`),
`services/roles.py`, `services/sources.py`, `services/routing_policy.py`,
`services/router_logic.py`, `services/budget.py`, `services/fit.py`, `routers/models.py`,
`routers/routing.py`.
- Frontend: `views/ModelsView.tsx`, `components/models/*`, `components/SystemDrawer.tsx`.
- Box (nicht im Repo): `/etc/llama-swap/config.yaml`, `~/.hermes/config.yaml`.
+326
View File
@@ -0,0 +1,326 @@
# Konzept: Bare-Metal-Wiederaufbau + First-Run-Wizard (VOLLSTÄNDIG)
> **Zweck:** Plan für den „hard crash"-Fall — die Box (`tobisniceaiarbeitstier`, Ubuntu 26.04,
> AMD Ryzen AI MAX+ 395 / gfx1151, 122 GB RAM) muss von **null** wiederherstellbar sein.
> **Anspruch:** *ALLES* ist erfasst — Engine, Hermes-Konfig 1:1, 3D-Avatare, Voice/Klonstimme,
> Memory, Browser, MCP, Skills, Secrets. Pro Komponente entscheidet der Nutzer im Wizard:
> **1:1 zurück** · **neu/Default** · **weglassen**.
>
> Dieses Dokument ist die **Spezifikation für eine künftige Implementierungs-Session** — es baut
> noch nichts. Stand: 2026-06-28.
---
## 1. Leitprinzip: 1:1 ODER modular — der Nutzer wählt
Der Wizard behandelt jede Komponente als **eigene Kachel mit drei Modi**:
| Modus | Bedeutung |
|---|---|
| 📦 **1:1 aus Backup** | Exakter alter Zustand wird zurückgespielt (Configs, Daten, Klonstimme, Avatare). |
| 🆕 **Neu / Default** | Frische Installation mit sinnvollen Defaults (z.B. entfesseltes Hermes-Profil, Default-Avatar). |
| ⏭️ **Weglassen** | Komponente wird (vorerst) nicht installiert. |
Ein „Alles 1:1"-Knopf wählt überall 📦 (wo ein Backup existiert), sonst 🆕. So bekommt der Nutzer
entweder den exakten alten Stand oder kann gezielt entrümpeln.
---
## 2. Was es schon gibt (wiederverwenden)
| Asset | Datei | Deckt ab |
|---|---|---|
| Code-Deploy | `deploy/deploy.sh` | git pull + venvs + Units + Restart. **Setzt Engine/llama-swap/Dirs/venvs voraus.** |
| Zustands-Backup | `deploy/backup.sh` + `mc2-backup.{service,timer}` | **Aktuell** (live): mem0, `~/.hermes/{config.yaml,.env,plugins}`, llama-swap-config. Retention 14. **Für echtes 1:1 zu erweitern** — fertiges Snippet in **Anhang B** (§5). |
| Restore | `deploy/restore.sh` | Spielt Tarball zurück (mit Pre-Restore-Sicherung). |
| Engine | `deploy/provision-engine.sh` | llama.cpp Vulkan/RADV-Build (root). |
| Voice-Setup | `voice_service/install.sh` | venv + Piper-Binary + dt. Piper-Stimmen + Chatterbox (best-effort). |
| Updates | `deploy/update-engine.sh`, `update-swap.sh` | Laufende Engine/Router-Updates. |
| Postchecks | `deploy/stack-postcheck.sh`, `hermes-postcheck.sh` | Funktionsprüfung → Wizard-Verifikationsschritt. |
---
## 3. VOLLSTÄNDIGER Komponenten-Katalog
> **Scan-verifiziert (2026-06-28)** gegen `backend/config.py`, alle `backend/services/*` & `routers/*`,
> `frontend/src/{nav.ts,views,components/voice}`, `voice_service/app.py`, `mem0_service/`, `deploy/*`.
> Alle persistenten Schreibpfade, Dienste, Secrets und Env-Vars sind unten erfasst.
Legende Restore-Quelle: 📦 = aus Backup-Tarball · ⬇️ = Re-Download/Install (Skript) · 🌐 = Git ·
🔑 = Secret (Nutzer/Generieren) · 🆕 = im Wizard neu gewählt.
„Im Backup?" = deckt der **aktuelle** `backup.sh` es ab.
### A · OS & System (L0, root/sudo, einmalig)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Ubuntu 26.04, User `hitonabi`, `enable-linger` | — | ⬇️ manuell | n/a |
| System-Pakete: python3.14+venv, git, curl, jq, ttyd, uv | — | ⬇️ apt/curl | nein |
| Vulkan-Stack: mesa-vulkan-drivers, libvulkan1, vulkan-tools | — | ⬇️ apt | nein |
| Chrome-Libs (Browser): `agent-browser install --with-deps` | — | ⬇️ apt | nein |
| Verzeichnis-Layout: `/srv/models/{,mem0,mc2-backups,drafts}`, `/etc/llama-swap/` | — | ⬇️ mkdir+chown | nein |
### B · Inferenz-Engine + Router (L1, root)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| llama.cpp (Vulkan) → `llama-server` | `/opt/llamacpp-vulkan`, `/usr/local/bin/llama-server` | ⬇️ provision-engine.sh / update-engine.sh (ggml-org Release) | nein (re-build) |
| **llama-swap** Binary (Router `:8080`) | `/usr/local/bin/llama-swap` | ⬇️ **bekannt:** `mostlygeek/llama-swap`-Release (Rezept in `update-swap.sh`) | nein (re-download) |
| **llama-swap systemd-System-Unit** | `/etc/systemd/system/llama-swap.service` (+ `.d/` Drop-ins) | ⬇️ Bootstrap legt sie an — **kompletter Unit-Inhalt in Anhang A** (von der Box abgegriffen; Drop-ins via provision-engine.sh) | nein |
| llama-swap-Config (Modelle/Rollen) | `/etc/llama-swap/config.yaml` | 📦 | **ja** |
| Draft-Modelle (Spec-Decoding) | `/srv/models/drafts/` | ⬇️ Re-Download | nein |
### C · MC2-App (L2, userspace)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| MC2-Code | `~/mission-control-v2` | 🌐 Git (Gitea) | n/a |
| Backend-venv (Python 3.14) | `backend/.venv` | ⬇️ deploy.sh | nein (rebuild) |
| Frontend (gebaut, inkl. **3D-Avatar `avatar.vrm`**) | `frontend/dist`, `frontend/public/avatar.vrm` | 🌐 Git | n/a |
| systemd-User-Dienst `mission-control-2` (`:9001`) | `~/.config/systemd/user/` | ⬇️ deploy.sh | nein |
### D · 3D-Avatar (Sprechen-Tab)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Default-Avatar (VRM) | `frontend/public/avatar.vrm` (→ dist) | 🌐 Git | n/a |
| Renderer | `frontend/src/components/voice/Avatar3D.tsx` | 🌐 Git | n/a |
| **Eigene/zusätzliche Avatare** (falls Nutzer welche ablegt) | **TODO: Ablageort definieren** (z.B. `/srv/models/avatars/` + DB-Verweis) | 📦/🆕 | **nein (Lücke)** |
> Heute ist der Avatar **fest** (`avatar.vrm`, kommt mit dem Git-Frontend zurück). Wenn künftig
> Nutzer-Avatare hochgeladen werden, brauchen sie einen persistenten Ablageort, der ins Backup geht.
### E · Voice-Sidecar (STT + TTS + Klonstimme)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Voice-venv (Python 3.12) | `~/.voice/venv` | ⬇️ install.sh | nein (rebuild) |
| STT (faster-whisper Modell) | `~/.voice/` (Cache) | ⬇️ install.sh / 1. Start | nein |
| Piper-Binary + dt. Stimmen (thorsten/kerstin) | `~/.voice/piper`, `~/.voice/voices` | ⬇️ install.sh | nein |
| Chatterbox (Premium-TTS, CPU-torch) | venv | ⬇️ install.sh (best-effort) | nein |
| **Klonstimme / Voice-Referenz-Audio** (Nutzer) | `~/.voice/refs/ref.wav` (`VOICE_REFS_DIR`, `/api/voice/reference`) | 📦 | **nein → Anhang B** |
| ElevenLabs-Key | `~/.hermes/.env` | 🔑 | ja |
| Stimm-/Lautstärke-Wahl | Browser-`localStorage` (pro Gerät) | 🆕 client | n/a |
| Dienst `voice-service` (`:8650`) | systemd-User | ⬇️ deploy.sh | nein |
### F · Mem0 (Langzeitgedächtnis)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Mem0-venv (Python 3.12, uv) | `~/.mem0/venv` | ⬇️ deploy.sh | nein (rebuild) |
| **Gedächtnis-Daten (Chroma + history.db)** | `/srv/models/mem0/` | 📦 | **ja** |
| Hermes-Memory-Plugin | `~/.hermes/plugins/mc2-memory/` | 📦/🌐 | ja |
| Dienst `mem0-service` (`:8765`) | systemd-User | ⬇️ deploy.sh | nein |
### G · Hermes-Agent (das Herz)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| Hermes-Code | `~/.hermes/hermes-agent` | 🌐 Git (NousResearch) | nein (re-clone) |
| Bundled Node v22 | `~/.hermes/node/bin` | ⬇️ (kommt mit Hermes) | nein |
| Hermes-venv | `~/.hermes/hermes-agent/venv` | ⬇️ rebuild | nein |
| **`config.yaml` 1:1** (Toolsets, MCP, Engine, Browser, Personalities, alles) | `~/.hermes/config.yaml` | 📦 | **ja** |
| **`.env` (Secrets: TELEGRAM_BOT_TOKEN, API_SERVER_KEY, ElevenLabs …)** | `~/.hermes/.env` | 📦/🔑 | **ja** |
| Plugins | `~/.hermes/plugins/` | 📦 | ja |
| **Skills (eigene)** | `~/.hermes/skills/` (45M; `.hub`-Cache re-downloadbar) | 📦 | **nein → Anhang B (ohne .hub)** |
| Sessions/History (optional) | `~/.hermes/sessions/` (856K) | 📦 | **nein → Anhang B** |
| Checkpoints (optional) | `~/.hermes/checkpoints/` (existiert nicht) | ⏭️ skip | nein |
| **Token-/Ersparnis-Statistik** | `~/.hermes/token_stats.json` | 📦 | **nein → Anhang B** |
| Dienst `hermes-gateway` (`:8642`) | systemd-User | ⬇️ | nein |
| Hermes-Terminal (ttyd → `hermes chat`, `:7681`) | systemd-User `hermes-terminal` | ⬇️ deploy.sh (+ttyd apt) | nein |
### H · MCP-Server (4)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| `mission-control-memory`, `-stack` (MC2-venv) | `~/mission-control-v2/mcp/*.py` | 🌐 Git | über config.yaml |
| `hermes-pc-control`, `hermes-web-fetch` (Hermes-venv) | dito | 🌐 Git | über config.yaml |
| MCP-Verdrahtung | `~/.hermes/config.yaml → mcp_servers` | 📦 | ja |
### I · Browser-Stack
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| agent-browser (npm global) | `~/.local/...`, symlink `~/.hermes/node/bin` | ⬇️ npm i -g | nein |
| Chrome (engine) + System-Libs | `~/.agent-browser/browsers/` + apt-Libs | ⬇️ install --with-deps | nein |
| lightpanda (optional, leicht) | `~/.local/bin/lightpanda` | ⬇️ Download | nein |
| Browser-Verdrahtung (engine=chrome, toolset) | `~/.hermes/config.yaml` | 📦 | ja |
### J · Modelle (GGUF — der große Brocken)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| GGUF-Modelle (Brain, fast, heavy, coder, vision, embedding …) | `/srv/models/` | ⬇️ Re-Download aus llama-swap-Manifest | **nein (zu groß, bewusst)** |
### K · Extern (anderer Rechner)
| Komponente | Ort | Quelle | Im Backup? |
|---|---|---|---|
| PC-Executor (Windows) | `client/hermes-pc/` → Scheduled Task | 🌐 Git + Task | n/a (anderer Host) |
| Hermes-WebUI (nesquena, optional, `:8787`) | `~/hermes-webui` | ⬇️ git clone | nein |
---
## 4. Ziel-Architektur
### 4.1 `deploy/bootstrap.sh` — orchestrierter From-Zero-Lauf
- **Idempotent & resumierbar** (Schritt-Marker in `~/.mc2-bootstrap.state`).
- **Getrennt nach sudo-Bedarf**: `bootstrap-root.sh` (L0+L1, bewusst mit sudo) + `bootstrap.sh`
(L2+, sudo-frei = Kern ist `deploy.sh`). Passt zum Nordstern „Runtime ohne sudo".
- **Mündet in den Wizard**: startet MC2 im First-Run-Modus, gibt Wizard-URL aus.
Phasen: `0 Vorflug → 1[sudo] System+Dirs → 2[sudo] Engine+llama-swap → 3 Code(MC2+Hermes+Node)
→ 4 venvs(backend/mem0/voice/hermes) → 5 Browser → 6 deploy.sh(Units/enable/restart)
→ 7 Restore(optional) → 8 Wizard hoch + postcheck`.
### 4.2 First-Run-Wizard (browserbasiert, von MC2 serviert)
MC2 erkennt unkonfigurierten Zustand → Frontend-Route `/setup` statt Dashboard. Schritte:
1. **Systemcheck** — Live-Ampel je Komponente aus §3 (`GET /api/setup/status` + `…/system/services`).
2. **Restore-Quelle wählen** — Backup-Tarball erkennen → globaler Modus „Alles 1:1" / „selektiv" / „frisch".
3. **Komponenten-Auswahl (Kernstück)** — pro Katalog-Eintrag aus §3 eine Kachel mit
📦/🆕/⏭️ (siehe §1). Zeigt Größe + ob Backup-Daten vorhanden.
4. **Netzwerk & Identität** — Box-IP (→ `HERMES_TERMINAL_URL`, `PC_EXECUTOR_URL`), Hostname.
5. **Secrets** 🔑 — `TELEGRAM_BOT_TOKEN`, erlaubte User-ID, `API_SERVER_KEY` (oder generieren),
ElevenLabs-Key → `~/.hermes/.env` (chmod 600). Bei 📦 vorbefüllt aus Backup.
6. **Hermes-Profil** — Brain-Alias + Toolset-Profil (Default = entfesselt: vision/tts/memory/browser
an, image_gen/video aus — siehe [[project-hermes-setup]]). Bei 📦 = exakte alte `config.yaml`.
7. **Avatar & Voice** — Avatar wählen (Default-VRM oder eigener), Stimme/Klonstimme
(📦 Referenz-Audio zurück, oder neu aufnehmen/hochladen).
8. **Modelle** — aus llama-swap-Manifest automatisch nachladen (`POST /api/models/install`,
Fortschritt `GET /api/jobs`) ODER geführte Discover-Neuauswahl. Reihenfolge: Brain → heavy → Rest.
9. **Externe Checkliste** — PC-Executor (Windows-Task), Hermes-WebUI als Haken.
10. **Verifikation & Abschluss**`stack-postcheck.sh` + `hermes-postcheck.sh` → grün/rot-Liste → Dashboard.
**Technik:** neuer Router `backend/routers/setup.py`
(`GET /api/setup/status`, `POST /api/setup/{secrets,network,hermes,components,finish}`).
First-Run-Gate: MC2 prüft beim Boot Marker `~/.mc2-setup-done` bzw. Pflicht-Secrets → leitet auf `/setup`.
---
## 5. Backup-Scope für echtes 1:1 ERWEITERN (umzusetzen — Snippet in Anhang B)
Der **aktuelle** `backup.sh` (live auf der Box, unverändert) reicht für „1:1" nicht. Für die Bau-Session
liegt das **fertige Erweiterungs-Snippet in Anhang B** — es ergänzt den Tarball um:
- **Voice-Klonstimme** → `~/.voice/refs/` (`ref.wav`)
- **Token-/Ersparnis-Statistik** → `~/.hermes/token_stats.json`
- **Hermes-Skills** → `~/.hermes/skills/` (re-downloadbarer `.hub`-Cache per `tar --exclude` ausgelassen)
- **Hermes-Sessions/History** → `~/.hermes/sessions/`
Restore soll skills/sessions **mergen** (frisch geladenen `.hub` nicht überschreiben) und `voice-service`
mit neu starten.
**Offen bleibt:** eigene Avatare (sobald Upload existiert — Ablageort heute undefiniert, siehe §3·D).
**Bewusst NICHT im Backup (re-downloadbar/rebuildbar):** Piper-Stimmen (install.sh), `.hub`-Skill-Cache,
`/srv/models/mc2-discover.json` (Cache), `/srv/models/mc2-memory.db` (Legacy-Migration), alle venvs, GGUF-Modelle.
→ Aufgabe der Bau-Session: `backup.sh` + `restore.sh` um diese Pfade erweitern (mit klarer
„opt-in für große/optionale Teile"-Logik), und den MANIFEST-Inhalt entsprechend.
---
## 6. Secrets-Strategie
- Single Source: `~/.hermes/.env` (chmod 600), im Tarball (selbst 600).
- Rebuild ohne Backup → Wizard-Schritt 5 erzeugt sie. `API_SERVER_KEY` generierbar; Telegram/ElevenLabs liefert Nutzer.
- Nie ins Git, nie in Logs, nie in die MC2-DB. **Off-Box-Spiegelung des Tarballs noch offen** ([[mc2-backup-restore]]).
## 7. Modell-Strategie
- **A (empfohlen):** `/etc/llama-swap/config.yaml` (im Backup) listet alle Modelle → Wizard leitet Repos/Quants ab und lädt automatisch (`/api/models/install`). „Ein Klick, lädt über Nacht."
- **B:** geführte Discover-Neuauswahl je Rolle. Reihenfolge Brain → heavy → Rest, danach `warmup.sh`.
---
## 8. Implementierungs-Reihenfolge (neue Session)
1.**Box-Verifikation erledigt (2026-06-28, nur gelesen — nichts verändert):** llama-swap.service-Inhalt
in **Anhang A**; skills/sessions/refs/token_stats existieren (Pfade in §3 verifiziert); Backup-Erweiterung
als fertiges Snippet in **Anhang B**. **Keine offenen Box-Fragen mehr** außer §10·36. → mit Schritt 2 starten.
2. `backup.sh`/`restore.sh` um die 1:1-Lücken erweitern (§5).
3. `deploy/bootstrap.sh` + `bootstrap-root.sh` (Phasen, State-Marker); llama-swap-Install skripten.
4. `backend/routers/setup.py` + First-Run-Gate.
5. Frontend `/setup`-Wizard (Schritte 110), Komponenten-Auswahl als Kernstück; bestehende Views
(Cockpit/AgentView/Sprechen) wiederverwenden.
6. Modell-Restore-aus-Manifest.
7. **Abnahme in frischer VM** (§9).
Vorschlag: 24 zuerst (Skelett lauffähig), Wizard danach. Branch + PR.
## 9. Abnahmekriterien
- Frisches Ubuntu 26.04 → `bootstrap-root.sh` + `bootstrap.sh` → laufender Stack, kein Spezialwissen.
- Postchecks grün; Telegram, Voice (inkl. Klonstimme bei 📦), 3D-Avatar, Browser funktionieren.
- „Alles 1:1" stellt mem0, Hermes-config.yaml, Secrets, Klonstimme, Skills exakt wieder her.
- Selektiver Modus: einzelne Komponenten ⏭️ überspringbar, Stack läuft trotzdem.
- Idempotenz: zweiter Lauf = No-op.
## 10. Offene Entscheidungen (vor dem Bau)
1. ~~llama-swap systemd-Unit-Inhalt~~**geklärt: kompletter Unit in Anhang A** (in der Bau-Session anlegen).
2. ~~Voice-Referenz-Audio-Ort~~**geklärt: `~/.voice/refs/`** (Backup-Snippet in Anhang B, in der Bau-Session umsetzen).
3. **Eigene Avatare**: künftiger Upload-/Ablage-Mechanismus + Backup-Pfad (einziger offener 1:1-Daten-Punkt).
4. **Off-Box-Backup-Ziel** (NAS/2. Platte/Cloud) — [[mc2-backup-restore]].
5. **Python 3.14** auf frischem Ubuntu beschaffen (deadsnakes?).
6. **Bootstrap-sudo-Modell**: getrenntes `bootstrap-root.sh` (empfohlen) vs. interaktives sudo.
## 11. Referenzen
- `backend/config.py`, [[project-mc2-architecture]], [[project-stack-state]]
- [[project-hermes-setup]], `docs/HERMES_SETUP.md` (entfesseltes Profil, Browser, PC-Executor)
- [[voice-sprechen-feature]] (Voice + 3D-Avatar), [[mem0-memory-architecture]], [[mc2-backup-restore]]
- `deploy/{deploy,backup,restore,provision-engine,stack-postcheck,warmup}.sh`, `voice_service/install.sh`
---
## Anhang A — `llama-swap.service` (1:1 von der Box abgegriffen, 2026-06-28)
Base-Unit nach `/etc/systemd/system/llama-swap.service` (root). User/GFX sind boxspezifisch
(hitonabi, gfx1151 → HSA 11.5.1) — auf anderer Hardware anpassen. Die `.d/`-Drop-ins
(`vulkan.conf`, `warmup.conf`) legt `provision-engine.sh` an.
```ini
[Unit]
Description=llama-swap (lokaler LLM Router)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=hitonabi
Environment=HSA_OVERRIDE_GFX_VERSION=11.5.1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:8080 --watch-config
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
```
```ini
# /etc/systemd/system/llama-swap.service.d/vulkan.conf
[Service]
Environment=LD_LIBRARY_PATH=/opt/llamacpp-vulkan
```
```ini
# /etc/systemd/system/llama-swap.service.d/warmup.conf
[Service]
ExecStartPost=-/usr/local/bin/llama-swap-warmup.sh
```
**Erstinstall-Reihenfolge:** `bash deploy/update-swap.sh` (Binary) → Unit kopieren →
`sudo bash deploy/provision-engine.sh` (Engine+Drop-ins) → `sudo systemctl enable --now llama-swap`.
---
## Anhang B — Backup-Scope-Erweiterung für echtes 1:1 (fertiges Snippet)
In `deploy/backup.sh` ergänzen (Variablen oben: `VOICE="${VOICE_HOME:-$HOME/.voice}"`,
Stage zusätzlich `"$STAGE/voice"`):
```bash
# 1:1-Erweiterung (scan-verifiziert 2026-06-28):
[ -f "$HERMES/token_stats.json" ] && cp -a "$HERMES/token_stats.json" "$STAGE/hermes/" || true
[ -d "$HERMES/skills" ] && cp -a "$HERMES/skills" "$STAGE/hermes/skills" || true
[ -d "$HERMES/sessions" ] && cp -a "$HERMES/sessions" "$STAGE/hermes/sessions" || true
[ -d "$VOICE/refs" ] && cp -a "$VOICE/refs" "$STAGE/voice/refs" || true
```
tar-Aufruf um den re-downloadbaren Skill-Cache erleichtern:
```bash
tar --exclude='./hermes/skills/.hub' -czf "$OUT" -C "$STAGE" .
```
In `deploy/restore.sh` spiegelbildlich (skills/sessions **mergen**, nicht ersetzen) und
`voice-service` mit neustarten:
```bash
VOICE="${VOICE_HOME:-$HOME/.voice}"
SERVICES="mem0-service voice-service mission-control-2 hermes-gateway"
[ -f "$STAGE/hermes/token_stats.json" ] && cp -a "$STAGE/hermes/token_stats.json" "$HERMES/"
[ -d "$STAGE/hermes/skills" ] && { mkdir -p "$HERMES/skills"; cp -a "$STAGE/hermes/skills/." "$HERMES/skills/"; }
[ -d "$STAGE/hermes/sessions" ] && { mkdir -p "$HERMES/sessions"; cp -a "$STAGE/hermes/sessions/." "$HERMES/sessions/"; }
[ -d "$STAGE/voice/refs" ] && { mkdir -p "$VOICE/refs"; cp -a "$STAGE/voice/refs/." "$VOICE/refs/"; }
```
+149
View File
@@ -0,0 +1,149 @@
# Lucy TTS — Optimierungs- & Strategieplan
> Ziel: **Lucys Stimme läuft 100 % lokal & on-device** (kein Box-Zwang, keine Cloud).
> Primärengine: **Kyutai pocket-tts 2.1.0** (CPU). Strategische Alternative: **F5-TTS** (GPU/ONNX).
> Stand: 2026-06-30. Resume-fähig im Stil von `docs/STATUS.md`.
## Leitentscheidung: Hardware
- **pocket-tts ist CPU-gebunden** — Kyutai bestätigt: *kein* GPU-Speedup (Batch 1, 100M Params).
**RDNA2 vs. RDNA4 ist für pocket irrelevant.** Lucy läuft dort, wo der beste CPU steht.
- **GPU zählt nur für F5-TTS** (non-autoregressiv, große Matmuls). Dort gewinnt **RDNA4 (9070 XT) + DirectML**
deutlich gegen RDNA2 → falls F5 die Lucy-Stimme wird, läuft sie auf der RDNA4-Maschine.
- **Konsequenz:** `oute n_gpu_layers=999` und „pocket auf ROCm/Vulkan" werden **eingestellt**.
---
## Phase A — Sofort-Wins (risikoarm, pocket_server.py)
**A1 — Voice → safetensors exportieren (einmalig)**
- Statt `get_state_for_audio_prompt(ref)` bei jedem Boot: einmal `export_model_state(...)``lucy_voice.safetensors`.
- Server lädt beim Start nur noch die safetensors (liest kvcache, keine Klon-Rechnung) → schnellerer Start.
- Fallback behalten: wenn safetensors fehlt → aus `ref.mp3` klonen + direkt exportieren.
**A2 — Referenz säubern**
- Kyutai: Sample-Qualität wird *mitreproduziert*. Sauber entrauschte/normalisierte `ref` → weniger Artefakte.
- Schritt in `_prep_ref()` ergänzen (Denoise/Highpass/Lautheit), Ergebnis cachen.
**A3 — temp-Sweep gegen Kollaps**
- Hypothese: `temp=0.9` treibt die best-of-N-Regenerationen. Niedriger testen (0.70.85).
- Akzeptanz: gleiche/bessere Natürlichkeit bei **messbar weniger Regenerationen** (= weniger Latenz).
*Gate A:* A1A3 live, Kollaps-Rate & TTFB protokolliert.
---
## Phase B — Benchmark-Harness (datenbasiert tunen)
**B1 — `bench_lucy.py`** (lokal, CPU)
- Misst je Konfig: **RTF**, **TTFB**, **Kollaps-/Regenerations-Rate**, Whisper-Rücktranskription (Verständlichkeit).
- Sweep-Achsen: `lsd_decode_steps` (6/8/10/12), `temp`, `noise_clamp`, `quantize` on/off, `frames_after_eos`.
- Feste Testsätze (kurz/mittel/lang, wie in `ptts_test.py`).
**B2 — CPU-Parallelität**
- pocket nutzt nur **2 Kerne** → mehrere **Satz-Worker parallel** statt globalem `LOCK`.
- Messen: Wall-Clock langer Antworten bei N Workern (1/2/3/4) vs. Qualität/Last.
*Gate B:* dokumentierte Best-Config (RTF + Kollaps-Rate) als neue Defaults in `pocket_server.py`.
---
## Phase C — Runtime-Eval (lokal schneller + Python-3.14-frei)
**C1 — ONNX-Pfade testen**
- Kandidaten: **PocketTTS.cpp** (Single-File C++/ONNX, CLI+HTTP+FFI) und **sherpa-onnx** (Windows, viele Bindings).
- Ziel: torch-CPU schlagen **und** das Python-3.14-Packaging-Problem umgehen.
- Vergleich gegen Phase-B-Baseline (gleicher Benchmark).
**C2 — Server-Vertrag prüfen**
- Fertige **OpenAI-kompatible Streaming-Server** (teddybear082 / ai-joe-git) gegen den aktuellen Custom-Proxy halten.
- Nur übernehmen, wenn Stimm-Wächter (F0 + Fingerabdruck) erhalten/abbildbar bleiben.
*Gate C:* Entscheidung „torch-CPU behalten" vs. „auf ONNX-Runtime wechseln" (mit Zahlen).
---
## Phase D — Strategische Weiche: pocket vs. F5
**D1 — F5 fair gegen pocket messen**
- `lucy-f5/` (F5-TTS DE, ONNX + DirectML, **RDNA4/9070 XT**) ist **non-autoregressiv → strukturell kein
Kollaps/Männerstimme/Wiederholung** (genau pockets Schmerz).
- Gleicher Benchmark wie Phase B: RTF, TTFB, Natürlichkeit, Stabilität.
**D2 — Entscheidung**
- **pocket gewinnt** (gut genug stabil, CPU, „nur lokal"-Ideal): pocket = Lucy-Stimme, F5 verworfen/geparkt.
- **F5 gewinnt** (Stabilität schlägt den Latenz-Overhead der Wächter): F5 = Lucy-Stimme auf RDNA4,
**pocket bleibt schneller CPU-Fallback** (z. B. wenn keine GPU verfügbar).
**Beobachtungsposten (extern):** distilliertes **`german`** (statt `german_24l`) ist noch nicht released
(Kyutai fixt Distillations-Datenqualität). Sobald da → größter Einzel-Win (Tempo + Stabilität),
dann `german_24l → german` swappen. Release-Feed im Blick behalten.
---
## Reihenfolge & Quick-Start
1. **A1 + A2 + A3** (heute) — sofort spürbar, kein Risiko.
2. **B1 + B2** — Zahlen sammeln, Defaults härten.
3. **C1/C2** — Runtime-Wechsel nur wenn Benchmark es trägt.
4. **D1/D2** — finale Stimm-Architektur entscheiden.
## Mess-Ergebnisse & finale Defaults (30.06., 9700X, german_24l)
Alles auf der lokalen Maschine gemessen (Logs: `sweep_b2.log`, `ttfb_test.log`, `sweep_b2_result.json`).
**B2-Sweep (6-Satz-Antwort, ~21s Audio):**
| Konfig | Wall | TTFB | RTF |
|---|---|---|---|
| seriell (1×alle Kerne) | 15,8s | **3,6s** | 0,74 |
| 2w×3t | 12,9s | 6,2s | 0,62 |
| 3w×2t | 10,3s | 6,8s | 0,50 |
| 4w×2t | 9,7s | 8,2s | **0,44** |
Erkenntnis: pocket ist **speicherbandbreiten-gebunden**. Der Pool verbessert nur die Gesamt-Wall-Clock
(Batch), verschlechtert aber die **TTFB** deutlich. Seriell liefert RTF 0,74 < 1 → generiert schneller
als Echtzeit → Streaming spielt **lückenlos** und startet am schnellsten. **Für Lucys Live-Stimme
gewinnt seriell.** Pool bleibt Opt-in für Batch (`/tts` ganze Datei): Sweet Spot **4w×2t** / **3w×2t**.
**B3 (FP-Drift-Gate überspringt kurze Audios) + kurzer erster Chunk:**
Der MFCC-Fingerabdruck ist auf <2s Audio unzuverlässig (sim ~0,84 < 0,94) → löste 3× Fehlalarm-
Regenerierung aus. B3 prüft den Drift erst ab `LUCY_FP_MIN_S=2.0`s stimmhafter Dauer; der F0-
Männerstimmen-Wächter bleibt immer aktiv. Effekt (Drift-Warnungen pro Lauf: ~6 → 1):
| | TTFB | Wall |
|---|---|---|
| kurzer 1. Chunk, ohne B3 | 4,71s | 19,9s |
| gebündelt (alt) | 3,74s | 15,8s |
| **kurzer 1. Chunk, mit B3** | **1,31s** | 16,6s |
→ Lucy spricht nach **1,3s** statt 3,7s, Wall ~gleich, weiter lückenlos.
**Finale Defaults in `pocket_server.py`:**
`LUCY_WORKERS=0` (seriell) · `LUCY_FAST_FIRST=1` (kurzer 1. Chunk) · `LUCY_FP_MIN_S=2.0` (B3) ·
`LUCY_REF_CLEAN=1` (A2) · Voice aus `lucy_voice.safetensors` (A1). Pool-Opt-in für Batch:
`LUCY_WORKERS=4 LUCY_WORKER_THREADS=2`.
**Offen / nächster Hebel:** distilliertes `german`-Modell (statt `german_24l`) abwarten — größter
Qualität/Tempo-Sprung. Optional: TTFB weiter drücken über kürzeres erstes Wort / `lsd`-Tuning nur
für den ersten Chunk.
## Umlaute (ae/oe/ue) — Fix (30.06.)
Symptom: Lucy spricht manchmal „u-e" statt „ü". Ursache: das LLM (Hermes/Qwen) gibt gelegentlich
ASCII-Ersatzschreibweisen (ueber/schoen/maerz) aus; pocket liest sie wörtlich. (ß vs ss ist
akustisch identisch -> ignoriert.)
**Wurzel-Fix (empfohlen, auf der Box im Hermes-System-Prompt/Persona ergänzen):**
> „Schreibe ausschließlich korrektes Deutsch mit echten Umlauten (ä, ö, ü) und ß. Verwende niemals
> die Ersatzschreibweisen ae, oe, ue oder ss anstelle von Umlauten."
**Schutznetz (in `pocket_server.py`, Default an `LUCY_UMLAUT_FIX=1`):** `_fix_umlauts()` wandelt NUR
bekannte deutsche Umlaut-Ganzwörter zurück (Ganzwort + gängige Flexionsendungen, case-erhaltend).
Verifiziert (`umlaut_test.py`): konvertiert über/für/größe/natürlich/mögliche/Gespräche; lässt
neue/aktuell/Steuer/Quelle/Feuer/Frauen/genau/blaue unverändert. Grenzen: reine Wortliste, deckt
nicht jedes seltene Wort — erweiterbar via `LUCY_UMLAUT_EXTRA="wort1,wort2"`. Der Wurzel-Fix bleibt
die zuverlässige Lösung.
## Referenzen
- pocket-tts README (GPU-kein-Speedup, export-voice, Sprachen): github.com/kyutai-labs/pocket-tts
- Multilingual-Ankündigung (DE = `german_24l`, undistilliert): kyutai.org/blog/2026-05-04-pocket-tts-multilingual
- Tech-Report / Performance: kyutai.org/blog/2026-01-13-pocket-tts · deepwiki.com/kyutai-labs/pocket-tts
+271
View File
@@ -0,0 +1,271 @@
# Komplett-Review Mission Control 2 + Lucy — 02.07.2026
**Methodik:** IST-Zustand live erhoben (Box per SSH, lokaler Lucy-PC, Working Tree `lucy-v2` inkl. uncommitted) — nicht aus Docs/Memory übernommen. SOLL-Zustand in 3 Recherche-Runden tagesaktuell (Stand 02.07.2026) ermittelt. Alle früheren Verdikte wurden neu auf den Prüfstand gestellt. Scope: alles außer Security.
---
## 1. Management-Summary
| Bereich | Zustand | Kernaussage |
|---|---|---|
| LLM-Stack (Box) | ✅ stark, 1 Bug | Vulkan-Pfad richtig, Qwen3.6+MTP läuft; aber chat-Lane kaputt (Autostart-Fund war Fehlalarm) |
| Lucy Voice-Latenz | 🔴 3,55 s | 2026-Ziel ist 0,50,8 s. Schuld sind STT (2,0 s) und VAD-Timer (0,9 s), **nicht** das LLM (TTFT 65 ms) |
| Lucy Avatar/App | ✅ sehr ausgereift | VRMA-Mocap, Lippensync v2, MateEngine-Features komplett; Electron 10 Major-Versionen alt |
| Backend/Frontend-Code | 🟡 solide | 3 Monolithen, etwas DRY-Schuld, dist/ im Git; keine Rot-Flaggen |
| Ökosystem-Aktualität | 🟡 | Hermes 1 Release hinter (v0.18.0 vom 01.07.); pocket-tts, mem0, llama-swap, three-vrm aktuell |
| Verdikte | ✅ alle bestätigt | Pocket TTS, Electron, Qwen3.6-Hirn, kein ZeroClaw, Mem0 — alle bestehen die Re-Prüfung |
**Die zwei größten Hebel:**
1. **STT-Tausch** (faster-whisper medium → Parakeet-TDT 0.6B v3): ~2,0 s → ~0,2 s pro Äußerung
2. **VAD-Tausch** (RMS-Eigenbau → Silero VAD v6.2): Turn-Ende 900 → ~450 ms bei besserer Genauigkeit
Zusammen: Voice-to-Voice von ~3,55 s auf realistisch **≤1,2 s**, ohne neue Hardware.
---
## 2. IST-Zustand (live verifiziert)
### 2.1 Box (192.168.178.151 — Strix Halo, gfx1151, 122 GB, Vulkan)
| Komponente | Installiert | Aktuell (02.07.2026) | Bewertung |
|---|---|---|---|
| llama.cpp | b9843 (86b94708f) | b9859 (01.07.) | ✅ nur ~16 Builds dahinter |
| llama-swap | v233 (29.06.) | v233 | ✅ aktuell |
| Hermes Agent | **v0.17.0** (19.06.) | **v0.18.0** (01.07.) | 🟡 Update lohnt (3 P0 + 493 P1 gefixt) |
| mem0ai / chromadb | 2.0.8 / 1.5.9 | 2.0.8 | ✅ aktuell |
| Kernel | 7.0.0-27-generic | — | ✅ |
**Live-Modell-Lineup** (`/etc/llama-swap/config.yaml`, live gelesen — der Repo-Snapshot `deploy/llama-swap.config.yaml` ist **veraltet** und zeigt noch gemma-4):
| Alias | Modell | ctx | Besonderheiten |
|---|---|---|---|
| hermes (Brain) | Qwen3.6-35B-A3B (UD-Q4_K_M, MTP-GGUF) | 65536 | `--spec-type draft-mtp --spec-draft-n-max 3 --parallel 1 -cram 16384`, **kein** cache-reuse |
| heavy | Qwen3.5-122B-A10B | 32768 | cache-reuse 256, ttl 600 |
| coder | Qwen3-Coder-Next | 131072 | cache-reuse 256, ttl 600 |
| coder-lite | Qwen3-Coder-30B-A3B | 131072 | cache-reuse 256, ttl 300 |
| vision | Qwen3-VL-8B | 32768 | mmproj, in brains |
| embed | Qwen3-Embedding-0.6B | 8192 | ttl 0, in brains |
| scout | GLM-4.6V-Flash | 131072 | mmproj, ttl 300 |
Gruppe `brains` (swap:false, persist:true) = embed + vision + Qwen3.6. Alle Modelle: `-ngl 999 -fa on --no-mmap --jinja`. **Kein Modell nutzt KV-Cache-Quantisierung** (alles F16).
**Dienste live:** llama-swap, hermes-gateway, hermes-terminal, hermes-webui, mem0-service, voice-service — alle running. Ports 7681/8080/8642/8650/8765/8787/9001 belegt.
### 2.2 🔴 Live-Bugs (selbst reproduziert)
**B1 — ~~MC2-Backend ohne Autostart~~ FEHLALARM (korrigiert 02.07., nachverifiziert).**
:9001 läuft als **User-Unit** `mission-control-2.service` (`~/.config/systemd/user/`, enabled, `Linger=yes` → reboot-fest). Die erste SSH-Prüfung sah User-Units nicht (fehlendes XDG_RUNTIME_DIR). Einziger echter Fund: Die **stale v1-System-Unit** `mission-control.service` (disabled, /opt/mission-control:9000) liegt noch in /etc/systemd/system und stiftet Verwirrung → bei Gelegenheit mit sudo entfernen (kosmetisch, kein Risiko).
**B2 — chat-Lane kaputt (live reproduziert).**
```
POST /v1/chat/completions {"model":"chat",...}
→ {"error":"no router for requested model","src":"llama-swap"}
```
Die Routing-Policy routet `chat → fast ↔ heavy`, aber llama-swap kennt den Alias `fast` nicht mehr (Qwen3.6 hat nur noch den Alias `hermes`). Lucy ist nicht betroffen (sie geht über den Hermes-Agenten :8642), aber jeder Client der chat-Lane bekommt Fehler. Fix: `fast` als zweiten Alias eintragen **oder** Policy auf `hermes` umstellen.
**B3 — Config-Drift.**
`deploy/llama-swap.config.yaml` ist untracked UND veraltet (gemma-4-Ära). Zusätzlich fehlen im Install-Template [`backend/config.py:33`](../backend/config.py) die Produktions-Tunings (cache-reuse, parallel, MTP) → neu installierte Modelle driften von der Box-Realität weg.
### 2.3 Gemessene Latenzkette Lucy
Quelle: `/api/voice/metrics` (live) + eigener TTFT-Test gegen llama-swap.
| Stufe | Messwert | Anteil am Problem |
|---|---|---|
| VAD-Turn-Ende | **900 ms** Fix-Timer (RMS-Eigenbau, [useVAD.ts](../client/lucy-desktop/src/renderer/src/lib/voice/useVAD.ts)) | groß |
| STT (faster-whisper medium int8, Box-CPU) | **avg 2.046 ms · p50 2.092 ms · p95 2.529 ms** (n=13) | **dominant** |
| LLM TTFT (Qwen3.6 warm, direkt :8080) | **65 ms** (24 Tokens in 334 ms) | ✅ kein Problem |
| TTS erster Ton (pocket, RTF 0,44 + LEAD_IN 0,35 s) | ~0,51 s für den ersten Satz | mittel |
| **Voice-to-Voice gesamt** | **~3,55 s** | Ziel 2026: **0,50,8 s** |
**Fazit: Das teuerste Glied ist NICHT das LLM.** STT + VAD-Timer fressen zusammen ~3 s. Genau dort setzt die Roadmap an.
### 2.4 Lokaler PC (RX 9070 XT / RDNA4)
- Lucy-App zum Prüfzeitpunkt nicht gestartet (kein Electron-Prozess, :8130 frei)
- `ptts-venv`: pocket-tts **2.1.0** (= neueste Version), torch 2.12.1+cpu (bewusst CPU), onnxruntime 1.27.0, fastapi 0.138.1
- GPU-Treiber 32.0.31021.5001
- [client/lucy-tts/](../client/lucy-tts/) (3 GB): Produktions-TTS mit Stimmen-Wächter (F0-Floor 160 Hz, MFCC-Fingerprint ≥0,94, Best-of-N), Phase A+B des TTS-Plans abgeschlossen (beste Config: 4 Worker × 2 Threads, RTF 0,44)
- [client/lucy-f5/](../client/lucy-f5/) (5,3 GB): F5-TTS-ONNX/DirectML-Experiment — **Phase C/D (Finalentscheid pocket vs. F5) offen**
- Lucy-Desktop: Electron **33** (aktuell: **43** vom 30.06. — 2 Jahre Chromium-Rückstand), three-vrm 3.4/animation 3.5.4 (≈ aktuell), TS strict
### 2.5 Code-Qualität (3 Explore-Agents über das ganze Repo)
**Backend (4.889 LOC, 11 Router / 28 Services):** insgesamt sauber, Multi-Sidecar-Architektur durchdacht. Findings:
- C1: Install-Template ohne Produktions-Tunings ([backend/config.py:33](../backend/config.py)) → Drift
- C2: `_describe_images()` blockiert den Voice-Chat bis 120 s synchron ([backend/routers/voice.py](../backend/routers/voice.py))
- C3: Thinking-Deaktivierung 3× dupliziert (voice.py, gateway.py, mem0_service/app.py) — DRY
- C4: Junk-Fact-Regex im Mem0-Sidecar hartcodiert (Wartungspunkt)
- Dead Code: `backend/services/pricing.py`, vermutlich `token_stats.py`
- Uncommitted Änderungen (voice.py No-Think, connect.py IDE-only-`coding`, mc2-memory-Guards, Mem0-Junk-Guard, voice_service install.sh) sind **alle konsistent und sinnvoll** — nur eben nicht committet
**Frontend (React 18/Vite 6/Tailwind 4/TanStack 5):**
- Voice-Umzug in die Desktop-App sauber (keine toten Imports/Nav-Reste) ✅
- UI-Rework (roleMeta/Health/Expert/WarmSet) vollständig; coder_lite↔coder-lite via ALIAS gelöst
- F1: [Cockpit.tsx](../frontend/src/views/models/Cockpit.tsx) (990 Z) + [SystemDrawer.tsx](../frontend/src/components/SystemDrawer.tsx) (934 Z) Monolithen
- F2: 🟡 `frontend/dist/`-Asset-Stand auf lucy-v2 inkonsistent (alte gelöscht, neue untracked) — dist-im-Git selbst ist Absicht (Box hat kein Node), avatar.vrm ist via .gitignore korrekt draußen
- F4: keine globale Error Boundary
- MC3-Prototyp ([frontend/src/mc3/](../frontend/src/mc3/)) non-destruktiv hinter `#mc3`, untracked
**Lucy-Desktop:**
- L1: [useVoiceAgent.ts](../client/lucy-desktop/src/renderer/src/lib/voice/useVoiceAgent.ts) (377 Z) monolithisch (SSE, Chunking, Tools, Perf, Vision in einem Hook)
- L2: kein Retry/Backoff beim TTS-Warmup (Crash → 2-min-Spinner)
- L3: VAD = reines RMS mit 900-ms-Stille-Regel
- L4: Echo-Schutz = 450-ms-Cooldown-Workaround (kein echtes Barge-in)
- Neues [voice-core/](../client/lucy-desktop/src/renderer/src/voice-core/)-Adapter-Layer (STT/TTS austauschbar) ist die richtige Architektur ✅
- H1: ~23 Dateien uncommitted, darunter die neue voice-core-Architektur → Verlustrisiko
---
## 3. SOLL-Zustand (Recherche, Stand 02.07.2026)
### 3.1 Voice-Latenz — der Stand der Technik
- 2026-Zielmarke: **500800 ms voice-to-voice**; Grundprinzip: **Streaming auf jeder Stufe** (STT-Partials sparen 200400 ms, TTS startet auf Teiltext, VAD+Turn-Taking-Budget 150300 ms) — [Latenz-Guide](https://futureagi.com/blog/how-to-optimize-voice-agent-latency-2026/), [Latenz-Budget-Design](https://smallest.ai/blog/designing-voice-assistants-stt-llm-tts-tools-and-latency-budget)
- **STT:** [Parakeet-TDT 0.6B v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) — 25 EU-Sprachen inkl. Deutsch, 6,32 % WER (besser als Whisper large-v3 mit 7,44 %), extrem schnell auch auf CPU, keine Silence-Halluzination. Deploy-Wege: [onnx-asr](https://pypi.org/project/onnx-asr/) (unterstützt CPU **und** DirectML → 9070 XT), fertige [OpenAI-kompatible FastAPI-Wrapper](https://github.com/groxaxo/parakeet-tdt-0.6b-v3-fastapi-openai). Kyutais Streaming-STT mit eingebautem semantic VAD ([delayed-streams-modeling](https://github.com/kyutai-labs/delayed-streams-modeling)) wäre architektonisch ideal, ist aber **nur EN/FR** → für Deutsch raus.
- **VAD:** [Silero VAD v6](https://github.com/snakers4/silero-vad/releases/tag/v6.0) (v6.2, ONNX): 16 % Fehler auf Noisy-Data vs. v5 — klar besser als der RMS-Eigenbau; erlaubt kürzeres Endpointing (~450 ms) bei weniger Fehlstarts.
- **Semantische Turn-Detection:** Smart Turn V2 (leichtgewichtig), [Easy Turn](https://arxiv.org/pdf/2509.23938) (akustisch+linguistisch, 4 Turn-States, open source).
- **TTS:** pocket-tts 2.1.0 ist Stand der Technik für CPU-Realtime ([kyutai](https://kyutai.org/blog/2026-01-13-pocket-tts/)). Challenger (CosyVoice2-0.5B, Chatterbox-Turbo) bieten keinen klaren Vorteil. → Einziger offener Entscheid bleibt das hauseigene F5-Experiment (Phase C/D).
### 3.2 Box maximieren
- **Vulkan/RADV bleibt der schnellste Pfad auf Strix Halo** (schlägt ROCm/HIP bei pp und tg) — die Box ist richtig aufgestellt ([llm-tracker Strix-Halo](https://llm-tracker.info/_TOORG/Strix-Halo), [Strix-Halo-Guide](https://github.com/hogeheer499-commits/strix-halo-guide)). ROCm 7.2/RDNA4 betrifft nur den lokalen PC.
- **Host-Memory-Prompt-Cache:** `--cache-ram` + Prefix-Restore bringt bis **93 % TTFT-Reduktion** bei Agent-Workloads mit wachsender Session ([llama.cpp #20574](https://github.com/ggml-org/llama.cpp/discussions/20574)). `-cram 16384` ist gesetzt; das Zusammenspiel mit dem (am hermes-Alias entfernten) `--cache-reuse` gehört gebencht.
- **KV-Cache-Quantisierung ungenutzt:** `-ctk/-ctv q8_0` halbiert den KV-Bedarf (relevant bei coder@131k, hermes@65k) bei praktisch verlustfreier Qualität; [TurboQuant](https://github.com/ggml-org/llama.cpp/discussions/20969) (3-bit, near-lossless ab 13B) steht vor der Integration — beobachten.
- **„MoE Speculative Trap":** Ein aktueller [Strix-Halo-Deep-Dive](https://dev.to/agustinsacco/breaking-the-moe-speculative-trap-460-ts-on-amd-strix-halo-446d) zu exakt Qwen3.6-35B-A3B zeigt: Spec-Verify kann bei sparsem MoE pro Verify-Pass fast alle 35B Gewichte anfassen — in seinem Setup war **ohne** Draft (parallel=1, KV Q8_0) 43,1 t/s vs. 17,7 t/s. Das widerspricht der eigenen Box-Messung (+35 % MIT MTP). Konsequenz: **beide Betriebsarten benchen**, auch bei vollem Kontext ([Decode-Drop bis 64 % dokumentiert](https://kmarble.dev/posts/strix-halo-full-context-decode-drops/)).
- **llama-swap-Features ungenutzt:** `capabilities` (v225), `-config-dir`-Fragmente (v230), Swap-Matrix/Groups V2 ([Releases](https://github.com/mostlygeek/llama-swap/releases)).
- Modell-Landschaft 128 GB für heavy-Kandidaten: gpt-oss-120B, Mistral Small 4 (119B-A6B), Llama 4 Scout — nur mit Bench-Gate.
### 3.3 Ökosystem
- **Hermes v0.18.0 „Judgment"** (01.07.): 3 P0 + 493 P1 gefixt, Background-Fan-out für Subagents, `/learn`-Skills, Memory-Graph in der Desktop-App ([Releases](https://github.com/NousResearch/hermes-agent/releases)). **Plugin-API:** `sync_turn()` bekommt optional `messages` (voller Turn-Kontext inkl. Tool-Calls); Legacy-Signatur bleibt → **mc2-memory ist update-kompatibel** und kann später Tool-Ergebnisse mitlernen ([Memory-Provider-Doku](https://hermes-agent.nousresearch.com/docs/developer-guide/memory-provider-plugin)).
- **Mem0 v3-Algorithmus** ([Migration](https://docs.mem0.ai/migration/oss-v2-to-v3)): Single-Pass-Extraktion (~2× schneller), Hybrid-Retrieval (semantisch + BM25 + Entity-Graph, ohne externe Graph-DB), +20 LoCoMo / +26 LongMemEval. 2.0.8 installiert, `custom_instructions` schon migriert → Status final verifizieren.
- **Electron 43** (30.06.), Chromium 150 / Node 24 — Lucy ist auf 33.
- **Vision:** Qwen3-VL ist aktuelle Generation ✅; [Qwen3-VL-30B-A3B](https://github.com/qwenlm/qwen3-vl) (MoE, 3B aktiv, top auf GUI-Benchmarks) wäre der Upgrade-Kandidat für die Bildschirm-Sicht.
- **Lokaler GPU-Klon:** [AMD Lemonade](https://runaihome.com/blog/amd-lemonade-local-llm-server-npu-gpu-guide-2026/) (llama.cpp-Vulkan + whisper.cpp + Kokoro, OpenAI-kompatibel, RDNA4-Support) als fertiger Unterbau; llama.cpp-Vulkan ist der [verlässlichste 9070-XT-Pfad](https://digtvbg.com/blog/llama-server-vulkan-rdna4-vllm-rocm-benchmark/).
### 3.4 Verdikte — Ergebnis der Re-Prüfung
| Verdikt | Ergebnis | Begründung |
|---|---|---|
| Pocket TTS bleibt | ✅ **bestätigt** | 2.1.0 aktuell, RTF 0,44 optimiert, Wächter-System; Challenger ohne klaren Vorteil. F5-Entscheid (Phase C/D) bleibt als einziger offener Punkt |
| Electron bleibt (nicht Tauri) | ✅ **bestätigt** | Alle nativen Features implementiert; aber Major-Update 33→43 einplanen |
| Qwen3.6 als Lucy-Hirn | ✅ **live bestätigt** | Läuft mit MTP, TTFT 65 ms; gemma-4 ist komplett raus |
| ZeroClaw bleibt tot | ✅ **bestätigt** | Kein neuer Anlass |
| Mem0 als Memory-Layer | ✅ **bestätigt** (neu geprüft) | Zep/Hindsight benchen höher, sind aber schwerer/teils closed; Mem0: beste Integration, v3-Algo, deployt |
---
## 4. Roadmap (Aufwand/Nutzen, Voice-Speed-first)
### P0 — Live-Bugs & Betriebssicherheit (Stunden)
| # | Maßnahme | Nutzen |
|---|---|---|
| 1 | ~~Autostart fixen~~ **erledigt als Fehlalarm** — User-Unit `mission-control-2` mit Linger ist reboot-fest; nur stale v1-Unit bei Gelegenheit löschen (sudo) | Klarheit |
| 2 | chat-Lane fixen (`fast`-Alias ergänzen oder Policy auf `hermes`) | Live-Bug, alle chat-Clients betroffen |
| 3 | Box-Config → Repo syncen + `deploy/` versionieren; Template-Drift C1 fixen | Quelle der Wahrheit |
| 4 | Hermes v0.17.0 → v0.18.0 + Health-Brain-Check | 496 Upstream-Fixes |
| 5 | Commit-Hygiene (voice-core, MC3, Lucy-Fixes); `frontend/dist/` in .gitignore | Verlustrisiko weg |
### P1 — Voice-Latenz: 3,55 s → Ziel ≤1,2 s (Tage)
| # | Maßnahme | Erwartung |
|---|---|---|
| 6 | **STT-Tausch**: Parakeet-TDT 0.6B v3 statt faster-whisper medium. Variante A: Box-CPU (onnx-asr im voice_service); Variante B: lokal 9070 XT (DirectML). A/B: Deutsch-WER + Latenz | **~2,0 s → ~0,2 s** |
| 7 | **VAD-Tausch**: Silero VAD v6.2 (ONNX) statt RMS in useVAD.ts; Endpointing 900 → ~450 ms | **450 ms** |
| 8 | TTS-TTFA: LEAD_IN 0,35 s prüfen, Erster-Satz-kurz-Regel messen (lucy_perf) | 100300 ms |
| 9 | **Brain-Bench-Matrix** am hermes-Alias: MTP n-max 3↔4↔ohne (Speculative-Trap-These) × ±KV Q8_0 × cache-reuse/cram × -b/-ub — bei Kurz- UND Voll-Kontext | Bestes Setup evidenzbasiert |
| 10 | voice_metrics ausbauen: VAD→STT→Agent→First-Audio je Turn | Messbarkeit |
| 10b | KV Q8_0 für coder@131k/heavy erwägen | Warm-Set-Reserve |
### P2 — Lucy v2 Kern & Code-Gesundheit (12 Wochen)
| # | Maßnahme |
|---|---|
| 11 | Semantische Turn-Detection (Smart Turn V2 / Easy Turn) auf Parakeet aufsetzen; Barge-in-Vorstufe statt 450-ms-Cooldown |
| 12 | F5-TTS Phase C/D abschließen → Finalentscheid pocket vs. F5 (Benchmark-Gate) |
| 13 | useVoiceAgent-Refactor in Hooks + TTS-Retry/Backoff; Electron 33→43 |
| 14 | Backend C2 (Vision async) + C3 (DRY) + Dead Code; Frontend Cockpit/SystemDrawer-Split + globale Error Boundary |
### P3 — Strategisch (nach Signal)
| # | Maßnahme |
|---|---|
| 15 | Mem0-v3-Status final verifizieren; Junk-Regex durch v3-Retrieval entschärfen; mc2-memory auf `sync_turn(messages)` heben |
| 16 | MC3-Vollbau; llama-swap-Features (capabilities, config-dir, Swap-Matrix) |
| 17 | heavy-Bench: Qwen3.5-122B vs. gpt-oss-120B / Mistral Small 4; Vision-Upgrade Qwen3-VL-30B-A3B für Bildschirm-Sicht |
| 18 | Lokaler GPU-Klon: AMD Lemonade auf der 9070 XT evaluieren |
---
## 5. Findings-Katalog (Referenz)
| ID | Schwere | Fund | Ort |
|---|---|---|---|
| B1 | ⚪ (Fehlalarm) | :9001 läuft als User-Unit mit Linger — reboot-fest; nur stale v1-Unit als Kosmetik-Rest | Box, /etc/systemd/system/mission-control.service (v1) |
| B2 | 🔴 | chat-Lane → fast-Alias existiert nicht mehr | Box, Routing-Policy ↔ /etc/llama-swap/config.yaml |
| B3 | 🟡 | deploy/llama-swap.config.yaml untracked + veraltet | Repo |
| C1 | 🟡 | Install-Template ohne Produktions-Tunings | backend/config.py:33 |
| C2 | 🟡 | Vision-Beschreibung blockiert Chat bis 120 s | backend/routers/voice.py |
| C3 | 🟢 | Thinking-Disable 3× dupliziert | voice.py / gateway.py / mem0_service/app.py |
| C4 | 🟢 | Junk-Fact-Regex hartcodiert | mem0_service/app.py |
| C5 | ⚪ (Fehlalarm) | pricing.py + token_stats.py sind IN BENUTZUNG (system.py /token_stats-Endpoint, gateway_stream) — kein Dead Code | backend/services/ |
| F1 | 🟡 | Monolithen 990/934 Z | frontend/src/views/models/Cockpit.tsx, components/SystemDrawer.tsx |
| F2 | 🟡 (korrigiert) | dist/ im Git ist ABSICHT (Box hat kein Node, deploy = git reset --hard; s. deploy/build.sh) — echter Fund war nur der inkonsistente Asset-Stand auf lucy-v2 (behoben durch Rebuild+Commit). Build-on-Box/CI wäre P3-Option | frontend/dist/ |
| F4 | 🟢 | Keine globale Error Boundary | frontend/src/App.tsx |
| L1 | 🟡 | useVoiceAgent monolithisch (377 Z) | client/lucy-desktop/.../lib/voice/useVoiceAgent.ts |
| L2 | 🟡 | Kein TTS-Warmup-Retry | ebd. + main/index.ts |
| L3 | 🔴 | RMS-VAD mit 900-ms-Timer | client/lucy-desktop/.../lib/voice/useVAD.ts |
| L4 | 🟡 | Echo-Cooldown statt Barge-in | ebd. |
| H1 | 🟡 | ~23 Dateien uncommitted (inkl. voice-core, MC3) | lucy-v2 Working Tree |
---
## 6. Nachtrag: P0/P1-Umsetzung (02.07.2026)
**P0 komplett:** chat-Lane gefixt (fast-Alias), Hermes v0.18.0 (Postcheck grün), Config versioniert, Template-Drift behoben, Git aufgeräumt. B1 war Fehlalarm (s.o.).
**P1-6 STT:** Parakeet-TDT 0.6B v3 (onnx-asr, int8, Box-CPU) als Default — **A/B gemessen: 0,45 s vs. 2,44 s** (whisper-medium) bei identischem Transkript. Live deployt, auch via :9001 verifiziert.
**P1-7 VAD:** Silero v5 (vad-web 0.0.30) statt RMS — Endpointing 900→450 ms, WAV direkt (kein Opus-Umweg mehr). Build + Asset-Auslieferung verifiziert; Mikro-Test beim User.
**P1-9 Brain-Bench-Matrix** (Qwen3.6-35B-A3B, separater Port, Vulkan, je ~100 Gen-Token):
| Config | tg kurz | tg tief (15k) | Draft-Akzeptanz kurz |
|---|---|---|---|
| MTP n-max 3, KV f16 (live) | **78,6 t/s** | — (Probe-EOS) | 55 % |
| MTP n-max 4 | 63,7 | 100,9* | 39 % |
| ohne Spec | 62,4 | — (Probe-EOS) | — |
| **MTP n-max 3 + KV Q8_0** | **78,6** | 83,7 | 56 % |
| ohne Spec + KV Q8_0 | 62,1 | 57,0 | — |
*\*repetitiver Test-Text → unrealistisch hohe Draft-Akzeptanz (95 %); die Kurz-Spalte ist maßgeblich.*
**Bench-Verdikte:** (1) MTP n-max 3 bestätigt (+26 % vs. ohne Spec — die „MoE Speculative Trap" gilt auf diesem Vulkan/parallel-1-Setup NICHT; der Artikel testete ROCm + parallel 4). (2) n-max 4 lohnt nicht (Akzeptanz fällt auf 39 %). (3) **KV Q8_0 ist gratis** (identische t/s) und halbiert den KV-Speicher → für hermes in deploy/llama-swap.config.yaml übernommen; Live-Schaltung braucht User-Freigabe. Für coder/heavy vorher cache-reuse×KV-Quant-Verträglichkeit testen (Context-Shift mit quantisiertem KV ist in llama.cpp historisch heikel).
**P1-10:** `chat_first_content`-Metrik in voice.py — misst die echte Hirn-Latenz (Agent-Overhead + LLM-TTFT bis zum ersten Inhalts-Token) statt nur des SSE-Starts.
**P2-Nachtrag (02.07.):**
- **Profiling:** Folge-Turns 1,3 s, NEUE Sessions 5,612 s (Session-Prefill System-Prompt+Tools ~45k Tok). Mem0-Search unschuldig (18 ms). **Aber:** Die Mem0-Lern-Extraktion (~2,4 s je Turn, gleiche Qwen3.6-Instanz) blockiert bei `--parallel 1` den nächsten Voice-Turn in der Queue → Fix vorbereitet: `--parallel 2 -c 131072 + KV Q8_0` (2×65k-Slots, speicherneutral zu 1×65k f16). Live-Schaltung = User (deploy/llama-swap.config.yaml ist fertig).
- **C2 gefixt:** Bildschirm-Sicht läuft jetzt IM Stream (SSE startet sofort, `hermes.vision.progress`-Event → Lucy kann Warte-Ansage sprechen); Vision-Timeout 120→45 s (MC_VISION_TIMEOUT).
- **C3 bewertet:** nur 2 Backend-Stellen mit unterschiedlicher Gating-Logik (dritte im separaten Mem0-venv) → kein Shared-Helper erzwungen, Pattern dokumentiert.
- **C5 war Fehlalarm** (s. Findings-Tabelle).
- **L2 gefixt:** TTS-Warm-Gate mit Retry/Backoff + sichtbarer Fehlermeldung (vorher: nach 120 s stumm „ready" ohne Stimme).
## 7. Nachtrag 2: P2/P3-Vollausbau (02.07., zweite Session-Hälfte)
**Brain-Config LIVE DEPLOYT + verifiziert (02.07., User-Freigabe):** hermes läuft mit `-c 131072 --parallel 2 -ctk/-ctv q8_0` + MTP. Gemessen: **2 gleichzeitige Requests laufen echt parallel** (~1,9 s/2,0 s statt seriell — Mem0-Extraktion blockiert Voice-Turns nicht mehr), tg 66 t/s (leichter parallel-2-Abschlag vs. 78 solo, bewusster Trade), RAM 40/122 GB. **Voice-E2E: neue Session 0,85 s total, first_content 803 ms** (Morgen-Baseline: 5,612 s bei neuen Sessions). capabilities werden via /v1/models ausgeliefert.
| Punkt | Ergebnis |
|---|---|
| **P2-11 Turn-Detection** | ✅ Smart Turn v3.2 (8 MB ONNX) im Voice-Sidecar (`/turn`, ~110 ms warm) + Semantik-Hold im Client (bei „unfertig" bis 1,8 s auf Fortsetzung warten). **Zwei Upstream-Fallen live diagnostiziert:** Audio muss LINKS gepadded werden (sonst konstant „complete"), und der Output ist P(unfertig) — entgegen der Doku. Verifiziert: fertig=0,74→true, mitten im Wort=0,04→false |
| **P2-12 F5 Phase C/D** | ✅ **Verdikt: Pocket bleibt.** F5-ONNX auf 9070 XT/DirectML: RTF 0,59@NFE32 / 0,30@NFE16 — aber nicht streamfähig (TTFA = ganze Satzdauer), NFE16-Qualitätsrisiko, GPU-Dauerbelegung. F5 taugt als Offline-Renderer, nicht für den Dialog-Loop |
| **P2-13a Refactor** | ✅ useVoiceAgent 397→305 Z; textPipeline/visionIntent/perf als pure Module |
| **P2-13b Electron** | ✅ 33→43 (Chromium 150/Node 24), Boot-Smoke-Test grün (allow-scripts-Falle: install.js manuell) |
| **P2-14 Cockpit/SystemDrawer** | ⏸ **bewusst vertagt:** reiner Qualitäts-Refactor von live deployter UI; braucht eine eigene Session mit Browser-Verifikation (MC_API_TARGET-Workflow) statt eines Blind-Splits am Session-Ende |
| **P3-15 Mem0 v3** | ✅ verifiziert aktiv (BM25/Entity/Hybrid in 2.0.8); mc2-memory nutzt jetzt `sync_turn(messages)` — lernt Tool-NAMEN mit (Ergebnisse bewusst nicht: Poisoning-Vektor). Deployt, Postcheck grün |
| **P3-16 llama-swap** | ✅ `capabilities` (in/out/tools/context) je Modell in deploy-Config; Swap-Matrix aktuell unnötig (brains-Gruppe reicht) |
| **P3-17 Kandidaten** | ✅ **Beide gebencht + als testbare Modelle live deployt** (ohne Alias-Wechsel — Qualitäts-Entscheid beim User): (1) **gpt-oss-120B: tg 5455 t/s vs. heavy (Qwen3.5-122B) 23,5 t/s = 2,3× schneller bei 60 statt 73 GB** → klare Empfehlung für die heavy-Lane nach Deutsch-/Reasoning-Check (`model:"gpt-oss-120b"` am Gateway testen). (2) **Qwen3-VL-30B-A3B: tg 9092 t/s** → Bildschirm-Sicht-Upgrade nach Screenshot-Check (`MC_VISION_MODEL=Qwen3-VL-30B-A3B-Instruct`); brains-Budget-Frage: 19 vs. 6 GB warm |
| **P3-18 Lemonade** | ✅ **Verdikt: nicht als Unterbau.** lemonade-sdk 9.1.4 (Python) installiert + Server lief (OpenAI-API, gute Registry inkl. gpt-oss/GLM) — aber: Python-Edition **offiziell deprecated** (C++-Installer ist der Weg), Server blockiert komplett während Model-Downloads (Health tot >10 min bei 400-MB-Modell), Ryzen-AI-Hybrid auf 9700X unsupported. **Empfehlung für den lokalen GPU-Klon: llama.cpp-Vulkan + llama-swap — derselbe Stack wie die Box** (ein Betriebsmodell, ein Know-how, bewährte Configs). Lemonade-C++ nur, falls Whisper+TTS+LLM aus einer Hand gewünscht |
| **MC3-Vollbau** | ⛔ außerhalb des Review-Scopes — eigenes Projekt (Prototyp steht unter #mc3) |
---
*Erhoben am 02.07.2026 durch Claude Code (Live-SSH auf Box, lokale PC-Prüfung, 3 Codebase-Agents, 3 Web-Recherche-Runden). Messwerte: /api/voice/metrics (n=13 STT, n=18 chat_ttfb), TTFT-Direktmessung llama-swap :8080.*
+83
View File
@@ -0,0 +1,83 @@
# Brief: ZeroClaw-PoC als Lucys schlanker Agent-Runtime (Latenz-Fix an der Wurzel)
> Für eine **frische Claude-Code-Session**. Aufgabe: ZeroClaw (ultraleichter Rust-Agent) als
> risikoarmen Proof-of-Concept neben Hermes aufsetzen und gegen Hermes benchmarken (Prompt-Größe +
> Latenz). Hermes bleibt **unangetastet** parallel laufen — null Risiko für die laufende Lucy.
## Warum (die ganze Vorgeschichte in Kürze)
Lucy (Hermes-Agent, Hirn = gemma-4-26B-A4B) braucht **ewig** zum Antworten (Output top, aber 2080 s/Turn).
Eine sehr lange Latenz-Jagd (2026-06-30) hat die Ursachen **definitiv** geklärt:
1. **mem0 nutzte `fast` (Qwen) als LLM** (`MEM0_LLM_MODEL=fast`) — das war noch von früher, als `fast` Lucys
Hirn WAR. Nach dem Umzug auf gemma blieb mem0 auf fast → jede Erinnerungs-Op lud fast → **verdrängte
gemmas Warm-Gruppe** → gemma-KV-Cache tot → 40 s Re-Prefill. (Halb-fertige Hirn-Migration.)
2. **gemma ist architektonisch langsam auf Vulkan/Strix-Halo.** Verifiziert am Box-GGUF:
`attention.key/value_length = 512` (head_dim 512!) vs. Qwen3.6 **256**, plus mixed sliding/full
attention → Flash-Attention auf RADV ineffizient → langsamer **Prefill** + **CPU-Spike** (teils
CPU-Fallback). gemma-GENERIERUNG ist ok (~93 t/s), gecachter Prompt **0,3 s** — nur **Cache-Misses**
(Prefill, ~1127 s) sind tödlich.
3. **gemmas Thinking ist per Default AN** (~90 Extra-Tokens/Antwort). Abschaltbar NUR via
`chat_template_kwargs:{enable_thinking:false}` (verifiziert; `/no_think` & `reasoning_effort` wirken nicht).
4. **Hermes selbst ist Schwergewicht** — DER eigentliche Overhead: 20k-Token-Prompt = **79 Tool-Schemas**
(~17,5k) + **Skills-Manifest** (27 Skills, ~8,7k) + **3-Schichten-Memory mit Auto-Injektion**. Die
Auto-Injektion **variiert den Prompt jeden Turn** → bricht gemmas Cache → Re-Prefill.
**Verdikt:** Auch nach JEDEM Fix (Eviction behoben: mem0/aux→fast + fast ko-resident; Slot geschützt;
Thinking aus; Auto-Injektion aus; RADV ✓; FA ✓; single-slot cache-reuse) blieb gemma **680 s,
unzuverlässig** — der Prozess lief stabil (kein Crash/Eviction), aber gemmas Prefill kommt mit Hermes'
pro-Turn-wechselndem 20k-Prompt nicht klar. **gemma ist auf dieser HW+Agent fundamental ungeeignet für
verlässlich niedrige Latenz.** Einziger gemma-Vorteil: **deutlich besseres Deutsch** (Qwen leakt englische
Wörter, z.B. „biggest"; gemma = natürlich + echte Umlaute).
## Die These des PoC
**Hermes' Schwergewicht (Skills + ChromaDB + Auto-Injektion) baut den fetten, instabilen Prompt.**
Ein schlanker Agent (**ZeroClaw**: 3,4 MB Rust, SQLite-Memory statt ChromaDB, minimaler Overhead, „ruthless
about cutting") würde einen **kleinen, STABILEN** Prompt bauen → gemmas langsamer Prefill hat kaum was zu
kauen → **Lucy schnell mit gemma UND Qwen** → gemmas Deutsch gerettet + Lucy entschlackt. Wurzel-Fix statt
Modell-Pflaster.
## PoC-Aufgabe (risikoarm, Hermes bleibt parallel)
1. **ZeroClaw** auf die Box holen (Rust-Binary; Repos: github.com/zeroclaw-labs/zeroclaw bzw.
github.com/elev8tion/zeroclaw — beides prüfen, das gepflegtere/passende nehmen). Eigener Port, **NICHT**
:8642 (Hermes) anfassen.
2. **An euer MC2-Gateway** hängen (OpenAI-kompatibel, `http://127.0.0.1:9001/v1`). Erst mit Modell `fast`
(Qwen, schnell) testen, dann `hermes` (gemma) für den Deutsch-Vergleich.
3. **Nur Kern-Tools** zuerst: Memory (ZeroClaw-eigenes SQLite ODER per MCP an mc2-memory) + 12 Stack-Tools.
Eure bestehenden MCP-Server (`hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch`) kann ZeroClaw als MCP-Client mitnutzen.
4. **Benchmark gegen Hermes (Zahlen, nicht Gefühl):**
- **Prompt-Größe** (prompt_tokens) eines simplen Turns: ZeroClaw vs. Hermes (Hermes ≈ 20.000).
- **Latenz** mehrerer Folge-Turns (Cache-Verhalten): wird der Prompt klein+stabil → cached gemma → schnell?
- Messmethode wie in der Saga: `curl` an den Agent + `/v1/chat/completions`-`usage.prompt_tokens` +
llama-swap-POST-Dauer (`journalctl -u llama-swap`).
5. **Entscheidung:** Wird Lucy mit ZeroClaw schnell (idealerweise mit gemma) → voller Umzug planen
(Persona, Memory-Migration mem0/Chroma→SQLite, MCP-Tools, Telegram/Voice). Wenn nicht → **Fallback:
Lucys Hirn auf Qwen3.6-35B-A3B** (schnell+verlässlich) + harte Deutsch-Direktive im System-Prompt.
## Box-Fakten (für den PoC)
- **Box:** `ssh hitonabi@192.168.178.151` (key-based). Strix Halo, 122 GB, GTT ~124 GB, Vulkan/RADV.
- **MC2-Gateway:** `:9001/v1` (OpenAI-kompat). Aliase: `hermes`→gemma-4-26B (Lucys Hirn), `fast`→Qwen3.6-35B,
`heavy`→Qwen3.5-122B, `coder`/`coder-lite`, `vision`→Qwen3-VL-8B, `embed`→Qwen3-Embedding.
- **llama-swap:** `:8080` (Engine v9843, Vulkan/RADV, `--list-devices` zeigt nur die RADV-GPU — kein
llvmpipe-Fallback). gemma-cmd: `-c 65536 -fa on --cache-reuse 256 -cram 16384` + MTP-Draft.
- **Hermes:** `:8642` (NousResearch), Config `~/.hermes/config.yaml`, API-Key in `~/.hermes/.env`
(`API_SERVER_KEY`). 4 MCP-Server aktiv. **NICHT anfassen — bleibt Lucys Live-Agent bis ZeroClaw steht.**
- **mem0-Sidecar:** `:8765` (`~/.mem0/`, Chroma unter `/srv/models/mem0/chroma`), `MEM0_LLM_MODEL=fast`,
`MEM0_EMBED_MODEL=embed`. Systemd-User-Unit `~/.config/systemd/user/mem0-service.service`.
- **brains-Gruppe** (immer warm, ko-resident): gemma + fast + embed + vision.
## Modell-Kandidaten (falls Fallback/Vergleich)
| Modell | Profil | Speed (Strix Halo) | Deutsch |
|---|---|---|---|
| gemma-4-26B-A4B | head_dim 512, Thinking, multimodal | langsam (Prefill) | **exzellent** |
| **Qwen3.6-35B-A3B** (auf Box) | head_dim 256, GQA kv=2 | **~85100 t/s** | gut, aber English-Leaks |
| Qwen3-30B-A3B-Instruct | gleiche Familie | 755 pp / 85 tg (RADV) | wie Qwen3.6 |
| LFM2-8B-A1B (Liquid) | 8B/1B aktiv, Hybrid Conv+MoE | **~170 t/s** | klein → unsicher Deutsch |
## Leitplanken
- **Hermes/Lucy bleibt live** — PoC läuft isoliert (eigener Port, eigene Memory-Datei). Null Risiko.
- Vor Box-Config-Änderungen Backup; llama-swap reloadt per `-watch-config`.
- Erst messen (Prompt-Größe + Latenz), dann über vollen Umzug entscheiden.
</content>
+118
View File
@@ -0,0 +1,118 @@
# ZeroClaw-PoC — Ergebnis & Verdikt (2026-06-30)
> Durchführung des Plans aus `docs/ZEROCLAW_POC_BRIEF.md`. Hermes blieb die ganze Zeit
> **unangetastet** live (eigener Config-Dir, eigene SQLite-Memory, kein eigener Daemon-Port nötig
> PoC lief als kurzlebiger CLI-Prozess). Null Risiko für die laufende Lucy.
## Setup (reproduzierbar, alles auf der Box unter `~/zeroclaw-poc/`)
- **Binary:** ZeroClaw **v0.8.2** (`zeroclaw-labs/zeroclaw`, 32k ⭐, gepflegt die andere Repo
`elev8tion/zeroclaw` ist ein 23-Commit-Mini-Fork). Prebuilt `x86_64-unknown-linux-gnu`, SHA256
gegen `SHA256SUMS` verifiziert (`6b9f7e9d…`). Kein Rust-Toolchain auf der Box nötig.
- **Config:** isoliert via `--config-dir ~/zeroclaw-poc/config`. Provider = nativer `openai`
gegen das **MC2-Gateway** (`uri = http://127.0.0.1:9001/v1`, ZeroClaw hängt `/chat/completions`
selbst an; **base-URL, nicht der volle Pfad!**). Gateway braucht **keine Auth** (HTTP 200 offen).
- **Agent:** `[agents.lucy]`, `model_provider = openai.default`, `risk_profile = default`
(supervised), Memory-Backend = **sqlite**. Modellwahl pro Turn via `--model fast|hermes`.
- Stolpersteine gelöst: quickstart braucht TTY → headless via `config set --no-interactive`;
Secrets (`api_key`) brauchen `--no-interactive` + Wert; `risk_profiles.<key>` ist Map →
per `config set` nicht adressierbar, Block direkt in `config.toml` angehängt.
## Messungen (4 Folge-Turns, je frischer CLI-Prozess, identischer 13k-Prefix)
| Turn | FAST (Qwen3.6-35B) | HERMES (gemma-4-26B) | input_tokens |
|---|---|---|---|
| 1 | 429 ms | **937 ms** | ~13.059 |
| 2 | 534 ms | **493 ms** | ~13.056 |
| 3 | 337 ms | **340 ms** | ~13.060 |
| 4 | 465 ms | **465 ms** | ~13.061 |
`hermes`-Alias → verifiziert echtes `gemma-4-26B-A4B-it-…Q4_K_M.gguf` (kein Fallback auf fast);
gemma ist warm in der brains-Gruppe (llama-swap `/running`: gemma + Qwen3.6 + embed alle `ready`).
## Kernbefunde
1. **Prompt-Größe:** ZeroClaw baut **~13.05513.061 Tokens** pro simplem Turn — vs. Hermes **≈ 20.000**.
~35 % kleiner. (Noch weiter reduzierbar: Default-Toolset; PoC lief mit voller Tool-Palette.)
2. **Prompt-Stabilität:** Der Prefix ist **konstant** (13.05513.061, nur die User-Message variiert
um wenige Tokens). Genau das Gegenteil von Hermes' pro-Turn-wechselnder Auto-Injektion, die
gemmas Cache jeden Turn brach.
3. **Latenz (das Verdikt):** gemma antwortet unter ZeroClaw in **0,30,9 s/Turn** — gegenüber
**2080 s** unter Hermes. Der Latenz-Abfall 937 ms → ~340 ms ab Turn 2 zeigt den **warmen Cache**
(stabiler Prefix → cache-reuse greift). gemma ist hier sogar **gleichauf mit Qwen**.
## Verdikt
**Die PoC-These ist bestätigt.** Lucys Latenz-Problem war **nicht gemma**, sondern Hermes'
fetter, pro-Turn-instabiler 20k-Prompt, der gemmas Prefill-Cache zerschoss. Ein schlanker Agent
mit **kleinem, stabilem** Prompt macht **gemma sub-sekündlich** — und rettet damit gemmas
**exzellentes Deutsch** (kein Umstieg auf Qwen mit English-Leaks nötig).
**Empfehlung: voller Umzug auf ZeroClaw planen** (mit gemma als Hirn). Kein Fallback auf Qwen nötig.
## Umzug umgesetzt (2026-06-30, Etappe 2)
Lucy läuft als ZeroClaw-Agent auf der Box (`~/zeroclaw-poc/`), Daemon auf `127.0.0.1:8088`:
- **Persona:** openclaw-Workspace-Dateien in `config/agents/lucy/workspace/` (`IDENTITY.md`, `SOUL.md`,
`USER.md`, `AGENTS.md`) — Lucy spricht „Commander", echte Umlaute, `<emo:>`-Tags. (Workspace-Pfad =
`<config>/agents/<alias>/workspace/`, NICHT `config/data`!)
- **MCP-Tools:** 4 Server / 37 Tools (`[[mcp.servers]]` als **Array** mit `name`, nicht Tabelle!) +
Bundle `lucy_tools` an `agents.lucy.mcp_bundles`. Alle verbunden, autonom nutzbar.
- **Hirn:** gemma (`hermes`), Autonomie `level="full"`, Thinking aus via
`providers.models.openai.default.chat_template_kwargs.enable_thinking=false`.
- **Aux:** `classifier_provider`/`summary_provider``openai.fastaux` (Qwen); Auto-Hydrate/Save aus.
## 🔴 KV-Cache-Killer gefunden & gefixt (der eigentliche Latenz-Showstopper)
Daemon-Latenz war erst 522 s/Turn (wild schwankend), obwohl Direkt-ans-Gateway gemma einen stabilen
20k-Prompt in **180 ms** cached (call 1 kalt 19 s, call 24 = 180 ms, cached=19820). Per Mitschnitt-Proxy
(ZeroClaw→Proxy→Gateway) zwei Folge-Turns **byte-genau gedifft**:
1. **HAUPTSCHULDIGER — Tool-Reihenfolge:** ZeroClaw hängt die 37 MCP-Tools (im `tools`-Array UND im
`deferred_loading`-Textblock) in **zufälliger HashMap-Reihenfolge pro Turn** an → ~5k Token im Prompt
ändern sich jeden Turn → llama.cpp-KV-Cache ab da tot → Full-Re-Prefill.
2. **Neben-Killer — User-Timestamp:** ZeroClaw prefixt die User-Message mit `[YYYY-MM-DD HH:MM:SS ±ZZ:ZZ]`
(variiert jeden Turn; steht am Ende → kleiner).
3. **Ausgeschlossen:** Classifier (feuert gar nicht, 1 Call/Turn), mem0/Memory (feuert bei Chat nicht).
**Fix (Binary nicht kompilierbar → Wire-Ebene):** schlanker Python-**Normalisierungs-Proxy**
(`~/zeroclaw-poc/normalizing-proxy.py`, Port 9009) zwischen ZeroClaw und Gateway: **sortiert `tools`
deterministisch** + **strippt den Timestamp**. Beide Provider-URIs zeigen auf `:9009`. Ergebnis byte-stabil
verifiziert (System + Tools identisch, nur echte User-Message variiert). **Latenz 522 s → ~400 ms typisch**
(Cache-Hit), vereinzelt 25 s (gemma cache-reuse/MTP-Rest, evtl. via `-cram 16384`); Tool-Turn ~3,6 s.
gemma-cmd: `-c 65536 -fa on --cache-reuse 256 -cram 16384 + MTP-draft`, single-slot (`--parallel 1` implizit).
## gemma-4 Cache-Recherche (llama.cpp) + FINALER Hirn-Entscheid: Qwen3.6
Nach dem Proxy blieben Rest-Spitzen (26 s). Tagesaktuelle llama.cpp-Recherche bestätigt: **bekannter,
Gemma-4-spezifischer Bug.**
- **Gemma 4 nutzt „Shared KV Cache"** (letzte Layer teilen K/V mit früheren) → llama.cpp loggt wörtlich
*„cache reuse is not supported - ignoring n_cache_reuse"* → **euer `--cache-reuse 256` ist für gemma-4
wirkungslos.** ([Issue #21468](https://github.com/ggml-org/llama.cpp/issues/21468))
- **Fix [PR #22288](https://github.com/ggml-org/llama.cpp/pull/22288)** (merged 24.04.2026): braucht
**`--swa-full`** (hält ganze KV-History, kein Sliding-Window-Pruning). Caveat: cache-reuse ↔ mmproj
inkompatibel.
- **Getestet an der live gemma:** `--swa-full` testweise gesetzt → mehr saubere 400-ms-Hits, ABER weiter
~50 % Misses (26 s). Auch nach Toolset-Eindampfung (Prompt 18k→**4908 Tok** via
`risk_profiles.default.excluded_tools`, 18 Kern-Tools) blieb gemma 1,56 s **spitzig**.
- **Befund:** gemma-4 cached auf Strix Halo **fundamental unzuverlässig** (SWA + Shared-KV + MTP), egal wie
klein/stabil der Prompt. → live gemma wieder auf **Original zurückgesetzt** (Hermes unberührt;
`--swa-full` als getestete Option dokumentiert, falls Hermes es nutzen will).
**Qwen3.6-35B-A3B als Lucys Hirn (Standard-GQA, cache-reuse funktioniert):** mit Proxy + Lean-Tools
**konstant ~1,21,8 s/Turn, NULL Spitzen** (vs gemma 1,56 s spitzig). Deutsch gut (echte Umlaute, kein
EN-Leak in Tests; seltener Kohärenz-Wackler). **Das ist der Brief-Fallback — jetzt empirisch als richtige
Wahl bestätigt.** Lucy-Daemon läuft auf `model=fast`.
## Finaler Lucy-Stack (läuft, manuell/nohup)
```
Desktop/Webhook → ZeroClaw-Daemon :8088 (Persona, 18 Lean-Tools, Autonomie full)
→ Normalisierungs-Proxy :9009 (sort tools + strip timestamp) ← der Cache-Fix
→ MC2-Gateway :9001 → llama-swap :8080 → Qwen3.6 (fast) ← rock-solid Cache
```
## Nächste Schritte (offen)
- **Persistenz:** Proxy + Daemon als systemd-User-Services (vom User freizugeben).
- **Voice/Telegram** an den Daemon hängen (Desktop-Client postet aktuell an MC2 `/api/voice/chat`).
- **ZeroClaw-Tool-Order-Bug** upstream melden (nicht-deterministische Tool-Sortierung killt KV-Cache).
- Optional: Deutsch-Direktive in IDENTITY.md härten gegen Qwens seltene Wackler.
- Persona/System-Prompt (Lucys Charakter) in ZeroClaw übertragen.
- Memory-Migration: mem0/Chroma → ZeroClaws SQLite (oder MCP-Bridge an `mission-control-memory`).
- MCP-Tools anbinden: `hermes-pc-control`, `mission-control-stack`, `mission-control-memory`,
`hermes-web-fetch` (ZeroClaw als MCP-Client).
- Toolset auf Kern reduzieren → Prompt < 13k drücken (noch schnellerer Prefill).
- Kanäle: Telegram + Voice an ZeroClaw hängen; Gateway-/Daemon-Betrieb (eigener Port, nicht :8642).
- Erst nach grünem Umzug Hermes ablösen.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<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-DjSNKxZa.js"></script> <script type="module" crossorigin src="/assets/index-DvUiHM4g.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-liXV9csI.css"> <link rel="stylesheet" crossorigin href="/assets/index-_Rap01H_.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8 -3
View File
@@ -9,13 +9,13 @@ import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView" import { MemoryView } from "@/views/MemoryView"
import { AgentView } from "@/views/AgentView" import { AgentView } from "@/views/AgentView"
import { TerminalView } from "@/views/TerminalView" import { TerminalView } from "@/views/TerminalView"
import { VoiceView } from "@/views/VoiceView"
import { GuideView } from "@/views/GuideView" import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer" import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries" import { useHealth, useSystemStatus } from "@/lib/queries"
import { useMetricsFeeder } from "@/lib/metricsStore" import { useMetricsFeeder } from "@/lib/metricsStore"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Mc3App } from "@/mc3/Mc3App"
export default function App() { export default function App() {
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
@@ -31,6 +31,8 @@ export default function App() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance") const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// MC3-Prototyp läuft nicht-destruktiv unter #mc3 (produktives MC2 bleibt Default).
const [isMc3, setIsMc3] = useState(() => window.location.hash === "#mc3")
const { data: health } = useHealth() const { data: health } = useHealth()
const { data: sysStatus } = useSystemStatus(20_000) const { data: sysStatus } = useSystemStatus(20_000)
@@ -60,6 +62,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
const onHash = () => { const onHash = () => {
setIsMc3(window.location.hash === "#mc3")
const h = window.location.hash.slice(1) as ViewId const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h) if (NAV.some((n) => n.id === h)) setView(h)
} }
@@ -69,6 +72,9 @@ export default function App() {
const active = NAV.find((n) => n.id === view)! const active = NAV.find((n) => n.id === view)!
// Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche.
if (isMc3) return <Mc3App />
return ( return (
<div className="flex h-full relative"> <div className="flex h-full relative">
{/* Background Aurora Ambient Effects */} {/* Background Aurora Ambient Effects */}
@@ -228,9 +234,8 @@ export default function App() {
{view === "memory" && <MemoryView />} {view === "memory" && <MemoryView />}
{view === "agent" && <AgentView />} {view === "agent" && <AgentView />}
{view === "terminal" && <TerminalView />} {view === "terminal" && <TerminalView />}
{view === "voice" && <VoiceView />}
{view === "guide" && <GuideView />} {view === "guide" && <GuideView />}
{!["dashboard", "models", "connect", "memory", "agent", "terminal", "voice", "guide"].includes(view) && ( {!["dashboard", "models", "connect", "memory", "agent", "terminal", "guide"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} /> <Placeholder title={active.label} hint={active.hint} />
)} )}
</main> </main>
@@ -0,0 +1,38 @@
import React from "react"
// Globale Error Boundary (Review F4): Ein JS-Fehler in einer View riss vorher die komplette
// App in einen weißen Screen. Hier: Fehler anzeigen + Neu-laden-Knopf, Rest bleibt bedienbar
// nach Reload. (GraphView hat zusätzlich seine eigene Boundary für Three.js-Crashes.)
interface State { error: Error | null }
export class AppErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Unbehandelter UI-Fehler:", error, info.componentStack)
}
render() {
if (!this.state.error) return this.props.children
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-neutral-200 p-8">
<div className="max-w-lg space-y-4 text-center">
<div className="text-2xl">Da ist etwas schiefgelaufen.</div>
<div className="text-sm text-neutral-400 break-all">
{this.state.error.message}
</div>
<button
className="px-4 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700"
onClick={() => window.location.reload()}
>
Neu laden
</button>
</div>
</div>
)
}
}
+10 -280
View File
@@ -1,8 +1,11 @@
import { useEffect, useState, useRef } from "react" import { useEffect, useState, useRef } from "react"
import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, Eye, EyeOff, Key, AlertTriangle, Camera, Bot, Box, FileText, Package, GitCommit, ExternalLink, ArrowRight, Shuffle } from "lucide-react" import { X, RefreshCw, Terminal, Cpu, Server, Shield, Power, AlertTriangle, Camera, Bot, Box, Shuffle } from "lucide-react"
import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api" import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomDialog } from "./CustomDialog" import { CustomDialog } from "./CustomDialog"
import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows"
import { SettingsTab } from "./system/SettingsTab"
import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal"
interface SystemDrawerProps { interface SystemDrawerProps {
@@ -11,58 +14,6 @@ interface SystemDrawerProps {
defaultTab?: "maintenance" | "logs" | "settings" defaultTab?: "maintenance" | "logs" | "settings"
} }
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
const SERVICES = [
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
]
function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) { export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) {
const [updates, setUpdates] = useState<UpdatesResp | null>(null) const [updates, setUpdates] = useState<UpdatesResp | null>(null)
@@ -81,7 +32,7 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
const [hermesUpdating, setHermesUpdating] = useState(false) const [hermesUpdating, setHermesUpdating] = useState(false)
// Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird. // Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird.
const [detail, setDetail] = useState<{ kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null } | null>(null) const [detail, setDetail] = useState<UpdateDetailState | null>(null)
// Custom Dialog State // Custom Dialog State
const [dialog, setDialog] = useState<{ const [dialog, setDialog] = useState<{
@@ -128,18 +79,6 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
}) })
} }
// Credentials State
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
useEffect(() => { useEffect(() => {
if (open && defaultTab) { if (open && defaultTab) {
@@ -702,223 +641,14 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div> </div>
)} )}
{activeTab === "settings" && ( {activeTab === "settings" && <SettingsTab open={open} showAlert={showAlert} />}
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort"
name="sudo-password"
autoComplete="off"
spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token"
name="hf-token"
autoComplete="off"
spellCheck={false}
placeholder="hf_…"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
{detail && (() => { {detail && (
const d = detail.data <UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
const meta = { onClose={() => setDetail(null)} onApply={applyFromDetail} />
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" }, )}
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={() => setDetail(null)} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={() => setDetail(null)} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div>
) : d?.error ? (
<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 ? (
<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>
<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>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={() => setDetail(null)} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={applyFromDetail} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button>
</div>
</div>
</div>
)
})()}
{dialog && ( {dialog && (
<CustomDialog <CustomDialog
@@ -14,7 +14,8 @@ const DOT: Record<string, string> = {
loading: "bg-muted-foreground/40", loading: "bg-muted-foreground/40",
} }
export function LucyHealthCard() { // `frame` = Erzählrahmen: "lucy" (MC2-Dashboard) oder "box" (MC3-Basisstation).
export function LucyHealthCard({ frame = "lucy" }: { frame?: "lucy" | "box" } = {}) {
const { verdict, checks, memory, problems } = useLucyHealth() const { verdict, checks, memory, problems } = useLucyHealth()
const { showAlert, dialogElement } = useDialog() const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient() const qc = useQueryClient()
@@ -49,12 +50,17 @@ export function LucyHealthCard() {
} }
} }
// Titel je Erzählrahmen (Box-Kontrollpanel vs. Lucy-Dashboard).
const T = frame === "box"
? { loading: "Box wird geprüft …", gut: "Alles okay", warn: "Kleinigkeit an der Box", problem: "Box braucht Hilfe" }
: { loading: "Lucy wird geprüft …", gut: "Lucy geht's gut 💚", warn: "Kleinigkeit bei Lucy", problem: "Lucy braucht Hilfe" }
// Banner-Optik je Gesamt-Verdikt. // Banner-Optik je Gesamt-Verdikt.
const banner = { const banner = {
loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: "Lucy wird geprüft …", sub: "Einen Moment, ich schaue nach dem Rechten." }, loading: { ring: "border-border/60 bg-card/45", icon: Loader2, iconCls: "text-muted-foreground animate-spin", title: T.loading, sub: "Einen Moment, ich schaue nach dem Rechten." },
gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: "Lucy geht's gut 💚", sub: "Alle wichtigen Fähigkeiten laufen." }, gut: { ring: "border-emerald-500/40 bg-emerald-500/5", icon: HeartPulse, iconCls: "text-emerald-400", title: T.gut, sub: "Alle wichtigen Fähigkeiten laufen." },
warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: "Kleinigkeit bei Lucy", sub: "Nichts Schlimmes — nur ein Hinweis." }, warn: { ring: "border-amber-500/40 bg-amber-500/5", icon: AlertTriangle, iconCls: "text-amber-400", title: T.warn, sub: "Nichts Schlimmes — nur ein Hinweis." },
problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: "Lucy braucht Hilfe", sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` }, problem: { ring: "border-red-500/50 bg-red-500/5", icon: AlertTriangle, iconCls: "text-red-400", title: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` },
}[verdict] }[verdict]
const BannerIcon = banner.icon const BannerIcon = banner.icon
@@ -0,0 +1,123 @@
import { useEffect, useState } from "react"
import { Eye, EyeOff, Key, Shield } from "lucide-react"
// Einstellungen-Tab des SystemDrawers (Review P2-14: extrahiert). Hält seinen State komplett
// selbst (localStorage-Zugangsdaten) — der Drawer liefert nur `open` (zum Neu-Laden) und
// `showAlert` (gemeinsamer Dialog).
export function SettingsTab({ open, showAlert }: {
open: boolean
showAlert: (title: string, message: string) => void
}) {
const [sudoPasswordInput, setSudoPasswordInput] = useState("")
const [hfTokenInput, setHfTokenInput] = useState("")
const [showSudo, setShowSudo] = useState(false)
const [showHf, setShowHf] = useState(false)
useEffect(() => {
if (open) {
setSudoPasswordInput(localStorage.getItem("mc_sudo_password") || "")
setHfTokenInput(localStorage.getItem("mc_hf_token") || "")
}
}, [open])
return (
<div className="space-y-6">
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Zugangsdaten & Schlüssel</h3>
<p className="text-[10px] text-muted-foreground leading-normal">
Diese Daten werden ausschließlich lokal in deinem Browser (localStorage) gespeichert und bei Bedarf an das Backend übertragen.
</p>
</div>
{/* Sudo Passwort */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Shield className="h-4 w-4 text-amber-400" />
Host Sudo-Passwort
</label>
<div className="relative">
<input
type={showSudo ? "text" : "password"}
value={sudoPasswordInput}
onChange={(e) => setSudoPasswordInput(e.target.value)}
aria-label="Host Sudo-Passwort"
name="sudo-password"
autoComplete="off"
spellCheck={false}
placeholder="Sudo-Passwort für System-Operationen"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowSudo(!showSudo)}
aria-label={showSudo ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showSudo ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Wird benötigt für systemd-Dienste (Llama Swap logs/restart), OS-Update (apt) und Reboot.
</p>
</div>
{/* HuggingFace Token */}
<div className="space-y-2">
<label className="text-xs font-semibold flex items-center gap-1.5">
<Key className="h-4 w-4 text-violet-400" />
HuggingFace API Token
</label>
<div className="relative">
<input
type={showHf ? "text" : "password"}
value={hfTokenInput}
onChange={(e) => setHfTokenInput(e.target.value)}
aria-label="HuggingFace API Token"
name="hf-token"
autoComplete="off"
spellCheck={false}
placeholder="hf_…"
className="w-full h-10 pl-3 pr-10 text-xs bg-background/40 border border-border/60 rounded-lg outline-none focus:border-primary transition-colors text-foreground"
/>
<button
type="button"
onClick={() => setShowHf(!showHf)}
aria-label={showHf ? "Token verbergen" : "Token anzeigen"}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
>
{showHf ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<p className="text-[9px] text-muted-foreground leading-normal">
Erlaubt das Herunterladen von geschützten oder Gate-restricted Modellen direkt über Mission Control.
</p>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
localStorage.setItem("mc_sudo_password", sudoPasswordInput);
localStorage.setItem("mc_hf_token", hfTokenInput);
showAlert("Erfolgreich", "Einstellungen erfolgreich lokal gespeichert.");
}}
className="flex-1 h-9 flex items-center justify-center text-xs font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors shadow shadow-primary/10 cursor-pointer"
>
Speichern
</button>
<button
onClick={() => {
setSudoPasswordInput("");
setHfTokenInput("");
localStorage.removeItem("mc_sudo_password");
localStorage.removeItem("mc_hf_token");
showAlert("Gelöscht", "Zugangsdaten gelöscht.");
}}
className="h-9 px-4 flex items-center justify-center text-xs font-semibold text-muted-foreground border border-border/60 bg-background/20 hover:bg-accent rounded-lg transition-colors cursor-pointer"
>
Zurücksetzen
</button>
</div>
</div>
)
}
@@ -0,0 +1,133 @@
import { ArrowRight, Bot, ExternalLink, GitCommit, Package, RefreshCw, Server, Shield, Shuffle, X } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Job, UpdateDetails } from "@/lib/api"
export type UpdateDetailState = { kind: "os" | "engine" | "swap" | "hermes"; loading: boolean; data: UpdateDetails | null }
// Update-Detail-Fenster des SystemDrawers (Review P2-14: extrahiert): zeigt VOR dem
// Anwenden, was genau aktualisiert wird (apt-Pakete, Engine-Builds, Hermes-Commits).
export function UpdateDetailModal({ detail, maintenanceJob, onClose, onApply }: {
detail: UpdateDetailState
maintenanceJob?: Job
onClose: () => void
onApply: () => void
}) {
const d = detail.data
const meta = {
os: { icon: Shield, cls: "text-cyan-400", title: "OS-Pakete (apt)" },
engine: { icon: Server, cls: "text-violet-400", title: "Inferenz-Engine (llama.cpp)" },
swap: { icon: Shuffle, cls: "text-fuchsia-400", title: "Router (llama-swap)" },
hermes: { icon: Bot, cls: "text-amber-400", title: "Hermes-Agent" },
}[detail.kind]
const Icon = meta.icon
const nothing = !d ? true
: detail.kind === "os" ? (d.count ?? 0) === 0
: detail.kind === "hermes" ? (d.behind ?? 0) === 0
: (d.installed_build != null && d.latest_build != null && d.latest_build <= d.installed_build)
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-lg max-h-[80vh] flex flex-col rounded-2xl border border-border/60 bg-card shadow-2xl font-sans text-foreground">
{/* Header */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-5">
<div className="flex items-center gap-2">
<Icon className={cn("h-4.5 w-4.5", meta.cls)} />
<h3 className="text-sm font-semibold">{meta.title}</h3>
</div>
<button onClick={onClose} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors">
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 text-xs space-y-3 scrollbar-thin">
{detail.loading ? (
<div className="flex h-24 items-center justify-center gap-2 text-muted-foreground">
<RefreshCw className="h-4 w-4 animate-spin" /> Details werden geladen
</div>
) : d?.error ? (
<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 ? (
<div className="text-muted-foreground">Keine neuen Commits Hermes-Agent ist bereits aktuell.</div>
) : (
<>
{d?.summary && (
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3 space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-primary">Was dieses Update bedeutet (Zusammenfassung der Box)</div>
<pre className="whitespace-pre-wrap text-[11px] leading-relaxed text-foreground/90 font-sans">{d.summary}</pre>
</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>
<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>
{/* Footer */}
<div className="flex gap-3 border-t border-border/40 p-4 shrink-0">
<button onClick={onClose} className="h-9 flex-1 rounded-lg border border-border/60 bg-background/20 text-xs font-semibold text-muted-foreground hover:bg-accent transition-colors cursor-pointer">
Schließen
</button>
<button onClick={onApply} disabled={detail.loading || nothing || !!maintenanceJob}
title={maintenanceJob ? `Update läuft bereits: ${maintenanceJob.label}` : undefined}
className="h-9 flex-1 rounded-lg bg-primary text-xs font-semibold text-primary-foreground hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-default">
{maintenanceJob ? "Update läuft…" : "Jetzt aktualisieren"}
</button>
</div>
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { RefreshCw, FileText } from "lucide-react"
import { cn } from "@/lib/utils"
// Präsentations-Atome des SystemDrawers (Review P2-14: aus dem 934-Z-Monolithen extrahiert).
// systemd-Units (restart/logs) + reach = Stichwort zum Mappen auf /api/system/services.
export const SERVICES = [
{ id: "mission-control-2", label: "Mission Control", type: "user", reach: "gateway (integr" },
{ id: "hermes-gateway", label: "Hermes Gateway", type: "user", reach: "hermes-gateway" },
{ id: "mem0-service", label: "Mem0 (Gedächtnis)", type: "user", reach: "mem0" },
{ id: "hermes-terminal", label: "Hermes Terminal", type: "user", reach: "hermes-terminal" },
{ id: "llama-swap", label: "Llama Swap", type: "system", reach: "llama-swap" },
]
export function UpdateRow({ icon: Icon, iconClass, name, status, available, busy, actionLabel, onAction }: {
icon: any; iconClass: string; name: string; status: string; available: boolean; busy?: boolean; actionLabel: string; onAction: () => void
}) {
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-3 py-2",
available ? "border-amber-500/30 bg-amber-500/5" : "border-border/50 bg-background/20")}>
<Icon className={cn("h-4 w-4 shrink-0", iconClass)} />
<div className="flex-1 min-w-0">
<div className="text-xs text-foreground truncate">{name}</div>
<div className={cn("text-[10px] truncate", available ? "text-amber-400/90" : "text-muted-foreground")}>{status}</div>
</div>
<button onClick={onAction} disabled={!available || busy}
className={cn("text-[11px] font-semibold rounded-lg px-2.5 py-1 transition-colors shrink-0",
available ? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer" : "border border-border/50 text-muted-foreground/40 cursor-default")}>
{busy ? "…" : actionLabel}
</button>
</div>
)
}
export function ServiceRow({ label, ok, system, busy, onRestart, onLogs }: {
label: string; ok?: boolean; system?: boolean; busy?: boolean; onRestart: () => void; onLogs: () => void
}) {
return (
<div className="flex items-center gap-2.5 rounded-lg border border-border/50 bg-background/20 px-3 py-2">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0",
ok === true ? "bg-emerald-500" : ok === false ? "bg-red-500" : "bg-muted-foreground/40")} />
<span className="flex-1 text-xs text-foreground truncate">{label}{system && <span className="text-[9px] text-muted-foreground"> (root)</span>}</span>
<button onClick={onRestart} disabled={busy} title="Neu starten" className="text-muted-foreground hover:text-primary transition-colors disabled:opacity-50">
<RefreshCw className={cn("h-3.5 w-3.5", busy && "animate-spin")} />
</button>
<button onClick={onLogs} title="Logs ansehen" className="text-muted-foreground hover:text-primary transition-colors">
<FileText className="h-3.5 w-3.5" />
</button>
</div>
)
}
export function formatBytes(bytes?: number) {
if (bytes == null) return ""
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
return `${(bytes / 1024 ** 2).toFixed(1)} MB`
}
-193
View File
@@ -1,193 +0,0 @@
import { Canvas, useFrame } from "@react-three/fiber"
import { OrbitControls } from "@react-three/drei"
import { useEffect, useRef, useState, type MutableRefObject } from "react"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
import type { Emotion } from "@/lib/voice/sentiment"
// 3D-Avatar (VRM) mit Lippensync (Mund folgt dem TTS-Audiopegel), automatischem Blinzeln und
// stimmungsabhängiger Mimik. Liest die Live-Werte aus Mutable-Refs (kein Re-Render pro Frame).
type LevelRef = MutableRefObject<{ current: number }> // audioLevel.current.current = Pegel 0..1
type EmotionRef = MutableRefObject<Emotion>
// Ruhepose (A-Pose) als Basis — VRMs laden sonst in T-Pose (Arme waagerecht).
const REST: Record<string, [number, number, number]> = {
leftUpperArm: [0, 0, 1.2],
rightUpperArm: [0, 0, -1.2],
leftLowerArm: [0, -0.2, 0],
rightLowerArm: [0, 0.2, 0],
}
function setBone(vrm: VRM, name: string, x: number, y: number, z: number) {
const b = vrm.humanoid?.getNormalizedBoneNode(name as any)
if (b) b.rotation.set(x, y, z)
}
function applyRestPose(vrm: VRM) {
for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
vrm.humanoid?.update()
}
// Lebendige Idle-Animation: prozedural (kein Animations-File). Über die Ruhepose gelegt:
// Atmung, langsames Wiegen + Gewichtsverlagerung, Umschauen (lookYaw/Pitch), Vorlehnen beim
// Sprechen (lean) und Arm-Mitbewegung. Die Augen (lookAt) hält die useFrame separat auf die Kamera.
function applyIdle(vrm: VRM, t: number, level: number, lookYaw: number, lookPitch: number, lean: number) {
const breathe = Math.sin(t * 1.7) // ~0.27 Hz Atmung
const sway = Math.sin(t * 0.45) // sanftes Wiegen
const weight = Math.sin(t * 0.32) // langsame Gewichtsverlagerung
const emphasis = Math.min(1, level * 1.4)
const nod = Math.sin(t * 1.3) * emphasis * 0.06 // Sprech-Nicken
// Rumpf — Atmung, Wiegen, Gewichtsverlagerung, Vorlehnen beim Sprechen
setBone(vrm, "hips", 0, weight * 0.045, weight * 0.03)
setBone(vrm, "spine", breathe * 0.025 + lean * 0.07, sway * 0.022, -weight * 0.03)
setBone(vrm, "chest", breathe * 0.02 + lean * 0.02, sway * 0.018, 0)
setBone(vrm, "upperChest", breathe * 0.015, 0, 0)
// Kopf/Hals — Umschauen (gedriftete Zielwinkel) + Atmung + Sprech-Nicken
setBone(vrm, "neck", lookPitch * 0.4 + nod * 0.5, lookYaw * 0.4, 0)
setBone(vrm, "head", lookPitch * 0.6 + nod * 0.5 + Math.sin(t * 0.6) * 0.015,
lookYaw * 0.6 + Math.sin(t * 0.27) * 0.025, Math.sin(t * 0.5) * 0.02)
// Arme — Ruhepose + leichtes Pendeln + Gewichtsverlagerung
const arm = Math.sin(t * 0.8) * 0.035
setBone(vrm, "leftUpperArm", 0, 0, 1.18 + arm + weight * 0.04)
setBone(vrm, "rightUpperArm", 0, 0, -1.18 - arm + weight * 0.04)
setBone(vrm, "leftLowerArm", 0, -0.18 - Math.sin(t * 0.8) * 0.03, 0)
setBone(vrm, "rightLowerArm", 0, 0.18 + Math.sin(t * 0.8) * 0.03, 0)
}
const EXPRESSIONS = ["happy", "angry", "sad", "surprised", "relaxed"] as const
const EMO_TO_EXPR: Record<Emotion, string | null> = {
neutral: null, happy: "happy", angry: "angry", sad: "sad", surprised: "surprised", relaxed: "relaxed",
}
function VrmModel({ url, audioLevel, emotion, onError }: {
url: string; audioLevel: LevelRef; emotion: EmotionRef; onError: (m: string) => void
}) {
const [vrm, setVrm] = useState<VRM | null>(null)
const smooth = useRef<Record<string, number>>({})
const blink = useRef({ t: 0, next: 3, active: 0 })
// Umschauen (gedriftete Kopf-Zielwinkel) + geglättetes Lehnen beim Sprechen.
const motion = useRef({ yaw: 0, pitch: 0, tYaw: 0, tPitch: 0, t: 0, next: 2.5, lean: 0 })
useEffect(() => {
let disposed = false
let loaded: VRM | null = null
const loader = new GLTFLoader()
loader.register((parser) => new VRMLoaderPlugin(parser))
loader.load(
url,
(gltf) => {
if (disposed) return
const v = gltf.userData.vrm as VRM | undefined
if (!v) { onError("Datei enthält kein gültiges VRM-Modell."); return }
VRMUtils.removeUnnecessaryVertices(gltf.scene)
if (v.meta?.metaVersion === "0") VRMUtils.rotateVRM0(v)
v.scene.rotation.y = Math.PI // dem Betrachter zuwenden
applyRestPose(v) // T-Pose → entspannte A-Pose (Arme unten)
loaded = v
setVrm(v)
},
undefined,
(err) => { console.error("VRM-Load-Fehler:", err); onError("Avatar konnte nicht geladen werden (CORS/URL?).") },
)
return () => {
disposed = true
if (loaded) VRMUtils.deepDispose(loaded.scene)
setVrm(null)
}
}, [url, onError])
useFrame((state, delta) => {
if (!vrm) return
const target = audioLevel.current?.current ?? 0
// Blickkontakt: Augen folgen der Kamera (schaut dich an).
if (vrm.lookAt) vrm.lookAt.target = state.camera
// Umschauen: alle paar Sekunden neues Kopf-Ziel, sanft hineinlerpen.
const m = motion.current
m.t += delta
if (m.t > m.next) {
m.tYaw = (Math.random() - 0.5) * 0.5 // ±0.25 rad Gieren
m.tPitch = (Math.random() - 0.5) * 0.24
m.t = 0
m.next = 2.5 + Math.random() * 3.5
}
m.yaw += (m.tYaw - m.yaw) * Math.min(1, delta * 1.5)
m.pitch += (m.tPitch - m.pitch) * Math.min(1, delta * 1.5)
m.lean += (Math.min(1, target * 1.6) - m.lean) * Math.min(1, delta * 3)
// Lebendige Idle-/Sprech-Bewegung (über die Ruhepose gelegt).
applyIdle(vrm, state.clock.elapsedTime, target, m.yaw, m.pitch, m.lean)
const em = vrm.expressionManager
if (em) {
// Lippensync: 'aa' folgt geglättet dem Audiopegel.
const aa = (smooth.current.aa ?? 0) * 0.4 + target * 0.6
smooth.current.aa = aa
em.setValue("aa", aa)
// Mimik: weich zur Ziel-Expression lerpen.
const want = EMO_TO_EXPR[emotion.current]
for (const name of EXPRESSIONS) {
const tv = want === name ? 0.75 : 0
const cv = smooth.current[name] ?? 0
const nv = cv + (tv - cv) * Math.min(1, delta * 4)
smooth.current[name] = nv
em.setValue(name, nv)
}
// Blinzeln: kurzer Dreieckspuls alle 37 s.
const b = blink.current
b.t += delta
if (b.active <= 0 && b.t > b.next) { b.active = 0.16; b.t = 0; b.next = 3 + Math.random() * 4 }
let blinkVal = 0
if (b.active > 0) {
b.active -= delta
const p = 1 - b.active / 0.16 // 0..1 Fortschritt
blinkVal = 1 - Math.abs(p - 0.5) * 2 // 0 → 1 → 0
}
em.setValue("blink", Math.max(0, blinkVal))
}
vrm.update(delta)
})
return vrm ? <primitive object={vrm.scene} /> : null
}
export function Avatar3D({ url, audioLevel, emotion }: {
url: string; audioLevel: LevelRef; emotion: EmotionRef
}) {
const [err, setErr] = useState<string | null>(null)
return (
<div className="relative h-full w-full">
<Canvas
camera={{ position: [0, 1.35, 1.25], fov: 30 }}
gl={{ alpha: true, antialias: true }}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.85} />
<directionalLight position={[1, 2, 2]} intensity={1.1} />
<directionalLight position={[-1, 1, -1]} intensity={0.4} />
{/* key=url → bei Avatarwechsel sauber neu mounten */}
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} onError={setErr} />
<OrbitControls
target={[0, 1.3, 0]}
enablePan={false}
minDistance={0.7}
maxDistance={3}
minPolarAngle={Math.PI / 3}
maxPolarAngle={Math.PI / 1.8}
/>
</Canvas>
{err && (
<div className="absolute inset-x-0 bottom-3 mx-auto w-fit rounded-md bg-red-500/15 border border-red-500/30 px-3 py-1.5 text-xs text-red-300">
{err}
</div>
)}
</div>
)
}
@@ -1,155 +0,0 @@
import { useEffect, useRef, useState } from "react"
import { Sparkles, Volume2, Loader2 } from "lucide-react"
// Stimm-Steuerung (Engine + Stimme + Probe). Der Avatar ist fest (kein Picker mehr).
// Engines: ElevenLabs (eigene Stimme) + Edge (gratis, nativ-deutsch). Auswahl in localStorage.
interface Voice { engine: string; id: string; label: string }
const ENGINES = ["elevenlabs", "edge"] as const
const ENGINE_LABEL: Record<string, string> = {
elevenlabs: "ElevenLabs (premium)",
edge: "Edge (natürlich · gratis)",
}
export function VoiceControls() {
const [voices, setVoices] = useState<Voice[]>([])
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "elevenlabs")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const [volume, setVolume] = useState(() => {
const raw = localStorage.getItem("mc_voice_volume")
if (raw === null || raw === "") return 0.6 // Number(null)===0 → sonst aus Versehen stumm
const v = Number(raw)
return Number.isNaN(v) ? 0.6 : v
})
const previewAudio = useRef<HTMLAudioElement | null>(null)
const onVolume = (v: number) => {
setVolume(v)
localStorage.setItem("mc_voice_volume", String(v))
window.dispatchEvent(new CustomEvent("mc-voice-volume", { detail: v }))
}
const saveVoice = (eng: string, v: string) => {
setEngine(eng); setVoice(v)
localStorage.setItem("mc_voice_engine", eng)
localStorage.setItem("mc_voice_voice", v)
}
// Stimmen holen. NIE auf leerer Stimme bleiben (ElevenLabs „Standardstimme" = Library-Voice, die das
// Free-Tier per API sperrt) → erste echte Stimme der aktiven Engine automatisch wählen.
useEffect(() => {
fetch("/api/voice/voices")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => {
const list: Voice[] = d.voices || []
setVoices(list)
if (!voice) {
const first = list.find((v) => v.engine === engine)
if (first) saveVoice(engine, first.id)
}
})
.catch(() => setVoices([]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const playPreview = async () => {
if (previewing) return
setPreviewing(true)
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Hallo! So klingt diese Stimme auf Deutsch.", engine, voice }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
const url = URL.createObjectURL(await r.blob())
previewAudio.current?.pause()
const a = new Audio(url)
a.volume = Math.min(1, volume) // Regler auch für die Probe respektieren
previewAudio.current = a
a.onended = () => URL.revokeObjectURL(url)
await a.play()
} catch (e) {
console.error("Probe fehlgeschlagen:", e)
} finally {
setPreviewing(false)
}
}
const pickEngine = (eng: string) => {
const first = voices.find((v) => v.engine === eng)
saveVoice(eng, first?.id || "")
}
const voicesForEngine = voices.filter((v) => v.engine === engine)
const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0
return (
<div className="text-sm">
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" /> Stimme
</div>
{voices.length === 0 ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
Voice-Dienst nicht erreichbar Stimmen werden geladen, sobald der Sidecar läuft.
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-1.5">
{ENGINES.map((eng) => (
<button
key={eng}
onClick={() => pickEngine(eng)}
className={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${
engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
}`}
>
{ENGINE_LABEL[eng]}
</button>
))}
</div>
<select
value={voice}
onChange={(e) => saveVoice(engine, e.target.value)}
className="w-full rounded-md border border-border/40 bg-background/40 px-2.5 py-2 text-xs outline-none focus:border-primary/50"
>
{voicesForEngine.map((v) => (
<option key={v.id} value={v.id}>{v.label}</option>
))}
</select>
<button
onClick={playPreview}
disabled={previewing || elKeyMissing}
className="flex w-full items-center justify-center gap-2 rounded-md border border-primary/40 bg-primary/10 px-2.5 py-2 text-xs text-primary hover:bg-primary/20 transition-colors disabled:opacity-60"
>
{previewing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
{previewing ? "Spielt …" : "Probe hören"}
</button>
{/* Lautstärke */}
<div className="flex items-center gap-2 pt-0.5">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="range" min={0} max={1.2} step={0.05} value={volume}
onChange={(e) => onVolume(Number(e.target.value))}
className="h-1 flex-1 cursor-pointer accent-primary"
title="Lautstärke"
/>
<span className="w-9 text-right text-[11px] tabular-nums text-muted-foreground">{Math.round(volume * 100)}%</span>
</div>
{elKeyMissing && (
<p className="text-[11px] text-amber-300">
Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder keine eigene Stimme angelegt.
</p>
)}
{engine === "edge" && (
<p className="text-[11px] text-muted-foreground">
Edge: native deutsche Stimmen, gratis & ohne Key. Cloud (Text Microsoft).
</p>
)}
</div>
)}
</div>
)
}
+2
View File
@@ -306,6 +306,8 @@ 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)
summary?: string
// os // os
count?: number count?: number
packages?: { name: string; current: string; candidate: string }[] packages?: { name: string; current: string; candidate: string }[]
-103
View File
@@ -1,103 +0,0 @@
// Sequentielle Audio-Wiedergabe für die TTS-Antworten + Pegel-Messung fürs Lippensync.
//
// Die einzelnen Satz-WAVs kommen nacheinander rein (satzweise Synthese → niedrige Latenz).
// Wir spielen sie über EINEN AudioContext geordnet ab und hängen einen AnalyserNode dazwischen,
// dessen Energie pro Frame in `level.current` (0..1) landet — der 3D-Avatar liest das im
// useFrame und öffnet den Mund entsprechend. Kein Re-Render pro Frame (Mutable-Ref-Muster).
export class AudioQueue {
private ctx: AudioContext
private analyser: AnalyserNode
private gain: GainNode
private queue: ArrayBuffer[] = []
private playing = false
private raf = 0
private freq: Uint8Array<ArrayBuffer>
/** Mutable, vom Avatar pro Frame gelesen. 0 = Mund zu, 1 = weit offen. */
readonly level = { current: 0 }
onSpeaking?: (speaking: boolean) => void
/** Lautstärke 0..1.5 aus localStorage (Default 0.6 — die Stimmen waren zu laut). */
static readVolume(): number {
const raw = localStorage.getItem("mc_voice_volume")
if (raw === null || raw === "") return 0.6 // Number(null)===0 → sonst aus Versehen stumm
const v = Number(raw)
return Number.isNaN(v) ? 0.6 : Math.max(0, Math.min(1.5, v))
}
constructor() {
const Ctor = window.AudioContext || (window as any).webkitAudioContext
this.ctx = new Ctor()
this.analyser = this.ctx.createAnalyser()
this.analyser.fftSize = 256
this.analyser.smoothingTimeConstant = 0.6
// Kette: Quelle → Analyser (Lippensync liest vollen Pegel) → Gain (Lautstärke) → Ausgang.
this.gain = this.ctx.createGain()
this.gain.gain.value = AudioQueue.readVolume()
this.analyser.connect(this.gain)
this.gain.connect(this.ctx.destination)
this.freq = new Uint8Array(new ArrayBuffer(this.analyser.frequencyBinCount))
// Live-Lautstärke vom Slider.
window.addEventListener("mc-voice-volume", (e: Event) => {
const v = Number((e as CustomEvent).detail)
if (!Number.isNaN(v)) this.gain.gain.value = Math.max(0, Math.min(1.5, v))
})
}
async enqueue(buf: ArrayBuffer) {
this.queue.push(buf)
if (!this.playing) await this.playNext()
}
/** Laufende + wartende Wiedergabe verwerfen (z.B. wenn der Nutzer dazwischenredet). */
clear() {
this.queue = []
}
private async playNext(): Promise<void> {
const buf = this.queue.shift()
if (!buf) {
this.playing = false
this.stopMeter()
this.onSpeaking?.(false)
return
}
this.playing = true
this.onSpeaking?.(true)
if (this.ctx.state === "suspended") {
try { await this.ctx.resume() } catch { /* vom User-Gesture freigeschaltet */ }
}
let audioBuf: AudioBuffer
try {
audioBuf = await this.ctx.decodeAudioData(buf.slice(0))
} catch {
return this.playNext() // kaputtes Segment überspringen
}
const src = this.ctx.createBufferSource()
src.buffer = audioBuf
src.connect(this.analyser)
src.onended = () => { void this.playNext() }
src.start()
this.startMeter()
}
private startMeter() {
cancelAnimationFrame(this.raf)
const tick = () => {
this.analyser.getByteFrequencyData(this.freq)
// Sprachenergie liegt v.a. in den unteren/mittleren Bändern.
const n = Math.min(this.freq.length, 48)
let sum = 0
for (let i = 2; i < n; i++) sum += this.freq[i]
const avg = sum / (n - 2) / 255
this.level.current = Math.min(1, avg * 1.9)
this.raf = requestAnimationFrame(tick)
}
tick()
}
private stopMeter() {
cancelAnimationFrame(this.raf)
this.level.current = 0
}
}
-17
View File
@@ -1,17 +0,0 @@
// Leichtgewichtige Stimmungs-Heuristik (v1) → treibt die Avatar-Mimik.
// Bewusst simpel/regelbasiert (kein Modell): mappt deutschen Antworttext auf eine VRM-Expression.
// Spätere Stufe: echte Hermes-Emotion/Audio-Tags. Siehe Plan.
export type Emotion = "neutral" | "happy" | "angry" | "sad" | "surprised" | "relaxed"
const RULES: [Emotion, RegExp][] = [
["happy", /(super|toll|klasse|freu|cool|prima|perfekt|danke|großartig|wunderbar|gerne|haha|:\)|😊|😄|🎉)/i],
["surprised", /(wow|wirklich\?|krass|unglaublich|echt\?|tatsächlich|\?!|!\?|oha)/i],
["angry", /(fehler|kaputt|mist|verdammt|nervt|schlecht|problem|ärgerlich|leider nicht|geht nicht)/i],
["sad", /(leider|schade|traurig|tut mir leid|entschuldigung|sorry|bedauere)/i],
]
export function sentimentToEmotion(text: string): Emotion {
for (const [emo, rx] of RULES) if (rx.test(text)) return emo
return "neutral"
}
-50
View File
@@ -1,50 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
// Push-to-talk-Aufnahme über MediaRecorder. `start` beim Drücken (Taste/Button), `stop` beim
// Loslassen → fertiges Audio-Blob (webm/opus) geht an `onAudio`. Bewusst minimal & generisch.
export function usePushToTalk(onAudio: (blob: Blob) => void) {
const [recording, setRecording] = useState(false)
const recRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([])
const streamRef = useRef<MediaStream | null>(null)
const start = useCallback(async () => {
if (recRef.current) return
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) {
console.error("Mikrofon-Zugriff verweigert:", e)
return
}
streamRef.current = stream
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm"
const rec = new MediaRecorder(stream, { mimeType: mime })
chunksRef.current = []
rec.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data) }
rec.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mime })
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
recRef.current = null
setRecording(false)
if (blob.size > 1200) onAudio(blob) // Mini-Blobs (Versehen) ignorieren
}
rec.start()
recRef.current = rec
setRecording(true)
}, [onAudio])
const stop = useCallback(() => {
recRef.current?.stop()
}, [])
useEffect(() => () => {
recRef.current?.stop()
streamRef.current?.getTracks().forEach((t) => t.stop())
}, [])
return { recording, start, stop }
}
-237
View File
@@ -1,237 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { usePushToTalk } from "./usePushToTalk"
import { AudioQueue } from "./audio"
import { sentimentToEmotion, type Emotion } from "./sentiment"
// Orchestriert die ganze Voll-Duplex-Schleife im Browser:
// PTT-Audio → /api/voice/stt → User-Text
// → /api/voice/chat (SSE vom Hermes-Agenten, server-seitiger Verlauf via Session-Id)
// → Antwort satzweise schneiden → /api/voice/tts je Satz → AudioQueue (Abspielen + Lippensync)
// → Stimmung aus dem Antworttext → Avatar-Mimik
//
// Avatar-Anbindung ohne Re-Render: `audioLevel` (Mundöffnung) und `emotion` sind Mutable-Refs,
// die der 3D-Avatar pro Frame liest.
export type VoiceStatus = "idle" | "listening" | "transcribing" | "thinking" | "speaking" | "error"
export interface ChatMsg { role: "user" | "assistant"; text: string }
const SYSTEM_PROMPT =
"Du sprichst per Sprache mit dem Nutzer. Antworte natürlich, freundlich und KNAPP in ganzen, " +
"gut vorlesbaren Sätzen. Kein Markdown, keine Codeblöcke, keine Aufzählungszeichen, keine Emojis — " +
"reiner Fließtext, den man laut vorlesen kann. Verwende IMMER echte deutsche Umlaute (ä, ö, ü, ß) " +
"und NIEMALS Umschreibungen wie ae, oe, ue oder ss. Nenne möglichst keine langen URLs oder Codebefehle."
// Entfernt, was nicht vorgelesen werden soll (Links, Markdown, Code, Emojis), bevor der Satz ans TTS geht.
// Die Anzeige im Chat bleibt unangetastet — nur die gesprochene Fassung wird gesäubert.
function cleanForTTS(s: string): string {
return s
.replace(/```[\s\S]*?```/g, " ") // Codeblöcke
.replace(/`([^`]*)`/g, "$1") // Inline-Code
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // [Text](url) → Text
.replace(/https?:\/\/\S+/gi, " ") // URLs
.replace(/www\.\S+/gi, " ")
.replace(/\b\S+@\S+\.\S+\b/g, " ") // E-Mails
.replace(/[*_#>~|`]+/g, " ") // Markdown-Zeichen
.replace(/^\s*[-•·]\s+/gm, " ") // Listen-Bullets
// Sonderzeichen aussprechbar machen (sonst liest die Stimme sie wörtlich/komisch):
.replace(/\s*&\s*/g, " und ")
.replace(/(\d)\s*%/g, "$1 Prozent").replace(/%/g, " Prozent ")
.replace(/(\d)\s*°\s*C?/g, "$1 Grad").replace(/°/g, " Grad ")
.replace(/\s*=\s*/g, " gleich ")
.replace(/\s*\/\s*/g, " ") // Schrägstriche → Pause statt „Schrägstrich"
.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "") // Emojis/Symbole
.replace(/\s+/g, " ")
.trim()
}
function getSessionId(): string {
let id = localStorage.getItem("mc_voice_session")
if (!id) {
id = "voice-" + Math.random().toString(36).slice(2) + Date.now().toString(36)
localStorage.setItem("mc_voice_session", id)
}
return id
}
function readSettings() {
return {
engine: localStorage.getItem("mc_voice_engine") || "elevenlabs",
voice: localStorage.getItem("mc_voice_voice") || "",
}
}
// Zerlegt einen wachsenden Text-Stream in fertige Sätze. Gibt komplette Sätze zurück und behält
// den unvollständigen Rest. So kann das erste TTS schon starten, bevor die Antwort fertig ist.
function splitSentences(buffer: string): { sentences: string[]; rest: string } {
const sentences: string[] = []
const rx = /[^.!?…]+[.!?…]+(\s|$)/g
let last = 0
let m: RegExpExecArray | null
while ((m = rx.exec(buffer))) {
sentences.push(m[0].trim())
last = rx.lastIndex
}
return { sentences, rest: buffer.slice(last) }
}
export function useVoiceAgent() {
const [status, setStatus] = useState<VoiceStatus>("idle")
const [messages, setMessages] = useState<ChatMsg[]>([])
const [error, setError] = useState<string | null>(null)
const audioLevel = useRef({ current: 0 }) // wird gleich auf die Queue-Pegel gezeigt
const emotion = useRef<Emotion>("neutral")
const queueRef = useRef<AudioQueue | null>(null)
const sessionId = useRef<string>(getSessionId())
// AudioQueue erst bei Bedarf (nach User-Geste) erzeugen — Autoplay-Policy.
const ensureQueue = useCallback(() => {
if (!queueRef.current) {
const q = new AudioQueue()
q.onSpeaking = (sp) => setStatus((s) => (sp ? "speaking" : s === "speaking" ? "idle" : s))
queueRef.current = q
audioLevel.current = q.level // Avatar liest ab jetzt echte Pegel
}
return queueRef.current
}, [])
const handleAudio = useCallback(async (blob: Blob) => {
setError(null)
const queue = ensureQueue()
queue.clear() // evtl. laufende Antwort abbrechen (Barge-in)
// 1) STT
setStatus("transcribing")
let userText = ""
try {
const fd = new FormData()
fd.append("audio", blob, "rec.webm")
const r = await fetch("/api/voice/stt", { method: "POST", body: fd })
if (!r.ok) throw new Error(`STT ${r.status}`)
userText = (await r.json()).text?.trim() || ""
} catch (e: any) {
setStatus("error"); setError(`Spracherkennung fehlgeschlagen: ${e.message}`); return
}
if (!userText) { setStatus("idle"); return }
setMessages((m) => [...m, { role: "user", text: userText }])
// 2) Chat (SSE) → 3) satzweises TTS
setStatus("thinking")
const { engine, voice } = readSettings()
let assistant = ""
let pending = ""
setMessages((m) => [...m, { role: "assistant", text: "" }])
// TTS SERIELL abarbeiten (Satz für Satz), nicht parallel: Chatterbox ist nicht thread-safe
// (parallele Generierungen → 502) und Cloud-Engines mögen keine gleichzeitigen Calls. Hält
// außerdem die Reihenfolge. Die Audio-Wiedergabe selbst puffert die AudioQueue.
let ttsChain: Promise<void> = Promise.resolve()
let spokeAny = false
let ttsFailed = false
const speak = (sentence: string) => {
const spoken = cleanForTTS(sentence)
if (!spoken) return // z.B. ein Satz, der nur aus einer URL bestand
ttsChain = ttsChain.then(async () => {
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: spoken, engine, voice }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
await queue.enqueue(await r.arrayBuffer())
spokeAny = true
} catch (e) {
ttsFailed = true
console.error("TTS-Fehler:", e)
}
})
}
try {
const r = await fetch("/api/voice/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: userText,
session_id: sessionId.current,
system: SYSTEM_PROMPT,
}),
})
if (!r.ok || !r.body) throw new Error(`Agent ${r.status}`)
const reader = r.body.getReader()
const dec = new TextDecoder()
let sse = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
sse += dec.decode(value, { stream: true })
const events = sse.split("\n\n")
sse = events.pop() || ""
for (const ev of events) {
const line = ev.split("\n").find((l) => l.startsWith("data:"))
if (!line) continue
const data = line.slice(5).trim()
if (data === "[DONE]") continue
let json: any
try { json = JSON.parse(data) } catch { continue }
if (json.error) throw new Error(json.error)
const delta = json.choices?.[0]?.delta?.content || ""
if (!delta) continue
assistant += delta
pending += delta
emotion.current = sentimentToEmotion(assistant)
setMessages((m) => {
const copy = m.slice()
copy[copy.length - 1] = { role: "assistant", text: assistant }
return copy
})
const { sentences, rest } = splitSentences(pending)
pending = rest
sentences.forEach(speak)
}
}
if (pending.trim()) speak(pending) // Rest (letzter Satz ohne Satzzeichen)
await ttsChain // auf alle (seriellen) TTS-Calls warten
if (!assistant.trim()) { setStatus("idle"); return }
// Nichts abgespielt → nicht ewig bei „denkt" hängen, Fehler zeigen.
if (!spokeAny) {
setStatus("error")
setError(ttsFailed ? "Sprachausgabe fehlgeschlagen — Engine/Stimme prüfen." : "Keine Sprachausgabe erzeugt.")
}
} catch (e: any) {
setStatus("error"); setError(`Agent-Antwort fehlgeschlagen: ${e.message}`)
}
}, [ensureQueue])
const { recording, start, stop } = usePushToTalk(handleAudio)
const pressStart = useCallback(() => {
ensureQueue()
setStatus("listening")
void start()
}, [ensureQueue, start])
const pressEnd = useCallback(() => { stop() }, [stop])
const reset = useCallback(() => {
queueRef.current?.clear()
setMessages([])
setError(null)
setStatus("idle")
localStorage.removeItem("mc_voice_session")
sessionId.current = getSessionId()
}, [])
// Leerlauf-Status zurücksetzen, wenn nichts mehr spricht/aufnimmt.
useEffect(() => {
if (!recording && (status === "listening")) setStatus("transcribing")
}, [recording, status])
return {
status, messages, error, recording,
audioLevel, emotion,
pressStart, pressEnd, reset,
}
}
+6 -3
View File
@@ -2,6 +2,7 @@ import React from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App" import App from "./App"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import "./index.css" import "./index.css"
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook. // Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
@@ -17,8 +18,10 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <AppErrorBoundary>
<App /> <QueryClientProvider client={queryClient}>
</QueryClientProvider> <App />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
) )
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react"
import { Activity, Code, Bookmark, Wrench, ArrowLeft, type LucideIcon } from "lucide-react"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth } from "@/lib/queries"
import { cn } from "@/lib/utils"
import { Mc3Start } from "./Mc3Start"
import { Mc3Maschinenraum } from "./Mc3Maschinenraum"
// MC3-Prototyp-Schale. Läuft nicht-destruktiv unter #mc3, das produktive MC2 bleibt
// unangetastet. Vier Türen; Innereien sind bewusst die bestehenden, bewährten Views.
type Door = "start" | "vibe" | "konstitution" | "maschinenraum"
const DOORS: { id: Door; label: string; icon: LucideIcon }[] = [
{ id: "start", label: "Start", icon: Activity },
{ id: "vibe", label: "Vibe-Coding", icon: Code },
{ id: "konstitution", label: "Konstitution", icon: Bookmark },
{ id: "maschinenraum", label: "Maschinenraum", icon: Wrench },
]
export function Mc3App() {
const [door, setDoor] = useState<Door>("start")
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
const { data: health } = useHealth()
useEffect(() => {
const onOpen = (e: Event) => {
setDrawerTab(((e as CustomEvent).detail?.tab as "maintenance" | "logs") || "maintenance")
setDrawerOpen(true)
}
window.addEventListener("open-system-drawer", onOpen)
return () => window.removeEventListener("open-system-drawer", onOpen)
}, [])
const boxOk = health && health.engine_reachable && (health.brain ? health.brain.ready : true)
return (
<div className="flex h-full relative">
<div className="fixed inset-0 -z-50 pointer-events-none overflow-hidden bg-[hsl(224,30%,6%)]">
<div className="absolute inset-0 opacity-30 animate-aurora bg-gradient-to-tr from-teal-500/15 via-indigo-500/10 to-purple-500/15 blur-[130px]" />
</div>
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Sidebar — 4 Türen */}
<aside className="flex w-56 shrink-0 flex-col border-r border-border/40 bg-card/45 backdrop-blur-md">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border/40">
<div className="h-7 w-7 rounded-lg bg-primary shadow-md shadow-primary/20" />
<div className="leading-tight">
<div className="text-sm font-semibold tracking-wide font-space">Mission Control</div>
<div className="text-[10px] text-muted-foreground">3 · Prototyp</div>
</div>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{DOORS.map((d) => (
<button key={d.id} onClick={() => setDoor(d.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm transition-all cursor-pointer",
door === d.id ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}>
<d.icon className="h-4.5 w-4.5 shrink-0" /> {d.label}
</button>
))}
</nav>
<div className="px-3 py-3 border-t border-border/40 space-y-2">
<div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", boxOk ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{boxOk ? "Box läuft" : "prüfen"}
</div>
<a href="#dashboard"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-3.5 w-3.5" /> zurück zu MC2
</a>
</div>
</aside>
{/* Main */}
<main className="flex-1 min-w-0 overflow-y-auto p-6 scrollbar-thin">
{door === "start" && <Mc3Start />}
{door === "vibe" && <ConnectView />}
{door === "konstitution" && <MemoryView />}
{door === "maschinenraum" && <Mc3Maschinenraum />}
</main>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from "react"
import { Cpu, ShieldCheck, Bot, ChevronRight, ArrowLeft } from "lucide-react"
import { Cockpit } from "@/views/models/Cockpit"
import { AgentView } from "@/views/AgentView"
import { ExpertToggle } from "@/components/ExpertToggle"
// Maschinenraum = die eine Tür für alles Technische. Faltet Modelle/Wartung/Hermes
// zusammen; jede Sektion rendert die BESTEHENDE, bewährte View wieder.
function SectionCard({ icon: Icon, title, sub, onClick }: {
icon: any; title: string; sub: string; onClick: () => void
}) {
return (
<button onClick={onClick}
className="w-full flex items-center gap-4 rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-left transition-all hover:border-primary/40 cursor-pointer">
<Icon className="h-6 w-6 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{sub}</div>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground shrink-0" />
</button>
)
}
export function Mc3Maschinenraum() {
const [section, setSection] = useState<null | "modelle" | "hermes">(null)
if (section) {
return (
<div className="space-y-4">
<button onClick={() => setSection(null)}
className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="h-4 w-4" /> Maschinenraum
</button>
{section === "modelle" ? <Cockpit /> : <AgentView />}
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Maschinenraum
</h1>
<p className="text-sm text-muted-foreground">Nur nötig, wenn du wirklich was ändern willst.</p>
</div>
<ExpertToggle />
</div>
<SectionCard icon={Cpu} title="Modelle" sub="Hirne, Coder, Immer-bereit-Set, Bibliothek"
onClick={() => setSection("modelle")} />
<SectionCard icon={ShieldCheck} title="Wartung und Logs" sub="Updates, Backup, Dienste, Logs, Zugangsdaten"
onClick={() => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))} />
<SectionCard icon={Bot} title="Hermes und Verdrahtung" sub="Agent, Gehirn-Modell, PC-Verbindung"
onClick={() => setSection("hermes")} />
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { ArrowUpCircle, ChevronRight, Check, Cpu, Code } from "lucide-react"
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
import { SystemStatusCard } from "@/components/dashboard/SystemStatusCard"
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
import { useUpdates, useModels } from "@/lib/queries"
import { cn } from "@/lib/utils"
// MC3-Startseite = der eine „Basisstation"-Blick: Ampel (wiederverwendet) + Updates
// (klickbar) + was gerade läuft + die geschätzte Telemetrie (System/Token/Sprach-Latenz).
// Bewusst KEINE Wärme/Platte/Speicher-Kacheln — die stehen schon im System-Status.
export function Mc3Start() {
const { data: updates } = useUpdates(30_000)
const { data: modelsResp } = useModels()
const upCount = (updates?.os || 0) + (updates?.engine || 0) + (updates?.swap || 0) + (updates?.models || 0)
const openUpdates = () => window.dispatchEvent(new CustomEvent("open-system-drawer", { detail: { tab: "maintenance" } }))
const models = modelsResp?.models ?? []
const running = modelsResp?.running ?? []
const brain = models.find((m) => m.role === "hermes")
const coder = models.find((m) => m.role === "coder-lite") || models.find((m) => m.role === "coder")
const isWarm = (name?: string) => (name ? running.includes(name) : false)
const shortName = (n?: string) => n?.split("/").pop()?.replace(/\.gguf$/i, "") || "—"
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Basisstation
</h1>
<p className="text-sm text-muted-foreground">Geht's der Box gut, gibt's Updates, was läuft gerade.</p>
</div>
{/* Gesamt-Ampel + Fähigkeiten + Speicher (voll wiederverwendet, Box-Rahmen) */}
<LucyHealthCard frame="box" />
{/* Updates — klickbar, führt direkt zur Liste, was aktualisiert wird */}
<button
onClick={openUpdates}
className={cn(
"w-full flex items-center gap-3 rounded-2xl border p-4 text-left transition-all cursor-pointer",
upCount > 0
? "border-amber-500/30 bg-amber-500/5 hover:bg-amber-500/10"
: "border-border/60 bg-card/45 hover:border-primary/40",
)}
>
{upCount > 0
? <ArrowUpCircle className="h-6 w-6 shrink-0 text-amber-400" />
: <Check className="h-6 w-6 shrink-0 text-emerald-400" />}
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-semibold", upCount > 0 ? "text-amber-400" : "text-foreground")}>
{upCount > 0 ? `${upCount} Update${upCount === 1 ? "" : "s"} verfügbar` : "Alles aktuell"}
</div>
<div className="text-xs text-muted-foreground">
{upCount > 0 ? "Antippen, um zu sehen, was genau aktualisiert wird." : "Keine ausstehenden Updates."}
</div>
</div>
{upCount > 0 && (
<span className="shrink-0 flex items-center gap-1 text-xs font-semibold text-amber-400">
Ansehen <ChevronRight className="h-4 w-4" />
</span>
)}
</button>
{/* Was gerade läuft — beide Lanes */}
<div>
<div className="text-xs text-muted-foreground mb-2">Was gerade läuft</div>
<div className="rounded-2xl border border-border/60 overflow-hidden">
{[
{ icon: Cpu, label: "Lucys Hirn", model: brain?.name, warm: isWarm(brain?.name) },
{ icon: Code, label: "Coder (Vibe-Coding)", model: coder?.name, warm: isWarm(coder?.name) },
].map((r) => (
<div key={r.label} className="flex items-center gap-3 px-4 py-3 border-b border-border/40 last:border-0">
<span className={cn("h-2 w-2 shrink-0 rounded-full", r.warm ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40")} />
<r.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground w-40 shrink-0">{r.label}</span>
<span className="text-xs text-muted-foreground flex-1 truncate font-mono">{shortName(r.model)}</span>
<span className={cn("text-xs shrink-0", r.warm ? "text-emerald-400" : "text-muted-foreground")}>
{r.model ? (r.warm ? "warm" : "bereit") : "nicht gesetzt"}
</span>
</div>
))}
</div>
</div>
{/* Geschätzte Telemetrie — 1:1 aus MC2 übernommen */}
<div className="grid gap-6 lg:grid-cols-2">
<SystemStatusCard />
<TokenPerformanceCard />
</div>
<VoiceLatencyCard />
</div>
)
}
+1 -3
View File
@@ -6,11 +6,10 @@ import {
Bot, Bot,
TerminalSquare, TerminalSquare,
HelpCircle, HelpCircle,
Mic,
type LucideIcon, type LucideIcon,
} from "lucide-react" } from "lucide-react"
export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "voice" | "guide" export type ViewId = "dashboard" | "models" | "memory" | "connect" | "agent" | "terminal" | "guide"
export interface NavItem { export interface NavItem {
id: ViewId id: ViewId
@@ -27,7 +26,6 @@ export const NAV: NavItem[] = [
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug }, { id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot }, { id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot },
{ id: "terminal", label: "Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare }, { id: "terminal", label: "Terminal", hint: "Interaktives Hermes-Agent-Terminal", icon: TerminalSquare },
{ id: "voice", label: "Sprechen", hint: "Mit Hermes per Sprache reden (3D-Avatar)", icon: Mic },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle }, { id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle },
] ]
-155
View File
@@ -1,155 +0,0 @@
import { useEffect, useRef } from "react"
import { Mic, RotateCcw, Loader2, Volume2 } from "lucide-react"
import { Avatar3D } from "@/components/voice/Avatar3D"
import { VoiceControls } from "@/components/voice/AvatarPicker"
import { useVoiceAgent } from "@/lib/voice/useVoiceAgent"
import { cn } from "@/lib/utils"
// Fester Avatar (das eine, gewählte VRM). Liegt unter frontend/public/avatar.vrm → /avatar.vrm.
const FIXED_AVATAR = "/avatar.vrm"
// **fett** als echte Fettschrift rendern (Rest als Text; Zeilenumbrüche via whitespace-pre-wrap).
function renderRich(text: string) {
return text.split(/(\*\*[^*\n]+\*\*)/g).map((p, i) => {
const m = p.match(/^\*\*([^*]+)\*\*$/)
return m ? <strong key={i} className="font-semibold text-foreground">{m[1]}</strong> : <span key={i}>{p}</span>
})
}
// Kleiner Tipp-Indikator („Hermes schreibt …").
function Dots() {
return (
<span className="inline-flex items-center gap-1 align-middle">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</span>
)
}
const STATUS_LABEL: Record<string, string> = {
idle: "Bereit — halte zum Sprechen",
listening: "Höre zu …",
transcribing: "Verstehe …",
thinking: "Hermes denkt …",
speaking: "Hermes spricht …",
error: "Fehler",
}
export function VoiceView() {
const { status, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset } =
useVoiceAgent()
const holding = useRef(false)
const convEndRef = useRef<HTMLDivElement>(null)
// Gespräch automatisch nach unten scrollen, wenn neue Tokens/Nachrichten kommen.
useEffect(() => {
convEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" })
}, [messages])
// Push-to-talk per Leertaste (solange der Sprechen-Tab fokussiert ist und kein Eingabefeld aktiv).
useEffect(() => {
const isField = (el: EventTarget | null) =>
el instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
const down = (e: KeyboardEvent) => {
if (e.code !== "Space" || e.repeat || holding.current || isField(e.target)) return
e.preventDefault(); holding.current = true; pressStart()
}
const up = (e: KeyboardEvent) => {
if (e.code !== "Space" || !holding.current) return
e.preventDefault(); holding.current = false; pressEnd()
}
window.addEventListener("keydown", down)
window.addEventListener("keyup", up)
return () => { window.removeEventListener("keydown", down); window.removeEventListener("keyup", up) }
}, [pressStart, pressEnd])
const speaking = status === "speaking"
const busy = status === "transcribing" || status === "thinking"
return (
<div className="flex h-full gap-5">
{/* Avatar-Bühne */}
<div className="relative flex min-w-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/30 overflow-hidden">
<div className="flex-1 min-h-0">
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} />
</div>
{/* Status + Push-to-talk */}
<div className="shrink-0 flex flex-col items-center gap-3 border-t border-border/40 bg-background/30 py-5 backdrop-blur-sm">
<div className={cn(
"flex items-center gap-2 text-sm",
status === "error" ? "text-red-400" : speaking ? "text-primary" : "text-muted-foreground",
)}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{speaking && <Volume2 className="h-4 w-4 animate-pulse" />}
<span>{error || STATUS_LABEL[status]}</span>
</div>
<button
onPointerDown={(e) => { e.preventDefault(); holding.current = true; pressStart() }}
onPointerUp={() => { if (holding.current) { holding.current = false; pressEnd() } }}
onPointerLeave={() => { if (holding.current) { holding.current = false; pressEnd() } }}
className={cn(
"flex h-20 w-20 items-center justify-center rounded-full border-2 transition-all select-none touch-none",
recording
? "border-red-500 bg-red-500/20 scale-110 shadow-lg shadow-red-500/30"
: "border-primary/60 bg-primary/15 hover:bg-primary/25 hover:scale-105",
)}
title="Gedrückt halten zum Sprechen (oder Leertaste halten)"
>
<Mic className={cn("h-8 w-8", recording ? "text-red-400" : "text-primary")} />
</button>
<div className="text-[11px] text-muted-foreground">
Halten zum Sprechen · <kbd className="rounded bg-muted px-1 py-0.5 font-mono">Leertaste</kbd> geht auch
</div>
</div>
</div>
{/* Seitenspalte: Einstellungen + Transcript */}
<div className="flex w-80 shrink-0 flex-col gap-4">
<div className="rounded-xl border border-border/40 bg-card/40 p-4">
<VoiceControls />
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/40">
<div className="flex items-center justify-between border-b border-border/40 px-4 py-2.5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Gespräch</span>
<button onClick={reset} className="text-muted-foreground hover:text-foreground" title="Neues Gespräch">
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 space-y-3 overflow-y-auto p-4 scrollbar-thin">
{messages.length === 0 && (
<p className="text-xs text-muted-foreground">
Halte den Knopf (oder die Leertaste) und sprich. Hermes hört zu, denkt mit seinem
vollen Gedächtnis und seinen Werkzeugen und antwortet hörbar.
</p>
)}
{messages.map((m, i) => (
<div key={i} className={cn("flex flex-col gap-1", m.role === "user" ? "items-end" : "items-start")}>
<span className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{m.role === "user" ? "Du" : "Hermes"}
</span>
<div className={cn(
"max-w-[88%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm leading-relaxed",
m.role === "user"
? "rounded-br-sm bg-primary/15 text-foreground"
: "rounded-bl-sm border border-border/40 bg-background/40 text-foreground/90",
)}>
{m.text ? renderRich(m.text) : <Dots />}
</div>
</div>
))}
<div ref={convEndRef} />
</div>
</div>
</div>
</div>
)
}
+7 -89
View File
@@ -1,5 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { Download, Trash2, Edit3, Activity, HardDrive, X, Check, Zap, Bot } from "lucide-react" import { Download, Trash2, Edit3, Activity, HardDrive, Check, Zap, Bot } from "lucide-react"
import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api" import { api, setGroup, type ModelInfo, type RoleRecResp } from "@/lib/api"
import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries" import { useModels, useGroups, useUpdates, useHermesBrain, useSystemStatus, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog" import { useDialog } from "@/lib/useDialog"
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
import { fmtSize, fmtCtx } from "@/lib/format" import { fmtSize, fmtCtx } from "@/lib/format"
import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges" import { getBrandInfo, ROLES, RoleLabel, roleMeta } from "@/components/models/ModelBadges"
import { SpecDraftModal } from "@/components/models/SpecDraftModal" import { SpecDraftModal } from "@/components/models/SpecDraftModal"
import { RoleAssignModal } from "./cockpit/RoleAssignModal"
import { LaneEditor } from "@/components/models/LaneEditor" import { LaneEditor } from "@/components/models/LaneEditor"
import { WarmSetManager } from "@/components/models/WarmSetManager" import { WarmSetManager } from "@/components/models/WarmSetManager"
import { useExpertMode } from "@/lib/useExpertMode" import { useExpertMode } from "@/lib/useExpertMode"
@@ -887,94 +888,11 @@ export function Cockpit() {
</div> </div>
{/* Role Assignment Modal */} {/* Role Assignment Modal */}
{activeRoleForAssign && (() => { {activeRoleForAssign && (
const rec = roleRec && roleRec.role === activeRoleForAssign ? roleRec : null <RoleAssignModal role={activeRoleForAssign} roleRec={roleRec} models={models}
const recByName: Record<string, RoleRecResp["models"][number]> = {} onAssign={(name) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }}
rec?.models.forEach((r) => { recByName[r.name] = r }) onClose={() => setActiveRoleForAssign(null)} />
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge. )}
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
const assign = (name: string) => { handleRoleChange(activeRoleForAssign, name); setActiveRoleForAssign(null) }
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(activeRoleForAssign).label} konfigurieren`}>
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={activeRoleForAssign} /> festlegen
</h3>
<button
onClick={() => setActiveRoleForAssign(null)}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(activeRoleForAssign).label}</strong>
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(activeRoleForAssign).desc}</span>
</p>
{rec?.recommended && (
<button
onClick={() => assign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(activeRoleForAssign) ? (
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben wähle stattdessen ein anderes Modell.
</div>
) : (
<button
onClick={() => assign("")}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
)}
{ordered.map((m) => {
const r = recByName[m.name]
const isCur = m.role === activeRoleForAssign
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => assign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
)
})}
</div>
</div>
</div>
)
})()}
{specModel && ( {specModel && (
<SpecDraftModal <SpecDraftModal
@@ -0,0 +1,104 @@
import { Check, X, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtSize } from "@/lib/format"
import { roleMeta } from "@/lib/roleMeta"
import { RoleLabel } from "@/components/models/ModelBadges"
import type { ModelInfo, RoleRecResp } from "@/lib/api"
// Rollen-Zuweisungs-Modal des Cockpits (Review P2-14, Teil 2: aus dem 990-Z-Monolithen
// extrahiert). Zeigt die Modell-Bibliothek sortiert nach Empfehlung (RoleRec-Score) und
// hält das Schutzgeländer: lebenswichtige Rollen (Hirn/Gedächtnis) können nicht leer bleiben.
const isProtected = (role?: string | null) => !!roleMeta(role).protected
export function RoleAssignModal({ role, roleRec, models, onAssign, onClose }: {
role: string
roleRec: RoleRecResp | null
models: ModelInfo[]
onAssign: (modelName: string) => void
onClose: () => void
}) {
const rec = roleRec && roleRec.role === role ? roleRec : null
const recByName: Record<string, RoleRecResp["models"][number]> = {}
rec?.models.forEach((r) => { recByName[r.name] = r })
// Empfohlene Reihenfolge (nach Score) wenn vorhanden, sonst Bibliotheks-Reihenfolge.
const ordered = rec
? rec.models.map((r) => models.find((m) => m.name === r.name)).filter(Boolean) as ModelInfo[]
: models
return (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overscroll-contain" role="dialog" aria-modal="true" aria-label={`${roleMeta(role).label} konfigurieren`}>
<div className="w-full max-w-md rounded-2xl border border-border/80 bg-card p-6 shadow-2xl space-y-4 animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between border-b border-border/20 pb-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-primary flex items-center gap-1.5">
<RoleLabel role={role} /> festlegen
</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Wähle ein Modell für <strong className="text-foreground">{roleMeta(role).label}</strong>
<span className="block text-[10px] text-muted-foreground/70 mt-0.5">{roleMeta(role).desc}</span>
</p>
{rec?.recommended && (
<button
onClick={() => onAssign(rec.recommended!)}
title={recByName[rec.recommended]?.reason}
className="shrink-0 h-7 px-3 text-[11px] font-semibold text-primary border border-primary/50 bg-primary/10 hover:bg-primary/20 rounded-lg cursor-pointer flex items-center gap-1"
>
<Zap className="h-3 w-3" /> Auto: {rec.recommended.split("/").pop()?.replace(/\.gguf$/i, "")}
</button>
)}
</div>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{/* Schutzgeländer: eine lebenswichtige Rolle (Hirn/Gedächtnis) lässt sich nicht leeren. */}
{isProtected(role) ? (
<div className="w-full px-3 py-2 rounded-lg text-[11px] text-muted-foreground border border-border/30 bg-background/20 flex items-center gap-1.5">
🔒 Diese Rolle ist lebenswichtig und kann nicht leer bleiben wähle stattdessen ein anderes Modell.
</div>
) : (
<button
onClick={() => onAssign("")}
className="w-full text-left px-3 py-2 rounded-lg text-xs hover:bg-accent text-red-400 font-semibold cursor-pointer border border-red-500/20 bg-red-500/5 flex items-center justify-between"
>
<span>Zuweisung entfernen</span>
</button>
)}
{ordered.map((m) => {
const r = recByName[m.name]
const isCur = m.role === role
const isRec = !!r?.recommended
const unfit = !!r && !r.suitable
return (
<button
key={m.name}
onClick={() => onAssign(m.name)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-xs hover:bg-accent flex items-center justify-between font-mono cursor-pointer border",
isRec ? "border-primary/50 bg-primary/10"
: isCur ? "text-primary font-bold bg-primary/5 border-primary/30"
: unfit ? "border-border/20 bg-background/10 opacity-60"
: "text-foreground bg-background/20 border-border/30"
)}
>
<div className="flex flex-col text-left min-w-0">
<span className="truncate max-w-[260px] font-semibold flex items-center gap-1.5">
{m.name.split("/").pop()?.replace(/\.gguf$/i, "")}
{isRec && <span className="text-[8px] font-bold uppercase tracking-wide text-primary bg-primary/15 px-1.5 py-0.5 rounded">Empfohlen</span>}
</span>
<span className="text-[9px] text-muted-foreground mt-0.5">
{r ? `${r.params_b}B · ${m.quant} · ${r.reason}` : `${fmtSize(m.size_bytes)} · ${m.quant}`}
</span>
</div>
{isCur && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
)
})}
</div>
</div>
</div>
)
}
+12
View File
@@ -150,6 +150,18 @@ class MC2MemoryProvider(MemoryProvider):
return return
msgs: List[Dict[str, str]] = [{"role": "user", "content": u}] msgs: List[Dict[str, str]] = [{"role": "user", "content": u}]
a = (assistant_content or "").strip() a = (assistant_content or "").strip()
# Hermes ≥0.18 liefert optional den vollen Turn-Kontext (`messages`, inkl. Tool-Calls).
# Bewusst NUR die Tool-NAMEN mitlernen ("hat X per Tool Y geprüft") — Tool-ERGEBNISSE
# sind untrusted (Web-Inhalte!) und wären ein Poisoning-Vektor am Junk-Guard vorbei.
if messages:
tools = []
for m in messages:
for tc in (m.get("tool_calls") or []):
name = ((tc.get("function") or {}).get("name") or "").strip()
if name and name not in tools:
tools.append(name)
if tools:
a = (a + f"\n[genutzte Tools: {', '.join(tools[:6])}]").strip()
if a: if a:
msgs.append({"role": "assistant", "content": a[:4000]}) msgs.append({"role": "assistant", "content": a[:4000]})
try: try:
+172 -7
View File
@@ -39,10 +39,16 @@ log = logging.getLogger("voice_service")
# --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ---------- # --- Konfiguration (alles über Env überschreibbar; Defaults = Box-Stand) ----------
PORT = int(os.environ.get("VOICE_PORT", "8650")) PORT = int(os.environ.get("VOICE_PORT", "8650"))
# STT # STT
STT_MODEL = os.environ.get("VOICE_STT_MODEL", "medium") # base|small|medium|large-v3 # Engine: parakeet (Default, ~10× schneller + bessere Deutsch-WER als whisper-medium,
# Review 2026-07-02) | whisper (Fallback). Pro Request via Form-Feld `engine` überschreibbar.
STT_ENGINE = os.environ.get("VOICE_STT_ENGINE", "parakeet")
STT_MODEL = os.environ.get("VOICE_STT_MODEL", "medium") # whisper: base|small|medium|large-v3
STT_DEVICE = os.environ.get("VOICE_STT_DEVICE", "cpu") STT_DEVICE = os.environ.get("VOICE_STT_DEVICE", "cpu")
STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend
STT_LANG = os.environ.get("VOICE_STT_LANG", "de") STT_LANG = os.environ.get("VOICE_STT_LANG", "de")
# Parakeet-TDT 0.6B v3 (25 EU-Sprachen inkl. DE, Auto-Language) via onnx-asr.
PARAKEET_MODEL = os.environ.get("VOICE_PARAKEET_MODEL", "nemo-parakeet-tdt-0.6b-v3")
PARAKEET_QUANT = os.environ.get("VOICE_PARAKEET_QUANT", "int8") # ""=fp32
# Piper (Default-TTS): Verzeichnis mit *.onnx (+ *.onnx.json) Stimmen + das Piper-Binary. # Piper (Default-TTS): Verzeichnis mit *.onnx (+ *.onnx.json) Stimmen + das Piper-Binary.
# Wir nutzen das offizielle Binary (statt des piper-tts-PyPI-Pakets), weil dessen Abhängigkeit # Wir nutzen das offizielle Binary (statt des piper-tts-PyPI-Pakets), weil dessen Abhängigkeit
# piper-phonemize auf neueren Linux-Systemen keine Wheels hat. Das Binary bringt espeak-ng mit. # piper-phonemize auf neueren Linux-Systemen keine Wheels hat. Das Binary bringt espeak-ng mit.
@@ -113,7 +119,7 @@ def stt_model():
return _stt return _stt
def transcribe(audio_bytes: bytes, suffix: str, language: str) -> str: def transcribe_whisper(audio_bytes: bytes, suffix: str, language: str) -> str:
# PyAV (in faster-whisper) dekodiert webm/opus/ogg/wav am robustesten aus einer Datei. # PyAV (in faster-whisper) dekodiert webm/opus/ogg/wav am robustesten aus einer Datei.
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
tf.write(audio_bytes) tf.write(audio_bytes)
@@ -130,6 +136,135 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str) -> str:
pass pass
# --- Parakeet-TDT (onnx-asr, lazy Singleton) --------------------------------------
_parakeet = None
_parakeet_failed = False
def parakeet_model():
global _parakeet, _parakeet_failed
if _parakeet is None and not _parakeet_failed:
try:
import onnx_asr
kwargs = {"quantization": PARAKEET_QUANT} if PARAKEET_QUANT else {}
log.info("Lade Parakeet '%s' (quant=%s) …", PARAKEET_MODEL, PARAKEET_QUANT or "fp32")
_parakeet = onnx_asr.load_model(PARAKEET_MODEL, **kwargs)
except Exception:
# int8-Variante kann je nach Release fehlen → einmal fp32 probieren, dann aufgeben.
try:
import onnx_asr
log.warning("Parakeet quant=%s nicht ladbar, versuche fp32", PARAKEET_QUANT, exc_info=True)
_parakeet = onnx_asr.load_model(PARAKEET_MODEL)
except Exception:
_parakeet_failed = True
log.exception("Parakeet nicht verfügbar — STT fällt auf whisper zurück.")
return _parakeet
def transcribe_parakeet(audio_bytes: bytes, suffix: str) -> str | None:
"""None = Engine nicht verfügbar (Aufrufer fällt auf whisper zurück).
Parakeet v3 erkennt die Sprache selbst; onnx-asr liest nur WAV Browser-webm/opus
erst via PyAV (steckt in faster-whisper) auf 16 kHz mono dekodieren."""
model = parakeet_model()
if model is None:
return None
import soundfile as sf
from faster_whisper.audio import decode_audio
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
tf.write(audio_bytes)
src = tf.name
wav = src + ".wav"
try:
arr = decode_audio(src, sampling_rate=16000)
sf.write(wav, arr, 16000, subtype="PCM_16")
return (model.recognize(wav) or "").strip()
finally:
for p in (src, wav):
try:
os.unlink(p)
except OSError:
pass
def transcribe(audio_bytes: bytes, suffix: str, language: str, engine: str = "") -> str:
eng = (engine or STT_ENGINE).strip().lower()
if eng == "parakeet":
text = transcribe_parakeet(audio_bytes, suffix)
if text is not None:
return text
return transcribe_whisper(audio_bytes, suffix, language)
# =================================================================================
# Turn-Detection — Smart Turn v3 (pipecat, BSD-2): semantisches Äußerungs-Ende.
# Whisper-Tiny-Encoder + Klassifikator (8 MB int8, ~12 ms CPU). Der Lucy-Client fragt
# nach dem akustischen VAD-Ende hier nach: "War das ein fertiger Satz?" — bei
# 'incomplete' wartet er kurz weiter, statt mitten im Gedanken loszuantworten.
# Inferenz-Pfad 1:1 aus pipecat-ai/smart-turn inference.py (Feature-Shape muss passen).
# =================================================================================
TURN_REPO = os.environ.get("VOICE_TURN_REPO", "pipecat-ai/smart-turn-v3")
TURN_FILE = os.environ.get("VOICE_TURN_FILE", "smart-turn-v3.2-cpu.onnx")
_turn = None # (session, feature_extractor)
_turn_failed = False
def turn_model():
global _turn, _turn_failed
if _turn is None and not _turn_failed:
try:
import onnxruntime as ort
from huggingface_hub import hf_hub_download
from transformers import WhisperFeatureExtractor
log.info("Lade Smart Turn '%s/%s'", TURN_REPO, TURN_FILE)
path = hf_hub_download(TURN_REPO, TURN_FILE)
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess = ort.InferenceSession(path, sess_options=so, providers=["CPUExecutionProvider"])
_turn = (sess, WhisperFeatureExtractor(chunk_length=8))
except Exception:
_turn_failed = True
log.exception("Smart Turn nicht verfügbar — /turn meldet complete=true (Fallback).")
return _turn
def check_turn(audio_bytes: bytes, suffix: str) -> dict:
model = turn_model()
if model is None:
return {"complete": True, "probability": 1.0, "engine": "none"}
sess, fe = model
from faster_whisper.audio import decode_audio
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
tf.write(audio_bytes)
src = tf.name
try:
import numpy as np
arr = decode_audio(src, sampling_rate=16000)
# Smart Turn erwartet das Audio AM ENDE des 8-s-Fensters (Zeros vorn) — die Turn-Ende-
# Hinweise liegen in den letzten Frames. Der FeatureExtractor padded rechts (Zeros hinten),
# damit sah das Modell immer nur Padding und meldete konstant 'complete' (live diagnostiziert).
# Darum manuell links auffüllen und exakt 8 s übergeben.
n = 8 * 16000
arr = arr[-n:]
if len(arr) < n:
arr = np.concatenate([np.zeros(n - len(arr), dtype=np.float32), arr.astype(np.float32)])
inputs = fe(arr, sampling_rate=16000, return_tensors="np", padding="do_not_pad",
truncation=True, do_normalize=True)
out = sess.run(None, {"input_features": inputs.input_features})
# Output-Tensor heißt 'logits' — je nach Export roher Logit ODER schon Sigmoid.
# Robust: Werte außerhalb [0,1] durch Sigmoid schicken, sonst direkt nutzen.
raw = float(out[0][0].item() if hasattr(out[0][0], "item") else out[0][0])
p = raw if 0.0 <= raw <= 1.0 else 1.0 / (1.0 + float(np.exp(-raw)))
# EMPIRISCH VERIFIZIERT (2026-07-02, Box): v3.2-cpu liefert P(UNFERTIG) —
# fertiger Satz -> 0.26, mitten im Wort abgeschnitten -> 0.96, Stille -> 0.99.
# (Die Upstream-Doku beschreibt es andersherum; Messung schlägt Doku.)
return {"complete": p <= 0.5, "probability": round(1.0 - p, 3), "engine": "smart-turn-v3"}
finally:
try:
os.unlink(src)
except OSError:
pass
# ================================================================================= # =================================================================================
# TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben) # TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben)
# ================================================================================= # =================================================================================
@@ -352,8 +487,25 @@ def edge_list() -> list[dict]:
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
try: try:
stt_model() # STT beim Start vorwärmen (Modell aus HF-Cache laden) # Nur die konfigurierte Engine vorwärmen — whisper bleibt als Fallback lazy
log.info("Voice-Sidecar bereit (STT '%s', Piper-Dir %s).", STT_MODEL, VOICES_DIR) # (spart ~1,5 GB RAM, solange Parakeet läuft).
if STT_ENGINE == "parakeet" and parakeet_model() is not None:
log.info("Voice-Sidecar bereit (STT parakeet '%s', Piper-Dir %s).", PARAKEET_MODEL, VOICES_DIR)
else:
stt_model()
log.info("Voice-Sidecar bereit (STT whisper '%s', Piper-Dir %s).", STT_MODEL, VOICES_DIR)
# Smart Turn vorwärmen (Modell + transformers-Import + 1 Probe-Inferenz): der erste
# /turn-Aufruf kostete sonst ~3,3 s — das traf beim Realtest den ersten gesprochenen Satz.
try:
import io as _io
import numpy as _np
import soundfile as _sf
buf = _io.BytesIO()
_sf.write(buf, _np.zeros(16000, dtype="float32"), 16000, format="WAV", subtype="PCM_16")
check_turn(buf.getvalue(), ".wav")
log.info("Smart Turn vorgewärmt.")
except Exception:
log.warning("Smart-Turn-Vorwärmen fehlgeschlagen (Feature bleibt lazy).", exc_info=True)
except Exception: except Exception:
log.exception("STT-Vorwärmen fehlgeschlagen (Dienst läuft, /health meldet Detail).") log.exception("STT-Vorwärmen fehlgeschlagen (Dienst läuft, /health meldet Detail).")
yield yield
@@ -372,7 +524,9 @@ class TTSIn(BaseModel):
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"ok": True, "stt_model": STT_MODEL, stt_active = "parakeet" if (STT_ENGINE == "parakeet" and not _parakeet_failed) else "whisper"
return {"ok": True, "stt_engine": stt_active,
"stt_model": PARAKEET_MODEL if stt_active == "parakeet" else STT_MODEL,
"engines": ["elevenlabs", "edge"], "engines": ["elevenlabs", "edge"],
"elevenlabs_key": bool(_eleven_key())} "elevenlabs_key": bool(_eleven_key())}
@@ -385,15 +539,26 @@ def voices() -> dict:
@app.post("/stt") @app.post("/stt")
async def stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: async def stt(audio: UploadFile = File(...), language: str = Form(default=""),
engine: str = Form(default="")) -> dict:
data = await audio.read() data = await audio.read()
if not data: if not data:
raise HTTPException(400, "Leeres Audio.") raise HTTPException(400, "Leeres Audio.")
suffix = Path(audio.filename or "rec.webm").suffix or ".webm" suffix = Path(audio.filename or "rec.webm").suffix or ".webm"
text = transcribe(data, suffix, language or STT_LANG) text = transcribe(data, suffix, language or STT_LANG, engine)
return {"text": text} return {"text": text}
@app.post("/turn")
async def turn(audio: UploadFile = File(...)) -> dict:
"""Semantische Turn-Detection: War die Äußerung ein fertiger Gedanke? (Smart Turn v3)"""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
suffix = Path(audio.filename or "rec.wav").suffix or ".wav"
return check_turn(data, suffix)
@app.post("/tts") @app.post("/tts")
def tts(body: TTSIn) -> Response: def tts(body: TTSIn) -> Response:
text = (body.text or "").strip() text = (body.text or "").strip()
+27 -9
View File
@@ -7,30 +7,48 @@ set -euo pipefail
SRC="${MC2_SRC:-$HOME/mission-control-v2}" SRC="${MC2_SRC:-$HOME/mission-control-v2}"
VENV="$HOME/.voice/venv" VENV="$HOME/.voice/venv"
VOICES="$HOME/.voice/voices" VOICES="$HOME/.voice/voices"
PY="${VOICE_PYTHON:-python3.12}" UV="$(command -v uv || echo "$HOME/.local/bin/uv")"
command -v "$PY" >/dev/null 2>&1 || PY=python3 # Fallback, falls python3.12 nicht im PATH # torch/chatterbox brauchen Python ≤3.13 — das Default-python3 der Box ist 3.14. Darum das venv
# bevorzugt per uv mit gemanagtem Python 3.12 anlegen (wie das mem0-venv). pip läuft via `uv pip`.
pipi() {
if [ -x "$UV" ]; then "$UV" pip install --python "$VENV/bin/python" "$@"
else "$VENV/bin/python" -m pip install "$@"; fi
}
# --- venv ------------------------------------------------------------------- # --- venv (Python 3.12) -----------------------------------------------------
if [ ! -x "$VENV/bin/python" ]; then if [ ! -x "$VENV/bin/python" ]; then
echo "[voice] Erstelle venv ($PY) → $VENV" echo "[voice] Erstelle venv (Python 3.12) → $VENV"
"$PY" -m venv "$VENV" if [ -x "$UV" ]; then "$UV" venv --python 3.12 "$VENV"
elif command -v python3.12 >/dev/null 2>&1; then python3.12 -m venv "$VENV"
else echo "[voice] FATAL: weder uv noch python3.12 vorhanden."; exit 1; fi
fi fi
"$VENV/bin/python" -m pip install -q --upgrade pip echo "[voice] venv-Python: $("$VENV/bin/python" --version 2>&1)"
# --- Kern-Deps (STT + Piper + Server) — required ---------------------------- # --- Kern-Deps (STT + Piper + Server) — required ----------------------------
echo "[voice] Installiere Kern-Abhängigkeiten …" echo "[voice] Installiere Kern-Abhängigkeiten …"
"$VENV/bin/python" -m pip install -q -r "$SRC/voice_service/requirements.txt" pipi -q -r "$SRC/voice_service/requirements.txt"
# --- Chatterbox (Premium-TTS) — best-effort, CPU-torch (kein 2-GB-CUDA-Wheel) ---- # --- Chatterbox (Premium-TTS) — best-effort, CPU-torch (kein 2-GB-CUDA-Wheel) ----
echo "[voice] Installiere Chatterbox (Premium-TTS, CPU-torch) — best-effort …" echo "[voice] Installiere Chatterbox (Premium-TTS, CPU-torch) — best-effort …"
if "$VENV/bin/python" -m pip install -q --index-url https://download.pytorch.org/whl/cpu torch torchaudio \ if pipi -q --index-url https://download.pytorch.org/whl/cpu torch torchaudio \
&& "$VENV/bin/python" -m pip install -q chatterbox-tts; then && pipi -q chatterbox-tts; then
echo "[voice] Chatterbox bereit." echo "[voice] Chatterbox bereit."
else else
echo "[voice] WARN: Chatterbox-Install fehlgeschlagen — Piper bleibt als Default-Stimme aktiv." echo "[voice] WARN: Chatterbox-Install fehlgeschlagen — Piper bleibt als Default-Stimme aktiv."
fi fi
# --- Piper-Binary (offiziell, bringt espeak-ng mit) → ~/.voice/piper -----------
if [ ! -x "$HOME/.voice/piper/piper" ]; then
echo "[voice] Lade Piper-Binary …"
curl -fsSL "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz" \
-o /tmp/piper.tgz \
&& tar xzf /tmp/piper.tgz -C "$HOME/.voice" \
&& rm -f /tmp/piper.tgz \
&& echo "[voice] Piper-Binary: $("$HOME/.voice/piper/piper" --version 2>&1 | head -1 || echo installiert)" \
|| echo "[voice] WARN: Piper-Binary-Download fehlgeschlagen."
fi
# --- Piper-Stimmen (Deutsch) nach ~/.voice/voices --------------------------- # --- Piper-Stimmen (Deutsch) nach ~/.voice/voices ---------------------------
mkdir -p "$VOICES" mkdir -p "$VOICES"
BASE="https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE" BASE="https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE"
+5
View File
@@ -8,3 +8,8 @@ soundfile
numpy numpy
faster-whisper faster-whisper
edge-tts edge-tts
# STT-Default seit Review 2026-07-02: Parakeet-TDT 0.6B v3 (DE-WER besser + ~10x schneller
# als whisper-medium auf CPU, via onnx-asr). whisper bleibt als Fallback installiert.
onnx-asr[cpu,hub]
# Semantische Turn-Detection (Smart Turn v3, Whisper-Mel-Features)
transformers