From 97be0f1d007b0d42e6f20e3c43c9b31a9cf59d7d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 7 Jul 2026 16:45:30 +0200 Subject: [PATCH] feat: lower memory dedupe threshold for more aggressive cleaning --- .aiexclude | 54 +- .claude/launch.json | 56 +- .gitignore | 44 +- AGENTS.md | 96 +- SAVEPOINT.md | 100 +- backend/app.py | 304 +- backend/config.py | 272 +- backend/migrate_config.py | 112 +- backend/models_catalog.json | 120 +- backend/routers/gateway_proxy.py | 242 +- backend/routers/system.py | 260 +- backend/routers/voice.py | 714 +- backend/services/agent.py | 528 +- backend/services/budget.py | 410 +- backend/services/discover.py | 358 +- backend/services/gateway_stream.py | 82 +- backend/services/gguf_meta.py | 532 +- backend/services/llamaswap.py | 1152 +-- backend/services/maintenance.py | 1452 +-- backend/services/memory.py | 2 +- backend/services/pricing.py | 116 +- backend/services/routing_policy.py | 254 +- backend/services/token_stats.py | 198 +- backend/services/warmer.py | 258 +- deploy/llama-swap.config.yaml | 230 +- deploy/skills/orchestrator/SKILL.md | 372 +- docs/ANTIGRAVITY_REVIEW.md | 104 +- docs/AUDIT_KICKOFF.md | 150 +- docs/BACKUP.md | 124 +- docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md | 212 +- docs/DISASTER_RECOVERY.md | 652 +- docs/LUCY_TTS_PLAN.md | 298 +- docs/REVIEW_2026-07-02.md | 562 +- docs/ZEROCLAW_POC_BRIEF.md | 166 +- docs/ZEROCLAW_POC_RESULTS.md | 236 +- frontend/package-lock.json | 9082 ++++++++--------- frontend/package.json | 78 +- frontend/src/App.tsx | 496 +- frontend/src/components/AppErrorBoundary.tsx | 76 +- frontend/src/components/CustomDialog.tsx | 164 +- frontend/src/components/SystemDrawer.tsx | 1278 +-- .../components/dashboard/AgentStatusCard.tsx | 344 +- .../components/dashboard/LucyHealthCard.tsx | 330 +- .../components/dashboard/MemoryInputCard.tsx | 188 +- .../src/components/dashboard/ServicesCard.tsx | 158 +- frontend/src/components/models/JobsBar.tsx | 136 +- .../src/components/models/ModelBadges.tsx | 120 +- .../src/components/models/WarmSetManager.tsx | 406 +- .../src/components/system/SettingsTab.tsx | 246 +- .../components/system/UpdateDetailModal.tsx | 392 +- frontend/src/components/system/rows.tsx | 116 +- frontend/src/lib/api.ts | 960 +- frontend/src/lib/format.ts | 64 +- frontend/src/lib/queries.ts | 304 +- frontend/src/lib/useDialog.tsx | 118 +- frontend/src/main.tsx | 54 +- frontend/src/mc3/Mc3App.tsx | 180 +- frontend/src/mc3/Mc3Maschinenraum.tsx | 120 +- frontend/src/mc3/Mc3Start.tsx | 186 +- frontend/src/nav.ts | 68 +- frontend/src/views/ModelsView.tsx | 92 +- frontend/src/views/models/Cockpit.tsx | 1816 ++-- frontend/src/views/models/Discover.tsx | 590 +- .../views/models/cockpit/RoleAssignModal.tsx | 208 +- hermes/plugins/mc2-memory/__init__.py | 436 +- mcp/mcp_mc.py | 652 +- voice_service/app.py | 1256 +-- voice_service/requirements.txt | 30 +- 68 files changed, 15783 insertions(+), 15783 deletions(-) diff --git a/.aiexclude b/.aiexclude index dc8c690..ecd39a6 100644 --- a/.aiexclude +++ b/.aiexclude @@ -1,27 +1,27 @@ -# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll. -# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten, -# Build-Output oder Binärdateien. - -# Abhängigkeiten & Build-Output (kein Review-Ziel) -backend/.venv/ -frontend/node_modules/ -frontend/dist/ -**/__pycache__/ -*.pyc -.git/ - -# Große / binäre / sensible Dateien -*.vrm -*.gguf -*.onnx -*.safetensors -*.wav -*.env -.env -credentials* -*.key -*.pem - -# Lokale Scratch-/Recon-Skripte -box_recon* -gemma_swap* +# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll. +# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten, +# Build-Output oder Binärdateien. + +# Abhängigkeiten & Build-Output (kein Review-Ziel) +backend/.venv/ +frontend/node_modules/ +frontend/dist/ +**/__pycache__/ +*.pyc +.git/ + +# Große / binäre / sensible Dateien +*.vrm +*.gguf +*.onnx +*.safetensors +*.wav +*.env +.env +credentials* +*.key +*.pem + +# Lokale Scratch-/Recon-Skripte +box_recon* +gemma_swap* diff --git a/.claude/launch.json b/.claude/launch.json index 46b3732..ee40f2d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,28 +1,28 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "mc2", - "runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe", - "runtimeArgs": [ - "-m", - "uvicorn", - "app:app", - "--app-dir", - "F:\\Coding Stuff\\mission-control-2\\backend", - "--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 - } - ] -} +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mc2", + "runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe", + "runtimeArgs": [ + "-m", + "uvicorn", + "app:app", + "--app-dir", + "F:\\Coding Stuff\\mission-control-2\\backend", + "--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 + } + ] +} diff --git a/.gitignore b/.gitignore index 9b42480..c9cbbfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,22 @@ -# Python -backend/.venv/ -__pycache__/ -*.pyc - -# Node / Vite -frontend/node_modules/ -# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ - -# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git. -# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git. -frontend/public/avatar.vrm -frontend/dist/avatar.vrm - -# Env / local -*.env -.DS_Store - -# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo) -box_recon* -gemma_swap* - +# Python +backend/.venv/ +__pycache__/ +*.pyc + +# Node / Vite +frontend/node_modules/ +# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ + +# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git. +# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git. +frontend/public/avatar.vrm +frontend/dist/avatar.vrm + +# Env / local +*.env +.DS_Store + +# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo) +box_recon* +gemma_swap* + diff --git a/AGENTS.md b/AGENTS.md index 81df035..1c26021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,48 +1,48 @@ -# AGENTS.md — Mission Control 2.0 - -Projekt-Geschmack für Coding-Agenten (Zed, Kilo Code, Claude Code lesen diese Datei). -Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`. - -## Was das ist -Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten: -Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) · -Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind. - -## Sprache (nicht verhandelbar) -- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen, - Skills, LLM-Summaries, Commit-Messages, Code-Kommentare. -- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht). - Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts. - -## Build & Deploy (die häufigste Falle) -- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die - gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann - **das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand. - (Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.) -- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor - dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht). -- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy. - -## Zeit & Umgebung -- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. - Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen - (siehe `backend/services/reminders.py`). - -## Backend-Konventionen -- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/` - (Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in - `services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`); - keine zweite Allowlist in einem Router duplizieren. -- **Gate vor Commit:** `python -m py_compile ` muss durchlaufen. -- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap = - System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen. -- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen. - -## Grenzen (Verdikt, eingehalten) -- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein. -- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.** -- Alles reversibel halten: Branch + Backup + Pin. - -## Gitea -Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über -PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub. +# AGENTS.md — Mission Control 2.0 + +Projekt-Geschmack für Coding-Agenten (Zed, Kilo Code, Claude Code lesen diese Datei). +Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`. + +## Was das ist +Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten: +Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) · +Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind. + +## Sprache (nicht verhandelbar) +- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen, + Skills, LLM-Summaries, Commit-Messages, Code-Kommentare. +- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht). + Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts. + +## Build & Deploy (die häufigste Falle) +- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die + gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann + **das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand. + (Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.) +- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor + dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht). +- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy. + +## Zeit & Umgebung +- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows. + Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen + (siehe `backend/services/reminders.py`). + +## Backend-Konventionen +- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/` + (Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in + `services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`); + keine zweite Allowlist in einem Router duplizieren. +- **Gate vor Commit:** `python -m py_compile ` muss durchlaufen. +- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap = + System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen. +- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen. + +## Grenzen (Verdikt, eingehalten) +- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein. +- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.** +- Alles reversibel halten: Branch + Backup + Pin. + +## Gitea +Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über +PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub. diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 354a3e8..06a4c19 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -1,50 +1,50 @@ -# Savepoint — Mission Control 2.0 (MC2) - -_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst -`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._ - -## Was das ist (in einem Satz) -MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD -Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis, -Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach- -Begleiterin „Lucy". - -## Architektur (Kurzform — Details in AGENTS.md/docs/) -- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei). - Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth). -- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git** - (Box hat kein Node; Backend liefert die gebauten Assets aus). -- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV). -- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab), - `mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor). - -## Aktueller Zustand -- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird - sauber gehalten und eingefroren, nicht neu erfunden. -- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung, - nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate. -- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab - (`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision` - statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin. -- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next · - heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed. - -## Nordstern & Randbedingungen (WICHTIG für den Review) -- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber - ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein. -- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf. -- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.** - -## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern) -- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf - `Europe/Berlin` umgestellt. -- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen). -- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich. - -## Was NICHT empfohlen werden soll (bereits entschieden) -Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in -124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind -**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.** - -## Letzter Sicherungspunkt -- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live). +# Savepoint — Mission Control 2.0 (MC2) + +_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst +`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._ + +## Was das ist (in einem Satz) +MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD +Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis, +Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach- +Begleiterin „Lucy". + +## Architektur (Kurzform — Details in AGENTS.md/docs/) +- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei). + Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth). +- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git** + (Box hat kein Node; Backend liefert die gebauten Assets aus). +- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV). +- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab), + `mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor). + +## Aktueller Zustand +- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird + sauber gehalten und eingefroren, nicht neu erfunden. +- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung, + nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate. +- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab + (`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision` + statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin. +- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next · + heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed. + +## Nordstern & Randbedingungen (WICHTIG für den Review) +- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber + ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein. +- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf. +- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.** + +## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern) +- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf + `Europe/Berlin` umgestellt. +- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen). +- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich. + +## Was NICHT empfohlen werden soll (bereits entschieden) +Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in +124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind +**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.** + +## Letzter Sicherungspunkt +- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live). diff --git a/backend/app.py b/backend/app.py index 5869488..3abe39f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,152 +1,152 @@ -""" -Mission Control 2.0 — dünner FastAPI-Einstieg. - -Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und -setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- -Server (proxyt /api hierher), daher CORS für localhost offen. -""" - -import asyncio -import logging -import os -from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Awaitable, Callable - -import httpx -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles -from starlette.requests import Request - -from config import FRONTEND_DIST, VERSION -from routers import ( - agent, - connect, - console, - gateway_proxy, - health, - hermes_ui, - maintenance, - memory, - models, - routing, - system, - voice, -) -from routers import reminders as reminders_router -from services import memory as memory_svc -from services import reminders, sentry, warmer - -# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration -# für alle Module (logging.getLogger(__name__)). -logging.basicConfig( - level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), - format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", -) -log = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - """Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn - + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram).""" - tasks: list[asyncio.Task[Any]] = [] - if warmer.ENABLED: - tasks.append(asyncio.create_task(warmer.rewarm_loop())) - log.info( - "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", - warmer.INTERVAL, - ) - if sentry.ENABLED: - tasks.append(asyncio.create_task(sentry.sentry_loop())) - tasks.append(asyncio.create_task(reminders.reminders_loop())) - if memory_svc.AUTO_DEDUPE_ENABLED: - tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) - log.info( - "Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", - memory_svc.AUTO_DEDUPE_INTERVAL, - memory_svc.AUTO_DEDUPE_THRESHOLD, - ) - # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client - # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). - app.state.gw_client = httpx.AsyncClient( - timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0), - limits=httpx.Limits(max_keepalive_connections=100, max_connections=200), - ) - try: - yield - finally: - for task in tasks: - _ = task.cancel() - await app.state.gw_client.aclose() - - -app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan) - -# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. -app.add_middleware( - CORSMiddleware, - allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.middleware("http") -async def no_cache( - request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] -) -> httpx.Response: - resp = await call_next(request) - if request.url.path.startswith("/api"): - resp.headers["Cache-Control"] = "no-cache" - return resp - - -app.include_router(health.router) -app.include_router(models.router) -app.include_router(routing.router) -app.include_router(system.router) -app.include_router(connect.router) -app.include_router(memory.router) -app.include_router(agent.router) -app.include_router( - voice.router -) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) -app.include_router( - reminders_router.router -) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten -app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) -app.include_router(maintenance.router) -app.include_router( - console.router -) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all -app.include_router( - hermes_ui.router -) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all - - -# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. -if FRONTEND_DIST.exists(): - app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") - - _DIST_ROOT = FRONTEND_DIST.resolve() - - @app.get("/{full_path:path}") - def spa(full_path: str): - # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber - # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. - try: - target = (FRONTEND_DIST / full_path).resolve() - if target.is_relative_to(_DIST_ROOT) and target.is_file(): - return FileResponse(target) - except (ValueError, OSError): - pass - - index = FRONTEND_DIST / "index.html" - if index.exists(): - # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. - return FileResponse( - index, headers={"Cache-Control": "no-cache, must-revalidate"} - ) - return {"detail": "frontend not built"} +""" +Mission Control 2.0 — dünner FastAPI-Einstieg. + +Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und +setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- +Server (proxyt /api hierher), daher CORS für localhost offen. +""" + +import asyncio +import logging +import os +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, Awaitable, Callable + +import httpx +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from starlette.requests import Request + +from config import FRONTEND_DIST, VERSION +from routers import ( + agent, + connect, + console, + gateway_proxy, + health, + hermes_ui, + maintenance, + memory, + models, + routing, + system, + voice, +) +from routers import reminders as reminders_router +from services import memory as memory_svc +from services import reminders, sentry, warmer + +# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration +# für alle Module (logging.getLogger(__name__)). +logging.basicConfig( + level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", +) +log = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn + + Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram).""" + tasks: list[asyncio.Task[Any]] = [] + if warmer.ENABLED: + tasks.append(asyncio.create_task(warmer.rewarm_loop())) + log.info( + "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", + warmer.INTERVAL, + ) + if sentry.ENABLED: + tasks.append(asyncio.create_task(sentry.sentry_loop())) + tasks.append(asyncio.create_task(reminders.reminders_loop())) + if memory_svc.AUTO_DEDUPE_ENABLED: + tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop())) + log.info( + "Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)", + memory_svc.AUTO_DEDUPE_INTERVAL, + memory_svc.AUTO_DEDUPE_THRESHOLD, + ) + # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client + # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). + app.state.gw_client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0), + limits=httpx.Limits(max_keepalive_connections=100, max_connections=200), + ) + try: + yield + finally: + for task in tasks: + _ = task.cancel() + await app.state.gw_client.aclose() + + +app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan) + +# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def no_cache( + request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] +) -> httpx.Response: + resp = await call_next(request) + if request.url.path.startswith("/api"): + resp.headers["Cache-Control"] = "no-cache" + return resp + + +app.include_router(health.router) +app.include_router(models.router) +app.include_router(routing.router) +app.include_router(system.router) +app.include_router(connect.router) +app.include_router(memory.router) +app.include_router(agent.router) +app.include_router( + voice.router +) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab) +app.include_router( + reminders_router.router +) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten +app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) +app.include_router(maintenance.router) +app.include_router( + console.router +) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all +app.include_router( + hermes_ui.router +) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all + + +# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. +if FRONTEND_DIST.exists(): + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") + + _DIST_ROOT = FRONTEND_DIST.resolve() + + @app.get("/{full_path:path}") + def spa(full_path: str): + # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber + # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. + try: + target = (FRONTEND_DIST / full_path).resolve() + if target.is_relative_to(_DIST_ROOT) and target.is_file(): + return FileResponse(target) + except (ValueError, OSError): + pass + + index = FRONTEND_DIST / "index.html" + if index.exists(): + # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. + return FileResponse( + index, headers={"Cache-Control": "no-cache, must-revalidate"} + ) + return {"detail": "frontend not built"} diff --git a/backend/config.py b/backend/config.py index 4a66392..3aa335d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,136 +1,136 @@ -""" -Zentrale Konfiguration für Mission Control 2.0. - -Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars -überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene -Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen. -""" - -import os -from pathlib import Path - -from ruamel.yaml import YAML - -# --- Engine (llama-swap) ----------------------------------------------------- -LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") -CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) -MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models")) -# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace). -# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster. -DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json"))) -DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h -# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen. -# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis -# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL). -MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db"))) -# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12), -# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an. -MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/") -# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen. -# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server — -# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching -# macht llama-server ohnehin automatisch pro Slot (KV-Reuse). -_DEFAULT_CMD_TEMPLATE = ( - "llama-server -m {model} --host 127.0.0.1 --port ${PORT} " - "-c {ctx} -ngl 999 -fa on --no-mmap" -) -CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE) -if "{model}" not in CMD_TEMPLATE: - CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE -DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) -# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines -# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt -# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern). -DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts"))) -# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur -# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt, -# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.) -SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "") -# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich -# zu --spec-draft-model, sonst ist Spec inaktiv). -SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple") -# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen -# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit -# `--model-draft --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT -# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust. -SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4")) -# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha). -HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"} - -# --- Routing-Gateway (builtin in MC2, model: auto) --------------------------- -# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer -# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr. -GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/") - -# --- Hermes Agent (eigener Dienst auf der Box) ------------------------------- -# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`). -HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/") -# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für -# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent -# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP. -def _read_hermes_env(key: str) -> str: - """Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env). - Der MC2-Dienst erbt die Hermes-Secrets sonst nicht.""" - try: - env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env" - for line in env_path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line.startswith(f"{key}="): - return line.split("=", 1)[1].strip().strip('"').strip("'") - except OSError: - pass - return "" - - -HERMES_API_KEY = ( - os.environ.get("HERMES_API_KEY") - or os.environ.get("API_SERVER_KEY") - or _read_hermes_env("API_SERVER_KEY") -) -# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn, -# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft. -HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes") - -# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) --------------- -# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen. -VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/") -# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI. Wie die Box-Konsole bindet -# es NUR an Loopback (127.0.0.1:7681, base-path /hermes-terminal) und wird von MC2 same-origin -# durchgereicht (routers/console.py → /hermes-terminal/). Login per `su` (Box-Passwort). -HERMES_TERMINAL_UPSTREAM = os.environ.get("MC_HERMES_TERMINAL_UPSTREAM", "http://127.0.0.1:7681").rstrip("/") -HERMES_TERMINAL_PATH = "/hermes-terminal/" -# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback -# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001 -# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole -# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff. -# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard. -BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/") -# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel). -BOX_CONSOLE_PATH = "/console/" -# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit -# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback -# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter -# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle -# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix -# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig. -HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/") -HERMES_BUILTIN_UI_PATH = "/hermes-ui/" -# GitHub-Repo für Update-Checks. -HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") -HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) -# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN. -PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/") - -# --- Server ------------------------------------------------------------------ -HOST = os.environ.get("MC_HOST", "0.0.0.0") -PORT = int(os.environ.get("MC_PORT", "9000")) -# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus; -# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher. -FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist"))) - -# Version (Phase 0 — Greenfield-Skeleton). -VERSION = "2.0.0-w8" - -# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml). -yaml = YAML() -yaml.preserve_quotes = True +""" +Zentrale Konfiguration für Mission Control 2.0. + +Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars +überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene +Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen. +""" + +import os +from pathlib import Path + +from ruamel.yaml import YAML + +# --- Engine (llama-swap) ----------------------------------------------------- +LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/") +CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")) +MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models")) +# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace). +# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster. +DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json"))) +DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h +# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen. +# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis +# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL). +MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db"))) +# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12), +# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an. +MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/") +# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen. +# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server — +# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching +# macht llama-server ohnehin automatisch pro Slot (KV-Reuse). +_DEFAULT_CMD_TEMPLATE = ( + "llama-server -m {model} --host 127.0.0.1 --port ${PORT} " + "-c {ctx} -ngl 999 -fa on --no-mmap" +) +CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE) +if "{model}" not in CMD_TEMPLATE: + CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE +DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300")) +# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines +# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt +# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern). +DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts"))) +# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur +# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt, +# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.) +SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "") +# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich +# zu --spec-draft-model, sonst ist Spec inaktiv). +SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple") +# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen +# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit +# `--model-draft --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT +# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust. +SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4")) +# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha). +HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"} + +# --- Routing-Gateway (builtin in MC2, model: auto) --------------------------- +# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer +# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr. +GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/") + +# --- Hermes Agent (eigener Dienst auf der Box) ------------------------------- +# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`). +HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/") +# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für +# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent +# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP. +def _read_hermes_env(key: str) -> str: + """Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env). + Der MC2-Dienst erbt die Hermes-Secrets sonst nicht.""" + try: + env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env" + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "" + + +HERMES_API_KEY = ( + os.environ.get("HERMES_API_KEY") + or os.environ.get("API_SERVER_KEY") + or _read_hermes_env("API_SERVER_KEY") +) +# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn, +# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft. +HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes") + +# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) --------------- +# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen. +VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/") +# Hermes-Terminal: ttyd-Web-Terminal der interaktiven Agent-CLI. Wie die Box-Konsole bindet +# es NUR an Loopback (127.0.0.1:7681, base-path /hermes-terminal) und wird von MC2 same-origin +# durchgereicht (routers/console.py → /hermes-terminal/). Login per `su` (Box-Passwort). +HERMES_TERMINAL_UPSTREAM = os.environ.get("MC_HERMES_TERMINAL_UPSTREAM", "http://127.0.0.1:7681").rstrip("/") +HERMES_TERMINAL_PATH = "/hermes-terminal/" +# Box-Konsole: zweites ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback +# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001 +# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole +# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff. +# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard. +BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/") +# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel). +BOX_CONSOLE_PATH = "/console/" +# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit +# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback +# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter +# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle +# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix +# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig. +HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/") +HERMES_BUILTIN_UI_PATH = "/hermes-ui/" +# GitHub-Repo für Update-Checks. +HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent") +HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) +# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN. +PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/") + +# --- Server ------------------------------------------------------------------ +HOST = os.environ.get("MC_HOST", "0.0.0.0") +PORT = int(os.environ.get("MC_PORT", "9000")) +# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus; +# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher. +FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist"))) + +# Version (Phase 0 — Greenfield-Skeleton). +VERSION = "2.0.0-w8" + +# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml). +yaml = YAML() +yaml.preserve_quotes = True diff --git a/backend/migrate_config.py b/backend/migrate_config.py index 38070f6..c5021c6 100644 --- a/backend/migrate_config.py +++ b/backend/migrate_config.py @@ -1,56 +1,56 @@ -import sys -from pathlib import Path - -# Add backend directory to sys.path so we can import services -sys.path.append(str(Path(__file__).resolve().parent)) - -from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE -from config import CONFIG_PATH - -def migrate(): - print(f"Reading config from {CONFIG_PATH}...") - if not CONFIG_PATH.exists(): - print(f"Config path {CONFIG_PATH} does not exist. Skipping.") - return - - cfg = read_config() - models = cfg.get("models", {}) - - for name, spec in models.items(): - if not isinstance(spec, dict): - continue - cmd = spec.get("cmd", "") - if not cmd: - continue - - print(f"Migrating model: {name}") - - # 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags, - # die llama-server ablehnt → Start scheitert). Caching macht llama-server - # automatisch pro Slot. - cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "") - - # 2. Extract aliases/role - aliases = spec.get("aliases", []) - role = aliases[0] if aliases else None - - # 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder. - # spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an; - # ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt. - if role in ("fast", "coder"): - if "--parallel" not in cmd: - cmd = cmd.strip() + " --parallel 2" - if "--spec-draft-model" not in cmd: - target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else "" - cmd = cmd.strip() + spec_draft_flags(target) - - # Update cmd - from ruamel.yaml.scalarstring import LiteralScalarString - spec["cmd"] = LiteralScalarString(cmd.strip() + "\n") - - print(f"Writing updated config back to {CONFIG_PATH}...") - write_config(cfg) - print("Migration completed successfully!") - -if __name__ == "__main__": - migrate() +import sys +from pathlib import Path + +# Add backend directory to sys.path so we can import services +sys.path.append(str(Path(__file__).resolve().parent)) + +from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE +from config import CONFIG_PATH + +def migrate(): + print(f"Reading config from {CONFIG_PATH}...") + if not CONFIG_PATH.exists(): + print(f"Config path {CONFIG_PATH} does not exist. Skipping.") + return + + cfg = read_config() + models = cfg.get("models", {}) + + for name, spec in models.items(): + if not isinstance(spec, dict): + continue + cmd = spec.get("cmd", "") + if not cmd: + continue + + print(f"Migrating model: {name}") + + # 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags, + # die llama-server ablehnt → Start scheitert). Caching macht llama-server + # automatisch pro Slot. + cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "") + + # 2. Extract aliases/role + aliases = spec.get("aliases", []) + role = aliases[0] if aliases else None + + # 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder. + # spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an; + # ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt. + if role in ("fast", "coder"): + if "--parallel" not in cmd: + cmd = cmd.strip() + " --parallel 2" + if "--spec-draft-model" not in cmd: + target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else "" + cmd = cmd.strip() + spec_draft_flags(target) + + # Update cmd + from ruamel.yaml.scalarstring import LiteralScalarString + spec["cmd"] = LiteralScalarString(cmd.strip() + "\n") + + print(f"Writing updated config back to {CONFIG_PATH}...") + write_config(cfg) + print("Migration completed successfully!") + +if __name__ == "__main__": + migrate() diff --git a/backend/models_catalog.json b/backend/models_catalog.json index 9b301dd..bc4fe2c 100644 --- a/backend/models_catalog.json +++ b/backend/models_catalog.json @@ -1,60 +1,60 @@ -{ - "_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.", - "version": "2026-07-03", - "models": [ - { - "role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF", - "family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3, - "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true - }, - { - "role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF", - "family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, - "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false - }, - - { - "role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF", - "family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10, - "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false - }, - { - "role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF", - "family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5, - "moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false - }, - - { - "role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF", - "family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3, - "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false - }, - { - "role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", - "family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, - "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false - }, - - { - "role": "vision", "name": "Qwen3-VL-30B-A3B-Instruct", "repo": "Qwen/Qwen3-VL-30B-A3B-Instruct-GGUF", - "family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, - "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true - }, - { - "role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF", - "family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8, - "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true - }, - { - "role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF", - "family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2, - "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true - }, - - { - "role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF", - "family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3, - "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true - } - ] -} +{ + "_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.", + "version": "2026-07-03", + "models": [ + { + "role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF", + "family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true + }, + { + "role": "fast", "name": "Qwen3-30B-A3B-Instruct", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF", + "family": "qwen", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false + }, + + { + "role": "heavy", "name": "Qwen3.5-122B-A10B", "repo": "Qwen/Qwen3.5-122B-A10B-GGUF", + "family": "qwen", "generation": 3.5, "total_params_b": 122, "active_params_b": 10, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": false + }, + { + "role": "heavy", "name": "gpt-oss-120b", "repo": "ggml-org/gpt-oss-120b-GGUF", + "family": "gpt-oss", "generation": 1.0, "total_params_b": 120, "active_params_b": 5, + "moe": true, "quant": "MXFP4", "ctx": 32768, "tools": true, "vision": false + }, + + { + "role": "coder", "name": "Qwen3-Coder-Next", "repo": "Qwen/Qwen3-Coder-Next-GGUF", + "family": "qwen-coder", "generation": 3.0, "total_params_b": 84, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false + }, + { + "role": "coder", "name": "Qwen3-Coder-30B-A3B-Instruct", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", + "family": "qwen-coder", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false + }, + + { + "role": "vision", "name": "Qwen3-VL-30B-A3B-Instruct", "repo": "Qwen/Qwen3-VL-30B-A3B-Instruct-GGUF", + "family": "qwen-vl", "generation": 3.0, "total_params_b": 30, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + }, + { + "role": "vision", "name": "Qwen3-VL-8B-Instruct", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF", + "family": "qwen-vl", "generation": 3.0, "total_params_b": 8, "active_params_b": 8, + "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + }, + { + "role": "vision", "name": "Qwen3-VL-2B-Instruct", "repo": "Qwen/Qwen3-VL-2B-Instruct-GGUF", + "family": "qwen-vl", "generation": 3.0, "total_params_b": 2, "active_params_b": 2, + "moe": false, "quant": "Q4_K_M", "ctx": 32768, "tools": false, "vision": true + }, + + { + "role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF", + "family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3, + "moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true + } + ] +} diff --git a/backend/routers/gateway_proxy.py b/backend/routers/gateway_proxy.py index 8d12a17..485fe8a 100644 --- a/backend/routers/gateway_proxy.py +++ b/backend/routers/gateway_proxy.py @@ -1,121 +1,121 @@ -import os - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse, StreamingResponse - -from config import LLAMA_SWAP_URL -from services.gateway_stream import record_stream_chunk, record_usage -from services.router_logic import LANES, choose_for_lane -from services.routing_policy import load_policy - -router = APIRouter(prefix="/v1") - -# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch -# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab. -_LANG_DIRECTIVE = os.environ.get( - "MC_GATEWAY_LANG_DIRECTIVE", - "Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, " - "Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. " - "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") - - -def _inject_language(body: dict, alias: str) -> None: - """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates - erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys - Persona (SOUL.md) regelt die Sprache selbst.""" - if not _LANG_DIRECTIVE or alias == "hermes": - return - msgs = body.get("messages") - if not isinstance(msgs, list): - return - first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None) - if first_sys is None: - msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE}) - elif isinstance(first_sys.get("content"), str): - first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE - elif isinstance(first_sys.get("content"), list): - first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) - -# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet. -_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"} - - -@router.get("/models") -async def models(request: Request): - client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) - r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) - data = r.json() - # Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können. - lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router", - "description": _LANE_LABELS.get(lane, lane)} for lane in LANES] - # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie - # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende - # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). - # Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf, - # Clients fragen aber genau damit an → als eigene Einträge einblenden. - ctx_map: dict[str, int] = {} - alias_entries: list[dict] = [] - try: - from services import llamaswap - for m in llamaswap.list_models(): - ctx = m.get("ctx") - if ctx: - for api_id in m.get("api_ids", []): - ctx_map[api_id] = ctx - for alias in m.get("aliases", []): - entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", - "description": f"Alias für {m['name']}"} - if ctx: - entry["context_length"] = ctx - alias_entries.append(entry) - except Exception: - pass - if isinstance(data, dict) and isinstance(data.get("data"), list): - for entry in data["data"]: - if (ctx := ctx_map.get(entry.get("id"))): - entry.setdefault("context_length", ctx) - data["data"] = lanes + alias_entries + data["data"] - return JSONResponse(data, status_code=r.status_code) - - -async def _proxy(path: str, request: Request): - body = await request.json() - requested = str(body.get("model") or "auto") - if requested.lower() in ("auto", "chat", "coding"): - lane = requested.lower() - alias, reason = choose_for_lane(lane, body) - body["model"] = alias - routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} - else: - alias = requested - routed = {"x-mc-routed-to": requested} - # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt). - pol = load_policy() - if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body: - body["chat_template_kwargs"] = {"enable_thinking": False} - _inject_language(body, alias) - url = f"{LLAMA_SWAP_URL}{path}" - - client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) - if body.get("stream"): - async def gen(): - async with client.stream("POST", url, json=body, timeout=None) as r: - async for chunk in r.aiter_raw(): - record_stream_chunk(chunk, alias) - yield chunk - return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) - - r = await client.post(url, json=body, timeout=600.0) - resp_json = r.json() - record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias) - return JSONResponse(resp_json, status_code=r.status_code, headers=routed) - - -@router.post("/chat/completions") -async def chat_completions(request: Request): - return await _proxy("/v1/chat/completions", request) - - -@router.post("/completions") -async def completions(request: Request): - return await _proxy("/v1/completions", request) +import os + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from config import LLAMA_SWAP_URL +from services.gateway_stream import record_stream_chunk, record_usage +from services.router_logic import LANES, choose_for_lane +from services.routing_policy import load_policy + +router = APIRouter(prefix="/v1") + +# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch +# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab. +_LANG_DIRECTIVE = os.environ.get( + "MC_GATEWAY_LANG_DIRECTIVE", + "Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, " + "Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. " + "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") + + +def _inject_language(body: dict, alias: str) -> None: + """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates + erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys + Persona (SOUL.md) regelt die Sprache selbst.""" + if not _LANG_DIRECTIVE or alias == "hermes": + return + msgs = body.get("messages") + if not isinstance(msgs, list): + return + first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None) + if first_sys is None: + msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE}) + elif isinstance(first_sys.get("content"), str): + first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE + elif isinstance(first_sys.get("content"), list): + first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) + +# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet. +_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"} + + +@router.get("/models") +async def models(request: Request): + client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) + r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) + data = r.json() + # Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können. + lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router", + "description": _LANE_LABELS.get(lane, lane)} for lane in LANES] + # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie + # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende + # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). + # Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf, + # Clients fragen aber genau damit an → als eigene Einträge einblenden. + ctx_map: dict[str, int] = {} + alias_entries: list[dict] = [] + try: + from services import llamaswap + for m in llamaswap.list_models(): + ctx = m.get("ctx") + if ctx: + for api_id in m.get("api_ids", []): + ctx_map[api_id] = ctx + for alias in m.get("aliases", []): + entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", + "description": f"Alias für {m['name']}"} + if ctx: + entry["context_length"] = ctx + alias_entries.append(entry) + except Exception: + pass + if isinstance(data, dict) and isinstance(data.get("data"), list): + for entry in data["data"]: + if (ctx := ctx_map.get(entry.get("id"))): + entry.setdefault("context_length", ctx) + data["data"] = lanes + alias_entries + data["data"] + return JSONResponse(data, status_code=r.status_code) + + +async def _proxy(path: str, request: Request): + body = await request.json() + requested = str(body.get("model") or "auto") + if requested.lower() in ("auto", "chat", "coding"): + lane = requested.lower() + alias, reason = choose_for_lane(lane, body) + body["model"] = alias + routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} + else: + alias = requested + routed = {"x-mc-routed-to": requested} + # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt). + pol = load_policy() + if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body: + body["chat_template_kwargs"] = {"enable_thinking": False} + _inject_language(body, alias) + url = f"{LLAMA_SWAP_URL}{path}" + + client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) + if body.get("stream"): + async def gen(): + async with client.stream("POST", url, json=body, timeout=None) as r: + async for chunk in r.aiter_raw(): + record_stream_chunk(chunk, alias) + yield chunk + return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) + + r = await client.post(url, json=body, timeout=600.0) + resp_json = r.json() + record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias) + return JSONResponse(resp_json, status_code=r.status_code, headers=routed) + + +@router.post("/chat/completions") +async def chat_completions(request: Request): + return await _proxy("/v1/chat/completions", request) + + +@router.post("/completions") +async def completions(request: Request): + return await _proxy("/v1/completions", request) diff --git a/backend/routers/system.py b/backend/routers/system.py index d3c799d..75bb572 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -1,130 +1,130 @@ -"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box). - -Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). -Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler -zurückgegeben statt zu crashen. -""" - -import logging -import os -import subprocess - -from fastapi import APIRouter -from pydantic import BaseModel - -import httpx - -from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL -from services import backup as backup_svc -from services import maintenance -from services.agent import agent_status -from services.gateway import gateway_reachable -from services.llamaswap import engine_reachable, list_models -from services.pricing import compute_savings -from services.system import system_status -from services.token_stats import get_stats - -log = logging.getLogger(__name__) - -router = APIRouter(prefix="/api") - -# Quelle für Self-Update (auf der Box ~/mission-control-v2). -SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")) - - -@router.get("/system/status") -def status() -> dict: - return system_status() - - -def _mem0_reachable() -> bool: - try: - return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200 - except Exception: - return False - - -def _voice_reachable() -> bool: - try: - return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 - except Exception: - return False - - -@router.get("/system/services") -def services() -> dict: - """Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige).""" - a = agent_status() - gw_url = f"{GATEWAY_URL}/v1" - return { - "services": [ - {"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()}, - {"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()}, - {"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]}, - {"name": "Hermes-Terminal", "unit": "hermes-terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]}, - {"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()}, - {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()}, - ], - "links": { - "engine_ui": f"{LLAMA_SWAP_URL}/ui", - "gateway": gw_url, - "hermes_terminal": a["terminal_url"], - }, - } - - -@router.post("/system/backup") -def backup() -> dict: - return backup_svc.backup_now() - - -@router.get("/system/backups") -def backups() -> dict: - return {"backups": backup_svc.list_backups()} - - -def _run(cmd: list[str], cwd: str | None = None) -> dict: - try: - p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) - return {"ok": p.returncode == 0, "code": p.returncode, - "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} - except Exception as exc: # noqa: BLE001 - return {"ok": False, "code": -1, "out": "", "err": str(exc)} - - -class RestartReq(BaseModel): - service: str - - -@router.post("/system/restart") -def restart(req: RestartReq) -> dict: - """Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit): - der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von - der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste - (ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per - Sprache/MCP schlug fehl.""" - return maintenance.restart_service(req.service) - - -@router.post("/system/self-update") -def self_update() -> dict: - """git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler.""" - pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR) - reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR) - restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"]) - return {"pull": pull, "reset": reset, "restart": restart_res} - - -@router.get("/system/token-stats") -def token_stats() -> dict: - """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" - # Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen. - role_map: dict[str, str | None] = {} - try: - for m in list_models(): - role_map[m["name"].lower()] = m.get("role") - for alias in m.get("aliases", []): - role_map[alias.lower()] = m.get("role") - except Exception: - log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True) - return compute_savings(get_stats(), role_map) +"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box). + +Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern). +Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler +zurückgegeben statt zu crashen. +""" + +import logging +import os +import subprocess + +from fastapi import APIRouter +from pydantic import BaseModel + +import httpx + +from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL +from services import backup as backup_svc +from services import maintenance +from services.agent import agent_status +from services.gateway import gateway_reachable +from services.llamaswap import engine_reachable, list_models +from services.pricing import compute_savings +from services.system import system_status +from services.token_stats import get_stats + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/api") + +# Quelle für Self-Update (auf der Box ~/mission-control-v2). +SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")) + + +@router.get("/system/status") +def status() -> dict: + return system_status() + + +def _mem0_reachable() -> bool: + try: + return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200 + except Exception: + return False + + +def _voice_reachable() -> bool: + try: + return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200 + except Exception: + return False + + +@router.get("/system/services") +def services() -> dict: + """Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige).""" + a = agent_status() + gw_url = f"{GATEWAY_URL}/v1" + return { + "services": [ + {"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()}, + {"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()}, + {"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]}, + {"name": "Hermes-Terminal", "unit": "hermes-terminal", "url": a["terminal_url"], "ok": a["terminal_reachable"]}, + {"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()}, + {"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()}, + ], + "links": { + "engine_ui": f"{LLAMA_SWAP_URL}/ui", + "gateway": gw_url, + "hermes_terminal": a["terminal_url"], + }, + } + + +@router.post("/system/backup") +def backup() -> dict: + return backup_svc.backup_now() + + +@router.get("/system/backups") +def backups() -> dict: + return {"backups": backup_svc.list_backups()} + + +def _run(cmd: list[str], cwd: str | None = None) -> dict: + try: + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180) + return {"ok": p.returncode == 0, "code": p.returncode, + "out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]} + except Exception as exc: # noqa: BLE001 + return {"ok": False, "code": -1, "out": "", "err": str(exc)} + + +class RestartReq(BaseModel): + service: str + + +@router.post("/system/restart") +def restart(req: RestartReq) -> dict: + """Dienst neustarten. Delegiert an den Wartungs-Service (EINE Allowlist-Wahrheit): + der kennt System-Dienste (llama-swap, via sudo -n) UND User-Dienste und wird auch von + der UI (/api/maintenance/restart) genutzt. Vorher lag hier eine zweite, veraltete Liste + (ohne llama-swap/hermes-terminal, mit Geist-Eintrag hermes-webui) → Engine-Neustart per + Sprache/MCP schlug fehl.""" + return maintenance.restart_service(req.service) + + +@router.post("/system/self-update") +def self_update() -> dict: + """git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler.""" + pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR) + reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR) + restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"]) + return {"pull": pull, "reset": reset, "restart": restart_res} + + +@router.get("/system/token-stats") +def token_stats() -> dict: + """Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT).""" + # Rolle je Modell/Alias (lowercase) für die Tarif-Auflösung auflösen. + role_map: dict[str, str | None] = {} + try: + for m in list_models(): + role_map[m["name"].lower()] = m.get("role") + for alias in m.get("aliases", []): + role_map[alias.lower()] = m.get("role") + except Exception: + log.warning("token_stats: list_models fehlgeschlagen, Tarife per Name", exc_info=True) + return compute_savings(get_stats(), role_map) diff --git a/backend/routers/voice.py b/backend/routers/voice.py index f76813e..a93e1a8 100644 --- a/backend/routers/voice.py +++ b/backend/routers/voice.py @@ -1,357 +1,357 @@ -""" -Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar). - -Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den -Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools + -geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den -Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht. - -LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. -""" - -import logging -import os -import time - -import httpx -from fastapi import APIRouter, File, Form, HTTPException, UploadFile -from fastapi.responses import Response, StreamingResponse -from pydantic import BaseModel - -from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL -from services import announce -from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern) - Timer, - TurnTrace, - get_metrics, - get_trace, - park, - record_stage, -) - -# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). -import sys as _sys -_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") -if _GUARD_DIR not in _sys.path: - _sys.path.insert(0, _GUARD_DIR) -try: - from guard import wrap_untrusted -except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen - def wrap_untrusted(text: str, label: str = "") -> str: - return text - -log = logging.getLogger(__name__) -router = APIRouter(prefix="/api") - -# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung -# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell -# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar. -VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") -# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2). -VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) - - -async def _describe_images(image_urls: list[str], hint: str) -> str: - """Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). - Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler.""" - multi = len(image_urls) > 1 - intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " - "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " - "Keine Einleitung, keine Wiederholung der Frage. " - if multi else - "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " - "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") - content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] - for u in image_urls: - content.append({"type": "image_url", "image_url": {"url": u}}) - try: - # 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={ - "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, - "messages": [{"role": "user", "content": content}], - }) - r.raise_for_status() - return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() - except Exception as exc: - log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) - return "" - -_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern - - -class TTSIn(BaseModel): - text: str - engine: str = "piper" - voice: str = "" - language: str = "" - ref_path: str = "" - - -class ChatIn(BaseModel): - text: str # die neue User-Äußerung (STT-Ergebnis) - session_id: str # stabiler Voice-Faden → server-seitiger Transcript - session_key: str = "" # optional: Langzeit-Memory-Scope - system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") - model: str = "" - images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") - - -class AnnounceIn(BaseModel): - text: str # die Meldung (wird von Lucy gesprochen) - subject: str = "" # kurze Betreffzeile (z.B. "[Update]") - source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel - priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen - - -class AlarmIn(BaseModel): - text: str # die Alarm-Meldung - subject: str = "[Alarm]" # Betreff (Telegram-Präfix) - source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog") - - -@router.post("/alarm") -def alarm(body: AlarmIn) -> dict: - """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den - Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran - der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten - nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only - wie alle MC2-Endpoints.""" - text = (body.text or "").strip() - if not text: - raise HTTPException(400, "Leere Meldung.") - subject = (body.subject or "[Alarm]").strip() - try: - item = announce.add(text, subject, body.source or "alarm", "normal") - except ValueError as exc: - raise HTTPException(400, str(exc)) - announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) - return {"ok": True, "item": item} - - -@router.post("/voice/announce") -def voice_announce(body: AnnounceIn) -> dict: - """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter, - notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints.""" - try: - return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} - except ValueError as exc: - raise HTTPException(400, str(exc)) - - -@router.get("/voice/announcements") -def voice_announcements(after: int | None = None, limit: int = 20) -> dict: - """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den - aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" - return announce.list_after(after, limit) - - -@router.get("/voice/health") -def voice_health() -> dict: - """Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist.""" - out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)} - try: - r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0)) - out["sidecar"] = r.status_code == 200 - out["detail"] = r.json() if r.status_code == 200 else None - except Exception as exc: # noqa: BLE001 - out["error"] = str(exc) - return out - - -@router.get("/voice/metrics") -def voice_metrics() -> dict: - """Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh.""" - return get_metrics() - - -@router.get("/voice/trace") -def voice_trace(limit: int = 20) -> dict: - """Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn · - Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit — - damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt.""" - return {"turns": get_trace(limit)} - - -@router.get("/voice/voices") -def voice_voices() -> dict: - try: - r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0)) - r.raise_for_status() - return r.json() - except Exception as exc: # noqa: BLE001 - raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}") - - -@router.post("/voice/stt") -async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: - """Mikro-Audio → Text (Proxy auf Sidecar /stt).""" - data = await audio.read() - if not data: - raise HTTPException(400, "Leeres Audio.") - files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - _t0 = time.perf_counter() - r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) - _ms = (time.perf_counter() - _t0) * 1000.0 - record_stage("stt", _ms) - park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein - r.raise_for_status() - return r.json() - except httpx.HTTPError as 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") -async def voice_set_reference(audio: UploadFile = File(...)) -> dict: - """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" - data = await audio.read() - if not data: - raise HTTPException(400, "Leeres Audio.") - files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")} - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files) - r.raise_for_status() - return r.json() - except httpx.HTTPError as exc: - raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}") - - -@router.get("/voice/reference") -def voice_get_reference() -> dict: - try: - r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) - r.raise_for_status() - return r.json() - except Exception as exc: # noqa: BLE001 - return {"active": False, "error": str(exc)} - - -@router.delete("/voice/reference") -def voice_clear_reference() -> dict: - try: - r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) - r.raise_for_status() - return r.json() - except httpx.HTTPError as exc: - raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}") - - -@router.post("/voice/tts") -async def voice_tts(body: TTSIn) -> Response: - """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes.""" - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - with Timer("tts"): - r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) - r.raise_for_status() - return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav")) - except httpx.HTTPError as exc: - raise HTTPException(502, f"TTS fehlgeschlagen: {exc}") - - -@router.post("/voice/chat") -async def voice_chat(body: ChatIn) -> StreamingResponse: - """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. - - Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht. - Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" - if not HERMES_API_KEY: - raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") - - headers = { - "Authorization": f"Bearer {HERMES_API_KEY}", - "X-Hermes-Session-Id": body.session_id, - } - if body.session_key: - headers["X-Hermes-Session-Key"] = body.session_key - - async def gen(): - # Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0 - # (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger. - trace = TurnTrace(session_id=body.session_id, kind="voice") - first = True - first_content = True - committed = False - - def _commit() -> None: - nonlocal committed - if not committed: - committed = True - trace.commit() - - try: - # 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] - trace.had_images = bool(imgs) - if imgs: - yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' - _tv = time.perf_counter() - desc = await _describe_images(imgs, body.text) - trace.note_vision((time.perf_counter() - _tv) * 1000.0) - 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}) - trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen) - 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: - async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: - async with client.stream( - "POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, - ) as r: - if r.status_code != 200: - detail = (await r.aread()).decode("utf-8", "replace")[:500] - trace.error = f"Hermes {r.status_code}" - yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() - return - async for chunk in r.aiter_raw(): - if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead) - trace.note_ttfb() - first = False - # Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) — - # chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind. - if first_content and b'"content"' in chunk: - trace.note_first_content() - first_content = False - yield chunk - except httpx.HTTPError as exc: - trace.error = "verbindung" - yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() - finally: - _commit() # Turn immer verbuchen (auch bei Fehler/Abbruch) - - return StreamingResponse(gen(), media_type="text/event-stream") +""" +Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar). + +Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den +Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools + +geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den +Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht. + +LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. +""" + +import logging +import os +import time + +import httpx +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import Response, StreamingResponse +from pydantic import BaseModel + +from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL +from services import announce +from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern) + Timer, + TurnTrace, + get_metrics, + get_trace, + park, + record_stage, +) + +# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv). +import sys as _sys +_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp") +if _GUARD_DIR not in _sys.path: + _sys.path.insert(0, _GUARD_DIR) +try: + from guard import wrap_untrusted +except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen + def wrap_untrusted(text: str, label: str = "") -> str: + return text + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/api") + +# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung +# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell +# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar. +VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") +# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2). +VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280")) + + +async def _describe_images(image_urls: list[str], hint: str) -> str: + """Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch). + Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler.""" + multi = len(image_urls) > 1 + intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens " + "5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). " + "Keine Einleitung, keine Wiederholung der Frage. " + if multi else + "Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot " + "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ") + content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}] + for u in image_urls: + content.append({"type": "image_url", "image_url": {"url": u}}) + try: + # 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={ + "model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False, + "messages": [{"role": "user", "content": content}], + }) + r.raise_for_status() + return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() + except Exception as exc: + log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) + return "" + +_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern + + +class TTSIn(BaseModel): + text: str + engine: str = "piper" + voice: str = "" + language: str = "" + ref_path: str = "" + + +class ChatIn(BaseModel): + text: str # die neue User-Äußerung (STT-Ergebnis) + session_id: str # stabiler Voice-Faden → server-seitiger Transcript + session_key: str = "" # optional: Langzeit-Memory-Scope + system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") + model: str = "" + images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen") + + +class AnnounceIn(BaseModel): + text: str # die Meldung (wird von Lucy gesprochen) + subject: str = "" # kurze Betreffzeile (z.B. "[Update]") + source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel + priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen + + +class AlarmIn(BaseModel): + text: str # die Alarm-Meldung + subject: str = "[Alarm]" # Betreff (Telegram-Präfix) + source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog") + + +@router.post("/alarm") +def alarm(body: AlarmIn) -> dict: + """Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den + Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran + der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten + nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only + wie alle MC2-Endpoints.""" + text = (body.text or "").strip() + if not text: + raise HTTPException(400, "Leere Meldung.") + subject = (body.subject or "[Alarm]").strip() + try: + item = announce.add(text, subject, body.source or "alarm", "normal") + except ValueError as exc: + raise HTTPException(400, str(exc)) + announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op) + return {"ok": True, "item": item} + + +@router.post("/voice/announce") +def voice_announce(body: AnnounceIn) -> dict: + """Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter, + notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints.""" + try: + return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)} + except ValueError as exc: + raise HTTPException(400, str(exc)) + + +@router.get("/voice/announcements") +def voice_announcements(after: int | None = None, limit: int = 20) -> dict: + """Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den + aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach.""" + return announce.list_after(after, limit) + + +@router.get("/voice/health") +def voice_health() -> dict: + """Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist.""" + out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)} + try: + r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0)) + out["sidecar"] = r.status_code == 200 + out["detail"] = r.json() if r.status_code == 200 else None + except Exception as exc: # noqa: BLE001 + out["error"] = str(exc) + return out + + +@router.get("/voice/metrics") +def voice_metrics() -> dict: + """Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh.""" + return get_metrics() + + +@router.get("/voice/trace") +def voice_trace(limit: int = 20) -> dict: + """Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn · + Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit — + damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt.""" + return {"turns": get_trace(limit)} + + +@router.get("/voice/voices") +def voice_voices() -> dict: + try: + r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0)) + r.raise_for_status() + return r.json() + except Exception as exc: # noqa: BLE001 + raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}") + + +@router.post("/voice/stt") +async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: + """Mikro-Audio → Text (Proxy auf Sidecar /stt).""" + data = await audio.read() + if not data: + raise HTTPException(400, "Leeres Audio.") + files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + _t0 = time.perf_counter() + r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) + _ms = (time.perf_counter() - _t0) * 1000.0 + record_stage("stt", _ms) + park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein + r.raise_for_status() + return r.json() + except httpx.HTTPError as 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") +async def voice_set_reference(audio: UploadFile = File(...)) -> dict: + """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" + data = await audio.read() + if not data: + raise HTTPException(400, "Leeres Audio.") + files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files) + r.raise_for_status() + return r.json() + except httpx.HTTPError as exc: + raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}") + + +@router.get("/voice/reference") +def voice_get_reference() -> dict: + try: + r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) + r.raise_for_status() + return r.json() + except Exception as exc: # noqa: BLE001 + return {"active": False, "error": str(exc)} + + +@router.delete("/voice/reference") +def voice_clear_reference() -> dict: + try: + r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) + r.raise_for_status() + return r.json() + except httpx.HTTPError as exc: + raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}") + + +@router.post("/voice/tts") +async def voice_tts(body: TTSIn) -> Response: + """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes.""" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + with Timer("tts"): + r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) + r.raise_for_status() + return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav")) + except httpx.HTTPError as exc: + raise HTTPException(502, f"TTS fehlgeschlagen: {exc}") + + +@router.post("/voice/chat") +async def voice_chat(body: ChatIn) -> StreamingResponse: + """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. + + Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht. + Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" + if not HERMES_API_KEY: + raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") + + headers = { + "Authorization": f"Bearer {HERMES_API_KEY}", + "X-Hermes-Session-Id": body.session_id, + } + if body.session_key: + headers["X-Hermes-Session-Key"] = body.session_key + + async def gen(): + # Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0 + # (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger. + trace = TurnTrace(session_id=body.session_id, kind="voice") + first = True + first_content = True + committed = False + + def _commit() -> None: + nonlocal committed + if not committed: + committed = True + trace.commit() + + try: + # 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] + trace.had_images = bool(imgs) + if imgs: + yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n' + _tv = time.perf_counter() + desc = await _describe_images(imgs, body.text) + trace.note_vision((time.perf_counter() - _tv) * 1000.0) + 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}) + trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen) + 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: + async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: + async with client.stream( + "POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, + ) as r: + if r.status_code != 200: + detail = (await r.aread()).decode("utf-8", "replace")[:500] + trace.error = f"Hermes {r.status_code}" + yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() + return + async for chunk in r.aiter_raw(): + if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead) + trace.note_ttfb() + first = False + # Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) — + # chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind. + if first_content and b'"content"' in chunk: + trace.note_first_content() + first_content = False + yield chunk + except httpx.HTTPError as exc: + trace.error = "verbindung" + yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() + finally: + _commit() # Turn immer verbuchen (auch bei Fehler/Abbruch) + + return StreamingResponse(gen(), media_type="text/event-stream") diff --git a/backend/services/agent.py b/backend/services/agent.py index cce55b8..a042ea3 100644 --- a/backend/services/agent.py +++ b/backend/services/agent.py @@ -1,264 +1,264 @@ -""" -Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur -Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in -Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md). -""" - -import logging -import os -import re - -import httpx -import psutil - -from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM, - BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH, - HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL) - -log = logging.getLogger(__name__) - - -def _hermes_version(name: str) -> float | None: - """Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0.""" - low = (name or "").lower() - if "hermes" not in low: - return None - m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low) - return float(m.group(1)) if m else None - - -def _active_brain_name() -> str: - """Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model).""" - try: - from ruamel.yaml import YAML - p = HERMES_HOME / "config.yaml" - if p.exists(): - with p.open(encoding="utf-8") as f: - cfg = YAML().load(f) or {} - m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {} - return str(m.get("default") or m.get("model") or "auto") - except Exception: - log.debug("_active_brain_name: Lesefehler", exc_info=True) - return "auto" - - -def hermes_brain_info() -> dict: - """Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default), - plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle.""" - from services import llamaswap - - models = llamaswap.list_models() - brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname - bl = brain.lower() - cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \ - or next((m for m in models if bl in (m["name"] or "").lower()), None) - cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None - current = None - if cur: - current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"), - "params_b": cur_params, "quant": cur.get("quant"), - "size_bytes": cur.get("size_bytes"), - "gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")} - - # Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget? - budget = None - try: - from services.budget import footprint_gb, gtt_budget_gb - groups = llamaswap.list_groups() - persist = set() - for g in groups.values(): - if isinstance(g, dict) and (g.get("persist") or g.get("persistent")): - persist.update(g.get("members") or []) - - cur_name = cur["name"] if cur else None - brain_gb = footprint_gb(cur) if cur else 0.0 - # voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info - warm = brain_gb + sum(footprint_gb(m) for m in models - if m["name"] in persist and m["name"] != cur_name) - largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0) - gtt = gtt_budget_gb() - # Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision) - # resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset + - # größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen. - budget = { - "gtt_gb": gtt, - "brain_gb": round(brain_gb, 1), - "warm_projected_gb": round(warm, 1), - "largest_ondemand_gb": round(largest_od, 1), - "fits": (warm + largest_od) <= gtt, - "free_after_gb": round(gtt - warm - largest_od, 1), - } - except Exception: - log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True) - - return {"current": current, "recommended": None, "update_available": False, "budget": budget} - - -def _reach(url: str, path: str = "") -> bool: - try: - with httpx.Client(timeout=3.0) as c: - return c.get(f"{url}{path}").status_code < 500 - except httpx.HTTPError: - return False - - -def _count_enabled_mcp_servers() -> int: - config_path = HERMES_HOME / "config.yaml" - if not config_path.exists(): - return 0 - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("r", encoding="utf-8") as f: - cfg = r_yaml.load(f) or {} - mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {} - if not isinstance(mcp_servers, dict): - return 0 - return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True)) - except Exception: - log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True) - return 0 - - -def agent_status() -> dict: - """Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise.""" - home = HERMES_HOME - brain_model = "auto" - config_path = home / "config.yaml" - if config_path.exists(): - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("r", encoding="utf-8") as f: - cfg = r_yaml.load(f) or {} - if isinstance(cfg, dict): - # Hermes nutzt model.default als aktives Modell (model.model = Provider-Param). - m = cfg.get("model", {}) or {} - brain_model = m.get("default") or m.get("model") or "auto" - except Exception: - log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True) - - - return { - "gateway_url": HERMES_API_URL, - # Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt. - "terminal_url": HERMES_TERMINAL_PATH, - # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/). - "box_console_url": BOX_CONSOLE_PATH, - # Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/). - "hermes_ui_url": HERMES_BUILTIN_UI_PATH, - "gateway_reachable": _reach(HERMES_API_URL, "/health"), - # Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path). - "terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"), - "box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"), - # Erreichbarkeit der eingebauten Hermes-GUI (Loopback :9119). - "hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"), - "home_exists": home.exists(), - "brain_model": brain_model, - # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). - "has_config": (home / "config.yaml").exists() or (home / "config.json").exists(), - "has_skills": (home / "skills").exists(), - "has_memories": (home / "memories").exists(), - # Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit. - "telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")), - "mcp_server_count": _count_enabled_mcp_servers(), - "pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"), - } - - -def set_agent_brain(model_id: str) -> dict: - """Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst: - 1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot), - 2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben), - 3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart. - So bleibt das neue Hirn warm und der Agent nutzt es sofort.""" - from services import llamaswap - models = {m["name"]: m for m in llamaswap.list_models()} - if model_id not in models: - return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."} - old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None) - if model_id == old: - # Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen. - try: - from services.llamaswap import set_ttl - brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] - if model_id not in brains: - llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True) - set_ttl(model_id, 0) - except PermissionError as exc: - return {"ok": False, "reason": str(exc)} - return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"} - try: - llamaswap.set_role(model_id, "hermes") # 1) Alias - brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] - new_members = [x for x in brains if x not in (old, model_id)] + [model_id] - llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm - # 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen. - from services.llamaswap import set_ttl, DEFAULT_TTL - set_ttl(model_id, 0) - if old: - set_ttl(old, DEFAULT_TTL) - except PermissionError as exc: - return {"ok": False, "reason": str(exc)} - update_brain_model("hermes") # 3) Config + Restart - # Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT? - warning = None - try: - b = hermes_brain_info().get("budget") or {} - if b and not b.get("fits", True): - warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-" - f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget " - f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden " - f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).") - except Exception: - log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True) - return {"ok": True, "old": old, "new": model_id, "warning": warning} - - -def update_brain_model(new_model: str) -> bool: - from config import HERMES_HOME - home = HERMES_HOME - config_path = home / "config.yaml" - - # Ensure home directory exists - home.mkdir(parents=True, exist_ok=True) - - cfg = {} - if config_path.exists(): - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("r", encoding="utf-8") as f: - cfg = r_yaml.load(f) or {} - except Exception: - log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True) - cfg = {} - - if not isinstance(cfg, dict): - cfg = {} - - if "model" not in cfg or not isinstance(cfg["model"], dict): - cfg["model"] = {} - - # Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. - # Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt). - cfg["model"]["default"] = new_model - cfg["model"]["model"] = new_model - - try: - from ruamel.yaml import YAML - r_yaml = YAML() - with config_path.open("w", encoding="utf-8") as f: - r_yaml.dump(cfg, f) - - # Restart the user-space service to apply changes - try: - import services.maintenance as maintenance - maintenance.restart_service("hermes-gateway") - except Exception: - log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) - - return True - except Exception: - log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True) - return False +""" +Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur +Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in +Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md). +""" + +import logging +import os +import re + +import httpx +import psutil + +from config import (HERMES_TERMINAL_UPSTREAM, HERMES_TERMINAL_PATH, BOX_CONSOLE_UPSTREAM, + BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH, + HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL) + +log = logging.getLogger(__name__) + + +def _hermes_version(name: str) -> float | None: + """Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0.""" + low = (name or "").lower() + if "hermes" not in low: + return None + m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low) + return float(m.group(1)) if m else None + + +def _active_brain_name() -> str: + """Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model).""" + try: + from ruamel.yaml import YAML + p = HERMES_HOME / "config.yaml" + if p.exists(): + with p.open(encoding="utf-8") as f: + cfg = YAML().load(f) or {} + m = (cfg.get("model") or {}) if isinstance(cfg, dict) else {} + return str(m.get("default") or m.get("model") or "auto") + except Exception: + log.debug("_active_brain_name: Lesefehler", exc_info=True) + return "auto" + + +def hermes_brain_info() -> dict: + """Aktuelles Agent-Hirn = Modell/Alias, das Hermes laut Config nutzt (model.default), + plus Budget-Check. Zeigt das REAL genutzte Hirn — unabhängig von einer 'hermes'-Rolle.""" + from services import llamaswap + + models = llamaswap.list_models() + brain = _active_brain_name() # z.B. "fast" (Alias) oder ein Modellname + bl = brain.lower() + cur = next((m for m in models if (m.get("role") or "").lower() == bl), None) \ + or next((m for m in models if bl in (m["name"] or "").lower()), None) + cur_params = (cur.get("capabilities") or {}).get("params_b") if cur else None + current = None + if cur: + current = {"name": cur["name"], "alias": brain, "filename": cur.get("filename"), + "params_b": cur_params, "quant": cur.get("quant"), + "size_bytes": cur.get("size_bytes"), + "gguf_path": cur.get("gguf_path"), "incomplete": cur.get("incomplete")} + + # Fit-Check: passt das (immer warme) Hirn + das größte on-demand-Modell zusammen ins Budget? + budget = None + try: + from services.budget import footprint_gb, gtt_budget_gb + groups = llamaswap.list_groups() + persist = set() + for g in groups.values(): + if isinstance(g, dict) and (g.get("persist") or g.get("persistent")): + persist.update(g.get("members") or []) + + cur_name = cur["name"] if cur else None + brain_gb = footprint_gb(cur) if cur else 0.0 + # voller Always-Warm-Footprint (alle persist, Brain=Empfehlung) — nur Info + warm = brain_gb + sum(footprint_gb(m) for m in models + if m["name"] in persist and m["name"] != cur_name) + largest_od = max((footprint_gb(m) for m in models if m["name"] not in persist), default=0.0) + gtt = gtt_budget_gb() + # Seit dem `persistent`-Fix (03.07.) bleibt das GANZE Warmset (Hirn+embed+vision) + # resident, wenn ein on-demand-Modell DANEBEN lädt → der reale Peak ist Warmset + + # größtes on-demand, nicht nur Hirn + größtes. Genau daran wird `fits` gemessen. + budget = { + "gtt_gb": gtt, + "brain_gb": round(brain_gb, 1), + "warm_projected_gb": round(warm, 1), + "largest_ondemand_gb": round(largest_od, 1), + "fits": (warm + largest_od) <= gtt, + "free_after_gb": round(gtt - warm - largest_od, 1), + } + except Exception: + log.debug("hermes_brain_info: Budget-Berechnung fehlgeschlagen", exc_info=True) + + return {"current": current, "recommended": None, "update_available": False, "budget": budget} + + +def _reach(url: str, path: str = "") -> bool: + try: + with httpx.Client(timeout=3.0) as c: + return c.get(f"{url}{path}").status_code < 500 + except httpx.HTTPError: + return False + + +def _count_enabled_mcp_servers() -> int: + config_path = HERMES_HOME / "config.yaml" + if not config_path.exists(): + return 0 + try: + from ruamel.yaml import YAML + r_yaml = YAML() + with config_path.open("r", encoding="utf-8") as f: + cfg = r_yaml.load(f) or {} + mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {} + if not isinstance(mcp_servers, dict): + return 0 + return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True)) + except Exception: + log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True) + return 0 + + +def agent_status() -> dict: + """Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise.""" + home = HERMES_HOME + brain_model = "auto" + config_path = home / "config.yaml" + if config_path.exists(): + try: + from ruamel.yaml import YAML + r_yaml = YAML() + with config_path.open("r", encoding="utf-8") as f: + cfg = r_yaml.load(f) or {} + if isinstance(cfg, dict): + # Hermes nutzt model.default als aktives Modell (model.model = Provider-Param). + m = cfg.get("model", {}) or {} + brain_model = m.get("default") or m.get("model") or "auto" + except Exception: + log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True) + + + return { + "gateway_url": HERMES_API_URL, + # Interaktives Web-Terminal (ttyd → `hermes chat`): same-origin über MC2 geproxyt. + "terminal_url": HERMES_TERMINAL_PATH, + # Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/). + "box_console_url": BOX_CONSOLE_PATH, + # Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/). + "hermes_ui_url": HERMES_BUILTIN_UI_PATH, + "gateway_reachable": _reach(HERMES_API_URL, "/health"), + # Erreichbarkeit der lokalen ttyd-Upstreams (Loopback, je base-path). + "terminal_reachable": _reach(HERMES_TERMINAL_UPSTREAM, "/hermes-terminal/"), + "box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"), + # Erreichbarkeit der eingebauten Hermes-GUI (Loopback :9119). + "hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"), + "home_exists": home.exists(), + "brain_model": brain_model, + # Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig). + "has_config": (home / "config.yaml").exists() or (home / "config.json").exists(), + "has_skills": (home / "skills").exists(), + "has_memories": (home / "memories").exists(), + # Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit. + "telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")), + "mcp_server_count": _count_enabled_mcp_servers(), + "pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"), + } + + +def set_agent_brain(model_id: str) -> dict: + """Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst: + 1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot), + 2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben), + 3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart. + So bleibt das neue Hirn warm und der Agent nutzt es sofort.""" + from services import llamaswap + models = {m["name"]: m for m in llamaswap.list_models()} + if model_id not in models: + return {"ok": False, "reason": "Modell nicht installiert — erst über Modelle-finden laden."} + old = next((m["name"] for m in models.values() if m.get("role") == "hermes"), None) + if model_id == old: + # Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen. + try: + from services.llamaswap import set_ttl + brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] + if model_id not in brains: + llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True) + set_ttl(model_id, 0) + except PermissionError as exc: + return {"ok": False, "reason": str(exc)} + return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"} + try: + llamaswap.set_role(model_id, "hermes") # 1) Alias + brains = (llamaswap.list_groups().get("brains") or {}).get("members") or [] + new_members = [x for x in brains if x not in (old, model_id)] + [model_id] + llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm + # 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen. + from services.llamaswap import set_ttl, DEFAULT_TTL + set_ttl(model_id, 0) + if old: + set_ttl(old, DEFAULT_TTL) + except PermissionError as exc: + return {"ok": False, "reason": str(exc)} + update_brain_model("hermes") # 3) Config + Restart + # Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT? + warning = None + try: + b = hermes_brain_info().get("budget") or {} + if b and not b.get("fits", True): + warning = (f"Speicher-Warnung: Hirn (~{b.get('brain_gb')} GB) + größtes on-demand-" + f"Modell (~{b.get('largest_ondemand_gb')} GB) übersteigen das GTT-Budget " + f"(~{b.get('gtt_gb')} GB) — das Hirn ist persistent, heavy/coder laden " + f"DANEBEN: Überlauf droht (Lade-Crash/Swapping statt Verdrängung).") + except Exception: + log.debug("set_agent_brain: Budget-Check fehlgeschlagen", exc_info=True) + return {"ok": True, "old": old, "new": model_id, "warning": warning} + + +def update_brain_model(new_model: str) -> bool: + from config import HERMES_HOME + home = HERMES_HOME + config_path = home / "config.yaml" + + # Ensure home directory exists + home.mkdir(parents=True, exist_ok=True) + + cfg = {} + if config_path.exists(): + try: + from ruamel.yaml import YAML + r_yaml = YAML() + with config_path.open("r", encoding="utf-8") as f: + cfg = r_yaml.load(f) or {} + except Exception: + log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True) + cfg = {} + + if not isinstance(cfg, dict): + cfg = {} + + if "model" not in cfg or not isinstance(cfg["model"], dict): + cfg["model"] = {} + + # Hermes liest model.default als aktives Modell; model.model ist der Provider-Param. + # Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt). + cfg["model"]["default"] = new_model + cfg["model"]["model"] = new_model + + try: + from ruamel.yaml import YAML + r_yaml = YAML() + with config_path.open("w", encoding="utf-8") as f: + r_yaml.dump(cfg, f) + + # Restart the user-space service to apply changes + try: + import services.maintenance as maintenance + maintenance.restart_service("hermes-gateway") + except Exception: + log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True) + + return True + except Exception: + log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True) + return False diff --git a/backend/services/budget.py b/backend/services/budget.py index fbdc2d7..4084e04 100644 --- a/backend/services/budget.py +++ b/backend/services/budget.py @@ -1,205 +1,205 @@ -""" -Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit. - -Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB): - • Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident — - seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist` - in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last - verdrängte damals die ganze Gruppe). - • Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss - deren Footprint mit einplanen. -Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss — -und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein). - -Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine -ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides. -""" - -import re - -import psutil - -from services.fit import ( - QUANT_BYTES_PER_PARAM, - estimate_memory_gb, - extract_params_b, - max_ctx_in_budget, -) - -HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung - - -def gtt_budget_gb() -> float: - """GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest - amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve.""" - try: - with open("/proc/cmdline") as f: - m = re.search(r"amdgpu\.gttsize=(\d+)", f.read()) - if m: - return round(int(m.group(1)) / 1024.0, 1) - except Exception: - pass - return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1) - - -def params_of_model(model: dict) -> float: - """Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und - Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs - (size_bytes = nur erster Teil → ignoriert) ab.""" - caps = model.get("capabilities") or {} - quant = model.get("quant") or "Q4_K_M" - bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) - size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) - pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 - return max(float(caps.get("params_b") or 0), pb_size, 7.0) - - -_CTK_RE = re.compile(r"(?:--cache-type-k|(? tuple[str | None, str | None]: - """K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None).""" - ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None - cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None - return ck, cv - - -def _real_kv_gb(model: dict, ctx: int) -> float | None: - """ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads × - Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist - → Aufrufer fällt auf die params-basierte Heuristik zurück.""" - from services import gguf_meta - path = model.get("gguf_path") - if not path: - return None - meta = gguf_meta.arch_meta(path) - if not meta: - return None - ck, cv = _cache_types(model.get("cmd") or "") - return gguf_meta.kv_cache_gb(meta, ctx, ck, cv) - - -def footprint_gb(model: dict) -> float: - """Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx). - KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) — - entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte - grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback.""" - quant = model.get("quant") or "Q4_K_M" - ctx = int(model.get("ctx") or 32768) - bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) - size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) - pb = params_of_model(model) - weights = max(pb * bpp, size_gb) - kv = _real_kv_gb(model, ctx) - if kv is None: - kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp - return weights + max(kv, 0.0) - - -def params_b_for(name: str) -> float: - """Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst, - sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe.""" - from services import catalog - meta = catalog.meta_for_name(name) if name else None - if meta and meta.get("total_params_b"): - return float(meta["total_params_b"]) - return extract_params_b(name) - - -def _coresident_members(groups: dict) -> set: - """Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen - (Ko-Residenz, z.B. brains = Hirn+embed+vision). Seit dem `persistent`-Fix (03.07.2026) - überlebt die Gruppe auch on-demand-Last: Coder/heavy laden DANEBEN, nicht an ihre - Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere - Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key.""" - out: set = set() - for g in (groups or {}).values(): - if isinstance(g, dict) and g.get("swap") is False: - out.update(g.get("members") or []) - return out - - -def reserved_gb(role: str | None) -> dict: - """Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der - seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente - `swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben. - - - Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN - Gruppen-Mitgliedern → reserviert deren Summe. - - Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe → - reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die - Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte). - """ - from services import llamaswap - models = llamaswap.list_models() - groups = llamaswap.list_groups() - cores = _coresident_members(groups) - brain = next((m for m in models if (m.get("role") == "hermes")), None) - brain_gb = footprint_gb(brain) if brain else 0.0 - role = (role or "").strip().lower() - - holder = next((m for m in models if (m.get("role") == role)), None) if role else None - holder_name = holder["name"] if holder else None - # Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen. - in_group = role == "hermes" or bool(holder_name and holder_name in cores) - - if in_group: - others = sum(footprint_gb(m) for m in models - if m["name"] in cores and m["name"] != holder_name) - return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb} - # on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren. - warm = sum(footprint_gb(m) for m in models if m["name"] in cores) - return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb} - - -def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict: - """Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem - bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz).""" - gtt = gtt_budget_gb() - r = reserved_gb(role) - budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) - ctx = max_ctx_in_budget(params_b, quant, budget) - return { - "ctx": ctx, - "gtt_gb": gtt, - "reserved_gb": round(r["reserved_gb"], 1), - "budget_gb": round(budget, 1), - "mode": r["mode"], - } - - -def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int: - """Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt).""" - from services.fit import _NICE_CTX - if cap: - raw_ctx = min(raw_ctx, cap) - best = _NICE_CTX[0] - for c in _NICE_CTX: - if c <= raw_ctx: - best = c - return best - - -def setup_aware_ctx_for_model(model: dict) -> dict: - """Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der - Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) — - Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist.""" - from services import gguf_meta - quant = model.get("quant") or "Q4_K_M" - path = model.get("gguf_path") - meta = gguf_meta.arch_meta(path) if path else None - if not meta: - return setup_aware_ctx(params_of_model(model), quant, role=model.get("role")) - - gtt = gtt_budget_gb() - r = reserved_gb(model.get("role")) - budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) - bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) - size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) - weights = max(params_of_model(model) * bpp, size_gb) - ck, cv = _cache_types(model.get("cmd") or "") - per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv) - ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048 - return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1), - "budget_gb": round(budget, 1), "mode": r["mode"]} +""" +Speicher-Budget & SETUP-BEWUSSTE ctx-Vergabe — EINE Quelle der Wahrheit. + +Modelliert die auf der Box VERIFIZIERTE Residenz-Realität (llama-swap, GTT ~124 GB): + • Die `persistent`-Gruppe (brains = Hirn+embed+vision) bleibt IMMER resident — + seit dem Key-Fix 03.07.2026 greift der Schutz wirklich (vorher stand `persist` + in der Config, das llama-swap stillschweigend ignorierte; on-demand-Last + verdrängte damals die ganze Gruppe). + • Ein on-demand-Modell (heavy/coder/…) lädt NEBEN die brains-Gruppe und muss + deren Footprint mit einplanen. +Daraus folgt, wie viel Speicher NEBEN einem Zielmodell reserviert bleiben muss — +und damit der größte Kontext, der wirklich passt (nicht nur für das Modell allein). + +Vorher rechnete nur der Hirn-Wechsel (agent.py) setup-bewusst; die allgemeine +ctx-Vergabe nahm den Gesamt-RAM in Isolation. Dieses Modul vereint beides. +""" + +import re + +import psutil + +from services.fit import ( + QUANT_BYTES_PER_PARAM, + estimate_memory_gb, + extract_params_b, + max_ctx_in_budget, +) + +HEADROOM_GB = 4.0 # OS/Treiber/Fragmentierung + + +def gtt_budget_gb() -> float: + """GPU-adressierbarer Speicher (GTT) in GB — die harte Obergrenze. Liest + amdgpu.gttsize aus /proc/cmdline, sonst RAM minus OS-Reserve.""" + try: + with open("/proc/cmdline") as f: + m = re.search(r"amdgpu\.gttsize=(\d+)", f.read()) + if m: + return round(int(m.group(1)) / 1024.0, 1) + except Exception: + pass + return round(psutil.virtual_memory().total / (1024 ** 3) - 6.0, 1) + + +def params_of_model(model: dict) -> float: + """Robuste Params (Mrd.) eines INSTALLIERTEN Modells: MAXIMUM aus Caps-Schätzung und + Dateigröße. Deckt 'Coder-Next' ohne Größe im Namen (→ aus Datei) und Split-GGUFs + (size_bytes = nur erster Teil → ignoriert) ab.""" + caps = model.get("capabilities") or {} + quant = model.get("quant") or "Q4_K_M" + bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) + size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) + pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 + return max(float(caps.get("params_b") or 0), pb_size, 7.0) + + +_CTK_RE = re.compile(r"(?:--cache-type-k|(? tuple[str | None, str | None]: + """K/V-Cache-Quantisierung aus dem llama-server-Cmd (Default f16 → None).""" + ck = m.group(1) if (m := _CTK_RE.search(cmd or "")) else None + cv = m.group(1) if (m := _CTV_RE.search(cmd or "")) else None + return ck, cv + + +def _real_kv_gb(model: dict, ctx: int) -> float | None: + """ECHTE KV-Cache-Größe (GiB) aus den GGUF-Architektur-Metadaten (Layer × KV-Heads × + Head-Dim) + der cache-type-Quantisierung des Cmds. None, wenn das GGUF nicht lesbar ist + → Aufrufer fällt auf die params-basierte Heuristik zurück.""" + from services import gguf_meta + path = model.get("gguf_path") + if not path: + return None + meta = gguf_meta.arch_meta(path) + if not meta: + return None + ck, cv = _cache_types(model.get("cmd") or "") + return gguf_meta.kv_cache_gb(meta, ctx, ck, cv) + + +def footprint_gb(model: dict) -> float: + """Loaded-Footprint eines Modells = Gewichte + KV-Cache (bei seinem aktuellen ctx). + KV kommt aus den ECHTEN Architektur-Metadaten des GGUF (nicht mehr params-geschätzt) — + entscheidend bei MoE (A3B): die alte Schätzung hing an den Gesamt-Params und überschätzte + grob (z.B. „68 GB reserviert" statt real ~25 GB). Heuristik bleibt Fallback.""" + quant = model.get("quant") or "Q4_K_M" + ctx = int(model.get("ctx") or 32768) + bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) + size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) + pb = params_of_model(model) + weights = max(pb * bpp, size_gb) + kv = _real_kv_gb(model, ctx) + if kv is None: + kv = estimate_memory_gb(pb, quant, ctx) - pb * bpp + return weights + max(kv, 0.0) + + +def params_b_for(name: str) -> float: + """Parameter (Mrd.) für einen Modell-/Repo-Namen: KATALOG (echte Metadaten) zuerst, + sonst Namens-Schätzung. Gemeinsam für Fit-Vorschau und ctx-Vergabe.""" + from services import catalog + meta = catalog.meta_for_name(name) if name else None + if meta and meta.get("total_params_b"): + return float(meta["total_params_b"]) + return extract_params_b(name) + + +def _coresident_members(groups: dict) -> set: + """Modelle, die GLEICHZEITIG warm sind: Mitglieder aller `swap:false`-Gruppen + (Ko-Residenz, z.B. brains = Hirn+embed+vision). Seit dem `persistent`-Fix (03.07.2026) + überlebt die Gruppe auch on-demand-Last: Coder/heavy laden DANEBEN, nicht an ihre + Stelle (live verifiziert: Coder + Qwen3.6 gleichzeitig `ready`). Die frühere + Beobachtung „heavy verdrängt die Gruppe" war der ignorierte `persist`-Key.""" + out: set = set() + for g in (groups or {}).values(): + if isinstance(g, dict) and g.get("swap") is False: + out.update(g.get("members") or []) + return out + + +def reserved_gb(role: str | None) -> dict: + """Speicher, der NEBEN einem Zielmodell der gegebenen Rolle resident bleibt — gemäß der + seit dem `persistent`-Fix (03.07.2026) geltenden Semantik: die ko-residente + `swap:false`-Gruppe (brains) bleibt IMMER geladen, on-demand-Modelle laden daneben. + + - Modell IN der Ko-Residenz-Gruppe (Hirn/embed/vision): koexistiert mit den ÜBRIGEN + Gruppen-Mitgliedern → reserviert deren Summe. + - Modell AUSSERHALB (heavy/coder/coder-lite/scout): lädt NEBEN die Gruppe → + reserviert deren GESAMTE Summe (früher 0.0, weil der kaputte `persist`-Key die + Gruppe verdrängen ließ — diese Rechnung erlaubte zu große Kontexte). + """ + from services import llamaswap + models = llamaswap.list_models() + groups = llamaswap.list_groups() + cores = _coresident_members(groups) + brain = next((m for m in models if (m.get("role") == "hermes")), None) + brain_gb = footprint_gb(brain) if brain else 0.0 + role = (role or "").strip().lower() + + holder = next((m for m in models if (m.get("role") == role)), None) if role else None + holder_name = holder["name"] if holder else None + # Hirn (hermes) ist per Definition Teil der Ko-Residenz-Gruppe; sonst Gruppen-Mitgliedschaft prüfen. + in_group = role == "hermes" or bool(holder_name and holder_name in cores) + + if in_group: + others = sum(footprint_gb(m) for m in models + if m["name"] in cores and m["name"] != holder_name) + return {"reserved_gb": others, "mode": "co-resident", "brain_gb": brain_gb} + # on-demand: lädt neben die (persistente) Ko-Residenz-Gruppe → deren Summe reservieren. + warm = sum(footprint_gb(m) for m in models if m["name"] in cores) + return {"reserved_gb": warm, "mode": "ondemand-beside-warmset", "brain_gb": brain_gb} + + +def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dict: + """Größter Kontext, der für ein Modell (params_b/quant) der gegebenen Rolle NEBEN dem + bestehenden Setup passt. Gibt ctx + die Budget-Herleitung zurück (für UI/Transparenz).""" + gtt = gtt_budget_gb() + r = reserved_gb(role) + budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) + ctx = max_ctx_in_budget(params_b, quant, budget) + return { + "ctx": ctx, + "gtt_gb": gtt, + "reserved_gb": round(r["reserved_gb"], 1), + "budget_gb": round(budget, 1), + "mode": r["mode"], + } + + +def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int: + """Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt).""" + from services.fit import _NICE_CTX + if cap: + raw_ctx = min(raw_ctx, cap) + best = _NICE_CTX[0] + for c in _NICE_CTX: + if c <= raw_ctx: + best = c + return best + + +def setup_aware_ctx_for_model(model: dict) -> dict: + """Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der + Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) — + Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist.""" + from services import gguf_meta + quant = model.get("quant") or "Q4_K_M" + path = model.get("gguf_path") + meta = gguf_meta.arch_meta(path) if path else None + if not meta: + return setup_aware_ctx(params_of_model(model), quant, role=model.get("role")) + + gtt = gtt_budget_gb() + r = reserved_gb(model.get("role")) + budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0) + bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55) + size_gb = (model.get("size_bytes") or 0) / (1024 ** 3) + weights = max(params_of_model(model) * bpp, size_gb) + ck, cv = _cache_types(model.get("cmd") or "") + per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv) + ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048 + return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1), + "budget_gb": round(budget, 1), "mode": r["mode"]} diff --git a/backend/services/discover.py b/backend/services/discover.py index ad56131..0d4adf4 100644 --- a/backend/services/discover.py +++ b/backend/services/discover.py @@ -1,179 +1,179 @@ -""" -Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige -HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit -und cached. Portiert aus Mission Control v1 (cookbook.py-Discover). - -Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable` -ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch -spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen. -""" - -import json -import math -import os -import time -from datetime import datetime - -import httpx - -import logging - -from config import DISCOVER_CACHE_PATH, DISCOVER_TTL -from services import catalog -from services.caps import capabilities -from services.fit import evaluate_fit, extract_params_b, max_ctx_for -from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS - -log = logging.getLogger(__name__) - -_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} - - -def _categorize(repo_id: str) -> str: - low = repo_id.lower() - for cat in CATEGORIES: - if any(k in low for k in cat["kw"]): - return cat["role"] - return "scout" - - -def _fetch_author_models(author: str) -> list: - url = (f"https://huggingface.co/api/models?author={author}" - f"&filter=gguf&sort=downloads&direction=-1&limit=40") - try: - with httpx.Client(timeout=12.0) as c: - data = c.get(url).json() - return data if isinstance(data, list) else [] - except Exception: - log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True) - return [] - - -def _age_days(last_modified, now_ts: float) -> float: - """Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre.""" - if not last_modified: - return 540.0 - try: - dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00")) - return max((now_ts - dt.timestamp()) / 86400.0, 0.0) - except Exception: - return 540.0 - - -def _score(m: dict, now_ts: float) -> float: - """Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert: - - Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst), - - Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified), - - Capability: mehr Parameter (log-skaliert), - - Popularity: Downloads (log-skaliert). - So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor - Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen.""" - fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0 - recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0) - cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0 - pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0 - return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop - - -def rank_runnable(models: list[dict]) -> list[dict]: - """EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware. - Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability - + Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden" - schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance).""" - now_ts = time.time() - return sorted( - [m for m in models if m["fit"]["level"] != "too_tight"], - key=lambda m: -_score(m, now_ts), - ) - - -def refresh_discover(ram_gb: float) -> dict: - """Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE - Quelle erreichbar war.""" - raw, seen, ok = [], set(), 0 - for author in TRUSTED_AUTHORS: - models = _fetch_author_models(author) - if models: - ok += 1 - for m in models: - rid = m.get("id") - if not rid or rid in seen: - continue - seen.add(rid) - raw.append(m) - if ok == 0 and not catalog.entries(): - raise RuntimeError("Keine Quelle erreichbar.") - - by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES} - for m in raw: - rid = m["id"] - low = rid.lower() - if any(tok in low for tok in SKIP_TOKENS): - continue - role = _categorize(rid) - params_b = extract_params_b(rid) - quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung - fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid) - tags = [str(t) for t in (m.get("tags") or [])] - by_cat[role].append({ - "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid, - "role": role, "params_b": params_b, "quant": quant, "tags": tags, - "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0), - "lastModified": m.get("lastModified"), - "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), - "caps": capabilities(name=rid, hf={"tags": tags}), - }) - - cats = [] - for c in CATEGORIES: - role = c["role"] - # 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) — - # macht die Empfehlung präzise statt Namens-Raterei. - cat_entries = sorted(catalog.entries_for_role(role), - key=lambda e: -catalog.stack_score(e, ram_gb)) - cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries) - if m["fit"]["level"] != "too_tight"] - # 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert. - hf_ranked = rank_runnable(by_cat[role]) - seen = {catalog._norm(m["repo"]) for m in cat_models} - extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen] - combined = cat_models + extra - if combined: - cats.append({ - "role": role, "title": c["title"], "icon": c["icon"], - "models": combined[:6], - # Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle. - "recommended": (cat_models[0]["repo"] if cat_models - else (hf_ranked[0]["repo"] if hf_ranked else None)), - }) - - data = {"updated": time.time(), "categories": cats} - try: - DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp") - tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - os.replace(tmp, DISCOVER_CACHE_PATH) - except Exception: - log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True) - return data - - -def load_discover() -> dict | None: - try: - if DISCOVER_CACHE_PATH.exists(): - return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8")) - except Exception: - log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True) - return None - - -def safe_discover(ram_gb: float) -> dict | None: - """Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da.""" - cached = load_discover() - if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL): - return cached - try: - return refresh_discover(ram_gb) - except Exception: - log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True) - return cached +""" +Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige +HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit +und cached. Portiert aus Mission Control v1 (cookbook.py-Discover). + +Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable` +ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch +spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen. +""" + +import json +import math +import os +import time +from datetime import datetime + +import httpx + +import logging + +from config import DISCOVER_CACHE_PATH, DISCOVER_TTL +from services import catalog +from services.caps import capabilities +from services.fit import evaluate_fit, extract_params_b, max_ctx_for +from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS + +log = logging.getLogger(__name__) + +_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2} + + +def _categorize(repo_id: str) -> str: + low = repo_id.lower() + for cat in CATEGORIES: + if any(k in low for k in cat["kw"]): + return cat["role"] + return "scout" + + +def _fetch_author_models(author: str) -> list: + url = (f"https://huggingface.co/api/models?author={author}" + f"&filter=gguf&sort=downloads&direction=-1&limit=40") + try: + with httpx.Client(timeout=12.0) as c: + data = c.get(url).json() + return data if isinstance(data, list) else [] + except Exception: + log.debug("discover: Abfrage für Autor %s fehlgeschlagen", author, exc_info=True) + return [] + + +def _age_days(last_modified, now_ts: float) -> float: + """Alter eines HF-Modells in Tagen (lastModified ISO). Unbekannt → ~1.5 Jahre.""" + if not last_modified: + return 540.0 + try: + dt = datetime.fromisoformat(str(last_modified).replace("Z", "+00:00")) + return max((now_ts - dt.timestamp()) / 86400.0, 0.0) + except Exception: + return 540.0 + + +def _score(m: dict, now_ts: float) -> float: + """Zukunftssicherer Rang-Score für DIESE Hardware. Kombiniert: + - Fit: perfect dominiert (Bonus 3.0 > Summe der übrigen Terme → passt-komfortabel zuerst), + - Recency: neuere Generationen bevorzugt (Halbwertszeit ~9 Monate über lastModified), + - Capability: mehr Parameter (log-skaliert), + - Popularity: Downloads (log-skaliert). + So gewinnt bei vergleichbarer Größe die NEUERE Generation (z.B. Qwen3-Coder vor + Qwen2.5-Coder), ohne dass kleine Populär-Modelle große verdrängen.""" + fit_bonus = 3.0 if m["fit"]["level"] == "perfect" else 0.0 + recency = 0.5 ** (_age_days(m.get("lastModified"), now_ts) / 270.0) + cap = math.log2(max(float(m.get("params_b") or 1.0), 1.0) + 1.0) / 8.0 + pop = math.log10(float(m.get("downloads") or 0) + 1.0) / 7.0 + return fit_bonus + 1.2 * recency + 1.2 * cap + 0.5 * pop + + +def rank_runnable(models: list[dict]) -> list[dict]: + """EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle für DIESE Hardware. + Nur was passt (too_tight fliegt raus), dann nach `_score` (Fit + Recency + Capability + + Popularity). Bevorzugt neuere, fähige Modelle → zukunftssicher; „Modelle finden" + schlägt nie ein Downgrade vor (Downgrade-Sperre zusätzlich in maintenance).""" + now_ts = time.time() + return sorted( + [m for m in models if m["fit"]["level"] != "too_tight"], + key=lambda m: -_score(m, now_ts), + ) + + +def refresh_discover(ram_gb: float) -> dict: + """Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE + Quelle erreichbar war.""" + raw, seen, ok = [], set(), 0 + for author in TRUSTED_AUTHORS: + models = _fetch_author_models(author) + if models: + ok += 1 + for m in models: + rid = m.get("id") + if not rid or rid in seen: + continue + seen.add(rid) + raw.append(m) + if ok == 0 and not catalog.entries(): + raise RuntimeError("Keine Quelle erreichbar.") + + by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES} + for m in raw: + rid = m["id"] + low = rid.lower() + if any(tok in low for tok in SKIP_TOKENS): + continue + role = _categorize(rid) + params_b = extract_params_b(rid) + quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung + fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid) + tags = [str(t) for t in (m.get("tags") or [])] + by_cat[role].append({ + "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid, + "role": role, "params_b": params_b, "quant": quant, "tags": tags, + "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0), + "lastModified": m.get("lastModified"), + "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb), + "caps": capabilities(name=rid, hf={"tags": tags}), + }) + + cats = [] + for c in CATEGORIES: + role = c["role"] + # 1) KATALOG zuerst (kuratierte, korrekte Metadaten, MoE-bewusst gerankt) — + # macht die Empfehlung präzise statt Namens-Raterei. + cat_entries = sorted(catalog.entries_for_role(role), + key=lambda e: -catalog.stack_score(e, ram_gb)) + cat_models = [m for m in (catalog.to_model_dict(e, ram_gb) for e in cat_entries) + if m["fit"]["level"] != "too_tight"] + # 2) HF-Dynamik als Ergänzung (nicht-kuratierte Funde), dedupliziert. + hf_ranked = rank_runnable(by_cat[role]) + seen = {catalog._norm(m["repo"]) for m in cat_models} + extra = [h for h in hf_ranked if catalog._norm(h["repo"]) not in seen] + combined = cat_models + extra + if combined: + cats.append({ + "role": role, "title": c["title"], "icon": c["icon"], + "models": combined[:6], + # Empfehlung = bester KURATIERTER Eintrag, sonst beste HF-Fundstelle. + "recommended": (cat_models[0]["repo"] if cat_models + else (hf_ranked[0]["repo"] if hf_ranked else None)), + }) + + data = {"updated": time.time(), "categories": cats} + try: + DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, DISCOVER_CACHE_PATH) + except Exception: + log.debug("discover: Cache-Schreiben fehlgeschlagen (nur Beschleunigung)", exc_info=True) + return data + + +def load_discover() -> dict | None: + try: + if DISCOVER_CACHE_PATH.exists(): + return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8")) + except Exception: + log.debug("discover: Cache-Lesen fehlgeschlagen", exc_info=True) + return None + + +def safe_discover(ram_gb: float) -> dict | None: + """Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da.""" + cached = load_discover() + if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL): + return cached + try: + return refresh_discover(ram_gb) + except Exception: + log.warning("discover: Live-Refresh fehlgeschlagen, nutze Cache", exc_info=True) + return cached diff --git a/backend/services/gateway_stream.py b/backend/services/gateway_stream.py index 6c05268..575749b 100644 --- a/backend/services/gateway_stream.py +++ b/backend/services/gateway_stream.py @@ -1,41 +1,41 @@ -"""Token-Erfassung für den Builtin-Gateway. - -Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet -sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline -verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser. -""" - -import json -import logging - -from services.token_stats import increment_tokens - -log = logging.getLogger(__name__) - - -def record_usage(usage: dict | None, model: str) -> None: - """Ein usage-Objekt verbuchen (no-op bei None/leer).""" - if not usage: - return - prompt = usage.get("prompt_tokens", 0) - completion = usage.get("completion_tokens", 0) - if prompt or completion: - increment_tokens(prompt, completion, model=model) - - -def record_stream_chunk(chunk: bytes, model: str) -> None: - """Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden - geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht.""" - if b'"usage"' not in chunk: - return - text = chunk.decode("utf-8", errors="ignore") - for line in text.splitlines(): - if not line.startswith("data:"): - continue - data_str = line[5:].strip() - if not data_str or data_str == "[DONE]": - continue - try: - record_usage(json.loads(data_str).get("usage"), model) - except json.JSONDecodeError: - log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120]) +"""Token-Erfassung für den Builtin-Gateway. + +Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet +sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline +verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser. +""" + +import json +import logging + +from services.token_stats import increment_tokens + +log = logging.getLogger(__name__) + + +def record_usage(usage: dict | None, model: str) -> None: + """Ein usage-Objekt verbuchen (no-op bei None/leer).""" + if not usage: + return + prompt = usage.get("prompt_tokens", 0) + completion = usage.get("completion_tokens", 0) + if prompt or completion: + increment_tokens(prompt, completion, model=model) + + +def record_stream_chunk(chunk: bytes, model: str) -> None: + """Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden + geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht.""" + if b'"usage"' not in chunk: + return + text = chunk.decode("utf-8", errors="ignore") + for line in text.splitlines(): + if not line.startswith("data:"): + continue + data_str = line[5:].strip() + if not data_str or data_str == "[DONE]": + continue + try: + record_usage(json.loads(data_str).get("usage"), model) + except json.JSONDecodeError: + log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120]) diff --git a/backend/services/gguf_meta.py b/backend/services/gguf_meta.py index 7ec2d37..3804ef7 100644 --- a/backend/services/gguf_meta.py +++ b/backend/services/gguf_meta.py @@ -1,266 +1,266 @@ -""" -GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem -GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell -**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative -Decoding in llama.cpp — sonst: "draft model vocab type must match target"). - -Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald -`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab -identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten -Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung -beim Laden bleibt der letzte Schiedsrichter. -""" - -import hashlib -import struct -from functools import lru_cache - -# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md) -_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \ - _T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13) - -_SCALAR_FMT = { - _T_UINT8: " bytes: - b = self.f.read(n) - if len(b) != n: - raise EOFError("unerwartetes Dateiende beim GGUF-Parsen") - return b - - def u32(self) -> int: - return struct.unpack(" int: - return struct.unpack(" str: - n = self.u64() - return self.read(n).decode("utf-8", "replace") - - def scalar(self, vtype: int): - """Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar.""" - fmt = _SCALAR_FMT.get(vtype) - if not fmt: - self.skip_value(vtype) - return None - return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0] - - def skip_value(self, vtype: int) -> None: - """Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt - weiterzuschieben). Arrays werden elementweise konsumiert.""" - if vtype == _T_STRING: - self.f.seek(self.u64(), 1) - elif vtype in _SCALAR_SIZE: - self.f.seek(_SCALAR_SIZE[vtype], 1) - elif vtype == _T_ARRAY: - etype = self.u32() - count = self.u64() - if etype == _T_STRING: - for _ in range(count): - self.f.seek(self.u64(), 1) - elif etype in _SCALAR_SIZE: - self.f.seek(_SCALAR_SIZE[etype] * count, 1) - else: - raise ValueError(f"unbekannter Array-Elementtyp {etype}") - else: - raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}") - - -def _read_fingerprint(path: str) -> dict | None: - """Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF.""" - try: - with open(path, "rb") as fh: - r = _Reader(fh) - if r.read(4) != b"GGUF": - return None - r.u32() # version - r.u64() # tensor_count - kv_count = r.u64() - fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None, - "tokens_sha": None} - for _ in range(kv_count): - key = r.gstr() - vtype = r.u32() - if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY: - etype = r.u32() - count = r.u64() - fp["n_vocab"] = count - if etype != _T_STRING: - return None - # ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste - # (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …). - h = hashlib.sha256() - h.update(count.to_bytes(8, "little")) - for _ in range(count): - n = r.u64() - h.update(r.read(n)) - fp["tokens_sha"] = h.hexdigest() - # model/pre kommen vor tokens → wir haben alles. Abbrechen. - break - if key in _WANT_STRINGS and vtype == _T_STRING: - val = r.gstr() - if key == "tokenizer.ggml.model": - fp["model"] = val - elif key == "tokenizer.ggml.pre": - fp["pre"] = val - else: - fp["arch"] = val - else: - r.skip_value(vtype) - if fp["model"] is None and fp["n_vocab"] is None: - return None - return fp - except (OSError, EOFError, ValueError, struct.error): - return None - - -@lru_cache(maxsize=256) -def _cached(path: str, mtime: float, size: int) -> tuple | None: - fp = _read_fingerprint(path) - if fp is None: - return None - return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha")) - - -def fingerprint(path: str) -> dict | None: - """Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size). - Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar.""" - import os - try: - st = os.stat(path) - except OSError: - return None - t = _cached(path, st.st_mtime, st.st_size) - if t is None: - return None - return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]} - - -def vocab_key(path: str) -> tuple | None: - """ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256 - der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht - nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität - verlangt llama.cpp für Speculative Decoding.""" - fp = fingerprint(path) - if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"): - return None - return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"]) - - -def compatible(target_path: str, draft_path: str) -> bool | None: - """True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar - (eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'.""" - a = vocab_key(target_path) - b = vocab_key(draft_path) - if a is None or b is None: - return None - return a == b - - -# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ────────────────────────────── -# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern. -# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung -# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier. -# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …), -# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix. -_ARCH_WANT = ( - ".block_count", ".attention.head_count_kv", ".attention.head_count", - ".attention.key_length", ".attention.value_length", ".embedding_length", - ".context_length", -) - - -def _read_arch_meta(path: str) -> dict | None: - try: - with open(path, "rb") as fh: - r = _Reader(fh) - if r.read(4) != b"GGUF": - return None - r.u32() # version - r.u64() # tensor_count - kv_count = r.u64() - raw: dict = {} - arch = None - for _ in range(kv_count): - key = r.gstr() - vtype = r.u32() - if key == "general.architecture" and vtype == _T_STRING: - arch = r.gstr() - continue - if key == "tokenizer.ggml.tokens": - break # Arch-Metadaten stehen davor → fertig, Rest überspringen - suf = next((s for s in _ARCH_WANT if key.endswith(s)), None) - if suf is not None and vtype in _SCALAR_SIZE: - raw[suf] = r.scalar(vtype) - else: - r.skip_value(vtype) - n_layers = raw.get(".block_count") - n_head = raw.get(".attention.head_count") - n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA - n_embd = raw.get(".embedding_length") - hd_k = raw.get(".attention.key_length") \ - or (int(n_embd / n_head) if (n_embd and n_head) else None) - hd_v = raw.get(".attention.value_length") or hd_k - if not (n_layers and n_head_kv and hd_k and hd_v): - return None # unvollständig → Aufrufer nutzt Heuristik-Fallback - return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv), - "head_dim_k": int(hd_k), "head_dim_v": int(hd_v), - "n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None} - except (OSError, EOFError, ValueError, struct.error): - return None - - -@lru_cache(maxsize=128) -def _arch_cached(path: str, mtime: float, size: int) -> dict | None: - return _read_arch_meta(path) - - -def arch_meta(path: str) -> dict | None: - """Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size): - {arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar - oder unvollständig.""" - import os - try: - st = os.stat(path) - except OSError: - return None - return _arch_cached(path, st.st_mtime, st.st_size) - - -# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants). -_KV_BPE = { - "f32": 4.0, "f16": 2.0, "bf16": 2.0, - "q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875, - "q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625, -} -_GIB = 1024 ** 3 - - -def _bpe(cache_type: str | None) -> float: - return _KV_BPE.get((cache_type or "f16").lower(), 2.0) - - -def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float: - """Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp: - je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V.""" - per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx - k = per_tok * meta["head_dim_k"] * _bpe(ck) - v = per_tok * meta["head_dim_v"] * _bpe(cv) - return (k + v) / _GIB - - -def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float: - """KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx).""" - return kv_cache_gb(meta, 1, ck, cv) +""" +GGUF-Tokenizer-Fingerprint — liest die Tokenizer-Identität direkt aus dem +GGUF-Header (ohne das Modell zu laden), um zu entscheiden, ob ein Draft-Modell +**vocab-kompatibel** mit einem Ziel-Modell ist (Voraussetzung für Speculative +Decoding in llama.cpp — sonst: "draft model vocab type must match target"). + +Wir lesen nur die Metadaten-KV-Sektion am Dateianfang und brechen ab, sobald +`tokenizer.ggml.tokens` erreicht ist (dessen Länge = n_vocab). model+pre+n_vocab +identifizieren den Tokenizer eindeutig genug, um die in der Praxis relevanten +Fälle zu unterscheiden (Qwen2.5 vs Qwen3 vs Qwen3.6 etc.). Die llama.cpp-Prüfung +beim Laden bleibt der letzte Schiedsrichter. +""" + +import hashlib +import struct +from functools import lru_cache + +# GGUF value types (https://github.com/ggml-org/ggml/blob/master/docs/gguf.md) +_T_UINT8, _T_INT8, _T_UINT16, _T_INT16, _T_UINT32, _T_INT32, _T_FLOAT32, \ + _T_BOOL, _T_STRING, _T_ARRAY, _T_UINT64, _T_INT64, _T_FLOAT64 = range(13) + +_SCALAR_FMT = { + _T_UINT8: " bytes: + b = self.f.read(n) + if len(b) != n: + raise EOFError("unerwartetes Dateiende beim GGUF-Parsen") + return b + + def u32(self) -> int: + return struct.unpack(" int: + return struct.unpack(" str: + n = self.u64() + return self.read(n).decode("utf-8", "replace") + + def scalar(self, vtype: int): + """Liest einen Skalar-Wert (für die Architektur-Metadaten). None bei Nicht-Skalar.""" + fmt = _SCALAR_FMT.get(vtype) + if not fmt: + self.skip_value(vtype) + return None + return struct.unpack(fmt, self.read(_SCALAR_SIZE[vtype]))[0] + + def skip_value(self, vtype: int) -> None: + """Liest einen Wert und verwirft ihn (um den Datei-Pointer korrekt + weiterzuschieben). Arrays werden elementweise konsumiert.""" + if vtype == _T_STRING: + self.f.seek(self.u64(), 1) + elif vtype in _SCALAR_SIZE: + self.f.seek(_SCALAR_SIZE[vtype], 1) + elif vtype == _T_ARRAY: + etype = self.u32() + count = self.u64() + if etype == _T_STRING: + for _ in range(count): + self.f.seek(self.u64(), 1) + elif etype in _SCALAR_SIZE: + self.f.seek(_SCALAR_SIZE[etype] * count, 1) + else: + raise ValueError(f"unbekannter Array-Elementtyp {etype}") + else: + raise ValueError(f"unbekannter GGUF-Wertetyp {vtype}") + + +def _read_fingerprint(path: str) -> dict | None: + """Liest model/pre/n_vocab aus dem GGUF-Header. None bei Fehler/kein GGUF.""" + try: + with open(path, "rb") as fh: + r = _Reader(fh) + if r.read(4) != b"GGUF": + return None + r.u32() # version + r.u64() # tensor_count + kv_count = r.u64() + fp: dict = {"model": None, "pre": None, "arch": None, "n_vocab": None, + "tokens_sha": None} + for _ in range(kv_count): + key = r.gstr() + vtype = r.u32() + if key == "tokenizer.ggml.tokens" and vtype == _T_ARRAY: + etype = r.u32() + count = r.u64() + fp["n_vocab"] = count + if etype != _T_STRING: + return None + # ECHTE Vocab-Identität: sha256 über die tatsächliche Token-Liste + # (familienunabhängig — funktioniert für Qwen, Llama, Mistral, …). + h = hashlib.sha256() + h.update(count.to_bytes(8, "little")) + for _ in range(count): + n = r.u64() + h.update(r.read(n)) + fp["tokens_sha"] = h.hexdigest() + # model/pre kommen vor tokens → wir haben alles. Abbrechen. + break + if key in _WANT_STRINGS and vtype == _T_STRING: + val = r.gstr() + if key == "tokenizer.ggml.model": + fp["model"] = val + elif key == "tokenizer.ggml.pre": + fp["pre"] = val + else: + fp["arch"] = val + else: + r.skip_value(vtype) + if fp["model"] is None and fp["n_vocab"] is None: + return None + return fp + except (OSError, EOFError, ValueError, struct.error): + return None + + +@lru_cache(maxsize=256) +def _cached(path: str, mtime: float, size: int) -> tuple | None: + fp = _read_fingerprint(path) + if fp is None: + return None + return (fp.get("model"), fp.get("pre"), fp.get("n_vocab"), fp.get("arch"), fp.get("tokens_sha")) + + +def fingerprint(path: str) -> dict | None: + """Tokenizer-Fingerprint eines GGUF (gecacht nach Pfad+mtime+size). + Returns dict(model, pre, n_vocab, arch, tokens_sha) oder None wenn nicht lesbar.""" + import os + try: + st = os.stat(path) + except OSError: + return None + t = _cached(path, st.st_mtime, st.st_size) + if t is None: + return None + return {"model": t[0], "pre": t[1], "n_vocab": t[2], "arch": t[3], "tokens_sha": t[4]} + + +def vocab_key(path: str) -> tuple | None: + """ECHTER Vergleichsschlüssel für Vocab-Kompatibilität: (model, pre, n_vocab, sha256 + der vollständigen Token-Liste). Vergleicht den TATSÄCHLICHEN Vokabular-Inhalt, nicht + nur Metadaten — familienunabhängig (Qwen, Llama, Mistral, …). Genau diese Identität + verlangt llama.cpp für Speculative Decoding.""" + fp = fingerprint(path) + if not fp or fp["n_vocab"] is None or not fp.get("tokens_sha"): + return None + return (fp["model"], fp["pre"], fp["n_vocab"], fp["tokens_sha"]) + + +def compatible(target_path: str, draft_path: str) -> bool | None: + """True/False ob draft vocab-kompatibel zum target ist. None = unbestimmbar + (eine Datei nicht lesbar) → UI behandelt das als 'nicht bestätigt'.""" + a = vocab_key(target_path) + b = vocab_key(draft_path) + if a is None or b is None: + return None + return a == b + + +# ── Architektur-Metadaten für EHRLICHE KV-Cache-Größen ────────────────────────────── +# Der KV-Cache hängt an (Layer × KV-Heads × Head-Dim), NICHT an den Gesamt-Parametern. +# Bei MoE (z.B. Qwen3.6-35B-A3B) ist das entscheidend: die alte params-basierte Schätzung +# überschätzte grob (aktive vs. gesamte Params + GQA), reale KV liest man direkt hier. +# Schlüssel sind arch-präfixiert ('qwen3moe.block_count', 'llama.attention.head_count_kv' …), +# gegen echte GGUFs verifiziert. Wir sammeln die gewünschten Skalar-Schlüssel per Suffix. +_ARCH_WANT = ( + ".block_count", ".attention.head_count_kv", ".attention.head_count", + ".attention.key_length", ".attention.value_length", ".embedding_length", + ".context_length", +) + + +def _read_arch_meta(path: str) -> dict | None: + try: + with open(path, "rb") as fh: + r = _Reader(fh) + if r.read(4) != b"GGUF": + return None + r.u32() # version + r.u64() # tensor_count + kv_count = r.u64() + raw: dict = {} + arch = None + for _ in range(kv_count): + key = r.gstr() + vtype = r.u32() + if key == "general.architecture" and vtype == _T_STRING: + arch = r.gstr() + continue + if key == "tokenizer.ggml.tokens": + break # Arch-Metadaten stehen davor → fertig, Rest überspringen + suf = next((s for s in _ARCH_WANT if key.endswith(s)), None) + if suf is not None and vtype in _SCALAR_SIZE: + raw[suf] = r.scalar(vtype) + else: + r.skip_value(vtype) + n_layers = raw.get(".block_count") + n_head = raw.get(".attention.head_count") + n_head_kv = raw.get(".attention.head_count_kv") or n_head # GQA fehlt → MHA + n_embd = raw.get(".embedding_length") + hd_k = raw.get(".attention.key_length") \ + or (int(n_embd / n_head) if (n_embd and n_head) else None) + hd_v = raw.get(".attention.value_length") or hd_k + if not (n_layers and n_head_kv and hd_k and hd_v): + return None # unvollständig → Aufrufer nutzt Heuristik-Fallback + return {"arch": arch, "n_layers": int(n_layers), "n_head_kv": int(n_head_kv), + "head_dim_k": int(hd_k), "head_dim_v": int(hd_v), + "n_ctx_train": int(raw[".context_length"]) if raw.get(".context_length") else None} + except (OSError, EOFError, ValueError, struct.error): + return None + + +@lru_cache(maxsize=128) +def _arch_cached(path: str, mtime: float, size: int) -> dict | None: + return _read_arch_meta(path) + + +def arch_meta(path: str) -> dict | None: + """Architektur-Metadaten eines GGUF (gecacht nach Pfad+mtime+size): + {arch, n_layers, n_head_kv, head_dim_k, head_dim_v, n_ctx_train}. None wenn nicht lesbar + oder unvollständig.""" + import os + try: + st = os.stat(path) + except OSError: + return None + return _arch_cached(path, st.st_mtime, st.st_size) + + +# Bytes pro KV-Cache-Element je cache-type (inkl. Block-Overhead der k-Quants). +_KV_BPE = { + "f32": 4.0, "f16": 2.0, "bf16": 2.0, + "q8_0": 1.0625, "q5_1": 0.75, "q5_0": 0.6875, + "q4_1": 0.625, "q4_0": 0.5625, "iq4_nl": 0.5625, +} +_GIB = 1024 ** 3 + + +def _bpe(cache_type: str | None) -> float: + return _KV_BPE.get((cache_type or "f16").lower(), 2.0) + + +def kv_cache_gb(meta: dict, ctx: int, ck: str | None = None, cv: str | None = None) -> float: + """Echte KV-Cache-Größe (GiB) für ctx Tokens, K/V ggf. quantisiert. Formel wie llama.cpp: + je Layer & Token hält der Cache n_head_kv × head_dim Elemente für K und für V.""" + per_tok = meta["n_layers"] * meta["n_head_kv"] * ctx + k = per_tok * meta["head_dim_k"] * _bpe(ck) + v = per_tok * meta["head_dim_v"] * _bpe(cv) + return (k + v) / _GIB + + +def kv_gb_per_token(meta: dict, ck: str | None = None, cv: str | None = None) -> float: + """KV-GiB pro Kontext-Token — für den analytischen ctx-Solver (linear in ctx).""" + return kv_cache_gb(meta, 1, ck, cv) diff --git a/backend/services/llamaswap.py b/backend/services/llamaswap.py index a3b4c99..b911f21 100644 --- a/backend/services/llamaswap.py +++ b/backend/services/llamaswap.py @@ -1,576 +1,576 @@ -""" -Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die -llama-swap-API. Portiert & erweitert aus Mission Control v1. - -NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen, -`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz. -""" - -import logging -import os -import re - -import httpx -from ruamel.yaml.scalarstring import LiteralScalarString - -from config import ( - CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL, - SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE, -) - -log = logging.getLogger(__name__) - -# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS, -# maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident -# in der `brains`-Gruppe); UI-Label „Hirn". -ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes"} - -_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)") -_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)") -_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE) -_SPLIT_RE = re.compile(r"-(\d+)-of-(\d+)\.gguf$", re.IGNORECASE) - - -def _gguf_total_size(path: str) -> int | None: - """Gesamtgröße eines GGUF inkl. ALLER Split-Teile (…-00001-of-00003.gguf). - Die Größe nur des ersten Teils ist bei Splits irreführend (oft nur ein Header).""" - try: - base = os.path.basename(path) - m = _SPLIT_RE.search(base) - if not m: - return os.path.getsize(path) - prefix, dirn = base[:m.start()], os.path.dirname(path) - total = sum(os.path.getsize(os.path.join(dirn, f)) - for f in os.listdir(dirn) - if f.startswith(prefix) and _SPLIT_RE.search(f)) - return total or os.path.getsize(path) - except OSError: - return None - - -# --- Lesen ------------------------------------------------------------------- -def read_config() -> dict: - if not CONFIG_PATH.exists(): - return {"models": {}} - from ruamel.yaml import YAML - r_yaml = YAML() - r_yaml.preserve_quotes = True - with CONFIG_PATH.open("r", encoding="utf-8") as f: - data = r_yaml.load(f) or {} - if not data.get("models"): - data["models"] = {} - return data - - -def _parse_model(name: str, spec: dict) -> dict: - spec = spec or {} - cmd = str(spec.get("cmd", "")).strip() - ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None - - path = filename = quant = "" - size_bytes = None - if (m := _PATH_RE.search(cmd)): - path = m.group(1).replace("'", "").replace('"', "") - filename = os.path.basename(path) - if os.path.exists(path): - size_bytes = _gguf_total_size(path) - if (q := _QUANT_RE.search(path)): - quant = q.group(1).upper() - - aliases = spec.get("aliases") or [] - if isinstance(aliases, str): - aliases = [aliases] - aliases = [str(a) for a in aliases] - role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None) - - prompt_cache = "--prompt-cache " in cmd or cmd.endswith("--prompt-cache") or "--prompt-cache-all" in cmd - # Draft-Modell erkennen — klassisch (--spec-draft-model) ODER MTP (--model-draft / -md). - spec_draft = None - if (m_draft := re.search(r"--(?:spec-draft-model|model-draft)\s+([^\s]+)", cmd)) \ - or (m_draft := re.search(r"(? list[dict]: - cfg = read_config() - return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()] - - -def engine_reachable() -> bool: - try: - with httpx.Client(timeout=3.0) as c: - return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200 - except Exception: - return False - - -# --- Schreiben --------------------------------------------------------------- -def model_id_from_path(model_path: str) -> str: - """Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne - '-GGUF'. Fallback: Dateiname ohne Quant-Suffix. - Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) → - dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'.""" - d = os.path.basename(os.path.dirname(model_path)) - if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I): - d = os.path.basename(os.path.dirname(os.path.dirname(model_path))) - name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_") - if not name: - fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I) - fn = re.sub(r"-\d+-of-\d+$", "", fn) - name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I) - return name or "modell" - - -def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None: - """Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen - anderen Modellen entfernen). role=None/leer entfernt den Alias.""" - models = cfg.get("models") or {} - role = (role or "").strip().lower() - if role: - for mid, spec in models.items(): - if mid == model_id or not isinstance(spec, dict): - continue - al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role] - if al: - spec["aliases"] = al - else: - spec.pop("aliases", None) - spec = models.get(model_id) - if isinstance(spec, dict): - if role and role != model_id.lower(): - spec["aliases"] = [role] - else: - spec.pop("aliases", None) - - -def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str: - """Vision-Modelle brauchen --mmproj und --jinja.""" - if mmproj_path: - if "--mmproj" not in cmd: - cmd += f" --mmproj {mmproj_path}" - if "--jinja" not in cmd: - cmd += " --jinja" - return cmd - - -def write_config(cfg: dict) -> None: - """Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie - eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung.""" - try: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp") - from ruamel.yaml import YAML - r_yaml = YAML() - r_yaml.preserve_quotes = True - with tmp.open("w", encoding="utf-8") as f: - r_yaml.dump(cfg, f) - os.replace(tmp, CONFIG_PATH) - # llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn. - # Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt. - try: - from services import warmer - warmer.nudge() - except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker - pass - except PermissionError as exc: - raise PermissionError( - f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. " - f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}" - ) from exc - - - -def register_model(model_path: str, role: str | None = None, ctx: int = 8192, - ttl: int | None = None, mmproj_path: str | None = None, - jinja: bool = False) -> str: - """Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die - Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).""" - cfg = read_config() - model_id = model_id_from_path(model_path) - cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx)) - cmd = _augment_vision(cmd, model_path, mmproj_path) - if jinja and "--jinja" not in cmd: - cmd += " --jinja" - - role_lower = (role or "").strip().lower() - # 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 - # neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix). - # 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" - # 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). - if role_lower == "coder" and "--parallel" not in cmd: - cmd += " --parallel 2" - # Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben - # dem Modell. Self-guarding: ohne kompatiblen/vorhandenen Draft passiert nichts (später im UI - # setzbar). Bei frischem Install existiert die Modell-GGUF noch nicht → ebenfalls kein Draft. - if "--spec-draft-model" not in cmd and "--model-draft" not in cmd: - cmd += spec_draft_flags(model_path) - - cfg.setdefault("models", {})[model_id] = { - "cmd": LiteralScalarString(cmd + "\n"), - "ttl": ttl if ttl is not None else DEFAULT_TTL, - } - set_role_alias(cfg, model_id, role) - write_config(cfg) - return model_id - - -# --- Speculative-Draft / Vocab-Kompatibilität -------------------------------- -def list_drafts() -> list[dict]: - """Alle Draft-GGUFs in DRAFTS_DIR mit Tokenizer-Fingerprint.""" - from services import gguf_meta - out = [] - if DRAFTS_DIR.is_dir(): - for p in sorted(DRAFTS_DIR.glob("*.gguf")): - out.append({ - "path": str(p), "filename": p.name, - "size_bytes": p.stat().st_size if p.exists() else None, - "vocab": gguf_meta.fingerprint(str(p)), - }) - return out - - -def _is_mtp_draft(draft_path: str) -> bool: - """Ist dieser Draft ein MTP-Kopf (Multi-Token-Prediction) statt eines klassischen - Draft-Modells? MTP-Köpfe (z.B. gemma-4) laden mit `--model-draft … --spec-type - draft-mtp` statt `--spec-draft-model … --spec-type draft-simple`. Erkennung am Arch - ('…-assistant' / 'mtp') oder Dateinamen ('mtp-*', '*-MTP', '*-assistant').""" - base = os.path.basename(draft_path).lower() - if base.startswith("mtp-") or "-mtp" in base or "assistant" in base: - return True - from services import gguf_meta - arch = ((gguf_meta.fingerprint(draft_path) or {}).get("arch") or "").lower() - return arch.endswith("-assistant") or "mtp" in arch - - -def _spec_flags_for_draft(draft_path: str) -> str: - """Korrekte llama-server-Spec-Flags für einen (vocab-kompatiblen) Draft. MTP-Kopf → - `--model-draft … --spec-type draft-mtp --spec-draft-n-max N`; klassischer Draft → - `--spec-draft-model … --spec-type draft-simple`. (Beides nötig, sonst Spec inaktiv.)""" - if _is_mtp_draft(draft_path): - return (f" --model-draft {draft_path} --spec-type draft-mtp" - f" --spec-draft-n-max {SPEC_DRAFT_N_MAX}") - return f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}" - - -def _sibling_mtp_drafters(target_path: str) -> list[str]: - """MTP-Kopf-GGUFs NEBEN dem Zielmodell (gleicher Ordner): 'mtp-*.gguf', '*-MTP.gguf', - '*-assistant*.gguf'. Per Konstruktion vocab-identisch zum Modell → idealer Draft.""" - out: list[str] = [] - d = os.path.dirname(target_path) - if os.path.isdir(d): - for f in sorted(os.listdir(d)): - fl = f.lower() - if fl.endswith(".gguf") and (fl.startswith("mtp-") or "-mtp" in fl or "assistant" in fl): - p = os.path.join(d, f) - if p != target_path: - out.append(p) - return out - - -def find_compatible_draft(target_path: str) -> str | None: - """Pfad eines vocab-kompatiblen Drafts für target_path, oder None. - Bevorzugt einen MTP-Kopf NEBEN dem Modell (höchste Qualität, by-construction), - dann MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste kompatible - Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (nicht verifizierbar → - bewusst KEIN Draft anhängen).""" - if not target_path or not os.path.exists(target_path): - return None - from services import gguf_meta - candidates: list[str] = list(_sibling_mtp_drafters(target_path)) - if SPEC_DRAFT_MODEL_PATH and os.path.exists(SPEC_DRAFT_MODEL_PATH): - candidates.append(SPEC_DRAFT_MODEL_PATH) - for d in list_drafts(): - if d["path"] not in candidates: - candidates.append(d["path"]) - for c in candidates: - if gguf_meta.compatible(target_path, c) is True: - return c - return None - - -def spec_draft_flags(target_path: str) -> str: - """llama-server-Flags für Speculative Decoding (Draft + --spec-type), oder '' - wenn kein kompatibler Draft existiert. MTP-bewusst (s. _spec_flags_for_draft).""" - d = find_compatible_draft(target_path) - return _spec_flags_for_draft(d) if d else "" - - -def drafts_for(target_path: str) -> dict: - """Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe - NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts. - compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt).""" - from services import gguf_meta - exists = bool(target_path and os.path.exists(target_path)) - drafts = list_drafts() - seen = {d["path"] for d in drafts} - for p in _sibling_mtp_drafters(target_path): - if p not in seen: - drafts.append({"path": p, "filename": os.path.basename(p), - "size_bytes": os.path.getsize(p) if os.path.exists(p) else None, - "vocab": gguf_meta.fingerprint(p)}) - for d in drafts: - d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None - d["mtp"] = _is_mtp_draft(d["path"]) - return { - "target_path": target_path, - "target_exists": exists, - "target_vocab": gguf_meta.fingerprint(target_path) if exists else None, - "drafts": drafts, - } - - -def set_spec_draft(model_id: str, draft_path: str | None) -> dict: - """Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells. - Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird - abgelehnt (idiotensicher). Returns {ok, reason}.""" - cfg = read_config() - spec = (cfg.get("models") or {}).get(model_id) - if not isinstance(spec, dict): - return {"ok": False, "reason": "Modell nicht gefunden"} - cmd = str(spec.get("cmd", "")) - # vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP. - cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd) - cmd = re.sub(r"\s+-md\s+\S+", "", cmd) - cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd) - cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd) - - if draft_path: - # relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen - if not os.path.isabs(draft_path) and "/" not in draft_path: - draft_path = str(DRAFTS_DIR / draft_path) - if not os.path.exists(draft_path): - return {"ok": False, "reason": "Draft-Datei nicht gefunden"} - from services import gguf_meta - target = "" - if (mt := _PATH_RE.search(cmd)): - target = mt.group(1).replace("'", "").replace('"', "") - comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None - if comp is not True: - reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding " - "würde beim Laden scheitern." - if comp is False else - "Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.") - return {"ok": False, "reason": reason} - cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path) - - spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n") - write_config(cfg) - return {"ok": True, "reason": ""} - - -# --- Groups (Ko-Residenz) ---------------------------------------------------- -def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None: - """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen - GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True → - Mitglieder werden nie von anderen Gruppen verdrängt. - - llama-swap kennt dafür AUSSCHLIESSLICH den Key `persistent` — `persist` wird von ihm - stillschweigend ignoriert (so verlor das Hirn seinen Verdrängungsschutz; live gefunden - 03.07.2026: Coder-Last warf die brains-Gruppe raus). `persist` wird zusätzlich weiter - geschrieben, weil MC2-API/UI (routers/models.py, agent.py, Frontend) diesen Key lesen.""" - cfg = read_config() - groups = cfg.setdefault("groups", {}) - groups[group] = {"swap": swap, "persist": persist, "persistent": persist, - "members": list(members)} - # Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied. - # `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen - # ttl-Selbstentladen — ein Mitglied mit ttl>0 fällt trotz Gruppen-Mitgliedschaft - # nach Leerlauf aus dem Warm-Set (live gefunden 03.07.2026: VL-30B mit ttl 300 - # entlud sich alle 5 Min). So kann dieser Fehler beim Warm-Set-Umbau nie wieder passieren. - if persist: - models = cfg.get("models") or {} - for mid in members: - spec = models.get(mid) - if isinstance(spec, dict): - spec["ttl"] = 0 - write_config(cfg) - - -def list_groups() -> dict: - return read_config().get("groups") or {} - - -def set_role(model_id: str, role: str | None) -> bool: - """Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man - z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert).""" - cfg = read_config() - if model_id not in (cfg.get("models") or {}): - return False - set_role_alias(cfg, model_id, role) - write_config(cfg) - return True - - -def set_ctx(model_id: str, ctx: int) -> bool: - """Kontextlänge (-c) eines bestehenden Modells ändern.""" - cfg = read_config() - spec = (cfg.get("models") or {}).get(model_id) - if not spec: - return False - cmd = str(spec.get("cmd", "")) - if _CTX_RE.search(cmd): - cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd) - else: - cmd = cmd.rstrip() + f" -c {ctx}" - spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n") - write_config(cfg) - return True - - -def set_ttl(model_id: str, ttl: int) -> bool: - """Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch - entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss).""" - cfg = read_config() - spec = (cfg.get("models") or {}).get(model_id) - if not isinstance(spec, dict): - return False - spec["ttl"] = int(ttl) - write_config(cfg) - return True - - -def delete_model(model_id: str) -> bool: - """Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen - GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner. - """ - cfg = read_config() - models = cfg.get("models") or {} - if model_id not in models: - return False - - model_spec = models[model_id] or {} - cmd = str(model_spec.get("cmd", "")).strip() - if (m := _PATH_RE.search(cmd)): - path = m.group(1).replace("'", "").replace('"', "") - if path: - # 1. Haupt-GGUF-Datei löschen - if os.path.exists(path): - try: - os.remove(path) - except Exception: - pass - - # 2. Split-GGUF-Teile löschen (z.B. dateiname-00001-of-00005.gguf etc.) - dirname = os.path.dirname(path) - basename = os.path.basename(path) - if os.path.isdir(dirname): - split_idx = basename.find("-00001-of-") - if split_idx != -1: - prefix = basename[:split_idx] - for f in os.listdir(dirname): - if f.startswith(prefix) and f.endswith(".gguf"): - try: - os.remove(os.path.join(dirname, f)) - except Exception: - pass - - # mmproj-Datei (Vision adapter) aus dem Befehl parsen & löschen - if "mmproj" in cmd: - mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd) - if mmproj_match: - m_path = mmproj_match.group(1) - if os.path.exists(m_path): - try: - os.remove(m_path) - except Exception: - pass - - # 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist - try: - if not os.listdir(dirname) and os.path.basename(dirname) != "models": - os.rmdir(dirname) - except Exception: - pass - - del models[model_id] - for g in (cfg.get("groups") or {}).values(): - if isinstance(g, dict) and model_id in (g.get("members") or []): - g["members"] = [m for m in g["members"] if m != model_id] - write_config(cfg) - return True - - -def brain_model_name() -> str | None: - """Modellname von Lucys Agent-Hirn. Bevorzugt das Modell mit dem 'hermes'-Alias/-Rolle; - fällt auf Hermes' aktives `model.default` zurück (deckt den Fall ab, dass die Config direkt - auf einen Modellnamen statt den Alias zeigt).""" - models = list_models() - for m in models: - names = {str(a).lower() for a in (m.get("aliases") or [])} - if m.get("role"): - names.add(str(m["role"]).lower()) - if "hermes" in names: - return m["name"] - # Fallback: das real von Hermes genutzte Hirn (model.default), per Alias/Name auflösen. - try: - from services.agent import _active_brain_name - brain = (_active_brain_name() or "").lower() - if brain and brain != "auto": - cur = next((m for m in models if (m.get("role") or "").lower() == brain), None) \ - or next((m for m in models if brain in (m["name"] or "").lower()), None) - if cur: - return cur["name"] - except Exception: - log.debug("brain_model_name: Hermes-Fallback fehlgeschlagen", exc_info=True) - return None - - -def brain_status() -> dict: - """Ist Lucys Agent-Hirn (Rolle 'hermes') WIRKLICH geladen & bereit? Prüft /running — ein - abgestürztes Modell (z.B. OOM/Crash nach Engine-Update) erscheint dort NICHT als running. - Fängt damit den Fall 'Engine erreichbar, aber Hirn tot', den engine_reachable() nicht sieht.""" - name = brain_model_name() - running = get_running_models() - return {"role": "hermes", "model": name, "ready": bool(name and name in running)} - - -def get_running_models() -> list[str]: - """Fragt den /running Endpunkt von llama-swap ab. Gibt die Namen der geladenen - Modelle zurück. Neuere llama-swap-Versionen liefern Objekte ({model, state, ...}) - statt Strings — beide Formen werden auf Namens-Strings normalisiert.""" - try: - with httpx.Client(timeout=2.0) as c: - r = c.get(f"{LLAMA_SWAP_URL}/running") - if r.status_code == 200: - data = r.json().get("running") or [] - return [x.get("model", "") if isinstance(x, dict) else x for x in data] - except Exception: - log.warning("get_running_models fehlgeschlagen", exc_info=True) - return [] +""" +Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die +llama-swap-API. Portiert & erweitert aus Mission Control v1. + +NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen, +`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz. +""" + +import logging +import os +import re + +import httpx +from ruamel.yaml.scalarstring import LiteralScalarString + +from config import ( + CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL, + SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE, +) + +log = logging.getLogger(__name__) + +# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS, +# maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident +# in der `brains`-Gruppe); UI-Label „Hirn". +ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes"} + +_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)") +_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)") +_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE) +_SPLIT_RE = re.compile(r"-(\d+)-of-(\d+)\.gguf$", re.IGNORECASE) + + +def _gguf_total_size(path: str) -> int | None: + """Gesamtgröße eines GGUF inkl. ALLER Split-Teile (…-00001-of-00003.gguf). + Die Größe nur des ersten Teils ist bei Splits irreführend (oft nur ein Header).""" + try: + base = os.path.basename(path) + m = _SPLIT_RE.search(base) + if not m: + return os.path.getsize(path) + prefix, dirn = base[:m.start()], os.path.dirname(path) + total = sum(os.path.getsize(os.path.join(dirn, f)) + for f in os.listdir(dirn) + if f.startswith(prefix) and _SPLIT_RE.search(f)) + return total or os.path.getsize(path) + except OSError: + return None + + +# --- Lesen ------------------------------------------------------------------- +def read_config() -> dict: + if not CONFIG_PATH.exists(): + return {"models": {}} + from ruamel.yaml import YAML + r_yaml = YAML() + r_yaml.preserve_quotes = True + with CONFIG_PATH.open("r", encoding="utf-8") as f: + data = r_yaml.load(f) or {} + if not data.get("models"): + data["models"] = {} + return data + + +def _parse_model(name: str, spec: dict) -> dict: + spec = spec or {} + cmd = str(spec.get("cmd", "")).strip() + ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None + + path = filename = quant = "" + size_bytes = None + if (m := _PATH_RE.search(cmd)): + path = m.group(1).replace("'", "").replace('"', "") + filename = os.path.basename(path) + if os.path.exists(path): + size_bytes = _gguf_total_size(path) + if (q := _QUANT_RE.search(path)): + quant = q.group(1).upper() + + aliases = spec.get("aliases") or [] + if isinstance(aliases, str): + aliases = [aliases] + aliases = [str(a) for a in aliases] + role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None) + + prompt_cache = "--prompt-cache " in cmd or cmd.endswith("--prompt-cache") or "--prompt-cache-all" in cmd + # Draft-Modell erkennen — klassisch (--spec-draft-model) ODER MTP (--model-draft / -md). + spec_draft = None + if (m_draft := re.search(r"--(?:spec-draft-model|model-draft)\s+([^\s]+)", cmd)) \ + or (m_draft := re.search(r"(? list[dict]: + cfg = read_config() + return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()] + + +def engine_reachable() -> bool: + try: + with httpx.Client(timeout=3.0) as c: + return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200 + except Exception: + return False + + +# --- Schreiben --------------------------------------------------------------- +def model_id_from_path(model_path: str) -> str: + """Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne + '-GGUF'. Fallback: Dateiname ohne Quant-Suffix. + Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) → + dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'.""" + d = os.path.basename(os.path.dirname(model_path)) + if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I): + d = os.path.basename(os.path.dirname(os.path.dirname(model_path))) + name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_") + if not name: + fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I) + fn = re.sub(r"-\d+-of-\d+$", "", fn) + name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I) + return name or "modell" + + +def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None: + """Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen + anderen Modellen entfernen). role=None/leer entfernt den Alias.""" + models = cfg.get("models") or {} + role = (role or "").strip().lower() + if role: + for mid, spec in models.items(): + if mid == model_id or not isinstance(spec, dict): + continue + al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role] + if al: + spec["aliases"] = al + else: + spec.pop("aliases", None) + spec = models.get(model_id) + if isinstance(spec, dict): + if role and role != model_id.lower(): + spec["aliases"] = [role] + else: + spec.pop("aliases", None) + + +def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str: + """Vision-Modelle brauchen --mmproj und --jinja.""" + if mmproj_path: + if "--mmproj" not in cmd: + cmd += f" --mmproj {mmproj_path}" + if "--jinja" not in cmd: + cmd += " --jinja" + return cmd + + +def write_config(cfg: dict) -> None: + """Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie + eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung.""" + try: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp") + from ruamel.yaml import YAML + r_yaml = YAML() + r_yaml.preserve_quotes = True + with tmp.open("w", encoding="utf-8") as f: + r_yaml.dump(cfg, f) + os.replace(tmp, CONFIG_PATH) + # llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn. + # Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt. + try: + from services import warmer + warmer.nudge() + except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker + pass + except PermissionError as exc: + raise PermissionError( + f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. " + f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}" + ) from exc + + + +def register_model(model_path: str, role: str | None = None, ctx: int = 8192, + ttl: int | None = None, mmproj_path: str | None = None, + jinja: bool = False) -> str: + """Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die + Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).""" + cfg = read_config() + model_id = model_id_from_path(model_path) + cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx)) + cmd = _augment_vision(cmd, model_path, mmproj_path) + if jinja and "--jinja" not in cmd: + cmd += " --jinja" + + role_lower = (role or "").strip().lower() + # 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 + # neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix). + # 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" + # 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). + if role_lower == "coder" and "--parallel" not in cmd: + cmd += " --parallel 2" + # Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben + # dem Modell. Self-guarding: ohne kompatiblen/vorhandenen Draft passiert nichts (später im UI + # setzbar). Bei frischem Install existiert die Modell-GGUF noch nicht → ebenfalls kein Draft. + if "--spec-draft-model" not in cmd and "--model-draft" not in cmd: + cmd += spec_draft_flags(model_path) + + cfg.setdefault("models", {})[model_id] = { + "cmd": LiteralScalarString(cmd + "\n"), + "ttl": ttl if ttl is not None else DEFAULT_TTL, + } + set_role_alias(cfg, model_id, role) + write_config(cfg) + return model_id + + +# --- Speculative-Draft / Vocab-Kompatibilität -------------------------------- +def list_drafts() -> list[dict]: + """Alle Draft-GGUFs in DRAFTS_DIR mit Tokenizer-Fingerprint.""" + from services import gguf_meta + out = [] + if DRAFTS_DIR.is_dir(): + for p in sorted(DRAFTS_DIR.glob("*.gguf")): + out.append({ + "path": str(p), "filename": p.name, + "size_bytes": p.stat().st_size if p.exists() else None, + "vocab": gguf_meta.fingerprint(str(p)), + }) + return out + + +def _is_mtp_draft(draft_path: str) -> bool: + """Ist dieser Draft ein MTP-Kopf (Multi-Token-Prediction) statt eines klassischen + Draft-Modells? MTP-Köpfe (z.B. gemma-4) laden mit `--model-draft … --spec-type + draft-mtp` statt `--spec-draft-model … --spec-type draft-simple`. Erkennung am Arch + ('…-assistant' / 'mtp') oder Dateinamen ('mtp-*', '*-MTP', '*-assistant').""" + base = os.path.basename(draft_path).lower() + if base.startswith("mtp-") or "-mtp" in base or "assistant" in base: + return True + from services import gguf_meta + arch = ((gguf_meta.fingerprint(draft_path) or {}).get("arch") or "").lower() + return arch.endswith("-assistant") or "mtp" in arch + + +def _spec_flags_for_draft(draft_path: str) -> str: + """Korrekte llama-server-Spec-Flags für einen (vocab-kompatiblen) Draft. MTP-Kopf → + `--model-draft … --spec-type draft-mtp --spec-draft-n-max N`; klassischer Draft → + `--spec-draft-model … --spec-type draft-simple`. (Beides nötig, sonst Spec inaktiv.)""" + if _is_mtp_draft(draft_path): + return (f" --model-draft {draft_path} --spec-type draft-mtp" + f" --spec-draft-n-max {SPEC_DRAFT_N_MAX}") + return f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}" + + +def _sibling_mtp_drafters(target_path: str) -> list[str]: + """MTP-Kopf-GGUFs NEBEN dem Zielmodell (gleicher Ordner): 'mtp-*.gguf', '*-MTP.gguf', + '*-assistant*.gguf'. Per Konstruktion vocab-identisch zum Modell → idealer Draft.""" + out: list[str] = [] + d = os.path.dirname(target_path) + if os.path.isdir(d): + for f in sorted(os.listdir(d)): + fl = f.lower() + if fl.endswith(".gguf") and (fl.startswith("mtp-") or "-mtp" in fl or "assistant" in fl): + p = os.path.join(d, f) + if p != target_path: + out.append(p) + return out + + +def find_compatible_draft(target_path: str) -> str | None: + """Pfad eines vocab-kompatiblen Drafts für target_path, oder None. + Bevorzugt einen MTP-Kopf NEBEN dem Modell (höchste Qualität, by-construction), + dann MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste kompatible + Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (nicht verifizierbar → + bewusst KEIN Draft anhängen).""" + if not target_path or not os.path.exists(target_path): + return None + from services import gguf_meta + candidates: list[str] = list(_sibling_mtp_drafters(target_path)) + if SPEC_DRAFT_MODEL_PATH and os.path.exists(SPEC_DRAFT_MODEL_PATH): + candidates.append(SPEC_DRAFT_MODEL_PATH) + for d in list_drafts(): + if d["path"] not in candidates: + candidates.append(d["path"]) + for c in candidates: + if gguf_meta.compatible(target_path, c) is True: + return c + return None + + +def spec_draft_flags(target_path: str) -> str: + """llama-server-Flags für Speculative Decoding (Draft + --spec-type), oder '' + wenn kein kompatibler Draft existiert. MTP-bewusst (s. _spec_flags_for_draft).""" + d = find_compatible_draft(target_path) + return _spec_flags_for_draft(d) if d else "" + + +def drafts_for(target_path: str) -> dict: + """Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe + NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts. + compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt).""" + from services import gguf_meta + exists = bool(target_path and os.path.exists(target_path)) + drafts = list_drafts() + seen = {d["path"] for d in drafts} + for p in _sibling_mtp_drafters(target_path): + if p not in seen: + drafts.append({"path": p, "filename": os.path.basename(p), + "size_bytes": os.path.getsize(p) if os.path.exists(p) else None, + "vocab": gguf_meta.fingerprint(p)}) + for d in drafts: + d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None + d["mtp"] = _is_mtp_draft(d["path"]) + return { + "target_path": target_path, + "target_exists": exists, + "target_vocab": gguf_meta.fingerprint(target_path) if exists else None, + "drafts": drafts, + } + + +def set_spec_draft(model_id: str, draft_path: str | None) -> dict: + """Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells. + Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird + abgelehnt (idiotensicher). Returns {ok, reason}.""" + cfg = read_config() + spec = (cfg.get("models") or {}).get(model_id) + if not isinstance(spec, dict): + return {"ok": False, "reason": "Modell nicht gefunden"} + cmd = str(spec.get("cmd", "")) + # vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP. + cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd) + cmd = re.sub(r"\s+-md\s+\S+", "", cmd) + cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd) + cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd) + + if draft_path: + # relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen + if not os.path.isabs(draft_path) and "/" not in draft_path: + draft_path = str(DRAFTS_DIR / draft_path) + if not os.path.exists(draft_path): + return {"ok": False, "reason": "Draft-Datei nicht gefunden"} + from services import gguf_meta + target = "" + if (mt := _PATH_RE.search(cmd)): + target = mt.group(1).replace("'", "").replace('"', "") + comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None + if comp is not True: + reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding " + "würde beim Laden scheitern." + if comp is False else + "Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.") + return {"ok": False, "reason": reason} + cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path) + + spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n") + write_config(cfg) + return {"ok": True, "reason": ""} + + +# --- Groups (Ko-Residenz) ---------------------------------------------------- +def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None: + """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen + GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True → + Mitglieder werden nie von anderen Gruppen verdrängt. + + llama-swap kennt dafür AUSSCHLIESSLICH den Key `persistent` — `persist` wird von ihm + stillschweigend ignoriert (so verlor das Hirn seinen Verdrängungsschutz; live gefunden + 03.07.2026: Coder-Last warf die brains-Gruppe raus). `persist` wird zusätzlich weiter + geschrieben, weil MC2-API/UI (routers/models.py, agent.py, Frontend) diesen Key lesen.""" + cfg = read_config() + groups = cfg.setdefault("groups", {}) + groups[group] = {"swap": swap, "persist": persist, "persistent": persist, + "members": list(members)} + # Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied. + # `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen + # ttl-Selbstentladen — ein Mitglied mit ttl>0 fällt trotz Gruppen-Mitgliedschaft + # nach Leerlauf aus dem Warm-Set (live gefunden 03.07.2026: VL-30B mit ttl 300 + # entlud sich alle 5 Min). So kann dieser Fehler beim Warm-Set-Umbau nie wieder passieren. + if persist: + models = cfg.get("models") or {} + for mid in members: + spec = models.get(mid) + if isinstance(spec, dict): + spec["ttl"] = 0 + write_config(cfg) + + +def list_groups() -> dict: + return read_config().get("groups") or {} + + +def set_role(model_id: str, role: str | None) -> bool: + """Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man + z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert).""" + cfg = read_config() + if model_id not in (cfg.get("models") or {}): + return False + set_role_alias(cfg, model_id, role) + write_config(cfg) + return True + + +def set_ctx(model_id: str, ctx: int) -> bool: + """Kontextlänge (-c) eines bestehenden Modells ändern.""" + cfg = read_config() + spec = (cfg.get("models") or {}).get(model_id) + if not spec: + return False + cmd = str(spec.get("cmd", "")) + if _CTX_RE.search(cmd): + cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd) + else: + cmd = cmd.rstrip() + f" -c {ctx}" + spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n") + write_config(cfg) + return True + + +def set_ttl(model_id: str, ttl: int) -> bool: + """Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch + entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss).""" + cfg = read_config() + spec = (cfg.get("models") or {}).get(model_id) + if not isinstance(spec, dict): + return False + spec["ttl"] = int(ttl) + write_config(cfg) + return True + + +def delete_model(model_id: str) -> bool: + """Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen + GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner. + """ + cfg = read_config() + models = cfg.get("models") or {} + if model_id not in models: + return False + + model_spec = models[model_id] or {} + cmd = str(model_spec.get("cmd", "")).strip() + if (m := _PATH_RE.search(cmd)): + path = m.group(1).replace("'", "").replace('"', "") + if path: + # 1. Haupt-GGUF-Datei löschen + if os.path.exists(path): + try: + os.remove(path) + except Exception: + pass + + # 2. Split-GGUF-Teile löschen (z.B. dateiname-00001-of-00005.gguf etc.) + dirname = os.path.dirname(path) + basename = os.path.basename(path) + if os.path.isdir(dirname): + split_idx = basename.find("-00001-of-") + if split_idx != -1: + prefix = basename[:split_idx] + for f in os.listdir(dirname): + if f.startswith(prefix) and f.endswith(".gguf"): + try: + os.remove(os.path.join(dirname, f)) + except Exception: + pass + + # mmproj-Datei (Vision adapter) aus dem Befehl parsen & löschen + if "mmproj" in cmd: + mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd) + if mmproj_match: + m_path = mmproj_match.group(1) + if os.path.exists(m_path): + try: + os.remove(m_path) + except Exception: + pass + + # 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist + try: + if not os.listdir(dirname) and os.path.basename(dirname) != "models": + os.rmdir(dirname) + except Exception: + pass + + del models[model_id] + for g in (cfg.get("groups") or {}).values(): + if isinstance(g, dict) and model_id in (g.get("members") or []): + g["members"] = [m for m in g["members"] if m != model_id] + write_config(cfg) + return True + + +def brain_model_name() -> str | None: + """Modellname von Lucys Agent-Hirn. Bevorzugt das Modell mit dem 'hermes'-Alias/-Rolle; + fällt auf Hermes' aktives `model.default` zurück (deckt den Fall ab, dass die Config direkt + auf einen Modellnamen statt den Alias zeigt).""" + models = list_models() + for m in models: + names = {str(a).lower() for a in (m.get("aliases") or [])} + if m.get("role"): + names.add(str(m["role"]).lower()) + if "hermes" in names: + return m["name"] + # Fallback: das real von Hermes genutzte Hirn (model.default), per Alias/Name auflösen. + try: + from services.agent import _active_brain_name + brain = (_active_brain_name() or "").lower() + if brain and brain != "auto": + cur = next((m for m in models if (m.get("role") or "").lower() == brain), None) \ + or next((m for m in models if brain in (m["name"] or "").lower()), None) + if cur: + return cur["name"] + except Exception: + log.debug("brain_model_name: Hermes-Fallback fehlgeschlagen", exc_info=True) + return None + + +def brain_status() -> dict: + """Ist Lucys Agent-Hirn (Rolle 'hermes') WIRKLICH geladen & bereit? Prüft /running — ein + abgestürztes Modell (z.B. OOM/Crash nach Engine-Update) erscheint dort NICHT als running. + Fängt damit den Fall 'Engine erreichbar, aber Hirn tot', den engine_reachable() nicht sieht.""" + name = brain_model_name() + running = get_running_models() + return {"role": "hermes", "model": name, "ready": bool(name and name in running)} + + +def get_running_models() -> list[str]: + """Fragt den /running Endpunkt von llama-swap ab. Gibt die Namen der geladenen + Modelle zurück. Neuere llama-swap-Versionen liefern Objekte ({model, state, ...}) + statt Strings — beide Formen werden auf Namens-Strings normalisiert.""" + try: + with httpx.Client(timeout=2.0) as c: + r = c.get(f"{LLAMA_SWAP_URL}/running") + if r.status_code == 200: + data = r.json().get("running") or [] + return [x.get("model", "") if isinstance(x, dict) else x for x in data] + except Exception: + log.warning("get_running_models fehlgeschlagen", exc_info=True) + return [] diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index 3ea72e5..2602ffb 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -1,726 +1,726 @@ -""" -Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware), -Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py). - -Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig -erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job. -""" - -import os -import re -import subprocess -import time -from datetime import datetime - -import httpx -import psutil - -from services import catalog, discover, jobengine, llamaswap, system - -# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). -SYSTEM_SERVICES = {"llama-swap"} -USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-terminal", "mem0-service", "voice-service"} - -# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root). -_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -ENGINE_UPDATE_CMD = os.environ.get( - "MC_ENGINE_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-engine.sh") -# Engine = offizieller Vulkan-Build von ggml-org/llama.cpp (RADV auf Strix Halo). -ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan") -ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp") -_engine_cache = {"ts": 0.0, "avail": False} - -# Neueste Release, die WIRKLICH ein Vulkan-x64-Binary trägt. Die allerneueste Release hat -# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check -# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release -# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert. -_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I) - - -def _latest_engine_asset_release() -> dict | None: - try: - rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15", - timeout=8, headers={"User-Agent": "MissionControl2"}).json() - for rel in (rels if isinstance(rels, list) else []): - if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])): - return rel - except Exception: - pass - return None - -# Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes -# Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt. -SWAP_UPDATE_CMD = os.environ.get( - "MC_SWAP_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-swap.sh") -SWAP_BIN = os.environ.get("MC_SWAP_BIN", "/usr/local/bin/llama-swap") -SWAP_REPO = os.environ.get("MC_SWAP_REPO", "mostlygeek/llama-swap") -_swap_cache = {"ts": 0.0, "avail": False} - -# Stack-Funktionsprüfung NACH jedem Update (OS/Engine/Router): verifiziert per echter -# Inferenz, dass der Stack noch läuft → Job wird rot, wenn ein Update etwas zerschossen hat. -STACK_POSTCHECK = os.path.join(_REPO_ROOT, "deploy", "stack-postcheck.sh") - - -def _installed_engine_build() -> int | None: - """Build-Nummer der installierten llama-server-Binary (z.B. 9821), oder None. - Vulkan-Build braucht LD_LIBRARY_PATH=ENGINE_PATH zum Start von --version.""" - bin_path = os.path.join(ENGINE_PATH, "llama-server") - if not os.path.exists(bin_path): - return None - try: - env = dict(os.environ, LD_LIBRARY_PATH=ENGINE_PATH) - out = subprocess.run([bin_path, "--version"], capture_output=True, text=True, - timeout=20, env=env) - txt = (out.stderr or "") + (out.stdout or "") - # Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: (9821)", "b9821". - if (m := re.search(r"version:\s*(\d{3,})", txt)) \ - or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \ - or (m := re.search(r"\bb(\d{3,})\b", txt)): - return int(m.group(1)) - except Exception: - return None - return None - - -def _ram_gb() -> float: - return psutil.virtual_memory().total / (1024 ** 3) - - -def _os_upgradable() -> int: - try: - # LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt - # grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0. - out = subprocess.run( - ["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"], - capture_output=True, text=True, timeout=10) - return int((out.stdout or "0").strip() or 0) - except Exception: - return 0 - - -def _engine_update_available() -> bool: - now = time.time() - if now - _engine_cache["ts"] < 3600: - return _engine_cache["avail"] - avail = False - try: - rel = _latest_engine_asset_release() or {} - tag = str(rel.get("tag_name", "")) - latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None - installed = _installed_engine_build() - if latest is not None and installed is not None: - avail = latest > installed # präziser Build-Nummer-Vergleich - elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime - pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp() - avail = pub > os.path.getmtime(ENGINE_PATH) + 86400 - except Exception: - avail = False - _engine_cache.update(ts=now, avail=avail) - return avail - - -def _installed_swap_version() -> int | None: - """Versions-Nummer der installierten llama-swap-Binary (z.B. 228), oder None.""" - if not os.path.exists(SWAP_BIN): - return None - try: - out = subprocess.run([SWAP_BIN, "--version"], capture_output=True, text=True, timeout=15) - txt = (out.stdout or "") + (out.stderr or "") - if (m := re.search(r"version:\s*(\d+)", txt)) or (m := re.search(r"\bv?(\d{2,})\b", txt)): - return int(m.group(1)) - except Exception: - return None - return None - - -def _swap_update_available() -> bool: - now = time.time() - if now - _swap_cache["ts"] < 3600: - return _swap_cache["avail"] - avail = False - try: - rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", - timeout=6, headers={"User-Agent": "MissionControl2"}).json() - tag = str(rel.get("tag_name", "")) - latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None - installed = _installed_swap_version() - if latest is not None and installed is not None: - avail = latest > installed - except Exception: - avail = False - _swap_cache.update(ts=now, avail=avail) - return avail - - -_comp_cache = {"ts": 0.0, "data": []} - - -def _hermes_agent_update() -> dict: - """Hermes-Agent wird aus **git** aktualisiert (CLI `hermes update` = git pull origin ). - Darum HEAD vs. origin/ prüfen (fetch + behind-count) — NICHT GitHub-Releases: die - werden selten getaggt, main läuft ihnen voraus → sonst zeigt das UI nie ein Update an.""" - info = {"key": "hermes_agent", "name": "Hermes Agent", "current": None, - "latest": None, "update": False, "reachable": None} - git = system.find_hermes_agent_git() - if not git or not git.get("path"): - return info - path = git["path"] - info["current"] = git.get("hash") - try: - branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, timeout=8).stdout.strip() or "main") - fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch], - capture_output=True, text=True, timeout=25) - info["reachable"] = (fetch.returncode == 0) - if fetch.returncode == 0: - cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"], - capture_output=True, text=True, timeout=8) - behind = int(cnt.stdout.strip() or "0") if cnt.returncode == 0 else 0 - info["behind"] = behind - info["update"] = behind > 0 - oh = subprocess.run(["git", "-C", path, "rev-parse", "--short", f"origin/{branch}"], - capture_output=True, text=True, timeout=8).stdout.strip() - info["latest"] = (f"{oh} ({behind} neu)" if behind else (oh or info["current"])) - except Exception: - info["reachable"] = False - return info - - -def _components_cached() -> list[dict]: - """Update-Status von Hermes-Agent (1h-Cache → GitHub schonen).""" - now = time.time() - if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]: - return _comp_cache["data"] - data = [_hermes_agent_update()] - _comp_cache.update(ts=now, data=data) - return data - - -def _params_of(m: dict) -> float: - """Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung - und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab).""" - from services.fit import QUANT_BYTES_PER_PARAM - caps = m.get("capabilities") or {} - bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55) - size_gb = (m.get("size_bytes") or 0) / (1024 ** 3) - pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 - return max(float(caps.get("params_b") or 0), pb_size, 0.0) - - -# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?"). -_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"), - ("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"), - ("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)")) - - -def _gen_key(name: str): - """(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0), - 'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar.""" - low = (name or "").lower() - sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \ - "-coder" if ("coder" in low or "-code" in low) else "" - for fam, pat in _FAM_PATS: - m = re.search(pat, low) - if m: - ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0 - return (fam + sub, ver) - return None - - -def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict: - """Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog, - sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik.""" - cm = catalog.meta_for_name(name) - if cm: - return {"family": cm.get("family"), "gen": cm.get("generation"), - "total": float(cm.get("total_params_b") or 0), - "active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))} - d = model_dict or {} - g = _gen_key(name) - total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0) - return {"family": (d.get("family") or (g[0] if g else None)), - "gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)), - "total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))} - - -def model_upgrades() -> list[dict]: - """Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist: - gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade - (MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens- - Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei.""" - disc = discover.safe_discover(_ram_gb()) - if not disc: - return [] - - installed = llamaswap.list_models() - inst_by_role = {m["role"]: m for m in installed if m.get("role")} - cmds = " ".join(str(s.get("cmd", "")).lower() - for s in (llamaswap.read_config().get("models") or {}).values()) - out = [] - - for c in disc.get("categories", []): - role = c["role"] - im = inst_by_role.get(role) - if im is None: - continue - rec = c.get("recommended") - if not rec: - continue - rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None) - - i = _meta(im["name"], im=im) - r = _meta(rec, model_dict=rec_model) - - if not i["family"] or not r["family"] or i["family"] != r["family"]: - continue # andere/unbekannte Familie → kein Upgrade - if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6: - continue # ältere Generation → niemals - same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6) - if same_gen: - if r["total"] and i["total"] and r["total"] < i["total"] * 1.05: - continue # gleiche Gen, nicht größer → kein Upgrade - # MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung - if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5: - continue - # Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn - ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"]) - if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3: - continue - - base = rec.split("/")[-1].lower() - stem = base[:-5] if base.endswith("-gguf") else base - if base in cmds or (stem and stem in cmds): - continue # schon installiert - out.append({"role": role, "title": c["title"], "repo": rec}) - return out - - -def _last_apt_update() -> float | None: - for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]: - if os.path.exists(path): - try: - return os.path.getmtime(path) - except Exception: - pass - return None - - -def updates() -> dict: - ups = model_upgrades() - return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0, - "swap": 1 if _swap_update_available() else 0, - "models": len(ups), "model_list": ups, "last_check": _last_apt_update(), - "components": _components_cached()} - - -# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ── - -def _os_held_back() -> list[dict]: - """Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund. - Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen: - • Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'. - • zurückgehalten wegen neuer Abhängigkeiten → 'kept back'. - Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl, - wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen.""" - held: list[dict] = [] - sections = { - "The following upgrades have been deferred due to phasing:": "phasing", - "The following packages have been kept back:": "kept_back", - } - try: - out = subprocess.run( - ["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"], - capture_output=True, text=True, timeout=25) - reason: str | None = None - for line in (out.stdout or "").splitlines(): - hdr = sections.get(line.strip()) - if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln - reason = hdr - continue - if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts - for name in line.split(): - held.append({"name": name, "reason": reason}) - elif reason: # nicht eingerückt → Abschnitt zu Ende - reason = None - except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker - pass - held.sort(key=lambda p: p["name"]) - return held - - -def os_update_details() -> dict: - """Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche - Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back).""" - out_pkgs: list[dict] = [] - try: - # LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift - # (deutsche Box meldet sonst "[aktualisierbar von:]" und die Liste bliebe leer). - out = subprocess.run(["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null"], - capture_output=True, text=True, timeout=20) - for line in (out.stdout or "").splitlines(): - # Format: name/repo neue_version arch [upgradable from: alte_version] - m = re.match(r"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s*([^\]]+)\]", - line.strip()) - if m: - out_pkgs.append({"name": m.group(1), "candidate": m.group(2), - "current": m.group(3).strip()}) - out_pkgs.sort(key=lambda p: p["name"]) - return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, - "held_back": _os_held_back()} - except Exception as exc: # noqa: BLE001 - return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)} - - -def engine_update_details() -> dict: - """Installierte vs. neueste Engine-Build-Nummer + Release-Name/-Notizen/-Link.""" - info: dict = {"kind": "engine", "installed_build": _installed_engine_build(), - "latest_build": None, "latest_tag": None, "name": None, - "url": None, "body": None} - try: - rel = _latest_engine_asset_release() or {} - tag = str(rel.get("tag_name", "")) - info["latest_tag"] = tag - info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None - info["name"] = rel.get("name") or tag - info["url"] = rel.get("html_url") - body = (rel.get("body") or "").strip() - info["body"] = body[:2000] if body else None - # Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call, - # wenn das Modal bei aktuellem Stand geöffnet wird). - if body and info["latest_build"] and info["installed_build"] \ - and info["latest_build"] > info["installed_build"]: - ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle " - "Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).") - info.update(_summarize_release("engine", tag, ctx, body[:6000])) - except Exception as exc: # noqa: BLE001 - info["error"] = str(exc) - return info - - -def swap_update_details() -> dict: - """Installierte vs. neueste llama-swap-Version + Release-Name/-Notizen/-Link.""" - info: dict = {"kind": "swap", "installed_build": _installed_swap_version(), - "latest_build": None, "latest_tag": None, "name": None, - "url": None, "body": None} - try: - rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", - timeout=8, headers={"User-Agent": "MissionControl2"}).json() - tag = str(rel.get("tag_name", "")) - info["latest_tag"] = tag - info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None - info["name"] = rel.get("name") or tag - info["url"] = rel.get("html_url") - body = (rel.get("body") or "").strip() - info["body"] = body[:2000] if body else None - if body and info["latest_build"] and info["installed_build"] \ - and info["latest_build"] > info["installed_build"]: - ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box " - "verteilt und Sprachmodelle heiß nachlädt).") - info.update(_summarize_release("swap", tag, ctx, body[:6000])) - except Exception as exc: # noqa: BLE001 - info["error"] = str(exc) - return info - - -# LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes -# selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer -# (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …". -# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig -# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen. -_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}} - -# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — ". -# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut. -_ACTION_RX = re.compile( - r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE) - - -def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict: - """Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt - ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`.""" - empty = {"summary": "", "action_needed": None, "action_text": ""} - if not key: - return empty - cache = _relnotes_caches.setdefault(kind, {}) - if cache.get("key") == key and cache.get("data"): - return cache["data"] - from config import LLAMA_SWAP_URL - prompt = ( - "Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, " - "fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n" - f"{context}\n\n" - "Anstehende Änderungen:\n" + changes + "\n\n" - "Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n" - "Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n" - " • 'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das " - "Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der " - "Normalfall.\n" - " • 'AKTION: JA — ' — NUR wenn er wirklich selbst " - "handeln muss.\n" - "Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder " - "geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup " - "betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift." - ) - try: - r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={ - "model": "fast", "max_tokens": 550, "temperature": 0.2, - "chat_template_kwargs": {"enable_thinking": False}, - "messages": [{"role": "user", "content": prompt}], - }) - r.raise_for_status() - resp = r.json() - choice = (resp.get("choices") or [{}])[0] - text = ((choice.get("message") or {}).get("content") or "").strip() - finish_reason = choice.get("finish_reason") or "" - - # finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten → - # letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig. - if finish_reason == "length" and text: - head, _, _tail = text.rpartition("\n") - text = head if head else "" - - # Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1, - # aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile). - action_needed: bool | None = None - action_text = "" - lines = text.splitlines() - for idx, ln in enumerate(lines): - if m := _ACTION_RX.match(ln): - action_needed = m.group(1).upper() == "JA" - action_text = (m.group(2) or "").strip() - text = "\n".join(lines[:idx] + lines[idx + 1:]).strip() - break - - data = {"summary": text, "action_needed": action_needed, "action_text": action_text} - if text: - cache.update(key=key, data=data) - return data - except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker - return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})", - "action_needed": None, "action_text": ""} - - -def _summarize_hermes_commits(commits: list[dict]) -> dict: - subjects = "\n".join(f"- {c['subject']}" for c in commits[:100]) - context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf " - "Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', " - "terminal-/web-Tools, approvals, cron).") - return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects) - - -def hermes_update_details() -> dict: - """Commits, die ein Hermes-Update einspielen würde (HEAD..origin/) + LLM-Zusammenfassung.""" - info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []} - git = system.find_hermes_agent_git() - if not git or not git.get("path"): - info["error"] = "Hermes-Agent-Repo nicht gefunden." - return info - path = git["path"] - try: - branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, timeout=8).stdout.strip() or "main") - info["branch"] = branch - subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch], - capture_output=True, text=True, timeout=25) - log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr", - f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10) - commits = [] - for line in (log.stdout or "").splitlines(): - parts = line.split("\x1f") - if len(parts) == 3: - commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]}) - info["commits"] = commits - info["behind"] = len(commits) - if commits: - info.update(_summarize_hermes_commits(commits)) - except Exception as exc: # noqa: BLE001 - info["error"] = str(exc) - return info - - -def update_details(kind: str) -> dict: - return {"os": os_update_details, "engine": engine_update_details, - "swap": swap_update_details, - "hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})() - - -def _run(cmd: list[str], sudo_password: str | None = None) -> dict: - actual_cmd = list(cmd) - has_sudo = False - - if cmd and cmd[0] == "sudo": - has_sudo = True - # If we have a password, use -S instead of -n - if sudo_password is not None: - if "-n" in actual_cmd: - actual_cmd = [x for x in actual_cmd if x != "-n"] - if "-S" not in actual_cmd: - actual_cmd.insert(1, "-S") - else: - # Force -n to fail cleanly if password is required - if "-S" in actual_cmd: - actual_cmd = [x for x in actual_cmd if x != "-S"] - if "-n" not in actual_cmd: - actual_cmd.insert(1, "-n") - - try: - input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None - p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120) - - err_msg = p.stderr or "" - if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg): - if sudo_password is not None: - return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."} - return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."} - - return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]} - except Exception as exc: # noqa: BLE001 - return {"ok": False, "out": "", "err": str(exc)} - - -def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None: - """Checks if sudo needs a password. Returns error dict if password required/incorrect, else None.""" - res = _run(["sudo", "true"], sudo_password=sudo_password) - if not res["ok"]: - return res - return None - - -def restart_service(name: str, sudo_password: str | None = None) -> dict: - if name in SYSTEM_SERVICES: - if err := check_sudo_needs_password(sudo_password): - return err - return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password) - if name in USER_SERVICES: - return _run(["systemctl", "--user", "restart", name]) - return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."} - - -def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict: - lines = max(1, min(lines, 1000)) - if service in USER_SERVICES: - r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]) - return {"ok": r["ok"], "text": r["out"] or r["err"]} - if service in SYSTEM_SERVICES: - # Journal-Lesen braucht i.d.R. KEIN sudo (User ist in Gruppe adm/systemd-journal). - # Erst ohne sudo versuchen; nur bei fehlenden Rechten auf sudo zurückfallen. - r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"]) - if r["ok"]: - return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"} - if err := check_sudo_needs_password(sudo_password): - return err - r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"], - sudo_password=sudo_password) - return {"ok": r["ok"], "text": r["out"] or r["err"]} - return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} - -def check_updates_job(sudo_password: str | None = None) -> dict: - if err := check_sudo_needs_password(sudo_password): - return err - - def on_done(): - _engine_cache.update(ts=0.0, avail=False) - _comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen - - cmd = "sudo apt-get update" - job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password) - return {"ok": True, "job_id": job_id} - - -def _maintenance_busy() -> dict | None: - """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert - Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts).""" - if j := jobengine.active_in_group("maintenance"): - return {"ok": False, "status": "busy", "running": j.get("label")} - return None - - -def os_update_job(sudo_password: str | None = None) -> dict: - if busy := _maintenance_busy(): - return busy - if err := check_sudo_needs_password(sudo_password): - return err - # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). - # DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) — - # sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab. - cmd = ("sudo apt-get update && " - "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " - f"&& bash {STACK_POSTCHECK}") - job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", - group="maintenance", sudo_password=sudo_password) - return {"ok": True, "job_id": job_id} - - -def engine_update_job(sudo_password: str | None = None) -> dict | None: - if not ENGINE_UPDATE_CMD: - return None - if busy := _maintenance_busy(): - return busy - # KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben - # (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das - # Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war. - - def on_done(): - _engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich - - # update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den - # Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem - # neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. - job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], - "Engine-Update (llama.cpp Vulkan)", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id} - - -def swap_update_job(sudo_password: str | None = None) -> dict | None: - if not SWAP_UPDATE_CMD: - return None - if busy := _maintenance_busy(): - return busy - # KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine). - - def on_done(): - _swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich - - # update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack - # (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer - # Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. - job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD], - "Router-Update (llama-swap)", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id} - - -def hermes_update_job() -> dict: - """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach - den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo - (alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern).""" - if busy := _maintenance_busy(): - return busy - git = system.find_hermes_agent_git() - path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent") - py = os.path.join(path, "venv", "bin", "python") - backup = os.path.join(_REPO_ROOT, "deploy", "backup.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 → 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; " - f"cd {path} && {py} -m hermes_cli.main update --yes " - f"&& {py} -m hermes_cli.main doctor " - f"&& systemctl --user restart hermes-gateway " - f"&& sleep 6 && bash {postcheck}") - - def on_done(): - _comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen - - job_id = jobengine.start_job(["bash", "-c", cmd], "Hermes-Agent-Update", - group="maintenance", on_done=on_done) - return {"ok": True, "job_id": job_id} - - -def reboot(sudo_password: str | None = None) -> dict: - if err := check_sudo_needs_password(sudo_password): - return err - return _run(["sudo", "reboot"], sudo_password=sudo_password) +""" +Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware), +Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py). + +Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig +erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job. +""" + +import os +import re +import subprocess +import time +from datetime import datetime + +import httpx +import psutil + +from services import catalog, discover, jobengine, llamaswap, system + +# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user). +SYSTEM_SERVICES = {"llama-swap"} +USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-terminal", "mem0-service", "voice-service"} + +# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root). +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ENGINE_UPDATE_CMD = os.environ.get( + "MC_ENGINE_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-engine.sh") +# Engine = offizieller Vulkan-Build von ggml-org/llama.cpp (RADV auf Strix Halo). +ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan") +ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp") +_engine_cache = {"ts": 0.0, "avail": False} + +# Neueste Release, die WIRKLICH ein Vulkan-x64-Binary trägt. Die allerneueste Release hat +# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check +# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release +# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert. +_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I) + + +def _latest_engine_asset_release() -> dict | None: + try: + rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15", + timeout=8, headers={"User-Agent": "MissionControl2"}).json() + for rel in (rels if isinstance(rels, list) else []): + if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])): + return rel + except Exception: + pass + return None + +# Router = llama-swap (mostlygeek): proxyt Anfragen und wechselt die Modelle heiß. Eigenes +# Upstream-Projekt mit eigenem Release-Zyklus → getrennt von der Engine geführt. +SWAP_UPDATE_CMD = os.environ.get( + "MC_SWAP_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-swap.sh") +SWAP_BIN = os.environ.get("MC_SWAP_BIN", "/usr/local/bin/llama-swap") +SWAP_REPO = os.environ.get("MC_SWAP_REPO", "mostlygeek/llama-swap") +_swap_cache = {"ts": 0.0, "avail": False} + +# Stack-Funktionsprüfung NACH jedem Update (OS/Engine/Router): verifiziert per echter +# Inferenz, dass der Stack noch läuft → Job wird rot, wenn ein Update etwas zerschossen hat. +STACK_POSTCHECK = os.path.join(_REPO_ROOT, "deploy", "stack-postcheck.sh") + + +def _installed_engine_build() -> int | None: + """Build-Nummer der installierten llama-server-Binary (z.B. 9821), oder None. + Vulkan-Build braucht LD_LIBRARY_PATH=ENGINE_PATH zum Start von --version.""" + bin_path = os.path.join(ENGINE_PATH, "llama-server") + if not os.path.exists(bin_path): + return None + try: + env = dict(os.environ, LD_LIBRARY_PATH=ENGINE_PATH) + out = subprocess.run([bin_path, "--version"], capture_output=True, text=True, + timeout=20, env=env) + txt = (out.stderr or "") + (out.stdout or "") + # Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: (9821)", "b9821". + if (m := re.search(r"version:\s*(\d{3,})", txt)) \ + or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \ + or (m := re.search(r"\bb(\d{3,})\b", txt)): + return int(m.group(1)) + except Exception: + return None + return None + + +def _ram_gb() -> float: + return psutil.virtual_memory().total / (1024 ** 3) + + +def _os_upgradable() -> int: + try: + # LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt + # grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0. + out = subprocess.run( + ["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"], + capture_output=True, text=True, timeout=10) + return int((out.stdout or "0").strip() or 0) + except Exception: + return 0 + + +def _engine_update_available() -> bool: + now = time.time() + if now - _engine_cache["ts"] < 3600: + return _engine_cache["avail"] + avail = False + try: + rel = _latest_engine_asset_release() or {} + tag = str(rel.get("tag_name", "")) + latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None + installed = _installed_engine_build() + if latest is not None and installed is not None: + avail = latest > installed # präziser Build-Nummer-Vergleich + elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime + pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp() + avail = pub > os.path.getmtime(ENGINE_PATH) + 86400 + except Exception: + avail = False + _engine_cache.update(ts=now, avail=avail) + return avail + + +def _installed_swap_version() -> int | None: + """Versions-Nummer der installierten llama-swap-Binary (z.B. 228), oder None.""" + if not os.path.exists(SWAP_BIN): + return None + try: + out = subprocess.run([SWAP_BIN, "--version"], capture_output=True, text=True, timeout=15) + txt = (out.stdout or "") + (out.stderr or "") + if (m := re.search(r"version:\s*(\d+)", txt)) or (m := re.search(r"\bv?(\d{2,})\b", txt)): + return int(m.group(1)) + except Exception: + return None + return None + + +def _swap_update_available() -> bool: + now = time.time() + if now - _swap_cache["ts"] < 3600: + return _swap_cache["avail"] + avail = False + try: + rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", + timeout=6, headers={"User-Agent": "MissionControl2"}).json() + tag = str(rel.get("tag_name", "")) + latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None + installed = _installed_swap_version() + if latest is not None and installed is not None: + avail = latest > installed + except Exception: + avail = False + _swap_cache.update(ts=now, avail=avail) + return avail + + +_comp_cache = {"ts": 0.0, "data": []} + + +def _hermes_agent_update() -> dict: + """Hermes-Agent wird aus **git** aktualisiert (CLI `hermes update` = git pull origin ). + Darum HEAD vs. origin/ prüfen (fetch + behind-count) — NICHT GitHub-Releases: die + werden selten getaggt, main läuft ihnen voraus → sonst zeigt das UI nie ein Update an.""" + info = {"key": "hermes_agent", "name": "Hermes Agent", "current": None, + "latest": None, "update": False, "reachable": None} + git = system.find_hermes_agent_git() + if not git or not git.get("path"): + return info + path = git["path"] + info["current"] = git.get("hash") + try: + branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=8).stdout.strip() or "main") + fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch], + capture_output=True, text=True, timeout=25) + info["reachable"] = (fetch.returncode == 0) + if fetch.returncode == 0: + cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"], + capture_output=True, text=True, timeout=8) + behind = int(cnt.stdout.strip() or "0") if cnt.returncode == 0 else 0 + info["behind"] = behind + info["update"] = behind > 0 + oh = subprocess.run(["git", "-C", path, "rev-parse", "--short", f"origin/{branch}"], + capture_output=True, text=True, timeout=8).stdout.strip() + info["latest"] = (f"{oh} ({behind} neu)" if behind else (oh or info["current"])) + except Exception: + info["reachable"] = False + return info + + +def _components_cached() -> list[dict]: + """Update-Status von Hermes-Agent (1h-Cache → GitHub schonen).""" + now = time.time() + if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]: + return _comp_cache["data"] + data = [_hermes_agent_update()] + _comp_cache.update(ts=now, data=data) + return data + + +def _params_of(m: dict) -> float: + """Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung + und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab).""" + from services.fit import QUANT_BYTES_PER_PARAM + caps = m.get("capabilities") or {} + bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55) + size_gb = (m.get("size_bytes") or 0) / (1024 ** 3) + pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0 + return max(float(caps.get("params_b") or 0), pb_size, 0.0) + + +# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?"). +_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"), + ("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"), + ("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)")) + + +def _gen_key(name: str): + """(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0), + 'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar.""" + low = (name or "").lower() + sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \ + "-coder" if ("coder" in low or "-code" in low) else "" + for fam, pat in _FAM_PATS: + m = re.search(pat, low) + if m: + ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0 + return (fam + sub, ver) + return None + + +def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict: + """Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog, + sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik.""" + cm = catalog.meta_for_name(name) + if cm: + return {"family": cm.get("family"), "gen": cm.get("generation"), + "total": float(cm.get("total_params_b") or 0), + "active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))} + d = model_dict or {} + g = _gen_key(name) + total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0) + return {"family": (d.get("family") or (g[0] if g else None)), + "gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)), + "total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))} + + +def model_upgrades() -> list[dict]: + """Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist: + gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade + (MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens- + Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei.""" + disc = discover.safe_discover(_ram_gb()) + if not disc: + return [] + + installed = llamaswap.list_models() + inst_by_role = {m["role"]: m for m in installed if m.get("role")} + cmds = " ".join(str(s.get("cmd", "")).lower() + for s in (llamaswap.read_config().get("models") or {}).values()) + out = [] + + for c in disc.get("categories", []): + role = c["role"] + im = inst_by_role.get(role) + if im is None: + continue + rec = c.get("recommended") + if not rec: + continue + rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None) + + i = _meta(im["name"], im=im) + r = _meta(rec, model_dict=rec_model) + + if not i["family"] or not r["family"] or i["family"] != r["family"]: + continue # andere/unbekannte Familie → kein Upgrade + if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6: + continue # ältere Generation → niemals + same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6) + if same_gen: + if r["total"] and i["total"] and r["total"] < i["total"] * 1.05: + continue # gleiche Gen, nicht größer → kein Upgrade + # MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung + if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5: + continue + # Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn + ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"]) + if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3: + continue + + base = rec.split("/")[-1].lower() + stem = base[:-5] if base.endswith("-gguf") else base + if base in cmds or (stem and stem in cmds): + continue # schon installiert + out.append({"role": role, "title": c["title"], "repo": rec}) + return out + + +def _last_apt_update() -> float | None: + for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]: + if os.path.exists(path): + try: + return os.path.getmtime(path) + except Exception: + pass + return None + + +def updates() -> dict: + ups = model_upgrades() + return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0, + "swap": 1 if _swap_update_available() else 0, + "models": len(ups), "model_list": ups, "last_check": _last_apt_update(), + "components": _components_cached()} + + +# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ── + +def _os_held_back() -> list[dict]: + """Pakete, die apt aktuell NICHT einspielt, obwohl es sie gäbe — mit ehrlichem Grund. + Zwei Fälle, aus `apt-get -s upgrade` (Simulation) gelesen: + • Phasen-Rollout (Ubuntu staffelt Updates prozentual aus) → 'deferred due to phasing'. + • zurückgehalten wegen neuer Abhängigkeiten → 'kept back'. + Beides ist normal und löst sich von selbst — verhindert nur das 'hängt fest'-Gefühl, + wenn nach 'Fertig' noch aktualisierbare Pakete übrig scheinen.""" + held: list[dict] = [] + sections = { + "The following upgrades have been deferred due to phasing:": "phasing", + "The following packages have been kept back:": "kept_back", + } + try: + out = subprocess.run( + ["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"], + capture_output=True, text=True, timeout=25) + reason: str | None = None + for line in (out.stdout or "").splitlines(): + hdr = sections.get(line.strip()) + if hdr: # Abschnitts-Kopf → folgende Zeilen sammeln + reason = hdr + continue + if reason and line.startswith((" ", "\t")): # eingerückt = Paketnamen des Abschnitts + for name in line.split(): + held.append({"name": name, "reason": reason}) + elif reason: # nicht eingerückt → Abschnitt zu Ende + reason = None + except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker + pass + held.sort(key=lambda p: p["name"]) + return held + + +def os_update_details() -> dict: + """Liste der aktualisierbaren apt-Pakete (Name, installiert → Kandidat) + ehrliche + Anzeige der vom System zurückgestellten Pakete (Phasen-Rollout / kept back).""" + out_pkgs: list[dict] = [] + try: + # LC_ALL=C → englische Ausgabe, damit der Regex "[upgradable from: ...]" greift + # (deutsche Box meldet sonst "[aktualisierbar von:]" und die Liste bliebe leer). + out = subprocess.run(["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null"], + capture_output=True, text=True, timeout=20) + for line in (out.stdout or "").splitlines(): + # Format: name/repo neue_version arch [upgradable from: alte_version] + m = re.match(r"^([^/\s]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s*([^\]]+)\]", + line.strip()) + if m: + out_pkgs.append({"name": m.group(1), "candidate": m.group(2), + "current": m.group(3).strip()}) + out_pkgs.sort(key=lambda p: p["name"]) + return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, + "held_back": _os_held_back()} + except Exception as exc: # noqa: BLE001 + return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)} + + +def engine_update_details() -> dict: + """Installierte vs. neueste Engine-Build-Nummer + Release-Name/-Notizen/-Link.""" + info: dict = {"kind": "engine", "installed_build": _installed_engine_build(), + "latest_build": None, "latest_tag": None, "name": None, + "url": None, "body": None} + try: + rel = _latest_engine_asset_release() or {} + tag = str(rel.get("tag_name", "")) + info["latest_tag"] = tag + info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None + info["name"] = rel.get("name") or tag + info["url"] = rel.get("html_url") + body = (rel.get("body") or "").strip() + info["body"] = body[:2000] if body else None + # Nur zusammenfassen, wenn wirklich ein neuerer Build ansteht (spart einen LLM-Call, + # wenn das Modal bei aktuellem Stand geöffnet wird). + if body and info["latest_build"] and info["installed_build"] \ + and info["latest_build"] > info["installed_build"]: + ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle " + "Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).") + info.update(_summarize_release("engine", tag, ctx, body[:6000])) + except Exception as exc: # noqa: BLE001 + info["error"] = str(exc) + return info + + +def swap_update_details() -> dict: + """Installierte vs. neueste llama-swap-Version + Release-Name/-Notizen/-Link.""" + info: dict = {"kind": "swap", "installed_build": _installed_swap_version(), + "latest_build": None, "latest_tag": None, "name": None, + "url": None, "body": None} + try: + rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest", + timeout=8, headers={"User-Agent": "MissionControl2"}).json() + tag = str(rel.get("tag_name", "")) + info["latest_tag"] = tag + info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None + info["name"] = rel.get("name") or tag + info["url"] = rel.get("html_url") + body = (rel.get("body") or "").strip() + info["body"] = body[:2000] if body else None + if body and info["latest_build"] and info["installed_build"] \ + and info["latest_build"] > info["installed_build"]: + ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box " + "verteilt und Sprachmodelle heiß nachlädt).") + info.update(_summarize_release("swap", tag, ctx, body[:6000])) + except Exception as exc: # noqa: BLE001 + info["error"] = str(exc) + return info + + +# LLM-Zusammenfassung anstehender Updates in Lucys Stimme (die Box liest ihre Release-Notes +# selbst). Jede Zusammenfassung beginnt mit einem klaren Aktions-Verdikt für den Besitzer +# (kein Entwickler): "Musst du etwas tun? NEIN — das Fangnetz regelt das / JA: …". +# Gecacht je Komponente auf den neuesten Commit-Hash/Release-Tag — das Modal darf beliebig +# oft geöffnet werden, ohne das Modell jedes Mal neu zu befragen. +_relnotes_caches: dict = {"hermes": {}, "engine": {}, "swap": {}} + +# Erste Zeile jeder Modell-Antwort: "AKTION: NEIN" oder "AKTION: JA — ". +# Toleriert Markdown-Deko (**fett**, Bullet, Überschrift), die 'fast' gern einstreut. +_ACTION_RX = re.compile( + r"^[\s*_>#\-]*AKTION:?\s*\**\s*(JA|NEIN)\b[\s—:,.\-*_]*(.*?)[\s*_]*$", re.IGNORECASE) + + +def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict: + """Fasst Release-Notes/Commits in Lucys Stimme zusammen und trennt das Aktions-Verdikt + ab. Rückgabe: {summary, action_needed, action_text}. Cache je Komponente auf `key`.""" + empty = {"summary": "", "action_needed": None, "action_text": ""} + if not key: + return empty + cache = _relnotes_caches.setdefault(kind, {}) + if cache.get("key") == key and cache.get("data"): + return cache["data"] + from config import LLAMA_SWAP_URL + prompt = ( + "Du bist Lucy, die Stimme einer lokalen AI-Box, und erklärst dem Besitzer (KEIN Entwickler, " + "fasst nie eine Konsole an) ein anstehendes Update ruhig und verständlich.\n" + f"{context}\n\n" + "Anstehende Änderungen:\n" + changes + "\n\n" + "Antworte auf DEUTSCH, knapp und klar. HALTE DICH GENAU an dieses Format:\n" + "Zeile 1 ist das Aktions-Verdikt und beginnt mit 'AKTION: ':\n" + " • 'AKTION: NEIN' — wenn der Besitzer nichts tun muss (die Box spielt es selbst ein, das " + "Fangnetz prüft danach automatisch und rollt bei Problemen von allein zurück). Das ist der " + "Normalfall.\n" + " • 'AKTION: JA — ' — NUR wenn er wirklich selbst " + "handeln muss.\n" + "Danach maximal 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Breaking Changes oder " + "geänderte/entfernte Config-Schlüssel ZUERST und mit '⚠️' markiert, dann was unser Setup " + "betrifft, dann lohnende neue Features. Keine Einleitung, keine Überschrift." + ) + try: + r = httpx.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", timeout=90.0, json={ + "model": "fast", "max_tokens": 550, "temperature": 0.2, + "chat_template_kwargs": {"enable_thinking": False}, + "messages": [{"role": "user", "content": prompt}], + }) + r.raise_for_status() + resp = r.json() + choice = (resp.get("choices") or [{}])[0] + text = ((choice.get("message") or {}).get("content") or "").strip() + finish_reason = choice.get("finish_reason") or "" + + # finish_reason == "length" → Antwort wurde wegen Token-Limits abgeschnitten → + # letzten (unvollständigen) Stichpunkt entfernen. Bei "stop"/None → vollständig. + if finish_reason == "length" and text: + head, _, _tail = text.rpartition("\n") + text = head if head else "" + + # Aktions-Verdikt herauslösen (strukturiertes Feld fürs UI). Normalerweise Zeile 1, + # aber tolerant: erste passende Zeile suchen (Modell startet manchmal mit Leerzeile). + action_needed: bool | None = None + action_text = "" + lines = text.splitlines() + for idx, ln in enumerate(lines): + if m := _ACTION_RX.match(ln): + action_needed = m.group(1).upper() == "JA" + action_text = (m.group(2) or "").strip() + text = "\n".join(lines[:idx] + lines[idx + 1:]).strip() + break + + data = {"summary": text, "action_needed": action_needed, "action_text": action_text} + if text: + cache.update(key=key, data=data) + return data + except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker + return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})", + "action_needed": None, "action_text": ""} + + +def _summarize_hermes_commits(commits: list[dict]) -> dict: + subjects = "\n".join(f"- {c['subject']}" for c in commits[:100]) + context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf " + "Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', " + "terminal-/web-Tools, approvals, cron).") + return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects) + + +def hermes_update_details() -> dict: + """Commits, die ein Hermes-Update einspielen würde (HEAD..origin/) + LLM-Zusammenfassung.""" + info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []} + git = system.find_hermes_agent_git() + if not git or not git.get("path"): + info["error"] = "Hermes-Agent-Repo nicht gefunden." + return info + path = git["path"] + try: + branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=8).stdout.strip() or "main") + info["branch"] = branch + subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch], + capture_output=True, text=True, timeout=25) + log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr", + f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10) + commits = [] + for line in (log.stdout or "").splitlines(): + parts = line.split("\x1f") + if len(parts) == 3: + commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]}) + info["commits"] = commits + info["behind"] = len(commits) + if commits: + info.update(_summarize_hermes_commits(commits)) + except Exception as exc: # noqa: BLE001 + info["error"] = str(exc) + return info + + +def update_details(kind: str) -> dict: + return {"os": os_update_details, "engine": engine_update_details, + "swap": swap_update_details, + "hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})() + + +def _run(cmd: list[str], sudo_password: str | None = None) -> dict: + actual_cmd = list(cmd) + has_sudo = False + + if cmd and cmd[0] == "sudo": + has_sudo = True + # If we have a password, use -S instead of -n + if sudo_password is not None: + if "-n" in actual_cmd: + actual_cmd = [x for x in actual_cmd if x != "-n"] + if "-S" not in actual_cmd: + actual_cmd.insert(1, "-S") + else: + # Force -n to fail cleanly if password is required + if "-S" in actual_cmd: + actual_cmd = [x for x in actual_cmd if x != "-S"] + if "-n" not in actual_cmd: + actual_cmd.insert(1, "-n") + + try: + input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None + p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120) + + err_msg = p.stderr or "" + if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg): + if sudo_password is not None: + return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."} + return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."} + + return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]} + except Exception as exc: # noqa: BLE001 + return {"ok": False, "out": "", "err": str(exc)} + + +def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None: + """Checks if sudo needs a password. Returns error dict if password required/incorrect, else None.""" + res = _run(["sudo", "true"], sudo_password=sudo_password) + if not res["ok"]: + return res + return None + + +def restart_service(name: str, sudo_password: str | None = None) -> dict: + if name in SYSTEM_SERVICES: + if err := check_sudo_needs_password(sudo_password): + return err + return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password) + if name in USER_SERVICES: + return _run(["systemctl", "--user", "restart", name]) + return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."} + + +def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict: + lines = max(1, min(lines, 1000)) + if service in USER_SERVICES: + r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"]) + return {"ok": r["ok"], "text": r["out"] or r["err"]} + if service in SYSTEM_SERVICES: + # Journal-Lesen braucht i.d.R. KEIN sudo (User ist in Gruppe adm/systemd-journal). + # Erst ohne sudo versuchen; nur bei fehlenden Rechten auf sudo zurückfallen. + r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"]) + if r["ok"]: + return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"} + if err := check_sudo_needs_password(sudo_password): + return err + r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"], + sudo_password=sudo_password) + return {"ok": r["ok"], "text": r["out"] or r["err"]} + return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} + +def check_updates_job(sudo_password: str | None = None) -> dict: + if err := check_sudo_needs_password(sudo_password): + return err + + def on_done(): + _engine_cache.update(ts=0.0, avail=False) + _comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen + + cmd = "sudo apt-get update" + job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password) + return {"ok": True, "job_id": job_id} + + +def _maintenance_busy() -> dict | None: + """Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert + Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts).""" + if j := jobengine.active_in_group("maintenance"): + return {"ok": False, "status": "busy", "running": j.get("label")} + return None + + +def os_update_job(sudo_password: str | None = None) -> dict: + if busy := _maintenance_busy(): + return busy + if err := check_sudo_needs_password(sudo_password): + return err + # Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging). + # DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) — + # sonst lehnt sudos env-Policy die Variable ggf. ab und das Upgrade bricht ab. + cmd = ("sudo apt-get update && " + "sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' " + f"&& bash {STACK_POSTCHECK}") + job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", + group="maintenance", sudo_password=sudo_password) + return {"ok": True, "job_id": job_id} + + +def engine_update_job(sudo_password: str | None = None) -> dict | None: + if not ENGINE_UPDATE_CMD: + return None + if busy := _maintenance_busy(): + return busy + # KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben + # (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das + # Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war. + + def on_done(): + _engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich + + # update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den + # Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem + # neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. + job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD], + "Engine-Update (llama.cpp Vulkan)", + group="maintenance", on_done=on_done) + return {"ok": True, "job_id": job_id} + + +def swap_update_job(sudo_password: str | None = None) -> dict | None: + if not SWAP_UPDATE_CMD: + return None + if busy := _maintenance_busy(): + return busy + # KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine). + + def on_done(): + _swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich + + # update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack + # (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer + # Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge. + job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD], + "Router-Update (llama-swap)", + group="maintenance", on_done=on_done) + return {"ok": True, "job_id": job_id} + + +def hermes_update_job() -> dict: + """Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach + den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo + (alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern).""" + if busy := _maintenance_busy(): + return busy + git = system.find_hermes_agent_git() + path = (git or {}).get("path") or os.path.expanduser("~/.hermes/hermes-agent") + py = os.path.join(path, "venv", "bin", "python") + backup = os.path.join(_REPO_ROOT, "deploy", "backup.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 → 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; " + f"cd {path} && {py} -m hermes_cli.main update --yes " + f"&& {py} -m hermes_cli.main doctor " + f"&& systemctl --user restart hermes-gateway " + f"&& sleep 6 && bash {postcheck}") + + def on_done(): + _comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen + + job_id = jobengine.start_job(["bash", "-c", cmd], "Hermes-Agent-Update", + group="maintenance", on_done=on_done) + return {"ok": True, "job_id": job_id} + + +def reboot(sudo_password: str | None = None) -> dict: + if err := check_sudo_needs_password(sudo_password): + return err + return _run(["sudo", "reboot"], sudo_password=sudo_password) diff --git a/backend/services/memory.py b/backend/services/memory.py index db53473..75d6dc0 100644 --- a/backend/services/memory.py +++ b/backend/services/memory.py @@ -161,7 +161,7 @@ def dedupe(apply: bool = False, threshold: float = 0.85) -> dict: AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0" AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start -AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.9")) +AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75")) async def auto_dedupe_loop() -> None: diff --git a/backend/services/pricing.py b/backend/services/pricing.py index bcad84c..f798a42 100644 --- a/backend/services/pricing.py +++ b/backend/services/pricing.py @@ -1,58 +1,58 @@ -"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit). - -Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer -Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so -eingesparte Summe. Wird vom System-Router dünn aufgerufen. -""" - -import os - -# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M). -PRICING: dict[str, tuple[float, float]] = { - "heavy": (15.0, 75.0), - "coder": (3.0, 15.0), - "hermes": (1.0, 5.0), - "fast": (0.15, 0.60), - "scout": (0.15, 0.60), - "vision": (0.15, 0.60), - "reasoning": (0.15, 0.60), -} -# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse). -DEFAULT_RATE: tuple[float, float] = (0.15, 0.60) -# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten, -# damit historische Ersparnis erhalten bleibt. -BASELINE_RATE: tuple[float, float] = PRICING["heavy"] -USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92")) - - -def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict: - """Aggregiert Tokens und berechnet die Cloud-Ersparnis. - - role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung. - """ - prompt = stats.get("prompt_tokens", 0) - completion = stats.get("completion_tokens", 0) - - modeled_p = modeled_c = 0 - saved_usd = 0.0 - for m_name, m_tokens in (stats.get("models") or {}).items(): - mp = m_tokens.get("prompt", 0) - mc = m_tokens.get("completion", 0) - modeled_p += mp - modeled_c += mc - role = role_map.get(m_name, m_name) - rate_in, rate_out = PRICING.get(role, DEFAULT_RATE) - saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0 - - baseline_p = max(0, prompt - modeled_p) - baseline_c = max(0, completion - modeled_c) - saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0 - - return { - "prompt_tokens": prompt, - "completion_tokens": completion, - "total_tokens": prompt + completion, - "saved_usd": round(saved_usd, 2), - "saved_eur": round(saved_usd * USD_TO_EUR, 2), - "pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()}, - } +"""Kosten-/Ersparnis-Berechnung für die Token-Statistik (eine Quelle der Wahrheit). + +Vergleicht die lokal verbrauchten Tokens gegen die Cloud-Listenpreise vergleichbarer +Modellklassen (Stand Juni 2026, USD pro 1M Tokens, in/out) und liefert die so +eingesparte Summe. Wird vom System-Router dünn aufgerufen. +""" + +import os + +# Cloud-Listenpreise je Rolle/Modellklasse: (input_usd_per_1M, output_usd_per_1M). +PRICING: dict[str, tuple[float, float]] = { + "heavy": (15.0, 75.0), + "coder": (3.0, 15.0), + "hermes": (1.0, 5.0), + "fast": (0.15, 0.60), + "scout": (0.15, 0.60), + "vision": (0.15, 0.60), + "reasoning": (0.15, 0.60), +} +# Tarif für nicht zuordenbare Tokens (Default-/Fallback-Klasse). +DEFAULT_RATE: tuple[float, float] = (0.15, 0.60) +# Baseline/Legacy-Tokens (vor modellspezifischem Logging) am Premium-Tarif bewerten, +# damit historische Ersparnis erhalten bleibt. +BASELINE_RATE: tuple[float, float] = PRICING["heavy"] +USD_TO_EUR = float(os.environ.get("MC_USD_TO_EUR", "0.92")) + + +def compute_savings(stats: dict, role_map: dict[str, str | None]) -> dict: + """Aggregiert Tokens und berechnet die Cloud-Ersparnis. + + role_map: Modell-/Alias-Name (lowercase) -> Rolle, zur Tarif-Auflösung. + """ + prompt = stats.get("prompt_tokens", 0) + completion = stats.get("completion_tokens", 0) + + modeled_p = modeled_c = 0 + saved_usd = 0.0 + for m_name, m_tokens in (stats.get("models") or {}).items(): + mp = m_tokens.get("prompt", 0) + mc = m_tokens.get("completion", 0) + modeled_p += mp + modeled_c += mc + role = role_map.get(m_name, m_name) + rate_in, rate_out = PRICING.get(role, DEFAULT_RATE) + saved_usd += (mp * rate_in + mc * rate_out) / 1_000_000.0 + + baseline_p = max(0, prompt - modeled_p) + baseline_c = max(0, completion - modeled_c) + saved_usd += (baseline_p * BASELINE_RATE[0] + baseline_c * BASELINE_RATE[1]) / 1_000_000.0 + + return { + "prompt_tokens": prompt, + "completion_tokens": completion, + "total_tokens": prompt + completion, + "saved_usd": round(saved_usd, 2), + "saved_eur": round(saved_usd * USD_TO_EUR, 2), + "pricing": {role: {"in": r[0], "out": r[1]} for role, r in PRICING.items()}, + } diff --git a/backend/services/routing_policy.py b/backend/services/routing_policy.py index cf83d1a..6137e23 100644 --- a/backend/services/routing_policy.py +++ b/backend/services/routing_policy.py @@ -1,127 +1,127 @@ -""" -UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat). - -Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json — -gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur -bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher -einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten. - -Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die -bleiben in router_logic.py im Code. -""" - -import json -import os -import threading -from pathlib import Path - -from config import MODELS_DIR - -POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json"))) - - -def _env_bool(name: str, default: str) -> bool: - return os.environ.get(name, default) not in ("0", "false", "") - - -# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert. -DEFAULTS: dict = { - "fast": os.environ.get("MC_ROUTE_FAST", "fast"), - "heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"), - "coder": os.environ.get("MC_ROUTE_CODER", "coder"), - "coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""), - "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), - "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")), - "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), -} - -# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung. -FIELDS: list[dict] = [ - {"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"}, - {"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"}, - {"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"}, - {"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"}, - {"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000}, - {"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000}, - {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"}, -] - -_LOCK = threading.Lock() -_CACHE: dict = {"mtime": None, "policy": None} - - -def _read_file() -> dict: - try: - with open(POLICY_PATH, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except (FileNotFoundError, json.JSONDecodeError, OSError): - return {} - - -def _coerce(patch: dict) -> dict: - """Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten.""" - spec = {f["key"]: f for f in FIELDS} - out: dict = {} - for k, v in (patch or {}).items(): - f = spec.get(k) - if not f: - continue # unbekannte Keys still verwerfen - if f["type"] == "int": - iv = int(v) - lo, hi = f.get("min", 1), f.get("max", 10**9) - if not (lo <= iv <= hi): - raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]") - out[k] = iv - elif f["type"] == "bool": - out[k] = bool(v) - else: # str - sv = str(v).strip() - if k != "coder_lite" and not sv: - raise ValueError(f"{k} darf nicht leer sein") - out[k] = sv - return out - - -def _coerce_safe(patch: dict) -> dict: - """Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen.""" - try: - return _coerce(patch) - except (ValueError, TypeError): - return {} - - -def load_policy() -> dict: - """Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig.""" - try: - mtime = POLICY_PATH.stat().st_mtime - except OSError: - mtime = None - with _LOCK: - if _CACHE["policy"] is None or _CACHE["mtime"] != mtime: - merged = {**DEFAULTS} - if mtime is not None: - merged.update(_coerce_safe(_read_file())) - _CACHE["mtime"] = mtime - _CACHE["policy"] = merged - return dict(_CACHE["policy"]) - - -def save_policy(patch: dict) -> dict: - """Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück.""" - clean = _coerce(patch) # wirft bei ungültigem Input - with _LOCK: - current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean} - POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) - tmp = POLICY_PATH.with_suffix(".json.tmp") - with open(tmp, "w", encoding="utf-8") as f: - json.dump(current, f, ensure_ascii=False, indent=2) - os.replace(tmp, POLICY_PATH) - _CACHE["mtime"] = None # nächster load_policy() lädt frisch - _CACHE["policy"] = None - return current - - -def policy_meta() -> dict: - """Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation.""" - return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS} +""" +UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat). + +Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json — +gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur +bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher +einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten. + +Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die +bleiben in router_logic.py im Code. +""" + +import json +import os +import threading +from pathlib import Path + +from config import MODELS_DIR + +POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json"))) + + +def _env_bool(name: str, default: str) -> bool: + return os.environ.get(name, default) not in ("0", "false", "") + + +# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert. +DEFAULTS: dict = { + "fast": os.environ.get("MC_ROUTE_FAST", "fast"), + "heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"), + "coder": os.environ.get("MC_ROUTE_CODER", "coder"), + "coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""), + "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), + "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")), + "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), +} + +# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung. +FIELDS: list[dict] = [ + {"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"}, + {"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"}, + {"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"}, + {"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"}, + {"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000}, + {"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000}, + {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"}, +] + +_LOCK = threading.Lock() +_CACHE: dict = {"mtime": None, "policy": None} + + +def _read_file() -> dict: + try: + with open(POLICY_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + + +def _coerce(patch: dict) -> dict: + """Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten.""" + spec = {f["key"]: f for f in FIELDS} + out: dict = {} + for k, v in (patch or {}).items(): + f = spec.get(k) + if not f: + continue # unbekannte Keys still verwerfen + if f["type"] == "int": + iv = int(v) + lo, hi = f.get("min", 1), f.get("max", 10**9) + if not (lo <= iv <= hi): + raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]") + out[k] = iv + elif f["type"] == "bool": + out[k] = bool(v) + else: # str + sv = str(v).strip() + if k != "coder_lite" and not sv: + raise ValueError(f"{k} darf nicht leer sein") + out[k] = sv + return out + + +def _coerce_safe(patch: dict) -> dict: + """Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen.""" + try: + return _coerce(patch) + except (ValueError, TypeError): + return {} + + +def load_policy() -> dict: + """Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig.""" + try: + mtime = POLICY_PATH.stat().st_mtime + except OSError: + mtime = None + with _LOCK: + if _CACHE["policy"] is None or _CACHE["mtime"] != mtime: + merged = {**DEFAULTS} + if mtime is not None: + merged.update(_coerce_safe(_read_file())) + _CACHE["mtime"] = mtime + _CACHE["policy"] = merged + return dict(_CACHE["policy"]) + + +def save_policy(patch: dict) -> dict: + """Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück.""" + clean = _coerce(patch) # wirft bei ungültigem Input + with _LOCK: + current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean} + POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = POLICY_PATH.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(current, f, ensure_ascii=False, indent=2) + os.replace(tmp, POLICY_PATH) + _CACHE["mtime"] = None # nächster load_policy() lädt frisch + _CACHE["policy"] = None + return current + + +def policy_meta() -> dict: + """Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation.""" + return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS} diff --git a/backend/services/token_stats.py b/backend/services/token_stats.py index 66fc5e5..f19d7df 100644 --- a/backend/services/token_stats.py +++ b/backend/services/token_stats.py @@ -1,99 +1,99 @@ -"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren. - -Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben -(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen -gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie -beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand. -""" - -import atexit -import json -import logging -import threading -import time -from pathlib import Path - -from config import HERMES_HOME - -STATS_FILE = HERMES_HOME / "token_stats.json" -FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes -# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging). -_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}} - -log = logging.getLogger(__name__) - -_lock = threading.Lock() -_stats: dict | None = None -_dirty = False -_last_flush = 0.0 - - -def _load_from_disk() -> dict: - if not STATS_FILE.exists(): - return dict(_BASELINE) - try: - with open(STATS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - data.setdefault("prompt_tokens", 0) - data.setdefault("completion_tokens", 0) - data.setdefault("models", {}) - return data - except (OSError, json.JSONDecodeError): - log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True) - return dict(_BASELINE) - - -def _ensure_loaded() -> dict: - global _stats - if _stats is None: - _stats = _load_from_disk() - return _stats - - -def _write(stats: dict) -> None: - try: - STATS_FILE.parent.mkdir(parents=True, exist_ok=True) - tmp = STATS_FILE.with_suffix(".tmp") - with open(tmp, "w", encoding="utf-8") as f: - json.dump(stats, f) - tmp.replace(STATS_FILE) - except OSError: - log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True) - - -def get_stats() -> dict: - """Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.""" - with _lock: - return json.loads(json.dumps(_ensure_loaded())) - - -def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None: - """Tokens im Cache verbuchen; gedrosselt auf Disk persistieren.""" - global _dirty, _last_flush - with _lock: - stats = _ensure_loaded() - stats["prompt_tokens"] += prompt - stats["completion_tokens"] += completion - if model: - m = stats.setdefault("models", {}).setdefault( - model.lower(), {"prompt": 0, "completion": 0}) - m["prompt"] += prompt - m["completion"] += completion - _dirty = True - now = time.monotonic() - if now - _last_flush >= FLUSH_INTERVAL: - _write(stats) - _dirty = False - _last_flush = now - - -def flush() -> None: - """Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown).""" - global _dirty - with _lock: - if _dirty and _stats is not None: - _write(_stats) - _dirty = False - - -atexit.register(flush) +"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren. + +Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben +(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen +gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie +beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand. +""" + +import atexit +import json +import logging +import threading +import time +from pathlib import Path + +from config import HERMES_HOME + +STATS_FILE = HERMES_HOME / "token_stats.json" +FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes +# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging). +_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}} + +log = logging.getLogger(__name__) + +_lock = threading.Lock() +_stats: dict | None = None +_dirty = False +_last_flush = 0.0 + + +def _load_from_disk() -> dict: + if not STATS_FILE.exists(): + return dict(_BASELINE) + try: + with open(STATS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + data.setdefault("prompt_tokens", 0) + data.setdefault("completion_tokens", 0) + data.setdefault("models", {}) + return data + except (OSError, json.JSONDecodeError): + log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True) + return dict(_BASELINE) + + +def _ensure_loaded() -> dict: + global _stats + if _stats is None: + _stats = _load_from_disk() + return _stats + + +def _write(stats: dict) -> None: + try: + STATS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = STATS_FILE.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(stats, f) + tmp.replace(STATS_FILE) + except OSError: + log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True) + + +def get_stats() -> dict: + """Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie.""" + with _lock: + return json.loads(json.dumps(_ensure_loaded())) + + +def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None: + """Tokens im Cache verbuchen; gedrosselt auf Disk persistieren.""" + global _dirty, _last_flush + with _lock: + stats = _ensure_loaded() + stats["prompt_tokens"] += prompt + stats["completion_tokens"] += completion + if model: + m = stats.setdefault("models", {}).setdefault( + model.lower(), {"prompt": 0, "completion": 0}) + m["prompt"] += prompt + m["completion"] += completion + _dirty = True + now = time.monotonic() + if now - _last_flush >= FLUSH_INTERVAL: + _write(stats) + _dirty = False + _last_flush = now + + +def flush() -> None: + """Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown).""" + global _dirty + with _lock: + if _dirty and _stats is not None: + _write(_stats) + _dirty = False + + +atexit.register(flush) diff --git a/backend/services/warmer.py b/backend/services/warmer.py index 05cad9d..2b43550 100644 --- a/backend/services/warmer.py +++ b/backend/services/warmer.py @@ -1,129 +1,129 @@ -""" -Hält das ganze WARM-SET (Hirn + Augen/vision + Gedächtnis/embed) dauerhaft warm. - -Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb -der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur -Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config- -Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen. -Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze -Set über deploy/warmup.sh nach — NICHT nur das Hirn (sonst blieben Augen+embed kalt, live -vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich -raus, verdrängt also nie ein gerade genutztes Modell. - -warmup.sh deckt korrekt ab: fast+vision über /v1/chat/completions, embed über /v1/embeddings -(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend. - -Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle -warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast vision') + MC_WARMUP_EMBED (Default 'embed'). -""" - -import asyncio -import logging -import os -import subprocess - -import httpx - -from config import LLAMA_SWAP_URL - -log = logging.getLogger(__name__) - -ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0" -INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks -START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25")) -NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen - -# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das -# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen. -_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh")) - -# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten). -# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die -# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL -# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt. -_loop: asyncio.AbstractEventLoop | None = None -_wake: asyncio.Event | None = None - - -def nudge() -> None: - """Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op, - solange der Wächter (noch) nicht läuft.""" - if _loop is not None and _wake is not None and not _loop.is_closed(): - try: - _loop.call_soon_threadsafe(_wake.set) - except RuntimeError: - pass - - -def _run_warmup() -> bool: - """Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions, - embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht. - False, wenn das Skript fehlt.""" - if not os.path.exists(_WARMUP_SH): - log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH) - return False - try: - subprocess.Popen(["bash", _WARMUP_SH], - env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL}, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - return True - except Exception: - log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True) - return False - - -def _warmset_members() -> set[str]: - """Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent).""" - try: - from services import llamaswap - groups = llamaswap.list_groups() or {} - except Exception: - return set() - want: set[str] = set() - for g in groups.values(): - if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")): - want.update(g.get("members") or []) - return want - - -async def _warmset_missing() -> list[str] | None: - """Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running - nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der - Fall, der nach einem watch-config-Reload/Deploy auftritt.""" - try: - async with httpx.AsyncClient(timeout=8.0) as c: - r = await c.get(f"{LLAMA_SWAP_URL}/running") - data = r.json() or {} - except Exception: - return None - ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"} - want = _warmset_members() - if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer - return [] if ready else [""] - return [m for m in want if m not in ready] - - -async def rewarm_loop() -> None: - """Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein - Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach.""" - global _loop, _wake - _loop = asyncio.get_running_loop() - _wake = asyncio.Event() - await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen - while True: - try: - missing = await _warmset_missing() - if missing: - log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing)) - _run_warmup() - except Exception: - log.debug("rewarm: Tick fehlgeschlagen", exc_info=True) - # Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren. - try: - await asyncio.wait_for(_wake.wait(), timeout=INTERVAL) - _wake.clear() - await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen - log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)") - _run_warmup() - except asyncio.TimeoutError: - pass +""" +Hält das ganze WARM-SET (Hirn + Augen/vision + Gedächtnis/embed) dauerhaft warm. + +Hintergrund: llama-swap ist EIN-Gruppen-resident — lädt ein on-demand-Modell außerhalb +der `brains`-Gruppe, wird die ganze Gruppe verdrängt. `persist: true` verhindert nur +Idle-Unload, NICHT die Gruppen-Verdrängung; auch ein `-watch-config`-Reload (jede Config- +Änderung/Deploy) verwirft das Set, ohne llama-swaps ExecStartPost-Warmup neu auszulösen. +Dieser Wächter schließt die Lücke: ist die Box idle (nichts geladen), lädt er das ganze +Set über deploy/warmup.sh nach — NICHT nur das Hirn (sonst blieben Augen+embed kalt, live +vom Review-Wächter beobachtet). Während aktiver Last (irgendetwas geladen) hält er sich +raus, verdrängt also nie ein gerade genutztes Modell. + +warmup.sh deckt korrekt ab: fast+vision über /v1/chat/completions, embed über /v1/embeddings +(anderer Endpunkt!) und das Vorkauen des ~70-KB-Agent-Prompts. Es ist selbst-detachend. + +Abschaltbar/justierbar via Env: MC_REWARM_ENABLED=0, MC_REWARM_INTERVAL. Welche Modelle +warmup.sh lädt: MC_WARMUP_MODELS (Default 'fast vision') + MC_WARMUP_EMBED (Default 'embed'). +""" + +import asyncio +import logging +import os +import subprocess + +import httpx + +from config import LLAMA_SWAP_URL + +log = logging.getLogger(__name__) + +ENABLED = os.environ.get("MC_REWARM_ENABLED", "1") != "0" +INTERVAL = int(os.environ.get("MC_REWARM_INTERVAL", "90")) # Sekunden zwischen Checks +START_DELAY = int(os.environ.get("MC_REWARM_START_DELAY", "25")) +NUDGE_GRACE = int(os.environ.get("MC_REWARM_NUDGE_GRACE", "3")) # llama-swap den Reload abschließen lassen + +# Das geteilte Warm-Skript (auch llama-swaps ExecStartPost) — EINE Quelle für „was ist das +# Warm-Set und wie wärmt man es korrekt", statt hier eine zweite, ärmere Logik zu pflegen. +_WARMUP_SH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "deploy", "warmup.sh")) + +# Wecksignal für einen sofortigen Vorwärm-Check (statt bis zum nächsten INTERVAL-Tick zu warten). +# Wird von write_config() nach einer Config-Änderung gesetzt: llama-swap (-watch-config) lädt die +# neue Config und verwirft dabei ALLE Modelle inkl. Hirn — ohne Nudge bliebe es bis zu INTERVAL +# Sekunden kalt liegen, bis der nächste Tick oder eine Anfrage es wieder lädt. +_loop: asyncio.AbstractEventLoop | None = None +_wake: asyncio.Event | None = None + + +def nudge() -> None: + """Threadsicher: bittet den Wächter, nach einem Config-Reload bald vorzuwärmen. No-op, + solange der Wächter (noch) nicht läuft.""" + if _loop is not None and _wake is not None and not _loop.is_closed(): + try: + _loop.call_soon_threadsafe(_wake.set) + except RuntimeError: + pass + + +def _run_warmup() -> bool: + """Volles Warm-Set via deploy/warmup.sh nachladen (fast+vision über /v1/chat/completions, + embed über /v1/embeddings, plus Agent-Prompt-Prefill). Selbst-detachend, blockiert nicht. + False, wenn das Skript fehlt.""" + if not os.path.exists(_WARMUP_SH): + log.warning("rewarm: warmup.sh nicht gefunden (%s) — kein Nachwärmen möglich", _WARMUP_SH) + return False + try: + subprocess.Popen(["bash", _WARMUP_SH], + env={**os.environ, "MC_LLAMA_SWAP_URL": LLAMA_SWAP_URL}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True + except Exception: + log.debug("rewarm: warmup.sh-Start fehlgeschlagen", exc_info=True) + return False + + +def _warmset_members() -> set[str]: + """Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent).""" + try: + from services import llamaswap + groups = llamaswap.list_groups() or {} + except Exception: + return set() + want: set[str] = set() + for g in groups.values(): + if isinstance(g, dict) and (g.get("swap") is False or g.get("persist") or g.get("persistent")): + want.update(g.get("members") or []) + return want + + +async def _warmset_missing() -> list[str] | None: + """Warm-Set-Mitglieder, die NICHT 'ready' in /running sind. [] = alles warm, None = /running + nicht lesbar. Erkennt auch TEIL-Kälte (nur Hirn warm, Augen/embed rausgefallen) — genau der + Fall, der nach einem watch-config-Reload/Deploy auftritt.""" + try: + async with httpx.AsyncClient(timeout=8.0) as c: + r = await c.get(f"{LLAMA_SWAP_URL}/running") + data = r.json() or {} + except Exception: + return None + ready = {str(x.get("model")) for x in (data.get("running") or []) if x.get("state") == "ready"} + want = _warmset_members() + if not want: # Set unbekannt → alte Heuristik: nur bei ganz leer + return [] if ready else [""] + return [m for m in want if m not in ready] + + +async def rewarm_loop() -> None: + """Endlos-Schleife (Hintergrund-Task): hält das ganze Warm-Set warm. Prüft periodisch, ob ein + Mitglied fehlt (Teil-Kälte!), und sofort nach einem Config-Reload-Nudge — lädt via warmup.sh nach.""" + global _loop, _wake + _loop = asyncio.get_running_loop() + _wake = asyncio.Event() + await asyncio.sleep(START_DELAY) # Box/Engine nach MC-Start setzen lassen + while True: + try: + missing = await _warmset_missing() + if missing: + log.info("rewarm: Warm-Set unvollständig (%s) → warmup.sh", ", ".join(missing)) + _run_warmup() + except Exception: + log.debug("rewarm: Tick fehlgeschlagen", exc_info=True) + # Bis zum nächsten Tick warten ODER sofort auf einen Config-Reload-Nudge reagieren. + try: + await asyncio.wait_for(_wake.wait(), timeout=INTERVAL) + _wake.clear() + await asyncio.sleep(NUDGE_GRACE) # llama-swap den Reload abschließen lassen + log.info("rewarm: Config-Reload → warmup.sh (volles Warm-Set nachladen)") + _run_warmup() + except asyncio.TimeoutError: + pass diff --git a/deploy/llama-swap.config.yaml b/deploy/llama-swap.config.yaml index 90a5a6e..31a0a3b 100644 --- a/deploy/llama-swap.config.yaml +++ b/deploy/llama-swap.config.yaml @@ -1,115 +1,115 @@ -# 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 0 (03.07.): Mitglied des Immer-bereit-Sets — ein Selbstauslöser widerspräche dem UI-Versprechen. - ttl: 0 - 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: - # Lucys AUGEN (vision, seit 03.07. live via set_role/set_group): MoE 3B aktiv, versteht die - # Bildschirm-SZENE statt nur OCR. Als brains-Mitglied MUSS ttl:0 sein — persistent schuetzt nur - # gegen Verdraengung, NICHT gegen ttl-Selbstentladen (sonst faellt es alle 5 Min raus). - 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: 0 - 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 - # llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der - # MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen! - persist: true - persistent: true - members: - - Qwen3-Embedding-0.6B - - Qwen3-VL-8B-Instruct - - Qwen3.6-35B-A3B +# 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 0 (03.07.): Mitglied des Immer-bereit-Sets — ein Selbstauslöser widerspräche dem UI-Versprechen. + ttl: 0 + 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: + # Lucys AUGEN (vision, seit 03.07. live via set_role/set_group): MoE 3B aktiv, versteht die + # Bildschirm-SZENE statt nur OCR. Als brains-Mitglied MUSS ttl:0 sein — persistent schuetzt nur + # gegen Verdraengung, NICHT gegen ttl-Selbstentladen (sonst faellt es alle 5 Min raus). + 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: 0 + 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 + # llama-swap versteht NUR `persistent` (Verdrängungsschutz); `persist` ist der + # MC2-interne Lese-Key (API/UI) und wird von llama-swap ignoriert. Beide pflegen! + persist: true + persistent: true + members: + - Qwen3-Embedding-0.6B + - Qwen3-VL-8B-Instruct + - Qwen3.6-35B-A3B diff --git a/deploy/skills/orchestrator/SKILL.md b/deploy/skills/orchestrator/SKILL.md index cd95041..19c97ac 100644 --- a/deploy/skills/orchestrator/SKILL.md +++ b/deploy/skills/orchestrator/SKILL.md @@ -1,186 +1,186 @@ ---- -name: orchestrator -description: "Orchestrieren wie ein Manager: einen groesseren Auftrag in Teil-Tasks zerlegen, jeden SERIELL an das passende Worker-Modell delegieren (worker.sh, Ein-Schuss via llama-swap), die Ergebnisse einsammeln + zusammensetzen, per Fremd-Kritiker gaten und als Telegram-Vorschlag zurueckgeben. NIE selbst mergen/deployen — der Commander ist das Gate." -version: 1.0.0 -author: MC2 (Autonomie — Orchestrator) -platforms: [linux] -metadata: - hermes: - tags: [orchestrator, delegation, werkstatt, mc2, fliessband] ---- - -# Orchestrator — du managst, du fuehrst nicht alles selbst aus - -Nutze diesen Skill, wenn ein Auftrag zu gross fuer EINEN Rutsch ist, sich aber in wenige klar -abgegrenzte Teil-Tasks zerlegen laesst (ueberwiegend Code-Bau). Du bist der **Manager**: du -zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse -zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet. - -Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt -(Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst -die Integration + das Gate. - -## Warum seriell (und warum das ok ist) -Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein -Modell laden → arbeiten → tauschen → naechstes. Orchestrierung ist ein **Fliessband, kein Schwarm.** -Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal. - -## Scope-Check zuerst (klein anfangen) -Passt: ein Auftrag, der in **2–4 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 1–2-Datei-Bau-Schritt -(neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in -wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung): -dann NUR einen **Plan** als Text im Telegram-Vorschlag liefern, KEINEN Code — der Commander schneidet -dann kleiner. - -## Leitplanken (nicht verhandelbar) -- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf - stammen. Schreibst du den Code selbst (auch wenn der Teil-Task „einfach genug" wirkt), statt ihn - vom Worker zu holen, ist der Lauf GESCHEITERT — sag das ehrlich, statt Eigenbau als Orchestrierung - auszugeben. Belege jeden Teil-Task im Vorschlag mit dem `worker.sh`-Aufruf UND den ersten Zeilen - seiner Roh-Ausgabe. (Warum so streng: das Manager-Modell kann kleine Tasks selbst — genau dann - faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.) -- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh` - sind installiert, aber NICHT auf deinem PATH → immer mit vollem Pfad aufrufen (nie bloss `worker.sh`). - Glaubst du, sie fehlen, IRRST du dich: erst `ls -la ~/.hermes/scripts/worker.sh` ausfuehren und den - Pfad lesen, dann nutzen. „Skript fehlt" ist KEIN gueltiger Grund, den Schritt selbst zu machen. -- **Propose-only.** Am Ende steht IMMER ein Telegram-Vorschlag; die Entscheidung trifft der Commander. - NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten. -- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt. -- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen. - Fuer die Umsetzung von Code nutzt du **`Qwen3-Coder-Next`** (laedt klein neben dem Warm-Set). - Fuer die initiale **Planung** nutzt du explizit den Chef-Gutachter **`gpt-oss-120b`** (heavy), - auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen - Hintergrund-Prozess gewollt. -- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes - Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind - zusammenklebt, produziert leisen Muell. -- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen. -- Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen. - -## Ablauf - -### 1. Beratung & Zerlegen (Manager plant mit dem Chef) -Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan: -1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer: - ```bash - printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt - ``` -2. Lies den Plan (`cat /tmp/orch-plan.txt`) und nutze ihn, um den Auftrag in eine **geordnete, nummerierte Liste von Teil-Tasks** zu zerlegen. -3. Fuer jeden Teil-Task entscheide: (a) Artefakt, (b) Worker (`Qwen3-Coder-Next` + `build`/`refactor`), (c) Kontext. - -### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags) -``` -cd ~/mission-control-v2 && git fetch -q origin \ - && git worktree add /tmp/orch- -b orchestrator/ origin/main -mkdir -p /tmp/orch--work # Ablage fuer rohe Worker-Ausgaben -``` - -### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell) -Fuer jeden Bau-Teil-Task: -1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits - fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht. -2. Ruf den Worker auf und fang die Ausgabe roh ab: - ``` - printf '%s' "$eingabe" | WORKER_ROLE=build ~/.hermes/scripts/worker.sh > /tmp/orch--work/.out - ``` - (Default-Modell ist `Qwen3-Coder-Next`. Fuer einen bewusst anderen Worker `WORKER_MODEL=`.) -3. **Pruefe die Ausgabe, bevor du sie verwendest:** Beginnt sie mit `FEHLT:`, hast du zu wenig - Kontext gegeben → nachliefern und erneut rufen. Hat der Worker versehentlich einen ```-Codezaun - oder Prosa drumherum gesetzt, entferne ihn. Dann schreibe das saubere Artefakt mit deinen - Datei-Tools an seinen Platz im Worktree (`/tmp/orch-/...`). Der Worker hat KEINE - Datei-Haende — das Schreiben machst DU. - -### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende) -Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung, -Namen). Dann das Selbst-Gate, was zutrifft: -- Python geaendert → `python3 -m py_compile ` (und wenn ein Test dabei ist und ohne - schwere Deps laeuft: den Test im Worktree ausfuehren). -- Shell geaendert → `bash -n ` -- Frontend (`frontend/src/...`) → auf der Box gibt es KEIN Node. Im Vorschlag ausweisen: - „Gate eingeschraenkt: tsc/Build laeuft erst beim Merge auf dem PC." -- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):** - - `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du - wirklich nur im Worktree gearbeitet hast — ein gruener Health-curl allein reicht NICHT, weil - der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht - bemerkt). - - `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben. - - Ist `git status` NICHT leer: die fremden Aenderungen im Live-Checkout mit - `git -C ~/mission-control-v2 checkout -- ` zuruecknehmen (deine Arbeit liegt sicher im - Worktree) und im Vorschlag ehrlich erwaehnen. - -### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht) -Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben -Grund wie in der Werkstatt („wer seine eigenen Hausaufgaben benotet, stimmt sich selbst zu"). **NICHT -`delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision). - -**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert -untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen): -``` -git -C /tmp/orch- add -A -``` -Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind): -``` -EINGABE="AUFTRAG (im Wortlaut): - - -ZERLEGUNG (deine Teil-Tasks, je 1 Zeile): -<1..n> - -GIT-DIFF des Worktrees: -$(git -C /tmp/orch- diff --cached origin/main)" -``` -Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben. -**Kritik A — Kompetenz** (`Qwen3-Coder-Next`, starker Coder, vom Bauen noch geladen → KEIN Swap): -``` -printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh -``` -**Kritik B — Fremd-Blick** (`GLM-4.6V-Flash`, andere Modellfamilie, andere blinde Flecken): -``` -printf '%s' "$EINGABE" | FREMDBLICK_MODE=code FREMDBLICK_MODEL=GLM-4.6V-Flash FREMDBLICK_MAXTOK=3000 ~/.hermes/scripts/fremdblick.sh -``` -Reihenfolge bewusst: erst A (Coder-Next ist noch warm), dann B (ein Swap zu GLM). Beide beginnen mit -`URTEIL: `. **Warte auf beide.** - -**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den -betroffenen Teil-Task neu an den Worker, integrieren, BEIDE Kritiker erneut). `FREIGABE-MIT-VORBEHALT` -→ die Vorbehalte adressieren oder im Vorschlag ehrlich ausweisen (NICHT wegdiskutieren). Nur wenn -BEIDE tragen → Vorschlag. Beide Urteile KONKRET in den Vorschlag (welcher Kritiker was sagte). Faellt -ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X -fiel aus" vermerken (NICHT als Freigabe zaehlen). - -> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB -> kalt laden und Lucys Warm-Set stoeren. Er prueft separat NACHTS im Leerlauf ueber die offenen -> Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind. -- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen - Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den - Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander. -- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich - „⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln). - -### 6. Telegram-Vorschlag (in LUCYS Stimme) -`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" ""` — an den Commander, nicht -als anonymer Job: erst 1–2 Saetze, was gebaut wurde und was er jetzt entscheiden soll; dann knapp: -- die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging), -- geaenderte/neue Dateien · Kern des Diffs, -- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"), -- Branch-Name · Frage „merge oder verwerfen?" - -### 7. Nichts loeschen -Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat. - -## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief -Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein -Zugriff aufs Repo. Er sieht NUR, was du ihm auf stdin gibst. Deshalb: -- Gib ihm den **relevanten Ausschnitt** mit — die betroffene Datei (oder den betroffenen Block), - benachbarte Muster zum Nachahmen, die genaue Signatur/den Namen, den er treffen muss, und bei - Folge-Schritten die bereits fertigen Artefakte, auf die er aufbaut. -- Sei praezise beim Zielartefakt: „Gib den VOLLSTAENDIGEN Inhalt der neuen Datei `X` aus" bzw. „Gib - einen unified Diff gegen den folgenden Stand von `Y` aus". -- Zu wenig Kontext → der Worker raet oder meldet `FEHLT:`. Zu viel unnoetiger Kontext → er verliert - den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht. - -## Nach dem Commander-Entscheid (kommt als neuer Auftrag) -- „verwerfen" → `git worktree remove /tmp/orch- --force && git branch -D orchestrator/` - (+ `rm -rf /tmp/orch--work`; Remote-Branch loeschen, falls gepusht). -- „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort). - Du pushst NIE nach main. +--- +name: orchestrator +description: "Orchestrieren wie ein Manager: einen groesseren Auftrag in Teil-Tasks zerlegen, jeden SERIELL an das passende Worker-Modell delegieren (worker.sh, Ein-Schuss via llama-swap), die Ergebnisse einsammeln + zusammensetzen, per Fremd-Kritiker gaten und als Telegram-Vorschlag zurueckgeben. NIE selbst mergen/deployen — der Commander ist das Gate." +version: 1.0.0 +author: MC2 (Autonomie — Orchestrator) +platforms: [linux] +metadata: + hermes: + tags: [orchestrator, delegation, werkstatt, mc2, fliessband] +--- + +# Orchestrator — du managst, du fuehrst nicht alles selbst aus + +Nutze diesen Skill, wenn ein Auftrag zu gross fuer EINEN Rutsch ist, sich aber in wenige klar +abgegrenzte Teil-Tasks zerlegen laesst (ueberwiegend Code-Bau). Du bist der **Manager**: du +zerlegst, verteilst die Teile SERIELL an Worker-Modelle (die arbeiten), fuegst die Ergebnisse +zusammen und lieferst am Ende einen **Vorschlag** — der Commander entscheidet. + +Das ist die Verallgemeinerung der **Werkstatt** (`wartung`): dort delegierst du EINEN Schritt +(Code-Review) an ein Fremdmodell. Hier delegierst du MEHRERE Bau-Schritte an Worker und behaeltst +die Integration + das Gate. + +## Warum seriell (und warum das ok ist) +Mehrere grosse Modelle GLEICHZEITIG passen nicht in den Speicher. llama-swap ist **seriell** — ein +Modell laden → arbeiten → tauschen → naechstes. Orchestrierung ist ein **Fliessband, kein Schwarm.** +Langsamer (Swap-Latenz + einer nach dem anderen: Minuten), aber bei Hintergrund-/Telegram-Jobs egal. + +## Scope-Check zuerst (klein anfangen) +Passt: ein Auftrag, der in **2–4 abgegrenzte Teil-Tasks** zerfaellt, jeder ein 1–2-Datei-Bau-Schritt +(neues kleines Modul + Test, kleine neue Funktion + Verdrahtung, zusammenhaengender Refactor in +wenigen Dateien). Zu gross (viele Module, echte Architektur-Entscheidungen, breite API-Aenderung): +dann NUR einen **Plan** als Text im Telegram-Vorschlag liefern, KEINEN Code — der Commander schneidet +dann kleiner. + +## Leitplanken (nicht verhandelbar) +- **Du delegierst, du baust NICHT selbst.** Jedes Bau-Artefakt MUSS aus einem `worker.sh`-Aufruf + stammen. Schreibst du den Code selbst (auch wenn der Teil-Task „einfach genug" wirkt), statt ihn + vom Worker zu holen, ist der Lauf GESCHEITERT — sag das ehrlich, statt Eigenbau als Orchestrierung + auszugeben. Belege jeden Teil-Task im Vorschlag mit dem `worker.sh`-Aufruf UND den ersten Zeilen + seiner Roh-Ausgabe. (Warum so streng: das Manager-Modell kann kleine Tasks selbst — genau dann + faellt Delegation aus Bequemlichkeit weg; das ist der haeufigste Orchestrierungs-Fehler.) +- **Die Helfer EXISTIEREN.** `~/.hermes/scripts/worker.sh` und `~/.hermes/scripts/fremdblick.sh` + sind installiert, aber NICHT auf deinem PATH → immer mit vollem Pfad aufrufen (nie bloss `worker.sh`). + Glaubst du, sie fehlen, IRRST du dich: erst `ls -la ~/.hermes/scripts/worker.sh` ausfuehren und den + Pfad lesen, dann nutzen. „Skript fehlt" ist KEIN gueltiger Grund, den Schritt selbst zu machen. +- **Propose-only.** Am Ende steht IMMER ein Telegram-Vorschlag; die Entscheidung trifft der Commander. + NIEMALS auf `main` committen, NIEMALS mergen, NIEMALS deployen oder Dienste neu starten. +- **Nur im Worktree arbeiten.** Die Live-Instanz `~/mission-control-v2` bleibt unberuehrt. +- **Warm-Set-Schutz (Speicher vs Latenz).** Worker werden ueber **`worker.sh`** aufgerufen. + Fuer die Umsetzung von Code nutzt du **`Qwen3-Coder-Next`** (laedt klein neben dem Warm-Set). + Fuer die initiale **Planung** nutzt du explizit den Chef-Gutachter **`gpt-oss-120b`** (heavy), + auch wenn dessen Laden (~15s) Lucys Sprech-Latenz temporaer aussetzt. Das ist fuer diesen + Hintergrund-Prozess gewollt. +- **Fremd-Kritiker ist Pflicht-Gate.** Der zusammengesetzte Diff MUSS von `fremdblick.sh` (anderes + Modell) gegengelesen werden, bevor du vorschlaegst. Ein Manager, der Worker-Output blind + zusammenklebt, produziert leisen Muell. +- **Security-Config ist TABU:** keine Tokens, approvals, ufw, sudoers, ssh-Keys anfassen. +- Gate rot oder Kritiker dagegen → trotzdem ehrlich melden (Branch bleibt liegen), nichts beschoenigen. + +## Ablauf + +### 1. Beratung & Zerlegen (Manager plant mit dem Chef) +Bevor du selbst Tasks aufteilst, befragst du zwingend **`gpt-oss-120b`** nach einem Architektur-Plan: +1. Sende den initialen Auftrag per `worker.sh` an den Chef-Planer: + ```bash + printf '%s' "Auftrag: $auftrag" | WORKER_MODEL=gpt-oss-120b WORKER_ROLE=plan ~/.hermes/scripts/worker.sh > /tmp/orch-plan.txt + ``` +2. Lies den Plan (`cat /tmp/orch-plan.txt`) und nutze ihn, um den Auftrag in eine **geordnete, nummerierte Liste von Teil-Tasks** zu zerlegen. +3. Fuer jeden Teil-Task entscheide: (a) Artefakt, (b) Worker (`Qwen3-Coder-Next` + `build`/`refactor`), (c) Kontext. + +### 2. Worktree anlegen (Slug = kurzer Kebab-Case-Name des Auftrags) +``` +cd ~/mission-control-v2 && git fetch -q origin \ + && git worktree add /tmp/orch- -b orchestrator/ origin/main +mkdir -p /tmp/orch--work # Ablage fuer rohe Worker-Ausgaben +``` + +### 3. Routen — je Teil-Task ein Worker-Ein-Schuss (seriell) +Fuer jeden Bau-Teil-Task: +1. Baue die Eingabe: **genau der noetige Kontext** + der praezise Auftrag (siehe unten). Bereits + fertige Artefakte aus frueheren Schritten als Kontext mitgeben, wenn der Worker sie braucht. +2. Ruf den Worker auf und fang die Ausgabe roh ab: + ``` + printf '%s' "$eingabe" | WORKER_ROLE=build ~/.hermes/scripts/worker.sh > /tmp/orch--work/.out + ``` + (Default-Modell ist `Qwen3-Coder-Next`. Fuer einen bewusst anderen Worker `WORKER_MODEL=`.) +3. **Pruefe die Ausgabe, bevor du sie verwendest:** Beginnt sie mit `FEHLT:`, hast du zu wenig + Kontext gegeben → nachliefern und erneut rufen. Hat der Worker versehentlich einen ```-Codezaun + oder Prosa drumherum gesetzt, entferne ihn. Dann schreibe das saubere Artefakt mit deinen + Datei-Tools an seinen Platz im Worktree (`/tmp/orch-/...`). Der Worker hat KEINE + Datei-Haende — das Schreiben machst DU. + +### 4. Integrieren + Self-Gate (nach jedem Schritt bzw. am Ende) +Die Worker liefern Bausteine — DU sorgst dafuer, dass sie zusammenpassen (Imports, Verdrahtung, +Namen). Dann das Selbst-Gate, was zutrifft: +- Python geaendert → `python3 -m py_compile ` (und wenn ein Test dabei ist und ohne + schwere Deps laeuft: den Test im Worktree ausfuehren). +- Shell geaendert → `bash -n ` +- Frontend (`frontend/src/...`) → auf der Box gibt es KEIN Node. Im Vorschlag ausweisen: + „Gate eingeschraenkt: tsc/Build laeuft erst beim Merge auf dem PC." +- **Live-Checkout unberuehrt (PFLICHT, zwei Beweise):** + - `git -C ~/mission-control-v2 status --porcelain -uno` **muss LEER sein** (Beweis, dass du + wirklich nur im Worktree gearbeitet hast — ein gruener Health-curl allein reicht NICHT, weil + der laufende Dienst den alten Code im RAM haelt und eine schmutzige Datei auf der Platte nicht + bemerkt). + - `curl -sf http://127.0.0.1:9001/api/health` muss gruen bleiben. + - Ist `git status` NICHT leer: die fremden Aenderungen im Live-Checkout mit + `git -C ~/mission-control-v2 checkout -- ` zuruecknehmen (deine Arbeit liegt sicher im + Worktree) und im Vorschlag ehrlich erwaehnen. + +### 5. Zwei-Kritiker-Gate ueber den GESAMTEN Diff (Pflicht) +Zwei Zweitgutachter mit je anderer Staerke lesen den zusammengesetzten Diff gegen — aus demselben +Grund wie in der Werkstatt („wer seine eigenen Hausaufgaben benotet, stimmt sich selbst zu"). **NICHT +`delegate_task`** (laeuft ueber `heavy` → 64K-Floor + Warm-Set-Kollision). + +**Zuerst ALLES stagen** — sonst fehlen NEUE Dateien im Diff (haeufigste Falle: `git diff` ignoriert +untracked Dateien, die Kritiker bekaemen einen LEEREN Diff und wuerden alles blind ablehnen): +``` +git -C /tmp/orch- add -A +``` +Dann die Eingabe EINMAL bauen (mit `--cached`, damit die neuen Dateien drin sind): +``` +EINGABE="AUFTRAG (im Wortlaut): + + +ZERLEGUNG (deine Teil-Tasks, je 1 Zeile): +<1..n> + +GIT-DIFF des Worktrees: +$(git -C /tmp/orch- diff --cached origin/main)" +``` +Ist dieser Diff LEER, hast du nicht gestaged (oder nichts gebaut) → NICHT weiter, erst beheben. +**Kritik A — Kompetenz** (`Qwen3-Coder-Next`, starker Coder, vom Bauen noch geladen → KEIN Swap): +``` +printf '%s' "$EINGABE" | FREMDBLICK_MODE=code ~/.hermes/scripts/fremdblick.sh +``` +**Kritik B — Fremd-Blick** (`GLM-4.6V-Flash`, andere Modellfamilie, andere blinde Flecken): +``` +printf '%s' "$EINGABE" | FREMDBLICK_MODE=code FREMDBLICK_MODEL=GLM-4.6V-Flash FREMDBLICK_MAXTOK=3000 ~/.hermes/scripts/fremdblick.sh +``` +Reihenfolge bewusst: erst A (Coder-Next ist noch warm), dann B (ein Swap zu GLM). Beide beginnen mit +`URTEIL: `. **Warte auf beide.** + +**Gate-Regel (streng):** Sagt AUCH NUR EINER `ABGELEHNT` → nachbessern (max. 2 Runden: den +betroffenen Teil-Task neu an den Worker, integrieren, BEIDE Kritiker erneut). `FREIGABE-MIT-VORBEHALT` +→ die Vorbehalte adressieren oder im Vorschlag ehrlich ausweisen (NICHT wegdiskutieren). Nur wenn +BEIDE tragen → Vorschlag. Beide Urteile KONKRET in den Vorschlag (welcher Kritiker was sagte). Faellt +ein Kritiker technisch aus (`FEHLGESCHLAGEN`/`ABGESCHNITTEN`) → einmal wiederholen, sonst „⚠ Kritik X +fiel aus" vermerken (NICHT als Freigabe zaehlen). + +> Der schwere **Chef-Gutachter (`gpt-oss`)** laeuft NICHT in diesem heissen Pfad — er muesste 60 GB +> kalt laden und Lucys Warm-Set stoeren. Er prueft separat NACHTS im Leerlauf ueber die offenen +> Orchestrator-Vorschlaege (eigener Cron), wo Ladezeit + Warm-Set-Umbau verkraftbar sind. +- `ABGELEHNT` / berechtigte `FREIGABE-MIT-VORBEHALT` → nachbessern (max. 2 Runden: den betroffenen + Teil-Task neu an den Worker geben, integrieren, erneut gaten). Bleibt es kritisch: Kritik in den + Vorschlag schreiben, Branch bleibt liegen, Entscheidung dem Commander. +- `FREMDBLICK FEHLGESCHLAGEN`/`ABGESCHNITTEN` → einmal wiederholen; sonst im Vorschlag ehrlich + „⚠ Fremd-Review fiel aus — UNGEPRUEFT" vermerken (NICHT als Freigabe behandeln). + +### 6. Telegram-Vorschlag (in LUCYS Stimme) +`bash ~/mission-control-v2/deploy/notify.sh -s "[Orchestrator]" ""` — an den Commander, nicht +als anonymer Job: erst 1–2 Saetze, was gebaut wurde und was er jetzt entscheiden soll; dann knapp: +- die **Zerlegung** (welcher Teil-Task an welches Worker-Modell ging), +- geaenderte/neue Dateien · Kern des Diffs, +- Self-Gate-Ergebnis · Kritiker-Urteil KONKRET ausgeschrieben (nicht nur „ok"), +- Branch-Name · Frage „merge oder verwerfen?" + +### 7. Nichts loeschen +Worktree + Branch + rohe Worker-Ausgaben bleiben liegen, bis der Commander entschieden hat. + +## Kontext-Uebergabe — hier geht Orchestrierung am ehesten schief +Jeder Worker startet **frisch und zustandslos**: kein Gedaechtnis der bisherigen Schritte, kein +Zugriff aufs Repo. Er sieht NUR, was du ihm auf stdin gibst. Deshalb: +- Gib ihm den **relevanten Ausschnitt** mit — die betroffene Datei (oder den betroffenen Block), + benachbarte Muster zum Nachahmen, die genaue Signatur/den Namen, den er treffen muss, und bei + Folge-Schritten die bereits fertigen Artefakte, auf die er aufbaut. +- Sei praezise beim Zielartefakt: „Gib den VOLLSTAENDIGEN Inhalt der neuen Datei `X` aus" bzw. „Gib + einen unified Diff gegen den folgenden Stand von `Y` aus". +- Zu wenig Kontext → der Worker raet oder meldet `FEHLT:`. Zu viel unnoetiger Kontext → er verliert + den Fokus. Ziel: das kleinste, das den Teil-Task eindeutig macht. + +## Nach dem Commander-Entscheid (kommt als neuer Auftrag) +- „verwerfen" → `git worktree remove /tmp/orch- --force && git branch -D orchestrator/` + (+ `rm -rf /tmp/orch--work`; Remote-Branch loeschen, falls gepusht). +- „merge" → das Mergen nach main + Deploy macht der PC/Claude (Frontend-Builds gibt es nur dort). + Du pushst NIE nach main. diff --git a/docs/ANTIGRAVITY_REVIEW.md b/docs/ANTIGRAVITY_REVIEW.md index df27a35..e27b9bc 100644 --- a/docs/ANTIGRAVITY_REVIEW.md +++ b/docs/ANTIGRAVITY_REVIEW.md @@ -1,52 +1,52 @@ -# Antigravity-Review-Prompt — Mission Control 2.0 - -Prompt zum Einfügen in Antigravity 2.0 (Modell: **Gemini 3.1 Pro, High-Modus**). Projektordner = -dieses Repo. `.aiexclude` hält `.venv`/`node_modules`/`dist` aus dem Kontext. - ---- - -Du bist ein erfahrener Senior-Reviewer und führst ein **vollständiges, ehrliches Code- und -Architektur-Review** dieses Projekts (Mission Control 2.0, kurz MC2) durch. - -**Zuerst lesen — das ist der Rahmen, weiche nicht davon ab:** -1. `AGENTS.md` — die verbindlichen Projekt-Regeln. -2. `SAVEPOINT.md` — wo das Projekt gerade steht, Nordstern und Randbedingungen. -3. `README.md` und `docs/` — Kontext. - -**Was MC2 ist:** die Web-Admin-Konsole einer **100 % lokalen** KI-Appliance (AMD Strix Halo, 128 GB, -llama.cpp-Vulkan). Backend = FastAPI (`backend/`, Dienst auf :9001), Frontend = React/Vite/Tailwind -(`frontend/`). Sidecars: `mem0_service/`, `voice_service/`, `mcp/`, `hermes/`, `client/`. - -**Nordstern:** Das System muss **über Jahre ohne Wartung** laufen, betrieben von einem -**Nicht-Entwickler**. Robustheit, Einfachheit und Selbstheilung schlagen Features. - -## Dein Auftrag -Prüfe das gesamte Repo (außer dem, was `.aiexclude` ausschließt). Suche gezielt nach: -- **Korrektheits-Bugs & Race Conditions** — besonders in `backend/services/` und den Routern. -- **Betriebs-Fragilität:** Was bricht bei **Reboot**, **Update** oder **Teil-Ausfall**? Was ist nicht - reboot-/idempotenz-fest? (Der Betreiber kann es nicht selbst debuggen.) -- **Sicherheitslücken** — Auth/Token-Handling, Command-Allowlists, Prompt-Injection-Guards, - Pfad-/Eingabe-Validierung, `sudo -n`-Nutzung. (Nur MELDEN, nichts an der Security-Config ändern.) -- **Ressourcen-Lecks & Fehlerbehandlung** — offene Clients/Dateien, verschluckte Exceptions, - fehlende Timeouts. -- **Toter/redundanter Code, DRY-/KISS-Verstöße, Über-Komplexität** — Vereinfachungs-Chancen. -- **Doku-/Realitäts-Drift** — Widersprüche zwischen `AGENTS.md`/Kommentaren und dem Code - (z. B. Zeitzone UTC vs. Europe/Berlin). -- **Wartbarkeit** — würde ein Fremder das in einem Jahr noch verstehen und sicher anfassen können? - -## Harte Leitplanken (nicht empfehlen) -- KEINE Cloud-Migration, KEIN Voll-Rewrite, KEINE schweren neuen Frameworks. -- KEINE Modell-/Engine-Wechsel, die nicht in ~124 GB passen; keine Cloud-LLMs. -- `frontend/dist` bleibt bewusst im git (Box hat kein Node) — nicht als Fehler werten. -- Security-Config nur MELDEN, nie „einfach ändern". -- Bevorzuge **Vereinfachung und Härtung** vor neuen Features. - -## Ausgabeformat (auf DEUTSCH) -1. **Gesamturteil** (3–5 Sätze): Wie gesund ist das Projekt? Trägt es den Nordstern „Jahre ohne Wartung"? -2. **Befunde, nach Schwere sortiert** — je Befund: - - Schweregrad **P0** (bricht/Sicherheit) · **P1** (echter Bug/Fragilität) · **P2** (Wartbarkeit) · **P3** (nice-to-have) - - `datei:zeile` · was ist das Problem · **warum es zählt** (konkretes Fehlszenario) · **konkreter Fix** -3. **Positives** — was bewusst gut gelöst ist (damit es nicht „wegoptimiert" wird). -4. **Was ich bewusst NICHT ändern würde** — und warum (Respekt vor den getroffenen Entscheidungen). - -Sei ehrlich und konkret. Erfinde keine Probleme, um die Liste zu füllen. Wenn etwas gut ist, sag das. +# Antigravity-Review-Prompt — Mission Control 2.0 + +Prompt zum Einfügen in Antigravity 2.0 (Modell: **Gemini 3.1 Pro, High-Modus**). Projektordner = +dieses Repo. `.aiexclude` hält `.venv`/`node_modules`/`dist` aus dem Kontext. + +--- + +Du bist ein erfahrener Senior-Reviewer und führst ein **vollständiges, ehrliches Code- und +Architektur-Review** dieses Projekts (Mission Control 2.0, kurz MC2) durch. + +**Zuerst lesen — das ist der Rahmen, weiche nicht davon ab:** +1. `AGENTS.md` — die verbindlichen Projekt-Regeln. +2. `SAVEPOINT.md` — wo das Projekt gerade steht, Nordstern und Randbedingungen. +3. `README.md` und `docs/` — Kontext. + +**Was MC2 ist:** die Web-Admin-Konsole einer **100 % lokalen** KI-Appliance (AMD Strix Halo, 128 GB, +llama.cpp-Vulkan). Backend = FastAPI (`backend/`, Dienst auf :9001), Frontend = React/Vite/Tailwind +(`frontend/`). Sidecars: `mem0_service/`, `voice_service/`, `mcp/`, `hermes/`, `client/`. + +**Nordstern:** Das System muss **über Jahre ohne Wartung** laufen, betrieben von einem +**Nicht-Entwickler**. Robustheit, Einfachheit und Selbstheilung schlagen Features. + +## Dein Auftrag +Prüfe das gesamte Repo (außer dem, was `.aiexclude` ausschließt). Suche gezielt nach: +- **Korrektheits-Bugs & Race Conditions** — besonders in `backend/services/` und den Routern. +- **Betriebs-Fragilität:** Was bricht bei **Reboot**, **Update** oder **Teil-Ausfall**? Was ist nicht + reboot-/idempotenz-fest? (Der Betreiber kann es nicht selbst debuggen.) +- **Sicherheitslücken** — Auth/Token-Handling, Command-Allowlists, Prompt-Injection-Guards, + Pfad-/Eingabe-Validierung, `sudo -n`-Nutzung. (Nur MELDEN, nichts an der Security-Config ändern.) +- **Ressourcen-Lecks & Fehlerbehandlung** — offene Clients/Dateien, verschluckte Exceptions, + fehlende Timeouts. +- **Toter/redundanter Code, DRY-/KISS-Verstöße, Über-Komplexität** — Vereinfachungs-Chancen. +- **Doku-/Realitäts-Drift** — Widersprüche zwischen `AGENTS.md`/Kommentaren und dem Code + (z. B. Zeitzone UTC vs. Europe/Berlin). +- **Wartbarkeit** — würde ein Fremder das in einem Jahr noch verstehen und sicher anfassen können? + +## Harte Leitplanken (nicht empfehlen) +- KEINE Cloud-Migration, KEIN Voll-Rewrite, KEINE schweren neuen Frameworks. +- KEINE Modell-/Engine-Wechsel, die nicht in ~124 GB passen; keine Cloud-LLMs. +- `frontend/dist` bleibt bewusst im git (Box hat kein Node) — nicht als Fehler werten. +- Security-Config nur MELDEN, nie „einfach ändern". +- Bevorzuge **Vereinfachung und Härtung** vor neuen Features. + +## Ausgabeformat (auf DEUTSCH) +1. **Gesamturteil** (3–5 Sätze): Wie gesund ist das Projekt? Trägt es den Nordstern „Jahre ohne Wartung"? +2. **Befunde, nach Schwere sortiert** — je Befund: + - Schweregrad **P0** (bricht/Sicherheit) · **P1** (echter Bug/Fragilität) · **P2** (Wartbarkeit) · **P3** (nice-to-have) + - `datei:zeile` · was ist das Problem · **warum es zählt** (konkretes Fehlszenario) · **konkreter Fix** +3. **Positives** — was bewusst gut gelöst ist (damit es nicht „wegoptimiert" wird). +4. **Was ich bewusst NICHT ändern würde** — und warum (Respekt vor den getroffenen Entscheidungen). + +Sei ehrlich und konkret. Erfinde keine Probleme, um die Liste zu füllen. Wenn etwas gut ist, sag das. diff --git a/docs/AUDIT_KICKOFF.md b/docs/AUDIT_KICKOFF.md index 4ec890f..d97220d 100644 --- a/docs/AUDIT_KICKOFF.md +++ b/docs/AUDIT_KICKOFF.md @@ -1,75 +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 --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. +# 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 --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. diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 5991245..d8ccfb4 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -1,62 +1,62 @@ -# Backup & Restore - -Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git, -Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre. - -## Was im Backup ist -- **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`) -- **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/` -- **Engine:** `/etc/llama-swap/config.yaml` - -Nicht enthalten (bewusst): GGUF-Modelle, MC2-Code (Git), venvs, systemd-Units (aus `deploy.sh`). - -Jedes Backup ist ein Tarball `mc2-state-.tar.gz` unter `/srv/models/mc2-backups/`, -`chmod 600` (enthält `.env`). Es werden die letzten **14** behalten. - -## Off-Box-Spiegel (C12) — zweite Kopie WEG von der Box -Nach jedem lokalen Backup spiegelt `backup.sh` die Tarballs per `rsync` auf ein **zweites Gerät** -(Default: **Proxmox-Host** `root@192.168.178.108:/var/lib/vz/mc2-backups`). Stirbt die NVMe der -Box, liegen die Backups noch auf dem Proxmox. Die Retention (14) wird per `--delete` mitgezogen, -`chmod 600` bleibt erhalten. Der Off-Box-Sync ist **best-effort**: schlägt er fehl, gilt das -lokale Backup trotzdem als erfolgreich (Warnung im Log). - -- **Auth:** eigener SSH-Key `~/.ssh/mc2_offsite` (nur für dieses Backup), im Proxmox in - `root/.ssh/authorized_keys` **gehärtet** eingetragen: `from=""`, kein Port-/Agent-/ - X11-Forwarding, kein PTY → der Key kann nur rsync-Backup, keinen Voll-Root-Fernzugang. -- **Ziel/Key überschreibbar:** `MC_BACKUP_OFFSITE` (leer = Off-Box aus) und `MC_BACKUP_OFFSITE_KEY`. - -## Backup erstellen -- **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status: - `systemctl --user list-timers mc2-backup.timer` -- **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh` -- **UI:** Wartungs-Drawer → „Snapshot erstellen" - -## Wiederherstellen (Restore) -Restore läuft **nur per CLI auf der Box** (bewusst — er stoppt Dienste und überschreibt Configs). -Vor dem Zurückspielen macht das Skript automatisch ein Sicherheits-Backup des aktuellen Zustands. - -```bash -cd ~/mission-control-v2 -bash deploy/restore.sh --list # vorhandene Backups anzeigen -bash deploy/restore.sh --dry-run latest # zeigen, was passieren würde -bash deploy/restore.sh latest # neuestes wiederherstellen (mit Rückfrage) -bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup -``` - -Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`, -`hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) → -Dienste starten → Health-Check. - -### Wenn die Box-Platte tot ist (Off-Box-Restore) -`restore.sh` kennt den Off-Box-Spiegel: fehlt ein Backup lokal, holt es sich das Skript -automatisch vom Proxmox. Kein manuelles Kopieren nötig. - -```bash -bash deploy/restore.sh --list # zeigt lokal UND Off-Box (Proxmox) -bash deploy/restore.sh --pull-offsite # ganzen Off-Box-Bestand nach lokal spiegeln -bash deploy/restore.sh latest # zieht das jüngste — vom Proxmox, falls lokal leer -``` - -## Erledigt -- **Off-Box-Spiegel (C12):** rsync auf den Proxmox-Host, live E2E verifiziert (2026-07-03). - Box ist Bare Metal (kein Proxmox-Gast → kein vzdump), daher aktiver Push statt vzdump-Pull. +# Backup & Restore + +Sichert den **nicht wiederherstellbaren Zustand** der AI-Box. Code kommt aus Git, +Modelle sind neu ladbar — gesichert wird nur, was sonst weg wäre. + +## Was im Backup ist +- **Gedächtnis:** `/srv/models/mem0/` (Chroma-Vektoren + `history.db`) +- **Hermes:** `~/.hermes/config.yaml`, `~/.hermes/.env` (**Secrets!**), `~/.hermes/plugins/` +- **Engine:** `/etc/llama-swap/config.yaml` + +Nicht enthalten (bewusst): GGUF-Modelle, MC2-Code (Git), venvs, systemd-Units (aus `deploy.sh`). + +Jedes Backup ist ein Tarball `mc2-state-.tar.gz` unter `/srv/models/mc2-backups/`, +`chmod 600` (enthält `.env`). Es werden die letzten **14** behalten. + +## Off-Box-Spiegel (C12) — zweite Kopie WEG von der Box +Nach jedem lokalen Backup spiegelt `backup.sh` die Tarballs per `rsync` auf ein **zweites Gerät** +(Default: **Proxmox-Host** `root@192.168.178.108:/var/lib/vz/mc2-backups`). Stirbt die NVMe der +Box, liegen die Backups noch auf dem Proxmox. Die Retention (14) wird per `--delete` mitgezogen, +`chmod 600` bleibt erhalten. Der Off-Box-Sync ist **best-effort**: schlägt er fehl, gilt das +lokale Backup trotzdem als erfolgreich (Warnung im Log). + +- **Auth:** eigener SSH-Key `~/.ssh/mc2_offsite` (nur für dieses Backup), im Proxmox in + `root/.ssh/authorized_keys` **gehärtet** eingetragen: `from=""`, kein Port-/Agent-/ + X11-Forwarding, kein PTY → der Key kann nur rsync-Backup, keinen Voll-Root-Fernzugang. +- **Ziel/Key überschreibbar:** `MC_BACKUP_OFFSITE` (leer = Off-Box aus) und `MC_BACKUP_OFFSITE_KEY`. + +## Backup erstellen +- **Automatisch:** systemd-Timer `mc2-backup.timer`, täglich ~03:30. Status: + `systemctl --user list-timers mc2-backup.timer` +- **Manuell (Box):** `bash ~/mission-control-v2/deploy/backup.sh` +- **UI:** Wartungs-Drawer → „Snapshot erstellen" + +## Wiederherstellen (Restore) +Restore läuft **nur per CLI auf der Box** (bewusst — er stoppt Dienste und überschreibt Configs). +Vor dem Zurückspielen macht das Skript automatisch ein Sicherheits-Backup des aktuellen Zustands. + +```bash +cd ~/mission-control-v2 +bash deploy/restore.sh --list # vorhandene Backups anzeigen +bash deploy/restore.sh --dry-run latest # zeigen, was passieren würde +bash deploy/restore.sh latest # neuestes wiederherstellen (mit Rückfrage) +bash deploy/restore.sh mc2-state-YYYYMMDD-HHMMSS.tar.gz # bestimmtes Backup +``` + +Ablauf: Sicherheits-Backup → Dienste stoppen (`mem0-service`, `mission-control-2`, +`hermes-gateway`) → Dateien zurückspielen (mem0 wird **ersetzt**, Configs überschrieben) → +Dienste starten → Health-Check. + +### Wenn die Box-Platte tot ist (Off-Box-Restore) +`restore.sh` kennt den Off-Box-Spiegel: fehlt ein Backup lokal, holt es sich das Skript +automatisch vom Proxmox. Kein manuelles Kopieren nötig. + +```bash +bash deploy/restore.sh --list # zeigt lokal UND Off-Box (Proxmox) +bash deploy/restore.sh --pull-offsite # ganzen Off-Box-Bestand nach lokal spiegeln +bash deploy/restore.sh latest # zieht das jüngste — vom Proxmox, falls lokal leer +``` + +## Erledigt +- **Off-Box-Spiegel (C12):** rsync auf den Proxmox-Host, live E2E verifiziert (2026-07-03). + Box ist Bare Metal (kein Proxmox-Gast → kein vzdump), daher aktiver Push statt vzdump-Pull. diff --git a/docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md b/docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md index 8d1aee3..463dd79 100644 --- a/docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md +++ b/docs/CLAUDE_CODE_BRIEF_lucy-brain-role.md @@ -1,106 +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 --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`. +# 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 --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`. diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index 135b901..a1aa0a9 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -1,326 +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/"; } -``` +# 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/"; } +``` diff --git a/docs/LUCY_TTS_PLAN.md b/docs/LUCY_TTS_PLAN.md index 3cce642..1551ff5 100644 --- a/docs/LUCY_TTS_PLAN.md +++ b/docs/LUCY_TTS_PLAN.md @@ -1,149 +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 +# 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 diff --git a/docs/REVIEW_2026-07-02.md b/docs/REVIEW_2026-07-02.md index ee1e932..eb8e297 100644 --- a/docs/REVIEW_2026-07-02.md +++ b/docs/REVIEW_2026-07-02.md @@ -1,281 +1,281 @@ -# 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) | - ---- - -## 8. Nachtrag 3: Trennung, Update-Playbook, Radikal-Aufräumen (02.07., Abend) - -**Lucy = eigenes Repo:** `F:\Coding Stuff\lucy` ↔ Gitea `Hitonabi/lucy` (privat; Repo via API angelegt). Verzeichnisnamen unverändert (lucy-desktop/ + lucy-tts/) → alle Pfade/BATs funktionieren; Boot aus neuem Pfad verifiziert. MC2 = reiner Box-Stack (+ hermes-pc-Executor). lucy-f5 (Verdikt final) und hermes-voice (Vor-Lucy) gelöscht — Historie bleibt in MC2. - -**Hermes-Update-Playbook LIVE + E2E-verifiziert (7/7 PASS):** Update-Job = Backup → update → **doctor** → Restart → erweiterter Postcheck (**Config-Drift-Scan** im Journal, **Tool-Smoke** via echtem Agenten, **Voice-Smoke** mit Retries). Update-Modal fasst anstehende Commits künftig per fast-Modell zusammen (Breaking Changes zuerst, gecacht). Beim E2E-Test sofort geliefert: doctor fand eine **ausstehende Config-Schema-Migration** (altes flaches `model: hermes` → neues Schema; per `doctor --fix` migriert, Gateway verifiziert) und der Voice-Smoke entlarvte eine **pipefail/SIGPIPE-Falle** im eigenen Check (head schließt Pipe → curl 23 → Fehlalarm; gefixt). Außerdem behoben: `approvals.mode auto→smart` (v0.18-Regression, einfache Fragen 25 s → 2,0 s). - -**Radikal aufgeräumt:** Git: lucy-v2 gemerged + gelöscht (lokal+Gitea), nur noch `main`; alter WIP-Stash gedroppt; stale Worktree entfernt. Lokal: lucy-f5-Assets (~5 GB), lemonade-eval, box_recon/gemma_swap-Scratch gelöscht. Box: 22-GB-Duplikat `Qwen3.6-35B-A3B-GGUF` (non-MTP) + 6 leere Modell-Hüllen gelöscht, v1-Altlasten (mission-control-*.db/json) nach mc2-backups/v1-legacy archiviert, /tmp-Bench-Scratch weg. Modell-Verzeichnis = exakt die 9 referenzierten Modelle + drafts + Backups. - ---- - -*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.* +# 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) | + +--- + +## 8. Nachtrag 3: Trennung, Update-Playbook, Radikal-Aufräumen (02.07., Abend) + +**Lucy = eigenes Repo:** `F:\Coding Stuff\lucy` ↔ Gitea `Hitonabi/lucy` (privat; Repo via API angelegt). Verzeichnisnamen unverändert (lucy-desktop/ + lucy-tts/) → alle Pfade/BATs funktionieren; Boot aus neuem Pfad verifiziert. MC2 = reiner Box-Stack (+ hermes-pc-Executor). lucy-f5 (Verdikt final) und hermes-voice (Vor-Lucy) gelöscht — Historie bleibt in MC2. + +**Hermes-Update-Playbook LIVE + E2E-verifiziert (7/7 PASS):** Update-Job = Backup → update → **doctor** → Restart → erweiterter Postcheck (**Config-Drift-Scan** im Journal, **Tool-Smoke** via echtem Agenten, **Voice-Smoke** mit Retries). Update-Modal fasst anstehende Commits künftig per fast-Modell zusammen (Breaking Changes zuerst, gecacht). Beim E2E-Test sofort geliefert: doctor fand eine **ausstehende Config-Schema-Migration** (altes flaches `model: hermes` → neues Schema; per `doctor --fix` migriert, Gateway verifiziert) und der Voice-Smoke entlarvte eine **pipefail/SIGPIPE-Falle** im eigenen Check (head schließt Pipe → curl 23 → Fehlalarm; gefixt). Außerdem behoben: `approvals.mode auto→smart` (v0.18-Regression, einfache Fragen 25 s → 2,0 s). + +**Radikal aufgeräumt:** Git: lucy-v2 gemerged + gelöscht (lokal+Gitea), nur noch `main`; alter WIP-Stash gedroppt; stale Worktree entfernt. Lokal: lucy-f5-Assets (~5 GB), lemonade-eval, box_recon/gemma_swap-Scratch gelöscht. Box: 22-GB-Duplikat `Qwen3.6-35B-A3B-GGUF` (non-MTP) + 6 leere Modell-Hüllen gelöscht, v1-Altlasten (mission-control-*.db/json) nach mc2-backups/v1-legacy archiviert, /tmp-Bench-Scratch weg. Modell-Verzeichnis = exakt die 9 referenzierten Modelle + drafts + Backups. + +--- + +*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.* diff --git a/docs/ZEROCLAW_POC_BRIEF.md b/docs/ZEROCLAW_POC_BRIEF.md index c6c9870..841dc6f 100644 --- a/docs/ZEROCLAW_POC_BRIEF.md +++ b/docs/ZEROCLAW_POC_BRIEF.md @@ -1,83 +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. - +# 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. + diff --git a/docs/ZEROCLAW_POC_RESULTS.md b/docs/ZEROCLAW_POC_RESULTS.md index ea16ad0..be67195 100644 --- a/docs/ZEROCLAW_POC_RESULTS.md +++ b/docs/ZEROCLAW_POC_RESULTS.md @@ -1,118 +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.` 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, ``-Tags. (Workspace-Pfad = - `/agents//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. +# 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.` 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, ``-Tags. (Workspace-Pfad = + `/agents//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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e3d83a..2668391 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,4541 +1,4541 @@ -{ - "name": "mission-control-2-frontend", - "version": "2.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "mission-control-2-frontend", - "version": "2.0.0", - "dependencies": { - "@pixiv/three-vrm": "^3.4.0", - "@react-three/drei": "^9.114.0", - "@react-three/fiber": "^8.17.10", - "@tanstack/react-query": "^5.101.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "graphology": "^0.26.0", - "graphology-layout-forceatlas2": "^0.10.1", - "lucide-react": "^0.460.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "recharts": "^3.9.0", - "sigma": "^3.0.3", - "tailwind-merge": "^2.5.5", - "three": "^0.169.0" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^22.10.1", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@types/three": "^0.169.0", - "@vitejs/plugin-react": "^4.3.4", - "tailwindcss": "^4.0.0", - "typescript": "^5.6.3", - "vite": "^6.0.3" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@monogrid/gainmap-js": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", - "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", - "license": "MIT", - "dependencies": { - "promise-worker-transferable": "^1.0.4" - }, - "peerDependencies": { - "three": ">= 0.159.0" - } - }, - "node_modules/@pixiv/three-vrm": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.4.tgz", - "integrity": "sha512-hY0MnmKVLUebFR9QN9vCRXo0rZmLpxjLVZAupQv5qDDGCKZuNxlOts9PqTkWlzhkMLLdhB48AF+P4PCy/DFOzA==", - "license": "MIT", - "dependencies": { - "@pixiv/three-vrm-core": "3.5.4", - "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.4", - "@pixiv/three-vrm-materials-mtoon": "3.5.4", - "@pixiv/three-vrm-materials-v0compat": "3.5.4", - "@pixiv/three-vrm-node-constraint": "3.5.4", - "@pixiv/three-vrm-springbone": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/three-vrm-core": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.4.tgz", - "integrity": "sha512-CgaxZ4qX6JmE2oKsOVGGlheT011qP4bBZGUaMJJ5iQINI999+cveIdISe459B+AMYD1dU5h1+xCtDthLQNH1Bg==", - "license": "MIT", - "dependencies": { - "@pixiv/types-vrm-0.0": "3.5.4", - "@pixiv/types-vrmc-vrm-1.0": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.4.tgz", - "integrity": "sha512-3BPZ42qW38cHhP+imqEnqTFsltvYkHA/t4VzWFdQ9sngWt0NiFAOotEsv8gZn1ZgKE9VRQezXne6s5aRWQlQqA==", - "license": "MIT", - "dependencies": { - "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/three-vrm-materials-mtoon": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.4.tgz", - "integrity": "sha512-vLHt7IYZxlijbCMa5TRRf6gaQjA65F/d7oZBNfZW3XC8sBg57ZV0M/xBAJm2d6UwVmTbBd9NG9li/Y9yuupDQw==", - "license": "MIT", - "dependencies": { - "@pixiv/types-vrm-0.0": "3.5.4", - "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/three-vrm-materials-v0compat": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.4.tgz", - "integrity": "sha512-qRRtg8vYFBRJpsa3evruWqmXr0Gwd8uJXwxxUKuKtrIN0Hw0hJsRLHkg9E238Q8xRTnoycdkSgX4VvVknvtXBA==", - "license": "MIT", - "dependencies": { - "@pixiv/types-vrm-0.0": "3.5.4", - "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/three-vrm-node-constraint": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.4.tgz", - "integrity": "sha512-nyAghDrYNp0Z2siEaY2+th+FzZdDs9EJy8K576Mpw9cy3jwtCJuTHBQmGzKkrgoLaBfrso+3Tdfgr62AJV0m1w==", - "license": "MIT", - "dependencies": { - "@pixiv/types-vrmc-node-constraint-1.0": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/three-vrm-springbone": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.4.tgz", - "integrity": "sha512-8KWA7vHU+OnW6XcZPnDuUrjjPFRzmv2yxcTMNCYQvBS547/Iqo0RLbQvpO/ppceLTNJFY2TGXAWJU67aeazWjw==", - "license": "MIT", - "dependencies": { - "@pixiv/types-vrm-0.0": "3.5.4", - "@pixiv/types-vrmc-springbone-1.0": "3.5.4", - "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.4" - }, - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/@pixiv/types-vrm-0.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.4.tgz", - "integrity": "sha512-g9VMPikJxKJ/XgnhXxnqj6ejVhQ9WwOEUp7KyyZHsqRnHcQfSqyZFTs9wkAMCbwCqHXX/lg9+9Ogj8KhxCkbXw==", - "license": "MIT" - }, - "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.4.tgz", - "integrity": "sha512-h9GEQ3q1VTylL/P40kJ8uoQhfUYY54NhTG6Xsnl4X0jf7oHh8MKXYnMMn903hXml6082Yjx1C6x3DKckdnophg==", - "license": "MIT" - }, - "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.4.tgz", - "integrity": "sha512-AU5sOcsbmcnzRPfgfk7fzKhk5lNm2MqrbvtAsSlhylVMdlzrmXqIfRuexABcB9k+ysTp49qIFioot9KRlQcDUw==", - "license": "MIT" - }, - "node_modules/@pixiv/types-vrmc-node-constraint-1.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.4.tgz", - "integrity": "sha512-RRbK5NNvZv4ewRELezueCiDB11FGkt4pdZR/UJ027DJPaNzd6rls2OEJ8weQ3OmgHZpbx/BtdpX2JWX5WZZd/Q==", - "license": "MIT" - }, - "node_modules/@pixiv/types-vrmc-springbone-1.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.4.tgz", - "integrity": "sha512-NO7HTRBuWEe89Wo9BRI5hX1kWVkZzA4YWg9XALSTLdIT8HMiArL4NRse/CF91jNuLcTGfPEWlBJyq9SciAQxAg==", - "license": "MIT" - }, - "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.4.tgz", - "integrity": "sha512-uzJmcRh/iHYnZPtnq6N1+jb+T/ptYNCyFEoArkWGNXHSfPjTkMcheTQkL5iMNvTE6hqhyMRVMha9OmbvyLmmUQ==", - "license": "MIT" - }, - "node_modules/@pixiv/types-vrmc-vrm-1.0": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.4.tgz", - "integrity": "sha512-pkjT4QXT/Hp6rcq8J8EFEHIldbtPGYWOTaWdoRGMA8/KzJI5PdekZ6AUYbnSP6ElfJ3BSZ01DxxpQZa/UNsFqA==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", - "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", - "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", - "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.13", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.10", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.12", - "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.7.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", - "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-escape-keydown": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", - "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", - "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.6", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", - "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", - "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.3.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", - "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@react-spring/three": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", - "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", - "license": "MIT", - "dependencies": { - "@react-spring/animated": "~9.7.5", - "@react-spring/core": "~9.7.5", - "@react-spring/shared": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "peerDependencies": { - "@react-three/fiber": ">=6.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "three": ">=0.126" - } - }, - "node_modules/@react-spring/three/node_modules/@react-spring/animated": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", - "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", - "license": "MIT", - "dependencies": { - "@react-spring/shared": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@react-spring/three/node_modules/@react-spring/core": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", - "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", - "license": "MIT", - "dependencies": { - "@react-spring/animated": "~9.7.5", - "@react-spring/shared": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-spring/donate" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@react-spring/three/node_modules/@react-spring/rafz": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", - "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", - "license": "MIT" - }, - "node_modules/@react-spring/three/node_modules/@react-spring/shared": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", - "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", - "license": "MIT", - "dependencies": { - "@react-spring/rafz": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@react-spring/three/node_modules/@react-spring/types": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", - "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", - "license": "MIT" - }, - "node_modules/@react-three/drei": { - "version": "9.122.0", - "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", - "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mediapipe/tasks-vision": "0.10.17", - "@monogrid/gainmap-js": "^3.0.6", - "@react-spring/three": "~9.7.5", - "@use-gesture/react": "^10.3.1", - "camera-controls": "^2.9.0", - "cross-env": "^7.0.3", - "detect-gpu": "^5.0.56", - "glsl-noise": "^0.0.0", - "hls.js": "^1.5.17", - "maath": "^0.10.8", - "meshline": "^3.3.1", - "react-composer": "^5.0.3", - "stats-gl": "^2.2.8", - "stats.js": "^0.17.0", - "suspend-react": "^0.1.3", - "three-mesh-bvh": "^0.7.8", - "three-stdlib": "^2.35.6", - "troika-three-text": "^0.52.0", - "tunnel-rat": "^0.1.2", - "utility-types": "^3.11.0", - "zustand": "^5.0.1" - }, - "peerDependencies": { - "@react-three/fiber": "^8", - "react": "^18", - "react-dom": "^18", - "three": ">=0.137" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/@react-three/drei/node_modules/@mediapipe/tasks-vision": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", - "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", - "license": "Apache-2.0" - }, - "node_modules/@react-three/drei/node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, - "node_modules/@react-three/fiber": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", - "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8", - "@types/react-reconciler": "^0.26.7", - "@types/webxr": "*", - "base64-js": "^1.5.1", - "buffer": "^6.0.3", - "its-fine": "^1.0.6", - "react-reconciler": "^0.27.0", - "react-use-measure": "^2.1.7", - "scheduler": "^0.21.0", - "suspend-react": "^0.1.3", - "zustand": "^3.7.1" - }, - "peerDependencies": { - "expo": ">=43.0", - "expo-asset": ">=8.4", - "expo-file-system": ">=11.0", - "expo-gl": ">=11.0", - "react": ">=18 <19", - "react-dom": ">=18 <19", - "react-native": ">=0.64", - "three": ">=0.133" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - }, - "expo-asset": { - "optional": true - }, - "expo-file-system": { - "optional": true - }, - "expo-gl": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@react-three/fiber/node_modules/scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/@react-three/fiber/node_modules/zustand": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", - "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", - "license": "MIT", - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", - "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", - "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "tailwindcss": "4.3.1" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.101.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz", - "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.101.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz", - "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.101.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@tweenjs/tween.js": { - "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/draco3d": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", - "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/offscreencanvas": { - "version": "2019.7.3", - "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", - "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/react-reconciler": { - "version": "0.26.7", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", - "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/stats.js": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", - "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", - "license": "MIT" - }, - "node_modules/@types/three": { - "version": "0.169.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", - "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", - "license": "MIT", - "dependencies": { - "@tweenjs/tween.js": "~23.1.3", - "@types/stats.js": "*", - "@types/webxr": "*", - "@webgpu/types": "*", - "fflate": "~0.8.2", - "meshoptimizer": "~0.18.1" - } - }, - "node_modules/@types/three/node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, - "node_modules/@types/webxr": { - "version": "0.5.24", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", - "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "license": "MIT" - }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@webgpu/types": { - "version": "0.1.71", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", - "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", - "license": "BSD-3-Clause" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/camera-controls": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", - "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.126.1" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cmdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", - "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-id": "^1.1.0", - "@radix-ui/react-primitive": "^2.0.2" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "react-dom": "^18 || ^19 || ^19.0.0-rc" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/detect-gpu": { - "version": "5.0.70", - "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", - "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", - "license": "MIT", - "dependencies": { - "webgl-constants": "^1.1.1" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", - "license": "Apache-2.0" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.378", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", - "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/glsl-noise": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", - "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", - "license": "MIT" - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphology": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", - "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0" - }, - "peerDependencies": { - "graphology-types": ">=0.24.0" - } - }, - "node_modules/graphology-layout-forceatlas2": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", - "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", - "license": "MIT", - "dependencies": { - "graphology-utils": "^2.1.0" - }, - "peerDependencies": { - "graphology-types": ">=0.19.0" - } - }, - "node_modules/graphology-types": { - "version": "0.24.8", - "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", - "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true - }, - "node_modules/graphology-utils": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", - "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", - "license": "MIT", - "peerDependencies": { - "graphology-types": ">=0.23.0" - } - }, - "node_modules/hls.js": { - "version": "1.6.16", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", - "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", - "license": "Apache-2.0" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/its-fine": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", - "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", - "license": "MIT", - "dependencies": { - "@types/react-reconciler": "^0.28.0" - }, - "peerDependencies": { - "react": ">=18.0" - } - }, - "node_modules/its-fine/node_modules/@types/react-reconciler": { - "version": "0.28.9", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", - "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.460.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", - "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/maath": { - "version": "0.10.8", - "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", - "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", - "license": "MIT", - "peerDependencies": { - "@types/three": ">=0.134.0", - "three": ">=0.134.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/meshline": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", - "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.137" - } - }, - "node_modules/meshoptimizer": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", - "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.49", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", - "integrity": "sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/potpack": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", - "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC" - }, - "node_modules/promise-worker-transferable": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", - "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", - "license": "Apache-2.0", - "dependencies": { - "is-promise": "^2.1.0", - "lie": "^3.0.2" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-composer": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", - "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.6.0" - }, - "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "license": "MIT", - "peer": true - }, - "node_modules/react-reconciler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", - "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.21.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.0.0" - } - }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", - "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-use-measure": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", - "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.13", - "react-dom": ">=16.13" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/recharts": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", - "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "^1.9.0 || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.2.0", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sigma": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz", - "integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0", - "graphology-utils": "^2.5.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stats-gl": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", - "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", - "license": "MIT", - "dependencies": { - "@types/three": "*", - "three": "^0.170.0" - }, - "peerDependencies": { - "@types/three": "*", - "three": "*" - } - }, - "node_modules/stats-gl/node_modules/three": { - "version": "0.170.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", - "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", - "license": "MIT" - }, - "node_modules/stats.js": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", - "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", - "license": "MIT" - }, - "node_modules/suspend-react": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", - "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=17.0" - } - }, - "node_modules/tailwind-merge": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", - "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/three": { - "version": "0.169.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", - "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", - "license": "MIT" - }, - "node_modules/three-mesh-bvh": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", - "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", - "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", - "license": "MIT", - "peerDependencies": { - "three": ">= 0.151.0" - } - }, - "node_modules/three-stdlib": { - "version": "2.36.1", - "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", - "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", - "license": "MIT", - "dependencies": { - "@types/draco3d": "^1.4.0", - "@types/offscreencanvas": "^2019.6.4", - "@types/webxr": "^0.5.2", - "draco3d": "^1.4.1", - "fflate": "^0.6.9", - "potpack": "^1.0.1" - }, - "peerDependencies": { - "three": ">=0.128.0" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/troika-three-text": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", - "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.2", - "troika-three-utils": "^0.52.4", - "troika-worker-utils": "^0.52.0", - "webgl-sdf-generator": "1.1.1" - }, - "peerDependencies": { - "three": ">=0.125.0" - } - }, - "node_modules/troika-three-text/node_modules/troika-worker-utils": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", - "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", - "license": "MIT" - }, - "node_modules/troika-three-utils": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", - "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", - "license": "MIT", - "peerDependencies": { - "three": ">=0.125.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tunnel-rat": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", - "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", - "license": "MIT", - "dependencies": { - "zustand": "^4.3.2" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/utility-types": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/webgl-constants": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", - "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" - }, - "node_modules/webgl-sdf-generator": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", - "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zustand": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.9.tgz", - "integrity": "sha512-Tat5r8jOMG1Vcsj8uldMyqYKC5IZvQif8zetmLHs9WoZlntTHmIoNM8TpLRY31ExncuUvUOXehd0kvahkuHjDw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "1.2.0" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "immer": ">=9.0", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/zustand/node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - } - } -} +{ + "name": "mission-control-2-frontend", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mission-control-2-frontend", + "version": "2.0.0", + "dependencies": { + "@pixiv/three-vrm": "^3.4.0", + "@react-three/drei": "^9.114.0", + "@react-three/fiber": "^8.17.10", + "@tanstack/react-query": "^5.101.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^3.9.0", + "sigma": "^3.0.3", + "tailwind-merge": "^2.5.5", + "three": "^0.169.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@pixiv/three-vrm": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm/-/three-vrm-3.5.4.tgz", + "integrity": "sha512-hY0MnmKVLUebFR9QN9vCRXo0rZmLpxjLVZAupQv5qDDGCKZuNxlOts9PqTkWlzhkMLLdhB48AF+P4PCy/DFOzA==", + "license": "MIT", + "dependencies": { + "@pixiv/three-vrm-core": "3.5.4", + "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.4", + "@pixiv/three-vrm-materials-mtoon": "3.5.4", + "@pixiv/three-vrm-materials-v0compat": "3.5.4", + "@pixiv/three-vrm-node-constraint": "3.5.4", + "@pixiv/three-vrm-springbone": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-core": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-core/-/three-vrm-core-3.5.4.tgz", + "integrity": "sha512-CgaxZ4qX6JmE2oKsOVGGlheT011qP4bBZGUaMJJ5iQINI999+cveIdISe459B+AMYD1dU5h1+xCtDthLQNH1Bg==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.4", + "@pixiv/types-vrmc-vrm-1.0": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.4.tgz", + "integrity": "sha512-3BPZ42qW38cHhP+imqEnqTFsltvYkHA/t4VzWFdQ9sngWt0NiFAOotEsv8gZn1ZgKE9VRQezXne6s5aRWQlQqA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-mtoon": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.4.tgz", + "integrity": "sha512-vLHt7IYZxlijbCMa5TRRf6gaQjA65F/d7oZBNfZW3XC8sBg57ZV0M/xBAJm2d6UwVmTbBd9NG9li/Y9yuupDQw==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.4", + "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-materials-v0compat": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.4.tgz", + "integrity": "sha512-qRRtg8vYFBRJpsa3evruWqmXr0Gwd8uJXwxxUKuKtrIN0Hw0hJsRLHkg9E238Q8xRTnoycdkSgX4VvVknvtXBA==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.4", + "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-node-constraint": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.4.tgz", + "integrity": "sha512-nyAghDrYNp0Z2siEaY2+th+FzZdDs9EJy8K576Mpw9cy3jwtCJuTHBQmGzKkrgoLaBfrso+3Tdfgr62AJV0m1w==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrmc-node-constraint-1.0": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/three-vrm-springbone": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.4.tgz", + "integrity": "sha512-8KWA7vHU+OnW6XcZPnDuUrjjPFRzmv2yxcTMNCYQvBS547/Iqo0RLbQvpO/ppceLTNJFY2TGXAWJU67aeazWjw==", + "license": "MIT", + "dependencies": { + "@pixiv/types-vrm-0.0": "3.5.4", + "@pixiv/types-vrmc-springbone-1.0": "3.5.4", + "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.4" + }, + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/@pixiv/types-vrm-0.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.4.tgz", + "integrity": "sha512-g9VMPikJxKJ/XgnhXxnqj6ejVhQ9WwOEUp7KyyZHsqRnHcQfSqyZFTs9wkAMCbwCqHXX/lg9+9Ogj8KhxCkbXw==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.4.tgz", + "integrity": "sha512-h9GEQ3q1VTylL/P40kJ8uoQhfUYY54NhTG6Xsnl4X0jf7oHh8MKXYnMMn903hXml6082Yjx1C6x3DKckdnophg==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.4.tgz", + "integrity": "sha512-AU5sOcsbmcnzRPfgfk7fzKhk5lNm2MqrbvtAsSlhylVMdlzrmXqIfRuexABcB9k+ysTp49qIFioot9KRlQcDUw==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-node-constraint-1.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.4.tgz", + "integrity": "sha512-RRbK5NNvZv4ewRELezueCiDB11FGkt4pdZR/UJ027DJPaNzd6rls2OEJ8weQ3OmgHZpbx/BtdpX2JWX5WZZd/Q==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-springbone-1.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.4.tgz", + "integrity": "sha512-NO7HTRBuWEe89Wo9BRI5hX1kWVkZzA4YWg9XALSTLdIT8HMiArL4NRse/CF91jNuLcTGfPEWlBJyq9SciAQxAg==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.4.tgz", + "integrity": "sha512-uzJmcRh/iHYnZPtnq6N1+jb+T/ptYNCyFEoArkWGNXHSfPjTkMcheTQkL5iMNvTE6hqhyMRVMha9OmbvyLmmUQ==", + "license": "MIT" + }, + "node_modules/@pixiv/types-vrmc-vrm-1.0": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.4.tgz", + "integrity": "sha512-pkjT4QXT/Hp6rcq8J8EFEHIldbtPGYWOTaWdoRGMA8/KzJI5PdekZ6AUYbnSP6ElfJ3BSZ01DxxpQZa/UNsFqA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-spring/three": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", + "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/three/node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three/node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three/node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/three/node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three/node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.122.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", + "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@react-spring/three": "~9.7.5", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^2.9.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "react-composer": "^5.0.3", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^8", + "react": "^18", + "react-dom": "^18", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/drei/node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@react-three/drei/node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz", + "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz", + "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/three/node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-layout-forceatlas2": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", + "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.1.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.49", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", + "integrity": "sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", + "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sigma": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz", + "integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-text/node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.9.tgz", + "integrity": "sha512-Tat5r8jOMG1Vcsj8uldMyqYKC5IZvQif8zetmLHs9WoZlntTHmIoNM8TpLRY31ExncuUvUOXehd0kvahkuHjDw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "immer": ">=9.0", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zustand/node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 2913628..65ae94e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,39 +1,39 @@ -{ - "name": "mission-control-2-frontend", - "private": true, - "version": "2.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@pixiv/three-vrm": "^3.4.0", - "@react-three/drei": "^9.114.0", - "@react-three/fiber": "^8.17.10", - "@tanstack/react-query": "^5.101.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "graphology": "^0.26.0", - "graphology-layout-forceatlas2": "^0.10.1", - "lucide-react": "^0.460.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "recharts": "^3.9.0", - "sigma": "^3.0.3", - "tailwind-merge": "^2.5.5", - "three": "^0.169.0" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/node": "^22.10.1", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@types/three": "^0.169.0", - "@vitejs/plugin-react": "^4.3.4", - "tailwindcss": "^4.0.0", - "typescript": "^5.6.3", - "vite": "^6.0.3" - } -} +{ + "name": "mission-control-2-frontend", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@pixiv/three-vrm": "^3.4.0", + "@react-three/drei": "^9.114.0", + "@react-three/fiber": "^8.17.10", + "@tanstack/react-query": "^5.101.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "graphology": "^0.26.0", + "graphology-layout-forceatlas2": "^0.10.1", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^3.9.0", + "sigma": "^3.0.3", + "tailwind-merge": "^2.5.5", + "three": "^0.169.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea66bbd..32bd901 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,248 +1,248 @@ -import { useEffect, useState, useCallback } from "react" -import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react" -import { NAV, type ViewId } from "@/nav" -import { CommandPalette } from "@/components/CommandPalette" -import { ExpertToggle } from "@/components/ExpertToggle" -import { ModelsView } from "@/views/ModelsView" -import { ConnectView } from "@/views/ConnectView" -import { MemoryView } from "@/views/MemoryView" -import { AgentView } from "@/views/AgentView" -import { TerminalView } from "@/views/TerminalView" -import { KonsoleView } from "@/views/KonsoleView" -import { GuideView } from "@/views/GuideView" -import { Placeholder } from "@/views/Placeholder" -import { SystemDrawer } from "@/components/SystemDrawer" -import { useHealth, useSystemStatus } from "@/lib/queries" -import { useMetricsFeeder } from "@/lib/metricsStore" -import { cn } from "@/lib/utils" -import { Mc3App } from "@/mc3/Mc3App" -import { CockpitView } from "@/views/cockpit/CockpitView" - -export default function App() { - useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab - // View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren. - const [view, setView] = useState(() => { - const h = window.location.hash.slice(1) as ViewId - return NAV.some((n) => n.id === h) ? h : "dashboard" - }) - const navigate = useCallback((v: ViewId) => { - if (window.location.hash.slice(1) === v) setView(v) - else window.location.hash = v - }, []) - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") - const [drawerOpen, setDrawerOpen] = useState(false) - 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: sysStatus } = useSystemStatus(20_000) - - useEffect(() => { - document.documentElement.classList.add("dark") - }, []) - - useEffect(() => { - const handleOpen = (e: Event) => { - const customEvent = e as CustomEvent - setDrawerTab(customEvent.detail?.tab || "maintenance") - setDrawerOpen(true) - } - window.addEventListener("open-system-drawer", handleOpen) - return () => window.removeEventListener("open-system-drawer", handleOpen) - }, []) - - useEffect(() => { - const handleNav = (e: Event) => { - const v = (e as CustomEvent).detail?.view as ViewId | undefined - if (v) navigate(v) - } - window.addEventListener("mc-navigate", handleNav) - return () => window.removeEventListener("mc-navigate", handleNav) - }, [navigate]) - - useEffect(() => { - const onHash = () => { - setIsMc3(window.location.hash === "#mc3") - const h = window.location.hash.slice(1) as ViewId - if (NAV.some((n) => n.id === h)) setView(h) - } - window.addEventListener("hashchange", onHash) - return () => window.removeEventListener("hashchange", onHash) - }, []) - - const active = NAV.find((n) => n.id === view)! - - // Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche. - if (isMc3) return - - return ( -
- {/* Background Aurora Ambient Effects */} -
-
-
-
-
-
- - - setDrawerOpen(false)} defaultTab={drawerTab} /> - - {/* Sidebar */} - - - {/* Main */} -
-
-
{active.hint}
-
- - - Hilfe - - - -
-
- -
- {view === "dashboard" && navigate(v as ViewId)} />} - {view === "models" && } - {view === "connect" && } - {view === "memory" && } - {view === "agent" && } - {view === "terminal" && } - {view === "konsole" && } - {view === "guide" && } - {!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && ( - - )} -
-
-
- ) -} - +import { useEffect, useState, useCallback } from "react" +import { Command as CommandIcon, ChevronLeft, ChevronRight } from "lucide-react" +import { NAV, type ViewId } from "@/nav" +import { CommandPalette } from "@/components/CommandPalette" +import { ExpertToggle } from "@/components/ExpertToggle" +import { ModelsView } from "@/views/ModelsView" +import { ConnectView } from "@/views/ConnectView" +import { MemoryView } from "@/views/MemoryView" +import { AgentView } from "@/views/AgentView" +import { TerminalView } from "@/views/TerminalView" +import { KonsoleView } from "@/views/KonsoleView" +import { GuideView } from "@/views/GuideView" +import { Placeholder } from "@/views/Placeholder" +import { SystemDrawer } from "@/components/SystemDrawer" +import { useHealth, useSystemStatus } from "@/lib/queries" +import { useMetricsFeeder } from "@/lib/metricsStore" +import { cn } from "@/lib/utils" +import { Mc3App } from "@/mc3/Mc3App" +import { CockpitView } from "@/views/cockpit/CockpitView" + +export default function App() { + useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab + // View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren. + const [view, setView] = useState(() => { + const h = window.location.hash.slice(1) as ViewId + return NAV.some((n) => n.id === h) ? h : "dashboard" + }) + const navigate = useCallback((v: ViewId) => { + if (window.location.hash.slice(1) === v) setView(v) + else window.location.hash = v + }, []) + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") + const [drawerOpen, setDrawerOpen] = useState(false) + 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: sysStatus } = useSystemStatus(20_000) + + useEffect(() => { + document.documentElement.classList.add("dark") + }, []) + + useEffect(() => { + const handleOpen = (e: Event) => { + const customEvent = e as CustomEvent + setDrawerTab(customEvent.detail?.tab || "maintenance") + setDrawerOpen(true) + } + window.addEventListener("open-system-drawer", handleOpen) + return () => window.removeEventListener("open-system-drawer", handleOpen) + }, []) + + useEffect(() => { + const handleNav = (e: Event) => { + const v = (e as CustomEvent).detail?.view as ViewId | undefined + if (v) navigate(v) + } + window.addEventListener("mc-navigate", handleNav) + return () => window.removeEventListener("mc-navigate", handleNav) + }, [navigate]) + + useEffect(() => { + const onHash = () => { + setIsMc3(window.location.hash === "#mc3") + const h = window.location.hash.slice(1) as ViewId + if (NAV.some((n) => n.id === h)) setView(h) + } + window.addEventListener("hashchange", onHash) + return () => window.removeEventListener("hashchange", onHash) + }, []) + + const active = NAV.find((n) => n.id === view)! + + // Nicht-destruktiver MC3-Prototyp: alle Hooks liefen oben, hier nur die Weiche. + if (isMc3) return + + return ( +
+ {/* Background Aurora Ambient Effects */} +
+
+
+
+
+
+ + + setDrawerOpen(false)} defaultTab={drawerTab} /> + + {/* Sidebar */} + + + {/* Main */} +
+
+
{active.hint}
+
+ + + Hilfe + + + +
+
+ +
+ {view === "dashboard" && navigate(v as ViewId)} />} + {view === "models" && } + {view === "connect" && } + {view === "memory" && } + {view === "agent" && } + {view === "terminal" && } + {view === "konsole" && } + {view === "guide" && } + {!["dashboard", "models", "connect", "memory", "agent", "terminal", "konsole", "guide"].includes(view) && ( + + )} +
+
+
+ ) +} + diff --git a/frontend/src/components/AppErrorBoundary.tsx b/frontend/src/components/AppErrorBoundary.tsx index fd18ac7..e889c71 100644 --- a/frontend/src/components/AppErrorBoundary.tsx +++ b/frontend/src/components/AppErrorBoundary.tsx @@ -1,38 +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 { - 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 ( -
-
-
Da ist etwas schiefgelaufen.
-
- {this.state.error.message} -
- -
-
- ) - } -} +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 { + 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 ( +
+
+
Da ist etwas schiefgelaufen.
+
+ {this.state.error.message} +
+ +
+
+ ) + } +} diff --git a/frontend/src/components/CustomDialog.tsx b/frontend/src/components/CustomDialog.tsx index 6a10f9d..90391e1 100644 --- a/frontend/src/components/CustomDialog.tsx +++ b/frontend/src/components/CustomDialog.tsx @@ -1,82 +1,82 @@ -import { useRef } from "react" -import { X } from "lucide-react" - -export interface CustomDialogProps { - type: "alert" | "confirm" | "prompt" - title: string - message: string - defaultValue?: string - autoValue?: string - autoLabel?: string - onConfirm: (val?: string) => void - onCancel?: () => void -} - -export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) { - const inputRef = useRef(null) - return ( -
-
-
-

{title}

- -
-

{message}

- - {type === "prompt" && ( -
- { - if (e.key === "Enter") { - onConfirm(inputRef.current?.value) - } - }} - /> - {autoValue !== undefined && ( - - )} -
- )} - -
- {(type === "confirm" || type === "prompt") && ( - - )} - -
-
-
- ) -} +import { useRef } from "react" +import { X } from "lucide-react" + +export interface CustomDialogProps { + type: "alert" | "confirm" | "prompt" + title: string + message: string + defaultValue?: string + autoValue?: string + autoLabel?: string + onConfirm: (val?: string) => void + onCancel?: () => void +} + +export function CustomDialog({ type, title, message, defaultValue, autoValue, autoLabel, onConfirm, onCancel }: CustomDialogProps) { + const inputRef = useRef(null) + return ( +
+
+
+

{title}

+ +
+

{message}

+ + {type === "prompt" && ( +
+ { + if (e.key === "Enter") { + onConfirm(inputRef.current?.value) + } + }} + /> + {autoValue !== undefined && ( + + )} +
+ )} + +
+ {(type === "confirm" || type === "prompt") && ( + + )} + +
+
+
+ ) +} diff --git a/frontend/src/components/SystemDrawer.tsx b/frontend/src/components/SystemDrawer.tsx index 4d6ab37..45a9943 100644 --- a/frontend/src/components/SystemDrawer.tsx +++ b/frontend/src/components/SystemDrawer.tsx @@ -1,639 +1,639 @@ -import { useEffect, useState } from "react" -import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle } from "lucide-react" -import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api" -import { cn } from "@/lib/utils" -import { CustomDialog } from "./CustomDialog" -import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows" -import { LogConsole } from "./system/LogConsole" -import { SettingsTab } from "./system/SettingsTab" -import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal" - - -interface SystemDrawerProps { - open: boolean - onClose: () => void - defaultTab?: "maintenance" | "logs" | "settings" -} - - -export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) { - const [updates, setUpdates] = useState(null) - const [jobs, setJobs] = useState([]) - const [selectedService, setSelectedService] = useState("llama-swap") - const [logs, setLogs] = useState("") - const [loadingLogs, setLoadingLogs] = useState(false) - const [logStatus, setLogStatus] = useState(null) - const [restartingServices, setRestartingServices] = useState>({}) - const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance") - const [checkingUpdates, setCheckingUpdates] = useState(false) - const [backupMsg, setBackupMsg] = useState("") - const [backupRunning, setBackupRunning] = useState(false) - const [services, setServices] = useState(null) - const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([]) - const [hermesUpdating, setHermesUpdating] = useState(false) - - // Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird. - const [detail, setDetail] = useState(null) - - // Custom Dialog State - const [dialog, setDialog] = useState<{ - type: "alert" | "confirm" - title: string - message: string - onConfirm: () => void - onCancel?: () => void - } | null>(null) - - function showAlert(title: string, message: string, onConfirm?: () => void) { - setDialog({ - type: "alert", - title, - message, - onConfirm: () => { - setDialog(null) - if (onConfirm) onConfirm() - } - }) - } - - function showConfirm(title: string, message: string, onConfirm: () => void) { - setDialog({ - type: "confirm", - title, - message, - onConfirm: () => { - setDialog(null) - onConfirm() - }, - onCancel: () => setDialog(null) - }) - } - - function formatLastCheck(ts?: number | null) { - if (!ts) return "Nie" - return new Date(ts * 1000).toLocaleString("de-DE", { - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }) - } - - - useEffect(() => { - if (open && defaultTab) { - setActiveTab(defaultTab) - } - }, [open, defaultTab]) - - - function loadUpdates() { - api("/api/maintenance/updates") - .then(setUpdates) - .catch((e) => console.error("Error loading updates", e)) - } - - function loadJobs() { - api<{ jobs: Job[] }>("/api/jobs") - .then((data) => setJobs(data.jobs || [])) - .catch((e) => console.error("Error loading jobs", e)) - } - - function loadServices() { - api("/api/system/services").then(setServices).catch(() => {}) - } - - function loadBackups() { - api<{ backups: { snapshot: string; size_mb?: number }[] }>("/api/system/backups") - .then((d) => setBackups(d.backups || [])).catch(() => {}) - } - - function loadServiceLogs(service: string) { - setLoadingLogs(true) - setLogStatus(null) - api<{ ok: boolean; text: string; err?: string; status?: string }>(`/api/maintenance/logs?service=${service}&lines=150`) - .then((res) => { - if (res.ok) { - setLogs(res.text) - } else { - setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`) - if (res.status === "incorrect_password" || res.status === "password_required") { - setLogStatus(res.status) - } - } - }) - .catch((e) => setLogs(`Fehler: ${e.message}`)) - .finally(() => setLoadingLogs(false)) - } - - // Poll jobs & updates when open - useEffect(() => { - if (!open) return - loadUpdates() - loadJobs() - loadServices() - loadBackups() - const timer = setInterval(() => { - loadJobs() - loadUpdates() - loadServices() - }, 3000) - return () => clearInterval(timer) - }, [open]) - - // Load logs when tab is active or service changes - useEffect(() => { - if (!open || activeTab !== "logs") return - loadServiceLogs(selectedService) - }, [open, activeTab, selectedService]) - - // Backend-Wartungsriegel: lehnt ein zweites Update ab, solange eines läuft. - function handledBusy(res: any): boolean { - if (res?.status === "busy") { - showAlert("Update läuft bereits", `Es läuft gerade „${res.running}". Bitte warte, bis es fertig ist.`) - loadJobs() - return true - } - return false - } - - // Passwort-Fehler NICHT mehr verschlucken: OS-Update braucht das Box-Passwort. Früher - // lief die Aktion bei fehlendem/falschem Passwort ins Leere („passiert nichts"). - function handledSudo(res: any): boolean { - if (res?.status === "password_required" || res?.status === "incorrect_password") { - showAlert( - "Box-Passwort nötig", - (res.status === "incorrect_password" - ? "Das gespeicherte Box-Passwort stimmt nicht. " - : "Für dieses Update braucht die Box dein Passwort. ") - + "Bitte oben im Reiter „Einstellungen“ setzen bzw. korrigieren.", - ) - return true - } - if (res && res.ok === false) { - showAlert("Update fehlgeschlagen", res.err || "Unbekannter Fehler.") - return true - } - return false - } - - async function triggerOsUpdate() { - try { - const res = await api("/api/maintenance/os-update", { method: "POST" }) - if (handledBusy(res) || handledSudo(res)) return - loadJobs() - setActiveTab("maintenance") - } catch (e: any) { - showAlert("Fehler", `Fehler beim Starten des OS-Updates: ${e.message}`) - } - } - - async function triggerEngineUpdate() { - try { - const res = await api("/api/maintenance/engine-update", { method: "POST" }) - if (handledBusy(res) || handledSudo(res)) return - loadJobs() - setActiveTab("maintenance") - } catch (e: any) { - showAlert("Fehler", `Fehler beim Engine-Update: ${e.message}`) - } - } - - async function triggerSwapUpdate() { - try { - const res = await api("/api/maintenance/swap-update", { method: "POST" }) - if (handledBusy(res) || handledSudo(res)) return - loadJobs() - setActiveTab("maintenance") - } catch (e: any) { - showAlert("Fehler", `Fehler beim Router-Update: ${e.message}`) - } - } - - async function doHermesUpdate() { - setHermesUpdating(true) - try { - const res = await api("/api/maintenance/hermes-update", { method: "POST" }) - if (handledBusy(res)) return - loadJobs() - } catch (e: any) { - showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`) - } finally { - setHermesUpdating(false) - } - } - - // Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde. - async function openUpdateDetails(kind: "os" | "engine" | "swap" | "hermes") { - setDetail({ kind, loading: true, data: null }) - try { - const d = await api(`/api/maintenance/update-details?kind=${kind}`) - setDetail({ kind, loading: false, data: d }) - } catch (e: any) { - setDetail({ kind, loading: false, data: { kind, error: e.message } }) - } - } - - // Bestätigung aus dem Detail-Fenster → startet das passende Update. - function applyFromDetail() { - const kind = detail?.kind - setDetail(null) - if (kind === "os") triggerOsUpdate() - else if (kind === "engine") triggerEngineUpdate() - else if (kind === "swap") triggerSwapUpdate() - else if (kind === "hermes") doHermesUpdate() - } - - async function triggerCheckUpdates() { - setCheckingUpdates(true) - try { - await api("/api/maintenance/check-updates", { method: "POST" }) - loadJobs() - setActiveTab("maintenance") - } catch (e: any) { - showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`) - } finally { - setCheckingUpdates(false) - } - } - - async function triggerModelUpgrade(repo: string, role: string) { - try { - await api("/api/models/install", { - method: "POST", - body: JSON.stringify({ repo, role }) - }) - showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`) - loadJobs() - setActiveTab("maintenance") - } catch (e: any) { - showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`) - } - } - - async function triggerReboot() { - showConfirm( - "Reboot bestätigen", - "Bist du sicher, dass du das gesamte Host-System neu starten willst?", - async () => { - try { - await api("/api/maintenance/reboot", { method: "POST" }) - showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => { - onClose() - }) - } catch (e: any) { - showAlert("Fehler", `Fehler beim Reboot: ${e.message}`) - } - } - ) - } - - async function doBackup() { - setBackupRunning(true) - setBackupMsg("Snapshot wird erzeugt...") - try { - const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" }) - setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Komponenten)` : "Backup fehlgeschlagen.") - loadBackups() - } catch (e: any) { - setBackupMsg(`Fehler: ${e.message}`) - } finally { - setBackupRunning(false) - } - } - - async function restartService(serviceId: string) { - setRestartingServices(prev => ({ ...prev, [serviceId]: true })) - try { - const res = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", { - method: "POST", - body: JSON.stringify({ service: serviceId }) - }) - if (res.ok) { - showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => { - if (activeTab === "logs" && selectedService === serviceId) { - loadServiceLogs(serviceId) - } - }) - } else { - showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`) - } - } catch (e: any) { - showAlert("Fehler", `Fehler beim Neustart: ${e.message}`) - } finally { - setRestartingServices(prev => ({ ...prev, [serviceId]: false })) - } - } - - async function cancelJob(jobId: string) { - try { - await api(`/api/jobs/${jobId}/cancel`, { method: "POST" }) - loadJobs() - } catch (e: any) { - showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`) - } - } - - // Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar. - const maintenanceJob = jobs.find(j => (j.state === "running" || j.state === "queued") && j.group === "maintenance") - - return ( - <> - {/* Backdrop */} -
- - {/* Drawer */} -
- {/* Header */} -
-
- -

OS-Zentrale & Pflege

-
- -
- - {/* Navigation Tabs */} -
- - - -
- - {/* Content */} -
- {activeTab === "maintenance" && ( - <> - {/* Updates */} -
-
-

Updates

- -
- {updates?.last_check && ( -
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
- )} - {maintenanceJob && ( -
- - Update läuft: {maintenanceJob.label} — bitte warten. Weitere Updates sind solange gesperrt. -
- )} -
- openUpdateDetails("os")} /> - openUpdateDetails("engine")} /> - openUpdateDetails("swap")} /> - {(() => { - const h = updates?.components?.find((c) => c.key === "hermes_agent") - return ( - openUpdateDetails("hermes")} /> - ) - })()} - {updates?.model_list?.map((m) => ( - triggerModelUpgrade(m.repo, m.role)} /> - ))} -
-
- - {/* Dienste */} -
-

Dienste

-
- {SERVICES.map((s) => ( - x.name.toLowerCase().includes(s.reach))?.ok} busy={restartingServices[s.id]} onRestart={() => restartService(s.id)} onLogs={() => { setSelectedService(s.id); setActiveTab("logs") }} /> - ))} -
-
- - {/* Backup */} -
-

Backup

-
-
-
{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}
-
{backups.length} Snapshots · Restore per CLI (restore.sh)
-
- -
- {backupMsg && ( -
{backupMsg}
- )} -
- - {/* Gefahrenzone */} -
-

Gefahrenzone

- -
- - {/* Active Jobs */} -
-
-

Hintergrund-Aufgaben

- - {jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv - -
- -
- {jobs.length === 0 ? ( -
- Aktuell keine aktiven Hintergrund-Jobs. -
- ) : ( - jobs.map((job) => { - const isActive = job.state === "running" || job.state === "queued" - return ( -
-
-
-
- {isActive && ( - - - - - )} - {job.label} -
-
- ID: {job.id} - - - {job.state} - -
-
- - {isActive && ( - - )} -
- - {/* Progress */} - {job.state === "running" && ( -
-
-
-
-
- {job.progress ?? 0}% - {job.done_bytes != null && job.total_bytes != null && ( - - {formatBytes(job.done_bytes)} / {formatBytes(job.total_bytes)} - {job.rate_bps != null && ` (${formatBytes(job.rate_bps)}/s)`} - - )} - {job.eta_s != null && ETA: {job.eta_s}s} -
-
- )} -
- ) - }) - )} -
-
- - )} - - {activeTab === "logs" && ( - // Logs View -
- {/* Service Select & Restart */} -
- - - -
- - loadServiceLogs(selectedService)} - onOpenSettings={() => setActiveTab("settings")} - /> -
- )} - - {activeTab === "settings" && } -
-
- - {detail && ( - setDetail(null)} onApply={applyFromDetail} /> - )} - - {dialog && ( - - )} - - ) -} +import { useEffect, useState } from "react" +import { X, RefreshCw, Cpu, Server, Shield, Power, Camera, Bot, Box, Shuffle } from "lucide-react" +import { api, type Job, type UpdatesResp, type ServicesResp, type UpdateDetails } from "@/lib/api" +import { cn } from "@/lib/utils" +import { CustomDialog } from "./CustomDialog" +import { SERVICES, ServiceRow, UpdateRow, formatBytes } from "./system/rows" +import { LogConsole } from "./system/LogConsole" +import { SettingsTab } from "./system/SettingsTab" +import { UpdateDetailModal, type UpdateDetailState } from "./system/UpdateDetailModal" + + +interface SystemDrawerProps { + open: boolean + onClose: () => void + defaultTab?: "maintenance" | "logs" | "settings" +} + + +export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: SystemDrawerProps) { + const [updates, setUpdates] = useState(null) + const [jobs, setJobs] = useState([]) + const [selectedService, setSelectedService] = useState("llama-swap") + const [logs, setLogs] = useState("") + const [loadingLogs, setLoadingLogs] = useState(false) + const [logStatus, setLogStatus] = useState(null) + const [restartingServices, setRestartingServices] = useState>({}) + const [activeTab, setActiveTab] = useState<"maintenance" | "logs" | "settings">("maintenance") + const [checkingUpdates, setCheckingUpdates] = useState(false) + const [backupMsg, setBackupMsg] = useState("") + const [backupRunning, setBackupRunning] = useState(false) + const [services, setServices] = useState(null) + const [backups, setBackups] = useState<{ snapshot: string; size_mb?: number }[]>([]) + const [hermesUpdating, setHermesUpdating] = useState(false) + + // Update-Detail-Fenster: zeigt VOR dem Anwenden, was genau aktualisiert wird. + const [detail, setDetail] = useState(null) + + // Custom Dialog State + const [dialog, setDialog] = useState<{ + type: "alert" | "confirm" + title: string + message: string + onConfirm: () => void + onCancel?: () => void + } | null>(null) + + function showAlert(title: string, message: string, onConfirm?: () => void) { + setDialog({ + type: "alert", + title, + message, + onConfirm: () => { + setDialog(null) + if (onConfirm) onConfirm() + } + }) + } + + function showConfirm(title: string, message: string, onConfirm: () => void) { + setDialog({ + type: "confirm", + title, + message, + onConfirm: () => { + setDialog(null) + onConfirm() + }, + onCancel: () => setDialog(null) + }) + } + + function formatLastCheck(ts?: number | null) { + if (!ts) return "Nie" + return new Date(ts * 1000).toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }) + } + + + useEffect(() => { + if (open && defaultTab) { + setActiveTab(defaultTab) + } + }, [open, defaultTab]) + + + function loadUpdates() { + api("/api/maintenance/updates") + .then(setUpdates) + .catch((e) => console.error("Error loading updates", e)) + } + + function loadJobs() { + api<{ jobs: Job[] }>("/api/jobs") + .then((data) => setJobs(data.jobs || [])) + .catch((e) => console.error("Error loading jobs", e)) + } + + function loadServices() { + api("/api/system/services").then(setServices).catch(() => {}) + } + + function loadBackups() { + api<{ backups: { snapshot: string; size_mb?: number }[] }>("/api/system/backups") + .then((d) => setBackups(d.backups || [])).catch(() => {}) + } + + function loadServiceLogs(service: string) { + setLoadingLogs(true) + setLogStatus(null) + api<{ ok: boolean; text: string; err?: string; status?: string }>(`/api/maintenance/logs?service=${service}&lines=150`) + .then((res) => { + if (res.ok) { + setLogs(res.text) + } else { + setLogs(`Fehler beim Laden der Logs: ${res.err || "Unbekannter Fehler"}`) + if (res.status === "incorrect_password" || res.status === "password_required") { + setLogStatus(res.status) + } + } + }) + .catch((e) => setLogs(`Fehler: ${e.message}`)) + .finally(() => setLoadingLogs(false)) + } + + // Poll jobs & updates when open + useEffect(() => { + if (!open) return + loadUpdates() + loadJobs() + loadServices() + loadBackups() + const timer = setInterval(() => { + loadJobs() + loadUpdates() + loadServices() + }, 3000) + return () => clearInterval(timer) + }, [open]) + + // Load logs when tab is active or service changes + useEffect(() => { + if (!open || activeTab !== "logs") return + loadServiceLogs(selectedService) + }, [open, activeTab, selectedService]) + + // Backend-Wartungsriegel: lehnt ein zweites Update ab, solange eines läuft. + function handledBusy(res: any): boolean { + if (res?.status === "busy") { + showAlert("Update läuft bereits", `Es läuft gerade „${res.running}". Bitte warte, bis es fertig ist.`) + loadJobs() + return true + } + return false + } + + // Passwort-Fehler NICHT mehr verschlucken: OS-Update braucht das Box-Passwort. Früher + // lief die Aktion bei fehlendem/falschem Passwort ins Leere („passiert nichts"). + function handledSudo(res: any): boolean { + if (res?.status === "password_required" || res?.status === "incorrect_password") { + showAlert( + "Box-Passwort nötig", + (res.status === "incorrect_password" + ? "Das gespeicherte Box-Passwort stimmt nicht. " + : "Für dieses Update braucht die Box dein Passwort. ") + + "Bitte oben im Reiter „Einstellungen“ setzen bzw. korrigieren.", + ) + return true + } + if (res && res.ok === false) { + showAlert("Update fehlgeschlagen", res.err || "Unbekannter Fehler.") + return true + } + return false + } + + async function triggerOsUpdate() { + try { + const res = await api("/api/maintenance/os-update", { method: "POST" }) + if (handledBusy(res) || handledSudo(res)) return + loadJobs() + setActiveTab("maintenance") + } catch (e: any) { + showAlert("Fehler", `Fehler beim Starten des OS-Updates: ${e.message}`) + } + } + + async function triggerEngineUpdate() { + try { + const res = await api("/api/maintenance/engine-update", { method: "POST" }) + if (handledBusy(res) || handledSudo(res)) return + loadJobs() + setActiveTab("maintenance") + } catch (e: any) { + showAlert("Fehler", `Fehler beim Engine-Update: ${e.message}`) + } + } + + async function triggerSwapUpdate() { + try { + const res = await api("/api/maintenance/swap-update", { method: "POST" }) + if (handledBusy(res) || handledSudo(res)) return + loadJobs() + setActiveTab("maintenance") + } catch (e: any) { + showAlert("Fehler", `Fehler beim Router-Update: ${e.message}`) + } + } + + async function doHermesUpdate() { + setHermesUpdating(true) + try { + const res = await api("/api/maintenance/hermes-update", { method: "POST" }) + if (handledBusy(res)) return + loadJobs() + } catch (e: any) { + showAlert("Fehler", `Hermes-Update fehlgeschlagen: ${e.message}`) + } finally { + setHermesUpdating(false) + } + } + + // Öffnet das Detail-Fenster und lädt, was genau aktualisiert würde. + async function openUpdateDetails(kind: "os" | "engine" | "swap" | "hermes") { + setDetail({ kind, loading: true, data: null }) + try { + const d = await api(`/api/maintenance/update-details?kind=${kind}`) + setDetail({ kind, loading: false, data: d }) + } catch (e: any) { + setDetail({ kind, loading: false, data: { kind, error: e.message } }) + } + } + + // Bestätigung aus dem Detail-Fenster → startet das passende Update. + function applyFromDetail() { + const kind = detail?.kind + setDetail(null) + if (kind === "os") triggerOsUpdate() + else if (kind === "engine") triggerEngineUpdate() + else if (kind === "swap") triggerSwapUpdate() + else if (kind === "hermes") doHermesUpdate() + } + + async function triggerCheckUpdates() { + setCheckingUpdates(true) + try { + await api("/api/maintenance/check-updates", { method: "POST" }) + loadJobs() + setActiveTab("maintenance") + } catch (e: any) { + showAlert("Fehler", `Fehler bei der Update-Suche: ${e.message}`) + } finally { + setCheckingUpdates(false) + } + } + + async function triggerModelUpgrade(repo: string, role: string) { + try { + await api("/api/models/install", { + method: "POST", + body: JSON.stringify({ repo, role }) + }) + showAlert("Gestartet", `Modell-Upgrade für '${role}' (${repo}) gestartet.`) + loadJobs() + setActiveTab("maintenance") + } catch (e: any) { + showAlert("Fehler", `Fehler beim Starten des Modell-Upgrades: ${e.message}`) + } + } + + async function triggerReboot() { + showConfirm( + "Reboot bestätigen", + "Bist du sicher, dass du das gesamte Host-System neu starten willst?", + async () => { + try { + await api("/api/maintenance/reboot", { method: "POST" }) + showAlert("Reboot", "Reboot ausgelöst. System startet neu...", () => { + onClose() + }) + } catch (e: any) { + showAlert("Fehler", `Fehler beim Reboot: ${e.message}`) + } + } + ) + } + + async function doBackup() { + setBackupRunning(true) + setBackupMsg("Snapshot wird erzeugt...") + try { + const r = await api<{ ok: boolean; snapshot: string; files: string[] }>("/api/system/backup", { method: "POST" }) + setBackupMsg(r.ok ? `Snapshot erzeugt: ${r.snapshot} (${r.files.length} Komponenten)` : "Backup fehlgeschlagen.") + loadBackups() + } catch (e: any) { + setBackupMsg(`Fehler: ${e.message}`) + } finally { + setBackupRunning(false) + } + } + + async function restartService(serviceId: string) { + setRestartingServices(prev => ({ ...prev, [serviceId]: true })) + try { + const res = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", { + method: "POST", + body: JSON.stringify({ service: serviceId }) + }) + if (res.ok) { + showAlert("Dienst neu gestartet", `Dienst ${serviceId} wurde erfolgreich neu gestartet.`, () => { + if (activeTab === "logs" && selectedService === serviceId) { + loadServiceLogs(serviceId) + } + }) + } else { + showAlert("Fehler", `Fehler beim Neustart: ${res.err || "Unbekannter Fehler"}`) + } + } catch (e: any) { + showAlert("Fehler", `Fehler beim Neustart: ${e.message}`) + } finally { + setRestartingServices(prev => ({ ...prev, [serviceId]: false })) + } + } + + async function cancelJob(jobId: string) { + try { + await api(`/api/jobs/${jobId}/cancel`, { method: "POST" }) + loadJobs() + } catch (e: any) { + showAlert("Fehler", `Fehler beim Abbrechen: ${e.message}`) + } + } + + // Aktiver Wartungs-Job (System-Update) → UI-Aktionen sperren, Dashboard bleibt sichtbar. + const maintenanceJob = jobs.find(j => (j.state === "running" || j.state === "queued") && j.group === "maintenance") + + return ( + <> + {/* Backdrop */} +
+ + {/* Drawer */} +
+ {/* Header */} +
+
+ +

OS-Zentrale & Pflege

+
+ +
+ + {/* Navigation Tabs */} +
+ + + +
+ + {/* Content */} +
+ {activeTab === "maintenance" && ( + <> + {/* Updates */} +
+
+

Updates

+ +
+ {updates?.last_check && ( +
Zuletzt gesucht: {formatLastCheck(updates.last_check)}
+ )} + {maintenanceJob && ( +
+ + Update läuft: {maintenanceJob.label} — bitte warten. Weitere Updates sind solange gesperrt. +
+ )} +
+ openUpdateDetails("os")} /> + openUpdateDetails("engine")} /> + openUpdateDetails("swap")} /> + {(() => { + const h = updates?.components?.find((c) => c.key === "hermes_agent") + return ( + openUpdateDetails("hermes")} /> + ) + })()} + {updates?.model_list?.map((m) => ( + triggerModelUpgrade(m.repo, m.role)} /> + ))} +
+
+ + {/* Dienste */} +
+

Dienste

+
+ {SERVICES.map((s) => ( + x.name.toLowerCase().includes(s.reach))?.ok} busy={restartingServices[s.id]} onRestart={() => restartService(s.id)} onLogs={() => { setSelectedService(s.id); setActiveTab("logs") }} /> + ))} +
+
+ + {/* Backup */} +
+

Backup

+
+
+
{backups[0] ? `Letztes: ${backups[0].snapshot}` : "Noch kein Backup"}
+
{backups.length} Snapshots · Restore per CLI (restore.sh)
+
+ +
+ {backupMsg && ( +
{backupMsg}
+ )} +
+ + {/* Gefahrenzone */} +
+

Gefahrenzone

+ +
+ + {/* Active Jobs */} +
+
+

Hintergrund-Aufgaben

+ + {jobs.filter(j => j.state === "running" || j.state === "queued").length} Aktiv + +
+ +
+ {jobs.length === 0 ? ( +
+ Aktuell keine aktiven Hintergrund-Jobs. +
+ ) : ( + jobs.map((job) => { + const isActive = job.state === "running" || job.state === "queued" + return ( +
+
+
+
+ {isActive && ( + + + + + )} + {job.label} +
+
+ ID: {job.id} + + + {job.state} + +
+
+ + {isActive && ( + + )} +
+ + {/* Progress */} + {job.state === "running" && ( +
+
+
+
+
+ {job.progress ?? 0}% + {job.done_bytes != null && job.total_bytes != null && ( + + {formatBytes(job.done_bytes)} / {formatBytes(job.total_bytes)} + {job.rate_bps != null && ` (${formatBytes(job.rate_bps)}/s)`} + + )} + {job.eta_s != null && ETA: {job.eta_s}s} +
+
+ )} +
+ ) + }) + )} +
+
+ + )} + + {activeTab === "logs" && ( + // Logs View +
+ {/* Service Select & Restart */} +
+ + + +
+ + loadServiceLogs(selectedService)} + onOpenSettings={() => setActiveTab("settings")} + /> +
+ )} + + {activeTab === "settings" && } +
+
+ + {detail && ( + setDetail(null)} onApply={applyFromDetail} /> + )} + + {dialog && ( + + )} + + ) +} diff --git a/frontend/src/components/dashboard/AgentStatusCard.tsx b/frontend/src/components/dashboard/AgentStatusCard.tsx index 792a672..8637f6b 100644 --- a/frontend/src/components/dashboard/AgentStatusCard.tsx +++ b/frontend/src/components/dashboard/AgentStatusCard.tsx @@ -1,172 +1,172 @@ -import { useState } from "react" -import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react" -import { api } from "@/lib/api" -import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries" -import { useDialog } from "@/lib/useDialog" -import { cn, resolveExternalUrl } from "@/lib/utils" - -export function AgentStatusCard() { - const qc = useQueryClient() - const { data: agent } = useAgentStatus(3_000) - const { data: modelsData } = useModels() - const { showAlert, dialogElement } = useDialog() - const [showBrainSelect, setShowBrainSelect] = useState(false) - - const models = modelsData?.models ?? [] - - async function changeBrainModel(model: string) { - try { - await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) }) - showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`) - qc.invalidateQueries({ queryKey: qk.agentStatus }) - setShowBrainSelect(false) - } catch (e: any) { - showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`) - } - } - - return ( -
-
-
-
- -

Hermes Agent

-
- {agent?.terminal_url && ( - - Terminal öffnen - - )} -
- - {agent ? ( -
-
-
Gateway
-
- - {agent.gateway_reachable ? "Online" : "Offline"} -
-
-
-
Terminal
-
- - {agent.terminal_reachable ? "Online" : "Offline"} -
-
-
setShowBrainSelect(true)} - className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer" - > -
- Gehirn - -
-
- - {agent.brain_model || "auto"} -
-
-
-
- Verdrahtung - -
-
-
Config: {agent.has_config ? : "—"}
-
Skills: {agent.has_skills ? : "—"}
-
Memory: {agent.has_memories ? : "—"}
-
-
-
- ) : ( -
Lade Agenten-Status…
- )} -
- {agent && ( -
-
- Telegram - - {agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"} - -
-
- MCP-Server - {agent.mcp_server_count ?? 0} verbunden -
-
- PC Executor - - {agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"} - -
-
- )} - - {agent && showBrainSelect && ( -
-
-
-

- - Hermes-Gehirn konfigurieren -

- -
-

- Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (auto / fast / heavy): -

- -
- {(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => { - const isAlias = ["auto", "fast", "heavy"].includes(m) - return ( - - ) - })} -
-
-
- )} - - {dialogElement} -
- ) -} +import { useState } from "react" +import { Bot, ExternalLink, Cpu, Layers, Wrench, X, Check } from "lucide-react" +import { api } from "@/lib/api" +import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries" +import { useDialog } from "@/lib/useDialog" +import { cn, resolveExternalUrl } from "@/lib/utils" + +export function AgentStatusCard() { + const qc = useQueryClient() + const { data: agent } = useAgentStatus(3_000) + const { data: modelsData } = useModels() + const { showAlert, dialogElement } = useDialog() + const [showBrainSelect, setShowBrainSelect] = useState(false) + + const models = modelsData?.models ?? [] + + async function changeBrainModel(model: string) { + try { + await api("/api/agent/brain", { method: "POST", body: JSON.stringify({ model }) }) + showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`) + qc.invalidateQueries({ queryKey: qk.agentStatus }) + setShowBrainSelect(false) + } catch (e: any) { + showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`) + } + } + + return ( +
+
+
+
+ +

Hermes Agent

+
+ {agent?.terminal_url && ( + + Terminal öffnen + + )} +
+ + {agent ? ( +
+
+
Gateway
+
+ + {agent.gateway_reachable ? "Online" : "Offline"} +
+
+
+
Terminal
+
+ + {agent.terminal_reachable ? "Online" : "Offline"} +
+
+
setShowBrainSelect(true)} + className="p-3 bg-background/20 rounded-xl border border-border/40 hover:border-primary/45 hover:bg-background/30 transition-all duration-300 cursor-pointer" + > +
+ Gehirn + +
+
+ + {agent.brain_model || "auto"} +
+
+
+
+ Verdrahtung + +
+
+
Config: {agent.has_config ? : "—"}
+
Skills: {agent.has_skills ? : "—"}
+
Memory: {agent.has_memories ? : "—"}
+
+
+
+ ) : ( +
Lade Agenten-Status…
+ )} +
+ {agent && ( +
+
+ Telegram + + {agent.telegram_enabled ? "aktiv" : "nicht konfiguriert"} + +
+
+ MCP-Server + {agent.mcp_server_count ?? 0} verbunden +
+
+ PC Executor + + {agent.pc_executor_reachable ? "erreichbar" : "nicht verbunden"} + +
+
+ )} + + {agent && showBrainSelect && ( +
+
+
+

+ + Hermes-Gehirn konfigurieren +

+ +
+

+ Das „Gehirn" ist das primäre LLM für Hermes. Wähle ein Modell aus deiner Bibliothek oder nutze ein Gateway-Alias (auto / fast / heavy): +

+ +
+ {(["auto", "fast", "heavy", ...models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)]).map((m) => { + const isAlias = ["auto", "fast", "heavy"].includes(m) + return ( + + ) + })} +
+
+
+ )} + + {dialogElement} +
+ ) +} diff --git a/frontend/src/components/dashboard/LucyHealthCard.tsx b/frontend/src/components/dashboard/LucyHealthCard.tsx index 2c88c32..1ead117 100644 --- a/frontend/src/components/dashboard/LucyHealthCard.tsx +++ b/frontend/src/components/dashboard/LucyHealthCard.tsx @@ -1,165 +1,165 @@ -import { useState } from "react" -import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react" -import { api } from "@/lib/api" -import { useDialog } from "@/lib/useDialog" -import { useQueryClient, qk } from "@/lib/queries" -import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth" -import { cn } from "@/lib/utils" - -// Farb-/Text-Ton pro Check-Status. -const DOT: Record = { - ok: "bg-emerald-500", - warn: "bg-amber-500", - down: "bg-red-500", - loading: "bg-muted-foreground/40", -} - -// `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 { showAlert, dialogElement } = useDialog() - const qc = useQueryClient() - const [busy, setBusy] = useState>({}) - - async function runRepair(id: string, repair: Repair) { - setBusy((b) => ({ ...b, [id]: true })) - try { - if (repair.kind === "loadModel") { - await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" }) - } else { - const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", { - method: "POST", - body: JSON.stringify({ service: repair.service }), - }) - if (!r.ok) { - showAlert( - "Reparatur nicht ganz geklappt", - (r.err || "Unbekannter Fehler") + - (repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""), - ) - } - } - // Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert. - qc.invalidateQueries({ queryKey: qk.health }) - qc.invalidateQueries({ queryKey: qk.services }) - qc.invalidateQueries({ queryKey: qk.models }) - } catch (e: any) { - showAlert("Reparatur fehlgeschlagen", String(e?.message || e)) - } finally { - setBusy((b) => ({ ...b, [id]: false })) - } - } - - // 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. - const banner = { - 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: 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: 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: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` }, - }[verdict] - const BannerIcon = banner.icon - - const memTone = { - ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40", - }[memory.status] - const memText = { - ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground", - }[memory.status] - - return ( -
- {/* Gesamt-Verdikt */} -
-
- -
-
-

{banner.title}

-

{banner.sub}

-
-
- - {/* Speicher-Ampel */} -
-
- - Speicher - - - {memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`} - -
-
-
-
-

{memory.text}

-
- - {/* Fähigkeiten-Checkliste */} -
- {checks.map((c) => ( - c.repair && runRepair(c.id, c.repair)} /> - ))} -
- - {dialogElement} -
- ) -} - -function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) { - const Icon = c.icon - const bad = c.status === "down" || c.status === "warn" - return ( -
-
- - - - -
-
- {c.label} - {c.status === "ok" && } - {!c.critical && optional} -
-
{c.detail}
-
-
- - {bad && c.repair && ( - - )} - {bad && !c.repair && ( - - manuell - - )} -
- ) -} +import { useState } from "react" +import { HeartPulse, HardDrive, Wrench, Check, AlertTriangle, Loader2, ShieldQuestion } from "lucide-react" +import { api } from "@/lib/api" +import { useDialog } from "@/lib/useDialog" +import { useQueryClient, qk } from "@/lib/queries" +import { useLucyHealth, type LucyCheck, type Repair } from "@/lib/useLucyHealth" +import { cn } from "@/lib/utils" + +// Farb-/Text-Ton pro Check-Status. +const DOT: Record = { + ok: "bg-emerald-500", + warn: "bg-amber-500", + down: "bg-red-500", + loading: "bg-muted-foreground/40", +} + +// `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 { showAlert, dialogElement } = useDialog() + const qc = useQueryClient() + const [busy, setBusy] = useState>({}) + + async function runRepair(id: string, repair: Repair) { + setBusy((b) => ({ ...b, [id]: true })) + try { + if (repair.kind === "loadModel") { + await api(`/api/models/${encodeURIComponent(repair.model)}/load`, { method: "POST" }) + } else { + const r = await api<{ ok: boolean; err?: string }>("/api/maintenance/restart", { + method: "POST", + body: JSON.stringify({ service: repair.service }), + }) + if (!r.ok) { + showAlert( + "Reparatur nicht ganz geklappt", + (r.err || "Unbekannter Fehler") + + (repair.needsSudo ? "\n\nTipp: Dafür wird evtl. das Box-Passwort gebraucht (oben rechts hinterlegen)." : ""), + ) + } + } + // Status-Queries neu ziehen, damit die Ampel sich sofort aktualisiert. + qc.invalidateQueries({ queryKey: qk.health }) + qc.invalidateQueries({ queryKey: qk.services }) + qc.invalidateQueries({ queryKey: qk.models }) + } catch (e: any) { + showAlert("Reparatur fehlgeschlagen", String(e?.message || e)) + } finally { + setBusy((b) => ({ ...b, [id]: false })) + } + } + + // 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. + const banner = { + 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: 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: 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: T.problem, sub: `${problems.length} wichtige${problems.length === 1 ? "s" : ""} Problem${problems.length === 1 ? "" : "e"} gefunden.` }, + }[verdict] + const BannerIcon = banner.icon + + const memTone = { + ok: "bg-emerald-500", warn: "bg-amber-500", full: "bg-red-500", unknown: "bg-muted-foreground/40", + }[memory.status] + const memText = { + ok: "text-emerald-400", warn: "text-amber-400", full: "text-red-400", unknown: "text-muted-foreground", + }[memory.status] + + return ( +
+ {/* Gesamt-Verdikt */} +
+
+ +
+
+

{banner.title}

+

{banner.sub}

+
+
+ + {/* Speicher-Ampel */} +
+
+ + Speicher + + + {memory.status === "unknown" ? "—" : `${memory.usedGb.toFixed(0)} / ${memory.totalGb.toFixed(0)} GB`} + +
+
+
+
+

{memory.text}

+
+ + {/* Fähigkeiten-Checkliste */} +
+ {checks.map((c) => ( + c.repair && runRepair(c.id, c.repair)} /> + ))} +
+ + {dialogElement} +
+ ) +} + +function CheckRow({ c, busy, onRepair }: { c: LucyCheck; busy: boolean; onRepair: () => void }) { + const Icon = c.icon + const bad = c.status === "down" || c.status === "warn" + return ( +
+
+ + + + +
+
+ {c.label} + {c.status === "ok" && } + {!c.critical && optional} +
+
{c.detail}
+
+
+ + {bad && c.repair && ( + + )} + {bad && !c.repair && ( + + manuell + + )} +
+ ) +} diff --git a/frontend/src/components/dashboard/MemoryInputCard.tsx b/frontend/src/components/dashboard/MemoryInputCard.tsx index 485ae16..fd093ae 100644 --- a/frontend/src/components/dashboard/MemoryInputCard.tsx +++ b/frontend/src/components/dashboard/MemoryInputCard.tsx @@ -1,94 +1,94 @@ -import { useState } from "react" -import { Brain, Plus } from "lucide-react" -import { api } from "@/lib/api" -import { useMemory, useQueryClient } from "@/lib/queries" - -export function MemoryInputCard() { - const qc = useQueryClient() - const { data: memories = [] } = useMemory({ limit: 3 }) - const [memContent, setMemContent] = useState("") - const [memCat, setMemCat] = useState("stable") - const [savingMem, setSavingMem] = useState(false) - - async function saveQuickMemory() { - if (!memContent.trim() || savingMem) return - setSavingMem(true) - try { - await api("/api/memory", { - method: "POST", - body: JSON.stringify({ content: memContent, category: memCat, source: "dashboard" }), - }) - setMemContent("") - qc.invalidateQueries({ queryKey: ["memory"] }) - } catch (e) { - console.error(e) - } finally { - setSavingMem(false) - } - } - - return ( -
-
-
- -

Gedächtnis

-
- -
-