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:
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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}"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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 (2–4 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"""
|
|
||||||
@@ -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",
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,5–2× 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.
|
||||||
@@ -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 (~5–10 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,5–2× 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`.
|
||||||
@@ -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·3–6. → 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 1–10), Komponenten-Auswahl als Kernstück; bestehende Views
|
||||||
|
(Cockpit/AgentView/Sprechen) wiederverwenden.
|
||||||
|
6. Modell-Restore-aus-Manifest.
|
||||||
|
7. **Abnahme in frischer VM** (§9).
|
||||||
|
|
||||||
|
Vorschlag: 2–4 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/"; }
|
||||||
|
```
|
||||||
@@ -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.7–0.85).
|
||||||
|
- Akzeptanz: gleiche/bessere Natürlichkeit bei **messbar weniger Regenerationen** (= weniger Latenz).
|
||||||
|
|
||||||
|
*Gate A:* A1–A3 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
|
||||||
@@ -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,5–5 s | 2026-Ziel ist 0,5–0,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,5–5 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,5–1 s für den ersten Satz | mittel |
|
||||||
|
| **Voice-to-Voice gesamt** | **~3,5–5 s** | Ziel 2026: **0,5–0,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: **500–800 ms voice-to-voice**; Grundprinzip: **Streaming auf jeder Stufe** (STT-Partials sparen 200–400 ms, TTS startet auf Teiltext, VAD+Turn-Taking-Budget 150–300 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,5–5 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) | −100–300 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 (1–2 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,6–12 s (Session-Prefill System-Prompt+Tools ~4–5k 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,6–12 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 54–55 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 90–92 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.*
|
||||||
@@ -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 20–80 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, ~11–27 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 **6–80 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) + 1–2 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 | **~85–100 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>
|
||||||
@@ -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.055–13.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.055–13.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,3–0,9 s/Turn** — gegenüber
|
||||||
|
**20–80 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 5–22 s/Turn (wild schwankend), obwohl Direkt-ans-Gateway gemma einen stabilen
|
||||||
|
20k-Prompt in **180 ms** cached (call 1 kalt 19 s, call 2–4 = 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 5–22 s → ~400 ms typisch**
|
||||||
|
(Cache-Hit), vereinzelt 2–5 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 (2–6 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 (2–6 s). Auch nach Toolset-Eindampfung (Prompt 18k→**4908 Tok** via
|
||||||
|
`risk_profiles.default.excluded_tools`, 18 Kern-Tools) blieb gemma 1,5–6 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,2–1,8 s/Turn, NULL Spitzen** (vs gemma 1,5–6 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.
|
||||||
+4
File diff suppressed because one or more lines are too long
+663
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Sudo Passwort */}
|
{detail && (
|
||||||
<div className="space-y-2">
|
<UpdateDetailModal detail={detail} maintenanceJob={maintenanceJob}
|
||||||
<label className="text-xs font-semibold flex items-center gap-1.5">
|
onClose={() => setDetail(null)} onApply={applyFromDetail} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{detail && (() => {
|
|
||||||
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={() => 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -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 3–7 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 }[]
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
<AppErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</AppErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user